diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index 6a7f49c..ac7c07d 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -2,14 +2,6 @@ """FastAPI web application for interactive Schedule of Activities creation. -Endpoints (deprecated): - POST /soa {name} -> create SOA container - GET /soa/{id} -> summary - POST /soa/{id}/visits {name, label} -> add visit - POST /soa/{id}/activities {name} -> add activity - POST /soa/{id}/cells {visit_id, activity_id, status} -> set cell value - GET /soa/{id}/matrix -> returns visits, activities, cells matrix - GET /soa/{id}/normalized -> run normalization pipeline and return summary Data persisted in SQLite (file: soa_builder_web.db by default). """ @@ -77,22 +69,16 @@ from .routers import timings as timings_router from .routers import schedule_timelines as schedule_timelines_router from .routers import instances as instances_router -from .routers.arms import create_arm # re-export for backward compatibility -from .routers.arms import delete_arm + # Avoid binding visit helpers directly to allow fresh reloads in tests from .schemas import ( - ArmCreate, SOACreate, SOAMetadataUpdate, ConceptsUpdate, ElementCreate, ElementUpdate, - # FreezeCreate, CellCreate, - # BulkActivities, - # MatrixVisit, - # MatrixActivity, MatrixImport, ) from .utils import ( @@ -285,63 +271,6 @@ def _record_activity_audit( logger.warning("Failed recording activity audit: %s", e) -# Moved to routers/epochs.py -""" -def _record_epoch_audit( - soa_id: int, - action: str, - epoch_id: Optional[int], - before: Optional[dict] = None, - after: Optional[dict] = None, -): - try: - conn = _connect() - cur = conn.cursor() - cur.execute( - "INSERT INTO epoch_audit (soa_id, epoch_id, action, before_json, after_json, performed_at) VALUES (?,?,?,?,?,?)", - ( - soa_id, - epoch_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: # pragma: no cover - logger.warning("Failed recording epoch audit: %s", e) -""" - - -def _record_arm_audit( - soa_id: int, - action: str, - arm_id: Optional[int], - before: Optional[dict] = None, - after: Optional[dict] = None, -): - try: - conn = _connect() - cur = conn.cursor() - cur.execute( - "INSERT INTO arm_audit (soa_id, arm_id, action, before_json, after_json, performed_at) VALUES (?,?,?,?,?,?)", - ( - soa_id, - arm_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: # pragma: no cover - logger.warning("Failed recording arm audit: %s", e) - - # API functions for reordering Encounters/Visits @app.post("/soa/{soa_id}/visits/reorder", response_class=JSONResponse) def reorder_visits_api(soa_id: int, order: List[int]): @@ -3365,7 +3294,7 @@ def ui_update_meta( ) -# Helper to fetch element audit rows with legacy-safe columns -> Moved to audits.py, audits.html +# Helper to fetch element audit rows with legacy-safe columns -> Deprecated (Moved to audits.py, audits.html) """ def _fetch_element_audits(soa_id: int): conn_ea = _connect() @@ -3991,838 +3920,322 @@ def ui_concept_detail(code: str, request: Request): ) -# UI endpoint for creating an Encounter/Visit <- moved to routers/visits.py -""" -@app.post("/ui/soa/{soa_id}/add_visit", response_class=HTMLResponse) -def ui_add_visit( +# UI endpoint for adding an element +@app.post("/ui/soa/{soa_id}/add_element", response_class=HTMLResponse) +def ui_add_element( request: Request, soa_id: int, name: str = Form(...), label: Optional[str] = Form(None), - epoch_id: Optional[str] = Form(None), description: Optional[str] = Form(None), + element_transition_start_rule_uid: str = Form(""), + element_transition_end_rule_uid: str = Form(""), ): + """Form handler to add an element.""" if not soa_exists(soa_id): raise HTTPException(404, "SOA not found") - # Coerce empty epoch_id from form to None, otherwise to int - parsed_epoch_id: Optional[int] = None - if epoch_id is not None: - eid = str(epoch_id).strip() - if eid: - try: - parsed_epoch_id = int(eid) - except ValueError: - parsed_epoch_id = None + # Coerce empty testrl from Form to None, otherwise to int - payload = VisitCreate( + testrl_uid: Optional[str] = ( + element_transition_start_rule_uid or "" + ).strip() or None + teenrl_uid: Optional[str] = (element_transition_end_rule_uid or "").strip() or None + + payload = ElementCreate( name=name, label=label, - epoch_id=parsed_epoch_id, description=description, + testrl=testrl_uid, + teenrl=teenrl_uid, ) - # Create the visit via the API helper to ensure audits and ordering + + # Create the element via the API helper to ensure audits and ordering try: - visits_router.add_visit(soa_id, payload) + elements_router.create_element(soa_id, payload) except Exception: pass return HTMLResponse( f"" ) -""" + + ''' + name = (name or "").strip() + if not name: + raise HTTPException(400, "Name required") + conn = _connect() + cur = conn.cursor() + # Determine next order index + cur.execute( + "SELECT COALESCE(MAX(order_index),0) FROM element WHERE soa_id=?", (soa_id,) + ) + next_ord = (cur.fetchone() or [0])[0] + 1 + now = datetime.now(timezone.utc).isoformat() + # Check if legacy/non-standard element_id column exists and populate if required + cur.execute("PRAGMA table_info(element)") + element_cols = {r[1] for r in cur.fetchall()} + element_identifier: Optional[str] = None + if "element_id" in element_cols: + # Generate StudyElement_ monotonically increasing for this SOA + element_identifier = _next_element_identifier(soa_id) + cur.execute( + """INSERT INTO element (soa_id,name,label,description,testrl,teenrl,order_index,created_at,element_id) + VALUES (?,?,?,?,?,?,?,?,?)""", + ( + soa_id, + name, + (label or "").strip() or None, + (description or "").strip() or None, + (testrl or "").strip() or None, + (teenrl or "").strip() or None, + next_ord, + now, + element_identifier, + ), + ) + else: + cur.execute( + """INSERT INTO element (soa_id,name,label,description,testrl,teenrl,order_index,created_at) + VALUES (?,?,?,?,?,?,?,?)""", + ( + soa_id, + name, + (label or "").strip() or None, + (description or "").strip() or None, + (testrl or "").strip() or None, + (teenrl or "").strip() or None, + next_ord, + now, + ), + ) + eid = cur.lastrowid + conn.commit() + conn.close() + # Audit should store the logical StudyElement_N in element_audit.element_id, not the row id + _record_element_audit( + soa_id, + "create", + element_identifier, + before=None, + after={ + "id": eid, + "name": name, + "label": (label or "").strip() or None, + "description": (description or "").strip() or None, + "testrl": (testrl or "").strip() or None, + "teenrl": (teenrl or "").strip() or None, + "order_index": next_ord, + "element_id": element_identifier, + }, + ) + return HTMLResponse( + f"" + ) + ''' -# UI endpoint for adding a new Arm -@app.post("/ui/soa/{soa_id}/add_arm", response_class=HTMLResponse) -async def ui_add_arm( +# UI endpoint for associating a Transition Start Rule with an Element (element.testrl) +@app.post( + "/ui/soa/{soa_id}/set_element_transition_start_rule", response_class=HTMLResponse +) +def ui_set_element_transition_start_rule( request: Request, soa_id: int, - name: str = Form(...), - label: Optional[str] = Form(None), - description: Optional[str] = Form(None), - element_id: Optional[str] = Form(None), + element_id: int = Form(...), + element_transition_start_rule_uid: str = Form(...), ): - """Form handler to create a new Arm.""" + """Form handler for associating a Transition Start Rule with an Element""" if not soa_exists(soa_id): raise HTTPException(404, "SOA not found") - # Accept blank/empty element selection gracefully. The form may submit "" which would 422 with Optional[int]. - eid = int(element_id) if element_id and element_id.strip().isdigit() else None - payload = ArmCreate(name=name, label=label, description=description, element_id=eid) - # Create base arm (function may not return id; fetch if needed) - created = create_arm(soa_id, payload) - # routers.arms.create_arm returns a row dict; extract id - new_arm_id = None - try: - if isinstance(created, dict): - new_arm_id = created.get("id") - elif isinstance(created, int): - new_arm_id = created - except Exception: - new_arm_id = None - if not new_arm_id: - try: - conn_tmp = _connect() - cur_tmp = conn_tmp.cursor() - cur_tmp.execute( - "SELECT id FROM arm WHERE soa_id=? ORDER BY id DESC LIMIT 1", - (soa_id,), - ) - rtmp = cur_tmp.fetchone() - new_arm_id = rtmp[0] if rtmp else None - conn_tmp.close() - except Exception: - new_arm_id = None - if not new_arm_id: - return HTMLResponse( - f"", - status_code=500, + + new_uid = (element_transition_start_rule_uid or "").strip() or None + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id,name,label,description,element_id,testrl FROM element WHERE id=? AND soa_id=?", + (element_id, soa_id), + ) + row = cur.fetchone() + if not row: + conn.close() + raise HTTPException(404, "Element not found") + + before = { + "id": row[0], + "name": row[1], + "label": row[2], + "description": row[3], + "element_id": row[4], + "testrl": row[5], + } + if new_uid is not None: + cur.execute( + "SELECT 1 FROM transition_rule WHERE transition_rule_uid=? AND soa_id=?", + ( + new_uid, + soa_id, + ), ) - # Read optional type fields with hyphenated names - try: - form_data = await request.form() - arm_type_submission = (form_data.get("arm-type") or "").strip() - data_origin_type_submission = (form_data.get("data-origin-type") or "").strip() - except Exception: - arm_type_submission = "" - data_origin_type_submission = "" + if not cur.fetchone(): + conn.close() + raise HTTPException("Invalid transition rule for this SOA") - # If type selections provided, resolve to terminology codes and persist via junction table - if arm_type_submission or data_origin_type_submission: - conn = _connect() - cur = conn.cursor() - logger.info( - "ui_add_arm: received type selections arm-type='%s', data-origin-type='%s' for soa_id=%s arm_id=%s", - arm_type_submission, - data_origin_type_submission, - soa_id, - new_arm_id, - ) - new_type_uid: Optional[str] = None - new_data_origin_uid: Optional[str] = None - if arm_type_submission: - cur.execute( - "SELECT code FROM protocol_terminology WHERE codelist_code='C174222' AND (cdisc_submission_value=? OR LOWER(TRIM(cdisc_submission_value))=LOWER(TRIM(?)))", - (arm_type_submission, arm_type_submission), - ) - r = cur.fetchone() - resolved_code = r[0] if r else None - if resolved_code is None: - logger.warning( - "ui_add_arm: unknown arm type submission '%s' for soa_id=%s", - arm_type_submission, - soa_id, - ) - conn.close() - return HTMLResponse( - f"", - status_code=400, - ) - # Create Code_N - new_type_uid = _get_next_code_uid(cur, soa_id) - cur.execute( - "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", - ( - soa_id, - new_type_uid, - "protocol_terminology", - "C174222", - resolved_code, - ), - ) - logger.info( - "ui_add_arm: created code junction %s -> table=%s list=%s code=%s", - new_type_uid, - "protocol_terminology", - "C174222", - resolved_code, - ) - if data_origin_type_submission: - cur.execute( - "SELECT code FROM ddf_terminology WHERE codelist_code='C188727' AND (cdisc_submission_value=? OR LOWER(TRIM(cdisc_submission_value))=LOWER(TRIM(?)))", - (data_origin_type_submission, data_origin_type_submission), - ) - r2 = cur.fetchone() - resolved_ddf_code = r2[0] if r2 else None - if resolved_ddf_code is None: - logger.warning( - "ui_add_arm: unknown data origin type submission '%s' for soa_id=%s", - data_origin_type_submission, - soa_id, - ) - conn.close() - # Properly escape the value for safety in HTML/JS context - escaped_selection = json.dumps(data_origin_type_submission) - return HTMLResponse( - f"", - status_code=400, - ) - # Create Code_N (continue numbering) - new_data_origin_uid = _get_next_code_uid(cur, soa_id) - cur.execute( - "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", - ( - soa_id, - new_data_origin_uid, - "ddf_terminology", - "C188727", - resolved_ddf_code, - ), - ) - logger.info( - "ui_add_arm: created code junction %s -> table=%s list=%s code=%s", - new_data_origin_uid, - "ddf_terminology", - "C188727", - resolved_ddf_code, - ) - # Update arm row with new code_uids - if new_type_uid or new_data_origin_uid: - cur.execute( - "UPDATE arm SET type=COALESCE(?, type), data_origin_type=COALESCE(?, data_origin_type) WHERE id=? AND soa_id=?", - (new_type_uid, new_data_origin_uid, new_arm_id, soa_id), - ) - logger.info( - "ui_add_arm: updated arm id=%s set type=%s data_origin_type=%s", - new_arm_id, - new_type_uid, - new_data_origin_uid, - ) - conn.commit() - # routers.arms.create_arm already records a create audit; avoid duplicating here - conn.close() + cur.execute( + "UPDATE element SET testrl=? WHERE id=? AND soa_id=?", + (new_uid, element_id, soa_id), + ) + conn.commit() + + cur.execute( + "SELECT id,name,label,description,element_id,testrl FROM element WHERE id=? AND soa_id=?", + (element_id, soa_id), + ) + r = cur.fetchone() + after = { + "id": r[0], + "name": r[1], + "label": r[2], + "description": r[3], + "element_id": r[4], + "testrl": r[5], + } + updated_fields = [ + f for f in ["testrl"] if (before.get(f) or None) != (after.get(f) or None) + ] + _record_element_audit( + soa_id, + "update", + element_id, + before=before, + after={**after, "updated_fields": updated_fields}, + ) + conn.close() return HTMLResponse( f"" ) -# UI endpoint for updating an Arm -@app.post("/ui/soa/{soa_id}/update_arm", response_class=HTMLResponse) -async def ui_update_arm( +# UI endpoint for associating a Transition Start Rule with an Element (element.teenrl) +@app.post( + "/ui/soa/{soa_id}/set_element_transition_end_rule", response_class=HTMLResponse +) +def ui_set_element_transition_end_rule( request: Request, soa_id: int, - arm_id: int = Form(...), - name: Optional[str] = Form(None), - label: Optional[str] = Form(None), - description: Optional[str] = Form(None), - element_id: Optional[str] = Form(None), + element_id: int = Form(...), + element_transition_end_rule_uid: str = Form(...), ): - """Form handler to update an existing Arm.""" + """Form handler for associating a Transition End Rule with an Element""" if not soa_exists(soa_id): raise HTTPException(404, "SOA not found") - # Read raw form to capture field names with hyphens: 'arm-type' and 'data-origin-type' - try: - form_data = await request.form() - arm_type_submission = (form_data.get("arm-type") or "").strip() - data_origin_type_submission = (form_data.get("data-origin-type") or "").strip() - except Exception: - arm_type_submission = "" - data_origin_type_submission = "" - logger.info( - "ui_update_arm: arm_id=%s soa_id=%s incoming arm-type='%s' data-origin-type='%s'", - arm_id, - soa_id, - arm_type_submission, - data_origin_type_submission, - ) + new_uid = (element_transition_end_rule_uid or "").strip() or None - # Fetch current arm (including existing type code_uid if any) conn = _connect() cur = conn.cursor() cur.execute( - "SELECT id, name, label, description, COALESCE(type,''), COALESCE(data_origin_type,'') FROM arm WHERE id=? AND soa_id=?", - (arm_id, soa_id), + "SELECT id,name,label,description,element_id,teenrl FROM element WHERE id=? AND soa_id=?", + (element_id, soa_id), ) row = cur.fetchone() if not row: conn.close() - raise HTTPException(404, "Arm not found") - current_code_uid = row[4] or None - current_data_origin_uid = row[5] or None - # Capture prior code values for audits when code mapping changes without uid change - prior_arm_type_code_value: Optional[str] = None - prior_data_origin_code_value: Optional[str] = None - if current_code_uid: - cur.execute( - "SELECT code FROM code WHERE soa_id=? AND code_uid=?", - (soa_id, current_code_uid), - ) - rcv = cur.fetchone() - prior_arm_type_code_value = rcv[0] if rcv else None - if current_data_origin_uid: - cur.execute( - "SELECT code FROM code WHERE soa_id=? AND code_uid=?", - (soa_id, current_data_origin_uid), - ) - rdv = cur.fetchone() - prior_data_origin_code_value = rdv[0] if rdv else None - before_state = { + raise HTTPException(404, "Element not found") + + before = { "id": row[0], "name": row[1], "label": row[2], "description": row[3], - "type": current_code_uid, - "data_origin_type": current_data_origin_uid, + "element_id": row[4], + "teenrl": row[5], } - # Resolve submission value to protocol terminology code (C174222) - resolved_code: Optional[str] = None - if arm_type_submission: + if new_uid is not None: cur.execute( - "SELECT code FROM protocol_terminology WHERE codelist_code='C174222' AND (cdisc_submission_value=? OR LOWER(TRIM(cdisc_submission_value))=LOWER(TRIM(?)))", - (arm_type_submission, arm_type_submission), + "SELECT 1 FROM transition_rule WHERE transition_rule_uid=? AND soa_id=?", + (new_uid, soa_id), ) - r = cur.fetchone() - resolved_code = r[0] if r else None - if resolved_code is None: - logger.warning( - "ui_update_arm: unknown arm type submission '%s' for soa_id=%s arm_id=%s", - arm_type_submission, - soa_id, - arm_id, - ) + if not cur.fetchone(): conn.close() - return HTMLResponse( - f"", - status_code=400, - ) - - # Maintain code table row with immutable code_uid (Code_N unique per SoA) - new_code_uid = current_code_uid - if resolved_code is not None: - if current_code_uid: - # Update existing junction row for this code_uid - cur.execute( - "UPDATE code SET code=?, codelist_code='C174222', codelist_table='protocol_terminology' WHERE soa_id=? AND code_uid=?", - (resolved_code, soa_id, current_code_uid), - ) - logger.info( - "ui_update_arm: updated junction code_uid=%s -> table=%s list=%s code=%s", - current_code_uid, - "protocol_terminology", - "C174222", - resolved_code, - ) - else: - # Create new Code_N within this SoA - new_code_uid = _get_next_code_uid(cur, soa_id) - cur.execute( - "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", - ( - soa_id, - new_code_uid, - "protocol_terminology", - "C174222", - resolved_code, - ), - ) - logger.info( - "ui_update_arm: created junction code_uid=%s -> table=%s list=%s code=%s", - new_code_uid, - "protocol_terminology", - "C174222", - resolved_code, - ) + raise HTTPException(400, "Invalid Transition Rule for this SOA") - # Resolve Data Origin Type submission value to DDF terminology code (C188727) - resolved_ddf_code: Optional[str] = None - new_data_origin_uid = current_data_origin_uid - if data_origin_type_submission: - cur.execute( - "SELECT code FROM ddf_terminology WHERE codelist_code='C188727' AND (cdisc_submission_value=? OR LOWER(TRIM(cdisc_submission_value))=LOWER(TRIM(?)))", - (data_origin_type_submission, data_origin_type_submission), - ) - r2 = cur.fetchone() - resolved_ddf_code = r2[0] if r2 else None - if resolved_ddf_code is None: - logger.warning( - "ui_update_arm: unknown data origin type submission '%s' for soa_id=%s arm_id=%s", - data_origin_type_submission, - soa_id, - arm_id, - ) - conn.close() - return HTMLResponse( - f"", - status_code=400, - ) - # Maintain/Upsert immutable Code_N for DDF mapping - if current_data_origin_uid: - cur.execute( - "UPDATE code SET code=?, codelist_code='C188727', codelist_table='ddf_terminology' WHERE soa_id=? AND code_uid=?", - (resolved_ddf_code, soa_id, current_data_origin_uid), - ) - new_data_origin_uid = current_data_origin_uid - logger.info( - "ui_update_arm: updated junction code_uid=%s -> table=%s list=%s code=%s", - current_data_origin_uid, - "ddf_terminology", - "C188727", - resolved_ddf_code, - ) - else: - # Create new Code_N, ensuring unique across this SoA - new_data_origin_uid = _get_next_code_uid(cur, soa_id) - cur.execute( - "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", - ( - soa_id, - new_data_origin_uid, - "ddf_terminology", - "C188727", - resolved_ddf_code, - ), - ) - logger.info( - "ui_update_arm: created junction code_uid=%s -> table=%s list=%s code=%s", - new_data_origin_uid, - "ddf_terminology", - "C188727", - resolved_ddf_code, - ) - - # Apply arm field updates (including setting type to code_uid if resolved) - new_name = name if name is not None else row[1] - new_label = label if label is not None else row[2] - new_desc = description if description is not None else row[3] cur.execute( - "UPDATE arm SET name=?, label=?, description=?, type=?, data_origin_type=? WHERE id=? AND soa_id=?", - ( - new_name, - new_label, - new_desc, - new_code_uid, - new_data_origin_uid, - arm_id, - soa_id, - ), - ) - logger.info( - "ui_update_arm: applied UPDATE arm id=%s set name='%s' label='%s' type=%s data_origin_type=%s", - arm_id, - new_name, - new_label, - new_code_uid, - new_data_origin_uid, + "UPDATE element SET teenrl=? WHERE id=? AND soa_id=?", + (new_uid, element_id, soa_id), ) conn.commit() - # Capture post-update code values - post_arm_type_code_value: Optional[str] = None - post_data_origin_code_value: Optional[str] = None - if new_code_uid: - cur.execute( - "SELECT code FROM code WHERE soa_id=? AND code_uid=?", - (soa_id, new_code_uid), - ) - rav = cur.fetchone() - post_arm_type_code_value = rav[0] if rav else None - if new_data_origin_uid: - cur.execute( - "SELECT code FROM code WHERE soa_id=? AND code_uid=?", - (soa_id, new_data_origin_uid), - ) - rdv2 = cur.fetchone() - post_data_origin_code_value = rdv2[0] if rdv2 else None - after_state = { - "id": arm_id, - "name": new_name, - "label": new_label, - "description": new_desc, - "type": new_code_uid, - "data_origin_type": new_data_origin_uid, - "type_code": post_arm_type_code_value, - "data_origin_type_code": post_data_origin_code_value, + + cur.execute( + "SELECT id,name,label,description,element_id,teenrl FROM element WHERE id=? AND soa_id=?", + (element_id, soa_id), + ) + r = cur.fetchone() + after = { + "id": r[0], + "name": r[1], + "label": r[2], + "description": r[3], + "element_id": r[4], + "teenrl": r[5], } - # Record audit if any relevant fields or underlying code mappings changed - if ( - before_state["type"] != after_state["type"] - or before_state["data_origin_type"] != after_state["data_origin_type"] - or prior_arm_type_code_value != post_arm_type_code_value - or prior_data_origin_code_value != post_data_origin_code_value - or before_state["name"] != after_state["name"] - or before_state["label"] != after_state["label"] - or before_state["description"] != after_state["description"] - ): - try: - _record_arm_audit( - soa_id, - "update", - arm_id=arm_id, - before=before_state, - after=after_state, - ) - except Exception: - pass - else: - logger.info( - "ui_update_arm: no-op update detected for arm_id=%s (no field or code changes)", - arm_id, - ) - conn.close() - return HTMLResponse( - f"" + updated_fields = [ + f for f in ["teenrl"] if (before.get(f) or None) != (after.get(f) or None) + ] + _record_element_audit( + soa_id, + "update", + element_id, + before=before, + after={**after, "updated_fields": updated_fields}, ) - - -# UI endpoint for deleting an Arm -@app.post("/ui/soa/{soa_id}/delete_arm", response_class=HTMLResponse) -def ui_delete_arm(request: Request, soa_id: int, arm_id: int = Form(...)): - delete_arm(soa_id, arm_id) + conn.close() return HTMLResponse( f"" ) -# UI endpoint for reordering Arms -@app.post("/ui/soa/{soa_id}/reorder_arms", response_class=HTMLResponse) -def ui_reorder_arms(request: Request, soa_id: int, order: str = Form("")): - """Form handler to reorder existing Arms.""" - if not soa_exists(soa_id): - raise HTTPException(404, "SOA not found") - ids = [int(x) for x in order.split(",") if x.strip().isdigit()] - if not ids: - return HTMLResponse("Invalid order", status_code=400) - conn = _connect() - cur = conn.cursor() - cur.execute("SELECT id FROM arm WHERE soa_id=? ORDER BY order_index", (soa_id,)) - old_order = [r[0] for r in cur.fetchall()] - cur.execute("SELECT id FROM arm WHERE soa_id=?", (soa_id,)) - existing = {r[0] for r in cur.fetchall()} - if set(ids) - existing: - conn.close() - return HTMLResponse("Order contains invalid arm id", status_code=400) - for idx, aid in enumerate(ids, start=1): - cur.execute("UPDATE arm SET order_index=? WHERE id=?", (idx, aid)) - conn.commit() - conn.close() - _record_reorder_audit(soa_id, "arm", old_order, ids) - _record_arm_audit( - soa_id, - "reorder", - arm_id=None, - before={"old_order": old_order}, - after={"new_order": ids}, - ) - return HTMLResponse("OK") - - -# UI endpoint for adding an element -@app.post("/ui/soa/{soa_id}/add_element", response_class=HTMLResponse) -def ui_add_element( +# UI endpoint for updating an element +@app.post("/ui/soa/{soa_id}/update_element", response_class=HTMLResponse) +def ui_update_element( request: Request, soa_id: int, - name: str = Form(...), + element_id: int = Form(...), + name: Optional[str] = Form(None), label: Optional[str] = Form(None), description: Optional[str] = Form(None), - element_transition_start_rule_uid: str = Form(""), - element_transition_end_rule_uid: str = Form(""), + # testrl: Optional[str] = Form(None), + # teenrl: Optional[str] = Form(None), ): - """Form handler to add an element.""" - if not soa_exists(soa_id): - raise HTTPException(404, "SOA not found") - - # Coerce empty testrl from Form to None, otherwise to int - - testrl_uid: Optional[str] = ( - element_transition_start_rule_uid or "" - ).strip() or None - teenrl_uid: Optional[str] = (element_transition_end_rule_uid or "").strip() or None - - payload = ElementCreate( + """Form handler to update an existing Element.""" + # Build payload with provided fields; blanks should clear values + payload = ElementUpdate( name=name, label=label, description=description, - testrl=testrl_uid, - teenrl=teenrl_uid, ) - - # Create the element via the API helper to ensure audits and ordering try: - elements_router.create_element(soa_id, payload) + elements_router.update_element(soa_id, element_id, payload) except Exception: + # Let redirect proceed; detailed errors will appear in API logs pass return HTMLResponse( f"" ) - ''' - name = (name or "").strip() - if not name: - raise HTTPException(400, "Name required") + """ conn = _connect() cur = conn.cursor() - # Determine next order index + cur.execute("SELECT id FROM element WHERE id=? AND soa_id=?", (element_id, soa_id)) + if not cur.fetchone(): + conn.close() + raise HTTPException(404, "Element not found") + # Capture before cur.execute( - "SELECT COALESCE(MAX(order_index),0) FROM element WHERE soa_id=?", (soa_id,) - ) - next_ord = (cur.fetchone() or [0])[0] + 1 - now = datetime.now(timezone.utc).isoformat() - # Check if legacy/non-standard element_id column exists and populate if required - cur.execute("PRAGMA table_info(element)") - element_cols = {r[1] for r in cur.fetchall()} - element_identifier: Optional[str] = None - if "element_id" in element_cols: - # Generate StudyElement_ monotonically increasing for this SOA - element_identifier = _next_element_identifier(soa_id) - cur.execute( - """INSERT INTO element (soa_id,name,label,description,testrl,teenrl,order_index,created_at,element_id) - VALUES (?,?,?,?,?,?,?,?,?)""", - ( - soa_id, - name, - (label or "").strip() or None, - (description or "").strip() or None, - (testrl or "").strip() or None, - (teenrl or "").strip() or None, - next_ord, - now, - element_identifier, - ), - ) - else: - cur.execute( - """INSERT INTO element (soa_id,name,label,description,testrl,teenrl,order_index,created_at) - VALUES (?,?,?,?,?,?,?,?)""", - ( - soa_id, - name, - (label or "").strip() or None, - (description or "").strip() or None, - (testrl or "").strip() or None, - (teenrl or "").strip() or None, - next_ord, - now, - ), - ) - eid = cur.lastrowid - conn.commit() - conn.close() - # Audit should store the logical StudyElement_N in element_audit.element_id, not the row id - _record_element_audit( - soa_id, - "create", - element_identifier, - before=None, - after={ - "id": eid, - "name": name, - "label": (label or "").strip() or None, - "description": (description or "").strip() or None, - "testrl": (testrl or "").strip() or None, - "teenrl": (teenrl or "").strip() or None, - "order_index": next_ord, - "element_id": element_identifier, - }, - ) - return HTMLResponse( - f"" - ) - ''' - - -# UI endpoint for associating a Transition Start Rule with an Element (element.testrl) -@app.post( - "/ui/soa/{soa_id}/set_element_transition_start_rule", response_class=HTMLResponse -) -def ui_set_element_transition_start_rule( - request: Request, - soa_id: int, - element_id: int = Form(...), - element_transition_start_rule_uid: str = Form(...), -): - """Form handler for associating a Transition Start Rule with an Element""" - if not soa_exists(soa_id): - raise HTTPException(404, "SOA not found") - - new_uid = (element_transition_start_rule_uid or "").strip() or None - conn = _connect() - cur = conn.cursor() - cur.execute( - "SELECT id,name,label,description,element_id,testrl FROM element WHERE id=? AND soa_id=?", - (element_id, soa_id), - ) - row = cur.fetchone() - if not row: - conn.close() - raise HTTPException(404, "Element not found") - - before = { - "id": row[0], - "name": row[1], - "label": row[2], - "description": row[3], - "element_id": row[4], - "testrl": row[5], - } - if new_uid is not None: - cur.execute( - "SELECT 1 FROM transition_rule WHERE transition_rule_uid=? AND soa_id=?", - ( - new_uid, - soa_id, - ), - ) - if not cur.fetchone(): - conn.close() - raise HTTPException("Invalid transition rule for this SOA") - - cur.execute( - "UPDATE element SET testrl=? WHERE id=? AND soa_id=?", - (new_uid, element_id, soa_id), - ) - conn.commit() - - cur.execute( - "SELECT id,name,label,description,element_id,testrl FROM element WHERE id=? AND soa_id=?", - (element_id, soa_id), - ) - r = cur.fetchone() - after = { - "id": r[0], - "name": r[1], - "label": r[2], - "description": r[3], - "element_id": r[4], - "testrl": r[5], - } - updated_fields = [ - f for f in ["testrl"] if (before.get(f) or None) != (after.get(f) or None) - ] - _record_element_audit( - soa_id, - "update", - element_id, - before=before, - after={**after, "updated_fields": updated_fields}, - ) - conn.close() - return HTMLResponse( - f"" - ) - - -# UI endpoint for associating a Transition Start Rule with an Element (element.teenrl) -@app.post( - "/ui/soa/{soa_id}/set_element_transition_end_rule", response_class=HTMLResponse -) -def ui_set_element_transition_end_rule( - request: Request, - soa_id: int, - element_id: int = Form(...), - element_transition_end_rule_uid: str = Form(...), -): - """Form handler for associating a Transition End Rule with an Element""" - if not soa_exists(soa_id): - raise HTTPException(404, "SOA not found") - - new_uid = (element_transition_end_rule_uid or "").strip() or None - - conn = _connect() - cur = conn.cursor() - cur.execute( - "SELECT id,name,label,description,element_id,teenrl FROM element WHERE id=? AND soa_id=?", - (element_id, soa_id), - ) - row = cur.fetchone() - if not row: - conn.close() - raise HTTPException(404, "Element not found") - - before = { - "id": row[0], - "name": row[1], - "label": row[2], - "description": row[3], - "element_id": row[4], - "teenrl": row[5], - } - - if new_uid is not None: - cur.execute( - "SELECT 1 FROM transition_rule WHERE transition_rule_uid=? AND soa_id=?", - (new_uid, soa_id), - ) - if not cur.fetchone(): - conn.close() - raise HTTPException(400, "Invalid Transition Rule for this SOA") - - cur.execute( - "UPDATE element SET teenrl=? WHERE id=? AND soa_id=?", - (new_uid, element_id, soa_id), - ) - conn.commit() - - cur.execute( - "SELECT id,name,label,description,element_id,teenrl FROM element WHERE id=? AND soa_id=?", - (element_id, soa_id), - ) - r = cur.fetchone() - after = { - "id": r[0], - "name": r[1], - "label": r[2], - "description": r[3], - "element_id": r[4], - "teenrl": r[5], - } - updated_fields = [ - f for f in ["teenrl"] if (before.get(f) or None) != (after.get(f) or None) - ] - _record_element_audit( - soa_id, - "update", - element_id, - before=before, - after={**after, "updated_fields": updated_fields}, - ) - conn.close() - return HTMLResponse( - f"" - ) - - -# UI endpoint for updating an element -@app.post("/ui/soa/{soa_id}/update_element", response_class=HTMLResponse) -def ui_update_element( - request: Request, - soa_id: int, - element_id: int = Form(...), - name: Optional[str] = Form(None), - label: Optional[str] = Form(None), - description: Optional[str] = Form(None), - # testrl: Optional[str] = Form(None), - # teenrl: Optional[str] = Form(None), -): - """Form handler to update an existing Element.""" - # Build payload with provided fields; blanks should clear values - payload = ElementUpdate( - name=name, - label=label, - description=description, - ) - try: - elements_router.update_element(soa_id, element_id, payload) - except Exception: - # Let redirect proceed; detailed errors will appear in API logs - pass - - return HTMLResponse( - f"" - ) - - """ - conn = _connect() - cur = conn.cursor() - cur.execute("SELECT id FROM element WHERE id=? AND soa_id=?", (element_id, soa_id)) - if not cur.fetchone(): - conn.close() - raise HTTPException(404, "Element not found") - # Capture before - cur.execute( - "SELECT id,name,label,description,testrl,teenrl,order_index,created_at FROM element WHERE id=?", - (element_id,), + "SELECT id,name,label,description,testrl,teenrl,order_index,created_at FROM element WHERE id=?", + (element_id,), ) b = cur.fetchone() before = None @@ -5218,257 +4631,6 @@ def ui_delete_study_cell(request: Request, soa_id: int, study_cell_id: int = For ) -# UI endpoint for adding a new Epoch <- moved to routers/epochs.py -''' -@app.post("/ui/soa/{soa_id}/add_epoch", response_class=HTMLResponse) -def ui_add_epoch( - request: Request, - soa_id: int, - name: str = Form(...), - epoch_label: Optional[str] = Form(None), - epoch_description: Optional[str] = Form(None), - epoch_type_submission_value: Optional[str] = Form(None), -): - """Form handler to add an Epoch.""" - if not soa_exists(soa_id): - raise HTTPException(404, "SOA not found") - conn = _connect() - cur = conn.cursor() - cur.execute("SELECT COUNT(*) FROM epoch WHERE soa_id=?", (soa_id,)) - order_index = cur.fetchone()[0] + 1 - cur.execute("SELECT MAX(epoch_seq) FROM epoch WHERE soa_id=?", (soa_id,)) - row = cur.fetchone() - next_seq = (row[0] or 0) + 1 - # Optional epoch type mapping via code junction (C99079) using API-only map - epoch_type_submission_value = (epoch_type_submission_value or "").strip() or None - selected_code_uid = None - if epoch_type_submission_value: - try: - from .utils import load_epoch_type_map, get_epoch_parent_package_href_cached - - epoch_map = load_epoch_type_map() - except Exception: - epoch_map = {} - # Invert map to find conceptId by submissionValue - concept_id = None - for cid, sv in (epoch_map or {}).items(): - if sv and sv.strip().lower() == epoch_type_submission_value.strip().lower(): - concept_id = cid - break - if concept_id: - # Create a new Code_N for this conceptId under C99079 (API-only) - code_uid = _get_next_code_uid(cur, soa_id) - try: - parent_href = get_epoch_parent_package_href_cached() or None - except Exception: - parent_href = None - cur.execute( - "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", - ( - soa_id, - code_uid, - parent_href, - "C99079", - concept_id, - ), - ) - selected_code_uid = code_uid - cur.execute( - "INSERT INTO epoch (soa_id,name,order_index,epoch_seq,epoch_label,epoch_description,type) VALUES (?,?,?,?,?,?,?)", - ( - soa_id, - name, - order_index, - next_seq, - (epoch_label or "").strip() or None, - (epoch_description or "").strip() or None, - selected_code_uid, - ), - ) - eid = cur.lastrowid - conn.commit() - conn.close() - _record_epoch_audit( - soa_id, - "create", - eid, - before={"type": None}, - after={ - "id": eid, - "name": name, - "order_index": order_index, - "epoch_seq": next_seq, - "epoch_label": (epoch_label or "").strip() or None, - "epoch_description": (epoch_description or "").strip() or None, - "type": selected_code_uid, - }, - ) - return HTMLResponse( - f"" - ) -''' - -# UI endpoint for updating an Epoch <- moved to routers/epochs.py -''' -@app.post("/ui/soa/{soa_id}/update_epoch", response_class=HTMLResponse) -def ui_update_epoch( - request: Request, - soa_id: int, - epoch_id: int = Form(...), - name: Optional[str] = Form(None), - epoch_label: Optional[str] = Form(None), - epoch_description: Optional[str] = Form(None), - epoch_type_submission_value: Optional[str] = Form(None), -): - """Form handler to update an existing Epoch.""" - if not soa_exists(soa_id): - raise HTTPException(404, "SOA not found") - conn = _connect() - cur = conn.cursor() - cur.execute("SELECT 1 FROM epoch WHERE id=? AND soa_id=?", (epoch_id, soa_id)) - if not cur.fetchone(): - conn.close() - raise HTTPException(404, "Epoch not found") - conn.close() - # Capture before - conn_b = _connect() - cur_b = conn_b.cursor() - cur_b.execute( - "SELECT id,name,order_index,epoch_seq,epoch_label,epoch_description FROM epoch WHERE id=?", - (epoch_id,), - ) - b = cur_b.fetchone() - conn_b.close() - before = None - if b: - before = { - "id": b[0], - "name": b[1], - "order_index": b[2], - "epoch_seq": b[3], - "epoch_label": b[4], - "epoch_description": b[5], - } - # Include current type in before snapshot for audit - try: - conn_bt = _connect() - cur_bt = conn_bt.cursor() - cur_bt.execute("SELECT type FROM epoch WHERE id=?", (epoch_id,)) - br = cur_bt.fetchone() - conn_bt.close() - if before is not None: - before["type"] = br[0] if br else None - except Exception: - pass - sets = [] - vals: list[Any] = [] - if name is not None: - sets.append("name=?") - vals.append((name or "").strip() or None) - if epoch_label is not None: - sets.append("epoch_label=?") - vals.append((epoch_label or "").strip() or None) - if epoch_description is not None: - sets.append("epoch_description=?") - vals.append((epoch_description or "").strip() or None) - # Handle epoch type mapping via code junction (C99079) using API-only map - epoch_type_submission_value = (epoch_type_submission_value or "").strip() or None - if epoch_type_submission_value is not None: - # If empty string provided, clear type - if epoch_type_submission_value == "": - sets.append("type=?") - vals.append(None) - else: - # Resolve submission value to conceptId via API-only map - try: - from .utils import ( - load_epoch_type_map, - get_epoch_parent_package_href_cached, - ) - - epoch_map = load_epoch_type_map() - except Exception: - epoch_map = {} - concept_id = None - for cid, sv in (epoch_map or {}).items(): - if ( - sv - and sv.strip().lower() - == epoch_type_submission_value.strip().lower() - ): - concept_id = cid - break - selected_code_uid = None - if concept_id: - conn_t = _connect() - cur_t = conn_t.cursor() - # Always create a new Code_N for C99079 selections (no reuse) - code_uid = _get_next_code_uid(cur_t, soa_id) - try: - parent_href = get_epoch_parent_package_href_cached() or None - except Exception: - parent_href = None - cur_t.execute( - "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", - ( - soa_id, - code_uid, - parent_href, - "C99079", - concept_id, - ), - ) - selected_code_uid = code_uid - conn_t.commit() - conn_t.close() - # Persist epoch.type even if concept_id not found will be None - sets.append("type=?") - vals.append(selected_code_uid) - if sets: - conn_u = _connect() - cur_u = conn_u.cursor() - vals.append(epoch_id) - cur_u.execute(f"UPDATE epoch SET {', '.join(sets)} WHERE id=?", vals) - conn_u.commit() - conn_u.close() - conn_a = _connect() - cur_a = conn_a.cursor() - cur_a.execute( - "SELECT id,name,order_index,epoch_seq,epoch_label,epoch_description FROM epoch WHERE id=?", - (epoch_id,), - ) - r = cur_a.fetchone() - conn_a.close() - after_api = { - "id": r[0], - "name": r[1], - "order_index": r[2], - "epoch_seq": r[3], - "epoch_label": r[4], - "epoch_description": r[5], - "type": None, - } - # Fetch type from epoch for audit after snapshot - conn_ta = _connect() - cur_ta = conn_ta.cursor() - cur_ta.execute("SELECT type FROM epoch WHERE id=?", (epoch_id,)) - tr_after = cur_ta.fetchone() - conn_ta.close() - if tr_after: - after_api["type"] = tr_after[0] - _record_epoch_audit( - soa_id, - "update", - epoch_id, - before=before, - after=after_api, - ) - return HTMLResponse( - f"" - ) -''' - - # Function to compute next available TransitionRule_{N} def _next_transition_rule_uid(soa_id: int) -> str: """Compute next monotonically increasing TransitionRule_N for an SoA. @@ -5938,119 +5100,6 @@ def ui_toggle_cell( return HTMLResponse(cell_html) -# UI code to delete an Encounter/Visit from an SOA <- moved to routers/visits.py -""" -@app.post("/ui/soa/{soa_id}/delete_visit", response_class=HTMLResponse) -def ui_delete_visit(request: Request, soa_id: int, visit_id: int = Form(...)): - if not soa_exists(soa_id): - raise HTTPException(404, "SOA not found") - - try: - # Call through router to avoid stale import bindings - visits_router.delete_visit(soa_id, visit_id) - except HTTPException: - # swallow 404 to keep UX smooth - pass - # If HTMX, use HX-Redirect; else script redirect - if request.headers.get("HX-Request") == "true": - return HTMLResponse("", headers={"HX-Redirect": f"/ui/soa/{int(soa_id)}/edit"}) - return HTMLResponse( - f"" - ) -""" - - -# UI endpoint for associating an Epoch with a Visit/Encounter <- Deprecated (Visits are not directly related to an Epoch) -''' -@app.post("/ui/soa/{soa_id}/set_visit_epoch", response_class=HTMLResponse) -def ui_set_visit_epoch( - request: Request, - soa_id: int, - visit_id: int = Form(...), - epoch_id_raw: str = Form(""), # new field name (blank means clear) - epoch_id: str = Form(""), # legacy field name used by template select -): - """Form handler to associate an Epoch with a Visit/Encounter.""" - if not soa_exists(soa_id): - raise HTTPException(404, "SOA not found") - # Determine provided raw value (prefer epoch_id_raw if non-blank) - raw_val = (epoch_id_raw or "").strip() or (epoch_id or "").strip() - parsed_epoch: Optional[int] = None - if raw_val: - if raw_val.isdigit(): - parsed_epoch = int(raw_val) - else: - raise HTTPException(400, "Invalid epoch_id value") - conn = _connect() - cur = conn.cursor() - cur.execute( - "SELECT id,name,label,order_index,epoch_id,encounter_uid,description FROM visit WHERE id=? AND soa_id=?", - (visit_id, soa_id), - ) - row = cur.fetchone() - if not row: - conn.close() - raise HTTPException(404, "Visit not found") - before = { - "id": row[0], - "name": row[1], - "label": row[2], - "order_index": row[3], - "epoch_id": row[4], - "encounter_uid": row[5], - "description": row[6], - } - if parsed_epoch is not None: - cur.execute( - "SELECT 1 FROM epoch WHERE id=? AND soa_id=?", (parsed_epoch, soa_id) - ) - if not cur.fetchone(): - conn.close() - raise HTTPException(400, "Invalid epoch_id for this SOA") - cur.execute("UPDATE visit SET epoch_id=? WHERE id=?", (parsed_epoch, visit_id)) - conn.commit() - """ - logger.info( - "ui_set_visit_epoch updated visit id=%s soa_id=%s epoch_id=%s raw_val='%s' db_path=%s", - visit_id, - soa_id, - parsed_epoch, - raw_val, - DB_PATH, - ) - """ - # Fetch after and record audit - cur.execute( - "SELECT id,name,label,order_index,epoch_id,encounter_uid,description FROM visit WHERE id=? AND soa_id=?", - (visit_id, soa_id), - ) - r = cur.fetchone() - after = { - "id": r[0], - "name": r[1], - "label": r[2], - "order_index": r[3], - "epoch_id": r[4], - "encounter_uid": r[5], - "description": r[6], - } - updated_fields = [ - f for f in ["epoch_id"] if (before.get(f) or None) != (after.get(f) or None) - ] - _record_visit_audit( - soa_id, - "update", - visit_id, - before=before, - after={**after, "updated_fields": updated_fields}, - ) - conn.close() - return HTMLResponse( - f"" - ) -''' - - # UI endpoint for associating a Transition Start Rule with Visit/Encounter (visit.transitionStartRule) @app.post( "/ui/soa/{soa_id}/set_visit_transition_start_rule", response_class=HTMLResponse @@ -6306,35 +5355,6 @@ def ui_set_timing( ) -# UI endpoint for updating an Encounter/Visit <- moved to routers/visits.py -''' -@app.post("/ui/soa/{soa_id}/update_visit", response_class=HTMLResponse) -def ui_update_visit( - request: Request, - soa_id: int, - visit_id: int = Form(...), - name: Optional[str] = Form(None), - label: Optional[str] = Form(None), - description: Optional[str] = Form(None), -): - """Form handler to update a Visit's mutable fields (name/label/description).""" - # Build payload with provided fields; blanks should clear values - payload = VisitUpdate( - name=name, - label=label, - description=description, - ) - try: - visits_router.update_visit(soa_id, visit_id, payload) - except Exception: - # Let redirect proceed; detailed errors will appear in API logs - pass - return HTMLResponse( - f"" - ) -''' - - # UI endpoint for deleting an Activity @app.post("/ui/soa/{soa_id}/delete_activity", response_class=HTMLResponse) def ui_delete_activity(request: Request, soa_id: int, activity_id: int = Form(...)): @@ -6345,48 +5365,6 @@ def ui_delete_activity(request: Request, soa_id: int, activity_id: int = Form(.. ) -# UI endpoint for deleting an Epoch <- moved to routers/epochs.py -''' -@app.post("/ui/soa/{soa_id}/delete_epoch", response_class=HTMLResponse) -def ui_delete_epoch(request: Request, soa_id: int, epoch_id: int = Form(...)): - """Form handler to delete an Epoch.""" - delete_epoch(soa_id, epoch_id) - return HTMLResponse( - f"" - ) -''' - -# UI endpoint for reordering Encounters/Visits <- Deprecated -''' -@app.post("/ui/soa/{soa_id}/reorder_visits", response_class=HTMLResponse) -def ui_reorder_visits(request: Request, soa_id: int, order: str = Form("")): - """Persist new visit ordering. 'order' is a comma-separated list of visit IDs in desired order.""" - if not soa_exists(soa_id): - raise HTTPException(404, "SOA not found") - ids = [int(x) for x in order.split(",") if x.strip().isdigit()] - if not ids: - return HTMLResponse("Invalid order", status_code=400) - conn = _connect() - cur = conn.cursor() - # Capture existing order BEFORE modifications - cur.execute("SELECT id FROM visit WHERE soa_id=? ORDER BY order_index", (soa_id,)) - old_order = [r[0] for r in cur.fetchall()] - # Validate membership - cur.execute("SELECT id FROM visit WHERE soa_id=?", (soa_id,)) - existing = {r[0] for r in cur.fetchall()} - if set(ids) - existing: - conn.close() - return HTMLResponse("Order contains invalid visit id", status_code=400) - # Apply new order indices - for idx, vid in enumerate(ids, start=1): - cur.execute("UPDATE visit SET order_index=? WHERE id=?", (idx, vid)) - conn.commit() - conn.close() - _record_reorder_audit(soa_id, "visit", old_order, ids) - return HTMLResponse("OK") -''' - - # UI endpoint for reordering Activities @app.post("/ui/soa/{soa_id}/reorder_activities", response_class=HTMLResponse) def ui_reorder_activities(request: Request, soa_id: int, order: str = Form("")): @@ -6416,57 +5394,6 @@ def ui_reorder_activities(request: Request, soa_id: int, order: str = Form("")): return HTMLResponse("OK") -# # UI endpoint for reordering Epochs <- moved to routers/epochs.py -''' -@app.post("/ui/soa/{soa_id}/reorder_epochs", response_class=HTMLResponse) -def ui_reorder_epochs(request: Request, soa_id: int, order: str = Form("")): - """Form handler to persist new epoch ordering.""" - if not soa_exists(soa_id): - raise HTTPException(404, "SOA not found") - ids = [int(x) for x in order.split(",") if x.strip().isdigit()] - if not ids: - return HTMLResponse("Invalid order", status_code=400) - conn = _connect() - cur = conn.cursor() - cur.execute("SELECT id FROM epoch WHERE soa_id=? ORDER BY order_index", (soa_id,)) - old_order = [r[0] for r in cur.fetchall()] - cur.execute("SELECT id FROM epoch WHERE soa_id=?", (soa_id,)) - existing = {r[0] for r in cur.fetchall()} - if set(ids) - existing: - conn.close() - return HTMLResponse("Order contains invalid epoch id", status_code=400) - for idx, eid in enumerate(ids, start=1): - cur.execute("UPDATE epoch SET order_index=? WHERE id=?", (idx, eid)) - conn.commit() - conn.close() - _record_reorder_audit(soa_id, "epoch", old_order, ids) - - # Also record epoch-specific reorder audit for parity with JSON endpoint - def _epoch_types_snapshot(soa_id_int: int) -> list[dict]: - conn_s = _connect() - cur_s = conn_s.cursor() - cur_s.execute( - "SELECT id,type FROM epoch WHERE soa_id=? ORDER BY order_index", - (soa_id_int,), - ) - rows = cur_s.fetchall() - conn_s.close() - return [{"id": rid, "type": rtype} for rid, rtype in rows] - - _record_epoch_audit( - soa_id, - "reorder", - epoch_id=None, - before={ - "old_order": old_order, - "types": _epoch_types_snapshot(soa_id), - }, - after={"new_order": ids}, - ) - return HTMLResponse("OK") -''' - - # Sanitize column headers in the XLSX export def _sanitize_column(name: str) -> str: """Sanitize Excel column header to safe SQLite identifier: lowercase, replace spaces & non-alnum with underscore, collapse repeats.""" @@ -7653,33 +6580,72 @@ def ui_protocol_audit( ) -# Moved to routers/epochs.py -''' -@app.delete("/soa/{soa_id}/epochs/{epoch_id}") -def delete_epoch(soa_id: int, epoch_id: int): - """Delete an Epoch from an SoA.""" - if not soa_exists(soa_id): - raise HTTPException(404, "SOA not found") - conn = _connect() - cur = conn.cursor() - cur.execute("SELECT 1 FROM epoch WHERE id=? AND soa_id=?", (epoch_id, soa_id)) - if not cur.fetchone(): - conn.close() - raise HTTPException(404, "Epoch not found") - cur.execute( - "SELECT id,name,order_index,epoch_seq,epoch_label,epoch_description FROM epoch WHERE id=?", - (epoch_id,), - ) - b = cur.fetchone() - before = None - if b: - before = { - "id": b[0], - "name": b[1], - "order_index": b[2], - "epoch_seq": b[3], - "epoch_label": b[4], - "epoch_description": b[5], +def main(): + import uvicorn + + uvicorn.run("soa_builder.web.app:app", host="0.0.0.0", port=8000, reload=True) + + +if __name__ == "__main__": + + main() + + +# Deprecated (Moved to routers/epochs.py) +""" +def _record_epoch_audit( + soa_id: int, + action: str, + epoch_id: Optional[int], + before: Optional[dict] = None, + after: Optional[dict] = None, +): + try: + conn = _connect() + cur = conn.cursor() + cur.execute( + "INSERT INTO epoch_audit (soa_id, epoch_id, action, before_json, after_json, performed_at) VALUES (?,?,?,?,?,?)", + ( + soa_id, + epoch_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: # pragma: no cover + logger.warning("Failed recording epoch audit: %s", e) +""" +# Moved to routers/epochs.py +''' +@app.delete("/soa/{soa_id}/epochs/{epoch_id}") +def delete_epoch(soa_id: int, epoch_id: int): + """Delete an Epoch from an SoA.""" + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + conn = _connect() + cur = conn.cursor() + cur.execute("SELECT 1 FROM epoch WHERE id=? AND soa_id=?", (epoch_id, soa_id)) + if not cur.fetchone(): + conn.close() + raise HTTPException(404, "Epoch not found") + cur.execute( + "SELECT id,name,order_index,epoch_seq,epoch_label,epoch_description FROM epoch WHERE id=?", + (epoch_id,), + ) + b = cur.fetchone() + before = None + if b: + before = { + "id": b[0], + "name": b[1], + "order_index": b[2], + "epoch_seq": b[3], + "epoch_label": b[4], + "epoch_description": b[5], } # Include current type in before snapshot try: @@ -7706,13 +6672,1027 @@ def delete_epoch(soa_id: int, epoch_id: int): return {"deleted_epoch_id": epoch_id} ''' +# # UI endpoint for reordering Epochs <- moved to routers/epochs.py +''' +@app.post("/ui/soa/{soa_id}/reorder_epochs", response_class=HTMLResponse) +def ui_reorder_epochs(request: Request, soa_id: int, order: str = Form("")): + """Form handler to persist new epoch ordering.""" + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + ids = [int(x) for x in order.split(",") if x.strip().isdigit()] + if not ids: + return HTMLResponse("Invalid order", status_code=400) + conn = _connect() + cur = conn.cursor() + cur.execute("SELECT id FROM epoch WHERE soa_id=? ORDER BY order_index", (soa_id,)) + old_order = [r[0] for r in cur.fetchall()] + cur.execute("SELECT id FROM epoch WHERE soa_id=?", (soa_id,)) + existing = {r[0] for r in cur.fetchall()} + if set(ids) - existing: + conn.close() + return HTMLResponse("Order contains invalid epoch id", status_code=400) + for idx, eid in enumerate(ids, start=1): + cur.execute("UPDATE epoch SET order_index=? WHERE id=?", (idx, eid)) + conn.commit() + conn.close() + _record_reorder_audit(soa_id, "epoch", old_order, ids) -def main(): - import uvicorn + # Also record epoch-specific reorder audit for parity with JSON endpoint + def _epoch_types_snapshot(soa_id_int: int) -> list[dict]: + conn_s = _connect() + cur_s = conn_s.cursor() + cur_s.execute( + "SELECT id,type FROM epoch WHERE soa_id=? ORDER BY order_index", + (soa_id_int,), + ) + rows = cur_s.fetchall() + conn_s.close() + return [{"id": rid, "type": rtype} for rid, rtype in rows] - uvicorn.run("soa_builder.web.app:app", host="0.0.0.0", port=8000, reload=True) + _record_epoch_audit( + soa_id, + "reorder", + epoch_id=None, + before={ + "old_order": old_order, + "types": _epoch_types_snapshot(soa_id), + }, + after={"new_order": ids}, + ) + return HTMLResponse("OK") +''' +# UI endpoint for deleting an Epoch <- moved to routers/epochs.py +''' +@app.post("/ui/soa/{soa_id}/delete_epoch", response_class=HTMLResponse) +def ui_delete_epoch(request: Request, soa_id: int, epoch_id: int = Form(...)): + """Form handler to delete an Epoch.""" + delete_epoch(soa_id, epoch_id) + return HTMLResponse( + f"" + ) +''' + +# UI endpoint for reordering Encounters/Visits <- Deprecated +''' +@app.post("/ui/soa/{soa_id}/reorder_visits", response_class=HTMLResponse) +def ui_reorder_visits(request: Request, soa_id: int, order: str = Form("")): + """Persist new visit ordering. 'order' is a comma-separated list of visit IDs in desired order.""" + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + ids = [int(x) for x in order.split(",") if x.strip().isdigit()] + if not ids: + return HTMLResponse("Invalid order", status_code=400) + conn = _connect() + cur = conn.cursor() + # Capture existing order BEFORE modifications + cur.execute("SELECT id FROM visit WHERE soa_id=? ORDER BY order_index", (soa_id,)) + old_order = [r[0] for r in cur.fetchall()] + # Validate membership + cur.execute("SELECT id FROM visit WHERE soa_id=?", (soa_id,)) + existing = {r[0] for r in cur.fetchall()} + if set(ids) - existing: + conn.close() + return HTMLResponse("Order contains invalid visit id", status_code=400) + # Apply new order indices + for idx, vid in enumerate(ids, start=1): + cur.execute("UPDATE visit SET order_index=? WHERE id=?", (idx, vid)) + conn.commit() + conn.close() + _record_reorder_audit(soa_id, "visit", old_order, ids) + return HTMLResponse("OK") +''' +# UI endpoint for updating an Encounter/Visit <- moved to routers/visits.py +''' +@app.post("/ui/soa/{soa_id}/update_visit", response_class=HTMLResponse) +def ui_update_visit( + request: Request, + soa_id: int, + visit_id: int = Form(...), + name: Optional[str] = Form(None), + label: Optional[str] = Form(None), + description: Optional[str] = Form(None), +): + """Form handler to update a Visit's mutable fields (name/label/description).""" + # Build payload with provided fields; blanks should clear values + payload = VisitUpdate( + name=name, + label=label, + description=description, + ) + try: + visits_router.update_visit(soa_id, visit_id, payload) + except Exception: + # Let redirect proceed; detailed errors will appear in API logs + pass + return HTMLResponse( + f"" + ) +''' +# UI code to delete an Encounter/Visit from an SOA <- moved to routers/visits.py +""" +@app.post("/ui/soa/{soa_id}/delete_visit", response_class=HTMLResponse) +def ui_delete_visit(request: Request, soa_id: int, visit_id: int = Form(...)): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + try: + # Call through router to avoid stale import bindings + visits_router.delete_visit(soa_id, visit_id) + except HTTPException: + # swallow 404 to keep UX smooth + pass + # If HTMX, use HX-Redirect; else script redirect + if request.headers.get("HX-Request") == "true": + return HTMLResponse("", headers={"HX-Redirect": f"/ui/soa/{int(soa_id)}/edit"}) + return HTMLResponse( + f"" + ) +""" -if __name__ == "__main__": - main() +# UI endpoint for associating an Epoch with a Visit/Encounter <- Deprecated (Visits are not directly related to an Epoch) +''' +@app.post("/ui/soa/{soa_id}/set_visit_epoch", response_class=HTMLResponse) +def ui_set_visit_epoch( + request: Request, + soa_id: int, + visit_id: int = Form(...), + epoch_id_raw: str = Form(""), # new field name (blank means clear) + epoch_id: str = Form(""), # legacy field name used by template select +): + """Form handler to associate an Epoch with a Visit/Encounter.""" + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + # Determine provided raw value (prefer epoch_id_raw if non-blank) + raw_val = (epoch_id_raw or "").strip() or (epoch_id or "").strip() + parsed_epoch: Optional[int] = None + if raw_val: + if raw_val.isdigit(): + parsed_epoch = int(raw_val) + else: + raise HTTPException(400, "Invalid epoch_id value") + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id,name,label,order_index,epoch_id,encounter_uid,description FROM visit WHERE id=? AND soa_id=?", + (visit_id, soa_id), + ) + row = cur.fetchone() + if not row: + conn.close() + raise HTTPException(404, "Visit not found") + before = { + "id": row[0], + "name": row[1], + "label": row[2], + "order_index": row[3], + "epoch_id": row[4], + "encounter_uid": row[5], + "description": row[6], + } + if parsed_epoch is not None: + cur.execute( + "SELECT 1 FROM epoch WHERE id=? AND soa_id=?", (parsed_epoch, soa_id) + ) + if not cur.fetchone(): + conn.close() + raise HTTPException(400, "Invalid epoch_id for this SOA") + cur.execute("UPDATE visit SET epoch_id=? WHERE id=?", (parsed_epoch, visit_id)) + conn.commit() + """ + logger.info( + "ui_set_visit_epoch updated visit id=%s soa_id=%s epoch_id=%s raw_val='%s' db_path=%s", + visit_id, + soa_id, + parsed_epoch, + raw_val, + DB_PATH, + ) + """ + # Fetch after and record audit + cur.execute( + "SELECT id,name,label,order_index,epoch_id,encounter_uid,description FROM visit WHERE id=? AND soa_id=?", + (visit_id, soa_id), + ) + r = cur.fetchone() + after = { + "id": r[0], + "name": r[1], + "label": r[2], + "order_index": r[3], + "epoch_id": r[4], + "encounter_uid": r[5], + "description": r[6], + } + updated_fields = [ + f for f in ["epoch_id"] if (before.get(f) or None) != (after.get(f) or None) + ] + _record_visit_audit( + soa_id, + "update", + visit_id, + before=before, + after={**after, "updated_fields": updated_fields}, + ) + conn.close() + return HTMLResponse( + f"" + ) +''' +# UI endpoint for adding a new Epoch <- moved to routers/epochs.py +''' +@app.post("/ui/soa/{soa_id}/add_epoch", response_class=HTMLResponse) +def ui_add_epoch( + request: Request, + soa_id: int, + name: str = Form(...), + epoch_label: Optional[str] = Form(None), + epoch_description: Optional[str] = Form(None), + epoch_type_submission_value: Optional[str] = Form(None), +): + """Form handler to add an Epoch.""" + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + conn = _connect() + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM epoch WHERE soa_id=?", (soa_id,)) + order_index = cur.fetchone()[0] + 1 + cur.execute("SELECT MAX(epoch_seq) FROM epoch WHERE soa_id=?", (soa_id,)) + row = cur.fetchone() + next_seq = (row[0] or 0) + 1 + # Optional epoch type mapping via code junction (C99079) using API-only map + epoch_type_submission_value = (epoch_type_submission_value or "").strip() or None + selected_code_uid = None + if epoch_type_submission_value: + try: + from .utils import load_epoch_type_map, get_epoch_parent_package_href_cached + + epoch_map = load_epoch_type_map() + except Exception: + epoch_map = {} + # Invert map to find conceptId by submissionValue + concept_id = None + for cid, sv in (epoch_map or {}).items(): + if sv and sv.strip().lower() == epoch_type_submission_value.strip().lower(): + concept_id = cid + break + if concept_id: + # Create a new Code_N for this conceptId under C99079 (API-only) + code_uid = _get_next_code_uid(cur, soa_id) + try: + parent_href = get_epoch_parent_package_href_cached() or None + except Exception: + parent_href = None + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + code_uid, + parent_href, + "C99079", + concept_id, + ), + ) + selected_code_uid = code_uid + cur.execute( + "INSERT INTO epoch (soa_id,name,order_index,epoch_seq,epoch_label,epoch_description,type) VALUES (?,?,?,?,?,?,?)", + ( + soa_id, + name, + order_index, + next_seq, + (epoch_label or "").strip() or None, + (epoch_description or "").strip() or None, + selected_code_uid, + ), + ) + eid = cur.lastrowid + conn.commit() + conn.close() + _record_epoch_audit( + soa_id, + "create", + eid, + before={"type": None}, + after={ + "id": eid, + "name": name, + "order_index": order_index, + "epoch_seq": next_seq, + "epoch_label": (epoch_label or "").strip() or None, + "epoch_description": (epoch_description or "").strip() or None, + "type": selected_code_uid, + }, + ) + return HTMLResponse( + f"" + ) +''' + +# UI endpoint for updating an Epoch <- moved to routers/epochs.py +''' +@app.post("/ui/soa/{soa_id}/update_epoch", response_class=HTMLResponse) +def ui_update_epoch( + request: Request, + soa_id: int, + epoch_id: int = Form(...), + name: Optional[str] = Form(None), + epoch_label: Optional[str] = Form(None), + epoch_description: Optional[str] = Form(None), + epoch_type_submission_value: Optional[str] = Form(None), +): + """Form handler to update an existing Epoch.""" + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + conn = _connect() + cur = conn.cursor() + cur.execute("SELECT 1 FROM epoch WHERE id=? AND soa_id=?", (epoch_id, soa_id)) + if not cur.fetchone(): + conn.close() + raise HTTPException(404, "Epoch not found") + conn.close() + # Capture before + conn_b = _connect() + cur_b = conn_b.cursor() + cur_b.execute( + "SELECT id,name,order_index,epoch_seq,epoch_label,epoch_description FROM epoch WHERE id=?", + (epoch_id,), + ) + b = cur_b.fetchone() + conn_b.close() + before = None + if b: + before = { + "id": b[0], + "name": b[1], + "order_index": b[2], + "epoch_seq": b[3], + "epoch_label": b[4], + "epoch_description": b[5], + } + # Include current type in before snapshot for audit + try: + conn_bt = _connect() + cur_bt = conn_bt.cursor() + cur_bt.execute("SELECT type FROM epoch WHERE id=?", (epoch_id,)) + br = cur_bt.fetchone() + conn_bt.close() + if before is not None: + before["type"] = br[0] if br else None + except Exception: + pass + sets = [] + vals: list[Any] = [] + if name is not None: + sets.append("name=?") + vals.append((name or "").strip() or None) + if epoch_label is not None: + sets.append("epoch_label=?") + vals.append((epoch_label or "").strip() or None) + if epoch_description is not None: + sets.append("epoch_description=?") + vals.append((epoch_description or "").strip() or None) + # Handle epoch type mapping via code junction (C99079) using API-only map + epoch_type_submission_value = (epoch_type_submission_value or "").strip() or None + if epoch_type_submission_value is not None: + # If empty string provided, clear type + if epoch_type_submission_value == "": + sets.append("type=?") + vals.append(None) + else: + # Resolve submission value to conceptId via API-only map + try: + from .utils import ( + load_epoch_type_map, + get_epoch_parent_package_href_cached, + ) + + epoch_map = load_epoch_type_map() + except Exception: + epoch_map = {} + concept_id = None + for cid, sv in (epoch_map or {}).items(): + if ( + sv + and sv.strip().lower() + == epoch_type_submission_value.strip().lower() + ): + concept_id = cid + break + selected_code_uid = None + if concept_id: + conn_t = _connect() + cur_t = conn_t.cursor() + # Always create a new Code_N for C99079 selections (no reuse) + code_uid = _get_next_code_uid(cur_t, soa_id) + try: + parent_href = get_epoch_parent_package_href_cached() or None + except Exception: + parent_href = None + cur_t.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + code_uid, + parent_href, + "C99079", + concept_id, + ), + ) + selected_code_uid = code_uid + conn_t.commit() + conn_t.close() + # Persist epoch.type even if concept_id not found will be None + sets.append("type=?") + vals.append(selected_code_uid) + if sets: + conn_u = _connect() + cur_u = conn_u.cursor() + vals.append(epoch_id) + cur_u.execute(f"UPDATE epoch SET {', '.join(sets)} WHERE id=?", vals) + conn_u.commit() + conn_u.close() + conn_a = _connect() + cur_a = conn_a.cursor() + cur_a.execute( + "SELECT id,name,order_index,epoch_seq,epoch_label,epoch_description FROM epoch WHERE id=?", + (epoch_id,), + ) + r = cur_a.fetchone() + conn_a.close() + after_api = { + "id": r[0], + "name": r[1], + "order_index": r[2], + "epoch_seq": r[3], + "epoch_label": r[4], + "epoch_description": r[5], + "type": None, + } + # Fetch type from epoch for audit after snapshot + conn_ta = _connect() + cur_ta = conn_ta.cursor() + cur_ta.execute("SELECT type FROM epoch WHERE id=?", (epoch_id,)) + tr_after = cur_ta.fetchone() + conn_ta.close() + if tr_after: + after_api["type"] = tr_after[0] + _record_epoch_audit( + soa_id, + "update", + epoch_id, + before=before, + after=after_api, + ) + return HTMLResponse( + f"" + ) +''' + +# UI endpoint for creating an Encounter/Visit <- Deprecated (moved to routers/visits.py) +""" +@app.post("/ui/soa/{soa_id}/add_visit", response_class=HTMLResponse) +def ui_add_visit( + request: Request, + soa_id: int, + name: str = Form(...), + label: Optional[str] = Form(None), + epoch_id: Optional[str] = Form(None), + description: Optional[str] = Form(None), +): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + # Coerce empty epoch_id from form to None, otherwise to int + parsed_epoch_id: Optional[int] = None + if epoch_id is not None: + eid = str(epoch_id).strip() + if eid: + try: + parsed_epoch_id = int(eid) + except ValueError: + parsed_epoch_id = None + + payload = VisitCreate( + name=name, + label=label, + epoch_id=parsed_epoch_id, + description=description, + ) + # Create the visit via the API helper to ensure audits and ordering + try: + visits_router.add_visit(soa_id, payload) + except Exception: + pass + + return HTMLResponse( + f"" + ) +""" + + +# UI endpoint for adding a new Arm <- Deprecated (moved to routers/arms.py) +''' +@app.post("/ui/soa/{soa_id}/add_arm", response_class=HTMLResponse) +async def ui_add_arm( + request: Request, + soa_id: int, + name: str = Form(...), + label: Optional[str] = Form(None), + description: Optional[str] = Form(None), + element_id: Optional[str] = Form(None), +): + """Form handler to create a new Arm.""" + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + # Accept blank/empty element selection gracefully. The form may submit "" which would 422 with Optional[int]. + eid = int(element_id) if element_id and element_id.strip().isdigit() else None + payload = ArmCreate(name=name, label=label, description=description, element_id=eid) + # Create base arm (function may not return id; fetch if needed) + created = create_arm(soa_id, payload) + # routers.arms.create_arm returns a row dict; extract id + new_arm_id = None + try: + if isinstance(created, dict): + new_arm_id = created.get("id") + elif isinstance(created, int): + new_arm_id = created + except Exception: + new_arm_id = None + if not new_arm_id: + try: + conn_tmp = _connect() + cur_tmp = conn_tmp.cursor() + cur_tmp.execute( + "SELECT id FROM arm WHERE soa_id=? ORDER BY id DESC LIMIT 1", + (soa_id,), + ) + rtmp = cur_tmp.fetchone() + new_arm_id = rtmp[0] if rtmp else None + conn_tmp.close() + except Exception: + new_arm_id = None + if not new_arm_id: + return HTMLResponse( + f"", + status_code=500, + ) + # Read optional type fields with hyphenated names + try: + form_data = await request.form() + arm_type_submission = (form_data.get("arm-type") or "").strip() + data_origin_type_submission = (form_data.get("data-origin-type") or "").strip() + except Exception: + arm_type_submission = "" + data_origin_type_submission = "" + + # If type selections provided, resolve to terminology codes and persist via junction table + if arm_type_submission or data_origin_type_submission: + conn = _connect() + cur = conn.cursor() + logger.info( + "ui_add_arm: received type selections arm-type='%s', data-origin-type='%s' for soa_id=%s arm_id=%s", + arm_type_submission, + data_origin_type_submission, + soa_id, + new_arm_id, + ) + new_type_uid: Optional[str] = None + new_data_origin_uid: Optional[str] = None + if arm_type_submission: + cur.execute( + "SELECT code FROM protocol_terminology WHERE codelist_code='C174222' AND (cdisc_submission_value=? OR LOWER(TRIM(cdisc_submission_value))=LOWER(TRIM(?)))", + (arm_type_submission, arm_type_submission), + ) + r = cur.fetchone() + resolved_code = r[0] if r else None + if resolved_code is None: + logger.warning( + "ui_add_arm: unknown arm type submission '%s' for soa_id=%s", + arm_type_submission, + soa_id, + ) + conn.close() + return HTMLResponse( + f"", + status_code=400, + ) + # Create Code_N + new_type_uid = _get_next_code_uid(cur, soa_id) + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + new_type_uid, + "protocol_terminology", + "C174222", + resolved_code, + ), + ) + logger.info( + "ui_add_arm: created code junction %s -> table=%s list=%s code=%s", + new_type_uid, + "protocol_terminology", + "C174222", + resolved_code, + ) + if data_origin_type_submission: + cur.execute( + "SELECT code FROM ddf_terminology WHERE codelist_code='C188727' AND (cdisc_submission_value=? OR LOWER(TRIM(cdisc_submission_value))=LOWER(TRIM(?)))", + (data_origin_type_submission, data_origin_type_submission), + ) + r2 = cur.fetchone() + resolved_ddf_code = r2[0] if r2 else None + if resolved_ddf_code is None: + logger.warning( + "ui_add_arm: unknown data origin type submission '%s' for soa_id=%s", + data_origin_type_submission, + soa_id, + ) + conn.close() + # Properly escape the value for safety in HTML/JS context + escaped_selection = json.dumps(data_origin_type_submission) + return HTMLResponse( + f"", + status_code=400, + ) + # Create Code_N (continue numbering) + new_data_origin_uid = _get_next_code_uid(cur, soa_id) + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + new_data_origin_uid, + "ddf_terminology", + "C188727", + resolved_ddf_code, + ), + ) + logger.info( + "ui_add_arm: created code junction %s -> table=%s list=%s code=%s", + new_data_origin_uid, + "ddf_terminology", + "C188727", + resolved_ddf_code, + ) + # Update arm row with new code_uids + if new_type_uid or new_data_origin_uid: + cur.execute( + "UPDATE arm SET type=COALESCE(?, type), data_origin_type=COALESCE(?, data_origin_type) WHERE id=? AND soa_id=?", + (new_type_uid, new_data_origin_uid, new_arm_id, soa_id), + ) + logger.info( + "ui_add_arm: updated arm id=%s set type=%s data_origin_type=%s", + new_arm_id, + new_type_uid, + new_data_origin_uid, + ) + conn.commit() + # routers.arms.create_arm already records a create audit; avoid duplicating here + conn.close() + return HTMLResponse( + f"" + ) +''' + +# UI endpoint for updating an Arm <- Deprecated (moved to routers/arms.py) +''' +@app.post("/ui/soa/{soa_id}/update_arm", response_class=HTMLResponse) +async def ui_update_arm( + request: Request, + soa_id: int, + arm_id: int = Form(...), + name: Optional[str] = Form(None), + label: Optional[str] = Form(None), + description: Optional[str] = Form(None), + element_id: Optional[str] = Form(None), +): + """Form handler to update an existing Arm.""" + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + # Read raw form to capture field names with hyphens: 'arm-type' and 'data-origin-type' + try: + form_data = await request.form() + arm_type_submission = (form_data.get("arm-type") or "").strip() + data_origin_type_submission = (form_data.get("data-origin-type") or "").strip() + except Exception: + arm_type_submission = "" + data_origin_type_submission = "" + logger.info( + "ui_update_arm: arm_id=%s soa_id=%s incoming arm-type='%s' data-origin-type='%s'", + arm_id, + soa_id, + arm_type_submission, + data_origin_type_submission, + ) + + # Fetch current arm (including existing type code_uid if any) + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id, name, label, description, COALESCE(type,''), COALESCE(data_origin_type,'') FROM arm WHERE id=? AND soa_id=?", + (arm_id, soa_id), + ) + row = cur.fetchone() + if not row: + conn.close() + raise HTTPException(404, "Arm not found") + current_code_uid = row[4] or None + current_data_origin_uid = row[5] or None + # Capture prior code values for audits when code mapping changes without uid change + prior_arm_type_code_value: Optional[str] = None + prior_data_origin_code_value: Optional[str] = None + if current_code_uid: + cur.execute( + "SELECT code FROM code WHERE soa_id=? AND code_uid=?", + (soa_id, current_code_uid), + ) + rcv = cur.fetchone() + prior_arm_type_code_value = rcv[0] if rcv else None + if current_data_origin_uid: + cur.execute( + "SELECT code FROM code WHERE soa_id=? AND code_uid=?", + (soa_id, current_data_origin_uid), + ) + rdv = cur.fetchone() + prior_data_origin_code_value = rdv[0] if rdv else None + before_state = { + "id": row[0], + "name": row[1], + "label": row[2], + "description": row[3], + "type": current_code_uid, + "data_origin_type": current_data_origin_uid, + } + + # Resolve submission value to protocol terminology code (C174222) + resolved_code: Optional[str] = None + if arm_type_submission: + cur.execute( + "SELECT code FROM protocol_terminology WHERE codelist_code='C174222' AND (cdisc_submission_value=? OR LOWER(TRIM(cdisc_submission_value))=LOWER(TRIM(?)))", + (arm_type_submission, arm_type_submission), + ) + r = cur.fetchone() + resolved_code = r[0] if r else None + if resolved_code is None: + logger.warning( + "ui_update_arm: unknown arm type submission '%s' for soa_id=%s arm_id=%s", + arm_type_submission, + soa_id, + arm_id, + ) + conn.close() + return HTMLResponse( + f"", + status_code=400, + ) + + # Maintain code table row with immutable code_uid (Code_N unique per SoA) + new_code_uid = current_code_uid + if resolved_code is not None: + if current_code_uid: + # Update existing junction row for this code_uid + cur.execute( + "UPDATE code SET code=?, codelist_code='C174222', codelist_table='protocol_terminology' WHERE soa_id=? AND code_uid=?", + (resolved_code, soa_id, current_code_uid), + ) + logger.info( + "ui_update_arm: updated junction code_uid=%s -> table=%s list=%s code=%s", + current_code_uid, + "protocol_terminology", + "C174222", + resolved_code, + ) + else: + # Create new Code_N within this SoA + new_code_uid = _get_next_code_uid(cur, soa_id) + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + new_code_uid, + "protocol_terminology", + "C174222", + resolved_code, + ), + ) + logger.info( + "ui_update_arm: created junction code_uid=%s -> table=%s list=%s code=%s", + new_code_uid, + "protocol_terminology", + "C174222", + resolved_code, + ) + + # Resolve Data Origin Type submission value to DDF terminology code (C188727) + resolved_ddf_code: Optional[str] = None + new_data_origin_uid = current_data_origin_uid + if data_origin_type_submission: + cur.execute( + "SELECT code FROM ddf_terminology WHERE codelist_code='C188727' AND (cdisc_submission_value=? OR LOWER(TRIM(cdisc_submission_value))=LOWER(TRIM(?)))", + (data_origin_type_submission, data_origin_type_submission), + ) + r2 = cur.fetchone() + resolved_ddf_code = r2[0] if r2 else None + if resolved_ddf_code is None: + logger.warning( + "ui_update_arm: unknown data origin type submission '%s' for soa_id=%s arm_id=%s", + data_origin_type_submission, + soa_id, + arm_id, + ) + conn.close() + return HTMLResponse( + f"", + status_code=400, + ) + # Maintain/Upsert immutable Code_N for DDF mapping + if current_data_origin_uid: + cur.execute( + "UPDATE code SET code=?, codelist_code='C188727', codelist_table='ddf_terminology' WHERE soa_id=? AND code_uid=?", + (resolved_ddf_code, soa_id, current_data_origin_uid), + ) + new_data_origin_uid = current_data_origin_uid + logger.info( + "ui_update_arm: updated junction code_uid=%s -> table=%s list=%s code=%s", + current_data_origin_uid, + "ddf_terminology", + "C188727", + resolved_ddf_code, + ) + else: + # Create new Code_N, ensuring unique across this SoA + new_data_origin_uid = _get_next_code_uid(cur, soa_id) + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + new_data_origin_uid, + "ddf_terminology", + "C188727", + resolved_ddf_code, + ), + ) + logger.info( + "ui_update_arm: created junction code_uid=%s -> table=%s list=%s code=%s", + new_data_origin_uid, + "ddf_terminology", + "C188727", + resolved_ddf_code, + ) + + # Apply arm field updates (including setting type to code_uid if resolved) + new_name = name if name is not None else row[1] + new_label = label if label is not None else row[2] + new_desc = description if description is not None else row[3] + cur.execute( + "UPDATE arm SET name=?, label=?, description=?, type=?, data_origin_type=? WHERE id=? AND soa_id=?", + ( + new_name, + new_label, + new_desc, + new_code_uid, + new_data_origin_uid, + arm_id, + soa_id, + ), + ) + logger.info( + "ui_update_arm: applied UPDATE arm id=%s set name='%s' label='%s' type=%s data_origin_type=%s", + arm_id, + new_name, + new_label, + new_code_uid, + new_data_origin_uid, + ) + conn.commit() + # Capture post-update code values + post_arm_type_code_value: Optional[str] = None + post_data_origin_code_value: Optional[str] = None + if new_code_uid: + cur.execute( + "SELECT code FROM code WHERE soa_id=? AND code_uid=?", + (soa_id, new_code_uid), + ) + rav = cur.fetchone() + post_arm_type_code_value = rav[0] if rav else None + if new_data_origin_uid: + cur.execute( + "SELECT code FROM code WHERE soa_id=? AND code_uid=?", + (soa_id, new_data_origin_uid), + ) + rdv2 = cur.fetchone() + post_data_origin_code_value = rdv2[0] if rdv2 else None + after_state = { + "id": arm_id, + "name": new_name, + "label": new_label, + "description": new_desc, + "type": new_code_uid, + "data_origin_type": new_data_origin_uid, + "type_code": post_arm_type_code_value, + "data_origin_type_code": post_data_origin_code_value, + } + # Record audit if any relevant fields or underlying code mappings changed + if ( + before_state["type"] != after_state["type"] + or before_state["data_origin_type"] != after_state["data_origin_type"] + or prior_arm_type_code_value != post_arm_type_code_value + or prior_data_origin_code_value != post_data_origin_code_value + or before_state["name"] != after_state["name"] + or before_state["label"] != after_state["label"] + or before_state["description"] != after_state["description"] + ): + try: + _record_arm_audit( + soa_id, + "update", + arm_id=arm_id, + before=before_state, + after=after_state, + ) + except Exception: + pass + else: + logger.info( + "ui_update_arm: no-op update detected for arm_id=%s (no field or code changes)", + arm_id, + ) + conn.close() + return HTMLResponse( + f"" + ) +''' + +# UI endpoint for deleting an Arm <- Deprecated (moved to routers/arms.py) +""" +@app.post("/ui/soa/{soa_id}/delete_arm", response_class=HTMLResponse) +def ui_delete_arm(request: Request, soa_id: int, arm_id: int = Form(...)): + delete_arm(soa_id, arm_id) + return HTMLResponse( + f"" + ) +""" + +# UI endpoint for reordering Arms <- Deprecated (no longer needed) +''' +@app.post("/ui/soa/{soa_id}/reorder_arms", response_class=HTMLResponse) +def ui_reorder_arms(request: Request, soa_id: int, order: str = Form("")): + """Form handler to reorder existing Arms.""" + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + ids = [int(x) for x in order.split(",") if x.strip().isdigit()] + if not ids: + return HTMLResponse("Invalid order", status_code=400) + conn = _connect() + cur = conn.cursor() + cur.execute("SELECT id FROM arm WHERE soa_id=? ORDER BY order_index", (soa_id,)) + old_order = [r[0] for r in cur.fetchall()] + cur.execute("SELECT id FROM arm WHERE soa_id=?", (soa_id,)) + existing = {r[0] for r in cur.fetchall()} + if set(ids) - existing: + conn.close() + return HTMLResponse("Order contains invalid arm id", status_code=400) + for idx, aid in enumerate(ids, start=1): + cur.execute("UPDATE arm SET order_index=? WHERE id=?", (idx, aid)) + conn.commit() + conn.close() + _record_reorder_audit(soa_id, "arm", old_order, ids) + _record_arm_audit( + soa_id, + "reorder", + arm_id=None, + before={"old_order": old_order}, + after={"new_order": ids}, + ) + return HTMLResponse("OK") +''' +# Deprecated (new definition in arms.py) +""" +def _record_arm_audit( + soa_id: int, + action: str, + arm_id: Optional[int], + before: Optional[dict] = None, + after: Optional[dict] = None, +): + try: + conn = _connect() + cur = conn.cursor() + cur.execute( + "INSERT INTO arm_audit (soa_id, arm_id, action, before_json, after_json, performed_at) VALUES (?,?,?,?,?,?)", + ( + soa_id, + arm_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: # pragma: no cover + logger.warning("Failed recording arm audit: %s", e) +""" diff --git a/src/soa_builder/web/routers/arms.py b/src/soa_builder/web/routers/arms.py index f5330f6..8298abb 100644 --- a/src/soa_builder/web/routers/arms.py +++ b/src/soa_builder/web/routers/arms.py @@ -1,25 +1,39 @@ import logging -from typing import List +import os +from typing import List, Optional -from fastapi import APIRouter, HTTPException -from fastapi.responses import JSONResponse +from fastapi import APIRouter, HTTPException, Request, Form +from fastapi.responses import JSONResponse, HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates from ..audit import _record_arm_audit, _record_reorder_audit from ..db import _connect from ..schemas import ArmCreate, ArmUpdate -from ..utils import soa_exists +from ..utils import ( + get_next_code_uid as _get_next_code_uid, + soa_exists, + load_arm_type_map, + load_arm_data_origin_type_map, +) -router = APIRouter(prefix="/soa/{soa_id}") +router = APIRouter() logger = logging.getLogger("soa_builder.web.routers.arms") +templates = Jinja2Templates( + directory=os.path.join(os.path.dirname(__file__), "..", "templates") +) -# Removed local _soa_exists; using shared utils.soa_exists +def _nz(s: Optional[str]) -> Optional[str]: + s = (s or "").strip() + return s or None -@router.get("/arms", response_class=JSONResponse) +# API endpoint for listing Arms +@router.get("/soa/{soa_id}/arms", response_class=JSONResponse, response_model=None) def list_arms(soa_id: int): if not soa_exists(soa_id): raise HTTPException(404, "SOA not found") + conn = _connect() cur = conn.cursor() cur.execute( @@ -43,19 +57,82 @@ def list_arms(soa_id: int): return rows -@router.post("/arms", response_class=JSONResponse, status_code=201) +# UI code for listing arms +@router.get("/ui/soa/{soa_id}/arms", response_class=HTMLResponse) +def ui_list_arms(request: Request, soa_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + arms = list_arms(soa_id) + + conn = _connect() + cur = conn.cursor() + # Map arm.type (code_uid) -> conceptId for Arm type (C174222) + cur.execute( + "SELECT code_uid,code FROM code WHERE soa_id=? AND codelist_code='C174222'", + (soa_id,), + ) + type_rows = cur.fetchall() + # Map arm.data_origin_type (code_uid) -> conceptId for Arm Data Origin Type (C188727) + cur.execute( + "SELECT code_uid,code FROM code WHERE soa_id=? AND codelist_code='C188727'", + (soa_id,), + ) + data_origin_rows = cur.fetchall() + conn.close() + + type_code_map = {row[0]: row[1] for row in type_rows if row[0]} + data_origin_code_map = {row[0]: row[1] for row in data_origin_rows if row[0]} + + for a in arms: + # Resolve Arm Type + type_uid = a.get("type") + type_concept_id = type_code_map.get(type_uid, "") + if not type_concept_id and type_uid: + type_concept_id = type_uid + a["type_concept_id"] = type_concept_id + + # Resolve Arm Data Origin Type + do_uid = a.get("data_origin_type") + data_origin_concept_id = data_origin_code_map.get(do_uid, "") + if not data_origin_concept_id and do_uid: + data_origin_concept_id = do_uid + a["data_origin_type_concept_id"] = data_origin_concept_id + + arm_type_options = load_arm_type_map() + arm_data_origin_type_options = load_arm_data_origin_type_map() + + return templates.TemplateResponse( + request, + "arms.html", + { + "request": request, + "soa_id": soa_id, + "arms": arms, + "arm_type_options": arm_type_options, + "arm_data_origin_type_options": arm_data_origin_type_options, + }, + ) + + +# API endpoint for creating an Arm +@router.post("/soa/{soa_id}/arms", response_class=JSONResponse, status_code=201) def create_arm(soa_id: int, payload: ArmCreate): if not soa_exists(soa_id): raise HTTPException(404, "SOA not found") + name = (payload.name or "").strip() if not name: - raise HTTPException(400, "Name required") + raise HTTPException(400, "Arm name required") + conn = _connect() cur = conn.cursor() cur.execute( "SELECT COALESCE(MAX(order_index),0) FROM arm WHERE soa_id=?", (soa_id,) ) next_ord = (cur.fetchone() or [0])[0] + 1 + + # Code to create arm_uid and increment order_index cur.execute( "SELECT arm_uid FROM arm WHERE soa_id=? AND arm_uid LIKE 'StudyArm_%'", (soa_id,), @@ -77,6 +154,41 @@ def create_arm(soa_id: int, payload: ArmCreate): next_n = (max(used_nums) if used_nums else 0) + 1 new_uid = f"StudyArm_{next_n}" + # Generate Code_{N} for type only if value selected + arm_type_value = (payload.type or "").strip() + arm_type = None + if arm_type_value: + arm_type = _get_next_code_uid(cur, soa_id) + logger.info("arm type: %s", arm_type) + arm_type_codelist_table = "db://protocol_terminology" + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + arm_type, + arm_type_codelist_table, + "C174222", + arm_type_value, + ), + ) + + arm_data_origin_type_value = (payload.data_origin_type or "").strip() + arm_data_origin_type = None + if arm_data_origin_type_value: + arm_data_origin_type = _get_next_code_uid(cur, soa_id) + logger.info("arm dataOriginType: %s", arm_data_origin_type) + arm_data_origin_type_codelist_table = "db://ddf_terminology" + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + arm_data_origin_type, + arm_data_origin_type_codelist_table, + "C188727", + arm_data_origin_type_value, + ), + ) + cur.execute( """INSERT INTO arm (soa_id,name,label,description,type,data_origin_type,order_index,arm_uid) VALUES (?,?,?,?,?,?,?,?)""", @@ -85,8 +197,8 @@ def create_arm(soa_id: int, payload: ArmCreate): name, (payload.label or "").strip() or None, (payload.description or "").strip() or None, - (payload.type or "").strip() or None, - (payload.data_origin_type or "").strip() or None, + arm_type, + arm_data_origin_type, next_ord, new_uid, ), @@ -94,7 +206,7 @@ def create_arm(soa_id: int, payload: ArmCreate): arm_id = cur.lastrowid conn.commit() conn.close() - row = { + after = { "id": arm_id, "name": name, "label": (payload.label or "").strip() or None, @@ -104,14 +216,41 @@ def create_arm(soa_id: int, payload: ArmCreate): "order_index": next_ord, "arm_uid": new_uid, } - _record_arm_audit(soa_id, "create", arm_id, before=None, after=row) - return row + _record_arm_audit(soa_id, "create", arm_id, before=None, after=after) + return after + + +# UI code for creating Arm +@router.post("/ui/soa/{soa_id}/arms/create") +def ui_create_arm( + request: Request, + soa_id: int, + name: str = Form(...), + label: Optional[str] = Form(None), + description: Optional[str] = Form(None), + type: Optional[str] = Form(None), + data_origin_type: Optional[str] = Form(None), +): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + payload = ArmCreate( + name=name, + label=label, + description=description, + type=type, + data_origin_type=data_origin_type, + ) + create_arm(soa_id, payload) + return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/arms", status_code=303) -@router.patch("/arms/{arm_id}", response_class=JSONResponse) +# API endpoint for updating an Arm +@router.patch("/soa/{soa_id}/arms/{arm_id}", response_class=JSONResponse) def update_arm(soa_id: int, arm_id: int, payload: ArmUpdate): if not soa_exists(soa_id): raise HTTPException(404, "SOA not found") + conn = _connect() cur = conn.cursor() cur.execute( @@ -122,6 +261,7 @@ def update_arm(soa_id: int, arm_id: int, payload: ArmUpdate): if not row: conn.close() raise HTTPException(404, "Arm not found") + before = { "id": row[0], "name": row[1], @@ -139,27 +279,112 @@ def update_arm(soa_id: int, arm_id: int, payload: ArmUpdate): if payload.description is not None else before["description"] ) - new_type = payload.type if payload.type is not None else before["type"] - new_origin = ( - payload.data_origin_type - if payload.data_origin_type is not None - else before["data_origin_type"] - ) + cur.execute( - "UPDATE arm SET name=?, label=?, description=?, type=?, data_origin_type=? WHERE id=?", + "UPDATE arm SET name=?, label=?, description=? WHERE id=? and soa_id=?", ( - (new_name or "").strip() or None, - (new_label or "").strip() or None, - (new_desc or "").strip() or None, - (new_type or "").strip() or None, - (new_origin or "").strip() or None, + _nz(new_name), + _nz(new_label), + _nz(new_desc), arm_id, + soa_id, ), ) conn.commit() + + new_type = (payload.type or "").strip() + type_uid = before["type"] + type_codelist_table = "db://protocol_terminology" + if new_type: + if not type_uid: + # Create new Code_{N} and attach to arm.type + type_uid = _get_next_code_uid(cur, soa_id) + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + type_uid, + type_codelist_table, + "C174222", + new_type, + ), + ) + cur.execute( + "UPDATE arm SET type=? WHERE id=? AND soa_id=?", + (type_uid, arm_id, soa_id), + ) + else: + cur.execute( + "UPDATE code SET code=? WHERE soa_id=? AND code_uid=?", + (new_type, soa_id, type_uid), + ) + if cur.rowcount == 0: + # Fallback if code row is missing + type_uid = _get_next_code_uid(cur, soa_id) + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + type_uid, + type_codelist_table, + "C174222", + new_type, + ), + ) + cur.execute( + "UPDATE arm SET type=? WHERE id=? AND soa_id=?", + (type_uid, arm_id, soa_id), + ) + conn.commit() + + new_data_origin_type = (payload.data_origin_type or "").strip() + data_origin_type_uid = before["data_origin_type"] + data_origin_type_codelist_table = "db://ddf_terminology" + if new_data_origin_type: + if not data_origin_type_uid: + data_origin_type_uid = _get_next_code_uid(cur, soa_id) + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + data_origin_type_uid, + data_origin_type_codelist_table, + "C188727", + new_data_origin_type, + ), + ) + cur.execute( + "UPDATE arm SET data_origin_type=? WHERE id=? AND soa_id=?", + (data_origin_type_uid, arm_id, soa_id), + ) + else: + cur.execute( + "UPDATE code SET code=? WHERE soa_id=? AND code_uid=?", + (new_data_origin_type, soa_id, data_origin_type_uid), + ) + if cur.rowcount == 0: + data_origin_type_uid = _get_next_code_uid(cur, soa_id) + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + data_origin_type_uid, + data_origin_type_codelist_table, + "C188727", + new_data_origin_type, + ), + ) + cur.execute( + "UPDATE arm SET data_origin_type=? WHERE id=? AND soa_id=?", + (data_origin_type_uid, arm_id, soa_id), + ) + conn.commit() + cur.execute( - "SELECT id,name,label,description,type,data_origin_type,order_index,arm_uid FROM arm WHERE id=?", - (arm_id,), + """ + SELECT id,name,label,description,type,data_origin_type,order_index,arm_uid FROM arm WHERE id=? AND soa_id=? + """, + (arm_id, soa_id), ) r = cur.fetchone() conn.close() @@ -173,8 +398,16 @@ def update_arm(soa_id: int, arm_id: int, payload: ArmUpdate): "order_index": r[6], "arm_uid": r[7], } - mutable = ["name", "label", "description", "type", "data_origin_type"] - updated_fields = [f for f in mutable if before.get(f) != after.get(f)] + mutable = { + "name", + "label", + "description", + "type", + "dataOriginType", + } + updated_fields = [ + f for f in mutable if (before.get(f) or None) != (after.get(f) or None) + ] _record_arm_audit( soa_id, "update", @@ -185,29 +418,56 @@ def update_arm(soa_id: int, arm_id: int, payload: ArmUpdate): return {**after, "updated_fields": updated_fields} -@router.delete("/arms/{arm_id}", response_class=JSONResponse) +# UI code for updating Arm +@router.post("/ui/soa/{soa_id}/arms/{arm_id}/update") +def ui_update_arm( + request: Request, + soa_id: int, + arm_id: int, + name: Optional[str] = Form(None), + label: Optional[str] = Form(None), + description: Optional[str] = Form(None), + type: Optional[str] = Form(None), + data_origin_type: Optional[str] = Form(None), +): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + payload = ArmUpdate( + name=name, + label=label, + description=description, + type=type, + data_origin_type=data_origin_type, + ) + update_arm(soa_id, arm_id, payload) + return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/arms", status_code=303) + + +# API endpoint for deleting an Arm +@router.delete( + "/soa/{soa_id}/arms/{arm_id}", response_class=JSONResponse, response_model=None +) def delete_arm(soa_id: int, arm_id: int): if not soa_exists(soa_id): raise HTTPException(404, "SOA not found") + conn = _connect() cur = conn.cursor() cur.execute( - "SELECT id,name,label,description,type,data_origin_type,order_index,arm_uid FROM arm WHERE id=? AND soa_id=?", + "SELECT id,arm_uid,name,label FROM arm WHERE id=? AND soa_id=?", (arm_id, soa_id), ) row = cur.fetchone() if not row: conn.close() raise HTTPException(404, "Arm not found") + before = { "id": row[0], - "name": row[1], - "label": row[2], - "description": row[3], - "type": row[4], - "data_origin_type": row[5], - "order_index": row[6], - "arm_uid": row[7], + "arm_uid": row[1], + "name": row[2], + "label": row[3], } cur.execute("DELETE FROM arm WHERE id=?", (arm_id,)) conn.commit() @@ -216,6 +476,14 @@ def delete_arm(soa_id: int, arm_id: int): return {"deleted": True, "id": arm_id} +# UI code for deleting Arm +@router.post("/ui/soa/{soa_id}/arms/{arm_id}/delete") +def ui_delete_arm(request: Request, soa_id: int, arm_id: int): + delete_arm(soa_id, arm_id) + return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/arms", status_code=303) + + +# API endpoint for reordering Arms <- Deprecated (no longer needed) @router.post("/arms/reorder", response_class=JSONResponse) def reorder_arms_api(soa_id: int, order: List[int]): if not soa_exists(soa_id): diff --git a/src/soa_builder/web/routers/epochs.py b/src/soa_builder/web/routers/epochs.py index 7f10129..01e7d4d 100644 --- a/src/soa_builder/web/routers/epochs.py +++ b/src/soa_builder/web/routers/epochs.py @@ -277,6 +277,7 @@ def update_epoch(soa_id: int, epoch_id: int, payload: EpochUpdate): ) row = cur.fetchone() if not row: + conn.close() raise HTTPException(404, f"Epoch id={int(epoch_id)} not found") before = { @@ -605,6 +606,7 @@ def update_epoch_metadata(soa_id: int, epoch_id: int, payload: EpochUpdate): return {**after, "updated_fields": updated_fields} +# Deprecated (no longer needed) @router.post("/soa/{soa_id}/epochs/reorder", response_class=JSONResponse) def reorder_epochs_api( soa_id: int, diff --git a/src/soa_builder/web/templates/arms.html b/src/soa_builder/web/templates/arms.html new file mode 100644 index 0000000..a963c64 --- /dev/null +++ b/src/soa_builder/web/templates/arms.html @@ -0,0 +1,102 @@ +{% extends 'base.html' %} +{% block content %} +Arms for SoA {{ soa_id }} + + + + + Name * + + + + Label + + + + Description + + + + Arm Type + + -- Select Arm Type -- + {% for code, sv in (arm_type_options or {}).items() %} + {{ sv }} + {% endfor %} + + + + Arm Data Origin Type + + -- Select Arm Data Origin Type -- + {% for code, sv in (arm_data_origin_type_options or {}).items() %} + {{ sv }} + {% endfor %} + + + Create Arm + + + + + + UID + Name + Label + Description + Type + Data Origin Type + Save + Delete + + {% for a in arms %} + + + {{ a.arm_uid }} + + + + + + -- Select Arm Type -- + {% for code, sv in (arm_type_options or {}).items() %} + + {{ sv }} + + {% endfor %} + + + + + -- Select Arm Data Origin Type -- + {% for code, sv in (arm_data_origin_type_options or {}).items() %} + + {{ sv }} + + {% endfor %} + + + + Save + + + + + + Delete + + + + + {% else %} + + No arms yet. + + +{% endfor %} + + + +{% endblock %} \ No newline at end of file diff --git a/src/soa_builder/web/templates/base.html b/src/soa_builder/web/templates/base.html index a757a39..b78c5ce 100644 --- a/src/soa_builder/web/templates/base.html +++ b/src/soa_builder/web/templates/base.html @@ -14,6 +14,7 @@ Study Timing Scheduled Activity Instances Epochs + Arms Encounters {% endif %} Biomedical Concept Categories diff --git a/src/soa_builder/web/templates/edit.html b/src/soa_builder/web/templates/edit.html index 6cc1dcb..e09f56f 100644 --- a/src/soa_builder/web/templates/edit.html +++ b/src/soa_builder/web/templates/edit.html @@ -166,77 +166,7 @@ Editing SoA {{ soa_id }} Add Element - - Arms ({{ arms|length }}) (drag to reorder) - - {% for arm in arms %} - - {{ arm.order_index }}. {% if arm.label %}{{ arm.label }}{% else %}{{ arm.name }}{% endif %} - - - - - - - {# Type selection from protocol terminology C174222 #} - {% if protocol_terminology_C174222 %} - - -- Select Epoch Type (C174222) -- - {% for opt in protocol_terminology_C174222 %} - {% set text = (opt.cdisc_submission_value or '') %} - {% set td = (arm.type_display or '') %} - {{ text }} - {% endfor %} - - {% endif %} - {# Type selection from ddf terminology C188727 #} - {% if ddf_terminology_C188727 %} - - -- Select Arm DataOriginType (C18872) -- - {% for ddf_opt in ddf_terminology_C188727 %} - {% set text = (ddf_opt.cdisc_submission_value or '') %} - {% set td = (arm.data_origin_type_display or '') %} - {{ text }} - {% endfor %} - - {% endif %} - Save - - - - - Delete - - - - - {% endfor %} - - - - - - {% if protocol_terminology_C174222 %} - - Type - {% for opt in protocol_terminology_C174222 %} - {% set text = (opt.cdisc_submission_value or '') %} - {{ text }} - {% endfor %} - - {% endif %} - {% if ddf_terminology_C188727 %} - - DataOriginType - {% for ddf_opt in ddf_terminology_C188727 %} - {% set text = (ddf_opt.cdisc_submission_value or '') %} - {{ text }} - {% endfor %} - - {% endif %} - Add Arm (auto StudyArm_N) - - + Study Cells ({{ study_cells|length }}) diff --git a/src/soa_builder/web/utils.py b/src/soa_builder/web/utils.py index 58c9258..7588ee5 100644 --- a/src/soa_builder/web/utils.py +++ b/src/soa_builder/web/utils.py @@ -132,6 +132,44 @@ def load_epoch_type_options(force: bool = False) -> list[str]: return [] +# Function for creating {code: submission_value} for Arm type selector +def load_arm_type_map() -> Dict[str, str]: + """Fetch Arm Type term mapping from the protocol_terminology database table""" + conn = _connect() + cur = conn.cursor() + cur.execute( + """ + SELECT code,cdisc_submission_value FROM protocol_terminology + WHERE codelist_code='C174222' + ORDER BY cdisc_submission_value + """ + ) + rows = cur.fetchall() + conn.close() + return { + str(code): str(sv) for (code, sv) in rows if code is not None and sv is not None + } + + +# Function for creating {code: submission_value} for Arm dataOriginType selector +def load_arm_data_origin_type_map() -> Dict[str, str]: + """Fetch arm data origin type from the ddf_terminology database table""" + conn = _connect() + cur = conn.cursor() + cur.execute( + """ + SELECT code,cdisc_submission_value FROM ddf_terminology + WHERE codelist_code='C188727' + ORDER BY cdisc_submission_value + """ + ) + rows = cur.fetchall() + conn.close() + return { + str(code): str(sv) for (code, sv) in rows if code is not None and sv is not None + } + + def load_epoch_type_map(force: bool = False) -> Dict[str, str]: """Fetch Epoch Type term mapping from CDISC Library API for C99079. @@ -717,6 +755,113 @@ def _extract_terms(data: Any) -> List[dict]: return None +# Generic function to return submission value for provided codelist_code and code +def get_submission_value_for_code(soa_id: int, codelist_code: str, code_uid: str): + """Resolve the environmental setting submission value via CDISC Library.""" + if not code_uid: + return None + + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT code FROM code WHERE soa_id=? AND code_uid=?", + (soa_id, code_uid), + ) + row = cur.fetchone() + conn.close() + if not row: + return None + target_code = str(row[0]).strip() + + package_slug = get_latest_sdtm_ct_href() + if not package_slug: + return None + + url = ( + f"https://library.cdisc.org/api/mdr/ct/packages/" + f"{package_slug}/codelists/{codelist_code}" + ) + + headers: dict[str, str] = {"Accept": "application/json"} + subscription_key = os.environ.get("CDISC_SUBSCRIPTION_KEY") + api_key = os.environ.get("CDISC_API_KEY") or subscription_key + if subscription_key: + headers["Ocp-Apim-Subscription-Key"] = subscription_key + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + headers["api-key"] = api_key + + def _match_term(term: dict[str, Any]) -> str | None: + term_id = next( + ( + term.get(field) + for field in ( + "conceptId", + "concept_id", + "code", + "termCode", + "term_code", + ) + if term.get(field) + ), + None, + ) + if term_id and str(term_id).lower() == target_code.lower(): + submission = term.get("submissionValue") or term.get( + "cdisc_submission_value" + ) + if submission: + return str(submission).strip() + return None + + def _extract_terms(data: Any) -> List[dict]: + if isinstance(data, list): + return [t for t in data if isinstance(t, dict)] + if isinstance(data, dict): + if isinstance(data.get("terms"), list): + return [t for t in data["terms"] if isinstance(t, dict)] + embedded = data.get("_embedded", {}) + if isinstance(embedded, dict) and isinstance(embedded.get("terms"), list): + return [t for t in embedded["terms"] if isinstance(t, dict)] + return [] + + try: + resp = requests.get(url, headers=headers, timeout=10) + if resp.status_code != 200: + return None + payload = resp.json() or {} + except Exception: + return None + + for term in _extract_terms(payload): + submission = _match_term(term) + if submission: + return submission + + term_links = payload.get("_links", {}).get("terms") or [] + if isinstance(term_links, dict): + term_links = [term_links] + + for link in term_links: + href = link.get("href") + if not href: + continue + if href.startswith("/"): + href = f"https://library.cdisc.org{href}" + try: + term_resp = requests.get(href, headers=headers, timeout=10) + if term_resp.status_code != 200: + continue + term_data = term_resp.json() or {} + except Exception: + continue + submission = _match_term(term_data if isinstance(term_data, dict) else {}) + if submission: + return submission + + return None + + # Return environmentalSettings options from CDISC Library API def load_environmental_setting_options(force: bool = False) -> List[dict[str, str]]: """Return [{'submissionValue': ..., 'conceptId': ...}, ...] for env settings.""" diff --git a/src/usdm/generate_encounters.py b/src/usdm/generate_encounters.py index 8f74da5..f9b7416 100644 --- a/src/usdm/generate_encounters.py +++ b/src/usdm/generate_encounters.py @@ -4,6 +4,8 @@ try: from soa_builder.web.app import _connect # reuse existing DB connector + from soa_builder.web.utils import get_encounter_environment_sv + from soa_builder.web.utils import get_submission_value_for_code except ImportError: import sys from pathlib import Path @@ -13,6 +15,8 @@ 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 soa_builder.web.utils import get_encounter_environment_sv + from soa_builder.web.utils import get_submission_value_for_code def _nz(s: Optional[str]) -> Optional[str]: @@ -111,7 +115,7 @@ def _get_type_code_tuple(soa_id: int, code_uid: str) -> Tuple[str, str, str, str return code_code, code_decode, code_system, code_system_version -def _get_environment_code_tuple(soa_id: int, code_uid: str) -> Tuple[str, str]: +def _get_code_tuple(soa_id: int, code_uid: str) -> Tuple[str, str]: conn = _connect() cur = conn.cursor() cur.execute( @@ -126,6 +130,7 @@ def _get_environment_code_tuple(soa_id: int, code_uid: str) -> Tuple[str, str]: conn.close() code_system = [r[0] for r in rows] code = [r[1] for r in rows] + return code, code_system @@ -171,7 +176,7 @@ def build_usdm_encounters(soa_id: int) -> List[Dict[str, Any]]: conn = _connect() cur = conn.cursor() cur.execute( - "SELECT name,label,order_index,encounter_uid,description,type,environmentalSettings,scheduledAtId,transitionStartRule,transitionEndRule FROM visit WHERE soa_id=?", + "SELECT name,label,order_index,encounter_uid,description,type,environmentalSettings,scheduledAtId,transitionStartRule,transitionEndRule,contactModes FROM visit WHERE soa_id=?", (soa_id,), ) rows = cur.fetchall() @@ -195,6 +200,7 @@ def build_usdm_encounters(soa_id: int) -> List[Dict[str, Any]]: scheduledAtId, transition_start_rule_uid, transition_end_rule_uid, + contactModes, ) = ( r[0], r[1], @@ -206,14 +212,25 @@ def build_usdm_encounters(soa_id: int) -> List[Dict[str, Any]]: r[7], r[8], r[9], + r[10], ) eid = encounter_uid t_code, t_decode, t_codeSystem, t_codeSystemVersion = _get_type_code_tuple( soa_id, type ) - e_code, e_codesystem = _get_environment_code_tuple( - soa_id, environmentalSettings - ) + + e_code: List[str] = [] + e_codesystem: List[str] = [] + + if environmentalSettings: + e_code, e_codesystem = _get_code_tuple(soa_id, environmentalSettings) + + c_code: List[str] = [] + c_codesystem: List[str] = [] + + if contactModes: + c_code, c_codesystem = _get_code_tuple(soa_id, contactModes) + # print(e_code, e_codesystem) prev_id = id_by_index.get(i - 1) next_id = id_by_index.get(i + 1) @@ -235,6 +252,52 @@ def build_usdm_encounters(soa_id: int) -> List[Dict[str, Any]]: soa_id, transition_end_rule_uid ) + # 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]) + ] + decode = get_submission_value_for_code( + soa_id, + "C127262", + environmentalSettings, + ) + env_settings.append( + { + "id": environmentalSettings, + "extensionAttributes": [], + "code": e_code[0], + "codeSystem": e_codesystem[0], + "codeSystemVersion": code_system_version, + "decode": decode, + "instanceType": "Code", + } + ) + + # 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]) + ] + c_decode = get_submission_value_for_code( + soa_id, + "C171445", + contactModes, + ) + contact_mode.append( + { + "id": contactModes, + "extensionAttributes": [], + "code": c_code[0], + "codeSystem": c_codesystem[0], + "codeSystemVersion": c_code_system_version, + "decode": c_decode, + "instanceType": "Code", + } + ) + encounter = { "id": eid, "extensionAttributes": [], @@ -253,18 +316,8 @@ def build_usdm_encounters(soa_id: int) -> List[Dict[str, Any]]: "previousId": prev_id, "nextId": next_id, "scheduledAt": timing_uid, - "environmentSettings": [ - { - "id": environmentalSettings, - "extensionAttributes": [], - "code": e_code[0], - "codeSystem": e_codesystem[0], - "codeSystemVersion": "2024-09-27", - "decode": "Clinic", - "instanceType": "Code", - }, - ], - "contactModes": [], + "environmentSettings": env_settings, + "contactModes": contact_mode, "transitionStartRule": transition_start_rule_obj or {}, "transitionEndRule": transition_end_rule_obj or {}, "notes": [],