diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index b4db60b..19b30bd 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -62,6 +62,7 @@ _migrate_add_epoch_type, _migrate_visit_columns, _migrate_timing_add_member_of_timeline, + _migrate_instances_add_member_of_timeline, ) from .routers import activities as activities_router from .routers import arms as arms_router @@ -161,6 +162,7 @@ def _configure_logging(): # Database migration steps +_migrate_instances_add_member_of_timeline() _migrate_timing_add_member_of_timeline() _migrate_visit_columns() _migrate_add_epoch_type() diff --git a/src/soa_builder/web/migrate_database.py b/src/soa_builder/web/migrate_database.py index fb10b8f..14a2db2 100644 --- a/src/soa_builder/web/migrate_database.py +++ b/src/soa_builder/web/migrate_database.py @@ -941,3 +941,25 @@ def _migrate_timing_add_member_of_timeline(): conn.close() except Exception as e: # pragma: no cover logger.warning("timing member_of_timeline migration failed: %s", e) + + +def _migrate_instances_add_member_of_timeline(): + """Add optional member_of_timeline""" + try: + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='instances'" + ) + if not cur.fetchone(): + conn.close() + return + cur.execute("PRAGMA table_info(instances)") + cols = {r[1] for r in cur.fetchall()} + if "member_of_timeline" not in cols: + cur.execute("ALTER TABLE instances ADD COLUMN member_of_timeline TEXT") + conn.commit() + logger.info("Added member_of_timeline column to instances table") + conn.close() + except Exception as e: + logger.warning("instances 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 3bb9562..c699ecc 100644 --- a/src/soa_builder/web/routers/instances.py +++ b/src/soa_builder/web/routers/instances.py @@ -9,7 +9,7 @@ from ..audit import _record_instance_audit from ..db import _connect from ..schemas import InstanceCreate, InstanceUpdate -from ..utils import soa_exists, get_encounter_id, get_epoch_uid +from ..utils import soa_exists, get_encounter_id, get_epoch_uid, get_schedule_timeline router = APIRouter() logger = logging.getLogger("soa_builder.web.routers.instances") @@ -33,7 +33,7 @@ def list_instances(soa_id: int): cur = conn.cursor() cur.execute( "SELECT id,instance_uid,name,label,description,default_condition_uid,epoch_uid,timeline_id," - "timeline_exit_id,order_index,encounter_uid FROM instances WHERE soa_id=? ORDER BY order_index,id", + "timeline_exit_id,order_index,encounter_uid,member_of_timeline FROM instances WHERE soa_id=? ORDER BY order_index,id", (soa_id,), ) rows = [ @@ -49,6 +49,7 @@ def list_instances(soa_id: int): "timeline_exit_id": r[8], "order_index": r[9], "encounter_uid": r[10], + "member_of_timeline": r[11], } for r in cur.fetchall() ] @@ -65,6 +66,8 @@ def ui_list_instances(request: Request, soa_id: int): instances = list_instances(soa_id) encounter_options = get_encounter_id(soa_id) epoch_options = get_epoch_uid(soa_id) + schedule_timelines_options = get_schedule_timeline(soa_id) + return templates.TemplateResponse( "instances.html", { @@ -73,6 +76,7 @@ def ui_list_instances(request: Request, soa_id: int): "instances": instances, "encounter_options": encounter_options, "epoch_options": epoch_options, + "schedule_timelines_options": schedule_timelines_options, }, ) @@ -120,7 +124,7 @@ def create_instance(soa_id: int, payload: InstanceCreate): new_uid = f"ScheduledActivityInstance_{next_n}" cur.execute( "INSERT INTO instances (soa_id,instance_uid,name,label,description,default_condition_uid,epoch_uid," - "timeline_id,timeline_exit_id,order_index,encounter_uid) VALUES (?,?,?,?,?,?,?,?,?,?,?)", + "timeline_id,timeline_exit_id,order_index,encounter_uid,member_of_timeline) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", ( soa_id, new_uid, @@ -133,6 +137,7 @@ def create_instance(soa_id: int, payload: InstanceCreate): _nz(payload.timeline_exit_id), next_ord, _nz(payload.encounter_uid), + _nz(payload.member_of_timeline), ), ) instance_id = cur.lastrowid @@ -144,6 +149,12 @@ def create_instance(soa_id: int, payload: InstanceCreate): "name": name, "label": (payload.label or "").strip() or None, "description": (payload.description or "").strip() or None, + "default_condition_uid": (payload.default_condition_uid or "").strip() or None, + "epoch_uid": (payload.epoch_uid or "").strip() or None, + "timeline_id": (payload.timeline_id or "").strip() or None, + "timeline_exit_id": (payload.timeline_exit_id or "").strip() or None, + "encounter_uid": (payload.encounter_uid or "").strip() or None, + "member_of_timeline": (payload.member_of_timeline or "").strip() or None, } _record_instance_audit(soa_id, "create", instance_id, before=None, after=after) @@ -163,6 +174,7 @@ def ui_create_instance( timeline_id: Optional[str] = Form(None), timeline_exit_id: Optional[str] = Form(None), encounter_uid: Optional[str] = Form(None), + member_of_timeline: Optional[str] = Form(None), ): payload = InstanceCreate( name=name, @@ -173,6 +185,7 @@ def ui_create_instance( timeline_id=timeline_id, timeline_exit_id=timeline_exit_id, encounter_uid=encounter_uid, + member_of_timeline=member_of_timeline, ) create_instance(soa_id, payload) return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/instances", status_code=303) @@ -192,7 +205,7 @@ def update_instance(soa_id: int, instance_id: int, payload: InstanceUpdate): cur = conn.cursor() cur.execute( "SELECT id,instance_uid,name,label,description,default_condition_uid, epoch_uid," - "timeline_id,timeline_exit_id,order_index,encounter_uid from instances WHERE soa_id=? and id=?", + "timeline_id,timeline_exit_id,order_index,encounter_uid,member_of_timeline from instances WHERE soa_id=? and id=?", ( soa_id, instance_id, @@ -215,6 +228,7 @@ def update_instance(soa_id: int, instance_id: int, payload: InstanceUpdate): "timeline_exit_id": row[8], "order_index": row[9], "encounter_uid": row[10], + "member_of_timeline": row[11], } 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"] @@ -246,10 +260,15 @@ def update_instance(soa_id: int, instance_id: int, payload: InstanceUpdate): if payload.encounter_uid is not None else before["encounter_uid"] ) + new_member_of_timeline = ( + payload.member_of_timeline + if payload.member_of_timeline is not None + else before["member_of_timeline"] + ) cur.execute( "UPDATE instances SET name=?, label=?, description=?, default_condition_uid=?, epoch_uid=?, " - "timeline_id=?, timeline_exit_id=?, encounter_uid=? WHERE id=? and soa_id=?", + "timeline_id=?, timeline_exit_id=?, encounter_uid=?, member_of_timeline=? WHERE id=? and soa_id=?", ( _nz(new_name), _nz(new_label), @@ -259,6 +278,7 @@ def update_instance(soa_id: int, instance_id: int, payload: InstanceUpdate): _nz(new_timeline_id), _nz(new_timeline_exit_id), _nz(new_encounter_uid), + _nz(new_member_of_timeline), instance_id, soa_id, ), @@ -266,7 +286,7 @@ def update_instance(soa_id: int, instance_id: int, payload: InstanceUpdate): conn.commit() cur.execute( "SELECT id,instance_uid,name,label,description,default_condition_uid,epoch_uid,timeline_id," - "timeline_exit_id,order_index,encounter_uid FROM instances WHERE soa_id=? and id=?", + "timeline_exit_id,order_index,encounter_uid,member_of_timeline FROM instances WHERE soa_id=? and id=?", ( soa_id, instance_id, @@ -286,6 +306,7 @@ def update_instance(soa_id: int, instance_id: int, payload: InstanceUpdate): "timeline_exit_id": r[8], "order_index": r[9], "encounter_uid": r[10], + "member_of_timeline": r[11], } mutable = [ "name", @@ -296,6 +317,7 @@ def update_instance(soa_id: int, instance_id: int, payload: InstanceUpdate): "timeline_id", "timeline_exit_id", "encounter_uid", + "member_of_timeline", ] update_fields = [ f for f in mutable if (before.get(f) or None) != (after.get(f) or None) @@ -324,6 +346,7 @@ def ui_update_instance( timeline_id: Optional[str] = Form(None), timeline_exit_id: Optional[str] = Form(None), encounter_uid: Optional[str] = Form(None), + member_of_timeline: Optional[str] = Form(None), ): payload = InstanceUpdate( name=name, @@ -334,6 +357,7 @@ def ui_update_instance( timeline_id=timeline_id, timeline_exit_id=timeline_exit_id, encounter_uid=encounter_uid, + member_of_timeline=member_of_timeline, ) update_instance(soa_id, instance_id, payload) return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/instances", status_code=303) diff --git a/src/soa_builder/web/schemas.py b/src/soa_builder/web/schemas.py index e82ab5f..041e366 100644 --- a/src/soa_builder/web/schemas.py +++ b/src/soa_builder/web/schemas.py @@ -12,6 +12,7 @@ class InstanceUpdate(BaseModel): timeline_id: Optional[str] = None timeline_exit_id: Optional[str] = None encounter_uid: Optional[str] = None + member_of_timeline: Optional[str] = None class InstanceCreate(BaseModel): @@ -23,6 +24,7 @@ class InstanceCreate(BaseModel): timeline_id: Optional[str] = None timeline_exit_id: Optional[str] = None encounter_uid: Optional[str] = None + member_of_timeline: Optional[str] = None class TimingCreate(BaseModel): diff --git a/src/soa_builder/web/templates/instances.html b/src/soa_builder/web/templates/instances.html index 2033a64..43293c8 100644 --- a/src/soa_builder/web/templates/instances.html +++ b/src/soa_builder/web/templates/instances.html @@ -6,23 +6,23 @@

Scheduled Activity Instances for SoA {{ soa_id }}

- +
- +
- +
- +
- +
- +
- +
- +
+
+ + +
@@ -62,6 +71,7 @@

Scheduled Activity Instances for SoA {{ soa_id }}

Timeline ID Timeline Exit ID Encounter + Member of Timeline Save Delete Instance @@ -91,6 +101,14 @@

Scheduled Activity Instances for SoA {{ soa_id }}

{% endfor %} + + + diff --git a/src/usdm/generate_schedule_timelines.py b/src/usdm/generate_schedule_timelines.py new file mode 100644 index 0000000..7f0c317 --- /dev/null +++ b/src/usdm/generate_schedule_timelines.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +# Prefer absolute import; fallback to adding src/ to sys.path when run directly +from typing import Optional, List, Dict, Any, Callable, Set +import logging + +try: + from soa_builder.web.app import _connect # reuse existing DB connector +except ImportError: + import sys + from pathlib import Path + + here = Path(__file__).resolve() + src_dir = here.parents[2] / "src" + if src_dir.exists() and str(src_dir) not in sys.path: + sys.path.insert(0, str(src_dir)) + from soa_builder.web.app import _connect # type: ignore + + +def _timing_uids_for_timeline(soa_id: int, schedule_timeline_uid: str) -> Set[str]: + conn = _connect() + cur = conn.cursor() + cur.execute( + """ + SELECT t.timing_uid FROM schedule_timelines s INNER JOIN timing t + ON s.schedule_timeline_uid = t.member_of_timeline AND s.soa_id = t.soa_id + WHERE s.soa_id=? AND s.schedule_timeline_uid=? ORDER BY t.timing_uid + """, + ( + soa_id, + schedule_timeline_uid, + ), + ) + rows = cur.fetchall() + conn.close() + return {r[0] for r in rows if r and r[0]} + + +def _load_generate_study_timings(): + """Return the timing builder from usdm.generate_study_timings (tries several names).""" + try: + import usdm.generate_study_timings as gst + except Exception: + import sys + from pathlib import Path + + here = Path(__file__).resolve() + src_dir = here.parents[2] / "src" + if src_dir.exists() and str(src_dir) not in sys.path: + sys.path.insert(0, str(src_dir)) + import usdm.generate_study_timings as gst + for name in ( + "build_usdm_study_timings", + "build_usdm_timings", + "generate_study_timings", + ): + fn = getattr(gst, name, None) + if callable(fn): + return fn + raise ImportError("usdm.generate_study_timings missing expected builder function") + + +def _load_generate_study_instances(): + """Return the instances builder from usdm.generate_scheduled_activity_instances.""" + try: + import usdm.generate_scheduled_activity_instances as gsai + except Exception: + import sys + from pathlib import Path + + here = Path(__file__).resolve() + src_dir = here.parents[2] / "src" + if src_dir.exists() and str(src_dir) not in sys.path: + sys.path.insert(0, str(src_dir)) + import usdm.generate_scheduled_activity_instances as gsai + for name in ( + "build_usdm_instances", + "generate_scheduled_activity_instances", + ): + fn = getattr(gsai, name, None) + if callable(fn): + return fn + raise ImportError( + "usdm.generate_scheduled_activity_instances missing expected builder function" + ) + + +def _nz(s: Optional[str]) -> Optional[str]: + s = (s or "").strip() + return s or None + + +generate_study_timings = _load_generate_study_timings() +generate_study_instances = _load_generate_study_instances() + + +def build_usdm_schedule_timelines(soa_id: int) -> List[Dict[str, Any]]: + """ + Build USDM scheduleTimelines + + :param soa_id: soa identifier + :type soa_id: int + :return: USDM JSON for scheduleTimelines entity + :rtype: List[Dict[str, Any]] + + + Output: + - id: string + - extensionAttributes?: string[] + - name: string + - label?: string + - description?: string + - mainTimeline?: bool + - entryCondition?: string + - entryId?: string + - exits?: dict[ + { + - id: string + - extensionAttributes?: string[] + - instanceType: "ScheduleTimelineExit" + }, + ] + - timings?: [ + { + - id: string + - extensionAttributes?: string[] + - name: string + - label?: string + - description?: string + - type?: { + - id: string + - extensionAttributes: string[] + - code: string + - codeSystem: string + - codeSystemVersion: string + - decode: string + - instanceType: "Code" + } + - value?: string + - valueLabel?: string + - relativeToFrom?: { + - id: string + - extensionAttributes: string[] + - code: string + - codeSystem: string + - codeSystemVersion: string + - decode: string + - instanceType: "Code" + } + - relativeFromScheduledInstanceId?: string + - relativeToScheduledInstanceId?: string + - windowLower?: string + - windowUpper?: string + - windowLabel?: string + - instanceType: "Timing" + }, + ] + - instances?: [ + { + - id: string + - extensionAttributes: string[]|[] + - name: string + - label?: string + - description?: string + - defaultConditionId?: string + - epochId?: string + - instanceType: "ScheduledActivityInstance" + - timelineId?: string + - timelineExitId?: string + - activityIds?: string[] + - encounterId?: string + } + ] + - plannedDuration?: string + - instanceType: "ScheduleTimeline" + """ + conn = _connect() + cur = conn.cursor() + cur.execute( + """ + SELECT schedule_timeline_uid,name,label,description,main_timeline,entry_condition, + entry_id,exit_id FROM schedule_timelines WHERE soa_id=? ORDER BY length(schedule_timeline_uid), + schedule_timeline_uid + """, + (soa_id,), + ) + rows = cur.fetchall() + conn.close() + out: List[Dict[str, Any]] = [] + + for i, r in enumerate(rows): + ( + schedule_timeline_uid, + name, + label, + description, + mainTimeline, + entryCondition, + entryId, + exitId, + ) = ( + r[0], + r[1], + r[2], + r[3], + r[4], + r[5], + r[6], + r[7], + ) + + scheduleTimeline = { + "id": schedule_timeline_uid, + "extensionAttributes": [], + "name": name, + "label": _nz(label), + "description": _nz(description), + "mainTimeline": bool(mainTimeline), + "entryCondition": _nz(entryCondition), + "entryId": _nz(entryId), + "exits": [], + "timings": generate_study_timings(soa_id, schedule_timeline_uid), + "instances": generate_study_instances(soa_id, schedule_timeline_uid), + "plannedDuration": None, + "instanceType": "ScheduleTimeline", + } + out.append(scheduleTimeline) + return out + + +if __name__ == "__main__": + import argparse + import json + import logging + import sys + + logger = logging.getLogger("usdm.generate_schedule_timelines") + + parser = argparse.ArgumentParser( + description="Export USDM Schedule Timelines for an SOA." + ) + parser.add_argument( + "soa_id", type=int, help="SOA id to export Schedule Timelines for" + ) + parser.add_argument( + "-o", "--output", default="-", help="Output file path or '-' for stdout" + ) + parser.add_argument("--indent", type=int, default=2, help="JSON indent") + args = parser.parse_args() + + try: + activities = build_usdm_schedule_timelines(args.soa_id) + except Exception: + logger.exception( + "Failed to build Schedule Timelines for soa_id=%s", args.soa_id + ) + sys.exit(1) + + payload = json.dumps(activities, indent=args.indent) + if args.output in ("-", "/dev/stdout"): + sys.stdout.write(payload + "\n") + else: + with open(args.output, "w", encoding="utf-8") as f: + f.write(payload + "\n") diff --git a/src/usdm/generate_scheduled_activity_instances.py b/src/usdm/generate_scheduled_activity_instances.py index 96b70dc..971181e 100644 --- a/src/usdm/generate_scheduled_activity_instances.py +++ b/src/usdm/generate_scheduled_activity_instances.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # Prefer absolute import; fallback to adding src/ to sys.path when run directly -from typing import Optional, List, Dict, Any, Tuple +from typing import Optional, List, Dict, Any try: from soa_builder.web.app import _connect # reuse existing DB connector @@ -40,7 +40,9 @@ def _get_activity_ids(soa_id: int, encounter_uid: str) -> List[str]: return activity_uids -def build_usdm_instances(soa_id: int) -> List[Dict[str, Any]]: +def build_usdm_instances( + soa_id: int, member_of_timeline: Optional[str] = None +) -> List[Dict[str, Any]]: """ Build USDM instances objects for the given SOA @@ -60,11 +62,24 @@ def build_usdm_instances(soa_id: int) -> List[Dict[str, Any]]: """ conn = _connect() cur = conn.cursor() - cur.execute( - "SELECT id,instance_uid,name,label,description,default_condition_uid,epoch_uid," - "timeline_id,timeline_exit_id,encounter_uid FROM instances where soa_id=? ORDER BY instance_uid", - (soa_id,), - ) + if member_of_timeline and member_of_timeline.strip(): + cur.execute( + """ + SELECT id,instance_uid,name,label,description,default_condition_uid,epoch_uid, + timeline_id,timeline_exit_id,encounter_uid FROM instances where soa_id=? AND member_of_timeline=? + ORDER BY length(instance_uid), instance_uid + """, + (soa_id, member_of_timeline.strip()), + ) + else: + cur.execute( + """ + SELECT id,instance_uid,name,label,description,default_condition_uid,epoch_uid, + timeline_id,timeline_exit_id,encounter_uid FROM instances where soa_id=? + ORDER BY length(instance_uid), instance_uid + """, + (soa_id,), + ) rows = cur.fetchall() conn.close() out: List[Dict[str, Any]] = [] @@ -131,6 +146,12 @@ def build_usdm_instances(soa_id: int) -> List[Dict[str, Any]]: "-o", "--output", default="-", help="Output file path or '-' for stdout" ) parser.add_argument("--indent", type=int, default=2, help="JSON indent") + parser.add_argument( + "--member-of-timeline", + dest="member_of_timeline", + default=None, + help="Optional schedule_timeline_uid to filter instances", + ) args = parser.parse_args() try: diff --git a/src/usdm/generate_study_timings.py b/src/usdm/generate_study_timings.py index 2905333..5896177 100644 --- a/src/usdm/generate_study_timings.py +++ b/src/usdm/generate_study_timings.py @@ -42,7 +42,9 @@ def _get_timing_code_values(soa_id: int, code_uid: str) -> Tuple[str, str, str, return code_code, code_decode, code_system, code_system_version -def build_usdm_timings(soa_id: int) -> List[Dict[str, Any]]: +def build_usdm_timings( + soa_id: int, member_of_timeline: Optional[str] = None +) -> List[Dict[str, Any]]: """ Build USDM Timings-Output objects for the given SOA. @@ -81,12 +83,25 @@ def build_usdm_timings(soa_id: int) -> List[Dict[str, Any]]: """ 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 timing_uid", - (soa_id,), - ) + if member_of_timeline and member_of_timeline.strip(): + 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 member_of_timeline=? order by length(timing_uid), + timing_uid + """, + (soa_id, member_of_timeline.strip()), + ) + else: + 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 length(timing_uid), timing_uid + """, + (soa_id,), + ) rows = cur.fetchall() conn.close() out: List[Dict[str, Any]] = [] @@ -184,10 +199,18 @@ def build_usdm_timings(soa_id: int) -> List[Dict[str, Any]]: "-o", "--output", default="-", help="Output file path or '-' for stdout" ) parser.add_argument("--indent", type=int, default=2, help="JSON indent") + parser.add_argument( + "--member-of-timeline", + dest="member_of_timeline", + default=None, + help="Optional schedule_timeline_uid to filter timings", + ) args = parser.parse_args() try: - activities = build_usdm_timings(args.soa_id) + activities = build_usdm_timings( + args.soa_id, member_of_timeline=args.member_of_timeline + ) except Exception: logger.exception("Failed to build timings for soa_id=%s", args.soa_id) sys.exit(1)