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 }}
+
+
+
+
+
+
+ | UID |
+ Name |
+ Label |
+ Description |
+ Main Timeline |
+ Entry Condition |
+ Entry ID |
+ Exit ID |
+ Save |
+ Delete |
+
+ {% for st in schedule_timelines %}
+
+
+ |
+
+ |
+
+ {% else %}
+
+ | No instances yet. |
+
+ {% endfor %}
+
+
+
+{% 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 }}
-
+
@@ -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 }}
|
-
|
-
|
+
+
+ |
+
+
+ |
|
|
|
@@ -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 %}