From 95d1247c12908a416fae108a02fbe0456e246736 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:30:24 -0400 Subject: [PATCH 01/39] Issue #199: SOA Matrix only displays ScheduledActivityInstance's that are assigned to the selected timeline --- src/soa_builder/web/app.py | 20 ++++++++++++++++++++ src/soa_builder/web/templates/edit.html | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index 01ad344..dfad151 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -4563,6 +4563,25 @@ def ui_edit(request: Request, soa_id: int): instances_by_timeline[timeline_key] = [] instances_by_timeline[timeline_key].append(inst) + # Activities per timeline: an activity is shown in timeline T's matrix + # if any matrix_cells row connects it to an instance whose + # member_of_timeline == T. The instance->timeline link (set on the + # study_timing page) is the authoritative criterion. + instance_timeline = { + inst["id"]: (inst.get("member_of_timeline") or "unassigned") + for inst in instances + } + activity_ids_by_timeline: dict = {tl: set() for tl in instances_by_timeline.keys()} + for c in cells: + tl = instance_timeline.get(c["instance_id"]) + if tl is None or tl not in activity_ids_by_timeline: + continue + activity_ids_by_timeline[tl].add(c["activity_id"]) + activities_by_timeline: dict = { + tl: [a for a in activities_page if a["id"] in ids] + for tl, ids in activity_ids_by_timeline.items() + } + # Determine default timeline (main_timeline or first available) default_timeline = None for tl in timelines: @@ -4641,6 +4660,7 @@ def ui_edit(request: Request, soa_id: int): "timings": timings, "timelines": timelines, "instances_by_timeline": instances_by_timeline, + "activities_by_timeline": activities_by_timeline, "default_timeline": default_timeline, "footnotes": footnotes, "superscript_map": superscript_map, diff --git a/src/soa_builder/web/templates/edit.html b/src/soa_builder/web/templates/edit.html index 78b7f52..48938e5 100644 --- a/src/soa_builder/web/templates/edit.html +++ b/src/soa_builder/web/templates/edit.html @@ -256,7 +256,7 @@

Matrix: {% if timeline_instances and timeline_instances[0].timeline_name %}{ {% endfor %} - {% for a in activities %} + {% for a in activities_by_timeline.get(timeline_uid, []) %} {% if a.label %}{{ a.label }}{% else %}{{ a.name }}{% endif %} {% set concepts_list = activity_concepts.get(a.id, []) %} From e875fde0652ef37c3b2d41e18f785372af4bd0fa Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:03:24 -0400 Subject: [PATCH 02/39] Added objectives and endpoints creation --- src/soa_builder/web/app.py | 83 ++++ src/soa_builder/web/audit.py | 54 +++ src/soa_builder/web/migrate_database.py | 100 +++++ src/soa_builder/web/routers/endpoints.py | 414 ++++++++++++++++++ src/soa_builder/web/routers/objectives.py | 408 +++++++++++++++++ src/soa_builder/web/schemas.py | 36 ++ .../web/templates/_objectives_section.html | 209 +++++++++ src/soa_builder/web/templates/edit.html | 3 + tests/test_routers_endpoints.py | 269 ++++++++++++ tests/test_routers_objectives.py | 228 ++++++++++ 10 files changed, 1804 insertions(+) create mode 100644 src/soa_builder/web/routers/endpoints.py create mode 100644 src/soa_builder/web/routers/objectives.py create mode 100644 src/soa_builder/web/templates/_objectives_section.html create mode 100644 tests/test_routers_endpoints.py create mode 100644 tests/test_routers_objectives.py diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index dfad151..1b026aa 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -83,6 +83,10 @@ _migrate_activity_concept_dss_add_display, _migrate_drop_protocol_terminology_tables, _migrate_drop_ddf_terminology_tables, + _migrate_add_objective_table, + _migrate_add_objective_audit_table, + _migrate_add_endpoint_table, + _migrate_add_endpoint_audit_table, ) from .routers import activities as activities_router from .routers import arms as arms_router @@ -113,6 +117,8 @@ from .routers import ( ddf_controlled_terminology as ddf_controlled_terminology_router, ) +from .routers import objectives as objectives_router +from .routers import endpoints as endpoints_router from .audit import _record_element_audit @@ -246,6 +252,10 @@ def _configure_logging(): _migrate_drop_ddf_terminology_tables() _migrate_add_activity_concept_dss_table() _migrate_activity_concept_dss_add_display() +_migrate_add_objective_table() +_migrate_add_objective_audit_table() +_migrate_add_endpoint_table() +_migrate_add_endpoint_audit_table() # Include routers @@ -278,6 +288,10 @@ def _configure_logging(): app.include_router(define_xml_terminology_router.router) app.include_router(protocol_controlled_terminology_router.router) app.include_router(ddf_controlled_terminology_router.router) +app.include_router(objectives_router.router) +app.include_router(objectives_router.ui_router) +app.include_router(endpoints_router.router) +app.include_router(endpoints_router.ui_router) def _record_visit_audit( @@ -4623,6 +4637,70 @@ def ui_edit(request: Request, soa_id: int): schedule_timelines_options = get_schedule_timeline(soa_id) instance_options = get_scheduled_activity_instance(soa_id) + # Objectives + Endpoints with DDF level decode lookups + c188725_map = _get_ddf_ct_codelist_map("C188725") + c188726_map = _get_ddf_ct_codelist_map("C188726") + objective_level_options = sorted({v for v in c188725_map.values() if v}) + endpoint_level_options = sorted({v for v in c188726_map.values() if v}) + conn_obj = _connect() + cur_obj = conn_obj.cursor() + cur_obj.execute( + "SELECT code_uid, code FROM code_association " + "WHERE soa_id=? AND codelist_code IN ('C188725','C188726')", + (soa_id,), + ) + level_code_to_sv: dict = {} + for code_uid, code_val in cur_obj.fetchall(): + sv = c188725_map.get(code_val) or c188726_map.get(code_val) or "" + level_code_to_sv[code_uid] = sv + cur_obj.execute( + "SELECT id,objective_uid,name,label,description,text," + "level_code_uid,order_index " + "FROM objective WHERE soa_id=? ORDER BY order_index, id", + (soa_id,), + ) + objectives = [ + { + "id": r[0], + "objective_uid": r[1], + "name": r[2], + "label": r[3], + "description": r[4], + "text": r[5], + "level_code_uid": r[6], + "level": level_code_to_sv.get(r[6], ""), + "order_index": r[7], + } + for r in cur_obj.fetchall() + ] + cur_obj.execute( + "SELECT id,endpoint_uid,objective_uid,name,label,description," + "text,purpose,level_code_uid,order_index " + "FROM endpoint WHERE soa_id=? ORDER BY order_index, id", + (soa_id,), + ) + endpoints_by_objective: dict = {} + orphan_endpoints: list = [] + for r in cur_obj.fetchall(): + ep = { + "id": r[0], + "endpoint_uid": r[1], + "objective_uid": r[2], + "name": r[3], + "label": r[4], + "description": r[5], + "text": r[6], + "purpose": r[7], + "level_code_uid": r[8], + "level": level_code_to_sv.get(r[8], ""), + "order_index": r[9], + } + if ep["objective_uid"]: + endpoints_by_objective.setdefault(ep["objective_uid"], []).append(ep) + else: + orphan_endpoints.append(ep) + conn_obj.close() + return templates.TemplateResponse( request, "edit.html", @@ -4664,6 +4742,11 @@ def ui_edit(request: Request, soa_id: int): "default_timeline": default_timeline, "footnotes": footnotes, "superscript_map": superscript_map, + "objectives": objectives, + "endpoints_by_objective": endpoints_by_objective, + "orphan_endpoints": orphan_endpoints, + "objective_level_options": objective_level_options, + "endpoint_level_options": endpoint_level_options, }, ) diff --git a/src/soa_builder/web/audit.py b/src/soa_builder/web/audit.py index a45a7db..70bea7a 100644 --- a/src/soa_builder/web/audit.py +++ b/src/soa_builder/web/audit.py @@ -419,3 +419,57 @@ def _record_footnote_audit( conn.close() except Exception as e: logger.warning("Failed recording footnote audit: %s", e) + + +def _record_objective_audit( + soa_id: int, + action: str, + objective_id: Optional[int], + before: Optional[Dict[str, Any]] = None, + after: Optional[Dict[str, Any]] = None, +): + try: + conn = _connect() + cur = conn.cursor() + cur.execute( + "INSERT INTO objective_audit (soa_id, objective_id, action, before_json, after_json, performed_at) VALUES (?,?,?,?,?,?)", + ( + soa_id, + objective_id, + action, + json.dumps(before) if before else None, + json.dumps(after) if after else None, + datetime.now(timezone.utc).isoformat(), + ), + ) + conn.commit() + conn.close() + except Exception as e: + logger.warning("Failed recording objective audit: %s", e) + + +def _record_endpoint_audit( + soa_id: int, + action: str, + endpoint_id: Optional[int], + before: Optional[Dict[str, Any]] = None, + after: Optional[Dict[str, Any]] = None, +): + try: + conn = _connect() + cur = conn.cursor() + cur.execute( + "INSERT INTO endpoint_audit (soa_id, endpoint_id, action, before_json, after_json, performed_at) VALUES (?,?,?,?,?,?)", + ( + soa_id, + endpoint_id, + action, + json.dumps(before) if before else None, + json.dumps(after) if after else None, + datetime.now(timezone.utc).isoformat(), + ), + ) + conn.commit() + conn.close() + except Exception as e: + logger.warning("Failed recording endpoint audit: %s", e) diff --git a/src/soa_builder/web/migrate_database.py b/src/soa_builder/web/migrate_database.py index d929949..49ff444 100644 --- a/src/soa_builder/web/migrate_database.py +++ b/src/soa_builder/web/migrate_database.py @@ -1750,3 +1750,103 @@ def _migrate_drop_ddf_terminology_tables(): conn.close() except Exception as e: logger.warning("_migrate_drop_ddf_terminology_tables failed: %s", e) + + +def _migrate_add_objective_table(): + """Create the objective table for USDM study objectives.""" + try: + conn = _connect() + cur = conn.cursor() + cur.execute( + """CREATE TABLE IF NOT EXISTS objective ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + soa_id INTEGER NOT NULL, + objective_uid TEXT NOT NULL, + name TEXT NOT NULL, + label TEXT, + description TEXT, + text TEXT, + level_code_uid TEXT, + order_index INTEGER, + UNIQUE(soa_id, objective_uid) + )""" + ) + conn.commit() + conn.close() + logger.info("_migrate_add_objective_table created objective table") + except Exception as e: + logger.warning("_migrate_add_objective_table failed: %s", e) + + +def _migrate_add_objective_audit_table(): + """Create objective_audit table for tracking objective mutations.""" + try: + conn = _connect() + cur = conn.cursor() + cur.execute( + """CREATE TABLE IF NOT EXISTS objective_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + soa_id INTEGER NOT NULL, + objective_id INTEGER, + action TEXT NOT NULL, + before_json TEXT, + after_json TEXT, + performed_at TEXT NOT NULL + )""" + ) + conn.commit() + conn.close() + logger.info("_migrate_add_objective_audit_table created objective_audit table") + except Exception as e: + logger.warning("_migrate_add_objective_audit_table failed: %s", e) + + +def _migrate_add_endpoint_table(): + """Create the endpoint table for USDM study endpoints.""" + try: + conn = _connect() + cur = conn.cursor() + cur.execute( + """CREATE TABLE IF NOT EXISTS endpoint ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + soa_id INTEGER NOT NULL, + endpoint_uid TEXT NOT NULL, + objective_uid TEXT, + name TEXT NOT NULL, + label TEXT, + description TEXT, + text TEXT, + purpose TEXT, + level_code_uid TEXT, + order_index INTEGER, + UNIQUE(soa_id, endpoint_uid) + )""" + ) + conn.commit() + conn.close() + logger.info("_migrate_add_endpoint_table created endpoint table") + except Exception as e: + logger.warning("_migrate_add_endpoint_table failed: %s", e) + + +def _migrate_add_endpoint_audit_table(): + """Create endpoint_audit table for tracking endpoint mutations.""" + try: + conn = _connect() + cur = conn.cursor() + cur.execute( + """CREATE TABLE IF NOT EXISTS endpoint_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + soa_id INTEGER NOT NULL, + endpoint_id INTEGER, + action TEXT NOT NULL, + before_json TEXT, + after_json TEXT, + performed_at TEXT NOT NULL + )""" + ) + conn.commit() + conn.close() + logger.info("_migrate_add_endpoint_audit_table created endpoint_audit table") + except Exception as e: + logger.warning("_migrate_add_endpoint_audit_table failed: %s", e) diff --git a/src/soa_builder/web/routers/endpoints.py b/src/soa_builder/web/routers/endpoints.py new file mode 100644 index 0000000..c4a99e6 --- /dev/null +++ b/src/soa_builder/web/routers/endpoints.py @@ -0,0 +1,414 @@ +import json +import logging + +from fastapi import APIRouter, Form, HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse + +from ..audit import _record_endpoint_audit +from ..db import _connect +from ..schemas import EndpointCreate, EndpointUpdate +from ..utils import ( + get_latest_ddf_ct_href, + get_next_code_uid, + soa_exists, +) + +router = APIRouter(prefix="/soa/{soa_id}") +ui_router = APIRouter() +logger = logging.getLogger("soa_builder.web.routers.endpoints") + +_ENDPOINT_LEVEL_CODELIST = "C188726" + + +def _next_endpoint_uid(cur, soa_id: int) -> str: + """Return next Endpoint_N UID, never reusing deleted UIDs.""" + max_n = 0 + cur.execute( + "SELECT endpoint_uid FROM endpoint WHERE soa_id=? " + "AND endpoint_uid LIKE 'Endpoint_%'", + (soa_id,), + ) + for (uid,) in cur.fetchall(): + if isinstance(uid, str) and uid.startswith("Endpoint_"): + try: + n = int(uid.split("_")[-1]) + if n > max_n: + max_n = n + except (ValueError, IndexError): + pass + cur.execute( + "SELECT before_json, after_json FROM endpoint_audit WHERE soa_id=?", + (soa_id,), + ) + for before_raw, after_raw in cur.fetchall(): + for raw in (before_raw, after_raw): + if not raw: + continue + try: + uid = json.loads(raw).get("endpoint_uid", "") + if isinstance(uid, str) and uid.startswith("Endpoint_"): + n = int(uid.split("_")[-1]) + if n > max_n: + max_n = n + except Exception: + pass + return f"Endpoint_{max_n + 1}" + + +def _row_to_dict(row) -> dict: + keys = [ + "id", + "soa_id", + "endpoint_uid", + "objective_uid", + "name", + "label", + "description", + "text", + "purpose", + "level_code_uid", + "order_index", + ] + return dict(zip(keys, row)) + + +def _objective_exists(cur, soa_id: int, objective_uid: str) -> bool: + cur.execute( + "SELECT 1 FROM objective WHERE soa_id=? AND objective_uid=?", + (soa_id, objective_uid), + ) + return cur.fetchone() is not None + + +def _insert_level_code(cur, soa_id: int, submission_value: str) -> str: + code_uid = get_next_code_uid(cur, soa_id) + slug = get_latest_ddf_ct_href() or "" + codelist_table = f"/mdr/ct/packages/{slug}" if slug else "/mdr/ct/packages" + cur.execute( + "INSERT INTO code_association " + "(soa_id, code_uid, codelist_table, codelist_code, code) " + "VALUES (?,?,?,?,?)", + ( + soa_id, + code_uid, + codelist_table, + _ENDPOINT_LEVEL_CODELIST, + submission_value, + ), + ) + return code_uid + + +def _delete_level_code(cur, soa_id: int, code_uid: str | None) -> None: + if not code_uid: + return + cur.execute( + "DELETE FROM code_association WHERE soa_id=? AND code_uid=?", + (soa_id, code_uid), + ) + + +# --------------------------------------------------------------------------- +# JSON API endpoints +# --------------------------------------------------------------------------- + + +@router.get("/endpoints", response_class=JSONResponse) +def list_endpoints(soa_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id,soa_id,endpoint_uid,objective_uid,name,label," + "description,text,purpose,level_code_uid,order_index " + "FROM endpoint WHERE soa_id=? ORDER BY order_index, id", + (soa_id,), + ) + rows = [_row_to_dict(r) for r in cur.fetchall()] + conn.close() + return JSONResponse(rows) + + +@router.post("/endpoints", response_class=JSONResponse) +def create_endpoint(soa_id: int, body: EndpointCreate): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + name = (body.name or "").strip() + level = (body.level or "").strip() + objective_uid = (body.objective_uid or "").strip() + if not name: + raise HTTPException(400, "Endpoint name required") + if not level: + raise HTTPException(400, "Endpoint level required") + if not objective_uid: + raise HTTPException(400, "Parent objective_uid required") + + conn = _connect() + cur = conn.cursor() + if not _objective_exists(cur, soa_id, objective_uid): + conn.close() + raise HTTPException(400, f"Objective {objective_uid!r} not found for this SOA") + + cur.execute( + "SELECT COALESCE(MAX(order_index),0) FROM endpoint WHERE soa_id=?", + (soa_id,), + ) + next_ord = (cur.fetchone() or [0])[0] + 1 + endpoint_uid = _next_endpoint_uid(cur, soa_id) + level_code_uid = _insert_level_code(cur, soa_id, level) + + label = (body.label or "").strip() or None + description = (body.description or "").strip() or None + text = (body.text or "").strip() or None + purpose = (body.purpose or "").strip() or None + + cur.execute( + "INSERT INTO endpoint " + "(soa_id,endpoint_uid,objective_uid,name,label,description," + "text,purpose,level_code_uid,order_index) " + "VALUES (?,?,?,?,?,?,?,?,?,?)", + ( + soa_id, + endpoint_uid, + objective_uid, + name, + label, + description, + text, + purpose, + level_code_uid, + next_ord, + ), + ) + endpoint_id = cur.lastrowid + conn.commit() + conn.close() + + after = { + "id": endpoint_id, + "endpoint_uid": endpoint_uid, + "objective_uid": objective_uid, + "name": name, + "label": label, + "description": description, + "text": text, + "purpose": purpose, + "level_code_uid": level_code_uid, + "level": level, + "order_index": next_ord, + } + _record_endpoint_audit(soa_id, "create", endpoint_id, before=None, after=after) + return JSONResponse(after, status_code=201) + + +@router.patch("/endpoints/{endpoint_id}", response_class=JSONResponse) +def update_endpoint(soa_id: int, endpoint_id: int, body: EndpointUpdate): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id,soa_id,endpoint_uid,objective_uid,name,label," + "description,text,purpose,level_code_uid,order_index " + "FROM endpoint WHERE id=? AND soa_id=?", + (endpoint_id, soa_id), + ) + row = cur.fetchone() + if not row: + conn.close() + raise HTTPException(404, "Endpoint not found") + before = _row_to_dict(row) + + new_objective_uid = before["objective_uid"] + if body.objective_uid is not None: + candidate = body.objective_uid.strip() or None + if candidate is not None and not _objective_exists(cur, soa_id, candidate): + conn.close() + raise HTTPException(400, f"Objective {candidate!r} not found for this SOA") + new_objective_uid = candidate + + new_name = body.name if body.name is not None else before["name"] + new_label = body.label if body.label is not None else before["label"] + new_desc = ( + body.description if body.description is not None else before["description"] + ) + new_text = body.text if body.text is not None else before["text"] + new_purpose = body.purpose if body.purpose is not None else before["purpose"] + + new_level_code_uid = before["level_code_uid"] + if body.level is not None: + new_level = body.level.strip() + if not new_level: + conn.close() + raise HTTPException(400, "Endpoint level cannot be empty") + if before["level_code_uid"]: + cur.execute( + "UPDATE code_association SET code=? WHERE soa_id=? AND code_uid=?", + (new_level, soa_id, before["level_code_uid"]), + ) + else: + new_level_code_uid = _insert_level_code(cur, soa_id, new_level) + + cur.execute( + "UPDATE endpoint SET objective_uid=?, name=?, label=?, " + "description=?, text=?, purpose=?, level_code_uid=? " + "WHERE id=? AND soa_id=?", + ( + new_objective_uid, + new_name, + (new_label or None) if new_label is not None else None, + (new_desc or None) if new_desc is not None else None, + (new_text or None) if new_text is not None else None, + (new_purpose or None) if new_purpose is not None else None, + new_level_code_uid, + endpoint_id, + soa_id, + ), + ) + conn.commit() + conn.close() + + after = { + **before, + "objective_uid": new_objective_uid, + "name": new_name, + "label": new_label, + "description": new_desc, + "text": new_text, + "purpose": new_purpose, + "level_code_uid": new_level_code_uid, + } + _record_endpoint_audit(soa_id, "update", endpoint_id, before=before, after=after) + return JSONResponse(after) + + +@router.delete("/endpoints/{endpoint_id}", response_class=JSONResponse) +def delete_endpoint(soa_id: int, endpoint_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id,soa_id,endpoint_uid,objective_uid,name,label," + "description,text,purpose,level_code_uid,order_index " + "FROM endpoint WHERE id=? AND soa_id=?", + (endpoint_id, soa_id), + ) + row = cur.fetchone() + if not row: + conn.close() + raise HTTPException(404, "Endpoint not found") + before = _row_to_dict(row) + + _delete_level_code(cur, soa_id, before["level_code_uid"]) + cur.execute( + "DELETE FROM endpoint WHERE id=? AND soa_id=?", + (endpoint_id, soa_id), + ) + + # Reindex remaining endpoints + cur.execute( + "SELECT id FROM endpoint WHERE soa_id=? ORDER BY order_index, id", + (soa_id,), + ) + remaining = [r[0] for r in cur.fetchall()] + for idx, eid in enumerate(remaining, start=1): + cur.execute("UPDATE endpoint SET order_index=? WHERE id=?", (idx, eid)) + conn.commit() + conn.close() + + _record_endpoint_audit(soa_id, "delete", endpoint_id, before=before, after=None) + return JSONResponse({"deleted": endpoint_id}) + + +# --------------------------------------------------------------------------- +# UI form endpoints +# --------------------------------------------------------------------------- + + +@ui_router.post("/ui/soa/{soa_id}/endpoints/create", response_class=HTMLResponse) +def ui_create_endpoint( + request: Request, + soa_id: int, + name: str = Form(...), + level: str = Form(...), + objective_uid: str = Form(...), + label: str | None = Form(None), + description: str | None = Form(None), + text: str | None = Form(None), + purpose: str | None = Form(None), +): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + create_endpoint( + soa_id, + EndpointCreate( + name=name, + level=level, + objective_uid=objective_uid, + label=label, + description=description, + text=text, + purpose=purpose, + ), + ) + redirect_url = f"/ui/soa/{soa_id}/edit" + if request.headers.get("HX-Request") == "true": + return HTMLResponse("", headers={"HX-Redirect": redirect_url}) + return RedirectResponse(redirect_url, status_code=303) + + +@ui_router.post( + "/ui/soa/{soa_id}/endpoints/{endpoint_id}/update", + response_class=HTMLResponse, +) +def ui_update_endpoint( + request: Request, + soa_id: int, + endpoint_id: int, + name: str | None = Form(None), + level: str | None = Form(None), + objective_uid: str | None = Form(None), + label: str | None = Form(None), + description: str | None = Form(None), + text: str | None = Form(None), + purpose: str | None = Form(None), +): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + update_endpoint( + soa_id, + endpoint_id, + EndpointUpdate( + name=name, + level=level, + objective_uid=objective_uid, + label=label, + description=description, + text=text, + purpose=purpose, + ), + ) + redirect_url = f"/ui/soa/{soa_id}/edit" + if request.headers.get("HX-Request") == "true": + return HTMLResponse("", headers={"HX-Redirect": redirect_url}) + return RedirectResponse(redirect_url, status_code=303) + + +@ui_router.post( + "/ui/soa/{soa_id}/endpoints/{endpoint_id}/delete", + response_class=HTMLResponse, +) +def ui_delete_endpoint( + request: Request, + soa_id: int, + endpoint_id: int, +): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + delete_endpoint(soa_id, endpoint_id) + redirect_url = f"/ui/soa/{soa_id}/edit" + if request.headers.get("HX-Request") == "true": + return HTMLResponse("", headers={"HX-Redirect": redirect_url}) + return RedirectResponse(redirect_url, status_code=303) diff --git a/src/soa_builder/web/routers/objectives.py b/src/soa_builder/web/routers/objectives.py new file mode 100644 index 0000000..73381d3 --- /dev/null +++ b/src/soa_builder/web/routers/objectives.py @@ -0,0 +1,408 @@ +import json +import logging + +from fastapi import APIRouter, Form, HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse + +from ..audit import _record_endpoint_audit, _record_objective_audit +from ..db import _connect +from ..schemas import ObjectiveCreate, ObjectiveUpdate +from ..utils import ( + get_latest_ddf_ct_href, + get_next_code_uid, + soa_exists, +) + +router = APIRouter(prefix="/soa/{soa_id}") +ui_router = APIRouter() +logger = logging.getLogger("soa_builder.web.routers.objectives") + +_OBJECTIVE_LEVEL_CODELIST = "C188725" + + +def _next_objective_uid(cur, soa_id: int) -> str: + """Return next Objective_N UID, never reusing deleted UIDs.""" + max_n = 0 + cur.execute( + "SELECT objective_uid FROM objective WHERE soa_id=? " + "AND objective_uid LIKE 'Objective_%'", + (soa_id,), + ) + for (uid,) in cur.fetchall(): + if isinstance(uid, str) and uid.startswith("Objective_"): + try: + n = int(uid.split("_")[-1]) + if n > max_n: + max_n = n + except (ValueError, IndexError): + pass + cur.execute( + "SELECT before_json, after_json FROM objective_audit WHERE soa_id=?", + (soa_id,), + ) + for before_raw, after_raw in cur.fetchall(): + for raw in (before_raw, after_raw): + if not raw: + continue + try: + uid = json.loads(raw).get("objective_uid", "") + if isinstance(uid, str) and uid.startswith("Objective_"): + n = int(uid.split("_")[-1]) + if n > max_n: + max_n = n + except Exception: + pass + return f"Objective_{max_n + 1}" + + +def _row_to_dict(row) -> dict: + keys = [ + "id", + "soa_id", + "objective_uid", + "name", + "label", + "description", + "text", + "level_code_uid", + "order_index", + ] + return dict(zip(keys, row)) + + +def _insert_level_code(cur, soa_id: int, submission_value: str) -> str: + """Insert a code_association row for the objective level and return + the generated Code_N UID.""" + code_uid = get_next_code_uid(cur, soa_id) + slug = get_latest_ddf_ct_href() or "" + codelist_table = f"/mdr/ct/packages/{slug}" if slug else "/mdr/ct/packages" + cur.execute( + "INSERT INTO code_association " + "(soa_id, code_uid, codelist_table, codelist_code, code) " + "VALUES (?,?,?,?,?)", + ( + soa_id, + code_uid, + codelist_table, + _OBJECTIVE_LEVEL_CODELIST, + submission_value, + ), + ) + return code_uid + + +def _delete_level_code(cur, soa_id: int, code_uid: str | None) -> None: + if not code_uid: + return + cur.execute( + "DELETE FROM code_association WHERE soa_id=? AND code_uid=?", + (soa_id, code_uid), + ) + + +# --------------------------------------------------------------------------- +# JSON API endpoints +# --------------------------------------------------------------------------- + + +@router.get("/objectives", response_class=JSONResponse) +def list_objectives(soa_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id,soa_id,objective_uid,name,label,description,text," + "level_code_uid,order_index " + "FROM objective WHERE soa_id=? ORDER BY order_index, id", + (soa_id,), + ) + rows = [_row_to_dict(r) for r in cur.fetchall()] + conn.close() + return JSONResponse(rows) + + +@router.post("/objectives", response_class=JSONResponse) +def create_objective(soa_id: int, body: ObjectiveCreate): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + name = (body.name or "").strip() + level = (body.level or "").strip() + if not name: + raise HTTPException(400, "Objective name required") + if not level: + raise HTTPException(400, "Objective level required") + + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT COALESCE(MAX(order_index),0) FROM objective WHERE soa_id=?", + (soa_id,), + ) + next_ord = (cur.fetchone() or [0])[0] + 1 + objective_uid = _next_objective_uid(cur, soa_id) + level_code_uid = _insert_level_code(cur, soa_id, level) + + label = (body.label or "").strip() or None + description = (body.description or "").strip() or None + text = (body.text or "").strip() or None + + cur.execute( + "INSERT INTO objective " + "(soa_id,objective_uid,name,label,description,text," + "level_code_uid,order_index) VALUES (?,?,?,?,?,?,?,?)", + ( + soa_id, + objective_uid, + name, + label, + description, + text, + level_code_uid, + next_ord, + ), + ) + objective_id = cur.lastrowid + conn.commit() + conn.close() + + after = { + "id": objective_id, + "objective_uid": objective_uid, + "name": name, + "label": label, + "description": description, + "text": text, + "level_code_uid": level_code_uid, + "level": level, + "order_index": next_ord, + } + _record_objective_audit(soa_id, "create", objective_id, before=None, after=after) + return JSONResponse(after, status_code=201) + + +@router.patch("/objectives/{objective_id}", response_class=JSONResponse) +def update_objective(soa_id: int, objective_id: int, body: ObjectiveUpdate): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id,soa_id,objective_uid,name,label,description,text," + "level_code_uid,order_index " + "FROM objective WHERE id=? AND soa_id=?", + (objective_id, soa_id), + ) + row = cur.fetchone() + if not row: + conn.close() + raise HTTPException(404, "Objective not found") + before = _row_to_dict(row) + + new_name = body.name if body.name is not None else before["name"] + new_label = body.label if body.label is not None else before["label"] + new_desc = ( + body.description if body.description is not None else before["description"] + ) + new_text = body.text if body.text is not None else before["text"] + + new_level_code_uid = before["level_code_uid"] + if body.level is not None: + new_level = body.level.strip() + if not new_level: + conn.close() + raise HTTPException(400, "Objective level cannot be empty") + if before["level_code_uid"]: + # Update the submission value in the existing Code_N row. + cur.execute( + "UPDATE code_association SET code=? WHERE soa_id=? AND code_uid=?", + (new_level, soa_id, before["level_code_uid"]), + ) + else: + new_level_code_uid = _insert_level_code(cur, soa_id, new_level) + + cur.execute( + "UPDATE objective SET name=?, label=?, description=?, text=?, " + "level_code_uid=? WHERE id=? AND soa_id=?", + ( + new_name, + (new_label or None) if new_label is not None else None, + (new_desc or None) if new_desc is not None else None, + (new_text or None) if new_text is not None else None, + new_level_code_uid, + objective_id, + soa_id, + ), + ) + conn.commit() + conn.close() + + after = { + **before, + "name": new_name, + "label": new_label, + "description": new_desc, + "text": new_text, + "level_code_uid": new_level_code_uid, + } + _record_objective_audit(soa_id, "update", objective_id, before=before, after=after) + return JSONResponse(after) + + +@router.delete("/objectives/{objective_id}", response_class=JSONResponse) +def delete_objective(soa_id: int, objective_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id,soa_id,objective_uid,name,label,description,text," + "level_code_uid,order_index " + "FROM objective WHERE id=? AND soa_id=?", + (objective_id, soa_id), + ) + row = cur.fetchone() + if not row: + conn.close() + raise HTTPException(404, "Objective not found") + before = _row_to_dict(row) + + # Orphan child endpoints: set objective_uid to NULL + orphaned: list[dict] = [] + cur.execute( + "SELECT id,endpoint_uid,objective_uid FROM endpoint " + "WHERE soa_id=? AND objective_uid=?", + (soa_id, before["objective_uid"]), + ) + for ep_id, ep_uid, ep_parent in cur.fetchall(): + orphaned.append( + { + "id": ep_id, + "endpoint_uid": ep_uid, + "objective_uid_before": ep_parent, + } + ) + if orphaned: + cur.execute( + "UPDATE endpoint SET objective_uid=NULL WHERE soa_id=? AND objective_uid=?", + (soa_id, before["objective_uid"]), + ) + + _delete_level_code(cur, soa_id, before["level_code_uid"]) + cur.execute( + "DELETE FROM objective WHERE id=? AND soa_id=?", + (objective_id, soa_id), + ) + + # Reindex remaining objectives + cur.execute( + "SELECT id FROM objective WHERE soa_id=? ORDER BY order_index, id", + (soa_id,), + ) + remaining = [r[0] for r in cur.fetchall()] + for idx, oid in enumerate(remaining, start=1): + cur.execute("UPDATE objective SET order_index=? WHERE id=?", (idx, oid)) + conn.commit() + conn.close() + + _record_objective_audit(soa_id, "delete", objective_id, before=before, after=None) + for entry in orphaned: + _record_endpoint_audit( + soa_id, + "update", + entry["id"], + before={ + "endpoint_uid": entry["endpoint_uid"], + "objective_uid": entry["objective_uid_before"], + }, + after={ + "endpoint_uid": entry["endpoint_uid"], + "objective_uid": None, + "orphaned_by_objective_delete": before["objective_uid"], + }, + ) + return JSONResponse({"deleted": objective_id, "orphaned_endpoints": len(orphaned)}) + + +# --------------------------------------------------------------------------- +# UI form endpoints +# --------------------------------------------------------------------------- + + +@ui_router.post("/ui/soa/{soa_id}/objectives/create", response_class=HTMLResponse) +def ui_create_objective( + request: Request, + soa_id: int, + name: str = Form(...), + level: str = Form(...), + label: str | None = Form(None), + description: str | None = Form(None), + text: str | None = Form(None), +): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + create_objective( + soa_id, + ObjectiveCreate( + name=name, + level=level, + label=label, + description=description, + text=text, + ), + ) + redirect_url = f"/ui/soa/{soa_id}/edit" + if request.headers.get("HX-Request") == "true": + return HTMLResponse("", headers={"HX-Redirect": redirect_url}) + return RedirectResponse(redirect_url, status_code=303) + + +@ui_router.post( + "/ui/soa/{soa_id}/objectives/{objective_id}/update", + response_class=HTMLResponse, +) +def ui_update_objective( + request: Request, + soa_id: int, + objective_id: int, + name: str | None = Form(None), + level: str | None = Form(None), + label: str | None = Form(None), + description: str | None = Form(None), + text: str | None = Form(None), +): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + update_objective( + soa_id, + objective_id, + ObjectiveUpdate( + name=name, + level=level, + label=label, + description=description, + text=text, + ), + ) + redirect_url = f"/ui/soa/{soa_id}/edit" + if request.headers.get("HX-Request") == "true": + return HTMLResponse("", headers={"HX-Redirect": redirect_url}) + return RedirectResponse(redirect_url, status_code=303) + + +@ui_router.post( + "/ui/soa/{soa_id}/objectives/{objective_id}/delete", + response_class=HTMLResponse, +) +def ui_delete_objective( + request: Request, + soa_id: int, + objective_id: int, +): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + delete_objective(soa_id, objective_id) + redirect_url = f"/ui/soa/{soa_id}/edit" + if request.headers.get("HX-Request") == "true": + return HTMLResponse("", headers={"HX-Redirect": redirect_url}) + return RedirectResponse(redirect_url, status_code=303) diff --git a/src/soa_builder/web/schemas.py b/src/soa_builder/web/schemas.py index 75002d0..276acdb 100644 --- a/src/soa_builder/web/schemas.py +++ b/src/soa_builder/web/schemas.py @@ -267,6 +267,42 @@ class ConceptsUpdate(BaseModel): concept_codes: List[str] +class ObjectiveCreate(BaseModel): + name: str + level: str + label: Optional[str] = None + description: Optional[str] = None + text: Optional[str] = None + + +class ObjectiveUpdate(BaseModel): + name: Optional[str] = None + label: Optional[str] = None + description: Optional[str] = None + text: Optional[str] = None + level: Optional[str] = None + + +class EndpointCreate(BaseModel): + name: str + level: str + objective_uid: str + label: Optional[str] = None + description: Optional[str] = None + text: Optional[str] = None + purpose: Optional[str] = None + + +class EndpointUpdate(BaseModel): + name: Optional[str] = None + objective_uid: Optional[str] = None + label: Optional[str] = None + description: Optional[str] = None + text: Optional[str] = None + purpose: Optional[str] = None + level: Optional[str] = None + + class FreezeCreate(BaseModel): version_label: Optional[str] = None diff --git a/src/soa_builder/web/templates/_objectives_section.html b/src/soa_builder/web/templates/_objectives_section.html new file mode 100644 index 0000000..af7e12f --- /dev/null +++ b/src/soa_builder/web/templates/_objectives_section.html @@ -0,0 +1,209 @@ + + +
+ Objectives ({{ objectives|length }}) · Endpoints ({{ endpoints_by_objective.values()|map('length')|sum + orphan_endpoints|length }}) + + {% if objectives|length == 0 and orphan_endpoints|length == 0 %} +

No objectives defined yet.

+ {% endif %} + + {% for obj in objectives %} +
+
+ {{ obj.objective_uid }} + — {{ obj.name }} + {% if obj.level %}[{{ obj.level }}]{% endif %} + {% if obj.label %} «{{ obj.label }}»{% endif %} +
+ {% if obj.description %}
{{ obj.description }}
{% endif %} + {% if obj.text %}
{{ obj.text }}
{% endif %} +
+
+ + + + + + +
+
+ +
+
+ + {% set obj_endpoints = endpoints_by_objective.get(obj.objective_uid, []) %} + {% if obj_endpoints %} +
    + {% for ep in obj_endpoints %} +
  • + {{ ep.endpoint_uid }} — {{ ep.name }} + {% if ep.level %}[{{ ep.level }}]{% endif %} + {% if ep.label %} «{{ ep.label }}»{% endif %} + {% if ep.purpose %}
    Purpose: {{ ep.purpose }}
    {% endif %} + {% if ep.text %}
    {{ ep.text }}
    {% endif %} + +
    + + + + + + + + +
    +
    + +
    +
    +
  • + {% endfor %} +
+ {% endif %} +
+ {% endfor %} + + {% if orphan_endpoints %} +
+
Unassigned Endpoints ({{ orphan_endpoints|length }})
+
    + {% for ep in orphan_endpoints %} +
  • + {{ ep.endpoint_uid }} — {{ ep.name }} + {% if ep.level %}[{{ ep.level }}]{% endif %} + +
    + + + +
    +
    + +
    +
    +
  • + {% endfor %} +
+
+ {% endif %} + +
+ Create Objective +
+ + + + + + +
+
+ +
+ Create Endpoint + {% if objectives|length == 0 %} +

Create an objective first — endpoints must be assigned to a parent objective.

+ {% else %} +
+ + + + + + + + +
+ {% endif %} +
+
diff --git a/src/soa_builder/web/templates/edit.html b/src/soa_builder/web/templates/edit.html index 48938e5..d2bdf3b 100644 --- a/src/soa_builder/web/templates/edit.html +++ b/src/soa_builder/web/templates/edit.html @@ -125,6 +125,9 @@

Editing SoA for {% if study_label %}{{ study_label }}{% else %}{{ study_id } + +{% include '_objectives_section.html' %} +
- -
- Objectives ({{ objectives|length }}) · Endpoints ({{ endpoints_by_objective.values()|map('length')|sum + orphan_endpoints|length }}) - - {% if objectives|length == 0 and orphan_endpoints|length == 0 %} -

No objectives defined yet.

- {% endif %} - - {% for obj in objectives %} -
-
- {{ obj.objective_uid }} - — {{ obj.name }} - {% if obj.level %}[{{ obj.level }}]{% endif %} - {% if obj.label %} «{{ obj.label }}»{% endif %} -
- {% if obj.description %}
{{ obj.description }}
{% endif %} - {% if obj.text %}
{{ obj.text }}
{% endif %} -
-
- - - - - - -
-
- -
-
- - {% set obj_endpoints = endpoints_by_objective.get(obj.objective_uid, []) %} - {% if obj_endpoints %} -
    - {% for ep in obj_endpoints %} -
  • - {{ ep.endpoint_uid }} — {{ ep.name }} - {% if ep.level %}[{{ ep.level }}]{% endif %} - {% if ep.label %} «{{ ep.label }}»{% endif %} - {% if ep.purpose %}
    Purpose: {{ ep.purpose }}
    {% endif %} - {% if ep.text %}
    {{ ep.text }}
    {% endif %} - -
    - - - - - - - - -
    -
    - -
    -
    -
  • - {% endfor %} -
- {% endif %} -
- {% endfor %} - - {% if orphan_endpoints %} -
-
Unassigned Endpoints ({{ orphan_endpoints|length }})
-
    - {% for ep in orphan_endpoints %} -
  • - {{ ep.endpoint_uid }} — {{ ep.name }} - {% if ep.level %}[{{ ep.level }}]{% endif %} - -
    - - - -
    -
    - -
    -
    -
  • - {% endfor %} -
-
- {% endif %} - -
- Create Objective -
- - - - - - -
-
- -
- Create Endpoint - {% if objectives|length == 0 %} -

Create an objective first — endpoints must be assigned to a parent objective.

- {% else %} -
- - - - - - - - -
- {% endif %} -
-
diff --git a/src/soa_builder/web/templates/base.html b/src/soa_builder/web/templates/base.html index df116e7..bff0819 100644 --- a/src/soa_builder/web/templates/base.html +++ b/src/soa_builder/web/templates/base.html @@ -17,6 +17,7 @@ ✍🏻 Study Design -
- - - - + +
+
+
+
@@ -258,87 +256,68 @@

Matrix: {% if timeline_instances and timeline_instances[0].timeline_name %}{ {% endif %}
- Manage Footnotes -
- {% if footnotes %} - - - - - - - {% for fn in footnotes %} - - - - - {% endfor %} - -
Footnotes
-
- {{ fn.footnote_uid }} - - - - - -
-
-
- -
-
- {% endif %} -
- - - - - + Manage Footnotes ({{ footnotes|length }}) + {% for fn in footnotes %} +
+ +
+ + {{ fn.footnote_uid }} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+
+ {% endfor %} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
-
- ⬇ Excel Matrix + - - -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/soa_builder/web/templates/epochs.html b/src/soa_builder/web/templates/epochs.html index 1b07d45..03f96d3 100644 --- a/src/soa_builder/web/templates/epochs.html +++ b/src/soa_builder/web/templates/epochs.html @@ -3,93 +3,89 @@

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

- - ← Return to Edit Page - + ← Return to Edit Page
-
-
-
- - -
-
- - -
-
- - -
-
- - -
- -
+
+ +
+

Create Epoch

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
- - - - - - - - - - - - - {% for e in epochs %} - - - - - - - - - - - - - - {% else %} - +

Epochs ({{ epochs|length }})

- {% endfor %} +
idOrderNameLabelDescriptionTypeSaveDelete - -
{{ e.epoch_uid }}{{ e.order_index }} - - - - -
- -
-
- - -
No epochs yet.
+ + + + + + + + + + + + {% for e in epochs %} + + + + + + + + + + + + + + {% else %} + + {% endfor %}
UIDOrderNameLabelDescriptionTypeSaveDelete + +
{{ e.epoch_uid }}{{ e.order_index }} + + +
+ +
+
+ + +
No epochs yet.
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/soa_builder/web/templates/index.html b/src/soa_builder/web/templates/index.html index 973bdd9..37272e7 100644 --- a/src/soa_builder/web/templates/index.html +++ b/src/soa_builder/web/templates/index.html @@ -1,8 +1,36 @@ {% extends 'base.html' %} {% block content %} -

Existing Studies

- - +

Studies

+ +
+ +
+

Create New Study

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +

Existing Studies ({{ soas|length }})

+ +
+ @@ -14,23 +42,17 @@

Existing Studies

{% for s in soas %} - - + + - - - - + + + + {% endfor %}
ID Study Name Study ID
{{ s.id }}{{ s.name }}{{ s.id }}{{ s.name }} {{ s.study_id or '' }} {{ s.study_label or '' }}{{ s.study_description or '' }}{{ s.created_at }}EditAudits{{ s.study_description or '' }}{{ s.created_at }}EditAudits
-

Create New Study

-
- - - - - -
+ {% endblock %} diff --git a/src/soa_builder/web/templates/objectives.html b/src/soa_builder/web/templates/objectives.html index 773f470..45749f3 100644 --- a/src/soa_builder/web/templates/objectives.html +++ b/src/soa_builder/web/templates/objectives.html @@ -8,9 +8,9 @@

Objectives & Endpoints for Study: {% if study_label %}{{ study_label }}{
-
+

Create Objective

-
+
@@ -86,12 +86,12 @@

Objectives ({{ objectives|length }})


-
+

Create Endpoint

{% if objectives|length == 0 %}

Create an objective first — endpoints must be assigned to a parent objective.

{% else %} - +
diff --git a/src/soa_builder/web/templates/protocol_controlled_terminology.html b/src/soa_builder/web/templates/protocol_controlled_terminology.html index 2de8b25..c82013c 100644 --- a/src/soa_builder/web/templates/protocol_controlled_terminology.html +++ b/src/soa_builder/web/templates/protocol_controlled_terminology.html @@ -1,57 +1,84 @@ {% extends 'base.html' %} {% block content %}

Protocol Controlled Terminology{% if slug %} — {{ slug }}{% else %} — (unavailable){% endif %}

-{% if error %}
Error: {{ error }}
{% endif %} - - - - -
- - - - - - - -
-

Total Rows: {{ total_count }} | Matched: {{ matched_count }} | Showing limit {{ limit }} offset {{ offset }}

+ + + +{% if error %} +
+ Error: {{ error }} +
+{% endif %} + +
+ +
+

Filter

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+
+ + +
+
+
+ +

+ Total: {{ total_count }} | Matched: {{ matched_count }} | Showing limit {{ limit }} offset {{ offset }} +

+ {% if rows %} - - - - {% for col in columns %} - - {% endfor %} - - - +
{{ col }}
+ + {% for col in columns %}{% endfor %} + {% for r in rows %} - - {% for col in columns %} - - {% endfor %} - + {% for col in columns %}{% endfor %} {% endfor %} -
{{ col }}
{{ r[col] }}
{{ r[col] }}
{% else %} -

No rows match current filters.

+

No rows match current filters.

{% endif %} + {% if matched_count > limit %} -
- {% set next_offset = offset + limit %} - {% if next_offset < matched_count %} -
- - - - - - - -
- {% endif %} -
+{% set next_offset = offset + limit %} +{% if next_offset < matched_count %} +
+
+ + + + + + + +
+
+{% endif %} {% endif %} {% endblock %} diff --git a/src/soa_builder/web/templates/rules.html b/src/soa_builder/web/templates/rules.html index bd844d5..da90e77 100644 --- a/src/soa_builder/web/templates/rules.html +++ b/src/soa_builder/web/templates/rules.html @@ -3,68 +3,65 @@

Transition Rules for Study: {% if study_label %}{{ study_label }}{% else %}{{ study_name }}{% endif %}

-
-
-
- - -
-
- - -
-
- - -
-
- - -
- -
+
+ +
+

Create Transition Rule

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
- - - - - - - - - - - {% for r in rules %} - - - - - - - - - - - - {% else %} - - - - {% endfor %} +

Transition Rules ({{ rules|length }})

+ +
idNameLabelDescriptionTextSaveDelete
{{ r.transition_rule_uid }} - - -
- -
-
No rules yet.
+ + + + + + + + + + {% for r in rules %} + + + + + + + + + + + + {% else %} + + {% endfor %}
UIDNameLabelDescriptionTextSaveDelete
{{ r.transition_rule_uid }} +
+ +
+
No rules yet.
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/soa_builder/web/templates/sdtm_specializations.html b/src/soa_builder/web/templates/sdtm_specializations.html index acdb5e1..d560fd7 100644 --- a/src/soa_builder/web/templates/sdtm_specializations.html +++ b/src/soa_builder/web/templates/sdtm_specializations.html @@ -1,48 +1,68 @@ {% extends 'base.html' %} {% block content %}

SDTM Dataset Specializations ({{ count }})

-

Data sourced from CDISC Library endpoint /mdr/specializations/sdtm/datasetspecializations. Raw API links may require headers (Ocp-Apim-Subscription-Key{% if not missing_key %}, Authorization: Bearer <key>{% endif %}).

+

+ Data sourced from CDISC Library /mdr/specializations/sdtm/datasetspecializations. +

+ + + {% if last_error %} -
+
Fetch diagnostics: Status {{ last_status }}{% if last_url %} from {{ last_url }}{% endif %}.
Error: {{ last_error }}
- {% if missing_key %}No API key detected; set CDISC_SUBSCRIPTION_KEY or CDISC_API_KEY environment variable.{% endif %} + {% if missing_key %}No API key detected — set CDISC_SUBSCRIPTION_KEY or CDISC_API_KEY.{% endif %}
{% elif missing_key %} -
+
Notice: No API key provided; response may be empty or unauthorized. Set CDISC_SUBSCRIPTION_KEY or CDISC_API_KEY.
{% endif %} -
- - - -
- -
- Status JSON + +
+ +
+
+ + +
+ +
+
+ +
+ Status JSON +
+ {% if rows and rows|length > 0 %} - - - - - - - - +
TitleHREF
+ + + + {% for r in rows %} - - - - + + + + {% endfor %} -
TitleDetail
{{ r.title }} - - Detail - -
{{ r.title }} + Detail +
+{% else %} +

No SDTM dataset specializations available.

+
    +
  • Verify API key env (CDISC_SUBSCRIPTION_KEY or CDISC_API_KEY).
  • +
  • If testing offline, export CDISC_SDTM_SPECIALIZATIONS_JSON with sample JSON.
  • +
  • Confirm network access to the CDISC Library domain.
  • +
+{% endif %} + -{% else %} -

No SDTM dataset specializations available.

-

Troubleshooting tips: -

    -
  • Verify API key env (CDISC_SUBSCRIPTION_KEY or CDISC_API_KEY).
  • -
  • If testing offline, export CDISC_SDTM_SPECIALIZATIONS_JSON with sample JSON including datasetSpecializations.
  • -
  • Confirm network access to the CDISC Library domain.
  • -
-

-{% endif %} -

Return Home

{% endblock %} diff --git a/src/soa_builder/web/templates/sdtm_terminology.html b/src/soa_builder/web/templates/sdtm_terminology.html index 4435203..2d36675 100644 --- a/src/soa_builder/web/templates/sdtm_terminology.html +++ b/src/soa_builder/web/templates/sdtm_terminology.html @@ -1,57 +1,84 @@ {% extends 'base.html' %} {% block content %}

SDTM Controlled Terminology{% if slug %} — {{ slug }}{% else %} — (unavailable){% endif %}

-{% if error %}
Error: {{ error }}
{% endif %} -
- - -
-
- - - - - - - -
-

Total Rows: {{ total_count }} | Matched: {{ matched_count }} | Showing limit {{ limit }} offset {{ offset }}

+ + + +{% if error %} +
+ Error: {{ error }} +
+{% endif %} + +
+ +
+

Filter

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+
+ + +
+
+
+ +

+ Total: {{ total_count }} | Matched: {{ matched_count }} | Showing limit {{ limit }} offset {{ offset }} +

+ {% if rows %} - - - - {% for col in columns %} - - {% endfor %} - - - +
{{ col }}
+ + {% for col in columns %}{% endfor %} + {% for r in rows %} - - {% for col in columns %} - - {% endfor %} - + {% for col in columns %}{% endfor %} {% endfor %} -
{{ col }}
{{ r[col] }}
{{ r[col] }}
{% else %} -

No rows match current filters.

+

No rows match current filters.

{% endif %} + {% if matched_count > limit %} -
- {% set next_offset = offset + limit %} - {% if next_offset < matched_count %} -
- - - - - - - -
- {% endif %} -
+{% set next_offset = offset + limit %} +{% if next_offset < matched_count %} +
+
+ + + + + + + +
+
+{% endif %} {% endif %} {% endblock %} diff --git a/src/soa_builder/web/templates/study_cells.html b/src/soa_builder/web/templates/study_cells.html index 2469509..0a9884f 100644 --- a/src/soa_builder/web/templates/study_cells.html +++ b/src/soa_builder/web/templates/study_cells.html @@ -2,82 +2,82 @@ {% block content %}

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

-
- - ← Return to Edit Page - + -
-
-
- - -
-
- - -
-
- - - Select one or more elements (Cmd/Ctrl+Click). -
-
- -
-
+
+ +
+

Add Study Cell

+
+
+ + +
+
+ + +
+
+ + + Select one or more elements (Cmd/Ctrl+Click). +
+ +
- - - - - - - - - - {% for sc in study_cells %} - - - - - - - - - {% else %} - - {% endfor %} +

Study Cells ({{ study_cells|length }})

+ +
UIDArmEpochElementDelete - -
{{ sc.study_cell_uid }}{{ sc.arm_name or sc.arm_uid }}{{ sc.epoch_name or sc.epoch_uid }}{{ sc.element_name or sc.element_uid }} -
- -
-
- - -
No study cells yet.
+ + + + + + + + + {% for sc in study_cells %} + + + + + + + + + {% else %} + + {% endfor %}
UIDArmEpochElementDelete + +
{{ sc.study_cell_uid }}{{ sc.arm_name or sc.arm_uid }}{{ sc.epoch_name or sc.epoch_uid }}{{ sc.element_name or sc.element_uid }} +
+ +
+
+ + +
No study cells yet.
+