CDISC Biomedical Concept Curation — a Flask/Jinja web application for curating, governing, and exporting CDISC Biomedical Concepts (BCs) and Data Element Concepts (DECs). Current phase: MVP Complete / Initial Build.
| Module | Status | Details |
|---|---|---|
| Flask App Foundation | ✅ Complete | app.py, extensions.py, config.py |
| Database Models | ✅ Complete | BC, DEC, Governance, Audit, Ingestion, Specialization |
| Dashboard | ✅ Complete | KPI stats, live CDISC Library API counts + BC/Spec panels, route / |
| Ingestion (Upload + Parse) | ✅ Complete | XLSX/CSV/JSON upload, AI field mapping, BC/DEC + Specialization grouping |
| BC CRUD + Export | ✅ Complete | JSON/XLSX/ODM-XML export |
| NCIt Mapping | ✅ Complete | EVS REST API search + mapping resolution |
| LOINC API Explorer | ✅ Complete | LOINC search + BC metadata integration, routes/loinc.py |
| Dataset Specializations | ✅ Complete | Full CRUD, BC selection (local + Library, auto-stub), bulk import via ingestion |
| Governance Workflow | ✅ Complete | 4-stage Kanban board, covers both BCs and Dataset Specializations |
| Notes (Governance Collaboration) | ✅ Complete | Per-BC/per-spec notes: create/edit/resolve/flag, locked at Ready to Publish, never exported, fully audited |
| Audit Trail | ✅ Complete | Filterable audit log |
| CDISC Library API Client | ✅ Complete | services/cdisc_api.py |
| NCIt EVS API Client | ✅ Complete | services/ncit_api.py |
| LOINC API Client | ✅ Complete | services/loinc_api.py |
| Export Service | ✅ Complete | XLSX, JSON, ODM-XML |
| UI (Bootstrap 5) | ✅ Complete | Sidebar layout, custom CDISC design tokens |
| Pre-commit Hooks | ✅ Complete | flake8 + black enforced on commit |
| Test Suite | 🚧 In Progress | BC routes, LOINC, NCIt coverage added |
| NCIt/BC Alignment | ✅ Complete | Background job runs the cdisc-bc-ncit-alignment submodule's 2-stage pipeline, services/alignment_runner.py |
- A leftover
AlignmentJobrow stuck inrunning_populatesince 2026-09-03 (its background thread died when the server process was restarted, but nothing marked the rowfailed) trippedstart_job()'s concurrency guard, so clicking "Start Alignment Run" always got a silent 409 — the UI just resumed polling the stale job. services/alignment_runner.py— addedreconcile_stale_jobs(), which fails out any leftover non-terminalAlignmentJobrow (marks itfailedwith an explanatoryerror_messageandcompleted_at).app.py— wiredreconcile_stale_jobs()into theif __name__ == "__main__":startup path (inside an app context, beforeapp.run()), so a server restart can never again leave a permanently-blocking stale row.tests/test_alignment_runner.py— newTestReconcileStaleJobsclass covering non-terminal jobs getting marked failed, terminal jobs left untouched, and the no-jobs no-op case; 484 tests passing, lint clean ✅
- A user reported the NCIt/BC Alignment feature failing with
populate_complete_list failed (exit code 1)after installing from theversion-0.5release download. Root cause:cdisc-bc-ncit-alignment/was completely empty in the archive they downloaded, sopython -m src.populate_complete_listcouldn't even be found. - Traced it to the actual
Releaseworkflow run for theversion-0.5tag (run 33779038795): the submodule checkout andscripts/build_release.pypackaging both succeeded (the produced zip/tar.gz were verified correct — submodule content present, no.git), but the finalgh release createstep failed witha release with the same tag name already exists, because aversion-0.5release had already been drafted (via the GitHub UI,createdAt2 minutes before the workflow ran) with zero assets. The job reported overall failure, but nobody was watching the Actions tab, so the release stayed live with only GitHub's auto-generated "Source code" zip/tarball — a plaingit archiveof the tag, which (as documented inRELEASING.mdfrom the start) never resolves the submodule. - Immediate remediation: downloaded the already-correct
dist/*artifact from that same failed run (gh run download 33779038795 --name release-archives), verified it contained real submodule files, and uploaded it directly to the existingversion-0.5release (gh release upload) — the release's download links are now fixed with no re-tag needed. - Root-cause fix:
.github/workflows/release.yml's publish step now checks whether a release for the tag already exists (gh release view) and uploads to it (gh release upload --clobber) instead of unconditionally tryinggh release create, which fails whenever one does.RELEASING.mdupdated to warn against drafting a release in the UI ahead of pushing the tag, and to check the Actions tab after pushing.
- The release workflow (
.github/workflows/release.yml) previously triggered on anyv*.*.*tag pushed from anywhere. Per request: only run when the archive is cut frommain, and use this project's actual tag convention (version-prefix, e.g.version-1.2.3), not semverv. on.push.tagschanged fromv*.*.*toversion-*. GitHub'spushtrigger filters only match ref names, not the branch a tag's commit happens to be on, so a name filter alone can't express "only from main" — aversion-*tag pushed from any branch would still trigger.- Added an explicit "Verify this is being released from main" step (
git merge-base --is-ancestor "$GITHUB_SHA" origin/main) that runs before packaging and fails the job with a clear::error::if the triggering commit isn't onmain— covers both a tag push and a manualworkflow_dispatchrun against a non-main branch. Checkout now usesfetch-depth: 0soorigin/main's full history is available for that check. gh release createalready uses the tag name as the release title when no--titleis passed, so aversion-1.2.3tag produces a release namedversion-1.2.3— no separate change needed for the "release name prefixed with version-" requirement.RELEASING.mdupdated to the new tag format and the main-only requirement; sanity-checked thegit merge-base --is-ancestorlogic locally (false on this feature branch's HEAD, true onorigin/main's own tip) and validated the workflow YAML parses.
- Issue #74: a user on Python 3.14 ran
pip install -r requirements.txt(manually, against a pre-installer v0.4 archive) and hit a cryptic native-build failure deep inpandas==2.2.2's metadata step (meson.build ... Could not find ... vswhere.exe) — pandas 2.2.2 predates Python 3.14 and ships no prebuilt wheel for it, so pip fell back to a source build requiring a Visual Studio/Meson toolchain the user didn't have. - The README already documents "Python 3.11 or 3.12" as required, but
install.py'scheck_python_version()only enforced a lower bound (>= 3.11) — a Python 3.13/3.14 user would sail straight past that check into the same wall of pip/Meson output, exactly the kind of failure this installer exists to prevent for non-technical users. install.py— addedMAX_PYTHON_EXCLUSIVE = (3, 13);check_python_version()now rejects anything outside3.11 <= version < 3.13with a plain-language explanation (some dependencies have no installable package yet for newer versions) and a concrete next step (install 3.12; on Windows,py -3.12 install.pyif multiple versions are installed) — before ever invoking pip, so the user never sees a Meson traceback.- Wrote tests first (TDD):
tests/test_install.py(new, 8 tests) parametrized over accepted (3.11.x, 3.12.x) and rejected (3.9, 3.10, 3.13, 3.14) versions viamonkeypatch.setattr(sys, "version_info", ...); the two 3.13/3.14 cases failed against the old lower-bound-only check, confirming the gap, before the fix made them pass. 481 tests passing, isort/black/flake8 clean ✅
- Non-technical SMEs can't work with git/GitHub; the only setup path was the README's manual clone + venv +
.env-editing instructions. Added a double-click installer and a GitHub Actions release workflow so a SME can download an archive, extract it, and be running the app with no command line beyond double-clicking. - Found and fixed a real bug on the way:
config.pynever calledload_dotenv()despitepython-dotenvbeing a pinned dependency, so a.envfile was silently ignored by every entry point (python app.py, the MCP server, tests). Fixed by addingload_dotenv()at the top ofconfig.py, covered by a newtests/test_config.py. install.py(stdlib-only, no git required) + platform wrappersinstall.sh,Install (Mac).command,Install (Windows).bat— creates.venv, installsrequirements.txt, sanity-checks thecdisc-bc-ncit-alignmentsubmodule isn't empty (non-fatal warning if so), prompts interactively forCDISC_API_KEY(required) and optional LOINC creds, generates a randomSECRET_KEY, writes.env, bootstraps the DB via the samedb_bootstrap.ensure_db()app.pyalready calls, and generates a platformStartlauncher (Start.bat/Start.command/start.sh) that opens the browser automatically once the server is up.- The
cdisc-bc-ncit-alignmentgit submodule doesn't survive a plain zip/git archive(submodules are gitlink pointers, not fetched content) — a naive "Download ZIP" would silently break the NCIt alignment feature. Addedscripts/build_release.py, which usesgit ls-files --recurse-submodules(lists tracked files across the main repo and the populated submodule together, no separate ignore list needed) to stage and package both a.zipand.tar.gz, explicitly preserving the executable bit on.sh/.commandfiles in the zip viaZipInfo.external_attr. .github/workflows/release.yml— new workflow, triggers on av*.*.*tag push or manualworkflow_dispatch; checks out withsubmodules: recursive(the actual fix for the gitlink problem — the packaging script only works correctly once the submodule is populated), runsbuild_release.py, and publishes both archives to a GitHub Release (gh release create) on a tag push, or uploads them as workflow artifacts for a manual test dispatch..env.example(new) documents every env var actually read in code, including several the README's table had omitted (CDISC_SUBSCRIPTION_KEY,PORT,FLASK_DEBUG,ALIGNMENT_SUBMODULE_DIR) — added to the README's table too. Had to add!.env.exampleto.gitignore, since the existing.env.*ignore pattern was also swallowing the example file.- README.md — new "Quick Start (no git required)" section pointing non-technical users at the Releases page and the installer; fixed the placeholder
your-orgclone URL to the realpendingintentorg. NewRELEASING.mddocuments the release process for maintainers. - Verified end-to-end rather than just linted: built both archive formats locally from this checkout (using
git add -Nto stage the new files for the dry run, then unstaged), extracted each, confirmed the submodule content and executable bits survive and no.gitdirectory exists anywhere in either archive, then raninstall.pyfor real against the extracted zip with a throwaway API key in a scratch directory —.venvcreated, dependencies installed,.envwritten, all 9 tables created via Alembic migrations,Start.commandlaunched the app and served HTTP 200 onlocalhost:8081. Not verified: an actual Windows run (no Windows machine available) — flagged as a follow-up. - 473 tests passing (472 + 1 new
test_config.pytest), isort/black/flake8 clean ✅
- Renamed the "Specializations" sidebar link to "SDTM Specializations" and moved it to sit directly under "Biomedical Concepts" (previously between "Biomedical Concepts" and "NCIT Mapping", now above "NCIT Mapping");
templates/base.htmlonly, no route/endpoint changes. - All 14 smoke checks still pass.
- Issue #60 asked for two things: incorporate the
cdisc-bc-ncit-alignmentrepo as a submodule, and let the workbench run its NCIt↔CDISC BC mapping report as a background process with progress tracking. - Added
cdisc-bc-ncit-alignmentas a git submodule (.gitmodules). Reviewed the submodule and found it had no.gitignore(generated artifacts and__pycache__were committed, ~65MB of tracked binary xlsx), norequirements.txt, an unusedthrottle()helper, and no tests for its two CLI entry points — fixed all of it on the submodule's ownfix-repo-hygienebranch (merged as pendingintent/cdisc-bc-ncit-alignment#4) before pointing this repo's gitlink at the merged commit (#70, merged torelease-v0.5). - README.md — new "Submodules" section documenting
git submodule update --init/--remote, and the submodule listed in the Project Structure tree. models/alignment.py— newAlignmentJobmodel tracking one run of the submodule's two-stage pipeline:status(pending → running_populate → running_augment → generating_json → completed/failed), per-stage progress counters, output file paths, timestamps,to_dict(). New Alembic revisionfb135e287995_add_alignment_jobs_table.py(hand-written, not autogenerated — autogenerate missed the new table entirely since the model wasn't imported anywhere the migration env traverses yet, and instead picked up an unrelated pre-existing schema drift; tested upgrade + downgrade against a scratch DB).services/alignment_runner.py—start_job()guards against a concurrent run (service-layer check, not a DB constraint — this repo has no precedent for partial-unique constraints and the rigor level matches unlocked governance-stage advances elsewhere) and spawns a daemonthreading.Thread. The thread pushes its own Flask app context and runs the submodule'spopulate_complete_listthenaugment_cdiscCLI stages assubprocess.Popencalls (notsubprocess.run— stderr must be read incrementally, while the process is still running, to update live progress), parsing their stderr for batch/row/hit counts; a non-zero exit or exception on stage 1 short-circuits stage 2 and marks the job failed.CDISC_API_KEYis passed explicitly via the subprocessenv=(rather than relying on inherited process env) since this app prefersCDISC_SUBSCRIPTION_KEYfor its own CDISC client but the submodule's client only readsCDISC_API_KEY. On success, derives a self-describing JSON export (list of row-dicts keyed by the xlsx header row) from the final xlsx.routes/ncit_alignment.py(new blueprint,/ncit-alignment) —GET /page,POST /start,GET /status(JSON polling endpoint; never exposes raw file paths, only computedxlsx_ready/json_readybooleans),GET /download/xlsx|json/<job_id>usingflask.send_file— the first use ofsend_filein this app, a deliberate deviation from every other export route's in-memoryResponsepattern, since the ~212k-row xlsx is streamed straight to disk by the subprocess (that's the whole point of itsopenpyxlwrite-only mode) and there's no in-memory buffer to hand back.templates/ncit_alignment.html(new) +templates/base.htmlnav link — "NCIt/BC Alignment" page with a Start button, two KPI cards reusing the dashboard's.metric-card/.metric-progressCSS but driven by live data, a results summary, and download links. Inline polling JS (extra_jsblock) — the firstsetIntervalpolling loop in this codebase (static/js/main.jsis otherwise entirely event-delegated, one-shotfetchcalls); kept page-scoped rather than added to the shared file for that reason. Start button styled with CDISC Blue#134678percdisc-brand-guidelines.config.py—ALIGNMENT_SUBMODULE_DIR;.gitignore—instance/alignment_jobs/(per-job generated report files).pyproject.toml/.flake8— excludedcdisc-bc-ncit-alignment/from this repo's isort/black/flake8 scope (already excluded from pytest for the same reason: it's a separate git repo with its own style/line-length conventions, and running the parent's tools repo-wide was reformatting/flagging its files).CLAUDE.md— added the new blueprint/service/config rows; fixed the blueprint count (was documented as 8, actually 9 before this change, now 10 — also added the previously-undocumentednotesblueprint).- Wrote tests first (TDD):
tests/test_alignment_models.py,tests/test_alignment_runner.py,tests/test_ncit_alignment_routes.py(26 new tests) — no realsubprocessorthreading.Threadis ever spawned;subprocess.Popenis mocked with canned stderr lines to test progress parsing (including comma-stripping) and the stage-1-failure-short-circuits-stage-2 case, andthreading.Threadis mocked/replaced with a synchronous stand-in so route tests can assert final DB state with no sleeping; 472 tests passing, isort/black/flake8 clean ✅ - Verified end-to-end against a live dev server on a throwaway DB/port: the existing 14-check smoke suite still passes with the new blueprint registered; confirmed the nav link renders, the page returns 200,
/statusreturns the{"status": "none"}shape with no prior job, and an unknown job id's download routes 404. Did not exercise a real full pipeline run (needs a liveCDISC_API_KEYand ~3 minutes against the real NCIt/CDISC APIs) — left as a follow-up manual check.
- Curators had no way to leave collaborative commentary on a BC or Dataset Specialization as it moves through governance — hand-off context (questions, caveats) had to travel outside the tool. Added a per-entity Notes panel at the bottom of the BC and Specialization detail pages, saved independently of the entity's own fields, per issue #35.
models/note.py— newNotemodel, scoped to exactly one of a BC or a Specialization via two nullable FKs plus ack_notes_one_entityCHECK constraint, the same polymorphic-association pattern already used byGovernanceRecord. Fields:text,flagged,resolved/resolved_at/resolved_by,created_by, timestamps. New Alembic revision59f58378e692_add_notes_table.py(tested upgrade + downgrade).services/notes_service.py—create_note/update_note_text/set_resolved/set_flagged/list_notes_for_entity, shared by both entity types (mirrorsgovernance_service's_advance/_reject(entity, id_field, ...)shape). Every mutation is locked once the parent reachespublished("Ready to Publish") status — this applies to all note actions (create, edit, resolve, flag), not just creation, matching the existing edit-lock added in the prior "Locked Editing" change below. Resolve and Flag are reversible toggles. There is intentionally nodelete_note— an audit trail is required for every note per the issue, so notes are only ever created, edited, resolved/unresolved, or flagged/unflagged.routes/notes.py(new blueprint, mounted at/notes) — JSON create/update/resolve/flag endpoints;templates/_notes_panel.html(new shared partial) renders the list most-recent-first with an inline edit/save toggle and Outlook-style "!" flag badge (CDISC Orange#D57E00, percdisc-brand-guidelines), included from bothbc_detail.htmlandspecializations.html.static/js/main.js—initNotesPanel()follows the existinginitKanban()fetch-then-reload pattern.- Notes are deliberately excluded from every BC/governance export:
services/export.py's field lists (GOVERNANCE_BC_FIELDS/GOVERNANCE_DEC_FIELDS) and bothto_dict()methods are explicit allow-lists, so nothing needed to change there — verified nonoteskey/column appears in the governance xlsx,/bc/export?format=json, or ODM-XML output. Conversely, every note action is captured in the audit trail for free (log_change("Note", ...)— the existing/audit/CSV/JSON export is generic overentity_type); added "Note" and Resolve/Flag options to thetemplates/audit.htmlfilter dropdowns. - Added 5 MCP tools (
add_note,update_note,resolve_note,flag_note,list_notes) tomcp_server/server.pyfor parity with the rest of the read/write tool surface, following the same@_with_app_context+ shared-service dispatch pattern as the governance tools. - Wrote tests first (TDD):
tests/test_notes_service.pyandtests/test_notes_routes.py(new, 44 tests) cover create/update/resolve/flag for both entity types, the Ready-to-Publish lock on every action, most-recent-first ordering, and export exclusion; extendedtests/test_models.py(CHECK constraint),tests/test_audit_routes.py(Note actions in CSV/JSON export, Resolve/Flag filters), andtests/test_mcp_server.py(all 5 new tools); 446 tests passing, isort/black/flake8 clean ✅ - Verified end-to-end against a live dev server on a throwaway DB/port: created a BC, added/edited/flagged/resolved a note via the JSON endpoints and confirmed the detail page rendered the text, flag indicator, and Resolved badge; advanced the BC to Ready to Publish and confirmed both creating a new note and resolving an existing one were rejected (400) with the read-only banner showing; confirmed the note text was absent from
/governance/exportand/bc/export?format=json, and that all four note actions (created/updated/flagged/resolved) appeared in/audit/?export=json.
- Governance stages run
provisional → sme_review → cdisc_approval → published; thepublishedstage is labeled "Ready to Publish" on the Governance Kanban board (templates/governance.html) but was inconsistently labeled "Published" everywhere else, and nothing in the app treated it as a terminal, locked state — every BC/specialization edit and delete route worked identically regardless of status. services/bc_service.py—update_bc()andmap_ncit_to_bc()now raiseValueErrorwhen the BC is alreadypublished; this guard is inherited automatically by the MCPupdate_bc/map_ncit_to_bctools since they call the same functions.routes/bc.py—edit(),clear_ncit(),clear_loinc(), anddelete()now refuse the action (flash + redirect) for a published BC;routes/ncit.pyresolve()catches the newValueError;routes/specializations.py's upsert route (update branch only, not create) anddelete()refuse the action for a published specialization.- The Governance board's
published/"Ready to Publish" Kanban column previously rendered no action buttons at all, so there was no way to reject an item once it reached that column even thoughreject_bc/reject_specializationalready worked unconditionally from any status — added a Reject button there for both BCs and specs, reusing the existingkanban-reject-btnJS wiring and routes. templates/bc_detail.htmlandtemplates/specializations.htmlnow show a locked notice and wrap their editable fields in a disabled<fieldset>whenpublished;templates/bc_list.htmlandtemplates/specializations.htmldisable the row Delete button for published items; relabeledpublishedas "Ready to Publish" consistently acrossbc_detail.html,bc_list.html, andspecializations.htmlto match the Kanban board.- Wrote tests first (TDD) covering every new guard across
tests/test_bc_routes.py,tests/test_specializations_routes.py,tests/test_ncit.py,tests/test_governance_routes.py, andtests/test_mcp_server.py; manually verified end-to-end against a running server (create → advance through all 4 stages → edit/delete blocked → reject via the new board button → edit/delete work again) for both a BC and a specialization; 383 tests passing, isort/black/flake8 clean ✅
- The "Export CSV" and "Export JSON" buttons on
/audit/appended?export=csv/?export=jsonto the URL, butroutes/audit.py'sindex()never read that parameter — it just re-rendered the same filtered HTML page, so clicking either button silently did nothing. routes/audit.py— extracted the existing filter-building logic into_filtered_query();index()now checksrequest.args.get("export")and, when set tocsvorjson, returns the full filtered (unpaginated) result set as a download via FlaskResponse(text/csv/application/json,Content-Disposition: attachment; filename=audit_log.{csv,json}), matching the existing export pattern inroutes/bc.py. Added_log_to_dict()and_export_csv()/_export_json()helpers;before_state/after_stateare JSON-serialized in the CSV and left as nested objects in the JSON export.- Wrote tests first (TDD):
TestAuditExportCSVandTestAuditExportJSONintests/test_audit_routes.pycover attachment headers/mimetypes, row/record content, filter pass-through (entity_type,actor), and exporting past the 50-row pagination limit (60 rows in, 60 rows out); 368 tests passing, isort/black/flake8 clean ✅
- In the exported
BC_LB/BC_VSxlsx sheet, column M (dec_id) was populated correctly but column N (ncit_dec_code) was left at whatever was stored on the DEC (typically blank, since the BC detail form no longer exposes it as a separate field — see the DEC ID entry below). Made the export always populate column N with the same value as column M. services/export.py—export_governance_xlsx()'s per-DEC row loop now special-cases thencit_dec_codeheader to usedec.dec_idinstead of the DEC's storedncit_dec_codeattribute. This is an export-time transformation only (nothing is rewritten in the database), applied uniformly since both/bc/export?format=xlsxand/governance/exportshare this function.- Wrote the test first (TDD):
TestExportGovernanceXlsx.test_ncit_dec_code_column_mirrors_dec_idandtest_ncit_dec_code_column_overrides_a_stored_distinct_valueintests/test_export_service.pyconfirm column N always equals column M, even when a DEC has a different storedncit_dec_code; 361 tests passing, isort/black/flake8 clean ✅ - Verified against a live dev server on a throwaway DB/port: created a BC with a DEC (
dec_id=C64849.DEC.1), downloaded/bc/export?format=xlsx, and confirmed columns M and N both readC64849.DEC.1on the DEC row (both blank on the BC-only row, as expected).
- The DEC table's
dec_idvalue (the column that populatesdec_idinfiles/BC Examples.xlsx) was a hidden input, silently auto-generated as{bc_id}.DEC.{n}if left blank — curators had no way to see or set it directly, e.g. to match a CDISC-published DEC id likeC64849.DEC.1. Made it a visible text column, positioned before "DEC Label" as requested. templates/bc_detail.html— added aDEC ID<th>beforeDEC Label; the per-rowdec_idinput changed fromtype="hidden"to a visibleform-control form-control-smtext input (matching the styling of the other DEC fields), still positioned first in the row.ncit_dec_coderemains a hidden input (not part of this request).static/js/main.js—buildDecRow()updated to match: new rows get the same visible DEC ID cell ahead of DEC Label.- No backend changes needed —
routes/bc.py's_decs_from_form()andservices/bc_service.save_decs()already read/persistdec_id(from the earlier DEC-save-bug fix) and still auto-generate one when the field is left blank, so this is purely a UI change plus a regression test. - Wrote the test first (TDD):
TestBcDetail.test_dec_id_field_is_visible_and_precedes_labelintests/test_bc_routes.pyasserts the "DEC ID" header precedes "DEC Label" and that the input is a visibletype="text"field carrying the DEC's value; 359 tests passing, isort/black/flake8 clean ✅ - Verified end-to-end against a live dev server on a throwaway DB/port: created a BC with an explicit custom
dec_id(C64849.DEC.1) via the form, confirmed it rendered in the new visible field, and confirmed it flowed through unchanged into thedec_idcolumn of the/bc/export?format=xlsxoutput.
- Adding DECs to a BC on the detail form and clicking Save silently deleted them: the rendered table and
static/js/main.jssubmit DEC fields asdecs[N][dec_label]etc., butroutes/bc.py's_decs_from_form()was still reading obsolete flat-array names (dec_label[]) that the form never sent, so it always parsed zero DECs andsave_decs()'s delete-then-recreate logic wiped every existing DEC and added nothing back — on both create and edit. routes/bc.py— rewrote_decs_from_form()to parse the actualdecs[N][field]keys via regex, grouped and ordered by row index.templates/bc_detail.html/static/js/main.js— added hiddendecs[N][dec_id]/decs[N][ncit_dec_code]inputs (previously never emitted at all) so editing an existing DEC preserves its identity/NCIt code instead ofsave_decsregenerating a newdec_idon every save.services/bc_service.py—save_decs()now also persists therequiredcheckbox ontoDataElementConcept.required, which the form submitted but the service silently discarded.- Separately, DECs weren't reaching any BC export either:
/bc/export?format=xlsxused an old exporter (export_xlsx/BC_EXPORT_FIELDS) with no DEC columns/rows at all and the wrong sheet name, while a newer exporter (export_governance_xlsx) already matched the referencefiles/BC Examples.xlsxBC_LBshape (BC row + one row per DEC, BC columns repeated) but was wired only to the published-only/governance/exportroute. Pointed/bc/export's xlsx branch atexport_governance_xlsxinstead and removed the now-deadexport_xlsx/BC_EXPORT_FIELDS. models/bc.py—BiomedicalConcept.to_dict()now includes adecslist (skipped gracefully for a transient, not-yet-session-bound BC), so JSON export carries DEC data too.services/export.py—export_odm_xml()now emits a DEC-derivedItemDefper DEC (data-type mapped to ODM's, NCIt alias when present).- Wrote tests first (TDD): fixed
TestCreateBc.test_create_with_decs(previously posted a fictitious flat-array shape the browser never sends), added DEC coverage toTestEditBc(unchanged/updated/added/removed rows,requiredpersistence), a newTestSaveDecsunit-test class intests/test_bc_service.py(previously zero coverage), strengthenedTestExportintests/test_bc_routes.pyandTestExportOdmXml/removed the now-deadTestExportXlsxintests/test_export_service.py, and addedto_dict()DEC tests totests/test_models.py; 358 tests passing, isort/black/flake8 clean ✅ - Verified end-to-end against a live dev server on a throwaway DB/port: created a BC with 2 DECs via the real
decs[N][field]form shape and confirmed both rendered; edited it (updated one DEC, dropped one, added one) in a single save and confirmed the detail page reflected exactly that; confirmed/bc/exportin JSON, XLSX (BC_LBsheet, correct row/column shape), and ODM-XML all carry the DEC data.
templates/bc_detail.html— moved the "Definition"bc-cardblock (textarea fordefinition) to appear right after the top summary section, ahead of the NCIt/LOINC panels; it previously sat lower on the page, after the LOINC results container. Template-only reorder, no field/logic changes.
- The DEC table's
ncit_dec_codecolumn has existed in the model/DB and round-tripped through save/export since the DEC-save-bug fix, but nothing in the UI ever set it — curators had to type DEC ID and DEC Label by hand for every new DEC, even when a matching NCIt concept existed. Added a second "NCIt" card, scoped to DEC creation, mirroring the existing BC-level NCIt search card (same search/results/metadata-panel layout and CSS classes) but positioned in the right column directly under the "Data Element Concepts (DECs)" card. templates/bc_detail.html— new card with#dec_ncit_codesearch input +#dec-ncit-lookup-btn,#dec-ncit-results-panel/#dec-ncit-results-container, and#dec-ncit-meta-display; omits the BC-level card's "Parent BC ID" field and "Clear" button since neither applies to DEC search. The search input intentionally has nonameattribute — it's a scratch field, not a submitted form value.static/js/main.js— refactoredinitDecTable()'s row-creation logic intoaddDecRow(), which also tracks the new row asactiveDecRow(module-level state). AddedinitDecNcitLookup()/renderDecNcitResults()/renderDecNcitMetaDisplay(), mirroring the BC-levelinitNcitLookup()/renderNcitResults()/renderNcitMetaDisplay()against the same/ncit/searchand/ncit/concept/<code>endpoints. Selecting a result setsactiveDecRow'sdec_id(from the NCIt code),dec_label(from the preferred name), and hiddenncit_dec_codeinputs directly; if no row is currently tracked (or it was deleted), a new one is created automatically so the card also works when searched before "Add DEC" is clicked. Existing/already-saved DEC rows are never touched by a card selection — only the most recently added row is ever a target, and deleting that row clears the tracked reference.- No backend/model/route changes — reuses
routes/ncit.py's existing/ncit/searchand/ncit/concept/<code>endpoints and the existingncit_dec_codehidden input/column as-is. - 361 tests passing (unchanged — no backend logic touched), isort/black/flake8 clean ✅
- Verified against a live dev server on a throwaway DB/port using a scripted Playwright session (the real NCI EVS API is unreachable from this sandbox, so the two NCIt endpoints were mocked client-side to exercise the DOM logic): clicked Add DEC, searched and selected a mocked concept, confirmed the new row's DEC ID/DEC Label/hidden NCIt code populated correctly and the metadata panel rendered, then saved and reloaded to confirm the DEC persisted with the expected values.
- Follow-up same day: with two "NCIt" cards now on the page, relabeled the headers so each is unambiguous —
templates/bc_detail.htmlnow reads "NCIt for Biomedical Concept" (BC-level card) and "NCIt for DEC" (the new DEC-scoped card). Header text only, no functional change.
- The BC create/edit form previously took
result_scalesas free text (e.g. Quantitative), so nothing stopped a curator from typing an arbitrary value. Replaced it with a checkbox group for the 5 allowed values (Narrative, Nominal, Ordinal, Quantitative, Temporal, alphabetically sorted), and made spreadsheet ingestion flag any value outside that list instead of silently accepting it. models/bc.py— newRESULT_SCALEStuple (the 5 allowed values) andsplit_result_scales()helper to parse the semicolon-separated string stored on the model.templates/bc_detail.html—result_scalesis now 5 checkboxes instead of a text input; any existing value not inRESULT_SCALES(e.g. a legacy/imported value like "Continuous") is rendered separately below in red with a "Not supported" label, and preserved via hidden inputs on save rather than silently dropped.routes/bc.py—create()/edit()join the checked checkbox values back into the semicolon-separated stringbc_serviceexpects.edit()uses a hiddenresult_scales_submittedmarker to distinguish "all checkboxes unchecked" (clear the field) from "this field wasn't part of the request" (preserve the existing value) — plain HTML checkboxes submit nothing at all when unchecked, so without the marker those two cases are indistinguishable.services/ingestion.py—validate_bc()now flags any spreadsheet-importedresult_scalesvalue outsideRESULT_SCALESas a validation error, surfaced through the existing red error badge on the/ingestionreview screen (no new UI needed there).- Wrote tests first (TDD): extended
tests/test_models.py(RESULT_SCALES/split_result_scales),tests/test_ingestion_service.py(unsupported/mixed scale validation), andtests/test_bc_routes.py(checkbox round-trip, unsupported-value preservation and red display); 337 tests passing, isort/black/flake8 clean ✅ - Not yet verified in a live browser session — implementation and full test suite only.
- Dataset Specializations previously had no governance lifecycle at all — no
statusfield, noGovernanceRecordlinkage, no Kanban presence — while BCs had the full 4-stageprovisional → sme_review → cdisc_approval → publishedworkflow. Brought specializations to parity so they're tracked, advanced, and rejected the same way. - New Alembic migration
df33730cf8d2—dataset_specializationsgainsstatus(defaultprovisional) andupdated_at;governance_records.bc_idis now nullable and a newvlm_group_idFK (→dataset_specializations.vlm_group_id) was added, with a check constraint (ck_governance_records_one_entity) enforcing exactly one ofbc_id/vlm_group_idis set on every row. Hit and fixed an Alembic SQLite batch-mode gotcha along the way (create_foreign_key(None, ...)fails with "Constraint must have a name" during a table rebuild — needs an explicit name). services/governance_service.py— refactoredadvance_governance/reject_bcinto shared_advance()/_reject()helpers generic over either entity type (bothBiomedicalConceptandDatasetSpecializationexpose.status/.short_name/.updated_at), then addedadvance_specialization_governance()/reject_specialization()on top; existing BC function signatures/return shapes unchanged.routes/governance.py— newPOST /governance/spec/advance/<vlm_group_id>andPOST /governance/spec/reject/<vlm_group_id>;board()now also queries specializations by stage;export()now pulls published specializations too.templates/governance.html— added a "Biomedical Concepts" / "Dataset Specializations" Bootstrap nav-tab toggle, each with its own 4-column Kanban board; reused the existing status-based CSS classes (bc-badge-*,kanban-header-*) since they were already generic rather than BC-specific, so no new CSS was needed.templates/specializations.html— added a Status badge column to the specializations list.static/js/main.js— generalized the Kanban advance/reject click handlers to branch ondata-vlm-group-idvsdata-bc-idand POST to the matching endpoint, additive to the existing BC button markup.mcp_server/server.py— addedadvance_specialization_governance/reject_specializationtools (parity withadvance_governance/reject_bc);list_review_queuenow includes aspecializationskey;get_bc's specialization dicts now includestatus.services/export.py—export_governance_xlsx()gained an optionalspec_objectsparam that adds specialization worksheet(s) when given; backward compatible (defaults toNone). (Sheet shape corrected below same day — see "Made the Governance Export Round-Trip Through the Ingestion Importer".)- Extended
tests/test_governance_routes.py(newTestSpecAdvance/TestSpecReject/TestGovernanceRecordOneEntityConstraintclasses),tests/test_mcp_server.py,tests/test_specializations_routes.py, and added asample_specfixture totests/conftest.py; 312 tests passing, isort/black/flake8 clean ✅ - Verified end-to-end against the live dev server: created a specialization, advanced it through all 4 stages and rejected it via the new routes, confirmed the
ck_governance_records_one_entityconstraint actually rejects both-null/both-set rows, and confirmed the governance XLSX export contains both worksheets.
- Reported bug: clicking Advance/Reject while on the "Dataset Specializations" tab bounced the user back to the "Biomedical Concepts" tab. Cause: the click handlers call
location.reload()on success, and a fresh page load always renders the first Bootstrap tab as active — nothing remembered which tab had been selected. static/js/main.js— addedinitGovernanceTabs(): onshown.bs.tab, the active tab'sdata-bs-targetis written into the URL hash viahistory.replaceState(no extra navigation); on page load, a matching hash reactivates that tab via Bootstrap's Tab API before anything else runs. Sincelocation.reload()reloads the same URL (hash included), the tab now survives the reload.- No Python changes; verified the rendered
data-bs-target/hash wiring matches and the file passesnode --check. Not click-tested in an actual browser this session (Chrome extension not installed) — asked the user to confirm.
- Reported bug: the Dataset Specializations sheet added to the governance XLSX export didn't match what
/ingestion/uploadexpects, so an exported file couldn't be re-imported. The first cut had written a single summary sheet named "Dataset Specializations" with 7 rollup columns (vlm_group_id, bc_id, bc_short_name, domain, short_name, status, variable_count) — nothing like the real per-domain, per-variable worksheet shapeservices/ingestion.pyparses. - Inspected the real reference workbook (
files/BC Examples.xlsx) to confirm the actual expected shape: one worksheet per domain namedSDTM_<domain>(e.g.SDTM_LB,SDTM_VS— theSDTM_/CDASH_sheet-name prefix is what_detect_record_type()keys off), header row =vlm_group_id, bc_id, domain, short_name, package_datefollowed by all 24VARIABLE_FIELDSin their exact snake_case worksheet-column form, one row per SDTM VLM variable with the spec-level fields repeated on every row (same repeat-per-child-row pattern theBC_LBsheet already uses for DECs). - Promoted
services/ingestion._SPEC_HEADER_FIELDSto a publicSPEC_HEADER_FIELDS(now needed byservices/export.pytoo) and rewroteexport_governance_xlsx()'s specialization branch to groupspec_objectsbydomain, create oneSDTM_<domain>sheet per group, and emit rows in the importer's exact shape (a specialization with zero variables still emits one header-only row so it round-trips instead of silently disappearing). - Added
TestExportGovernanceXlsxSpecializationstotests/test_export_service.py, including a round-trip test that feeds the exportedBytesIOstraight intoservices.ingestion.parse_xlsx()and asserts the resulting record matches the original; 318 tests passing, isort/black/flake8 clean ✅ - Verified against the live dev server with the real HTTP path end-to-end: created a specialization with a variable via the UI, advanced it to published, downloaded
/governance/export, and re-uploaded that exact file through/ingestion/upload— it parsed back out as aDataset Specializationingestion record with the samevlm_group_id/bc_id/domain/variables. - Follow-up same day: the header row above was importer-parseable but not byte-for-byte identical to the reference file — column order was wrong (
vlm_group_id, bc_id, domain, short_name, package_date, ...) and it was missing 3 real columns the reference sheet has (sdtmig_start_version,sdtmig_end_version,vlm_source) that the model doesn't track but the importer still expects to see as headers. Readfiles/BC Examples.xlsx'sSDTM_LB/SDTM_VSsheets directly to get the exact column order, addedservices/export.py:SPEC_SHEET_HEADER_FIELDSmatching it exactly (the 3 untracked columns are exported blank rather than omitted), and switched the test to assert the literal reference header list instead of deriving it from the same constant the implementation uses. Verified with a directopenpyxldiff between the reference file's header row and a live export's — exact match.
- The Specializations page was blank because there was no bulk-import path into
dataset_specializations— only one-at-a-time manual entry or "Generate from DEC Templates" existed. Taught the existing/ingestionupload+review pipeline (previously BC-only) to also recognize and import SDTM Dataset Specialization rows from the same curation workbooks (e.g.files/BC Examples.xlsx, whoseSDTM_LB/SDTM_VSsheets are specializations joined to a BC viabc_id) services/ingestion.py— addedSPEC_FIELD_MAP,_detect_record_type()(sheet-name prefixBC_/SDTM_/CDASH_first, falls back to avlm_group_idcolumn signature),validate_specialization(),_group_by_spec()(groups VLM variable rows byvlm_group_id, mirroring_group_by_bc's DEC grouping); parameterizedmap_fields()/_match_field()to accept a field map;parse_xlsx/csv/jsonanddeduplicate()now branch by record typemodels/ingestion.py— addedrecord_typecolumn ("bc"/"specialization", new migration95643d73ac0e);models/specialization.py—created_atnow actually defaults instead of always beingNULLroutes/ingestion.py—approve()/approve_all()now createDatasetSpecializationrows too;approve_all()resolves all BC-type records first (flushed) before specialization-type ones, so a single workbook mixingBC_/SDTM_sheets imports correctly in one click; a specialization whose BC doesn't exist yet is leftpendingwith a flash error instead of orphaning or crashingroutes/specializations.py—create()now auto-creates a minimal local BC stub (newservices/bc_service.get_or_create_bc_stub()) when a user picks a CDISC-Library-only BC on the manual Specialization form, fixing a real gap: SQLite FK enforcement is off, so that path previously created specializations with no matching local BC row; also sorted the Library<optgroup>alphabetically (it was the only unsorted one)templates/ingestion.html— split the review queue into separate "Biomedical Concepts" / "Dataset Specializations" sections;templates/specializations.html— fixed two dead-attribute bugs (spec.bc_name,spec.variable_countdon't exist on the model) that were silently showing rawbc_idand "0 variables" for every row- Caught via manual smoke test (not just unit tests): the worksheet's
domaincolumn holds the real SDTM domain codelist value (LB,VS, ...), not anSDTM/CDASHtoggle — an early version of this change incorrectly forced a literal"SDTM"constant; corrected to map the real column value through - Added
tests/test_bc_service.pyand extendedtests/test_ingestion_service.py,tests/test_ingestion_routes.py,tests/test_specializations_routes.py— 280 tests passing, isort/black/flake8 clean ✅ - Verified end-to-end against the real
files/BC Examples.xlsx: 25 BCs + 23 specializations parsed, 25 BCs + 22 specializations approved (1 correctly held pending — its BC wasn't in that workbook's BC sheets)
Replaced the Manual Specialization Form's SDTM/CDASH Toggle with a Real SDTM Domain Codelist Dropdown
- The manual "Add Specialization" form previously let
domainbe either the literal stringSDTMorCDASHvia a radio toggle — meaningless once the ingestion import above started populatingdomainwith real SDTM domain codes (LB,VS, ...). Replaced the toggle with a<select>populated live from the CDISC Library's own SDTM Domain Abbreviation codelist (C66734), so only real, current domain codes can be chosen services/cdisc_api.py— addedget_ct_packages()(lists all CT packages from the Library's general MDR API atLIBRARY_BASE_URL = "https://library.cdisc.org/api", a different host/path than the COSMoS-specific base this client otherwise uses) andget_sdtm_domain_codes()(picks the most recentsdtmct-*package by date-sorted href, fetches codelistC66734, returns[{code, label}]from each term'ssubmissionValue/preferredTerm, cached like the existing BC/specialization list calls); verified the exact response shape against the live API before writing code against itroutes/specializations.py—index()/detail()now passdomain_codesinto the template;create()/generate()now require a non-blankdomain(previously silently defaulted to"SDTM")templates/specializations.html— domain<select>shows"CODE — Preferred Term"options, with a fallback option so editing an existing spec whose domain code has since been retired/superseded doesn't silently blank the field; dropped the SDTM-vs-CDASH conditional badge coloring in the list view (domain is now an open codelist, not a two-value toggle); updated the "Generate from DEC Templates" JS to read the select instead of a checked radiomodels/specialization.py— corrected the stale# SDTM or CDASHcomment ondomain- Extended
tests/test_cdisc_api_cache.py(new package/codelist fetch tests, mocked at therequests.getlevel per this file's existing convention) andtests/test_specializations_routes.py(domain required, real domain code persists, dropdown rendered from the mocked codelist); 290 tests passing, isort/black/flake8 clean ✅ - Verified live against the real CDISC Library API (real API key already in this shell's env) that the dropdown renders all 85 real SDTM domain codes from the current package (e.g.
AE — Adverse Event Domain,VS — Vital Signs Domain)
Fixed the Edit Button + Rebuilt Variable Import Against Real Worksheet Columns (not a synthesized name/label/data_type/required shape)
- Edit button did nothing:
specializations.detail()renders the same template withedit_specpopulated, but the pre-filled form lives in a Bootstrapcollapsediv that was never told to expand — the page loaded correctly but the form stayed hidden.templates/specializations.htmlnow adds theshowclass wheneveredit_specis set. - Variables were imported wrong: for specialization
KETONESURIN, the worksheet has 11 rows (sdtm_variablecolumn), but only 8 garbled entries were showing (e.g.KETONES,URINALYSIS,mmol/Linstead ofLBTESTCD,LBORRES, ...). Root cause:SPEC_FIELD_MAPonly had ~8 curated canonical fields, so the ~20 other real VLM columns (dec_id,codelist,assigned_value,subject,object,predicate_term, ...) fuzzy-matched onto those few fields with score > 0.5 and silently overwrote them within the same row —assigned_valuewas literally clobberingsdtm_variableitself. - Fixed by mapping every real worksheet column (I-AF, 24 variable-level fields) 1:1 to itself instead of a small curated+fuzzy list —
services/ingestion.pynow hasVARIABLE_FIELD_DEFS/VARIABLE_FIELDS(the full column list:sdtm_variable,dec_id,nsv_flag,codelist,codelist_submission_value,subset_codelist,value_list,assigned_term,assigned_value,role,subject,linking_phrase,predicate_term,object,data_type,length,format,significant_digits,mandatory_variable,mandatory_value,origin_type,origin_source,comparator,vlm_target), each scoring 1.0 against itself so same-row collisions between similarly-named columns (e.g.mandatory_variablevsmandatory_value) are now structurally impossible;_group_by_spec()builds one full-width dict per variable row instead of the old synthesized{name, label, data_type, required}shape - Dropped the fabricated
label/requiredfields (not real worksheet columns) per explicit instruction — the UI's variable table now shows/edits exactly the worksheet's I-AF columns, driven from a singlevariable_field_defslist shared betweenroutes/specializations.pyand the template (header row, form field names, and the JS that builds new/generated rows all read from it, so there's one source of truth instead of three copies) routes/specializations.py—_variables_from_form()rewritten to parse allVARIABLE_FIELDSper row (gated onsdtm_variablebeing non-blank);_variable_from_dec()seedssdtm_variable/dec_id/data_typefrom a BC's DataElementConcept for the "Generate from DEC Templates" flow, leaving the columns a DEC can't supply blank for the user to fill in- Updated
tests/test_ingestion_service.py,tests/test_specializations_routes.pyfor the new shape; 293 tests passing, flake8 clean ✅ - Verified end-to-end against the real workbook:
KETONESURINnow shows exactly 11 variable rows with correct values in the right order (LBTESTCD,LBTEST,LBCAT,LBORRES,LBORRESU,LBSTRESC,LBSTRESN,LBSTRESU,LBLOINC,LBSPEC,LBFAST), and the Edit link now opens the form expanded
- Fixed
services/export.pyexport_xlsx()— the generic per-field loop still readbc.get(field, "")for every column, so the "Code" column stayed blank; now special-casesfield == "code"to source the value frombc.get("loinc_code", ""), matching the pattern already used inexport_governance_xlsx() - Added
tests/test_export_service.py::TestExportXlsx::test_code_column_uses_loinc_codeas a regression test - All 246 tests passing, lint clean ✅
- Resolves the last (11th) open Copilot comment on PR #39
- Added
import logging+logger = logging.getLogger(__name__)to the 11 route/service files that lacked one:routes/audit.py,routes/dashboard.py,routes/governance.py,routes/ingestion.py,routes/loinc.py,routes/ncit.py,routes/specializations.py,services/audit.py,services/bc_service.py,services/export.py,services/governance_service.py app.pyand the three API clients (cdisc_api.py,ncit_api.py,loinc_api.py) already had loggers; this closes the remaining gap from PR #39's Workstream B.1 audit- All 245 tests passing, isort/black/flake8 clean ✅
- Fixed
services/bc_service.pysave_decs()— removed a stray duplicateforloop line (IndentationError: expected an indented block) left over from an automated PR-review fix commit - Fixed
services/export.pyexport_governance_xlsx()— restored the missing innerfor col_idx, header in enumerate(GOVERNANCE_HEADERS, start=1):loop (IndentationError: unindent does not match any outer indentation level) dropped by the same commit - Both files failed to even compile after
git pull origin release-v0.3pulled in 5 "Potential fix for pull request finding" commits responding to PR #39 review comments; the other 3 (.mcp.json,routes/governance.py,tests/test_ncit.py) were fine - All 239 tests passing after the fix
- Removed unused
GovernanceRecordimport fromroutes/governance.pythat failed CI's flake8 F401 check on PR #39
- Fixed
services/bc_service.pymap_ncit_to_bc()— promoting anIMPORT_id to a resolved NCIt code now bulk-updatesDataElementConcept.bc_id,DatasetSpecialization.bc_id,GovernanceRecord.bc_id, andBiomedicalConcept.parent_bc_idto the new id instead of leaving them pointed at the old, now-nonexistentbc_id - Added a check that raises
ValueError(f"BC {ncit_code} already exists")when the targetncit_codecollides with an existing BC'sbc_id, instead of surfacing a rawIntegrityError - Added 3 tests to
tests/test_mcp_server.py::TestMapNcitcovering dependent-row re-pointing, child-BCparent_bc_idre-pointing, and the collision case - All 242 tests passing, isort/black/flake8 clean ✅
- Relabeled the "NCI Thesaurus HREF" field in
templates/library_bc_detail.htmlto "CDISC Library HREF" —bc.hrefis the CDISC Library API's own self-link, not an NCIt URL - Fixed
routes/specializations.pycreate()— POSTing an existingvlm_group_id(the "Edit Specialization" flow) now updates the existing row and logs an"updated"audit action instead of attempting a duplicate INSERT and raising anIntegrityError/500 - Added
_variables_from_form()toroutes/specializations.pyto parse thevariables[i][name/label/data_type/required]form rows, whichcreate()previously discarded entirely (always savedvariables = []) - Added 3 tests to
tests/test_specializations_routes.py(TestCreate.test_create_persists_variable_rows_from_form,TestEditUpsert.test_posting_existing_vlm_group_id_updates_instead_of_duplicating,TestEditUpsert.test_edit_writes_updated_audit_log) - All 245 tests passing, isort/black/flake8 clean ✅
- Fixed
templates/specializations.html:221— Delete form now POSTs to properspecializations.deleteroute keyed onvlm_group_id(removed brokenaction=delete/spec.idinputs) - Added
models/specialization.py—to_dict()method for clean audit log serialization - Added
POST /<vlm_group_id>/deleteroute toroutes/specializations.py— fully audited delete operation - Updated
routes/specializations.py—log_change()calls on create/generate/delete operations (actor="user") - Updated
routes/ingestion.py—approve_all()now writes oneAuditLogper created BiomedicalConcept (action="created_via_ingestion", actor="system") - Added comprehensive test coverage: 5 new tests in
tests/test_specializations_routes.pyandtests/test_ingestion_routes.py(audit logging for create/delete/generate, 404 handling, etc.) - All 239 tests passing, isort/black/flake8 clean ✅
- Added
routes/loinc.pyblueprint — LOINC concept search integrated into the app - Added
services/loinc_api.py— LOINC REST API client - Added DB migrations for NCIt and LOINC metadata fields on
BiomedicalConcept(models/bc.py) - Extended
routes/bc.pywith LOINC/NCIt metadata display and BC detail improvements - Performance improvements to BC detail page — optimized
routes/bc.pyandroutes/dashboard.pyqueries, reduced redundant API calls - Enhanced
templates/bc_detail.htmlwith NCIt and LOINC metadata panels and improved layout - Fixed BC deletion — added delete route to
routes/bc.pyand updatedtemplates/bc_list.htmlwith confirmation UI - Added BC selection to specializations —
routes/specializations.pyupdated with BC association logic - Fixed specialization search in
routes/specializations.pyandservices/cdisc_api.py - Added pagination to dashboard (
templates/dashboard.htmlredesigned,routes/dashboard.pyupdated) - HTTP listening port now configurable via
config.pyenv var (updatedapp.py) - Added
tests/test_bc_routes.py,tests/test_loinc.py,tests/test_ncit.py— initial test coverage - Configured pre-commit hooks with flake8 and black enforcement
- Updated
templates/library_bc_detail.htmlandstatic/js/main.jswith LOINC/NCIt explorer UX
- Fixed variable name mismatches in
routes/dashboard.py(recent_bcs->recent_submissions,pipeline->governance_items) that were causing empty dashboard tables - Added
DatasetSpecializationquery toroutes/dashboard.pyso the dashboard reflects locally stored specializations routes/dashboard.pynow calls the CDISC Library API for live BC and specialization counts and lists on every dashboard load (with cache)- Fixed
services/cdisc_api.pyget_biomedical_concepts()to correctly parse the_links.biomedicalConceptsarray from the API response (~1127 BCs returned) - Added
get_dataset_specializations()toservices/cdisc_api.pyusing the correct endpoint/mdr/specializations/datasetspecializations(~1123 specs returned) - Added 5-minute in-memory cache to
services/cdisc_api.pyto avoid redundant API calls on rapid page refreshes - Fixed
check_duplicate()inservices/cdisc_api.pyto use thetitlefield from link objects (was using wrong key) - Added two new panels to
templates/dashboard.html: "CDISC Library - Biomedical Concepts" (first 50 of ~1127) and "CDISC Library - Dataset Specializations" (first 50 of ~1123) - Updated KPI cards in
templates/dashboard.html: "BCs in CDISC Library" and "Dataset Specializations" now display live counts from the API rather than static zeros
- ✅ Built complete Flask/Jinja web application from scratch for CDISC BC curation
- ✅ Created
app.py— Flask application factory with blueprint registration - ✅ Created
extensions.py— SQLAlchemy + Flask-Migrate instances (avoids circular imports) - ✅ Created
config.py— Config class usingCDISC_API_KEYenv var - ✅ Created
requirements.txt— All dependencies pinned - ✅ Created
models/bc.py—BiomedicalConcept+DataElementConceptSQLAlchemy models - ✅ Created
models/specialization.py—DatasetSpecializationmodel - ✅ Created
models/governance.py—GovernanceRecordmodel (4-stage Kanban) - ✅ Created
models/audit.py—AuditLogmodel - ✅ Created
models/ingestion.py—IngestionRecordmodel (DB-backed session storage) - ✅ Created
routes/dashboard.py— Dashboard with KPI stats - ✅ Created
routes/ingestion.py— File upload + parse queue + approve/reject flow - ✅ Created
routes/bc.py— BC CRUD + JSON/XLSX/ODM-XML export - ✅ Created
routes/ncit.py— NCIt concept search and mapping resolution - ✅ Created
routes/specializations.py— Dataset specialization management - ✅ Created
routes/governance.py— 4-stage Kanban governance workflow - ✅ Created
routes/audit.py— Filterable audit trail - ✅ Created
services/cdisc_api.py— CDISC Library REST API client - ✅ Created
services/ncit_api.py— NCIt EVS REST API client - ✅ Created
services/ingestion.py— XLSX/CSV/JSON parser with AI field mapping + BC/DEC grouping - ✅ Created
services/export.py— XLSX, JSON, ODM-XML export - ✅ Created
templates/base.html— Bootstrap 5 sidebar layout - ✅ Created all 8 feature templates (dashboard, ingestion, bc_list, bc_detail, ncit_mapping, specializations, governance, audit)
- ✅ Created
static/css/custom.css— Custom design tokens matching CDISC mockup - ✅ Created
static/js/main.js— Interactive JS (NCIt lookup, Kanban drag/drop, DEC table, file upload) - 🐛 Fixed circular import — moved
db/migrateinstances toextensions.py - 🐛 Fixed ingestion form 405 error — corrected form
actionto/ingestion/upload - 🐛 Fixed session cookie size overflow (287 records exceeding 4KB limit) — replaced cookie session with DB-backed
IngestionRecordmodel - 🐛 Fixed XLSX DEC sub-rows being parsed as separate BCs — added
_group_by_bc()grouping logic inservices/ingestion.py - ✅ Initial commit pushed to GitHub — all 50 files, 5,828 insertions
- 📝 Created
files/implementation.md— implementation plan - 📝 Created
files/result.md— build summary