diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index 92ea8d9..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 @@ -72,6 +73,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 @@ -159,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() @@ -193,6 +196,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..ab36d21 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 recording 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/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/instances.py b/src/soa_builder/web/routers/instances.py index 8bc6b51..3bb9562 100644 --- a/src/soa_builder/web/routers/instances.py +++ b/src/soa_builder/web/routers/instances.py @@ -138,7 +138,7 @@ def create_instance(soa_id: int, payload: InstanceCreate): instance_id = cur.lastrowid conn.commit() conn.close() - row = { + after = { "id": instance_id, "instance_uid": new_uid, "name": name, @@ -146,8 +146,8 @@ def create_instance(soa_id: int, payload: InstanceCreate): "description": (payload.description or "").strip() or None, } - _record_instance_audit(soa_id, "create", instance_id, before=None, after=row) - return row + _record_instance_audit(soa_id, "create", instance_id, before=None, after=after) + return after # UI code to create new instance in an SOA 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..5551aad --- /dev/null +++ b/src/soa_builder/web/routers/schedule_timelines.py @@ -0,0 +1,436 @@ +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, get_scheduled_activity_instance + + +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 + + +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_timeline=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): + 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": bool(r[5]) if r[5] is not None else False, + "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) + instance_options = get_scheduled_activity_instance(soa_id) + + return templates.TemplateResponse( + request, + "schedule_timelines.html", + { + "request": request, + "soa_id": soa_id, + "schedule_timelines": schedule_timelines, + "instance_options": instance_options, + }, + ) + + +# 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") + + if payload.main_timeline: + _assert_main_unique(soa_id) + + 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 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"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), + 1 if payload.main_timeline else 0, + _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": 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, + } + _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[str] = Form(None), + entry_condition: Optional[str] = Form(None), + 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=_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/{int(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": bool(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"] + ) + + # 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 + else before["entry_condition"] + ) + if payload.entry_id is not None: + new_entry_id = _nz(payload.entry_id) + else: + new_entry_id = 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), + 1 if new_main_timeline else 0, + _nz(new_entry_condition), + new_entry_id, + 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": bool(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[str] = 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=_to_bool(main_timeline), + entry_condition=entry_condition, + entry_id=_nz(entry_id), + exit_id=_nz(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={int(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": bool(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/routers/timings.py b/src/soa_builder/web/routers/timings.py index 582bbbb..bfa2e9b 100644 --- a/src/soa_builder/web/routers/timings.py +++ b/src/soa_builder/web/routers/timings.py @@ -10,9 +10,13 @@ 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_schedule_timeline, + get_study_timing_type, + get_next_code_uid as _get_next_code_uid, +) router = APIRouter() logger = logging.getLogger("soa_builder.web.routers.timings") @@ -26,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): @@ -64,6 +108,10 @@ 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) + schedule_timelines_options = get_schedule_timeline(soa_id) + return templates.TemplateResponse( request, "timings.html", @@ -73,10 +121,106 @@ 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, + "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( @@ -94,6 +238,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 @@ -158,48 +303,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) @@ -241,84 +348,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() - row = { - "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, - } - _record_timing_audit(soa_id, "create", timing_id, before=None, after=row) - return row - - # API endpoint to update a timing in an SOA @router.patch( "/soa/{soa_id}/timings/{timing_id}", @@ -334,7 +363,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, @@ -361,6 +390,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"] @@ -406,11 +436,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), @@ -424,6 +459,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, ), @@ -432,7 +468,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() @@ -453,6 +489,7 @@ def update_timing(soa_id: int, timing_id: int, payload: TimingUpdate): "window_upper": r[12], "window_lower": r[13], "order_index": r[14], + "member_of_timeline": r[15], } mutable = [ "name", @@ -467,8 +504,9 @@ def update_timing(soa_id: int, timing_id: int, payload: TimingUpdate): "window_label", "window_upper", "window_lower", + "member_of_timeline", ] - 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 +514,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 @@ -499,6 +537,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 @@ -596,9 +635,10 @@ 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/{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 +663,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/schemas.py b/src/soa_builder/web/schemas.py index 6c48726..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,27 @@ 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): + 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): diff --git a/src/soa_builder/web/templates/base.html b/src/soa_builder/web/templates/base.html index d0ccab6..e811813 100644 --- a/src/soa_builder/web/templates/base.html +++ b/src/soa_builder/web/templates/base.html @@ -12,8 +12,9 @@