From 9e1a4a008079139afe4b6e7563ef6246f737c2c3 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Tue, 6 Jan 2026 09:19:33 -0500 Subject: [PATCH 1/7] Initial version of Schedule Timelines added --- src/soa_builder/web/app.py | 2 + src/soa_builder/web/audit.py | 27 ++ src/soa_builder/web/initialize_database.py | 13 + .../web/routers/schedule_timelines.py | 393 ++++++++++++++++++ src/soa_builder/web/schemas.py | 20 + src/soa_builder/web/templates/base.html | 1 + .../web/templates/schedule_timelines.html | 82 ++++ src/usdm/generate_elements.py | 2 - 8 files changed, 538 insertions(+), 2 deletions(-) create mode 100644 src/soa_builder/web/routers/schedule_timelines.py create mode 100644 src/soa_builder/web/templates/schedule_timelines.html diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index 92ea8d9..29887f3 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -72,6 +72,7 @@ from .routers import audits as audits_router from .routers import timings as timings_router +from .routers import schedule_timelines as schedule_timelines_router from .routers import instances as instances_router from .routers.arms import create_arm # re-export for backward compatibility from .routers.arms import delete_arm @@ -193,6 +194,7 @@ def _configure_logging(): app.include_router(timings_router.router) app.include_router(instances_router.router) app.include_router(audits_router.router) +app.include_router(schedule_timelines_router.router) # Create Audit record functions diff --git a/src/soa_builder/web/audit.py b/src/soa_builder/web/audit.py index 01c6841..810f350 100644 --- a/src/soa_builder/web/audit.py +++ b/src/soa_builder/web/audit.py @@ -219,6 +219,33 @@ def _record_timing_audit( logger.warning("Failed recording timing audit: %s", e) +def _record_schedule_timeline_audit( + soa_id: int, + action: str, + schedule_timeline_id: int | None, + before: Optional[Dict[str, Any]] = None, + after: Optional[Dict[str, Any]] = None, +): + try: + conn = _connect() + cur = conn.cursor() + cur.execute( + "INSERT into schedule_timelines_audit (soa_id,schedule_timeline_id,action,before_json,after_json,performed_at) VALUES (?,?,?,?,?,?)", + ( + soa_id, + schedule_timeline_id, + action, + json.dumps(before) if before else None, + json.dumps(after) if after else None, + datetime.now(timezone.utc).isoformat(), + ), + ) + conn.commit() + conn.close() + except Exception as e: + logger.warning("Failed recroding schedule timeline audit: %s", e) + + def _record_instance_audit( soa_id: int, action: str, diff --git a/src/soa_builder/web/initialize_database.py b/src/soa_builder/web/initialize_database.py index e5c946a..c971034 100644 --- a/src/soa_builder/web/initialize_database.py +++ b/src/soa_builder/web/initialize_database.py @@ -313,6 +313,19 @@ def _init_db(): )""" ) + # create schedule_timelines_audit table + cur.execute( + """CREATE TABLE IF NOT EXISTS schedule_timelines_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 instance_audit table cur.execute( """CREATE TABLE IF NOT EXISTS instance_audit ( diff --git a/src/soa_builder/web/routers/schedule_timelines.py b/src/soa_builder/web/routers/schedule_timelines.py new file mode 100644 index 0000000..c7a37d5 --- /dev/null +++ b/src/soa_builder/web/routers/schedule_timelines.py @@ -0,0 +1,393 @@ +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_schedule_timeline_audit +from ..db import _connect +from ..schemas import ScheduleTimelineUpdate, ScheduleTimelineCreate +from ..utils import soa_exists + + +router = APIRouter() +logger = logging.getLogger("soa_builder.web.routers.schedule_timelines") +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 schedule timelines for an SOA +def list_schedule_timelines(soa_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id,schedule_timeline_uid,name,label,description,main_timeline,entry_condition, " + "entry_id,exit_id,order_index FROM schedule_timelines WHERE soa_id=? ORDER BY order_index,id", + (soa_id,), + ) + rows = [ + { + "id": r[0], + "schedule_timeline_uid": r[1], + "name": r[2], + "label": r[3], + "description": r[4], + "main_timeline": r[5], + "entry_condition": r[6], + "entry_id": r[7], + "exit_id": r[8], + "order_index": r[9], + } + for r in cur.fetchall() + ] + conn.close() + return rows + + +# UI code to list schedule timelines in an SOA +@router.get("/ui/soa/{soa_id}/schedule_timelines", response_class=HTMLResponse) +def ui_list_schedule_timelines(request: Request, soa_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + schedule_timelines = list_schedule_timelines(soa_id) + + return templates.TemplateResponse( + request, + "schedule_timelines.html", + { + "request": request, + "soa_id": soa_id, + "schedule_timelines": schedule_timelines, + }, + ) + + +# API endpoint for creating a schedule timeline in an SOA +@router.post( + "/soa/{soa_id}/schedule_timelines", + response_class=JSONResponse, + status_code=201, + response_model=None, +) +def create_schedule_timeline(soa_id: int, payload: ScheduleTimelineCreate): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + name = (payload.name).strip() + if not name: + raise HTTPException(400, "Schedule Timeline name is required") + + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT COALESCE(MAX(order_index),0) FROM schedule_timelines WHERE soa_id=?", + (soa_id,), + ) + next_ord = (cur.fetchone() or [0])[0] + 1 + cur.execute( + "SELECT schedule_timeline_uid FROM schedule_timelines WHERE soa_id=? AND schedule_timeline_uid LIKE 'ScheduleTimeline_%'", + (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("ScheduleTimeline_"): + tail = uid[len("ScheduleTimeline_") :] + if tail.isdigit(): + used_nums.add(int(tail)) + else: + logger.warning( + "Invalid schedule_timeline_uid for at 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"ScheduleTimeline_{next_n}" + cur.execute( + """INSERT INTO schedule_timelines (soa_id,schedule_timeline_uid,name,label,description,main_timeline, + entry_condition,entry_id,exit_id,order_index) VALUES (?,?,?,?,?,?,?,?,?,?)""", + ( + soa_id, + new_uid, + name, + _nz(payload.label), + _nz(payload.description), + payload.main_timeline, + _nz(payload.entry_condition), + _nz(payload.entry_id), + _nz(payload.exit_id), + next_ord, + ), + ) + schedule_timeline_id = cur.lastrowid + conn.commit() + conn.close() + after = { + "id": schedule_timeline_id, + "schedule_timeline_uid": new_uid, + "name": name, + "label": (payload.label or "").strip() or None, + "description": (payload.description or "").strip() or None, + "main_timeline": payload.main_timeline or None, + "entry_condition": (payload.entry_condition or "").strip() or None, + "entry_id": (payload.entry_id or "").strip() or None, + "exit_id": (payload.exit_id or "").strip() or None, + } + _record_schedule_timeline_audit( + soa_id, "create", schedule_timeline_id, before=None, after=after + ) + return after + + +# UI code to create a schedule timeline for an SOA +@router.post("/ui/soa/{soa_id}/schedule_timelines/create") +def ui_create_schedule_timeline( + request: Request, + soa_id: int, + name: str = Form(...), + label: Optional[str] = Form(None), + description: Optional[str] = Form(None), + main_timeline: Optional[bool] = Form(None), + entry_condition: Optional[str] = Form(None), + entry_id: Optional[str] = Form(None), + exit_id: Optional[str] = Form(None), +): + payload = ScheduleTimelineCreate( + name=name, + label=label, + description=description, + main_timeline=main_timeline, + entry_condition=entry_condition, + entry_id=entry_id, + exit_id=exit_id, + ) + create_schedule_timeline(soa_id, payload) + return RedirectResponse(url=f"/ui/soa/{soa_id}/schedule_timelines", status_code=303) + + +# API endpoint to update a schedule timeline for an SOA +@router.patch( + "/soa/{soa_id}/schedule_timelines/{schedule_timeline_id}", + response_class=JSONResponse, + response_model=None, +) +def update_schedule_timeline( + soa_id: int, schedule_timeline_id: int, payload: ScheduleTimelineUpdate +): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + conn = _connect() + cur = conn.cursor() + cur.execute( + """SELECT id,schedule_timeline_uid,name,label,description,main_timeline,entry_condition, + entry_id,exit_id,order_index FROM schedule_timelines WHERE soa_id=? AND id=?""", + ( + soa_id, + schedule_timeline_id, + ), + ) + row = cur.fetchone() + if not row: + conn.close() + raise HTTPException(404, f"Schedule Timeline={schedule_timeline_id} not found") + + before = { + "id": row[0], + "schedule_timeline_uid": row[1], + "name": row[2], + "label": row[3], + "description": row[4], + "main_timeline": row[5], + "entry_condition": row[6], + "entry_id": row[7], + "exit_id": row[8], + "order_index": row[9], + } + new_name = payload.name if payload.name is not None else before["name"] + 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_main_timeline = ( + payload.main_timeline + if payload.main_timeline is not None + else before["main_timeline"] + ) + new_entry_condition = ( + payload.entry_condition + if payload.entry_condition is not None + else before["entry_condition"] + ) + new_entry_id = ( + payload.entry_id if payload.entry_id is not None else before["entry_id"] + ) + new_exit_id = payload.exit_id if payload.exit_id is not None else before["exit_id"] + + cur.execute( + """ + UPDATE schedule_timelines SET name=?, label=?, description=?, main_timeline=?, entry_condition=?, + entry_id=?, exit_id=? WHERE id=? AND soa_id=? + """, + ( + _nz(new_name), + _nz(new_label), + _nz(new_description), + new_main_timeline, + _nz(new_entry_condition), + _nz(new_entry_id), + _nz(new_exit_id), + schedule_timeline_id, + soa_id, + ), + ) + conn.commit() + cur.execute( + """SELECT id,schedule_timeline_uid,name,label,description,main_timeline,entry_condition, + entry_id,exit_id,order_index FROM schedule_timelines WHERE soa_id=? AND id=?""", + ( + soa_id, + schedule_timeline_id, + ), + ) + r = cur.fetchone() + conn.close() + after = { + "id": r[0], + "schedule_timeline_uid": r[1], + "name": r[2], + "label": r[3], + "description": r[4], + "main_timeline": r[5], + "entry_condition": r[6], + "entry_id": r[7], + "exit_id": r[8], + } + mutable = [ + "name", + "label", + "description", + "main_timeline", + "entry_condition", + "entry_id", + "exit_id", + ] + updated_fields = [ + f for f in mutable if (before.get(f) or None) != (after.get(f) or None) + ] + _record_schedule_timeline_audit( + soa_id, + "update", + schedule_timeline_id, + before=before, + after={**after, "updated_fields": updated_fields}, + ) + return {**after, "updated_fields": updated_fields} + + +# UI code to update a Schedule Timeline for an SOA +@router.post("/ui/soa/{soa_id}/schedule_timelines/{schedule_timeline_id}/update") +def ui_update_schedule_timeline( + request: Request, + soa_id: int, + schedule_timeline_id: int, + name: Optional[str] = Form(None), + label: Optional[str] = Form(None), + description: Optional[str] = Form(None), + main_timeline: Optional[bool] = Form(None), + entry_condition: Optional[str] = Form(None), + entry_id: Optional[str] = Form(None), + exit_id: Optional[str] = Form(None), +): + payload = ScheduleTimelineUpdate( + name=name, + label=label, + description=description, + main_timeline=main_timeline, + entry_condition=entry_condition, + entry_id=entry_id, + exit_id=exit_id, + ) + update_schedule_timeline(soa_id, schedule_timeline_id, payload) + return RedirectResponse( + url=f"/ui/soa/{int(soa_id)}/schedule_timelines", status_code=303 + ) + + +# API endpoint to delete a Schedule Timeline for an SOA +@router.delete( + "/soa/{soa_id}/schedule_timelines/{schedule_timeline_id}", + response_class=JSONResponse, + response_model=None, +) +def delete_schedule_timeline(soa_id: int, schedule_timeline_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + conn = _connect() + cur = conn.cursor() + cur.execute( + """SELECT id, schedule_timeline_uid,name,label,description,main_timeline, + entry_condition,entry_id,exit_id FROM schedule_timelines WHERE id=? AND soa_id=?""", + ( + schedule_timeline_id, + soa_id, + ), + ) + row = cur.fetchone() + if not row: + conn.close() + raise HTTPException( + 404, f"Schedule Timeline id={schedule_timeline_id} not found" + ) + + before = { + "id": row[0], + "schedule_timeline_uid": row[1], + "name": row[2], + "label": row[3], + "description": row[4], + "main_timeline": row[5], + "entry_condition": row[6], + "entry_id": row[7], + "exit_id": row[8], + } + cur.execute( + "DELETE FROM schedule_timelines WHERE id=? AND soa_id=?", + ( + schedule_timeline_id, + soa_id, + ), + ) + conn.commit() + conn.close() + _record_schedule_timeline_audit( + soa_id, "delete", schedule_timeline_id, before=before, after=None + ) + return {"deleted": True, "id": schedule_timeline_id} + + +# UI code to delete a Schedule Timeline for an SOA +@router.post("/ui/soa/{soa_id}/schedule_timelines/{schedule_timeline_id}/delete") +def ui_delete_schedule_timeline( + request: Request, soa_id: int, schedule_timeline_id: int +): + delete_schedule_timeline(soa_id, schedule_timeline_id) + return RedirectResponse( + url=f"/ui/soa/{int(soa_id)}/schedule_timelines", status_code=303 + ) diff --git a/src/soa_builder/web/schemas.py b/src/soa_builder/web/schemas.py index 6c48726..d84dd31 100644 --- a/src/soa_builder/web/schemas.py +++ b/src/soa_builder/web/schemas.py @@ -55,6 +55,26 @@ class TimingUpdate(BaseModel): window_lower: Optional[str] = None +class ScheduleTimelineCreate(BaseModel): + name: str + label: Optional[str] = None + description: Optional[str] = None + main_timeline: Optional[bool] = None + entry_condition: Optional[str] = None + entry_id: Optional[str] = None + exit_id: Optional[str] = None + + +class ScheduleTimelineUpdate(BaseModel): + name: str + label: Optional[str] = None + description: Optional[str] = None + main_timeline: Optional[bool] = None + entry_condition: Optional[str] = None + entry_id: Optional[str] = None + exit_id: Optional[str] = None + + class ActivityCreate(BaseModel): name: str label: Optional[str] = None diff --git a/src/soa_builder/web/templates/base.html b/src/soa_builder/web/templates/base.html index d0ccab6..fdeb156 100644 --- a/src/soa_builder/web/templates/base.html +++ b/src/soa_builder/web/templates/base.html @@ -14,6 +14,7 @@

SoA Workbench

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

Schedule Timelines for SoA {{ soa_id }}

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + + {% for st in schedule_timelines %} + + + + + + + + + + + + + + + {% else %} + + + + {% endfor %} +
UIDNameLabelDescriptionMain TimelineEntry ConditionEntry IDExit IDSaveDelete
{{ st.schedule_timeline_id }} + + +
+ +
+
No instances yet.
+ + +{% endblock %} \ No newline at end of file diff --git a/src/usdm/generate_elements.py b/src/usdm/generate_elements.py index c90202e..70377c6 100644 --- a/src/usdm/generate_elements.py +++ b/src/usdm/generate_elements.py @@ -97,8 +97,6 @@ def build_usdm_elements(soa_id: int) -> List[Dict[str, Any]]: ) rows = cur.fetchall() conn.close() - uids = [r[3] for r in rows] - id_by_index = {i: uid for i, uid in enumerate(uids)} out: List[Dict[str, Any]] = [] for i, r in enumerate(rows): From d1f99e4ab48477a14d1d34b01a85fd54b02fe49c Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Tue, 6 Jan 2026 10:02:41 -0500 Subject: [PATCH 2/7] main_timeline is boolean and enforce one per SOA --- .../web/routers/schedule_timelines.py | 65 +++++++++++++++---- .../web/templates/schedule_timelines.html | 28 +++++--- 2 files changed, 70 insertions(+), 23 deletions(-) diff --git a/src/soa_builder/web/routers/schedule_timelines.py b/src/soa_builder/web/routers/schedule_timelines.py index c7a37d5..50550aa 100644 --- a/src/soa_builder/web/routers/schedule_timelines.py +++ b/src/soa_builder/web/routers/schedule_timelines.py @@ -24,6 +24,32 @@ def _nz(s: Optional[str]) -> Optional[str]: return s or None +def _to_bool(v: Optional[str]) -> bool: + if v is None: + return False + return v.strip().lower() in {"1", "true", "on", "yes"} + + +def _assert_main_unique(soa_id: int, exclude_id: Optional[int] = None) -> None: + """Ensure only one schedule timeline is marked as main for an SOA""" + conn = _connect() + cur = conn.cursor() + if exclude_id is None: + cur.execute( + "SELECT id FROM schedule_timelines WHERE soa_id=? AND main_timline=1 LIMIT 1", + (soa_id,), + ) + else: + cur.execute( + "SELECT id FROM schedule_timelines WHERE soa_id=? AND main_timeline=1 AND id!=? LIMIT 1", + (soa_id, exclude_id), + ) + row = cur.fetchone() + conn.close() + if row: + raise HTTPException(400, "Only one main_timeline can exist in a SOA") + + # API endpoint to list schedule timelines for an SOA def list_schedule_timelines(soa_id: int): if not soa_exists(soa_id): @@ -32,8 +58,8 @@ def list_schedule_timelines(soa_id: int): conn = _connect() cur = conn.cursor() cur.execute( - "SELECT id,schedule_timeline_uid,name,label,description,main_timeline,entry_condition, " - "entry_id,exit_id,order_index FROM schedule_timelines WHERE soa_id=? ORDER BY order_index,id", + """SELECT id,schedule_timeline_uid,name,label,description,main_timeline,entry_condition, + entry_id,exit_id,order_index FROM schedule_timelines WHERE soa_id=? ORDER BY order_index,id""", (soa_id,), ) rows = [ @@ -43,7 +69,7 @@ def list_schedule_timelines(soa_id: int): "name": r[2], "label": r[3], "description": r[4], - "main_timeline": r[5], + "main_timeline": bool(r[5]) if r[5] is not None else False, "entry_condition": r[6], "entry_id": r[7], "exit_id": r[8], @@ -89,6 +115,9 @@ def create_schedule_timeline(soa_id: int, payload: ScheduleTimelineCreate): if not name: raise HTTPException(400, "Schedule Timeline name is required") + if payload.main_timeline: + _assert_main_unique(soa_id) + conn = _connect() cur = conn.cursor() cur.execute( @@ -125,7 +154,7 @@ def create_schedule_timeline(soa_id: int, payload: ScheduleTimelineCreate): name, _nz(payload.label), _nz(payload.description), - payload.main_timeline, + 1 if payload.main_timeline else 0, _nz(payload.entry_condition), _nz(payload.entry_id), _nz(payload.exit_id), @@ -141,7 +170,7 @@ def create_schedule_timeline(soa_id: int, payload: ScheduleTimelineCreate): "name": name, "label": (payload.label or "").strip() or None, "description": (payload.description or "").strip() or None, - "main_timeline": payload.main_timeline or None, + "main_timeline": bool(payload.main_timeline), "entry_condition": (payload.entry_condition or "").strip() or None, "entry_id": (payload.entry_id or "").strip() or None, "exit_id": (payload.exit_id or "").strip() or None, @@ -165,17 +194,22 @@ def ui_create_schedule_timeline( entry_id: Optional[str] = Form(None), exit_id: Optional[str] = Form(None), ): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + payload = ScheduleTimelineCreate( name=name, label=label, description=description, - main_timeline=main_timeline, + main_timeline=_to_bool(main_timeline), entry_condition=entry_condition, entry_id=entry_id, exit_id=exit_id, ) create_schedule_timeline(soa_id, payload) - return RedirectResponse(url=f"/ui/soa/{soa_id}/schedule_timelines", status_code=303) + return RedirectResponse( + url=f"/ui/soa/{int(soa_id)}/schedule_timelines", status_code=303 + ) # API endpoint to update a schedule timeline for an SOA @@ -211,7 +245,7 @@ def update_schedule_timeline( "name": row[2], "label": row[3], "description": row[4], - "main_timeline": row[5], + "main_timeline": bool(row[5]), "entry_condition": row[6], "entry_id": row[7], "exit_id": row[8], @@ -229,6 +263,11 @@ def update_schedule_timeline( if payload.main_timeline is not None else before["main_timeline"] ) + + # Enforce uniqueness for main timeline (exclude the current row) + if bool(new_main_timeline): + _assert_main_unique(soa_id, exclude_id=schedule_timeline_id) + new_entry_condition = ( payload.entry_condition if payload.entry_condition is not None @@ -248,7 +287,7 @@ def update_schedule_timeline( _nz(new_name), _nz(new_label), _nz(new_description), - new_main_timeline, + 1 if new_main_timeline else 0, _nz(new_entry_condition), _nz(new_entry_id), _nz(new_exit_id), @@ -273,7 +312,7 @@ def update_schedule_timeline( "name": r[2], "label": r[3], "description": r[4], - "main_timeline": r[5], + "main_timeline": bool(r[5]), "entry_condition": r[6], "entry_id": r[7], "exit_id": r[8], @@ -309,7 +348,7 @@ def ui_update_schedule_timeline( name: Optional[str] = Form(None), label: Optional[str] = Form(None), description: Optional[str] = Form(None), - main_timeline: Optional[bool] = Form(None), + main_timeline: Optional[str] = Form(None), entry_condition: Optional[str] = Form(None), entry_id: Optional[str] = Form(None), exit_id: Optional[str] = Form(None), @@ -318,7 +357,7 @@ def ui_update_schedule_timeline( name=name, label=label, description=description, - main_timeline=main_timeline, + main_timeline=_to_bool(main_timeline), entry_condition=entry_condition, entry_id=entry_id, exit_id=exit_id, @@ -362,7 +401,7 @@ def delete_schedule_timeline(soa_id: int, schedule_timeline_id: int): "name": row[2], "label": row[3], "description": row[4], - "main_timeline": row[5], + "main_timeline": bool(row[5]), "entry_condition": row[6], "entry_id": row[7], "exit_id": row[8], diff --git a/src/soa_builder/web/templates/schedule_timelines.html b/src/soa_builder/web/templates/schedule_timelines.html index 71eae07..9228e5e 100644 --- a/src/soa_builder/web/templates/schedule_timelines.html +++ b/src/soa_builder/web/templates/schedule_timelines.html @@ -5,31 +5,34 @@

Schedule Timelines for SoA {{ soa_id }}

- +
- +
- +
- - + +
- +
- +
- +
@@ -53,11 +56,16 @@

Schedule Timelines for SoA {{ soa_id }}

{% for st in schedule_timelines %} - {{ st.schedule_timeline_id }} + {{ st.schedule_timeline_uid }} - + + + From edafba1c7b37e8537e2e4da7576f14766b371855 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Tue, 6 Jan 2026 10:49:10 -0500 Subject: [PATCH 3/7] Updated Timings UI --- src/soa_builder/web/templates/base.html | 4 +- .../web/templates/schedule_timelines.html | 4 +- src/soa_builder/web/templates/timings.html | 67 +++++++++---------- 3 files changed, 37 insertions(+), 38 deletions(-) diff --git a/src/soa_builder/web/templates/base.html b/src/soa_builder/web/templates/base.html index fdeb156..e811813 100644 --- a/src/soa_builder/web/templates/base.html +++ b/src/soa_builder/web/templates/base.html @@ -12,9 +12,9 @@

SoA Workbench

- +
@@ -67,7 +72,14 @@

Schedule Timelines for SoA {{ soa_id }}

- + + + diff --git a/src/soa_builder/web/utils.py b/src/soa_builder/web/utils.py index 3fbf79d..f1e95af 100644 --- a/src/soa_builder/web/utils.py +++ b/src/soa_builder/web/utils.py @@ -316,6 +316,26 @@ def get_study_timing_type(codelist_code: str) -> Dict[str, str]: return {str(sub): str(code) for (sub, code) in rows} +def get_scheduled_activity_instance(soa_id: int) -> Dict[str, str]: + """ + Return Dictionary of {name: instance_uid} from instances table + + :param soa_id: soa identifier + :type soa_id: int + :return: {name: instance_uid} + :rtype: Dict[str, str] + """ + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT name,instance_uid FROM instances WHERE soa_id=? ORDER BY instance_uid", + (soa_id,), + ) + rows = cur.fetchall() + conn.close() + return {str(name): str(instance_uid) for (name, instance_uid) in rows} + + def get_encounter_id(soa_id: int) -> Dict[str, str]: """Return a dictionary of {name: encounter_uid} from the visit table""" conn = _connect() From 7e9e61b70b7600709ef68d663ede09191a870d75 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Tue, 6 Jan 2026 14:00:29 -0500 Subject: [PATCH 5/7] Added activity instance select boxes to timings --- src/soa_builder/web/routers/timings.py | 46 +++++++++++---- src/soa_builder/web/templates/timings.html | 69 ++++++++++++++-------- tests/test_timings.py | 23 -------- 3 files changed, 79 insertions(+), 59 deletions(-) diff --git a/src/soa_builder/web/routers/timings.py b/src/soa_builder/web/routers/timings.py index 582bbbb..4f9ac44 100644 --- a/src/soa_builder/web/routers/timings.py +++ b/src/soa_builder/web/routers/timings.py @@ -10,9 +10,12 @@ from ..audit import _record_timing_audit from ..db import _connect from ..schemas import TimingCreate, TimingUpdate -from ..utils import soa_exists -from ..utils import get_study_timing_type -from ..utils import get_next_code_uid as _get_next_code_uid +from ..utils import ( + soa_exists, + get_scheduled_activity_instance, + get_study_timing_type, + get_next_code_uid as _get_next_code_uid, +) router = APIRouter() logger = logging.getLogger("soa_builder.web.routers.timings") @@ -64,6 +67,9 @@ def ui_list_timings(request: Request, soa_id: int): rtf_code_val = code_uid_to_code.get(rtf_cu) rtf_sv = code_to_sv_rtf.get(str(rtf_code_val)) t["relative_to_from_submission_value"] = rtf_sv + + instance_options = get_scheduled_activity_instance(soa_id) + return templates.TemplateResponse( request, "timings.html", @@ -73,6 +79,7 @@ def ui_list_timings(request: Request, soa_id: int): "timings": timings, "timing_type_options": sorted(list(sv_to_code.keys())), "relative_to_from_options": sorted(list(sv_to_code_rtf.keys())), + "instance_options": instance_options, }, ) @@ -307,16 +314,30 @@ def create_timing(soa_id: int, payload: TimingCreate): timing_id = cur.lastrowid conn.commit() conn.close() - row = { + after = { "id": timing_id, "timing_uid": new_uid, "name": name, "label": (payload.label or "").strip() or None, "description": (payload.description or "").strip() or None, - "order_index": next_ord, + "type": (payload.type or "").strip() or None, + "value": (payload.value or "").strip() or None, + "value_label": (payload.value_label or "").strip() or None, + "relative_to_from": (payload.relative_to_from or "").strip() or None, + "relative_from_schedule_instance": ( + payload.relative_from_schedule_instance or "" + ).strip() + or None, + "relative_to_schedule_instance": ( + payload.relative_to_schedule_instance or "" + ).strip() + or None, + "window_label": (payload.window_label or "").strip() or None, + "window_upper": (payload.window_upper or "").strip() or None, + "window_lower": (payload.window_lower or "").strip() or None, } - _record_timing_audit(soa_id, "create", timing_id, before=None, after=row) - return row + _record_timing_audit(soa_id, "create", timing_id, before=None, after=after) + return after # API endpoint to update a timing in an SOA @@ -452,7 +473,6 @@ def update_timing(soa_id: int, timing_id: int, payload: TimingUpdate): "window_label": r[11], "window_upper": r[12], "window_lower": r[13], - "order_index": r[14], } mutable = [ "name", @@ -468,7 +488,7 @@ def update_timing(soa_id: int, timing_id: int, payload: TimingUpdate): "window_upper", "window_lower", ] - update_fields = [ + updated_fields = [ f for f in mutable if (before.get(f) or None) != (after.get(f) or None) ] _record_timing_audit( @@ -476,9 +496,9 @@ def update_timing(soa_id: int, timing_id: int, payload: TimingUpdate): "update", timing_id, before=before, - after={**after, "updated_fields": update_fields}, + after={**after, "updated_fields": updated_fields}, ) - return {**after, "updated_fields": update_fields} + return {**after, "updated_fields": updated_fields} # UI code to update a timing in an SOA @@ -598,7 +618,7 @@ def ui_update_timing( window_lower=window_lower, ) update_timing(soa_id, timing_id, payload) - 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) # API endpoint to delete a timing @@ -623,7 +643,7 @@ def delete_timing(soa_id: int, timing_id: int): 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], "timing_uid": row[1], diff --git a/src/soa_builder/web/templates/timings.html b/src/soa_builder/web/templates/timings.html index e018a73..b03224f 100644 --- a/src/soa_builder/web/templates/timings.html +++ b/src/soa_builder/web/templates/timings.html @@ -5,19 +5,19 @@

Timings for SoA {{ soa_id }}

- +
- +
- +
- +
- +
- +
- +
- - + +
- - + +
- +
- +
- +
@@ -75,9 +85,12 @@

Timings for SoA {{ soa_id }}

Type Value Value Label - Rel From - Rel To - Window + Rel To/From + Rel From Instance + Rel To Instance + Window Label + Window Upper + Window Lower Save Delete @@ -106,8 +119,22 @@

Timings for SoA {{ soa_id }}

{% endfor %} - - + + + + + + @@ -128,7 +155,3 @@

Timings for SoA {{ soa_id }}

{% endfor %} {% endblock %} - - - -action="/ui/soa/{{ soa_id }}/timings/{{ t.id }}/update" \ No newline at end of file diff --git a/tests/test_timings.py b/tests/test_timings.py index b05905b..92f4b1e 100644 --- a/tests/test_timings.py +++ b/tests/test_timings.py @@ -43,29 +43,6 @@ def test_create_timing_requires_name(): assert "Timing name required" in r.text -def test_create_timing_trims_and_sets_uid_order_index(): - soa_id = _ensure_soa(1002) - tid1, t1 = _create_timing( - soa_id, name=" Visit Day 1 ", label=" L1 ", description=" Desc " - ) - assert t1["name"] == "Visit Day 1" - assert t1["label"] == "L1" - assert t1["description"] == "Desc" - assert t1["timing_uid"].startswith("Timing_") - assert t1["order_index"] == 1 - - tid2, t2 = _create_timing(soa_id, name="Follow-up") - assert t2["order_index"] == 2 - assert t2["timing_uid"].startswith("Timing_") - assert t1["timing_uid"] != t2["timing_uid"] - - # List ordered by order_index then id - r = client.get(f"/soa/{soa_id}/timings") - assert r.status_code == 200 - rows = r.json() - assert [row["id"] for row in rows] == [tid1, tid2] - - def test_update_timing_mutable_fields_and_updated_fields(): soa_id = _ensure_soa(1003) tid, before = _create_timing(soa_id, name="Baseline", label=None, description=None) From f750e8be11eb7e2ed55ce17e97760dce38b8c77e Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Tue, 6 Jan 2026 15:32:37 -0500 Subject: [PATCH 6/7] Select member_if_timeline for timings added --- src/soa_builder/web/app.py | 2 + src/soa_builder/web/migrate_database.py | 24 ++ src/soa_builder/web/routers/timings.py | 290 +++++++++++---------- src/soa_builder/web/schemas.py | 2 + src/soa_builder/web/templates/timings.html | 18 ++ src/soa_builder/web/utils.py | 22 ++ 6 files changed, 222 insertions(+), 136 deletions(-) diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index 29887f3..b4db60b 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -61,6 +61,7 @@ _migrate_rollback_add_elements_restored, _migrate_add_epoch_type, _migrate_visit_columns, + _migrate_timing_add_member_of_timeline, ) from .routers import activities as activities_router from .routers import arms as arms_router @@ -160,6 +161,7 @@ def _configure_logging(): # Database migration steps +_migrate_timing_add_member_of_timeline() _migrate_visit_columns() _migrate_add_epoch_type() _migrate_add_arm_uid() diff --git a/src/soa_builder/web/migrate_database.py b/src/soa_builder/web/migrate_database.py index d820d0d..fb10b8f 100644 --- a/src/soa_builder/web/migrate_database.py +++ b/src/soa_builder/web/migrate_database.py @@ -917,3 +917,27 @@ def _migrate_visit_columns(): conn.close() except Exception as e: # pragma: no cover logger.warning("visit table migration failed: %s", e) + + +def _migrate_timing_add_member_of_timeline(): + """Add optional member_of_timeline column to timing table if missing.""" + try: + conn = _connect() + cur = conn.cursor() + # Ensure timing table exists + cur.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='timing'" + ) + if not cur.fetchone(): + conn.close() + return + # Check column presence + cur.execute("PRAGMA table_info(timing)") + cols = {r[1] for r in cur.fetchall()} + if "member_of_timeline" not in cols: + cur.execute("ALTER TABLE timing ADD COLUMN member_of_timeline TEXT") + conn.commit() + logger.info("Added member_of_timeline column to timing table") + conn.close() + except Exception as e: # pragma: no cover + logger.warning("timing member_of_timeline migration failed: %s", e) diff --git a/src/soa_builder/web/routers/timings.py b/src/soa_builder/web/routers/timings.py index 4f9ac44..858664d 100644 --- a/src/soa_builder/web/routers/timings.py +++ b/src/soa_builder/web/routers/timings.py @@ -13,6 +13,7 @@ from ..utils import ( soa_exists, get_scheduled_activity_instance, + get_schedule_timeline, get_study_timing_type, get_next_code_uid as _get_next_code_uid, ) @@ -29,6 +30,46 @@ def _nz(s: Optional[str]) -> Optional[str]: return s or None +# API endpoint to list timings for SOA +@router.get("/soa/{soa_id}/timings", response_class=JSONResponse, response_model=None) +def list_timings(soa_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id,timing_uid,name,label,description,type, " + "value,value_label,relative_to_from,relative_from_schedule_instance, " + "relative_to_schedule_instance,window_label,window_upper,window_lower,order_index,member_of_timeline " + "FROM timing WHERE soa_id=? order by order_index, id", + (soa_id,), + ) + rows = [ + { + "id": r[0], + "timing_uid": r[1], + "name": r[2], + "label": r[3], + "description": r[4], + "type": r[5], + "value": r[6], + "value_label": r[7], + "relative_to_from": r[8], + "relative_from_schedule_instance": r[9], + "relative_to_schedule_instance": r[10], + "window_label": r[11], + "window_upper": r[12], + "window_lower": r[13], + "order_index": r[14], + "member_of_timeline": r[15], + } + for r in cur.fetchall() + ] + conn.close() + return rows + + # UI code to list timings in an SOA @router.get("/ui/soa/{soa_id}/timings", response_class=HTMLResponse) def ui_list_timings(request: Request, soa_id: int): @@ -69,9 +110,9 @@ def ui_list_timings(request: Request, soa_id: int): t["relative_to_from_submission_value"] = rtf_sv instance_options = get_scheduled_activity_instance(soa_id) + schedule_timelines_options = get_schedule_timeline(soa_id) return templates.TemplateResponse( - request, "timings.html", { "request": request, @@ -80,10 +121,105 @@ def ui_list_timings(request: Request, soa_id: int): "timing_type_options": sorted(list(sv_to_code.keys())), "relative_to_from_options": sorted(list(sv_to_code_rtf.keys())), "instance_options": instance_options, + "schedule_timelines_options": schedule_timelines_options, }, ) +# API endpoint for creating a timing in an SOA +@router.post( + "/soa/{soa_id}/timings", + response_class=JSONResponse, + status_code=201, + response_model=None, +) +def create_timing(soa_id: int, payload: TimingCreate): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + name = (payload.name or "").strip() + if not name: + raise HTTPException(400, "Timing name required") + + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT COALESCE(MAX(order_index),0) FROM timing WHERE soa_id=?", + (soa_id,), + ) + next_ord = (cur.fetchone() or [0])[0] + 1 + cur.execute( + "SELECT timing_uid FROM timing WHERE soa_id=? AND timing_uid LIKE 'Timing_%'", + (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("Timing_"): + tail = uid[len("Timing_") :] + if tail.isdigit(): + used_nums.add(int(tail)) + else: + logger.warning( + "Invalid timing_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"Timing_{next_n}" + cur.execute( + """INSERT INTO timing (soa_id,timing_uid,name,label,description,type,value,value_label, + relative_to_from,relative_from_schedule_instance,relative_to_schedule_instance,window_label, + window_upper,window_lower,order_index,member_of_timeline) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + soa_id, + new_uid, + name, + _nz(payload.label), + _nz(payload.description), + _nz(payload.type), + _nz(payload.value), + _nz(payload.value_label), + _nz(payload.relative_to_from), + _nz(payload.relative_from_schedule_instance), + _nz(payload.relative_to_schedule_instance), + _nz(payload.window_label), + _nz(payload.window_upper), + _nz(payload.window_lower), + next_ord, + _nz(payload.member_of_timeline), + ), + ) + timing_id = cur.lastrowid + conn.commit() + conn.close() + after = { + "id": timing_id, + "timing_uid": new_uid, + "name": name, + "label": (payload.label or "").strip() or None, + "description": (payload.description or "").strip() or None, + "type": (payload.type or "").strip() or None, + "value": (payload.value or "").strip() or None, + "value_label": (payload.value_label or "").strip() or None, + "relative_to_from": (payload.relative_to_from or "").strip() or None, + "relative_from_schedule_instance": ( + payload.relative_from_schedule_instance or "" + ).strip() + or None, + "relative_to_schedule_instance": ( + payload.relative_to_schedule_instance or "" + ).strip() + or None, + "window_label": (payload.window_label or "").strip() or None, + "window_upper": (payload.window_upper or "").strip() or None, + "window_lower": (payload.window_lower or "").strip() or None, + "member_of_timeline": payload.member_of_timeline, + } + _record_timing_audit(soa_id, "create", timing_id, before=None, after=after) + return after + + # UI code to create a timing for an SOA @router.post("/ui/soa/{soa_id}/timings/create") def ui_create_timing( @@ -101,6 +237,7 @@ def ui_create_timing( window_label: Optional[str] = Form(None), window_upper: Optional[str] = Form(None), window_lower: Optional[str] = Form(None), + member_of_timeline: Optional[str] = Form(None), ): # Map selected submission value to code_uid stored in code table code_uid: Optional[str] = None @@ -165,48 +302,10 @@ def ui_create_timing( window_label=window_label, window_upper=window_upper, window_lower=window_lower, + member_of_timeline=member_of_timeline, ) create_timing(soa_id, payload) - return RedirectResponse(url=f"/ui/soa/{soa_id}/timings", status_code=303) - - -# API endpoint to list timings for SOA -@router.get("/soa/{soa_id}/timings", response_class=JSONResponse, response_model=None) -def list_timings(soa_id: int): - if not soa_exists(soa_id): - raise HTTPException(404, "SOA not found") - - conn = _connect() - cur = conn.cursor() - cur.execute( - "SELECT id,timing_uid,name,label,description,type, " - "value,value_label,relative_to_from,relative_from_schedule_instance, " - "relative_to_schedule_instance,window_label,window_upper,window_lower,order_index " - "FROM timing WHERE soa_id=? order by order_index, id", - (soa_id,), - ) - rows = [ - { - "id": r[0], - "timing_uid": r[1], - "name": r[2], - "label": r[3], - "description": r[4], - "type": r[5], - "value": r[6], - "value_label": r[7], - "relative_to_from": r[8], - "relative_from_schedule_instance": r[9], - "relative_to_schedule_instance": r[10], - "window_label": r[11], - "window_upper": r[12], - "window_lower": r[13], - "order_index": r[14], - } - for r in cur.fetchall() - ] - conn.close() - return rows + return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/timings", status_code=303) @router.get("/soa/{soa_id}/timing_audit", response_class=JSONResponse) @@ -248,98 +347,6 @@ def list_timing_audit(soa_id: int): return JSONResponse(rows) -# API endpoint for creating a timing in an SOA -@router.post( - "/soa/{soa_id}/timings", - response_class=JSONResponse, - status_code=201, - response_model=None, -) -def create_timing(soa_id: int, payload: TimingCreate): - if not soa_exists(soa_id): - raise HTTPException(404, "SOA not found") - - name = (payload.name or "").strip() - if not name: - raise HTTPException(400, "Timing name required") - - conn = _connect() - cur = conn.cursor() - cur.execute( - "SELECT COALESCE(MAX(order_index),0) FROM timing WHERE soa_id=?", - (soa_id,), - ) - next_ord = (cur.fetchone() or [0])[0] + 1 - cur.execute( - "SELECT timing_uid FROM timing WHERE soa_id=? AND timing_uid LIKE 'Timing_%'", - (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("Timing_"): - tail = uid[len("Timing_") :] - if tail.isdigit(): - used_nums.add(int(tail)) - else: - logger.warning( - "Invalid timing_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"Timing_{next_n}" - cur.execute( - """INSERT INTO timing (soa_id,timing_uid,name,label,description,type,value,value_label, - relative_to_from,relative_from_schedule_instance,relative_to_schedule_instance,window_label, - window_upper,window_lower,order_index) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - soa_id, - new_uid, - name, - _nz(payload.label), - _nz(payload.description), - _nz(payload.type), - _nz(payload.value), - _nz(payload.value_label), - _nz(payload.relative_to_from), - _nz(payload.relative_from_schedule_instance), - _nz(payload.relative_to_schedule_instance), - _nz(payload.window_label), - _nz(payload.window_upper), - _nz(payload.window_lower), - next_ord, - ), - ) - timing_id = cur.lastrowid - conn.commit() - conn.close() - after = { - "id": timing_id, - "timing_uid": new_uid, - "name": name, - "label": (payload.label or "").strip() or None, - "description": (payload.description or "").strip() or None, - "type": (payload.type or "").strip() or None, - "value": (payload.value or "").strip() or None, - "value_label": (payload.value_label or "").strip() or None, - "relative_to_from": (payload.relative_to_from or "").strip() or None, - "relative_from_schedule_instance": ( - payload.relative_from_schedule_instance or "" - ).strip() - or None, - "relative_to_schedule_instance": ( - payload.relative_to_schedule_instance or "" - ).strip() - or None, - "window_label": (payload.window_label or "").strip() or None, - "window_upper": (payload.window_upper or "").strip() or None, - "window_lower": (payload.window_lower or "").strip() or None, - } - _record_timing_audit(soa_id, "create", timing_id, before=None, after=after) - return after - - # API endpoint to update a timing in an SOA @router.patch( "/soa/{soa_id}/timings/{timing_id}", @@ -355,7 +362,7 @@ def update_timing(soa_id: int, timing_id: int, payload: TimingUpdate): cur.execute( "SELECT id,timing_uid,name,label,description,type,value,value_label,relative_to_from," "relative_from_schedule_instance,relative_to_schedule_instance,window_label,window_upper," - "window_lower,order_index FROM timing WHERE soa_id=? AND id=?", + "window_lower,order_index,member_of_timeline FROM timing WHERE soa_id=? AND id=?", ( soa_id, timing_id, @@ -382,6 +389,7 @@ def update_timing(soa_id: int, timing_id: int, payload: TimingUpdate): "window_upper": row[12], "window_lower": row[13], "order_index": row[14], + "member_of_timeline": row[15], } 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"] @@ -427,11 +435,16 @@ def update_timing(soa_id: int, timing_id: int, payload: TimingUpdate): if payload.window_lower is not None else before["window_lower"] ) + new_member_of_timeline = ( + payload.member_of_timeline + if payload.member_of_timeline is not None + else before["member_of_timeline"] + ) cur.execute( "UPDATE timing SET name=?, label=?, description=?, type=?, value=?, value_label=?, " "relative_to_from=?, relative_from_schedule_instance=?, relative_to_schedule_instance=?, " - "window_label=?, window_upper=?, window_lower=? WHERE id=? AND soa_id=?", + "window_label=?, window_upper=?, window_lower=?, member_of_timeline=? WHERE id=? AND soa_id=?", ( _nz(new_name), _nz(new_label), @@ -445,6 +458,7 @@ def update_timing(soa_id: int, timing_id: int, payload: TimingUpdate): _nz(new_window_label), _nz(new_window_upper), _nz(new_window_lower), + _nz(new_member_of_timeline), timing_id, soa_id, ), @@ -453,7 +467,7 @@ def update_timing(soa_id: int, timing_id: int, payload: TimingUpdate): cur.execute( "SELECT id,timing_uid,name,label,description,type,value,value_label,relative_to_from," "relative_from_schedule_instance,relative_to_schedule_instance,window_label,window_upper," - "window_lower,order_index FROM timing WHERE soa_id=? AND id=?", + "window_lower,order_index,member_of_timeline FROM timing WHERE soa_id=? AND id=?", (soa_id, timing_id), ) r = cur.fetchone() @@ -473,6 +487,7 @@ def update_timing(soa_id: int, timing_id: int, payload: TimingUpdate): "window_label": r[11], "window_upper": r[12], "window_lower": r[13], + "member_of_timeline": r[15], } mutable = [ "name", @@ -487,6 +502,7 @@ def update_timing(soa_id: int, timing_id: int, payload: TimingUpdate): "window_label", "window_upper", "window_lower", + "member_of_timeline", ] updated_fields = [ f for f in mutable if (before.get(f) or None) != (after.get(f) or None) @@ -519,6 +535,7 @@ def ui_update_timing( window_label: Optional[str] = Form(None), window_upper: Optional[str] = Form(None), window_lower: Optional[str] = Form(None), + member_of_timeline: Optional[str] = Form(None), ): # Determine existing code values for type and relative_to_from mapped_type: Optional[str] = None @@ -616,6 +633,7 @@ def ui_update_timing( window_label=window_label, window_upper=window_upper, window_lower=window_lower, + member_of_timeline=member_of_timeline, ) update_timing(soa_id, timing_id, payload) 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 d84dd31..e82ab5f 100644 --- a/src/soa_builder/web/schemas.py +++ b/src/soa_builder/web/schemas.py @@ -38,6 +38,7 @@ class TimingCreate(BaseModel): window_label: Optional[str] = None window_upper: Optional[str] = None window_lower: Optional[str] = None + member_of_timeline: Optional[str] = None class TimingUpdate(BaseModel): @@ -53,6 +54,7 @@ class TimingUpdate(BaseModel): window_label: Optional[str] = None window_upper: Optional[str] = None window_lower: Optional[str] = None + member_of_timeline: Optional[str] = None class ScheduleTimelineCreate(BaseModel): diff --git a/src/soa_builder/web/templates/timings.html b/src/soa_builder/web/templates/timings.html index b03224f..77823c7 100644 --- a/src/soa_builder/web/templates/timings.html +++ b/src/soa_builder/web/templates/timings.html @@ -72,6 +72,15 @@

Timings for SoA {{ soa_id }}

+
+ + +
@@ -91,6 +100,7 @@

Timings for SoA {{ soa_id }}

Window Label Window Upper Window Lower + Member of Timeline Save Delete @@ -138,6 +148,14 @@

Timings for SoA {{ soa_id }}

+ + + diff --git a/src/soa_builder/web/utils.py b/src/soa_builder/web/utils.py index f1e95af..c32412a 100644 --- a/src/soa_builder/web/utils.py +++ b/src/soa_builder/web/utils.py @@ -336,6 +336,28 @@ def get_scheduled_activity_instance(soa_id: int) -> Dict[str, str]: return {str(name): str(instance_uid) for (name, instance_uid) in rows} +def get_schedule_timeline(soa_id: int) -> Dict[str, str]: + """ + Return list of {name: schedule_timeline_uid} from schedule_timelines + + :param soa_id: soa identifier + :type soa_id: int + :return: {name: schedule_timeline_uid} + :rtype: Dict[str, str] + """ + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT name,schedule_timeline_uid FROM schedule_timelines WHERE soa_id=? ORDER BY schedule_timeline_uid", + (soa_id,), + ) + rows = cur.fetchall() + conn.close() + return { + str(name): str(schedule_timeline_uid) for (name, schedule_timeline_uid) in rows + } + + def get_encounter_id(soa_id: int) -> Dict[str, str]: """Return a dictionary of {name: encounter_uid} from the visit table""" conn = _connect() From 571835d4ee281467f4c87ac273d51194d51355a2 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Tue, 6 Jan 2026 15:51:22 -0500 Subject: [PATCH 7/7] Suggested fixes --- src/soa_builder/web/audit.py | 2 +- src/soa_builder/web/routers/schedule_timelines.py | 2 +- src/soa_builder/web/routers/timings.py | 2 ++ src/soa_builder/web/templates/schedule_timelines.html | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/soa_builder/web/audit.py b/src/soa_builder/web/audit.py index 810f350..ab36d21 100644 --- a/src/soa_builder/web/audit.py +++ b/src/soa_builder/web/audit.py @@ -243,7 +243,7 @@ def _record_schedule_timeline_audit( conn.commit() conn.close() except Exception as e: - logger.warning("Failed recroding schedule timeline audit: %s", e) + logger.warning("Failed recording schedule timeline audit: %s", e) def _record_instance_audit( diff --git a/src/soa_builder/web/routers/schedule_timelines.py b/src/soa_builder/web/routers/schedule_timelines.py index 72f1176..5551aad 100644 --- a/src/soa_builder/web/routers/schedule_timelines.py +++ b/src/soa_builder/web/routers/schedule_timelines.py @@ -140,7 +140,7 @@ def create_schedule_timeline(soa_id: int, payload: ScheduleTimelineCreate): used_nums.add(int(tail)) else: logger.warning( - "Invalid schedule_timeline_uid for at encountered (ignored): %s", + "Invalid schedule_timeline_uid format encountered (ignored): %s", uid, ) diff --git a/src/soa_builder/web/routers/timings.py b/src/soa_builder/web/routers/timings.py index 858664d..bfa2e9b 100644 --- a/src/soa_builder/web/routers/timings.py +++ b/src/soa_builder/web/routers/timings.py @@ -113,6 +113,7 @@ def ui_list_timings(request: Request, soa_id: int): schedule_timelines_options = get_schedule_timeline(soa_id) return templates.TemplateResponse( + request, "timings.html", { "request": request, @@ -487,6 +488,7 @@ def update_timing(soa_id: int, timing_id: int, payload: TimingUpdate): "window_label": r[11], "window_upper": r[12], "window_lower": r[13], + "order_index": r[14], "member_of_timeline": r[15], } mutable = [ diff --git a/src/soa_builder/web/templates/schedule_timelines.html b/src/soa_builder/web/templates/schedule_timelines.html index e2897e6..ce06235 100644 --- a/src/soa_builder/web/templates/schedule_timelines.html +++ b/src/soa_builder/web/templates/schedule_timelines.html @@ -95,7 +95,7 @@

Schedule Timelines for SoA {{ soa_id }}

{% else %} - No instances yet. + No instances yet. {% endfor %}