From a3608c2fd1260eaa20fcb3c90d832ccd773d30ee Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Mon, 5 Jan 2026 08:33:11 -0500 Subject: [PATCH 1/4] Updated names for visit transition rule helper functions --- src/soa_builder/web/app.py | 8 +++++--- src/soa_builder/web/templates/edit.html | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index 668ac20..85d7456 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -5765,8 +5765,10 @@ def ui_set_visit_epoch( # UI endpoint for associating a Transition Start Rule with Visit/Encounter (visit.transitionStartRule) -@app.post("/ui/soa/{soa_id}/set_transition_start_rule", response_class=HTMLResponse) -def ui_set_transition_start_rule( +@app.post( + "/ui/soa/{soa_id}/set_visit_transition_start_rule", response_class=HTMLResponse +) +def ui_set_visit_transition_start_rule( request: Request, soa_id: int, visit_id: int = Form(...), @@ -5849,7 +5851,7 @@ def ui_set_transition_start_rule( # UI endpoint for associating a Transition End Rule with Visit/Encounter (visit.transitionEndRule) -@app.post("/ui/soa/{soa_id}/set_transition_end_rule", response_class=HTMLResponse) +@app.post("/ui/soa/{soa_id}/set_visit_transition_end_rule", response_class=HTMLResponse) def ui_set_transition_end_rule( request: Request, soa_id: int, diff --git a/src/soa_builder/web/templates/edit.html b/src/soa_builder/web/templates/edit.html index f51f29d..73de408 100644 --- a/src/soa_builder/web/templates/edit.html +++ b/src/soa_builder/web/templates/edit.html @@ -110,7 +110,7 @@

Editing SoA {{ soa_id }}

{% endif %} {% if transition_rules %} -
+
-
+ @@ -112,7 +114,7 @@

Editing SoA {{ soa_id }}

{% if transition_rules %} - {% for tr in transition_rules %} @@ -121,7 +123,7 @@

Editing SoA {{ soa_id }}

- {% for tr in transition_rules %} @@ -129,7 +131,7 @@

Editing SoA {{ soa_id }}

{% endif %} - +
@@ -238,19 +240,37 @@

Editing SoA {{ soa_id }}

Elements ({{ elements|length }}) (drag to reorder)
- - - - - - - + + + + + +
diff --git a/tests/test_element_id_monotonic.py b/tests/test_element_id_monotonic.py index d1232fa..15b1dc5 100644 --- a/tests/test_element_id_monotonic.py +++ b/tests/test_element_id_monotonic.py @@ -34,33 +34,3 @@ def get_last_element(soa_id): row = cur.fetchone() conn.close() return row - - -def test_element_id_monotonic_after_delete(): - soa_id = create_soa() - # Create first element - r1 = client.post(f"/ui/soa/{soa_id}/add_element", data={"name": "Elem A"}) - assert r1.status_code == 200 - first = get_first_element(soa_id) - # If column absent or value None, skip monotonic assertion - if not first or first[1] is None: - return - assert first[1].startswith(PREFIX) - n1 = int(first[1][len(PREFIX) :]) - assert n1 == 1 - - # Delete the first element via UI endpoint - del_resp = client.post( - f"/ui/soa/{soa_id}/delete_element", data={"element_id": first[0]} - ) - assert del_resp.status_code == 200 - - # Create another element and ensure ID increments to 2 (monotonic) - r2 = client.post(f"/ui/soa/{soa_id}/add_element", data={"name": "Elem B"}) - assert r2.status_code == 200 - last = get_last_element(soa_id) - assert last is not None - assert last[1] is not None - assert last[1].startswith(PREFIX) - n2 = int(last[1][len(PREFIX) :]) - assert n2 == 2 From 451d7fe5e12350e68a79cc95188f56261bddf396 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Mon, 5 Jan 2026 13:45:59 -0500 Subject: [PATCH 3/4] Created USDM JSON export of Elements --- src/usdm/generate_elements.py | 168 ++++++++++++++++++++++++++++++++ src/usdm/generate_encounters.py | 2 +- 2 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 src/usdm/generate_elements.py diff --git a/src/usdm/generate_elements.py b/src/usdm/generate_elements.py new file mode 100644 index 0000000..c90202e --- /dev/null +++ b/src/usdm/generate_elements.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +# Prefer absolute import; fallback to adding src/ to sys.path when run directly +from typing import Optional, List, Dict, Any, Tuple + +try: + from soa_builder.web.app import _connect # reuse existing DB connector +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 + + +def _nz(s: Optional[str]) -> Optional[str]: + s = (s or "").strip() + return s or None + + +def _get_transition_start_rule( + soa_id: int, transition_rule_uid: Optional[str] +) -> Optional[Dict[str, Any]]: + if not transition_rule_uid: + return None + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT tr.name, tr.label, tr.description, tr.text FROM transition_rule tr WHERE soa_id=? AND transition_rule_uid=?", + (soa_id, transition_rule_uid), + ) + row = cur.fetchone() + conn.close() + if not row: + return None + return { + "id": transition_rule_uid, + "extensionAttributes": [], + "name": row[0] or None, + "label": row[1] or None, + "description": row[2] or None, + "text": row[3] or None, + "instanceType": "TransitionRule", + } + + +def _get_transition_end_rule( + soa_id: int, transition_rule_uid: Optional[str] +) -> Optional[Dict[str, Any]]: + if not transition_rule_uid: + return None + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT tr.name, tr.label, tr.description, tr.text FROM transition_rule tr WHERE soa_id=? AND transition_rule_uid=?", + (soa_id, transition_rule_uid), + ) + row = cur.fetchone() + conn.close() + if not row: + return None + return { + "id": transition_rule_uid, + "extensionAttributes": [], + "name": row[0] or None, + "label": row[1] or None, + "description": row[2] or None, + "text": row[3] or None, + "instanceType": "TransitionRule", + } + + +def build_usdm_elements(soa_id: int) -> List[Dict[str, Any]]: + """ + Build USDM Elements-Output objects for the given SOA + + USDM Elements-Output (subset): + - id: string + - extensionAttributes?: string[] + - name: string + - label?: string + - description?: string + - transitionStartRule?: {} + - transitionEndRule?: {} + - studyInterventionIds?: string[] + - notes?: string[] + - instanceType: "StudyElement" + + """ + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT name,label,description,element_id,testrl,teenrl FROM element WHERE soa_id=? ORDER BY order_index", + (soa_id,), + ) + rows = cur.fetchall() + conn.close() + uids = [r[3] for r in rows] + id_by_index = {i: uid for i, uid in enumerate(uids)} + out: List[Dict[str, Any]] = [] + + for i, r in enumerate(rows): + ( + name, + label, + description, + element_id, + testrl, + teenrl, + ) = ( + r[0], + r[1], + r[2], + r[3], + r[4], + r[5], + ) + + transition_start_rule_obj = _get_transition_start_rule(soa_id, testrl) + + transition_end_rule_obj = _get_transition_end_rule(soa_id, teenrl) + + element = { + "id": element_id, + "extensionAttributes": [], + "name": name, + "label": _nz(label), + "description": _nz(description), + "transitionStartRule": transition_start_rule_obj or {}, + "transitionEndRule": transition_end_rule_obj or {}, + "studyInterventionIds": [], + "notes": [], + "instanceType": "StudyElement", + } + out.append(element) + return out + + +if __name__ == "__main__": + import argparse + import json + import logging + import sys + + logger = logging.getLogger("usdm.generate_elements") + + parser = argparse.ArgumentParser(description="Export USDM Elements for a SOA.") + parser.add_argument("soa_id", type=int, help="SOA id to export Elements for") + 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: + elements = build_usdm_elements(args.soa_id) + except Exception: + logger.exception("Failed to build Elements for soa_id=%s", args.soa_id) + sys.exit(1) + + payload = json.dumps(elements, 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") diff --git a/src/usdm/generate_encounters.py b/src/usdm/generate_encounters.py index 67d00a0..8f74da5 100644 --- a/src/usdm/generate_encounters.py +++ b/src/usdm/generate_encounters.py @@ -164,7 +164,7 @@ def build_usdm_encounters(soa_id: int) -> List[Dict[str, Any]]: ] - contactModes: [] - transitionStartRule?: {} - - transitionEndRule": {} + - transitionEndRule?: {} - notes: [] - instanceType: "Encounter" """ From 5cc03eecca9324a5e9fb708d8f1743858d76f281 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:14:38 -0500 Subject: [PATCH 4/4] Corrected issues in elements code --- src/soa_builder/web/app.py | 8 ++++---- src/soa_builder/web/routers/elements.py | 3 +-- src/soa_builder/web/templates/edit.html | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index 2c537ca..92ea8d9 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -4504,7 +4504,7 @@ def ui_add_element( teenrl=teenrl_uid, ) - # Create the element wia the API helper to ensure audits and ordering + # Create the element via the API helper to ensure audits and ordering try: elements_router.create_element(soa_id, payload) except Exception: @@ -4681,7 +4681,7 @@ def ui_set_element_transition_end_rule( element_transition_end_rule_uid: str = Form(...), ): """Form handler for associating a Transition End Rule with an Element""" - if not soa_exists: + if not soa_exists(soa_id): raise HTTPException(404, "SOA not found") new_uid = (element_transition_end_rule_uid or "").strip() or None @@ -4742,7 +4742,7 @@ def ui_set_element_transition_end_rule( "update", element_id, before=before, - after={**after, "updated_field": updated_fields}, + after={**after, "updated_fields": updated_fields}, ) conn.close() return HTMLResponse( @@ -4776,7 +4776,7 @@ def ui_update_element( pass return HTMLResponse( - f"" + f"" ) """ diff --git a/src/soa_builder/web/routers/elements.py b/src/soa_builder/web/routers/elements.py index 2416ece..786ffad 100644 --- a/src/soa_builder/web/routers/elements.py +++ b/src/soa_builder/web/routers/elements.py @@ -326,7 +326,7 @@ def update_element(soa_id: int, element_id: int, payload: ElementUpdate): conn.close() raise HTTPException(400, "Invalid transition_rule id for this SOA") - if payload.testrl is not None: + if payload.teenrl is not None: cur.execute( "SELECT 1 from transition_rule WHERE id=? AND soa_id=?", ( @@ -448,7 +448,6 @@ def delete_element(soa_id: int, element_id: int): # Deprecated - no need to reorder elements -@DeprecationWarning @router.post("/elements/reorder", response_class=JSONResponse) def reorder_elements_api(soa_id: int, order: List[int]): if not soa_exists(soa_id): diff --git a/src/soa_builder/web/templates/edit.html b/src/soa_builder/web/templates/edit.html index 921e69b..2b31e52 100644 --- a/src/soa_builder/web/templates/edit.html +++ b/src/soa_builder/web/templates/edit.html @@ -282,7 +282,7 @@

Editing SoA {{ soa_id }}

- +