From 71fbf698f96b9d061acffba5016c75a497774c9f Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:05:35 -0500 Subject: [PATCH 01/18] Added UI page for generation of USDM JSON assets --- src/soa_builder/web/app.py | 2 + src/soa_builder/web/routers/usdm_json.py | 121 +++++++++++++++++++ src/soa_builder/web/templates/base.html | 1 + src/soa_builder/web/templates/usdm_json.html | 36 ++++++ 4 files changed, 160 insertions(+) create mode 100644 src/soa_builder/web/routers/usdm_json.py create mode 100644 src/soa_builder/web/templates/usdm_json.html diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index dc3685f..dbffa01 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -74,6 +74,7 @@ from .routers import schedule_timelines as schedule_timelines_router from .routers import cells as cells_router from .routers import instances as instances_router +from .routers import usdm_json as usdm_json_router # Avoid binding visit helpers directly to allow fresh reloads in tests @@ -202,6 +203,7 @@ def _configure_logging(): app.include_router(schedule_timelines_router.router) app.include_router(rules_router.router) app.include_router(cells_router.router) +app.include_router(usdm_json_router.router) def _record_visit_audit( diff --git a/src/soa_builder/web/routers/usdm_json.py b/src/soa_builder/web/routers/usdm_json.py new file mode 100644 index 0000000..29378c5 --- /dev/null +++ b/src/soa_builder/web/routers/usdm_json.py @@ -0,0 +1,121 @@ +import io +import json +import logging +import os + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import HTMLResponse, StreamingResponse +from fastapi.templating import Jinja2Templates + +from ..db import _connect +from ..utils import soa_exists + +router = APIRouter() +logger = logging.getLogger("soa_builder.web.routers.usdm_json") +templates = Jinja2Templates( + directory=os.path.join(os.path.dirname(__file__), "..", "templates") +) + +_COMPONENTS = [ + ("full", "Full USDM Document", "usdm_full.json"), + ("arms", "Arms", "usdm_arms.json"), + ("activities", "Activities", "usdm_activities.json"), + ("elements", "Study Elements", "usdm_elements.json"), + ("encounters", "Encounters", "usdm_encounters.json"), + ("epochs", "Study Epochs", "usdm_epochs.json"), + ("schedule_timelines", "Schedule Timelines", "usdm_schedule_timelines.json"), + ("timings", "Timings", "usdm_timings.json"), + ("instances", "Scheduled Activity Instances", "usdm_instances.json"), + ("study_cells", "Study Cells", "usdm_study_cells.json"), +] + + +def _build(component: str, soa_id: int): + """Delegate to the appropriate usdm generator.""" + if component == "full": + from usdm.generate_usdm import build_usdm + + return build_usdm(soa_id) + if component == "arms": + from usdm.generate_arms import build_usdm_arms + + return build_usdm_arms(soa_id) + if component == "activities": + from usdm.generate_activities import build_usdm_activities + + return build_usdm_activities(soa_id) + if component == "elements": + from usdm.generate_elements import build_usdm_elements + + return build_usdm_elements(soa_id) + if component == "encounters": + from usdm.generate_encounters import build_usdm_encounters + + return build_usdm_encounters(soa_id) + if component == "epochs": + from usdm.generate_study_epochs import build_usdm_epochs + + return build_usdm_epochs(soa_id) + if component == "schedule_timelines": + from usdm.generate_schedule_timelines import build_usdm_schedule_timelines + + return build_usdm_schedule_timelines(soa_id) + if component == "timings": + from usdm.generate_study_timings import build_usdm_timings + + return build_usdm_timings(soa_id, None) + if component == "instances": + from usdm.generate_scheduled_activity_instances import build_usdm_instances + + return build_usdm_instances(soa_id, None) + if component == "study_cells": + from usdm.generate_study_cells import build_usdm_study_cells + + return build_usdm_study_cells(soa_id) + raise ValueError(f"Unknown component: {component}") + + +@router.get("/ui/soa/{soa_id}/usdm_json", response_class=HTMLResponse) +def ui_usdm_json(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 name, study_id, study_label FROM soa WHERE id=?", (soa_id,)) + row = cur.fetchone() + conn.close() + return templates.TemplateResponse( + request, + "usdm_json.html", + { + "soa_id": soa_id, + "study_name": row[0], + "study_id_value": row[1], + "study_label": row[2], + "components": _COMPONENTS, + }, + ) + + +@router.get("/soa/{soa_id}/usdm_json/{component}") +def download_usdm_component(soa_id: int, component: str): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + valid_keys = {c[0] for c in _COMPONENTS} + if component not in valid_keys: + raise HTTPException(400, f"Unknown component '{component}'") + try: + data = _build(component, soa_id) + except Exception as exc: + logger.exception( + "Failed to build USDM component %s for soa_id=%s", component, soa_id + ) + raise HTTPException(500, f"Failed to generate {component}: {exc}") from exc + filename = next(c[2] for c in _COMPONENTS if c[0] == component) + payload = json.dumps(data, indent=2) + "\n" + buf = io.BytesIO(payload.encode("utf-8")) + return StreamingResponse( + buf, + media_type="application/json", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) diff --git a/src/soa_builder/web/templates/base.html b/src/soa_builder/web/templates/base.html index cd95642..fddebc9 100644 --- a/src/soa_builder/web/templates/base.html +++ b/src/soa_builder/web/templates/base.html @@ -18,6 +18,7 @@ Encounters Elements Transition Rules + Generate USDM JSON {% endif %} Biomedical Concept Categories Biomedical Concepts diff --git a/src/soa_builder/web/templates/usdm_json.html b/src/soa_builder/web/templates/usdm_json.html new file mode 100644 index 0000000..c0b45c8 --- /dev/null +++ b/src/soa_builder/web/templates/usdm_json.html @@ -0,0 +1,36 @@ +{% extends 'base.html' %} +{% block content %} +
Study ID: {{ study_id_value }}
+{% endif %} + +| Component | +Download | +
|---|---|
| {{ label }} | ++ + Download JSON + + | +
;by7yDAVSD
z_^HToqt`xLFhs{tRUtRZTNLaOvsT`mzZ7B-zt>}6L=?YG7% %i
z36+hoV!^JdOABkk*Gz)iN$a5!-yF1z`EuDX^WtuZ2Bo!(Q%bs%hODutVI;yFDbng$
ziMK9`9K}Zt$0GJC@}%sl5ToH* sk+H*c$&BRDez1TXVwXkWbcSU$g9&5}<;V`R4R2uAN$sUjXzAnb8NFRol8QzM6
z2+h0OBgxgXX4IlNp{(qQ?p8Q~r35iKRojn}1WAF)Q0SMvl_QJ~7+UDjRZ}UOVu0I9
zhfPy#0Ht(cj+Mc|#Ks~RBLbFkXQw5?9XFASiX<6hxG(rU7}mAKso*0p$s>%4{@a}R
z_VVRUaoh+82R_)JRpIY|)as)T?o
zNE$o?rvdbHWVJW)d{cdYc_QCz1YgdE`o36G)Dxx_=}<>B%VICbS~QJ5G^f|f%p?WC
z$sVo+pL{`%#yylp1<6|z9h3-w9_SFiDWVGHE_2zt3z~%OR{Z;x1doCGYZU`*Yxq@)FecVkyKFU8ekN;hg)Y(2PR3th=-EP
z9GuhWw+0j9Xvc2A_*CCaSa_nBabl`BS!kD^G5TU~75nKo6xwdsp_>?YG3
zh38eG0&P*KH~IJXz3LrKD@>+kh^Q=nXbE;>=OB}mb%$`$;)W_(UNCXtA|6F}%&9L@
zg1b%e0~PVeM65bnZ|)hWKpdQeDNm0LR09`~ZifpfmF2&AVzN((iMvw-;0`dPwmT?|
zG?+F~!LLB=Ul~!;<5&5eWWrc61f6-Vr$6cf!#5uRa(kbcsE`O_=m^2v-Hf%2zbw(L
z{<8#BT`vH<+%cxkN8^*;Ho0plndut+<3V<%k*94;h}lBQ&L_)Y=*p0;rfM#RarR@9
zUDR#DDwE{nlCOB3UJt-f*DZtIrV5kvk25Tk&7GCGR;%UBL*>vPC!VY>Lv&A{pDNcv
zhA9&&lgx>EHg3d&u5~5FKZ`q1{CtF|#U*K`zJ+DE{CM~wQ
zCRt_#aS6br5Z#nz>(40ED(xM73rVqeTcjb82<0if0T&dB_AQ#WS6j?=BkY1+ZYL6k
zzG9f9KmY-QmOYClCS=xrMCuaBTvs4>A91y@j)bvbv7uF4QB0GUDBuMu-i7*+NIUD%
zt2sU;SxRkr%@Lv#6#@^rVbTqgsRq|~YJG&fcMb_%_g88>Zj^
Pipt_lGigbd%2D`Znl6zmyvZBP4UU)(GDbBb;N)M;JpF;%NZHDt(HFV{a`g1v?15
z)h{Qg7>xeW5U$MM6LT3$DH!UrX8ZPv{GJj`OrG3f>)4erj_E07^BZ(r35Cfle$%i?
zB1dl&9>B@-h=zSwM4I{xOY#ZkyTO&Xgvj?!+{8Ti3`KXrU!wloH)15aC5N34
Wt9$>mcDb93z?Tp0-q-@3XZp$#{3#o&uc~8fFteR0Z88*oR`qe
zA8v~DI>3aZz0-cl3Fzi7{${J=*tq`fZlG
~N1WZcFBsA0aN~Hn29rA9
z)9pJu{jw+B@C+vm><9|djkF~Q>)SYZuVlk4aYyt-i@{K~zm3;TrMu9cC|gvjHOoc)
zJME2IKIMWpFOJ
ThiSM6ywV7M!Kg-7_iXYpB
zv