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 %} +
| UID | +Order | +Name | +Label | +Description | +Save | +Delete | ++ + | +
|---|---|---|---|---|---|---|---|
| + + | ++ + + | +||||||
| No activities yet. | |||||||