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 }}
+
+
+
+
+
+ | UID |
+ Name |
+ Label |
+ Description |
+ Default Condition |
+ Epoch |
+ Timeline ID |
+ Timeline Exit ID |
+ Encounter |
+ Save |
+ Delete Instance |
+
+ {% for i in instances or [] %}
+
+
+ |
+
+ |
+
+ {% else %}
+ | No instances yet. |
+ {% endfor %}
+
+
+{% 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 }}
|
|
|
- |
+
+ |
|
diff --git a/src/soa_builder/web/utils.py b/src/soa_builder/web/utils.py
index 29a076b..1a3f6d2 100644
--- a/src/soa_builder/web/utils.py
+++ b/src/soa_builder/web/utils.py
@@ -306,3 +306,16 @@ def get_study_timing_type(codelist_code: str) -> Dict[str, str]:
conn.close()
return {str(sub): str(code) for (sub, code) in rows}
+
+
+def get_encounter_id(soa_id: int) -> Dict[str, str]:
+ """Return a dictionary of {id: name} from the visit table"""
+ conn = _connect()
+ cur = conn.cursor()
+ cur.execute(
+ "SELECT name,encounter_uid FROM visit WHERE soa_id=? ORDER BY encounter_uid",
+ (soa_id,),
+ )
+ rows = cur.fetchall()
+ conn.close()
+ return {str(name): str(enc_uid) for (name, enc_uid) in rows if name is not None}
From cddd5c048b2e2f32ec7a291bee53f6842392ed7c Mon Sep 17 00:00:00 2001
From: Darren <3921919+pendingintent@users.noreply.github.com>
Date: Thu, 18 Dec 2025 15:28:21 -0500
Subject: [PATCH 04/17] Added epoch select to instances UI
---
src/soa_builder/web/routers/instances.py | 4 +++-
src/soa_builder/web/templates/instances.html | 17 +++++++++++++++--
src/soa_builder/web/utils.py | 15 ++++++++++++++-
3 files changed, 32 insertions(+), 4 deletions(-)
diff --git a/src/soa_builder/web/routers/instances.py b/src/soa_builder/web/routers/instances.py
index 8c558a1..1a753fb 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, get_encounter_id
+from ..utils import soa_exists, get_encounter_id, get_epoch_uid
router = APIRouter()
logger = logging.getLogger("soa_builder.web.routers.instances")
@@ -64,6 +64,7 @@ def ui_list_instances(request: Request, soa_id: int):
instances = list_instances(soa_id)
encounter_options = get_encounter_id(soa_id)
+ epoch_options = get_epoch_uid(soa_id)
return templates.TemplateResponse(
"instances.html",
{
@@ -71,6 +72,7 @@ def ui_list_instances(request: Request, soa_id: int):
"soa_id": soa_id,
"instances": instances,
"encounter_options": encounter_options,
+ "epoch_options": epoch_options,
},
)
diff --git a/src/soa_builder/web/templates/instances.html b/src/soa_builder/web/templates/instances.html
index 4c73c96..a1e3013 100644
--- a/src/soa_builder/web/templates/instances.html
+++ b/src/soa_builder/web/templates/instances.html
@@ -23,7 +23,12 @@ 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)
{% for v in visits %}
- - {{ v.order_index }}. {{ v.name }} ({{ v.raw_header }})
+
- {{ v.order_index }}. {{ v.name }} ({{ v.label }})
{% if epochs %}
{% endif %}
-
{% endfor %}
|