From c3c1724b691aad56f5b70d454056e54d597381fa Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:00:56 -0400 Subject: [PATCH 01/20] Added BC groupings functionality --- .github/workflows/ci.yml | 15 +- .../biomedical-concept-grouping_60ce0d1.md | 85 ++ files/biomedical_concept_grouping.md | 25 + pyproject.toml | 1 + src/soa_builder/web/app.py | 107 ++- src/soa_builder/web/migrate_database.py | 69 ++ src/soa_builder/web/routers/activities.py | 51 +- src/soa_builder/web/routers/bc_surrogates.py | 73 +- src/soa_builder/web/routers/concept_groups.py | 836 ++++++++++++++++++ src/soa_builder/web/templates/activities.html | 1 + src/soa_builder/web/templates/base.html | 3 +- .../web/templates/concept_groups.html | 241 +++++ .../web/templates/concepts_cell.html | 110 ++- tests/ROUTER_TESTS_README.md | 227 ----- tests/TEST_FILES_REVIEW.md | 333 ------- 15 files changed, 1569 insertions(+), 608 deletions(-) create mode 100644 changelog/biomedical-concept-grouping_60ce0d1.md create mode 100644 files/biomedical_concept_grouping.md create mode 100644 src/soa_builder/web/routers/concept_groups.py create mode 100644 src/soa_builder/web/templates/concept_groups.html delete mode 100644 tests/ROUTER_TESTS_README.md delete mode 100644 tests/TEST_FILES_REVIEW.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6862c22..84d86c0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,11 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: CI (Python) on: push: - branches: [ "main", "master" ] + branches: [ "master" ] pull_request: - branches: [ "main", "master" ] + branches: [ "master" ] permissions: contents: read @@ -32,17 +33,12 @@ jobs: python-version: ${{ matrix.python-version }} cache: pip cache-dependency-path: | - requirements.txt + pyproject.toml - name: Install dependencies run: | python -m pip install --upgrade pip - # Core dev tooling - pip install pytest pytest-cov ruff flake8 - # Project deps - pip install -r requirements.txt - # If your package is installable (optional) - if [ -f pyproject.toml ] || [ -f setup.cfg ] || [ -f setup.py ]; then pip install -e .; fi + pip install -e ".[dev]" - name: Lint (ruff) run: | @@ -54,7 +50,6 @@ jobs: run: | mkdir -p reports pytest -q tests \ - --maxfail=1 \ --disable-warnings \ --junitxml=reports/junit.xml \ --cov=soa_builder \ diff --git a/changelog/biomedical-concept-grouping_60ce0d1.md b/changelog/biomedical-concept-grouping_60ce0d1.md new file mode 100644 index 00000000..77bca69d --- /dev/null +++ b/changelog/biomedical-concept-grouping_60ce0d1.md @@ -0,0 +1,85 @@ +# Changelog: biomedical-concept-grouping + +**Branch:** `biomedical-concept-grouping` +**Base commit:** `60ce0d1` + +--- + +## New Feature: Biomedical Concept Groups + +Introduces a global grouping mechanism for biomedical concepts. Groups are +not SoA-specific — once created they can be associated with activities in +any SoA. + +### Database Migrations (`migrate_database.py`) + +- `_migrate_add_concept_group_table()` — creates two new tables: + - `concept_group` — stores `concept_group_uid`, `name`, `label`, + `description` + - `concept_group_concept` — stores the many-to-many relationship between + a group and its assigned concept codes +- `_migrate_activity_concept_add_concept_group_uid()` — adds + `concept_group_uid` column to `activity_concept` +- `_migrate_surrogate_add_concept_group_uid()` — adds `concept_group_uid` + column to `biomedical_concept_surrogate` + +### New Router (`routers/concept_groups.py`) + +Full CRUD for concept groups, including: +- Create, read, update, delete concept groups (API + UI endpoints) +- Assign / unassign individual concept codes to a group +- Link a concept group to an activity (applies all group concepts at once) + +### New Template (`templates/concept_groups.html`) + +Management page for concept groups — create groups, assign concepts, and +view group membership. + +### `app.py` + +- Imports and runs the three new migration functions at startup +- Registers `concept_groups_router` (both API and UI routers) +- `_get_activity_concepts()` updated to `LEFT JOIN concept_group` and + return `concept_group_uid` and `group_name` alongside existing fields +- New `_get_concept_groups_for_cell()` helper used when rendering the + concepts cell partial + +### Updated Router: `routers/activities.py` + +- `ui_list_activities` query branches updated to `LEFT JOIN concept_group` + when the `concept_group_uid` column is present +- `concept_group_uid` and `group_name` included in the per-activity concept + dict +- `concept_groups` list fetched globally and passed to the template context + +### Updated Router: `routers/bc_surrogates.py` + +- `_render_concepts_cell` updated to detect the `concept_group_uid` column + and include `concept_group_uid` / `group_name` in `selected_list` + +### Updated Templates + +- `templates/activities.html` — computes `activity_group_uids` (unique + group UIDs currently assigned to the activity) for use inside the + `concepts_cell.html` include +- `templates/base.html` — adds **Biomedical Concept Groups** navigation + link; reorders BC-related nav items (Concepts → Categories → Groups → + SDTM Specializations) +- `templates/concepts_cell.html` — updated to display assigned concept + groups, render group badges, and expose group-assignment controls via + HTMX + +--- + +## CI / Tooling + +### `pyproject.toml` + +- Added `pytest-cov>=4.0.0` to the `[dev]` optional dependencies so + coverage reporting works in local and CI environments. + +### `.github/workflows/ci.yml` + +- Added `# yaml-language-server: $schema=…` modeline to pin the GitHub + Actions schema and suppress false-positive CDISC schema diagnostics + from the VS Code YAML extension. diff --git a/files/biomedical_concept_grouping.md b/files/biomedical_concept_grouping.md new file mode 100644 index 00000000..828e8b81 --- /dev/null +++ b/files/biomedical_concept_grouping.md @@ -0,0 +1,25 @@ +# Biomedical Concepts + +## New Features +- Biomedical Concepts can be grouped together using tags in the UI. +- Tags are first created by the user. +- User can select from a list of concepts to apply tags as well as search for a particular concept. +- Biomedical Concept Surrogates can be assigned to a concept group. +- A biomedical concept category can be assigned to a group. + + +## Architecture +- The biomedical concept groupings ARE NOT soa specific, and once created, can be used in any SOA. +- New database table to store name, label, description, concept_group_uid. +- New database table to store the relationship between a concept group and the assigned concepts. +- Concept group can be assigned to an activity. The assigned concepts in the group will follow the same behavior as a biomedical concept assigned individually. + + +## Web front end +- New page for the creation of a group and assignment of concepts to the group. +- New menu bar section to organize the biomedical concept and dataset specialization pages. + +## Approach +- Reuse code/functions where possible. +- Existing behavior for the application and biomedical concepts is preserved. + diff --git a/pyproject.toml b/pyproject.toml index 22bcdfc1..93060135 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ [project.optional-dependencies] dev = [ "pytest>=7.0.0", + "pytest-cov>=4.0.0", "ruff>=0.5.0", "black>=24.0.0", "detect-secrets>=1.4.0" diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index aeb499eb..7d4f9d30 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -77,6 +77,9 @@ _migrate_add_bc_surrogate_table, _migrate_add_activity_surrogate_table, _migrate_add_bc_surrogate_audit_table, + _migrate_add_concept_group_table, + _migrate_activity_concept_add_concept_group_uid, + _migrate_surrogate_add_concept_group_uid, ) from .routers import activities as activities_router from .routers import arms as arms_router @@ -97,6 +100,7 @@ from .routers import condition_assignments as condition_assignments_router from .routers import footnotes as footnotes_router from .routers import bc_surrogates as bc_surrogates_router +from .routers import concept_groups as concept_groups_router from .audit import _record_element_audit @@ -221,6 +225,9 @@ def _configure_logging(): _migrate_add_bc_surrogate_table() _migrate_add_activity_surrogate_table() _migrate_add_bc_surrogate_audit_table() +_migrate_add_concept_group_table() +_migrate_activity_concept_add_concept_group_uid() +_migrate_surrogate_add_concept_group_uid() # Include routers @@ -246,6 +253,8 @@ def _configure_logging(): app.include_router(footnotes_router.ui_router) app.include_router(bc_surrogates_router.router) app.include_router(bc_surrogates_router.ui_router) +app.include_router(concept_groups_router.router) +app.include_router(concept_groups_router.ui_router) def _record_visit_audit( @@ -2343,24 +2352,92 @@ def set_activity_concepts(soa_id: int, activity_id: int, payload: ConceptsUpdate # API endpoint for returning BC associated with an Activity def _get_activity_concepts(activity_id: int): - """Return list of concepts (immutable: stored snapshot).""" + """Return list of concepts including concept_group_uid and group_name.""" conn = _connect() cur = conn.cursor() - if _table_has_columns(cur, "activity_concept", ("soa_id",)): + has_soa = _table_has_columns(cur, "activity_concept", ("soa_id",)) + has_group = _table_has_columns(cur, "activity_concept", ("concept_group_uid",)) + if has_soa and has_group: + cur.execute( + "SELECT ac.concept_code, ac.concept_title, " + "ac.concept_group_uid, cg.name AS group_name " + "FROM activity_concept ac " + "LEFT JOIN concept_group cg " + "ON cg.concept_group_uid=ac.concept_group_uid " + "WHERE ac.activity_id=? " + "AND ac.soa_id=(SELECT soa_id FROM activity WHERE id=?) " + "ORDER BY ac.concept_group_uid NULLS LAST, ac.id", + (activity_id, activity_id), + ) + rows = [ + { + "code": r[0], + "title": r[1], + "concept_group_uid": r[2], + "group_name": r[3], + } + for r in cur.fetchall() + ] + elif has_soa: cur.execute( - "SELECT concept_code, concept_title FROM activity_concept WHERE activity_id=? AND soa_id=(SELECT soa_id FROM activity WHERE id=?)", + "SELECT concept_code, concept_title " + "FROM activity_concept WHERE activity_id=? " + "AND soa_id=(SELECT soa_id FROM activity WHERE id=?)", (activity_id, activity_id), ) + rows = [ + { + "code": r[0], + "title": r[1], + "concept_group_uid": None, + "group_name": None, + } + for r in cur.fetchall() + ] else: cur.execute( - "SELECT concept_code, concept_title FROM activity_concept WHERE activity_id=?", + "SELECT concept_code, concept_title " + "FROM activity_concept WHERE activity_id=?", (activity_id,), ) - rows = [{"code": c, "title": t} for c, t in cur.fetchall()] + rows = [ + { + "code": r[0], + "title": r[1], + "concept_group_uid": None, + "group_name": None, + } + for r in cur.fetchall() + ] conn.close() return rows +def _get_concept_groups_for_cell(soa_id: int, activity_id: int): + """Return (concept_groups, activity_group_uids) for concepts_cell rendering.""" + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id, concept_group_uid, name, label FROM concept_group ORDER BY id" + ) + concept_groups = [ + {"id": r[0], "concept_group_uid": r[1], "name": r[2], "label": r[3]} + for r in cur.fetchall() + ] + has_group = _table_has_columns(cur, "activity_concept", ("concept_group_uid",)) + if has_group: + cur.execute( + "SELECT DISTINCT concept_group_uid FROM activity_concept " + "WHERE activity_id=? AND soa_id=? AND concept_group_uid IS NOT NULL", + (activity_id, soa_id), + ) + activity_group_uids = [r[0] for r in cur.fetchall()] + else: + activity_group_uids = [] + conn.close() + return concept_groups, activity_group_uids + + def _get_activity_surrogates(soa_id: int, activity_id: int): """Return (surrogates, selected_surrogate_list, selected_surrogate_uids) for concepts_cell render.""" conn = _connect() @@ -3175,6 +3252,9 @@ def ui_add_activity_concept( surrogates, selected_surrogate_list, selected_surrogate_uids = ( _get_activity_surrogates(soa_id, activity_id) ) + concept_groups, activity_group_uids = _get_concept_groups_for_cell( + soa_id, activity_id + ) html = templates.get_template("concepts_cell.html").render( request=request, soa_id=soa_id, @@ -3185,6 +3265,8 @@ def ui_add_activity_concept( surrogates=surrogates, selected_surrogate_list=selected_surrogate_list, selected_surrogate_uids=selected_surrogate_uids, + concept_groups=concept_groups, + activity_group_uids=activity_group_uids, edit=False, ) return HTMLResponse(html) @@ -3250,6 +3332,9 @@ def ui_remove_activity_concept( surrogates, selected_surrogate_list, selected_surrogate_uids = ( _get_activity_surrogates(soa_id, activity_id) ) + concept_groups, activity_group_uids = _get_concept_groups_for_cell( + soa_id, activity_id + ) html = templates.get_template("concepts_cell.html").render( request=request, soa_id=soa_id, @@ -3260,6 +3345,8 @@ def ui_remove_activity_concept( surrogates=surrogates, selected_surrogate_list=selected_surrogate_list, selected_surrogate_uids=selected_surrogate_uids, + concept_groups=concept_groups, + activity_group_uids=activity_group_uids, edit=False, ) return HTMLResponse(html) @@ -5671,6 +5758,9 @@ def ui_set_activity_concepts( surrogates, selected_surrogate_list, selected_surrogate_uids = ( _get_activity_surrogates(soa_id, activity_id) ) + concept_groups, activity_group_uids = _get_concept_groups_for_cell( + soa_id, activity_id + ) html = templates.get_template("concepts_cell.html").render( request=request, soa_id=soa_id, @@ -5681,6 +5771,8 @@ def ui_set_activity_concepts( surrogates=surrogates, selected_surrogate_list=selected_surrogate_list, selected_surrogate_uids=selected_surrogate_uids, + concept_groups=concept_groups, + activity_group_uids=activity_group_uids, edit=False, ) return HTMLResponse(html) @@ -5721,6 +5813,9 @@ def ui_activity_concepts_cell( surrogates, selected_surrogate_list, selected_surrogate_uids = ( _get_activity_surrogates(soa_id, activity_id) ) + concept_groups, activity_group_uids = _get_concept_groups_for_cell( + soa_id, activity_id + ) return HTMLResponse( templates.get_template("concepts_cell.html").render( request=request, @@ -5732,6 +5827,8 @@ def ui_activity_concepts_cell( surrogates=surrogates, selected_surrogate_list=selected_surrogate_list, selected_surrogate_uids=selected_surrogate_uids, + concept_groups=concept_groups, + activity_group_uids=activity_group_uids, edit=bool(edit), ) ) diff --git a/src/soa_builder/web/migrate_database.py b/src/soa_builder/web/migrate_database.py index 6a0df9c6..9551afcd 100644 --- a/src/soa_builder/web/migrate_database.py +++ b/src/soa_builder/web/migrate_database.py @@ -1284,3 +1284,72 @@ def _migrate_add_bc_surrogate_audit_table(): logger.info("_migrate_add_bc_surrogate_audit_table: audit table ready") except Exception as e: logger.warning("_migrate_add_bc_surrogate_audit_table failed: %s", e) + + +def _migrate_add_concept_group_table(): + """Create global concept_group and concept_group_concept tables if missing.""" + try: + conn = _connect() + cur = conn.cursor() + cur.execute( + """CREATE TABLE IF NOT EXISTS concept_group ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + concept_group_uid TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + label TEXT, + description TEXT + )""" + ) + cur.execute( + """CREATE TABLE IF NOT EXISTS concept_group_concept ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + concept_group_uid TEXT NOT NULL, + concept_code TEXT NOT NULL, + concept_title TEXT, + UNIQUE(concept_group_uid, concept_code) + )""" + ) + conn.commit() + conn.close() + logger.info("_migrate_add_concept_group_table: concept_group tables ready") + except Exception as e: + logger.warning("_migrate_add_concept_group_table failed: %s", e) + + +def _migrate_activity_concept_add_concept_group_uid(): + """Add concept_group_uid column to activity_concept if missing.""" + try: + conn = _connect() + cur = conn.cursor() + cur.execute("PRAGMA table_info(activity_concept)") + cols = {r[1] for r in cur.fetchall()} + if "concept_group_uid" not in cols: + cur.execute( + "ALTER TABLE activity_concept ADD COLUMN concept_group_uid TEXT" + ) + conn.commit() + logger.info("Added concept_group_uid column to activity_concept") + conn.close() + except Exception as e: + logger.warning("_migrate_activity_concept_add_concept_group_uid failed: %s", e) + + +def _migrate_surrogate_add_concept_group_uid(): + """Add concept_group_uid column to biomedical_concept_surrogate if missing.""" + try: + conn = _connect() + cur = conn.cursor() + cur.execute("PRAGMA table_info(biomedical_concept_surrogate)") + cols = {r[1] for r in cur.fetchall()} + if "concept_group_uid" not in cols: + cur.execute( + "ALTER TABLE biomedical_concept_surrogate " + "ADD COLUMN concept_group_uid TEXT" + ) + conn.commit() + logger.info( + "Added concept_group_uid column to biomedical_concept_surrogate" + ) + conn.close() + except Exception as e: + logger.warning("_migrate_surrogate_add_concept_group_uid failed: %s", e) diff --git a/src/soa_builder/web/routers/activities.py b/src/soa_builder/web/routers/activities.py index 7f7bef17..fe8e8a7f 100644 --- a/src/soa_builder/web/routers/activities.py +++ b/src/soa_builder/web/routers/activities.py @@ -619,15 +619,40 @@ def ui_list_activities(request: Request, soa_id: int): # Fetch activity concepts for all activities in this SOA activity_concepts: dict = {} has_dss = _table_has_columns(cur, "activity_concept", ("dss_title",)) + has_group = _table_has_columns(cur, "activity_concept", ("concept_group_uid",)) if _table_has_columns(cur, "activity_concept", ("soa_id",)): - if has_dss: + if has_dss and has_group: + cur.execute( + "SELECT ac.activity_id, ac.concept_code, ac.concept_title, " + "ac.dss_title, ac.dss_href, ac.concept_group_uid, cg.name " + "FROM activity_concept ac " + "LEFT JOIN concept_group cg " + "ON cg.concept_group_uid=ac.concept_group_uid " + "WHERE ac.soa_id=? " + "ORDER BY ac.concept_group_uid NULLS LAST, ac.id", + (soa_id,), + ) + elif has_dss: + cur.execute( + "SELECT activity_id, concept_code, concept_title, dss_title, dss_href " + "FROM activity_concept WHERE soa_id=?", + (soa_id,), + ) + elif has_group: cur.execute( - "SELECT activity_id, concept_code, concept_title, dss_title, dss_href FROM activity_concept WHERE soa_id=?", + "SELECT ac.activity_id, ac.concept_code, ac.concept_title, " + "NULL, NULL, ac.concept_group_uid, cg.name " + "FROM activity_concept ac " + "LEFT JOIN concept_group cg " + "ON cg.concept_group_uid=ac.concept_group_uid " + "WHERE ac.soa_id=? " + "ORDER BY ac.concept_group_uid NULLS LAST, ac.id", (soa_id,), ) else: cur.execute( - "SELECT activity_id, concept_code, concept_title FROM activity_concept WHERE soa_id=?", + "SELECT activity_id, concept_code, concept_title " + "FROM activity_concept WHERE soa_id=?", (soa_id,), ) else: @@ -636,12 +661,14 @@ def ui_list_activities(request: Request, soa_id: int): placeholders = ",".join("?" * len(activity_ids)) if has_dss: cur.execute( - f"SELECT activity_id, concept_code, concept_title, dss_title, dss_href FROM activity_concept WHERE activity_id IN ({placeholders})", + f"SELECT activity_id, concept_code, concept_title, dss_title, dss_href " + f"FROM activity_concept WHERE activity_id IN ({placeholders})", activity_ids, ) else: cur.execute( - f"SELECT activity_id, concept_code, concept_title FROM activity_concept WHERE activity_id IN ({placeholders})", + f"SELECT activity_id, concept_code, concept_title " + f"FROM activity_concept WHERE activity_id IN ({placeholders})", activity_ids, ) else: @@ -650,14 +677,27 @@ def ui_list_activities(request: Request, soa_id: int): aid, code, title = row[0], row[1], row[2] dss_title = row[3] if has_dss and len(row) > 3 else None dss_href = row[4] if has_dss and len(row) > 4 else None + concept_group_uid = row[5] if has_group and len(row) > 5 else None + group_name = row[6] if has_group and len(row) > 6 else None activity_concepts.setdefault(aid, []).append( { "code": code, "title": title, "dss_title": dss_title or "", "dss_href": dss_href or "", + "concept_group_uid": concept_group_uid, + "group_name": group_name, } ) + + # Fetch concept groups globally (for the dropdown in concepts_cell) + cur.execute( + "SELECT id, concept_group_uid, name, label FROM concept_group ORDER BY id" + ) + concept_groups = [ + {"id": r[0], "concept_group_uid": r[1], "name": r[2], "label": r[3]} + for r in cur.fetchall() + ] conn.close() # Fetch biomedical concepts list (lazy import to avoid circular dependency) @@ -727,6 +767,7 @@ def ui_list_activities(request: Request, soa_id: int): "sdtm_specializations": sdtm_specializations, "surrogates": surrogates, "activity_surrogates": activity_surrogates, + "concept_groups": concept_groups, "study_id": study_id, "study_label": study_label, "study_description": study_description, diff --git a/src/soa_builder/web/routers/bc_surrogates.py b/src/soa_builder/web/routers/bc_surrogates.py index f07c8e9e..09be109b 100644 --- a/src/soa_builder/web/routers/bc_surrogates.py +++ b/src/soa_builder/web/routers/bc_surrogates.py @@ -483,7 +483,7 @@ def ui_delete_bc_surrogate(request: Request, soa_id: int, surrogate_id: int): def _render_concepts_cell(request: Request, soa_id: int, activity_id: int): - """Re-render the concepts_cell partial after a surrogate link/unlink.""" + """Re-render the concepts_cell partial after a surrogate/group link/unlink.""" conn = _connect() cur = conn.cursor() @@ -498,22 +498,56 @@ def _render_concepts_cell(request: Request, soa_id: int, activity_id: int): raise HTTPException(404, "Activity not found") activity_uid = act_row[0] - # Fetch linked BC concepts - cur.execute( - "SELECT concept_code, concept_title FROM activity_concept WHERE activity_id=? AND soa_id=?", - (activity_id, soa_id), - ) - selected_list = [ - {"code": r[0], "title": r[1], "dss_title": "", "dss_href": ""} - for r in cur.fetchall() - ] + # Fetch linked BC concepts (include concept_group_uid and group name) + cur.execute("PRAGMA table_info(activity_concept)") + ac_cols = {r[1] for r in cur.fetchall()} + has_group_uid = "concept_group_uid" in ac_cols + if has_group_uid: + cur.execute( + "SELECT ac.concept_code, ac.concept_title, ac.concept_group_uid, " + "cg.name AS group_name " + "FROM activity_concept ac " + "LEFT JOIN concept_group cg ON cg.concept_group_uid=ac.concept_group_uid " + "WHERE ac.activity_id=? AND ac.soa_id=? " + "ORDER BY ac.concept_group_uid NULLS LAST, ac.id", + (activity_id, soa_id), + ) + selected_list = [ + { + "code": r[0], + "title": r[1], + "dss_title": "", + "dss_href": "", + "concept_group_uid": r[2], + "group_name": r[3], + } + for r in cur.fetchall() + ] + else: + cur.execute( + "SELECT concept_code, concept_title " + "FROM activity_concept WHERE activity_id=? AND soa_id=?", + (activity_id, soa_id), + ) + selected_list = [ + { + "code": r[0], + "title": r[1], + "dss_title": "", + "dss_href": "", + "concept_group_uid": None, + "group_name": None, + } + for r in cur.fetchall() + ] selected_codes = [c["code"] for c in selected_list] # Fetch linked surrogates cur.execute( "SELECT bcs.id, bcs.surrogate_uid, bcs.name, bcs.label " "FROM activity_surrogate asr " - "JOIN biomedical_concept_surrogate bcs ON bcs.surrogate_uid=asr.surrogate_uid AND bcs.soa_id=asr.soa_id " + "JOIN biomedical_concept_surrogate bcs " + "ON bcs.surrogate_uid=asr.surrogate_uid AND bcs.soa_id=asr.soa_id " "WHERE asr.activity_uid=? AND asr.soa_id=?", (activity_uid, soa_id), ) @@ -525,13 +559,26 @@ def _render_concepts_cell(request: Request, soa_id: int, activity_id: int): # Fetch all surrogates for this SOA (for the dropdown) cur.execute( - "SELECT id, surrogate_uid, name, label FROM biomedical_concept_surrogate WHERE soa_id=? ORDER BY id", + "SELECT id, surrogate_uid, name, label " + "FROM biomedical_concept_surrogate WHERE soa_id=? ORDER BY id", (soa_id,), ) surrogates = [ {"id": r[0], "surrogate_uid": r[1], "name": r[2], "label": r[3]} for r in cur.fetchall() ] + + # Fetch all concept groups (for the dropdown) + cur.execute( + "SELECT id, concept_group_uid, name, label FROM concept_group ORDER BY id" + ) + concept_groups = [ + {"id": r[0], "concept_group_uid": r[1], "name": r[2], "label": r[3]} + for r in cur.fetchall() + ] + activity_group_uids = list( + {c["concept_group_uid"] for c in selected_list if c["concept_group_uid"]} + ) conn.close() # Fetch BC concepts list (for the dropdown) @@ -552,6 +599,8 @@ def _render_concepts_cell(request: Request, soa_id: int, activity_id: int): "selected_surrogate_uids": selected_surrogate_uids, "concepts": concepts, "surrogates": surrogates, + "concept_groups": concept_groups, + "activity_group_uids": activity_group_uids, "edit": False, }, ) diff --git a/src/soa_builder/web/routers/concept_groups.py b/src/soa_builder/web/routers/concept_groups.py new file mode 100644 index 00000000..5bad742a --- /dev/null +++ b/src/soa_builder/web/routers/concept_groups.py @@ -0,0 +1,836 @@ +import logging +import os +from typing import List, Optional + +from fastapi import APIRouter, BackgroundTasks, Form, HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from pydantic import BaseModel + +from ..db import _connect +from ..utils import ( + get_next_concept_uid as _get_next_concept_uid, + soa_exists, +) + +router = APIRouter() +ui_router = APIRouter() +logger = logging.getLogger("soa_builder.web.routers.concept_groups") +templates = Jinja2Templates( + directory=os.path.join(os.path.dirname(__file__), "..", "templates") +) + + +def _nz(v: Optional[str]) -> Optional[str]: + """Return None for blank strings, otherwise the stripped value.""" + if v is None: + return None + v = v.strip() + return v if v else None + + +def _next_group_uid(cur) -> str: + """Generate next ConceptGroup_N UID (globally monotonic, never reuses).""" + prefix = "ConceptGroup_" + max_n = 0 + cur.execute("SELECT concept_group_uid FROM concept_group") + for (uid,) in cur.fetchall(): + if isinstance(uid, str) and uid.startswith(prefix): + try: + n = int(uid[len(prefix) :]) + if n > max_n: + max_n = n + except (ValueError, IndexError): + pass + return f"{prefix}{max_n + 1}" + + +def _fetch_group_row(cur, group_id: int): + """Return (id, concept_group_uid, name) or raise 404.""" + cur.execute( + "SELECT id, concept_group_uid, name FROM concept_group WHERE id=?", + (group_id,), + ) + row = cur.fetchone() + if not row: + raise HTTPException(404, "Group not found") + return row + + +def _expand_group_to_activity( + cur, soa_id: int, activity_id: int, activity_uid: str, group_uid: str +) -> int: + """Insert all group concepts into activity_concept for the given activity. + + Skips concepts already present (any source). Returns count added. + """ + from ..app import _upsert_biomedical_concept + + cur.execute( + "SELECT concept_code, concept_title " + "FROM concept_group_concept WHERE concept_group_uid=?", + (group_uid,), + ) + concepts = cur.fetchall() + added = 0 + for code, title in concepts: + cur.execute( + "SELECT 1 FROM activity_concept " + "WHERE activity_id=? AND soa_id=? AND concept_code=?", + (activity_id, soa_id, code), + ) + if cur.fetchone(): + continue + concept_uid = _get_next_concept_uid(cur, soa_id) + cur.execute( + "INSERT INTO activity_concept " + "(soa_id, activity_id, activity_uid, concept_uid, " + "concept_code, concept_title, concept_group_uid) " + "VALUES (?,?,?,?,?,?,?)", + ( + soa_id, + activity_id, + activity_uid, + concept_uid, + code, + title, + group_uid, + ), + ) + _upsert_biomedical_concept(cur, soa_id, concept_uid, title, code) + added += 1 + return added + + +# --------------------------------------------------------------------------- +# Pydantic schemas (local to this router) +# --------------------------------------------------------------------------- + + +class ConceptGroupCreate(BaseModel): + name: str + label: Optional[str] = None + description: Optional[str] = None + + +class ConceptGroupUpdate(BaseModel): + name: Optional[str] = None + label: Optional[str] = None + description: Optional[str] = None + + +class ConceptsAdd(BaseModel): + concept_codes: List[str] + + +class CategoryAdd(BaseModel): + category: str + + +# --------------------------------------------------------------------------- +# API — list +# --------------------------------------------------------------------------- + + +@router.get("/concept-groups", response_class=JSONResponse, response_model=None) +def list_concept_groups(): + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id, concept_group_uid, name, label, description " + "FROM concept_group ORDER BY id" + ) + groups = [ + { + "id": r[0], + "concept_group_uid": r[1], + "name": r[2], + "label": r[3], + "description": r[4], + } + for r in cur.fetchall() + ] + for g in groups: + cur.execute( + "SELECT concept_code, concept_title " + "FROM concept_group_concept WHERE concept_group_uid=? ORDER BY id", + (g["concept_group_uid"],), + ) + g["concepts"] = [{"code": r[0], "title": r[1]} for r in cur.fetchall()] + conn.close() + return groups + + +# --------------------------------------------------------------------------- +# API — create +# --------------------------------------------------------------------------- + + +@router.post("/concept-groups", response_class=JSONResponse, response_model=None) +def create_concept_group(payload: ConceptGroupCreate): + name = (payload.name or "").strip() + if not name: + raise HTTPException(400, "Group name required") + conn = _connect() + cur = conn.cursor() + uid = _next_group_uid(cur) + cur.execute( + "INSERT INTO concept_group " + "(concept_group_uid, name, label, description) VALUES (?,?,?,?)", + (uid, name, _nz(payload.label), _nz(payload.description)), + ) + group_id = cur.lastrowid + conn.commit() + conn.close() + return { + "id": group_id, + "concept_group_uid": uid, + "name": name, + "label": _nz(payload.label), + "description": _nz(payload.description), + } + + +# --------------------------------------------------------------------------- +# API — update +# --------------------------------------------------------------------------- + + +@router.patch( + "/concept-groups/{group_id}", + response_class=JSONResponse, + response_model=None, +) +def update_concept_group(group_id: int, payload: ConceptGroupUpdate): + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id, concept_group_uid, name, label, description " + "FROM concept_group WHERE id=?", + (group_id,), + ) + row = cur.fetchone() + if not row: + conn.close() + raise HTTPException(404, "Group not found") + new_name = _nz(payload.name) if payload.name is not None else row[2] + new_label = _nz(payload.label) if payload.label is not None else row[3] + new_desc = _nz(payload.description) if payload.description is not None else row[4] + cur.execute( + "UPDATE concept_group SET name=?, label=?, description=? WHERE id=?", + (new_name, new_label, new_desc, group_id), + ) + conn.commit() + conn.close() + return { + "id": row[0], + "concept_group_uid": row[1], + "name": new_name, + "label": new_label, + "description": new_desc, + } + + +# --------------------------------------------------------------------------- +# API — delete +# --------------------------------------------------------------------------- + + +@router.delete( + "/concept-groups/{group_id}", + response_class=JSONResponse, + response_model=None, +) +def delete_concept_group(group_id: int): + conn = _connect() + cur = conn.cursor() + row = _fetch_group_row(cur, group_id) + uid = row[1] + cur.execute("DELETE FROM concept_group_concept WHERE concept_group_uid=?", (uid,)) + cur.execute("PRAGMA table_info(activity_concept)") + if "concept_group_uid" in {r[1] for r in cur.fetchall()}: + cur.execute("DELETE FROM activity_concept WHERE concept_group_uid=?", (uid,)) + cur.execute("DELETE FROM concept_group WHERE id=?", (group_id,)) + conn.commit() + conn.close() + return {"deleted": True, "id": group_id} + + +# --------------------------------------------------------------------------- +# API — add/remove individual concepts from group definition +# --------------------------------------------------------------------------- + + +@router.post( + "/concept-groups/{group_id}/concepts", + response_class=JSONResponse, + response_model=None, +) +def add_concepts_to_group(group_id: int, payload: ConceptsAdd): + conn = _connect() + cur = conn.cursor() + row = _fetch_group_row(cur, group_id) + uid = row[1] + from ..app import fetch_biomedical_concepts as _fetch_concepts + + lookup = {c["code"]: c["title"] for c in _fetch_concepts()} + added = 0 + for code in payload.concept_codes: + code = code.strip() + if not code: + continue + title = lookup.get(code, code) + cur.execute( + "INSERT OR IGNORE INTO concept_group_concept " + "(concept_group_uid, concept_code, concept_title) VALUES (?,?,?)", + (uid, code, title), + ) + if cur.rowcount: + added += 1 + conn.commit() + conn.close() + return {"added": added} + + +@router.delete( + "/concept-groups/{group_id}/concepts/{concept_code:path}", + response_class=JSONResponse, + response_model=None, +) +def remove_concept_from_group(group_id: int, concept_code: str): + conn = _connect() + cur = conn.cursor() + row = _fetch_group_row(cur, group_id) + uid = row[1] + cur.execute( + "DELETE FROM concept_group_concept " + "WHERE concept_group_uid=? AND concept_code=?", + (uid, concept_code), + ) + conn.commit() + conn.close() + return {"removed": True, "concept_code": concept_code} + + +# --------------------------------------------------------------------------- +# API — bulk add from CDISC category +# --------------------------------------------------------------------------- + + +@router.post( + "/concept-groups/{group_id}/concepts/from-category", + response_class=JSONResponse, + response_model=None, +) +def add_category_to_group(group_id: int, payload: CategoryAdd): + conn = _connect() + cur = conn.cursor() + row = _fetch_group_row(cur, group_id) + uid = row[1] + from ..app import ( + fetch_biomedical_concepts_by_category as _fetch_by_cat, + ) + + concepts = _fetch_by_cat(payload.category) + added = 0 + for c in concepts: + cur.execute( + "INSERT OR IGNORE INTO concept_group_concept " + "(concept_group_uid, concept_code, concept_title) VALUES (?,?,?)", + (uid, c["code"], c.get("title", c["code"])), + ) + if cur.rowcount: + added += 1 + conn.commit() + conn.close() + return {"added": added, "category": payload.category} + + +# --------------------------------------------------------------------------- +# API — assign / unassign group to/from activity +# --------------------------------------------------------------------------- + + +@router.post( + "/soa/{soa_id}/activities/{activity_id}/concept-groups/{group_id}", + response_class=JSONResponse, + response_model=None, +) +def assign_group_to_activity(soa_id: int, activity_id: int, group_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT activity_uid FROM activity WHERE id=? AND soa_id=?", + (activity_id, soa_id), + ) + act_row = cur.fetchone() + if not act_row: + conn.close() + raise HTTPException(404, "Activity not found") + activity_uid = act_row[0] + + row = _fetch_group_row(cur, group_id) + group_uid = row[1] + + added = _expand_group_to_activity(cur, soa_id, activity_id, activity_uid, group_uid) + conn.commit() + conn.close() + return {"assigned": True, "added": added, "group_uid": group_uid} + + +@router.delete( + "/soa/{soa_id}/activities/{activity_id}/concept-groups/{group_id}", + response_class=JSONResponse, + response_model=None, +) +def unassign_group_from_activity(soa_id: int, activity_id: int, group_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + conn = _connect() + cur = conn.cursor() + row = _fetch_group_row(cur, group_id) + group_uid = row[1] + cur.execute( + "DELETE FROM activity_concept " + "WHERE activity_id=? AND soa_id=? AND concept_group_uid=?", + (activity_id, soa_id, group_uid), + ) + conn.commit() + conn.close() + return {"unassigned": True, "group_uid": group_uid} + + +# --------------------------------------------------------------------------- +# UI — concept group management page +# --------------------------------------------------------------------------- + + +@ui_router.get("/ui/concept-groups", response_class=HTMLResponse) +def ui_list_concept_groups(request: Request): + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id, concept_group_uid, name, label, description " + "FROM concept_group ORDER BY id" + ) + groups = [ + { + "id": r[0], + "concept_group_uid": r[1], + "name": r[2], + "label": r[3], + "description": r[4], + } + for r in cur.fetchall() + ] + for g in groups: + # Concepts sorted alphabetically by title + cur.execute( + "SELECT concept_code, concept_title " + "FROM concept_group_concept WHERE concept_group_uid=? " + "ORDER BY concept_title COLLATE NOCASE ASC", + (g["concept_group_uid"],), + ) + g["concepts"] = [{"code": r[0], "title": r[1]} for r in cur.fetchall()] + # Surrogates assigned to this group (from all SOAs) + cur.execute( + "SELECT bcs.id, bcs.soa_id, bcs.surrogate_uid, bcs.name, bcs.label, " + "s.name AS soa_name " + "FROM biomedical_concept_surrogate bcs " + "JOIN soa s ON s.id=bcs.soa_id " + "WHERE bcs.concept_group_uid=? " + "ORDER BY bcs.soa_id, bcs.name COLLATE NOCASE", + (g["concept_group_uid"],), + ) + g["surrogates"] = [ + { + "id": r[0], + "soa_id": r[1], + "surrogate_uid": r[2], + "name": r[3], + "label": r[4], + "soa_name": r[5], + } + for r in cur.fetchall() + ] + + # All surrogates (all SOAs) not yet assigned to any group, for add dropdown + cur.execute( + "SELECT bcs.id, bcs.soa_id, bcs.surrogate_uid, bcs.name, bcs.label, " + "s.name AS soa_name " + "FROM biomedical_concept_surrogate bcs " + "JOIN soa s ON s.id=bcs.soa_id " + "WHERE bcs.concept_group_uid IS NULL " + "ORDER BY s.name COLLATE NOCASE, bcs.name COLLATE NOCASE" + ) + all_unassigned_surrogates = [ + { + "id": r[0], + "soa_id": r[1], + "surrogate_uid": r[2], + "name": r[3], + "label": r[4], + "soa_name": r[5], + } + for r in cur.fetchall() + ] + conn.close() + from ..app import ( + fetch_biomedical_concept_categories as _fetch_cats, + fetch_biomedical_concepts as _fetch_concepts, + ) + + return templates.TemplateResponse( + request, + "concept_groups.html", + { + "request": request, + "groups": groups, + "concepts": _fetch_concepts(), + "categories": _fetch_cats(), + "all_unassigned_surrogates": all_unassigned_surrogates, + }, + ) + + +# --------------------------------------------------------------------------- +# UI — create / update / delete (form POST → redirect to list) +# --------------------------------------------------------------------------- + + +@ui_router.post("/ui/concept-groups/create", response_class=HTMLResponse) +def ui_create_concept_group( + request: Request, + name: str = Form(...), + label: str = Form(""), + description: str = Form(""), + copy_from_group_id: str = Form(""), +): + name = name.strip() + if not name: + raise HTTPException(400, "Group name required") + conn = _connect() + cur = conn.cursor() + uid = _next_group_uid(cur) + cur.execute( + "INSERT INTO concept_group " + "(concept_group_uid, name, label, description) VALUES (?,?,?,?)", + (uid, name, _nz(label), _nz(description)), + ) + # Copy concepts from an existing group if requested + copy_id = int(copy_from_group_id) if copy_from_group_id.strip() else None + if copy_id: + cur.execute( + "SELECT concept_group_uid FROM concept_group WHERE id=?", (copy_id,) + ) + src_row = cur.fetchone() + if src_row: + cur.execute( + "INSERT OR IGNORE INTO concept_group_concept " + "(concept_group_uid, concept_code, concept_title) " + "SELECT ?, concept_code, concept_title " + "FROM concept_group_concept WHERE concept_group_uid=?", + (uid, src_row[0]), + ) + conn.commit() + conn.close() + return RedirectResponse("/ui/concept-groups", status_code=303) + + +@ui_router.post("/ui/concept-groups/{group_id}/update", response_class=HTMLResponse) +def ui_update_concept_group( + request: Request, + group_id: int, + name: str = Form(...), + label: str = Form(""), + description: str = Form(""), +): + conn = _connect() + cur = conn.cursor() + cur.execute("SELECT id, name FROM concept_group WHERE id=?", (group_id,)) + row = cur.fetchone() + if not row: + conn.close() + raise HTTPException(404, "Group not found") + new_name = name.strip() or row[1] + cur.execute( + "UPDATE concept_group SET name=?, label=?, description=? WHERE id=?", + (new_name, _nz(label), _nz(description), group_id), + ) + conn.commit() + conn.close() + return RedirectResponse("/ui/concept-groups", status_code=303) + + +@ui_router.post("/ui/concept-groups/{group_id}/delete", response_class=HTMLResponse) +def ui_delete_concept_group(request: Request, group_id: int): + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id, concept_group_uid FROM concept_group WHERE id=?", + (group_id,), + ) + row = cur.fetchone() + if not row: + conn.close() + raise HTTPException(404, "Group not found") + uid = row[1] + cur.execute("DELETE FROM concept_group_concept WHERE concept_group_uid=?", (uid,)) + cur.execute("PRAGMA table_info(activity_concept)") + if "concept_group_uid" in {r[1] for r in cur.fetchall()}: + cur.execute("DELETE FROM activity_concept WHERE concept_group_uid=?", (uid,)) + cur.execute("DELETE FROM concept_group WHERE id=?", (group_id,)) + conn.commit() + conn.close() + return RedirectResponse("/ui/concept-groups", status_code=303) + + +# --------------------------------------------------------------------------- +# UI — add/remove concept from group definition +# --------------------------------------------------------------------------- + + +@ui_router.post( + "/ui/concept-groups/{group_id}/concepts/add", response_class=HTMLResponse +) +def ui_add_concept_to_group( + request: Request, + group_id: int, + concept_code: str = Form(...), +): + code = concept_code.strip() + if not code: + raise HTTPException(400, "concept_code required") + conn = _connect() + cur = conn.cursor() + cur.execute("SELECT concept_group_uid FROM concept_group WHERE id=?", (group_id,)) + row = cur.fetchone() + if not row: + conn.close() + raise HTTPException(404, "Group not found") + uid = row[0] + from ..app import fetch_biomedical_concepts as _fetch_concepts + + lookup = {c["code"]: c["title"] for c in _fetch_concepts()} + title = lookup.get(code, code) + cur.execute( + "INSERT OR IGNORE INTO concept_group_concept " + "(concept_group_uid, concept_code, concept_title) VALUES (?,?,?)", + (uid, code, title), + ) + conn.commit() + conn.close() + return RedirectResponse("/ui/concept-groups", status_code=303) + + +@ui_router.post( + "/ui/concept-groups/{group_id}/concepts/remove", + response_class=HTMLResponse, +) +def ui_remove_concept_from_group( + request: Request, + group_id: int, + concept_code: str = Form(...), +): + conn = _connect() + cur = conn.cursor() + cur.execute("SELECT concept_group_uid FROM concept_group WHERE id=?", (group_id,)) + row = cur.fetchone() + if not row: + conn.close() + raise HTTPException(404, "Group not found") + uid = row[0] + cur.execute( + "DELETE FROM concept_group_concept " + "WHERE concept_group_uid=? AND concept_code=?", + (uid, concept_code.strip()), + ) + conn.commit() + conn.close() + return RedirectResponse("/ui/concept-groups", status_code=303) + + +@ui_router.post( + "/ui/concept-groups/{group_id}/concepts/add-category", + response_class=HTMLResponse, +) +def ui_add_category_to_group( + request: Request, + group_id: int, + category: str = Form(...), +): + conn = _connect() + cur = conn.cursor() + cur.execute("SELECT concept_group_uid FROM concept_group WHERE id=?", (group_id,)) + row = cur.fetchone() + if not row: + conn.close() + raise HTTPException(404, "Group not found") + uid = row[0] + from ..app import ( + fetch_biomedical_concepts_by_category as _fetch_by_cat, + ) + + concepts = _fetch_by_cat(category.strip()) + for c in concepts: + cur.execute( + "INSERT OR IGNORE INTO concept_group_concept " + "(concept_group_uid, concept_code, concept_title) VALUES (?,?,?)", + (uid, c["code"], c.get("title", c["code"])), + ) + conn.commit() + conn.close() + return RedirectResponse("/ui/concept-groups", status_code=303) + + +# --------------------------------------------------------------------------- +# UI — add/remove surrogate from group definition +# --------------------------------------------------------------------------- + + +@ui_router.post( + "/ui/concept-groups/{group_id}/surrogates/add", response_class=HTMLResponse +) +def ui_add_surrogate_to_group( + request: Request, + group_id: int, + surrogate_id: int = Form(...), +): + conn = _connect() + cur = conn.cursor() + cur.execute("SELECT concept_group_uid FROM concept_group WHERE id=?", (group_id,)) + row = cur.fetchone() + if not row: + conn.close() + raise HTTPException(404, "Group not found") + group_uid = row[0] + cur.execute( + "UPDATE biomedical_concept_surrogate SET concept_group_uid=? WHERE id=?", + (group_uid, surrogate_id), + ) + conn.commit() + conn.close() + return RedirectResponse("/ui/concept-groups", status_code=303) + + +@ui_router.post( + "/ui/concept-groups/{group_id}/surrogates/remove", + response_class=HTMLResponse, +) +def ui_remove_surrogate_from_group( + request: Request, + group_id: int, + surrogate_id: int = Form(...), +): + conn = _connect() + cur = conn.cursor() + cur.execute( + "UPDATE biomedical_concept_surrogate SET concept_group_uid=NULL WHERE id=?", + (surrogate_id,), + ) + conn.commit() + conn.close() + return RedirectResponse("/ui/concept-groups", status_code=303) + + +# --------------------------------------------------------------------------- +# UI — HTMX add/remove group on activity (returns concepts_cell partial) +# --------------------------------------------------------------------------- + + +@ui_router.post( + "/ui/soa/{soa_id}/activity/{activity_id}/concept-groups/add", + response_class=HTMLResponse, +) +def ui_add_group_to_activity( + request: Request, + background_tasks: BackgroundTasks, + soa_id: int, + activity_id: int, + concept_group_uid: str = Form(...), +): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT activity_uid FROM activity WHERE id=? AND soa_id=?", + (activity_id, soa_id), + ) + act_row = cur.fetchone() + if not act_row: + conn.close() + raise HTTPException(404, "Activity not found") + activity_uid = act_row[0] + + cur.execute( + "SELECT 1 FROM concept_group WHERE concept_group_uid=?", + (concept_group_uid,), + ) + if not cur.fetchone(): + conn.close() + raise HTTPException(404, "Group not found") + + # Fetch group concepts before expanding (for background enrichment) + cur.execute( + "SELECT concept_code FROM concept_group_concept WHERE concept_group_uid=?", + (concept_group_uid,), + ) + codes = [r[0] for r in cur.fetchall()] + + _expand_group_to_activity(cur, soa_id, activity_id, activity_uid, concept_group_uid) + conn.commit() + conn.close() + + from ..app import ( + _enrich_biomedical_concept_bg, + _enrich_code_bg, + _lookup_and_save_dss, + _populate_bc_properties_bg, + ) + + for code in codes: + background_tasks.add_task(_enrich_biomedical_concept_bg, code, soa_id) + background_tasks.add_task(_enrich_code_bg, code, soa_id) + background_tasks.add_task(_lookup_and_save_dss, soa_id, activity_id, code) + background_tasks.add_task(_populate_bc_properties_bg, soa_id, activity_id, code) + + from .bc_surrogates import _render_concepts_cell + + return _render_concepts_cell(request, soa_id, activity_id) + + +@ui_router.post( + "/ui/soa/{soa_id}/activity/{activity_id}/concept-groups/remove", + response_class=HTMLResponse, +) +def ui_remove_group_from_activity( + request: Request, + soa_id: int, + activity_id: int, + concept_group_uid: str = Form(...), +): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT 1 FROM activity WHERE id=? AND soa_id=?", + (activity_id, soa_id), + ) + if not cur.fetchone(): + conn.close() + raise HTTPException(404, "Activity not found") + cur.execute( + "DELETE FROM activity_concept " + "WHERE activity_id=? AND soa_id=? AND concept_group_uid=?", + (activity_id, soa_id, concept_group_uid), + ) + conn.commit() + conn.close() + + from .bc_surrogates import _render_concepts_cell + + return _render_concepts_cell(request, soa_id, activity_id) diff --git a/src/soa_builder/web/templates/activities.html b/src/soa_builder/web/templates/activities.html index 9daefef4..d6e1c556 100644 --- a/src/soa_builder/web/templates/activities.html +++ b/src/soa_builder/web/templates/activities.html @@ -75,6 +75,7 @@
+ Groups are global (not SOA-specific) and can be assigned to activities in any SoA. + Concepts added to a group via an activity assignment can be individually removed from + that activity without affecting the group definition. +
+ + +No concept groups defined yet.
+{% endif %} + +{% for g in groups %} +{{ g.concept_group_uid }}
+ {{ g.concepts | length }} concept(s)
+
+
+
+
+
+
+ ActivityBiomedical Concepts >
+Concept Groups from the navigation bar.
+
+New Group.
+
+If there are existing groups, the user can choose to copy the group
+and tagged biomedical concepts.
+
+tag
+(assign) biomedical concepts to add to the group.
+
+
+
+The user can also add an entire Biomedical Concept Category to a new +group.
+The user can also add a Biomedical Concept Surrogate to the new +group.
+Activities page
+for a study.
+
+
+
+Biomedical Concepts >
+Concept Groups page, expand a group.
+
+Simply click on the ‘x’ beside a biomedical concept to remove from +the group.
+Study Design > Activities page,
+expand an assigned group.
+
+Simply click on the x beside a biomedical concept to
+remove from the group and activity.
Study Design > Activities page,
+expand an assigned group.
+
+Simply click on the x all icon and the group will be
+unassigned from the activity.