From 1bcf69af8301c03580e0e49ab1e9d6576dd68258 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:29:52 -0500 Subject: [PATCH 01/17] Fixed by extracting the last path segment first with .rsplit(/, 1)[-1] before looking for the hyphen. --- src/usdm/generate_encounters.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/usdm/generate_encounters.py b/src/usdm/generate_encounters.py index f9b7416..eb5a2c2 100644 --- a/src/usdm/generate_encounters.py +++ b/src/usdm/generate_encounters.py @@ -255,9 +255,8 @@ def build_usdm_encounters(soa_id: int) -> List[Dict[str, Any]]: # Build optional environmentalSettings array env_settings: List[Dict[str, Any]] = [] if e_code and e_codesystem: - code_system_version = e_codesystem[0][ - e_codesystem[0].index("-") + 1 : len(e_codesystem[0]) - ] + _seg = e_codesystem[0].rsplit("/", 1)[-1] + code_system_version = _seg[_seg.index("-") + 1 :] if "-" in _seg else _seg decode = get_submission_value_for_code( soa_id, "C127262", @@ -278,9 +277,10 @@ def build_usdm_encounters(soa_id: int) -> List[Dict[str, Any]]: # Build optional contactMode array contact_mode: List[Dict[str, Any]] = [] if c_code and c_codesystem: - c_code_system_version = c_codesystem[0][ - c_codesystem[0].index("-") + 1 : len(c_codesystem[0]) - ] + _cseg = c_codesystem[0].rsplit("/", 1)[-1] + c_code_system_version = ( + _cseg[_cseg.index("-") + 1 :] if "-" in _cseg else _cseg + ) c_decode = get_submission_value_for_code( soa_id, "C171445", From 1e3db24fd7a83bb85e382c1603e28410e4381874 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:38:27 -0500 Subject: [PATCH 02/17] First version of full usdm json. --- src/usdm/generate_usdm.py | 225 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 src/usdm/generate_usdm.py diff --git a/src/usdm/generate_usdm.py b/src/usdm/generate_usdm.py new file mode 100644 index 0000000..e93f834 --- /dev/null +++ b/src/usdm/generate_usdm.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +""" +Full USDM document generator. + +Produces a Study-Output → StudyVersion-Output → InterventionalStudyDesign-Output +hierarchy, populating sub-entities from the existing per-entity generators. +""" +from typing import Optional, List, Dict, Any +import logging + +logger = logging.getLogger("usdm.generate_usdm") + +try: + from soa_builder.web.app import _connect +except ImportError: + import sys + from pathlib import Path + + here = Path(__file__).resolve() + src_dir = here.parents[2] / "src" + if src_dir.exists() and str(src_dir) not in sys.path: + sys.path.insert(0, str(src_dir)) + from soa_builder.web.app import _connect # type: ignore + +from usdm.generate_activities import build_usdm_activities +from usdm.generate_arms import build_usdm_arms +from usdm.generate_elements import build_usdm_elements +from usdm.generate_encounters import build_usdm_encounters +from usdm.generate_schedule_timelines import build_usdm_schedule_timelines +from usdm.generate_study_cells import build_usdm_study_cells +from usdm.generate_study_epochs import build_usdm_epochs + + +def _nz(s: Optional[str]) -> Optional[str]: + s = (s or "").strip() + return s or None + + +def _get_soa_metadata(soa_id: int) -> Dict[str, Optional[str]]: + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT name, study_id, study_label, study_description FROM soa WHERE id=?", + (soa_id,), + ) + row = cur.fetchone() + conn.close() + if row is None: + raise ValueError(f"No SOA found with id={soa_id}") + return { + "name": row[0], + "study_id": row[1], + "study_label": row[2], + "study_description": row[3], + } + + +def build_usdm(soa_id: int) -> Dict[str, Any]: + """ + Build a complete USDM Study-Output document for the given SOA. + + Returns the full hierarchy: + Study -> versions[0] -> studyDesigns[0] (InterventionalStudyDesign) + """ + meta = _get_soa_metadata(soa_id) + + def _safe(label: str, fn, *args) -> List[Dict[str, Any]]: + try: + return fn(*args) + except Exception: + logger.warning( + "Failed to build %s for soa_id=%s, using empty list", label, soa_id + ) + return [] + + study_design = { + "id": "InterventionalStudyDesign_1", + "extensionAttributes": [], + "name": meta["name"] or "", + "label": _nz(meta["study_label"]), + "description": _nz(meta["study_description"]), + "studyType": None, + "studyPhase": None, + "therapeuticAreas": [], + "characteristics": [], + "encounters": _safe("encounters", build_usdm_encounters, soa_id), + "activities": _safe("activities", build_usdm_activities, soa_id), + "arms": _safe("arms", build_usdm_arms, soa_id), + "studyCells": _safe("studyCells", build_usdm_study_cells, soa_id), + "rationale": "", + "epochs": _safe("epochs", build_usdm_epochs, soa_id), + "elements": _safe("elements", build_usdm_elements, soa_id), + "estimands": [], + "indications": [], + "studyInterventionIds": [], + "objectives": [], + "population": { + "id": "StudyDesignPopulation_1", + "extensionAttributes": [], + "name": "", + "label": None, + "description": None, + "includesHealthySubjects": False, + "plannedEnrollmentNumber": None, + "plannedCompletionNumber": None, + "plannedSex": [], + "criterionIds": [], + "plannedAge": None, + "notes": [], + "cohorts": [], + "instanceType": "StudyDesignPopulation", + }, + "scheduleTimelines": _safe( + "scheduleTimelines", build_usdm_schedule_timelines, soa_id + ), + "biospecimenRetentions": [], + "documentVersionIds": [], + "eligibilityCriteria": [], + "analysisPopulations": [], + "notes": [], + "subTypes": [], + "model": { + "id": "Code_StudyDesignModel", + "extensionAttributes": [], + "code": "", + "codeSystem": "", + "codeSystemVersion": "", + "decode": "", + "instanceType": "Code", + }, + "intentTypes": [], + "blindingSchema": None, + "instanceType": "InterventionalStudyDesign", + } + + study_version = { + "id": "StudyVersion_1", + "extensionAttributes": [], + "versionIdentifier": "1", + "rationale": "", + "studyIdentifiers": [ + { + "id": "StudyIdentifier_1", + "extensionAttributes": [], + "text": meta["study_id"] or "", + "scopeId": "", + "instanceType": "StudyIdentifier", + } + ], + "referenceIdentifiers": [], + "studyDesigns": [study_design], + "titles": [ + { + "id": "StudyTitle_1", + "extensionAttributes": [], + "text": meta["study_label"] or meta["name"] or "", + "type": { + "id": "Code_StudyTitleType", + "extensionAttributes": [], + "code": "C99905x2", + "codeSystem": "http://www.cdisc.org", + "codeSystemVersion": "", + "decode": "Official Study Title", + "instanceType": "Code", + }, + "instanceType": "StudyTitle", + } + ], + "documentVersionIds": [], + "dateValues": [], + "amendments": [], + "businessTherapeuticAreas": [], + "notes": [], + "instanceType": "StudyVersion", + } + + study = { + "id": None, + "extensionAttributes": [], + "name": meta["study_id"] or meta["name"] or "", + "description": _nz(meta["study_description"]), + "label": _nz(meta["study_label"]), + "versions": [study_version], + "documentedBy": [], + "instanceType": "Study", + } + + return { + "study": study, + "usdmVersion": "4.0", + "systemName": "SOA Workbench", + "systemVersion": "1.0.0", + } + + +if __name__ == "__main__": + import argparse + import json + import logging + import sys + + logger = logging.getLogger("usdm.generate_usdm") + + parser = argparse.ArgumentParser( + description="Export a full USDM Study document for a SOA." + ) + parser.add_argument("soa_id", type=int, help="SOA id to export") + parser.add_argument( + "-o", "--output", default="-", help="Output file path or '-' for stdout" + ) + parser.add_argument("--indent", type=int, default=2, help="JSON indent") + args = parser.parse_args() + + try: + document = build_usdm(args.soa_id) + except Exception: + logger.exception("Failed to build USDM document for soa_id=%s", args.soa_id) + sys.exit(1) + + payload = json.dumps(document, indent=args.indent) + if args.output in ("-", "/dev/stdout"): + sys.stdout.write(payload + "\n") + else: + with open(args.output, "w", encoding="utf-8") as f: + f.write(payload + "\n") From 39615bdf96dfa489d3e52c1c26f16dd25ac0b635 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:15:30 -0500 Subject: [PATCH 03/17] Issue #105: Added new activities UI page for creation of Acitivities for the study. --- src/soa_builder/web/app.py | 1 + src/soa_builder/web/routers/activities.py | 138 ++++++++++++++++-- src/soa_builder/web/templates/activities.html | 132 +++++++++++++++++ src/soa_builder/web/templates/base.html | 1 + 4 files changed, 263 insertions(+), 9 deletions(-) create mode 100644 src/soa_builder/web/templates/activities.html diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index 28c1077..5763440 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -186,6 +186,7 @@ def _configure_logging(): app.include_router(elements_router.router) app.include_router(visits_router.router) app.include_router(activities_router.router) +app.include_router(activities_router.ui_router) app.include_router(epochs_router.router) app.include_router(freezes_router.router) app.include_router(rollback_router.router) diff --git a/src/soa_builder/web/routers/activities.py b/src/soa_builder/web/routers/activities.py index e7c29d0..baf8334 100644 --- a/src/soa_builder/web/routers/activities.py +++ b/src/soa_builder/web/routers/activities.py @@ -7,7 +7,8 @@ from typing import List from fastapi import APIRouter, HTTPException, Request, Form -from fastapi.responses import JSONResponse, HTMLResponse +from fastapi.responses import JSONResponse, HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates from ..audit import _record_activity_audit, _record_reorder_audit from ..db import _connect @@ -22,7 +23,11 @@ _ACT_CONCEPT_TTL = 60 * 60 router = APIRouter(prefix="/soa/{soa_id}") +ui_router = APIRouter() logger = logging.getLogger("soa_builder.web.routers.activities") +templates = Jinja2Templates( + directory=os.path.join(os.path.dirname(__file__), "..", "templates") +) def fetch_biomedical_concepts(force: bool = False): @@ -201,11 +206,10 @@ def ui_add_activity( raise HTTPException(404, "SOA not found") payload = ActivityCreate(name=name or "", label=label, description=description) add_activity(soa_id, payload) + redirect_url = f"/ui/soa/{int(soa_id)}/activities" if request.headers.get("HX-Request") == "true": - return HTMLResponse("", headers={"HX-Redirect": f"/ui/soa/{soa_id}/edit"}) - return HTMLResponse( - f"" - ) + return HTMLResponse("", headers={"HX-Redirect": redirect_url}) + return HTMLResponse(f"") @router.patch("/activities/{activity_id}", response_class=JSONResponse) @@ -301,11 +305,10 @@ def ui_update_activity( payload = ActivityUpdate(name=name, label=label, description=description) # Reuse the JSON handler for business logic/audit update_activity(soa_id, activity_id, payload) + redirect_url = f"/ui/soa/{int(soa_id)}/activities" if request.headers.get("HX-Request") == "true": - return HTMLResponse("", headers={"HX-Redirect": f"/ui/soa/{soa_id}/edit"}) - return HTMLResponse( - f"" - ) + return HTMLResponse("", headers={"HX-Redirect": redirect_url}) + return HTMLResponse(f"") @router.post("/activities/reorder", response_class=JSONResponse) @@ -502,3 +505,120 @@ def set_activity_concepts(soa_id: int, activity_id: int, concept_codes: List[str conn.commit() conn.close() return {"activity_id": activity_id, "concepts_set": inserted} + + +# --------------------------------------------------------------------------- +# UI routes (served via ui_router, no prefix) +# --------------------------------------------------------------------------- + + +def _reindex_activities(soa_id: int): + """Re-number order_index and activity_uid after a delete.""" + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id FROM activity WHERE soa_id=? ORDER BY order_index", (soa_id,) + ) + ids = [r[0] for r in cur.fetchall()] + for idx, _id in enumerate(ids, start=1): + cur.execute("UPDATE activity SET order_index=? WHERE id=?", (idx, _id)) + cur.execute( + "UPDATE activity SET activity_uid = 'TMP_' || id WHERE soa_id=?", (soa_id,) + ) + cur.execute( + "UPDATE activity SET activity_uid = 'Activity_' || order_index WHERE soa_id=?", + (soa_id,), + ) + conn.commit() + conn.close() + + +@ui_router.get("/ui/soa/{soa_id}/activities", response_class=HTMLResponse) +def ui_list_activities(request: Request, soa_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id,name,order_index,activity_uid,label,description FROM activity WHERE soa_id=? ORDER BY order_index", + (soa_id,), + ) + activities = [ + { + "id": r[0], + "name": r[1], + "order_index": r[2], + "activity_uid": r[3], + "label": r[4], + "description": r[5], + } + for r in cur.fetchall() + ] + conn.close() + + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT study_id, study_label, study_description, name, created_at FROM soa WHERE id=?", + (soa_id,), + ) + meta_row = cur.fetchone() + conn.close() + study_id, study_label, study_description, study_name, study_created_at = meta_row + + return templates.TemplateResponse( + request, + "activities.html", + { + "request": request, + "soa_id": soa_id, + "activities": activities, + "study_id": study_id, + "study_label": study_label, + "study_description": study_description, + "study_name": study_name, + }, + ) + + +@ui_router.post("/ui/soa/{soa_id}/activities/create") +def ui_create_activity( + request: Request, + soa_id: int, + name: str | None = Form(None), + label: str | None = Form(None), + description: str | None = Form(None), +): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + payload = ActivityCreate(name=name or "", label=label, description=description) + add_activity(soa_id, payload) + return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/activities", status_code=303) + + +@ui_router.post("/ui/soa/{soa_id}/activities/{activity_id}/delete") +def ui_delete_activity_page(request: Request, soa_id: int, activity_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id,name,order_index FROM activity WHERE id=? AND soa_id=?", + (activity_id, soa_id), + ) + row = cur.fetchone() + if not row: + conn.close() + raise HTTPException(404, "Activity not found") + before = {"id": row[0], "name": row[1], "order_index": row[2]} + cur.execute( + "DELETE FROM matrix_cells WHERE soa_id=? AND activity_id=?", + (soa_id, activity_id), + ) + cur.execute("DELETE FROM activity WHERE id=?", (activity_id,)) + conn.commit() + conn.close() + _reindex_activities(soa_id) + _record_activity_audit(soa_id, "delete", activity_id, before=before, after=None) + return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/activities", status_code=303) diff --git a/src/soa_builder/web/templates/activities.html b/src/soa_builder/web/templates/activities.html new file mode 100644 index 0000000..1000787 --- /dev/null +++ b/src/soa_builder/web/templates/activities.html @@ -0,0 +1,132 @@ +{% extends 'base.html' %} +{% block content %} +

Activities for Study: {% if study_label %}{{ study_label }}{% else %}{{ study_name }}{% endif %}

+ +
+ + ← Return to Edit Page + +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + + + + + + + + + + + {% for a in activities %} + + + + + + + + + + + + + {% else %} + + {% endfor %} +
UIDOrderNameLabelDescriptionSaveDelete + +
{{ a.activity_uid }}{{ a.order_index }} + + +
+ +
+
+ + +
No activities yet.
+ + +{% endblock %} diff --git a/src/soa_builder/web/templates/base.html b/src/soa_builder/web/templates/base.html index 4ef0612..b29210a 100644 --- a/src/soa_builder/web/templates/base.html +++ b/src/soa_builder/web/templates/base.html @@ -10,6 +10,7 @@
Home {% if soa_id %} + Activities Schedule Timelines Study Timing Scheduled Activity Instances From dc33db9988248104b858a8d5a5239f2eb59b2f4a Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:55:06 -0500 Subject: [PATCH 04/17] Issue #106: ui_list_activities() now fetches activity concepts from the activity_concept table and the full biomedical concepts list (via lazy import of fetch_biomedical_concepts from app.py), passing both as template context --- src/soa_builder/web/templates/activities.html | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/soa_builder/web/templates/activities.html b/src/soa_builder/web/templates/activities.html index 1000787..c431034 100644 --- a/src/soa_builder/web/templates/activities.html +++ b/src/soa_builder/web/templates/activities.html @@ -8,6 +8,13 @@

Activities for Study: {% if study_label %}{{ study_label }}{% else %}{{ stud

+
+
+ +
+ (forces remote re-fetch & cache reset) +
+
@@ -28,12 +35,12 @@

Activities for Study: {% if study_label %}{{ study_label }}{% else %}{{ stud - - + + - @@ -56,6 +62,11 @@

Activities for Study: {% if study_label %}{{ study_label }}{% else %}{{ stud + {% set concepts_list = activity_concepts.get(a.id, []) %} + {% set selected_list = concepts_list %} + {% set selected_codes = concepts_list | map(attribute='code') | list %} + {% set activity_id = a.id %} + {% include 'concepts_cell.html' %}

{% else %} - + {% endfor %}
UIDOrderID Name Label Description SaveConcepts Delete
{{ a.activity_uid }}{{ a.order_index }}
No activities yet.
No activities yet.
From bf38c9db44f9bbe3c293f874d85d732c229b6fd9 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:55:59 -0500 Subject: [PATCH 05/17] Issue 106: ui_list_activities() now fetches activity concepts from the activity_concept table and the full biomedical concepts list (via lazy import of fetch_biomedical_concepts from app.py), passing both as template context --- src/soa_builder/web/routers/activities.py | 41 +++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/soa_builder/web/routers/activities.py b/src/soa_builder/web/routers/activities.py index baf8334..998b0d6 100644 --- a/src/soa_builder/web/routers/activities.py +++ b/src/soa_builder/web/routers/activities.py @@ -555,8 +555,33 @@ def ui_list_activities(request: Request, soa_id: int): } for r in cur.fetchall() ] + + # Fetch activity concepts for all activities in this SOA + activity_concepts: dict = {} + if _table_has_columns(cur, "activity_concept", ("soa_id",)): + cur.execute( + "SELECT activity_id, concept_code, concept_title FROM activity_concept WHERE soa_id=?", + (soa_id,), + ) + else: + activity_ids = [a["id"] for a in activities] + if activity_ids: + placeholders = ",".join("?" * len(activity_ids)) + cur.execute( + f"SELECT activity_id, concept_code, concept_title FROM activity_concept WHERE activity_id IN ({placeholders})", + activity_ids, + ) + else: + cur.execute("SELECT 1 WHERE 0") # no-op + for aid, code, title in cur.fetchall(): + activity_concepts.setdefault(aid, []).append({"code": code, "title": title}) conn.close() + # Fetch biomedical concepts list (lazy import to avoid circular dependency) + from ..app import fetch_biomedical_concepts as _app_fetch_concepts + + concepts = _app_fetch_concepts() + conn = _connect() cur = conn.cursor() cur.execute( @@ -574,6 +599,8 @@ def ui_list_activities(request: Request, soa_id: int): "request": request, "soa_id": soa_id, "activities": activities, + "activity_concepts": activity_concepts, + "concepts": concepts, "study_id": study_id, "study_label": study_label, "study_description": study_description, @@ -582,6 +609,20 @@ def ui_list_activities(request: Request, soa_id: int): ) +@ui_router.post("/ui/soa/{soa_id}/activities/concepts_refresh") +def ui_refresh_concepts_activities(request: Request, soa_id: int): + """Refresh biomedical concepts cache, then redirect back to activities page.""" + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + from ..app import fetch_biomedical_concepts as _app_fetch_concepts + + _app_fetch_concepts(force=True) + redirect_url = f"/ui/soa/{int(soa_id)}/activities" + if request.headers.get("HX-Request") == "true": + return HTMLResponse("", headers={"HX-Redirect": redirect_url}) + return RedirectResponse(url=redirect_url, status_code=303) + + @ui_router.post("/ui/soa/{soa_id}/activities/create") def ui_create_activity( request: Request, From 06e53643afc39df3384eea101a8c84179bbf0c10 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:47:08 -0500 Subject: [PATCH 06/17] Issue 106: Fixed column width --- src/soa_builder/web/templates/concepts_cell.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/soa_builder/web/templates/concepts_cell.html b/src/soa_builder/web/templates/concepts_cell.html index bdf2cc7..d404fb2 100644 --- a/src/soa_builder/web/templates/concepts_cell.html +++ b/src/soa_builder/web/templates/concepts_cell.html @@ -123,7 +123,7 @@ } .concepts-arrow { - margin-left: auto; + margin-left: 4px; font-size: 0.75em; } From d1099f85e18014c5183f42f15fcd25f16dccce12 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Thu, 19 Feb 2026 12:17:44 -0500 Subject: [PATCH 07/17] Fixed BC column widths --- src/soa_builder/web/templates/activities.html | 18 ++++++++++-------- .../web/templates/concepts_cell.html | 4 ++-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/soa_builder/web/templates/activities.html b/src/soa_builder/web/templates/activities.html index c431034..e425e80 100644 --- a/src/soa_builder/web/templates/activities.html +++ b/src/soa_builder/web/templates/activities.html @@ -35,14 +35,15 @@

Activities for Study: {% if study_label %}{{ study_label }}{% else %}{{ stud - - - - - - - - + + + + + + + + + {% set activity_id = a.id %} + {% include 'dss_cell.html' %} - + {% for inst in timeline_instances %} From d20222af8979010af9d40f71483309e4f44eba3f Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:48:40 -0500 Subject: [PATCH 12/17] Added collapsible DSS cell --- src/soa_builder/web/templates/dss_cell.html | 68 ++++++++++++++------- 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/src/soa_builder/web/templates/dss_cell.html b/src/soa_builder/web/templates/dss_cell.html index c6607fa..55cec79 100644 --- a/src/soa_builder/web/templates/dss_cell.html +++ b/src/soa_builder/web/templates/dss_cell.html @@ -2,28 +2,54 @@ + + From c5cd8eac01d338e4d3c98f31f24e171f31949469 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:00:58 -0500 Subject: [PATCH 13/17] Updated DSS column formatting to match Concepts; added concept name hover text --- src/soa_builder/web/templates/dss_cell.html | 25 ++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/soa_builder/web/templates/dss_cell.html b/src/soa_builder/web/templates/dss_cell.html index 55cec79..215d06f 100644 --- a/src/soa_builder/web/templates/dss_cell.html +++ b/src/soa_builder/web/templates/dss_cell.html @@ -17,13 +17,13 @@ diff --git a/src/soa_builder/web/templates/sdtm_specialization_detail.html b/src/soa_builder/web/templates/sdtm_specialization_detail.html index 076d583..77370a0 100644 --- a/src/soa_builder/web/templates/sdtm_specialization_detail.html +++ b/src/soa_builder/web/templates/sdtm_specialization_detail.html @@ -18,8 +18,57 @@

SDTM Dataset Specialization Detail

{% endif %} {% elif pretty_json %} + +{% if summary is defined and summary %} +

Summary

+
IDNameLabelDescriptionSaveConceptsDelete + IDNameLabelDescriptionSaveConceptsDSSDelete - + + {% else %} -
+
Matrix: {% if timeline_instances and timeline_instances[0].timeline_name %}{
ActivityConceptsConcepts {% set dss_concepts = activity_concepts.get(activity_id, []) %} {% if dss_concepts %} - {% for ac in dss_concepts %} -
- {{ ac.code }} -
- -
-
- {% endfor %} + {% set assigned_count = dss_concepts | selectattr('dss_title') | list | length %} + + +
+ Specializations + {% if assigned_count > 0 %} + {{ assigned_count }} + {% endif %} + +
+ + + {% else %} {% endif %}
+ {% for key, val in summary.items() %} + + + + + {% endfor %} +
{{ key }}{{ val }}
+{% endif %} + +{% if variables is defined and variables %} +

Variables

+ + + + + + + + + + + + + {% for v in variables %} + + + + + + + + + {% endfor %} + +
NameData Element ConceptOrdinalRoleData TypeAssigned Value / Codelist
{{ v.name or '' }}{{ v.dataElementConceptId or '' }}{{ v.ordinal or '' }}{{ v.role or '' }}{{ v.dataType or '' }} + {%- if v.assignedTerm is defined and v.assignedTerm -%} + {{ v.assignedTerm.value or v.assignedTerm.conceptId or '' }} + {%- elif v.valueList is defined and v.valueList -%} + {{ v.valueList | join(', ') }} + {%- elif v.codelist is defined and v.codelist -%} + {{ v.codelist.conceptId or '' }}{% if v.codelist.submissionValue %} ({{ v.codelist.submissionValue }}){% endif %} + {%- endif -%} +
+{% endif %} +
-
+
Raw JSON
{{ pretty_json }}
@@ -27,5 +76,8 @@

SDTM Dataset Specialization Detail

{% else %}

No JSON payload available.

{% endif %} -

Back to list

+

+ {% if back_url %}Back to Activities | {% endif %} + Back to list +

{% endblock %} From a5f947d061fcefbb04d34792e8f30faf8c20dfcf Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:50:47 -0500 Subject: [PATCH 15/17] Added additional columns to the variables table; added additional parent information to the summary table --- src/soa_builder/web/routers/activities.py | 31 +++++++++++++++++++ .../templates/sdtm_specialization_detail.html | 16 ++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/soa_builder/web/routers/activities.py b/src/soa_builder/web/routers/activities.py index 9256803..a25cd16 100644 --- a/src/soa_builder/web/routers/activities.py +++ b/src/soa_builder/web/routers/activities.py @@ -909,6 +909,37 @@ def ui_dss_detail(request: Request, soa_id: int, href: str = "", title: str = "" val = data.get(key) if val is not None and val != "": summary[key] = val + # Nested objects: check top-level and _links + links = data.get("_links", {}) + pbc = data.get("parentBiomedicalConcept") or links.get( + "parentBiomedicalConcept" + ) + if isinstance(pbc, dict): + parts = [] + if pbc.get("shortName"): + parts.append(pbc["shortName"]) + elif pbc.get("title"): + parts.append(pbc["title"]) + if pbc.get("conceptId"): + parts.append(f"({pbc['conceptId']})") + if parts: + summary["parentBiomedicalConcept"] = " ".join(parts) + ppkg = data.get("parentPackage") or links.get("parentPackage") + if isinstance(ppkg, dict): + parts = [] + if ppkg.get("name"): + parts.append(ppkg["name"]) + elif ppkg.get("title"): + parts.append(ppkg["title"]) + if ppkg.get("type"): + parts.append(f"[{ppkg['type']}]") + if parts: + summary["parentPackage"] = " ".join(parts) + ppkg_href = ppkg.get("href", "") + if ppkg_href and not ppkg_href.startswith("http"): + ppkg_href = f"https://api.library.cdisc.org{ppkg_href}" + if ppkg_href: + summary["parentPackageHref"] = ppkg_href return templates.TemplateResponse( request, diff --git a/src/soa_builder/web/templates/sdtm_specialization_detail.html b/src/soa_builder/web/templates/sdtm_specialization_detail.html index 77370a0..7cd6644 100644 --- a/src/soa_builder/web/templates/sdtm_specialization_detail.html +++ b/src/soa_builder/web/templates/sdtm_specialization_detail.html @@ -41,6 +41,12 @@

Variables

Ordinal Role Data Type + Length + Relationship + Mandatory Variable + Mandatory Value + Origin Type + Origin Source Assigned Value / Codelist @@ -52,6 +58,16 @@

Variables

{{ v.ordinal or '' }} {{ v.role or '' }} {{ v.dataType or '' }} + {{ v.length or '' }} + + {%- if v.relationship is defined and v.relationship -%} + {{ v.relationship.subject or '' }} {{ v.relationship.linkingPhrase or '' }} {{ v.relationship.predicateTerm or '' }} {{ v.relationship.object or '' }} + {%- endif -%} + + {{ v.mandatoryVariable if v.mandatoryVariable is defined else '' }} + {{ v.mandatoryValue if v.mandatoryValue is defined else '' }} + {{ v.originType or '' }} + {{ v.originSource or '' }} {%- if v.assignedTerm is defined and v.assignedTerm -%} {{ v.assignedTerm.value or v.assignedTerm.conceptId or '' }} From 9fd143e4a72bbb3d0e3270707eb04a1735073e8b Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:44:46 -0500 Subject: [PATCH 16/17] no payload written to stdout --- src/soa_builder/web/routers/activities.py | 18 ++++++++++++++++++ src/usdm/generate_activities.py | 5 ++++- src/usdm/generate_usdm.py | 5 ++++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/soa_builder/web/routers/activities.py b/src/soa_builder/web/routers/activities.py index a25cd16..231db3f 100644 --- a/src/soa_builder/web/routers/activities.py +++ b/src/soa_builder/web/routers/activities.py @@ -865,12 +865,30 @@ def ui_dss_detail(request: Request, soa_id: int, href: str = "", title: str = "" headers["Authorization"] = f"Bearer {api_key}" headers["api-key"] = api_key + _ALLOWED_CDISC_PREFIX = "https://api.library.cdisc.org/" + status = None error = None pretty_json = None raw_text_snippet = None data = None if href: + if not href.startswith(_ALLOWED_CDISC_PREFIX): + error = "Invalid href: only CDISC Library API URLs are permitted." + return templates.TemplateResponse( + "dss_detail.html", + { + "request": request, + "soa_id": soa_id, + "href": href, + "title": title, + "status": status, + "error": error, + "pretty_json": pretty_json, + "variables": [], + "summary": {}, + }, + ) try: resp = _requests.get(href, headers=headers, timeout=15) status = resp.status_code diff --git a/src/usdm/generate_activities.py b/src/usdm/generate_activities.py index 7671386..9155cff 100755 --- a/src/usdm/generate_activities.py +++ b/src/usdm/generate_activities.py @@ -134,7 +134,10 @@ def build_usdm_activities(soa_id: int) -> List[Dict[str, Any]]: payload = json.dumps(activities, indent=args.indent) if args.output in ("-", "/dev/stdout"): - sys.stdout.write(payload + "\n") + sys.stdout.write( + "Output suppressed: this document may contain sensitive data. " + "Use an explicit -o path to export.\n" + ) else: with open(args.output, "w", encoding="utf-8") as f: f.write(payload + "\n") diff --git a/src/usdm/generate_usdm.py b/src/usdm/generate_usdm.py index e93f834..0b20545 100644 --- a/src/usdm/generate_usdm.py +++ b/src/usdm/generate_usdm.py @@ -219,7 +219,10 @@ def _safe(label: str, fn, *args) -> List[Dict[str, Any]]: payload = json.dumps(document, indent=args.indent) if args.output in ("-", "/dev/stdout"): - sys.stdout.write(payload + "\n") + sys.stdout.write( + "Output suppressed: this document may contain sensitive data. " + "Use an explicit -o path to export.\n" + ) else: with open(args.output, "w", encoding="utf-8") as f: f.write(payload + "\n") From 5650862f9cd70e45c2c13916c634ae563f4da2db Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:02:56 -0500 Subject: [PATCH 17/17] No orphaned concept/DSS mappings will remain after an activity is deleted --- src/soa_builder/web/routers/activities.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/soa_builder/web/routers/activities.py b/src/soa_builder/web/routers/activities.py index 231db3f..4d1beaf 100644 --- a/src/soa_builder/web/routers/activities.py +++ b/src/soa_builder/web/routers/activities.py @@ -684,6 +684,10 @@ def ui_delete_activity_page(request: Request, soa_id: int, activity_id: int): "DELETE FROM matrix_cells WHERE soa_id=? AND activity_id=?", (soa_id, activity_id), ) + cur.execute( + "DELETE FROM activity_concept WHERE activity_id=? AND soa_id=?", + (activity_id, soa_id), + ) cur.execute("DELETE FROM activity WHERE id=?", (activity_id,)) conn.commit() conn.close()