From 72da06d7381908d64e3922c74ddb1174e98da52e Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Thu, 18 Dec 2025 11:43:37 -0500 Subject: [PATCH 01/17] Added endpoints for timeline instances --- src/soa_builder/web/app.py | 30 +- src/soa_builder/web/audit.py | 40 ++- src/soa_builder/web/initialize_database.py | 49 +++ src/soa_builder/web/routers/instances.py | 370 +++++++++++++++++++++ src/soa_builder/web/routers/timings.py | 4 +- src/soa_builder/web/schemas.py | 20 ++ 6 files changed, 480 insertions(+), 33 deletions(-) create mode 100644 src/soa_builder/web/routers/instances.py diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index f87a697..dec5972 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -69,6 +69,7 @@ from .routers import rollback as rollback_router from .routers import visits as visits_router from .routers import timings as timings_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 from .schemas import ArmCreate, SOACreate, SOAMetadataUpdate @@ -155,11 +156,16 @@ def _configure_logging(): _backfill_dataset_date("ddf_terminology", "ddf_terminology_audit") _backfill_dataset_date("protocol_terminology", "protocol_terminology_audit") - -# All models moved -# Visit & Activity models moved to routers/visits.py and routers/activities.py -# Element models moved to routers/elements.py -# Visit & Activity update models moved to routers/visits.py and routers/activities.py +# routers +app.include_router(arms_router.router) +app.include_router(elements_router.router) +app.include_router(visits_router.router) +app.include_router(activities_router.router) +app.include_router(epochs_router.router) +app.include_router(freezes_router.router) +app.include_router(rollback_router.router) +app.include_router(timings_router.router) +app.include_router(instances_router.router) # Utility functions @@ -307,20 +313,6 @@ def _record_arm_audit( logger.warning("Failed recording arm audit: %s", e) -"""Element endpoints moved to routers/elements.py""" -"""Epoch endpoints moved to routers/epochs.py""" -"""Arm endpoints moved to routers/arms.py and schemas to schemas.py""" - -app.include_router(arms_router.router) -app.include_router(elements_router.router) -app.include_router(visits_router.router) -app.include_router(activities_router.router) -app.include_router(epochs_router.router) -app.include_router(freezes_router.router) -app.include_router(rollback_router.router) -app.include_router(timings_router.router) - - @app.post("/soa/{soa_id}/visits/reorder", response_class=JSONResponse) def reorder_visits_api(soa_id: int, order: List[int]): """JSON reorder endpoint for visits (parity with elements). Body is array of visit IDs in desired order.""" diff --git a/src/soa_builder/web/audit.py b/src/soa_builder/web/audit.py index da3b265..fe9237e 100644 --- a/src/soa_builder/web/audit.py +++ b/src/soa_builder/web/audit.py @@ -202,18 +202,6 @@ def _record_timing_audit( try: conn = _connect() cur = conn.cursor() - # Ensure table exists (defensive for migrated databases) - cur.execute( - """CREATE TABLE IF NOT EXISTS timing_audit ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - soa_id INTEGER NOT NULL, - timing_id INTEGER, - action TEXT NOT NULL, - before_json TEXT, - after_json TEXT, - performed_at TEXT NOT NULL - )""" - ) cur.execute( "INSERT INTO timing_audit (soa_id, timing_id, action, before_json, after_json, performed_at) VALUES (?,?,?,?,?,?)", ( @@ -229,3 +217,31 @@ def _record_timing_audit( conn.close() except Exception as e: logger.warning("Failed recording timing audit: %s", e) + + +def _record_instance_audit( + soa_id: int, + action: str, + instance_uid: str, + before: Optional[Dict[str, Any]], + after: Optional[Dict[str, Any]], +): + try: + conn = _connect() + cur = conn.cursor() + cur.execute( + "INSERT INTO instance_audit (soa_id, instance_id, action, before_json, after_json, performed_at) " + "VALUES (?,?,?,?,?,?)", + ( + soa_id, + instance_uid, + action, + before, + after, + datetime.now(timezone.utc).isoformat(), + ), + ) + conn.commit() + conn.close() + except Exception as e: + logger.warning("Failed recording instance audti: %s", e) diff --git a/src/soa_builder/web/initialize_database.py b/src/soa_builder/web/initialize_database.py index 8c7182d..d0b2a33 100644 --- a/src/soa_builder/web/initialize_database.py +++ b/src/soa_builder/web/initialize_database.py @@ -291,5 +291,54 @@ def _init_db(): )""" ) + # create schedule_timelines table + cur.execute( + """CREATE TABLE IF NOT EXISTS schedule_timelines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + soa_id INT NOT NULL, + schedule_timeline_uid TEXT NOT NULL, -- immutable ScheduleTimeline_N identifier unique within SOA + name TEXT NOT NULL, + label TEXT, + description TEXT, + main_timeline INT, -- 1=True|0=False + entry_condition TEXT, + entry_id, -- dropdown select for ScheduledActivityInstance_ + exit_id TEXT, + order_index INT, + UNIQUE(soa_id, schedule_timeline_uid) + )""" + ) + + # create instance_audit table + cur.execute( + """CREATE TABLE IF NOT EXISTS instance_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + soa_id INTEGER NOT NULL, + instance_id INTEGER, + action TEXT NOT NULL, + before_json TEXT, + after_json TEXT, + performed_at TEXT NOT NULL + )""" + ) + + # create instances table + cur.execute( + """CREATE TABLE IF NOT EXISTS instances ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + soa_id INT NOT NULL, + instance_uid TEXT NOT NULL, -- immutable ScheduledActivityInstance_N identifier unique withiun SOA + name TEXT NOT NULL, + label TEXT, + description TEXT, + default_condition_uid TEXT, + epoch_uid TEXT, + timeline_id TEXT, + timeline_exit_id TEXT, + order_index INT, + UNIQUE(soa_id, instance_uid) + )""" + ) + conn.commit() conn.close() diff --git a/src/soa_builder/web/routers/instances.py b/src/soa_builder/web/routers/instances.py new file mode 100644 index 0000000..c44ceba --- /dev/null +++ b/src/soa_builder/web/routers/instances.py @@ -0,0 +1,370 @@ +import logging +import os +from typing import Optional + +from fastapi import APIRouter, HTTPException, Request, Form +from fastapi.responses import JSONResponse, HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates + +from ..audit import _record_instance_audit +from ..db import _connect +from ..schemas import InstanceCreate, InstanceUpdate +from ..utils import soa_exists + +router = APIRouter() +logger = logging.getLogger("soa_builder.web.routers.instances") +templates = Jinja2Templates( + directory=os.path.join(os.path.dirname(__file__), "..", "templates") +) + + +def _nz(s: Optional[str]) -> Optional[str]: + s = (s or "").strip() + return s or None + + +# API endpoint to list timeline instances for SOA +@router.get("/soa/{soa_id}/instances", response_class=JSONResponse, response_model=None) +def list_instances(soa_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id,instance_uid,name,label,description,default_condition_uid,epoch_uid,timeline_id," + "timeline_exit_id,order_index FROM instances WHERE soa_id=? ORDER BY order_index,id", + (soa_id,), + ) + rows = [ + { + "id": r[0], + "instance_uid": r[1], + "name": r[2], + "label": r[3], + "description": r[4], + "default_condition_uid": r[5], + "epoch_uid": r[6], + "timeline_id": r[7], + "timeline_exit_id": r[8], + "order_index": r[9], + } + for r in cur.fetchall() + ] + conn.close() + return rows + + +# UI code to list instances in an SOA +@router.get("/ui/soa/{soa_id}/instances", response_class=HTMLResponse) +def ui_list_instances(request: Request, soa_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + instances = list_instances(soa_id) + return templates.TemplateResponse( + request, + "instances.html", + { + "request": request, + "soa_id": soa_id, + "instances": instances, + }, + ) + + +# API endpoint for creating a timeline instance in an SOA +@router.post( + "/soa/{soa_id}/instances", + response_class=JSONResponse, + status_code=201, + response_model=None, +) +def create_instance(soa_id: int, payload: InstanceCreate): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + name = (payload.name or "").strip() + if not name: + raise HTTPException(400, "Instance name required") + + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT COALESCE(MAX(order_index),0) FROM instances WHERE soa_id=?", + (soa_id,), + ) + next_ord = (cur.fetchone() or [0])[0] + 1 + cur.execute( + "SELECT instance_uid FROM instances WHERE soa_id=? AND instance_uid LIKE 'ScheduledActivityInstance_%'", + (soa_id,), + ) + existing_uids = [r[0] for r in cur.fetchall() if r[0]] + used_nums = set() + for uid in existing_uids: + if uid.startswith("ScheduledActivityInstance_"): + tail = uid[len("ScheduledActivityInstance_") :] + if tail.isdigit(): + used_nums.add(int(tail)) + else: + logger.warning( + "Invalid ingtance_uid format encountered (ignored): %s", + uid, + ) + next_n = 1 + while next_n in used_nums: + next_n += 1 + new_uid = f"ScheduledActivityInstance_{next_n}" + cur.execute( + "INSERT INTO instances (soa_id,instance_uid,name,label,description,default_condition_uid,epoch_uid," + "timeline_id,timeline_exit_id,order_index) VALUES (?,?,?,?,?,?,?,?,?,?)", + ( + soa_id, + new_uid, + name, + _nz(payload.label), + _nz(payload.description), + _nz(payload.default_condition_uid), + _nz(payload.epoch_uid), + _nz(payload.timeline_id), + _nz(payload.timeline_exit_id), + next_ord, + ), + ) + instance_id = cur.lastrowid + conn.commit() + conn.close() + row = { + "id": instance_id, + "instance_uid": new_uid, + "name": name, + "label": (payload.label or "").strip() or None, + "description": (payload.description or "").strip() or None, + } + + _record_instance_audit(soa_id, "create", instance_id, before=None, after=row) + return row + + +# UI code to create new intsance in an SOA +@router.post("/ui/soa/{soa_id}/instances/create") +def ui_create_instance( + request: Request, + soa_id: int, + name: str = Form(...), + label: Optional[str] = Form(None), + description: Optional[str] = Form(None), + default_condition_uid: Optional[str] = Form(None), + epoch_uid: Optional[str] = Form(None), + timeline_id: Optional[str] = Form(None), + timeline_exit_id: Optional[str] = Form(None), +): + payload = InstanceCreate( + name=name, + label=label, + description=description, + default_condition_uid=default_condition_uid, + epoch_uid=epoch_uid, + timeline_id=timeline_id, + timeline_exit_id=timeline_exit_id, + ) + create_instance(soa_id, payload) + return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/instances", status_code=303) + + +# API endpoint to update a timeline instance in an SOA +@router.patch( + "/soa/{soa_id}/instances/{instance_id}", + response_class=JSONResponse, + response_model=None, +) +def update_instance(soa_id: int, instance_id: int, payload: InstanceUpdate): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id,instance_uid,name,label,description,default_condition_uid, epoch_uid," + "timeline_id,timeline_exit_id,order_index from instances WHERE soa_id=? and id=?", + ( + soa_id, + instance_id, + ), + ) + row = cur.fetchone() + if not row: + conn.close() + raise HTTPException(404, f"Instance id={int(instance_id)} not found") + + before = { + "id": row[0], + "instance_uid": row[1], + "name": row[2], + "label": row[3], + "description": row[4], + "default_condition_uid": row[5], + "epoch_uid": row[6], + "timeline_id": row[7], + "timeline_exit_id": row[8], + "order_index": row[9], + } + new_name = (payload.name if payload.name is not None else before["name"]) or "" + new_label = payload.label if payload.label is not None else before["label"] + new_description = ( + payload.description + if payload.description is not None + else before["description"] + ) + new_default_condition_uid = ( + payload.default_condition_uid + if payload.default_condition_uid is not None + else before["default_condition_uid"] + ) + new_epoch_uid = ( + payload.epoch_uid if payload.epoch_uid is not None else before["epoch_uid"] + ) + new_timeline_id = ( + payload.timeline_id + if payload.timeline_id is not None + else before["timeline_id"] + ) + new_timeline_exit_id = ( + payload.timeline_exit_id + if payload.timeline_exit_id is not None + else before["timeline_exit_id"] + ) + + cur.execute( + "UPDATE instances SET name=?, label=?, description=?, default_condition_uid=?, epoch_uid=?, " + "timeline_id=?, timeline_exit_id=? WHERE id=? and soa_id=?", + ( + _nz(new_name), + _nz(new_label), + _nz(new_description), + _nz(new_default_condition_uid), + _nz(new_epoch_uid), + _nz(new_timeline_id), + _nz(new_timeline_exit_id), + ), + ) + conn.commit() + cur.execute( + "SELECT id,intance_uid,name,label,description,default_condition_uid,epoch_uid,timeline_id," + "timeline_exit_id,order_index FROM instances WHERE soa_id=? and instance_id=?", + ( + soa_id, + instance_id, + ), + ) + r = cur.fetchone() + conn.close() + after = { + "id": r[0], + "instance_uid": r[1], + "name": r[2], + "label": r[3], + "description": r[4], + "default_condition_uid": r[5], + "epoch_uid": r[6], + "timeline_id": r[7], + "timeline_exit_id": r[8], + "order_index": r[9], + } + mutable = [ + "name", + "label", + "description", + "default_condition_uid", + "epoch_uid", + "timeline_id", + "timeline_exit_id", + ] + update_fields = [ + f for f in mutable if (before.get(f) or None) != (after.get(f) or None) + ] + _record_instance_audit( + soa_id, + "update", + instance_id, + before=before, + after={**after, "updated_fields": update_fields}, + ) + return {**after, "updated_fields": update_fields} + + +# UI code to update an instance in an SOA +@router.post("/ui/soa/{soa_id}/instances/{instance_id}/update") +def ui_update_instance( + request: Request, + soa_id: int, + instance_id: int, + name: Optional[str] = Form(None), + label: Optional[str] = Form(None), + description: Optional[str] = Form(None), + default_condition_uid: Optional[str] = Form(None), + epoch_uid: Optional[str] = Form(None), + timeline_id: Optional[str] = Form(None), + timeline_exit_id: Optional[str] = Form(None), +): + payload = InstanceUpdate( + name=name, + label=label, + description=description, + default_condition_uid=default_condition_uid, + epoch_uid=epoch_uid, + timeline_id=timeline_id, + timeline_exit_id=timeline_exit_id, + ) + update_instance(soa_id, instance_id, payload) + return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/instances", status_code=303) + + +# API endpoint to delete a timeline instance +@router.delete( + "/soa/{soa_id}/instances/{instance_id}", + response_class=JSONResponse, + response_model=None, +) +def delete_instance(soa_id: int, instance_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id,instance_uid,name,label,description FROM instances WHERE soa_id=? and id=?", + ( + soa_id, + instance_id, + ), + ) + row = cur.fetchone() + if not row: + conn.close() + raise HTTPException(404, f"Timing id={int(instance_id)} not found") + before = { + "id": row[0], + "instance_id": row[1], + "name": row[2], + "label": row[3], + "desciption": row[4], + } + cur.execute( + "DELETE FROM instances WHERE id=? and soa_id=?", + ( + instance_id, + soa_id, + ), + ) + conn.commit() + conn.close() + _record_instance_audit(soa_id, "delete", instance_id, before, afvter=None) + return {"deleted": True, "id": instance_id} + + +# UI code to delete timeline instance +@router.post("/ui/soa/{soa_id}/instances/{int(instance_id)}/delete") +def ui_del_instance(request: Request, soa_id: int, instance_id: int): + delete_instance(soa_id, instance_id) + return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/instances", status_code=303) diff --git a/src/soa_builder/web/routers/timings.py b/src/soa_builder/web/routers/timings.py index f48d0ae..f6ecd00 100644 --- a/src/soa_builder/web/routers/timings.py +++ b/src/soa_builder/web/routers/timings.py @@ -344,7 +344,7 @@ def update_timing(soa_id: int, timing_id: int, payload: TimingUpdate): row = cur.fetchone() if not row: conn.close() - raise HTTPException(404, f"Timing id={timing_id} not found") + raise HTTPException(404, f"Timing id={int(timing_id)} not found") before = { "id": row[0], @@ -650,4 +650,4 @@ def delete_timing(soa_id: int, timing_id: int): @router.post("/ui/soa/{soa_id}/timings/{timing_id}/delete") def ui_delete_timing(request: Request, soa_id: int, timing_id: int): delete_timing(soa_id, timing_id) - return RedirectResponse(url=f"/ui/soa/{soa_id}/timings", status_code=303) + return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/timings", status_code=303) diff --git a/src/soa_builder/web/schemas.py b/src/soa_builder/web/schemas.py index f7d1652..ccc5808 100644 --- a/src/soa_builder/web/schemas.py +++ b/src/soa_builder/web/schemas.py @@ -3,6 +3,26 @@ from pydantic import BaseModel +class InstanceUpdate(BaseModel): + name: str + label: Optional[str] = None + description: Optional[str] = None + default_condition_uid: Optional[str] = None + epoch_uid: Optional[str] = None + timeline_id: Optional[str] = None + timeline_exit_id: Optional[str] = None + + +class InstanceCreate(BaseModel): + name: str + label: Optional[str] = None + description: Optional[str] = None + default_condition_uid: Optional[str] = None + epoch_uid: Optional[str] = None + timeline_id: Optional[str] = None + timeline_exit_id: Optional[str] = None + + class TimingCreate(BaseModel): name: str label: Optional[str] = None From 2b72de1a0109f2a5991f7e1b308cfe280a510921 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Thu, 18 Dec 2025 13:57:10 -0500 Subject: [PATCH 02/17] Added encounter_uid to instances table; unit tests; update schemas --- src/soa_builder/web/audit.py | 29 +++-- src/soa_builder/web/initialize_database.py | 1 + src/soa_builder/web/routers/instances.py | 42 +++++--- src/soa_builder/web/schemas.py | 4 +- src/soa_builder/web/templates/base.html | 1 + src/soa_builder/web/templates/instances.html | 85 +++++++++++++++ tests/test_instances_audit.py | 106 +++++++++++++++++++ 7 files changed, 245 insertions(+), 23 deletions(-) create mode 100644 src/soa_builder/web/templates/instances.html create mode 100644 tests/test_instances_audit.py diff --git a/src/soa_builder/web/audit.py b/src/soa_builder/web/audit.py index fe9237e..01c6841 100644 --- a/src/soa_builder/web/audit.py +++ b/src/soa_builder/web/audit.py @@ -222,26 +222,37 @@ def _record_timing_audit( def _record_instance_audit( soa_id: int, action: str, - instance_uid: str, - before: Optional[Dict[str, Any]], - after: Optional[Dict[str, Any]], + instance_id: int | None, + before: Optional[Dict[str, Any]] = None, + after: Optional[Dict[str, Any]] = None, ): try: conn = _connect() cur = conn.cursor() + # Ensure table exists defensively cur.execute( - "INSERT INTO instance_audit (soa_id, instance_id, action, before_json, after_json, performed_at) " - "VALUES (?,?,?,?,?,?)", + """CREATE TABLE IF NOT EXISTS instance_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + soa_id INTEGER NOT NULL, + instance_id INTEGER, + action TEXT NOT NULL, + before_json TEXT, + after_json TEXT, + performed_at TEXT NOT NULL + )""" + ) + cur.execute( + "INSERT INTO instance_audit (soa_id, instance_id, action, before_json, after_json, performed_at) VALUES (?,?,?,?,?,?)", ( soa_id, - instance_uid, + instance_id, action, - before, - after, + json.dumps(before) if before else None, + json.dumps(after) if after else None, datetime.now(timezone.utc).isoformat(), ), ) conn.commit() conn.close() except Exception as e: - logger.warning("Failed recording instance audti: %s", e) + logger.warning("Failed recording instance audit: %s", e) diff --git a/src/soa_builder/web/initialize_database.py b/src/soa_builder/web/initialize_database.py index d0b2a33..10cf976 100644 --- a/src/soa_builder/web/initialize_database.py +++ b/src/soa_builder/web/initialize_database.py @@ -336,6 +336,7 @@ def _init_db(): timeline_id TEXT, timeline_exit_id TEXT, order_index INT, + encounter_uid TEXT, UNIQUE(soa_id, instance_uid) )""" ) diff --git a/src/soa_builder/web/routers/instances.py b/src/soa_builder/web/routers/instances.py index c44ceba..5128c9a 100644 --- a/src/soa_builder/web/routers/instances.py +++ b/src/soa_builder/web/routers/instances.py @@ -33,7 +33,7 @@ def list_instances(soa_id: int): cur = conn.cursor() cur.execute( "SELECT id,instance_uid,name,label,description,default_condition_uid,epoch_uid,timeline_id," - "timeline_exit_id,order_index FROM instances WHERE soa_id=? ORDER BY order_index,id", + "timeline_exit_id,order_index,encounter_uid FROM instances WHERE soa_id=? ORDER BY order_index,id", (soa_id,), ) rows = [ @@ -48,6 +48,7 @@ def list_instances(soa_id: int): "timeline_id": r[7], "timeline_exit_id": r[8], "order_index": r[9], + "encounter_uid": r[10], } for r in cur.fetchall() ] @@ -63,7 +64,6 @@ def ui_list_instances(request: Request, soa_id: int): instances = list_instances(soa_id) return templates.TemplateResponse( - request, "instances.html", { "request": request, @@ -108,7 +108,7 @@ def create_instance(soa_id: int, payload: InstanceCreate): used_nums.add(int(tail)) else: logger.warning( - "Invalid ingtance_uid format encountered (ignored): %s", + "Invalid instance_uid format encountered (ignored): %s", uid, ) next_n = 1 @@ -117,7 +117,7 @@ def create_instance(soa_id: int, payload: InstanceCreate): new_uid = f"ScheduledActivityInstance_{next_n}" cur.execute( "INSERT INTO instances (soa_id,instance_uid,name,label,description,default_condition_uid,epoch_uid," - "timeline_id,timeline_exit_id,order_index) VALUES (?,?,?,?,?,?,?,?,?,?)", + "timeline_id,timeline_exit_id,order_index,encounter_uid) VALUES (?,?,?,?,?,?,?,?,?,?,?)", ( soa_id, new_uid, @@ -129,6 +129,7 @@ def create_instance(soa_id: int, payload: InstanceCreate): _nz(payload.timeline_id), _nz(payload.timeline_exit_id), next_ord, + _nz(payload.encounter_uid), ), ) instance_id = cur.lastrowid @@ -158,6 +159,7 @@ def ui_create_instance( epoch_uid: Optional[str] = Form(None), timeline_id: Optional[str] = Form(None), timeline_exit_id: Optional[str] = Form(None), + encounter_uid: Optional[str] = Form(None), ): payload = InstanceCreate( name=name, @@ -167,6 +169,7 @@ def ui_create_instance( epoch_uid=epoch_uid, timeline_id=timeline_id, timeline_exit_id=timeline_exit_id, + encounter_uid=encounter_uid, ) create_instance(soa_id, payload) return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/instances", status_code=303) @@ -186,7 +189,7 @@ def update_instance(soa_id: int, instance_id: int, payload: InstanceUpdate): cur = conn.cursor() cur.execute( "SELECT id,instance_uid,name,label,description,default_condition_uid, epoch_uid," - "timeline_id,timeline_exit_id,order_index from instances WHERE soa_id=? and id=?", + "timeline_id,timeline_exit_id,order_index,encounter_uid from instances WHERE soa_id=? and id=?", ( soa_id, instance_id, @@ -208,6 +211,7 @@ def update_instance(soa_id: int, instance_id: int, payload: InstanceUpdate): "timeline_id": row[7], "timeline_exit_id": row[8], "order_index": row[9], + "encounter_uid": row[10], } new_name = (payload.name if payload.name is not None else before["name"]) or "" new_label = payload.label if payload.label is not None else before["label"] @@ -234,10 +238,15 @@ def update_instance(soa_id: int, instance_id: int, payload: InstanceUpdate): if payload.timeline_exit_id is not None else before["timeline_exit_id"] ) + new_encounter_uid = ( + payload.encounter_uid + if payload.encounter_uid is not None + else before["encounter_uid"] + ) cur.execute( "UPDATE instances SET name=?, label=?, description=?, default_condition_uid=?, epoch_uid=?, " - "timeline_id=?, timeline_exit_id=? WHERE id=? and soa_id=?", + "timeline_id=?, timeline_exit_id=?, encounter_uid=? WHERE id=? and soa_id=?", ( _nz(new_name), _nz(new_label), @@ -246,12 +255,15 @@ def update_instance(soa_id: int, instance_id: int, payload: InstanceUpdate): _nz(new_epoch_uid), _nz(new_timeline_id), _nz(new_timeline_exit_id), + _nz(new_encounter_uid), + instance_id, + soa_id, ), ) conn.commit() cur.execute( - "SELECT id,intance_uid,name,label,description,default_condition_uid,epoch_uid,timeline_id," - "timeline_exit_id,order_index FROM instances WHERE soa_id=? and instance_id=?", + "SELECT id,instance_uid,name,label,description,default_condition_uid,epoch_uid,timeline_id," + "timeline_exit_id,order_index,encounter_uid FROM instances WHERE soa_id=? and id=?", ( soa_id, instance_id, @@ -270,6 +282,7 @@ def update_instance(soa_id: int, instance_id: int, payload: InstanceUpdate): "timeline_id": r[7], "timeline_exit_id": r[8], "order_index": r[9], + "encounter_uid": r[10], } mutable = [ "name", @@ -279,6 +292,7 @@ def update_instance(soa_id: int, instance_id: int, payload: InstanceUpdate): "epoch_uid", "timeline_id", "timeline_exit_id", + "encounter_uid", ] update_fields = [ f for f in mutable if (before.get(f) or None) != (after.get(f) or None) @@ -306,6 +320,7 @@ def ui_update_instance( epoch_uid: Optional[str] = Form(None), timeline_id: Optional[str] = Form(None), timeline_exit_id: Optional[str] = Form(None), + encounter_uid: Optional[str] = Form(None), ): payload = InstanceUpdate( name=name, @@ -315,6 +330,7 @@ def ui_update_instance( epoch_uid=epoch_uid, timeline_id=timeline_id, timeline_exit_id=timeline_exit_id, + encounter_uid=encounter_uid, ) update_instance(soa_id, instance_id, payload) return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/instances", status_code=303) @@ -342,13 +358,13 @@ def delete_instance(soa_id: int, instance_id: int): row = cur.fetchone() if not row: conn.close() - raise HTTPException(404, f"Timing id={int(instance_id)} not found") + raise HTTPException(404, f"Instance id={int(instance_id)} not found") before = { "id": row[0], - "instance_id": row[1], + "instance_uid": row[1], "name": row[2], "label": row[3], - "desciption": row[4], + "description": row[4], } cur.execute( "DELETE FROM instances WHERE id=? and soa_id=?", @@ -359,12 +375,12 @@ def delete_instance(soa_id: int, instance_id: int): ) conn.commit() conn.close() - _record_instance_audit(soa_id, "delete", instance_id, before, afvter=None) + _record_instance_audit(soa_id, "delete", instance_id, before, after=None) return {"deleted": True, "id": instance_id} # UI code to delete timeline instance -@router.post("/ui/soa/{soa_id}/instances/{int(instance_id)}/delete") +@router.post("/ui/soa/{soa_id}/instances/{instance_id}/delete") def ui_del_instance(request: Request, soa_id: int, instance_id: int): delete_instance(soa_id, instance_id) return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/instances", status_code=303) diff --git a/src/soa_builder/web/schemas.py b/src/soa_builder/web/schemas.py index ccc5808..d2c4aa9 100644 --- a/src/soa_builder/web/schemas.py +++ b/src/soa_builder/web/schemas.py @@ -4,13 +4,14 @@ class InstanceUpdate(BaseModel): - name: str + name: Optional[str] = None label: Optional[str] = None description: Optional[str] = None default_condition_uid: Optional[str] = None epoch_uid: Optional[str] = None timeline_id: Optional[str] = None timeline_exit_id: Optional[str] = None + encounter_uid: Optional[str] = None class InstanceCreate(BaseModel): @@ -21,6 +22,7 @@ class InstanceCreate(BaseModel): epoch_uid: Optional[str] = None timeline_id: Optional[str] = None timeline_exit_id: Optional[str] = None + encounter_uid: Optional[str] = None class TimingCreate(BaseModel): diff --git a/src/soa_builder/web/templates/base.html b/src/soa_builder/web/templates/base.html index 039be85..d0ccab6 100644 --- a/src/soa_builder/web/templates/base.html +++ b/src/soa_builder/web/templates/base.html @@ -13,6 +13,7 @@

SoA Workbench

Home | {% if soa_id %} Study Timing | + Scheduled Activity Instances {% endif %} Biomedical Concept Categories | Biomedical Concepts | diff --git a/src/soa_builder/web/templates/instances.html b/src/soa_builder/web/templates/instances.html new file mode 100644 index 0000000..bbb0b68 --- /dev/null +++ b/src/soa_builder/web/templates/instances.html @@ -0,0 +1,85 @@ +{% extends 'base.html' %} +{% block content %} + +

Scheduled Activity Instances for SoA {{ soa_id }}

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + + {% for i in instances or [] %} + + + + + + + + + + + + + + + + {% else %} + + {% endfor %} +
UIDNameLabelDescriptionDefault ConditionEpochTimeline IDTimeline Exit IDEncounterSaveDelete Instance
{{ i.instance_uid }} + + +
+ +
+
No instances yet.
+ +{% endblock %} \ No newline at end of file diff --git a/tests/test_instances_audit.py b/tests/test_instances_audit.py new file mode 100644 index 0000000..af956a9 --- /dev/null +++ b/tests/test_instances_audit.py @@ -0,0 +1,106 @@ +from fastapi.testclient import TestClient + +from soa_builder.web.app import app +from soa_builder.web.db import _connect + +client = TestClient(app) + + +def _ensure_soa_clean(soa_id: int) -> int: + conn = _connect() + cur = conn.cursor() + # Ensure SOA exists + cur.execute( + "INSERT OR IGNORE INTO soa (id, name) VALUES (?, ?)", + (soa_id, f"Test SOA {soa_id}"), + ) + # Clean related tables + cur.execute("DELETE FROM instances WHERE soa_id=?", (soa_id,)) + cur.execute("DELETE FROM instance_audit WHERE soa_id=?", (soa_id,)) + conn.commit() + conn.close() + return soa_id + + +def _audit_rows(soa_id: int): + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT action, before_json, after_json FROM instance_audit WHERE soa_id=? ORDER BY id", + (soa_id,), + ) + rows = cur.fetchall() or [] + conn.close() + return rows + + +def _list_instances(soa_id: int): + r = client.get(f"/soa/{soa_id}/instances") + assert r.status_code == 200 + return r.json() + + +def test_instance_create_update_delete_audit_flow(): + soa_id = _ensure_soa_clean(18001) + + # CREATE + r = client.post( + f"/soa/{soa_id}/instances", + json={ + "name": "Inst A", + "label": None, + "description": None, + "default_condition_uid": None, + "epoch_uid": None, + "timeline_id": None, + "timeline_exit_id": None, + }, + ) + assert r.status_code == 201, r.text + created = r.json() + instance_id = created["id"] + + insts = _list_instances(soa_id) + assert any(i["id"] == instance_id for i in insts) + + rows = _audit_rows(soa_id) + assert len(rows) == 1 + action, before_json, after_json = rows[0] + assert action == "create" + assert before_json is None + assert after_json is not None and "Inst A" in after_json + + # UPDATE + r2 = client.patch( + f"/soa/{soa_id}/instances/{instance_id}", + json={ + "name": "Inst A+", + "label": "L1", + "description": None, + "default_condition_uid": None, + "epoch_uid": None, + "timeline_id": None, + "timeline_exit_id": None, + }, + ) + assert r2.status_code == 200, r2.text + updated = r2.json() + assert updated["name"] == "Inst A+" + assert updated["label"] == "L1" + + rows2 = _audit_rows(soa_id) + assert len(rows2) == 2 + action2, before_json2, after_json2 = rows2[-1] + assert action2 == "update" + assert before_json2 is not None + assert after_json2 is not None and "updated_fields" in after_json2 + + # DELETE + r3 = client.delete(f"/soa/{soa_id}/instances/{instance_id}") + assert r3.status_code == 200 + rows3 = _audit_rows(soa_id) + assert len(rows3) == 3 + action3, before_json3, after_json3 = rows3[-1] + assert action3 == "delete" + assert before_json3 is not None + assert after_json3 is None From 77be20283ef484ced669aa3abcc57d52da273032 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Thu, 18 Dec 2025 14:55:21 -0500 Subject: [PATCH 03/17] Added encounter select to the instances UI --- src/soa_builder/web/initialize_database.py | 5 ++++- src/soa_builder/web/routers/instances.py | 4 +++- src/soa_builder/web/templates/instances.html | 15 +++++++++++++-- src/soa_builder/web/utils.py | 13 +++++++++++++ 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/soa_builder/web/initialize_database.py b/src/soa_builder/web/initialize_database.py index 10cf976..b23e90a 100644 --- a/src/soa_builder/web/initialize_database.py +++ b/src/soa_builder/web/initialize_database.py @@ -17,7 +17,10 @@ def _init_db(): soa_id INTEGER, name TEXT, raw_header TEXT, - order_index INTEGER + order_index INTEGER, + epoch_id INTEGER, + encounter_uid TEXT, + UNIQUE(soa_id,encounter_uid) )""" ) cur.execute( diff --git a/src/soa_builder/web/routers/instances.py b/src/soa_builder/web/routers/instances.py index 5128c9a..8c558a1 100644 --- a/src/soa_builder/web/routers/instances.py +++ b/src/soa_builder/web/routers/instances.py @@ -9,7 +9,7 @@ from ..audit import _record_instance_audit from ..db import _connect from ..schemas import InstanceCreate, InstanceUpdate -from ..utils import soa_exists +from ..utils import soa_exists, get_encounter_id router = APIRouter() logger = logging.getLogger("soa_builder.web.routers.instances") @@ -63,12 +63,14 @@ def ui_list_instances(request: Request, soa_id: int): raise HTTPException(404, "SOA not found") instances = list_instances(soa_id) + encounter_options = get_encounter_id(soa_id) return templates.TemplateResponse( "instances.html", { "request": request, "soa_id": soa_id, "instances": instances, + "encounter_options": encounter_options, }, ) diff --git a/src/soa_builder/web/templates/instances.html b/src/soa_builder/web/templates/instances.html index bbb0b68..4c73c96 100644 --- a/src/soa_builder/web/templates/instances.html +++ b/src/soa_builder/web/templates/instances.html @@ -35,7 +35,12 @@

Scheduled Activity Instances for SoA {{ soa_id }}

- +
@@ -66,7 +71,13 @@

Scheduled Activity Instances for SoA {{ soa_id }}

- + + +
@@ -68,7 +73,14 @@

Scheduled Activity Instances for SoA {{ soa_id }}

- + + + @@ -77,6 +89,7 @@

Scheduled Activity Instances for SoA {{ soa_id }}

{% for name, enc_uid in (encounter_options or {}).items() %} {% endfor %} + diff --git a/src/soa_builder/web/utils.py b/src/soa_builder/web/utils.py index 1a3f6d2..6b5a1af 100644 --- a/src/soa_builder/web/utils.py +++ b/src/soa_builder/web/utils.py @@ -309,7 +309,7 @@ def get_study_timing_type(codelist_code: str) -> Dict[str, str]: def get_encounter_id(soa_id: int) -> Dict[str, str]: - """Return a dictionary of {id: name} from the visit table""" + """Return a dictionary of {name: encounter_uid} from the visit table""" conn = _connect() cur = conn.cursor() cur.execute( @@ -319,3 +319,16 @@ def get_encounter_id(soa_id: int) -> Dict[str, str]: rows = cur.fetchall() conn.close() return {str(name): str(enc_uid) for (name, enc_uid) in rows if name is not None} + + +def get_epoch_uid(soa_id: int) -> Dict[str, str]: + """Return a dictionary of {name: epoch_uid} from the epoch table""" + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT name, epoch_uid FROM epoch WHERE soa_id=? ORDER BY epoch_uid", + (soa_id,), + ) + rows = cur.fetchall() + conn.close() + return {str(name): str(epoch_uid) for (name, epoch_uid) in rows if name is not None} From dc1a17790cc7d374958e7d42ed2a2f07bd7ece14 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Thu, 18 Dec 2025 16:34:13 -0500 Subject: [PATCH 05/17] Generate the USDM JSON for ScheduledActivityInstances --- .../generate_scheduled_activity_instances.py | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 src/usdm/generate_scheduled_activity_instances.py diff --git a/src/usdm/generate_scheduled_activity_instances.py b/src/usdm/generate_scheduled_activity_instances.py new file mode 100644 index 0000000..96b70dc --- /dev/null +++ b/src/usdm/generate_scheduled_activity_instances.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +# Prefer absolute import; fallback to adding src/ to sys.path when run directly +from typing import Optional, List, Dict, Any, Tuple + +try: + from soa_builder.web.app import _connect # reuse existing DB connector +except ImportError: + import sys + from pathlib import Path + + here = Path(__file__).resolve() + src_dir = here.parents[2] / "src" + if src_dir.exists() and str(src_dir) not in sys.path: + sys.path.insert(0, str(src_dir)) + from soa_builder.web.app import _connect # type: ignore + + +def _nz(s: Optional[str]) -> Optional[str]: + s = (s or "").strip() + return s or None + + +def _get_activity_ids(soa_id: int, encounter_uid: str) -> List[str]: + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT a.activity_uid from activity a " + "INNER JOIN matrix_cells m ON a.id = m.activity_id AND a.soa_id = m.soa_id " + "INNER JOIN visit v ON m.visit_id = v.id AND m.soa_id = v.soa_id " + "INNER JOIN instances i ON v.encounter_uid = i.encounter_uid AND v.soa_id = i.soa_id " + "WHERE i.soa_id=? and i.encounter_uid=?", + ( + soa_id, + encounter_uid, + ), + ) + rows = cur.fetchall() + conn.close() + activity_uids = [r[0] for r in rows] or [] + return activity_uids + + +def build_usdm_instances(soa_id: int) -> List[Dict[str, Any]]: + """ + Build USDM instances objects for the given SOA + + USDM instances: + - id: string + - extensionAttributes: string[]|[] + - name: string + - label?: string + - description?: string + - defaultConditionId?: string + - epochId?: string + - instanceType: "ScheduledActivityInstance" + - timelineId?: string + - timelineExitId?: string + - activityIds?: string[] + - encounterId?: string + """ + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id,instance_uid,name,label,description,default_condition_uid,epoch_uid," + "timeline_id,timeline_exit_id,encounter_uid FROM instances where soa_id=? ORDER BY instance_uid", + (soa_id,), + ) + rows = cur.fetchall() + conn.close() + out: List[Dict[str, Any]] = [] + + for i, r in enumerate(rows): + ( + id, + instance_uid, + name, + label, + description, + default_condition_uid, + epoch_uid, + timeline_id, + timeline_exit_id, + encounter_uid, + ) = ( + r[0], + r[1], + r[2], + r[3], + r[4], + r[5], + r[6], + r[7], + r[8], + r[9], + ) + + instances = { + "id": instance_uid, + "extensionAttributes": [], + "name": name, + "label": _nz(label), + "description": _nz(description), + "defaultConditionId": _nz(default_condition_uid), + "epochId": _nz(epoch_uid), + "instanceType": "ScheduledActivityInstance", + "timelineId": _nz(timeline_id), + "timelineExitId": _nz(timeline_exit_id), + "activityIds": _get_activity_ids(soa_id, encounter_uid), + "encounterId": _nz(encounter_uid), + } + out.append(instances) + + return out + + +if __name__ == "__main__": + import argparse + import json + import logging + import sys + + logger = logging.getLogger("usdm.generate_instances") + + parser = argparse.ArgumentParser( + description="Export USDM Scheduled Activity Instances for a SOA." + ) + parser.add_argument( + "soa_id", type=int, help="SOA id to export Scheduled Activity Instances for" + ) + parser.add_argument( + "-o", "--output", default="-", help="Output file path or '-' for stdout" + ) + parser.add_argument("--indent", type=int, default=2, help="JSON indent") + args = parser.parse_args() + + try: + activities = build_usdm_instances(args.soa_id) + except Exception: + logger.exception( + "Failed to build Scheduled Activity Instances for soa_id=%s", args.soa_id + ) + sys.exit(1) + + payload = json.dumps(activities, indent=args.indent) + if args.output in ("-", "/dev/stdout"): + sys.stdout.write(payload + "\n") + else: + with open(args.output, "w", encoding="utf-8") as f: + f.write(payload + "\n") From e6bd8255731e3bb2a8668040099a2d2b62c01927 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Fri, 19 Dec 2025 10:57:23 -0500 Subject: [PATCH 06/17] Changed logic in create to always assign _uid as max(existing) + 1 and not use the first gap --- src/soa_builder/web/routers/instances.py | 4 ++++ src/soa_builder/web/routers/timings.py | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/soa_builder/web/routers/instances.py b/src/soa_builder/web/routers/instances.py index 1a753fb..fc619c0 100644 --- a/src/soa_builder/web/routers/instances.py +++ b/src/soa_builder/web/routers/instances.py @@ -115,9 +115,13 @@ def create_instance(soa_id: int, payload: InstanceCreate): "Invalid instance_uid format encountered (ignored): %s", uid, ) + """ next_n = 1 while next_n in used_nums: next_n += 1 + """ + # Always pick max(existing) + 1, do not fill gaps + next_n = (max(used_nums) if used_nums else 0) + 1 new_uid = f"ScheduledActivityInstance_{next_n}" cur.execute( "INSERT INTO instances (soa_id,instance_uid,name,label,description,default_condition_uid,epoch_uid," diff --git a/src/soa_builder/web/routers/timings.py b/src/soa_builder/web/routers/timings.py index f6ecd00..a1f82e0 100644 --- a/src/soa_builder/web/routers/timings.py +++ b/src/soa_builder/web/routers/timings.py @@ -279,9 +279,12 @@ def create_timing(soa_id: int, payload: TimingCreate): "Invalid timing_uid format encountered (ignored): %s", uid, ) - next_n = 1 + """next_n = 1 while next_n in used_nums: next_n += 1 + """ + # Always pick max(existing) + 1, do not fill gaps + next_n = (max(used_nums) if used_nums else 0) + 1 new_uid = f"Timing_{next_n}" cur.execute( """INSERT INTO timing (soa_id,timing_uid,name,label,description,type,value,value_label, From 3bac37c30db6c3251aae230041bacca9201f528a Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Fri, 19 Dec 2025 11:09:05 -0500 Subject: [PATCH 07/17] Added db migration for label and description columns to visit table --- src/soa_builder/web/migrate_database.py | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/soa_builder/web/migrate_database.py b/src/soa_builder/web/migrate_database.py index 16ac802..30b3396 100644 --- a/src/soa_builder/web/migrate_database.py +++ b/src/soa_builder/web/migrate_database.py @@ -225,6 +225,34 @@ def _migrate_add_epoch_seq(): logger.warning("epoch_seq migration failed: %s", e) +# Migration: add visit label/description +def _migrate_visit_add_label_desc(): + """Add optional label and description columns to visit if missing.""" + try: + conn = _connect() + cur = conn.cursor() + cur.execute("PRAGMA table_info(visit)") + cols = {r[1] for r in cur.fetchall()} + alters: list[str] = [] + if "label" not in cols: + alters.append("ALTER TABLE visit ADD COLUMN label TEXT") + if "description" not in cols: + alters.append("ALTER TABLE visit ADD COLUMN description TEXT") + for stmt in alters: + try: + cur.execute(stmt) + except Exception as e: + logger.warning("Failed visit migration '%s': %s", stmt, e) + if alters: + conn.commit() + logger.info( + "Applied visit label/description migration: %s", ", ".join(alters) + ) + conn.close() + except Exception as e: + logger.warning("visit label/description migration failed: %s", e) + + # Migration: add epoch label/description def _migrate_add_epoch_label_desc(): """Add optional epoch_label and epoch_description columns if missing.""" From d2939781f2f55ca0d8ad3a1ed4b521619d7d3790 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:33:45 -0500 Subject: [PATCH 08/17] Fixed test suite to use updated visit.label instead of visit.raw_header --- README.md | 11 +- README_endpoints.md | 2 +- normalize_soa.py | 10 +- src/soa_builder/cli.py | 2 +- src/soa_builder/normalization.py | 8 +- src/soa_builder/schedule.py | 8 +- src/soa_builder/web/app.py | 141 ++++++++++--- src/soa_builder/web/db.py | 22 ++- src/soa_builder/web/initialize_database.py | 3 +- src/soa_builder/web/routers/visits.py | 187 ++++++++++++++---- src/soa_builder/web/schemas.py | 6 +- src/soa_builder/web/templates/edit.html | 12 +- .../web/templates/freeze_modal.html | 2 +- tests/conftest.py | 34 +++- tests/test_bulk_import.py | 2 +- tests/test_deletion.py | 4 +- tests/test_ui_set_visit_epoch.py | 2 +- tests/test_ui_visit_create.py | 2 +- tests/test_web_api.py | 2 +- validate_soa.py | 2 +- 20 files changed, 358 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index f809de2..e7545d9 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,15 @@ Run unit tests: pytest ``` +### Test database +- Tests run against a separate SQLite file to avoid touching your local/prod data. +- Default path: `soa_builder_web_tests.db` in the repo root. Override with env var `SOA_BUILDER_DB`. +- A pytest session fixture removes any stale test DB/WAL/SHM files at start to prevent I/O errors. +- Manually clear the test DB before a run if needed: +```bash +rm -f soa_builder_web_tests.db soa_builder_web_tests.db-wal soa_builder_web_tests.db-shm +``` + > Full, updated endpoint reference (including Elements, freezes, audits, JSON CRUD and UI helpers) lives in `README_endpoints.md`. Consult that file for detailed request/response examples, curl snippets, and future enhancement notes. Endpoints: @@ -77,7 +86,7 @@ Running the script produces (in `--out-dir`): ### visits.csv Columns - `visit_id`: Sequential numeric id. -- `raw_header`: Original header text. +- `label`: Original header text. - `visit_name`: Header stripped of parenthetical codes. - `visit_code`: Code extracted from parentheses (e.g., `C1D1`, `EOT`). - `sequence_index`: Positional order. diff --git a/README_endpoints.md b/README_endpoints.md index bcb1166..f0a51d5 100644 --- a/README_endpoints.md +++ b/README_endpoints.md @@ -47,7 +47,7 @@ Response: | Method | Path | Purpose | | ------ | ---- | ------- | -| POST | `/soa/{soa_id}/visits` | Create visit `{ name, raw_header?, epoch_id? }` | +| POST | `/soa/{soa_id}/visits` | Create visit `{ name, label?, epoch_id? }` | | PATCH | `/soa/{soa_id}/visits/{visit_id}` | Update visit (partial) returns `updated_fields` | | DELETE | `/soa/{soa_id}/visits/{visit_id}` | Delete visit (and its cells) | | GET | `/soa/{soa_id}/visits/{visit_id}` | Fetch visit detail | diff --git a/normalize_soa.py b/normalize_soa.py index 11f7431..2df52e9 100644 --- a/normalize_soa.py +++ b/normalize_soa.py @@ -55,7 +55,7 @@ @dataclass class Visit: visit_id: int - raw_header: str + label: str visit_name: str visit_code: Optional[str] sequence_index: int @@ -229,7 +229,7 @@ def build_visits(headers: List[str]) -> List[Visit]: visits.append( Visit( visit_id=idx, - raw_header=h, + label=h, visit_name=re.sub(r"\s*\(.*?\)", "", h).strip(), visit_code=code, sequence_index=idx, @@ -305,7 +305,7 @@ def build_schedule_rules( rule_id = 1 # From headers (e.g., Survival FU (q12w)) for v in visits: - header_lower = v.raw_header.lower() + header_lower = v.label.lower() for pat in REPEAT_PATTERNS: if pat in header_lower: rules.append( @@ -316,7 +316,7 @@ def build_schedule_rules( source_type="header", activity_id=None, visit_id=v.visit_id, - raw_text=v.raw_header, + raw_text=v.label, ) ) rule_id += 1 @@ -386,7 +386,7 @@ def to_sqlite( """ CREATE TABLE IF NOT EXISTS visits ( visit_id INTEGER PRIMARY KEY, - raw_header TEXT, + label TEXT, visit_name TEXT, visit_code TEXT, sequence_index INTEGER, diff --git a/src/soa_builder/cli.py b/src/soa_builder/cli.py index 8527c40..cace32e 100644 --- a/src/soa_builder/cli.py +++ b/src/soa_builder/cli.py @@ -64,7 +64,7 @@ def _load_visits(normalized_dir: str) -> dict: visits[vid] = VisitStub( visit_id=vid, visit_name=r.get("visit_name", ""), - raw_header=r.get("raw_header", ""), + label=r.get("label", ""), sequence_index=int(r.get("sequence_index", "0")), ) return visits diff --git a/src/soa_builder/normalization.py b/src/soa_builder/normalization.py index 652d7cf..002a89f 100644 --- a/src/soa_builder/normalization.py +++ b/src/soa_builder/normalization.py @@ -71,7 +71,7 @@ @dataclass class Visit: visit_id: int - raw_header: str + label: str visit_name: str visit_code: Optional[str] sequence_index: int @@ -246,7 +246,7 @@ def build_schedule_rules( rid = 1 # headers for v in visits: - low = v.raw_header.lower() + low = v.label.lower() for pat in REPEAT_PATTERNS: if pat in low: rules.append( @@ -257,7 +257,7 @@ def build_schedule_rules( "header", None, v.visit_id, - v.raw_header, + v.label, ) ) rid += 1 @@ -342,7 +342,7 @@ def write(name: str, items: List[Any]): ]: cur.execute(f"DROP TABLE IF EXISTS {tbl}") cur.execute( - """CREATE TABLE visits (visit_id INTEGER PRIMARY KEY, raw_header TEXT, visit_name TEXT, visit_code TEXT, sequence_index INTEGER, window_lower INTEGER, window_upper INTEGER, repeat_pattern TEXT, category TEXT)""" + """CREATE TABLE visits (visit_id INTEGER PRIMARY KEY, label TEXT, visit_name TEXT, visit_code TEXT, sequence_index INTEGER, window_lower INTEGER, window_upper INTEGER, repeat_pattern TEXT, category TEXT)""" ) cur.execute( """CREATE TABLE activities (activity_id INTEGER PRIMARY KEY, activity_name TEXT)""" diff --git a/src/soa_builder/schedule.py b/src/soa_builder/schedule.py index 3bef773..a88717a 100644 --- a/src/soa_builder/schedule.py +++ b/src/soa_builder/schedule.py @@ -26,7 +26,7 @@ class VisitStub: visit_id: int visit_name: str - raw_header: str + label: str sequence_index: int @@ -88,7 +88,7 @@ def derive_nominal_day_for_visit( v: VisitStub, cycle_length_days: int, cycle_lengths: Optional[List[int]] ) -> int: name = v.visit_name.lower() - header = v.raw_header.lower() + header = v.label.lower() for txt in (name, header): mc = CYCLE_DAY1_RE.search(txt) if mc: @@ -183,9 +183,7 @@ def expand_schedule_rules( anchor_cycle = 1 if rule.visit_id and rule.visit_id in visits: txt = ( - visits[rule.visit_id].visit_name - + " " - + visits[rule.visit_id].raw_header + visits[rule.visit_id].visit_name + " " + visits[rule.visit_id].label ).lower() mc = CYCLE_DAY1_RE.search(txt) if mc: diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index dec5972..82404b3 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -5,7 +5,7 @@ Endpoints: POST /soa {name} -> create SOA container GET /soa/{id} -> summary - POST /soa/{id}/visits {name, raw_header} -> add visit + 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 @@ -39,12 +39,14 @@ from ..normalization import normalize_soa from .initialize_database import _connect, _init_db +from .db import DB_PATH as _DB_PATH from .migrate_database import ( _backfill_dataset_date, _drop_unused_override_table, _migrate_activity_add_uid, _migrate_add_arm_uid, _migrate_add_epoch_id_to_visit, + _migrate_visit_add_label_desc, _migrate_add_epoch_label_desc, _migrate_add_epoch_seq, _migrate_add_study_fields, @@ -67,17 +69,23 @@ from .routers import epochs as epochs_router from .routers import freezes as freezes_router from .routers import rollback as rollback_router +import importlib from .routers import visits as visits_router + + from .routers import timings as timings_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 -from .schemas import ArmCreate, SOACreate, SOAMetadataUpdate + +# Avoid binding visit helpers directly to allow fresh reloads in tests +from .schemas import ArmCreate, SOACreate, SOAMetadataUpdate, VisitCreate from .utils import ( get_next_code_uid as _get_next_code_uid, get_next_concept_uid as _get_next_concept_uid, load_epoch_type_options, soa_exists, + load_epoch_type_map, table_has_columns as _table_has_columns, ) @@ -100,7 +108,8 @@ def _configure_logging(): logger = _configure_logging() load_dotenv() # must come BEFORE reading env-based configuration so values are populated -DB_PATH = os.environ.get("SOA_BUILDER_DB", "soa_builder_web.db") +# Use the DB path resolved by db.py to keep consistency across modules +DB_PATH = _DB_PATH NORMALIZED_ROOT = os.environ.get("SOA_BUILDER_NORMALIZED_ROOT", "normalized") @@ -139,6 +148,7 @@ def _configure_logging(): _migrate_add_arm_uid() _migrate_drop_arm_element_link() _migrate_add_epoch_id_to_visit() +_migrate_visit_add_label_desc() _migrate_add_epoch_seq() _migrate_add_epoch_label_desc() _migrate_add_epoch_uid() @@ -166,6 +176,8 @@ def _configure_logging(): app.include_router(rollback_router.router) app.include_router(timings_router.router) app.include_router(instances_router.router) +# Ensure fresh router code on app reload (tests call reload(webapp)) +visits_router = importlib.reload(visits_router) # Utility functions @@ -760,11 +772,11 @@ def _rollback_freeze(soa_id: int, freeze_id: int) -> dict: visit_id_map = {} for v in sorted(visits, key=lambda x: x.get("order_index", 0)): cur.execute( - "INSERT INTO visit (soa_id,name,raw_header,order_index) VALUES (?,?,?,?)", + "INSERT INTO visit (soa_id,name,label,order_index) VALUES (?,?,?,?)", ( soa_id, v.get("name"), - v.get("raw_header") or v.get("name"), + v.get("label") or None, v.get("order_index"), ), ) @@ -1059,7 +1071,7 @@ class BulkActivities(BaseModel): class MatrixVisit(BaseModel): name: str - raw_header: Optional[str] = None + label: Optional[str] = None class MatrixActivity(BaseModel): @@ -1078,11 +1090,11 @@ def _fetch_matrix(soa_id: int): cur = conn.cursor() # Epochs not part of matrix axes currently; retrieved separately where needed. cur.execute( - "SELECT id,name,raw_header,order_index,epoch_id FROM visit WHERE soa_id=? ORDER BY order_index", + "SELECT id,name,label,order_index,epoch_id FROM visit WHERE soa_id=? ORDER BY order_index", (soa_id,), ) visits = [ - dict(id=r[0], name=r[1], raw_header=r[2], order_index=r[3], epoch_id=r[4]) + dict(id=r[0], name=r[1], label=r[2], order_index=r[3], epoch_id=r[4]) for r in cur.fetchall() ] # Activities: include optional label/description if schema supports them @@ -1971,8 +1983,8 @@ def _generate_wide_csv(soa_id: int) -> str: raise ValueError( "Cannot generate CSV: need at least one visit and one activity" ) - # Build matrix with first column Activity, subsequent visit headers using raw_header or name - visit_headers = [v["raw_header"] or v["name"] for v in visits] + # Build matrix with first column Activity, subsequent visit headers using label or name + visit_headers = [v["label"] or v["name"] for v in visits] matrix = [] for a in activities: row = [a["name"]] @@ -1998,7 +2010,7 @@ def _generate_wide_csv(soa_id: int) -> str: def _matrix_arrays(soa_id: int): """Return visit headers list and rows (activity name + statuses).""" visits, activities, cells = _fetch_matrix(soa_id) - visit_headers = [v["raw_header"] or v["name"] for v in visits] + visit_headers = [v["label"] or v["name"] for v in visits] cell_lookup = {(c["visit_id"], c["activity_id"]): c["status"] for c in cells} rows = [] for a in activities: @@ -2428,7 +2440,11 @@ def set_cell(soa_id: int, payload: CellCreate): conn.close() return {"cell_id": None, "status": "", "deleted": False} if row: - cur.execute("UPDATE cell SET status=? WHERE id=?", (payload.status, row[0])) + # Update existing matrix cell status + cur.execute( + "UPDATE matrix_cells SET status=? WHERE id=?", + (payload.status, row[0]), + ) cid = row[0] else: cur.execute( @@ -2761,7 +2777,7 @@ def export_pdf(soa_id: int): arms = cur.fetchall() # Visits cur.execute( - "SELECT id, name, COALESCE(raw_header,'') FROM visit WHERE soa_id=? ORDER BY COALESCE(order_index, id)", + "SELECT id, name, COALESCE(label,'') FROM visit WHERE soa_id=? ORDER BY COALESCE(order_index, id)", (soa_id,), ) visits = cur.fetchall() @@ -2942,8 +2958,8 @@ def import_matrix(soa_id: int, payload: MatrixImport): for v in payload.visits: v_index += 1 cur.execute( - "INSERT INTO visit (soa_id,name,raw_header,order_index) VALUES (?,?,?,?)", - (soa_id, v.name, v.raw_header or v.name, v_index), + "INSERT INTO visit (soa_id,name,label,order_index) VALUES (?,?,?,?)", + (soa_id, v.name, v.label or v.name, v_index), ) visit_id_map.append(cur.lastrowid) # Insert activities @@ -3010,7 +3026,7 @@ def _reindex(table: str, soa_id: int): conn.close() -@app.delete("/soa/{soa_id}/visits/{visit_id}") +'''@app.delete("/soa/{soa_id}/visits/{visit_id}") def delete_visit(soa_id: int, visit_id: int): """Delete Visit from an SoA.""" if not soa_exists(soa_id): @@ -3024,7 +3040,7 @@ def delete_visit(soa_id: int, visit_id: int): # cascade cells # Capture before for audit cur.execute( - "SELECT id,name,raw_header,order_index,epoch_id FROM visit WHERE id=?", + "SELECT id,name,label,order_index,epoch_id FROM visit WHERE id=?", (visit_id,), ) b = cur.fetchone() @@ -3033,7 +3049,7 @@ def delete_visit(soa_id: int, visit_id: int): before = { "id": b[0], "name": b[1], - "raw_header": b[2], + "label": b[2], "order_index": b[3], "epoch_id": b[4], } @@ -3046,6 +3062,7 @@ def delete_visit(soa_id: int, visit_id: int): _reindex("visit", soa_id) _record_visit_audit(soa_id, "delete", visit_id, before=before, after=None) return {"deleted_visit_id": visit_id} +''' @app.delete("/soa/{soa_id}/activities/{activity_id}") @@ -3617,7 +3634,6 @@ def ui_edit(request: Request, soa_id: int): code_map[eid] = code conn_em.close() try: - from .utils import load_epoch_type_map code_to_submission = load_epoch_type_map(force=False) or {} except Exception: @@ -3983,10 +3999,19 @@ def ui_add_visit( request: Request, soa_id: int, name: str = Form(...), - raw_header: str = Form(""), - epoch_id_raw: str = Form(""), # new flexible field name - epoch_id: str = Form(""), # legacy field name still used in template + label: Optional[str] = Form(None), + epoch_id: Optional[str] = Form(None), + description: Optional[str] = Form(None), ): + payload = VisitCreate( + name=name, + label=label, + epoch_id=epoch_id, + description=description, + ) + # Call through router module to ensure fresh code under reload + visits_router.add_visit(soa_id, payload) + ''' """Create a visit (UI form). Accepts either form field name `epoch_id_raw` (new) or `epoch_id` (legacy). @@ -4014,8 +4039,8 @@ def ui_add_visit( conn.close() raise HTTPException(400, "Invalid epoch_id for this SOA") cur.execute( - "INSERT INTO visit (soa_id,name,raw_header,order_index,epoch_id) VALUES (?,?,?,?,?)", - (soa_id, name, raw_header or name, order_index, parsed_epoch), + "INSERT INTO visit (soa_id,name,label,order_index,epoch_id) VALUES (?,?,?,?,?)", + (soa_id, name, label or name, order_index, parsed_epoch), ) vid = cur.lastrowid conn.commit() @@ -4039,11 +4064,12 @@ def ui_add_visit( after={ "id": vid, "name": name, - "raw_header": raw_header or name, + "label": label, "order_index": order_index, "epoch_id": parsed_epoch, }, ) + ''' return HTMLResponse( f"" ) @@ -5688,12 +5714,32 @@ def ui_toggle_cell( return HTMLResponse(cell_html) +# UI code to delete an encounter from an SOA @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"" + ) + + +''' +def ui_delete_visit(request: Request, soa_id: int, visit_id: int): """Form handler to delete a visit.""" # Use API logic to delete and log try: - delete_visit(soa_id, visit_id) + visits_router.delete_visit(soa_id, visit_id) logger.info( "ui_delete_visit deleted visit id=%s soa_id=%s db_path=%s", visit_id, @@ -5707,6 +5753,7 @@ def ui_delete_visit(request: Request, soa_id: int, visit_id: int = Form(...)): return HTMLResponse( f"" ) +''' @app.post("/ui/soa/{soa_id}/set_visit_epoch", response_class=HTMLResponse) @@ -5730,10 +5777,23 @@ def ui_set_visit_epoch( raise HTTPException(400, "Invalid epoch_id value") conn = _connect() cur = conn.cursor() - cur.execute("SELECT id FROM visit WHERE id=? AND soa_id=?", (visit_id, soa_id)) - if not cur.fetchone(): + 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) @@ -5751,6 +5811,31 @@ def ui_set_visit_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"" diff --git a/src/soa_builder/web/db.py b/src/soa_builder/web/db.py index 681a2b5..628259b 100644 --- a/src/soa_builder/web/db.py +++ b/src/soa_builder/web/db.py @@ -1,18 +1,32 @@ import os import sqlite3 +import sys from dotenv import load_dotenv +# Load environment variables from .env early load_dotenv() -DB_PATH = os.environ.get("SOA_BUILDER_DB", "soa_builder_web.db") + +# Prefer explicit env var; otherwise, auto-select test DB under pytest +_env_db = os.environ.get("SOA_BUILDER_DB") +_running_pytest = "PYTEST_CURRENT_TEST" in os.environ or "pytest" in sys.modules +if _env_db: + DB_PATH = _env_db +else: + DB_PATH = "soa_builder_web_tests.db" if _running_pytest else "soa_builder_web.db" def _connect(): conn = sqlite3.connect(DB_PATH, timeout=5.0, check_same_thread=False) try: - # Improve concurrency and reduce lock errors - conn.execute("PRAGMA journal_mode=WAL") - conn.execute("PRAGMA synchronous=NORMAL") + # Improve concurrency and reduce lock errors; favor simpler mode under pytest + _is_pytest = "PYTEST_CURRENT_TEST" in os.environ or "pytest" in sys.modules + if _is_pytest: + conn.execute("PRAGMA journal_mode=DELETE") + conn.execute("PRAGMA synchronous=OFF") + else: + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") conn.execute("PRAGMA busy_timeout=3000") except Exception: pass diff --git a/src/soa_builder/web/initialize_database.py b/src/soa_builder/web/initialize_database.py index b23e90a..6e99aab 100644 --- a/src/soa_builder/web/initialize_database.py +++ b/src/soa_builder/web/initialize_database.py @@ -16,10 +16,11 @@ def _init_db(): id INTEGER PRIMARY KEY AUTOINCREMENT, soa_id INTEGER, name TEXT, - raw_header TEXT, + label TEXT, order_index INTEGER, epoch_id INTEGER, encounter_uid TEXT, + description TEXT, UNIQUE(soa_id,encounter_uid) )""" ) diff --git a/src/soa_builder/web/routers/visits.py b/src/soa_builder/web/routers/visits.py index dcfdf9d..1ed985f 100644 --- a/src/soa_builder/web/routers/visits.py +++ b/src/soa_builder/web/routers/visits.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Optional from fastapi import APIRouter, HTTPException import logging @@ -13,9 +13,12 @@ logger = logging.getLogger("soa_builder.web.routers.visits") -# Removed local _soa_exists; using shared utils.soa_exists +def _nz(s: Optional[str]) -> Optional[str]: + s = (s or "").strip() + return s or None +# API endpoint to list encounters for an SOA @router.get("/visits", response_class=JSONResponse) def list_visits(soa_id: int): if not soa_exists(soa_id): @@ -23,16 +26,18 @@ def list_visits(soa_id: int): conn = _connect() cur = conn.cursor() cur.execute( - "SELECT id,name,raw_header,order_index,epoch_id FROM visit WHERE soa_id=? ORDER BY order_index", + "SELECT id,name,label,order_index,epoch_id,encounter_uid,description FROM visit WHERE soa_id=? ORDER BY order_index", (soa_id,), ) rows = [ { "id": r[0], "name": r[1], - "raw_header": r[2], + "label": r[2], "order_index": r[3], "epoch_id": r[4], + "encounter_uid": r[5], + "description": r[6], } for r in cur.fetchall() ] @@ -40,6 +45,7 @@ def list_visits(soa_id: int): return JSONResponse(rows) +# API endpoint to return a visit @router.get("/visits/{visit_id}", response_class=JSONResponse) def get_visit(soa_id: int, visit_id: int): if not soa_exists(soa_id): @@ -47,7 +53,7 @@ def get_visit(soa_id: int, visit_id: int): conn = _connect() cur = conn.cursor() cur.execute( - "SELECT id,name,raw_header,order_index,epoch_id FROM visit WHERE id=? AND soa_id=?", + "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() @@ -58,20 +64,57 @@ def get_visit(soa_id: int, visit_id: int): "id": row[0], "soa_id": soa_id, "name": row[1], - "raw_header": row[2], + "label": row[2], "order_index": row[3], "epoch_id": row[4], + "encounter_uid": row[5], + "description": row[6], } +# API endpoint to add a visit @router.post("/visits", response_class=JSONResponse) def add_visit(soa_id: int, payload: VisitCreate): if not soa_exists(soa_id): raise HTTPException(404, "SOA not found") + + name = (payload.name or "").strip() + if not name: + raise HTTPException(400, "Encounter name required") + conn = _connect() cur = conn.cursor() - cur.execute("SELECT COUNT(*) FROM visit WHERE soa_id=?", (soa_id,)) - order_index = cur.fetchone()[0] + 1 + # Replace existing block with new block to create new encounter_uid and increment order_index + # cur.execute("SELECT COUNT(*) FROM visit WHERE soa_id=?", (soa_id,)) + # order_index = cur.fetchone()[0] + 1 + + # New code to calculate order_index + cur.execute( + "SELECT COALESCE(MAX(order_index),0) FROM visit WHERE soa_id=?", + (soa_id,), + ) + next_ord = (cur.fetchone() or [0])[0] + 1 + + # New code to create encounter_uid and increment order_index + cur.execute( + "SELECT encounter_uid FROM visit WHERE soa_id=? AND encounter_uid LIKE 'Encounter_%'", + (soa_id,), + ) + existing_uids = [r[0] for r in cur.fetchall() if r[0]] + used_nums = set() + for uid in existing_uids: + if uid.startswith("Encounter_"): + tail = uid[len("Encounter_") :] + if tail.isdigit(): + used_nums.add(int(tail)) + else: + logger.warning( + "Invalid encounter_uid format encountered (ignored): %s", uid + ) + # Always pick max(existing) + 1, do not fill gaps + next_n = (max(used_nums) if used_nums else 0) + 1 + new_uid = f"Encounter_{next_n}" + if payload.epoch_id is not None: cur.execute( "SELECT 1 FROM epoch WHERE id=? AND soa_id=?", (payload.epoch_id, soa_id) @@ -79,30 +122,35 @@ def add_visit(soa_id: int, payload: VisitCreate): if not cur.fetchone(): conn.close() raise HTTPException(400, "Invalid epoch_id for this SOA") + cur.execute( - "INSERT INTO visit (soa_id,name,raw_header,order_index,epoch_id) VALUES (?,?,?,?,?)", + "INSERT INTO visit (soa_id,name,label,order_index,epoch_id,encounter_uid,description) VALUES (?,?,?,?,?,?,?)", ( soa_id, - payload.name, - payload.raw_header or payload.name, - order_index, + name, + _nz(payload.label), + next_ord, payload.epoch_id, + new_uid, + _nz(payload.description), ), ) - vid = cur.lastrowid + encounter_id = cur.lastrowid conn.commit() conn.close() after = { - "id": vid, + "id": encounter_id, "name": payload.name, - "raw_header": payload.raw_header or payload.name, - "order_index": order_index, + "label": (payload.label or "").strip() or None, "epoch_id": payload.epoch_id, + "description": (payload.description or "").strip() or None, } - _record_visit_audit(soa_id, "create", vid, before=None, after=after) - return {"visit_id": vid, "order_index": order_index} + _record_visit_audit(soa_id, "create", encounter_id, before=None, after=after) + # Backwards-compatible field expected in tests + return {**after, "visit_id": encounter_id} +# API endpoint to update a visit @router.patch("/visits/{visit_id}", response_class=JSONResponse) def update_visit(soa_id: int, visit_id: int, payload: VisitUpdate): if not soa_exists(soa_id): @@ -110,7 +158,7 @@ def update_visit(soa_id: int, visit_id: int, payload: VisitUpdate): conn = _connect() cur = conn.cursor() cur.execute( - "SELECT id,name,raw_header,order_index,epoch_id FROM visit WHERE id=? AND soa_id=?", + "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() @@ -120,10 +168,14 @@ def update_visit(soa_id: int, visit_id: int, payload: VisitUpdate): before = { "id": row[0], "name": row[1], - "raw_header": row[2], + "label": row[2], "order_index": row[3], "epoch_id": row[4], + "encounter_uid": row[5], + "description": row[6], } + + new_name = (payload.name if payload.name is not None else before["name"]) or "" if payload.epoch_id is not None: cur.execute( "SELECT 1 FROM epoch WHERE id=? AND soa_id=?", (payload.epoch_id, soa_id) @@ -131,38 +183,59 @@ def update_visit(soa_id: int, visit_id: int, payload: VisitUpdate): if not cur.fetchone(): conn.close() raise HTTPException(400, "Invalid epoch_id for this SOA") - new_name = (payload.name if payload.name is not None else before["name"]) or "" - new_name = new_name.strip() - new_raw_header = ( - (payload.raw_header if payload.raw_header is not None else before["raw_header"]) - or new_name - or "" - ) - new_raw_header = new_raw_header.strip() + + # new_name = new_name.strip() + new_label = payload.label if payload.label is not None else before["label"] new_epoch_id = ( payload.epoch_id if payload.epoch_id is not None else before["epoch_id"] ) + new_description = ( + payload.description + if payload.description is not None + else before["description"] + ) + cur.execute( - "UPDATE visit SET name=?, raw_header=?, epoch_id=? WHERE id=?", - (new_name or None, new_raw_header or None, new_epoch_id, visit_id), + "UPDATE visit SET name=?, label=?, epoch_id=?, description=? WHERE id=?", + ( + _nz(new_name), + _nz(new_label), + _nz(new_epoch_id), + _nz(new_description), + visit_id, + ), ) conn.commit() cur.execute( - "SELECT id,name,raw_header,order_index,epoch_id FROM visit WHERE id=?", - (visit_id,), + "SELECT id,name,label,order_index,epoch_id,encounter_uid,description FROM visit WHERE soa_id=? AND id=?", + ( + soa_id, + visit_id, + ), ) r = cur.fetchone() conn.close() after = { "id": r[0], "name": r[1], - "raw_header": r[2], + "label": r[2], "order_index": r[3], "epoch_id": r[4], + "encounter_uid": r[5], + "description": r[6], } + + mutable = [ + "name", + "label", + "epoch_id", + "description", + ] + updated_fields = [ - f for f in ["name", "raw_header", "epoch_id"] if before.get(f) != after.get(f) + f for f in mutable if (before.get(f) or None) != (after.get(f) or None) ] + _record_visit_audit( soa_id, "update", @@ -173,6 +246,52 @@ def update_visit(soa_id: int, visit_id: int, payload: VisitUpdate): return JSONResponse({**after, "updated_fields": updated_fields}) +# API endpoint to delete a visit from an SOA +@router.delete( + "/visits/{visit_id}", + response_class=JSONResponse, +) +def delete_visit(soa_id: int, visit_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,encounter_uid FROM visit WHERE soa_id=? AND id=?", + ( + soa_id, + visit_id, + ), + ) + row = cur.fetchone() + if not row: + raise HTTPException(404, f"Encounter id={int(visit_id)} not found") + + before = { + "id": row[0], + "name": row[1], + "label": row[2], + "encounter_uid": row[3], + } + # Delete target visit and its matrix cells + cur.execute( + "DELETE FROM matrix_cells WHERE soa_id=? AND visit_id=?", (soa_id, visit_id) + ) + cur.execute("DELETE FROM visit WHERE id=? AND soa_id=?", (visit_id, soa_id)) + conn.commit() + # Reindex remaining visits' order_index sequentially + cur.execute("SELECT id FROM visit WHERE soa_id=? ORDER BY order_index", (soa_id,)) + remaining = [r[0] for r in cur.fetchall()] + for idx, vid in enumerate(remaining, start=1): + cur.execute("UPDATE visit SET order_index=? WHERE id=?", (idx, vid)) + conn.commit() + conn.close() + _record_visit_audit(soa_id, "delete", visit_id, before, after=None) + return {"deleted": True, "id": visit_id} + + +# API endpoint to reorder a visit @router.post("/visits/reorder", response_class=JSONResponse) def reorder_visits_api(soa_id: int, order: List[int]): if not soa_exists(soa_id): diff --git a/src/soa_builder/web/schemas.py b/src/soa_builder/web/schemas.py index d2c4aa9..88779a0 100644 --- a/src/soa_builder/web/schemas.py +++ b/src/soa_builder/web/schemas.py @@ -101,14 +101,16 @@ class EpochUpdate(BaseModel): class VisitCreate(BaseModel): name: str - raw_header: Optional[str] = None + label: Optional[str] = None epoch_id: Optional[int] = None + description: Optional[str] = None class VisitUpdate(BaseModel): name: Optional[str] = None - raw_header: Optional[str] = None + label: Optional[str] = None epoch_id: Optional[int] = None + description: Optional[str] = None class ArmCreate(BaseModel): diff --git a/src/soa_builder/web/templates/edit.html b/src/soa_builder/web/templates/edit.html index 3736aa3..49e57b8 100644 --- a/src/soa_builder/web/templates/edit.html +++ b/src/soa_builder/web/templates/edit.html @@ -86,7 +86,7 @@

Editing SoA {{ soa_id }}

Visits ({{ visits|length }}) (drag to reorder)
- + {% if epochs %} {% for name, enc_uid in (encounter_options or {}).items() %} - + {% endfor %}
From 92db6f14187584a83be367b23d885af9c37e06b3 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:41:54 -0500 Subject: [PATCH 10/17] Update src/soa_builder/web/initialize_database.py spelling Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/soa_builder/web/initialize_database.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/soa_builder/web/initialize_database.py b/src/soa_builder/web/initialize_database.py index 6e99aab..e5c946a 100644 --- a/src/soa_builder/web/initialize_database.py +++ b/src/soa_builder/web/initialize_database.py @@ -331,7 +331,7 @@ def _init_db(): """CREATE TABLE IF NOT EXISTS instances ( id INTEGER PRIMARY KEY AUTOINCREMENT, soa_id INT NOT NULL, - instance_uid TEXT NOT NULL, -- immutable ScheduledActivityInstance_N identifier unique withiun SOA + instance_uid TEXT NOT NULL, -- immutable ScheduledActivityInstance_N identifier unique within SOA name TEXT NOT NULL, label TEXT, description TEXT, From ee2901127cfe7dc5b7fd9d69d0e4d2a87aeae968 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:42:18 -0500 Subject: [PATCH 11/17] Update src/soa_builder/web/routers/visits.py removed commented-out code Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/soa_builder/web/routers/visits.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/soa_builder/web/routers/visits.py b/src/soa_builder/web/routers/visits.py index 1ed985f..0e2470c 100644 --- a/src/soa_builder/web/routers/visits.py +++ b/src/soa_builder/web/routers/visits.py @@ -184,7 +184,6 @@ def update_visit(soa_id: int, visit_id: int, payload: VisitUpdate): conn.close() raise HTTPException(400, "Invalid epoch_id for this SOA") - # new_name = new_name.strip() new_label = payload.label if payload.label is not None else before["label"] new_epoch_id = ( payload.epoch_id if payload.epoch_id is not None else before["epoch_id"] From 5cb2301473a72bb98410f013895aa596ca5a4ca5 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:42:35 -0500 Subject: [PATCH 12/17] Update src/soa_builder/web/routers/timings.py removed commented-out code Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/soa_builder/web/routers/timings.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/soa_builder/web/routers/timings.py b/src/soa_builder/web/routers/timings.py index a1f82e0..582bbbb 100644 --- a/src/soa_builder/web/routers/timings.py +++ b/src/soa_builder/web/routers/timings.py @@ -279,10 +279,6 @@ def create_timing(soa_id: int, payload: TimingCreate): "Invalid timing_uid format encountered (ignored): %s", uid, ) - """next_n = 1 - while next_n in used_nums: - next_n += 1 - """ # Always pick max(existing) + 1, do not fill gaps next_n = (max(used_nums) if used_nums else 0) + 1 new_uid = f"Timing_{next_n}" From 2ec99d54cc8463cc5cc26ef3107218be21102b2e Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:42:49 -0500 Subject: [PATCH 13/17] Update src/soa_builder/web/routers/instances.py removed commented-out code Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/soa_builder/web/routers/instances.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/soa_builder/web/routers/instances.py b/src/soa_builder/web/routers/instances.py index fc619c0..5da3620 100644 --- a/src/soa_builder/web/routers/instances.py +++ b/src/soa_builder/web/routers/instances.py @@ -115,11 +115,6 @@ def create_instance(soa_id: int, payload: InstanceCreate): "Invalid instance_uid format encountered (ignored): %s", uid, ) - """ - next_n = 1 - while next_n in used_nums: - next_n += 1 - """ # Always pick max(existing) + 1, do not fill gaps next_n = (max(used_nums) if used_nums else 0) + 1 new_uid = f"ScheduledActivityInstance_{next_n}" From 9bf6b070e843ab8cbaf5a9c43fa872dbfa78e992 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:43:23 -0500 Subject: [PATCH 14/17] Update src/soa_builder/web/routers/instances.py spelling Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/soa_builder/web/routers/instances.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/soa_builder/web/routers/instances.py b/src/soa_builder/web/routers/instances.py index 5da3620..8bc6b51 100644 --- a/src/soa_builder/web/routers/instances.py +++ b/src/soa_builder/web/routers/instances.py @@ -150,7 +150,7 @@ def create_instance(soa_id: int, payload: InstanceCreate): return row -# UI code to create new intsance in an SOA +# UI code to create new instance in an SOA @router.post("/ui/soa/{soa_id}/instances/create") def ui_create_instance( request: Request, From 26e4e605c7a13bc351c9ccfe5229de5780b92a3b Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:45:26 -0500 Subject: [PATCH 15/17] Update src/soa_builder/web/routers/visits.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/soa_builder/web/routers/visits.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/soa_builder/web/routers/visits.py b/src/soa_builder/web/routers/visits.py index 0e2470c..ad10ff9 100644 --- a/src/soa_builder/web/routers/visits.py +++ b/src/soa_builder/web/routers/visits.py @@ -199,7 +199,7 @@ def update_visit(soa_id: int, visit_id: int, payload: VisitUpdate): ( _nz(new_name), _nz(new_label), - _nz(new_epoch_id), + new_epoch_id, _nz(new_description), visit_id, ), From afd052d67549df583ff027a64efd3ec883b6e70b Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:45:55 -0500 Subject: [PATCH 16/17] Update src/soa_builder/web/app.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/soa_builder/web/app.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index 82404b3..50e0c0b 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -176,8 +176,6 @@ def _configure_logging(): app.include_router(rollback_router.router) app.include_router(timings_router.router) app.include_router(instances_router.router) -# Ensure fresh router code on app reload (tests call reload(webapp)) -visits_router = importlib.reload(visits_router) # Utility functions From e3e45c359f0f7251de5ebd6c553638619307b1e9 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:52:49 -0500 Subject: [PATCH 17/17] Removed commented out code --- src/soa_builder/web/app.py | 117 +++++-------------------------------- 1 file changed, 16 insertions(+), 101 deletions(-) diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index 50e0c0b..cf79147 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -69,7 +69,6 @@ from .routers import epochs as epochs_router from .routers import freezes as freezes_router from .routers import rollback as rollback_router -import importlib from .routers import visits as visits_router @@ -3024,45 +3023,6 @@ def _reindex(table: str, soa_id: int): conn.close() -'''@app.delete("/soa/{soa_id}/visits/{visit_id}") -def delete_visit(soa_id: int, visit_id: int): - """Delete Visit 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 visit WHERE id=? AND soa_id=?", (visit_id, soa_id)) - if not cur.fetchone(): - conn.close() - raise HTTPException(404, "Visit not found") - # cascade cells - # Capture before for audit - cur.execute( - "SELECT id,name,label,order_index,epoch_id FROM visit WHERE id=?", - (visit_id,), - ) - b = cur.fetchone() - before = None - if b: - before = { - "id": b[0], - "name": b[1], - "label": b[2], - "order_index": b[3], - "epoch_id": b[4], - } - cur.execute( - "DELETE FROM matrix_cells WHERE soa_id=? AND visit_id=?", (soa_id, visit_id) - ) - cur.execute("DELETE FROM visit WHERE id=?", (visit_id,)) - conn.commit() - conn.close() - _reindex("visit", soa_id) - _record_visit_audit(soa_id, "delete", visit_id, before=before, after=None) - return {"deleted_visit_id": visit_id} -''' - - @app.delete("/soa/{soa_id}/activities/{activity_id}") def delete_activity(soa_id: int, activity_id: int): """Delete Activity from an SoA.""" @@ -4001,73 +3961,28 @@ def ui_add_visit( epoch_id: Optional[str] = Form(None), description: Optional[str] = Form(None), ): + # 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=epoch_id, + epoch_id=parsed_epoch_id, description=description, ) - # Call through router module to ensure fresh code under reload - visits_router.add_visit(soa_id, payload) - ''' - """Create a visit (UI form). + # Create the visit via the API helper to ensure audits and ordering + try: + visits_router.add_visit(soa_id, payload) + except Exception: + # Swallow and continue redirect; detailed errors are handled by API logs + pass - Accepts either form field name `epoch_id_raw` (new) or `epoch_id` (legacy). - Blank selection is treated as None without triggering 422 validation. - """ - if not soa_exists(soa_id): - raise HTTPException(404, "SOA not found") - # Determine which raw epoch string was provided - provided = (epoch_id_raw or "").strip() or (epoch_id or "").strip() - parsed_epoch: Optional[int] = None - if provided: - if provided.isdigit(): - parsed_epoch = int(provided) - else: - raise HTTPException(400, "Invalid epoch_id value") - conn = _connect() - cur = conn.cursor() - cur.execute("SELECT COUNT(*) FROM visit WHERE soa_id=?", (soa_id,)) - order_index = cur.fetchone()[0] + 1 - 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( - "INSERT INTO visit (soa_id,name,label,order_index,epoch_id) VALUES (?,?,?,?,?)", - (soa_id, name, label or name, order_index, parsed_epoch), - ) - vid = cur.lastrowid - conn.commit() - # Debug verification query - cur.execute("SELECT COUNT(*) FROM visit WHERE soa_id=?", (soa_id,)) - _total_visits = cur.fetchone()[0] - conn.close() - logger.info( - "ui_add_visit inserted visit id=%s soa_id=%s total_visits_now=%s epoch_raw='%s' db_path=%s", - vid, - soa_id, - _total_visits, - provided, - DB_PATH, - ) - _record_visit_audit( - soa_id, - "create", - vid, - before=None, - after={ - "id": vid, - "name": name, - "label": label, - "order_index": order_index, - "epoch_id": parsed_epoch, - }, - ) - ''' return HTMLResponse( f"" )