From f5218ef5d992abc9d1a312b2c8283d9e3df08265 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Mon, 23 Feb 2026 10:18:19 -0500 Subject: [PATCH 1/7] Consolidated study timing pages into one to support easier workflow --- src/soa_builder/web/app.py | 15 ++ src/soa_builder/web/routers/instances.py | 25 +- .../web/routers/schedule_timelines.py | 114 ++++++++- src/soa_builder/web/routers/timings.py | 25 +- .../web/templates/_instances_section.html | 219 +++++++++++++++++ .../_schedule_timelines_section.html | 107 +++++++++ .../web/templates/_timings_section.html | 208 ++++++++++++++++ src/soa_builder/web/templates/base.html | 4 +- src/soa_builder/web/templates/edit.html | 10 +- src/soa_builder/web/templates/instances.html | 222 +----------------- .../web/templates/schedule_timelines.html | 110 +-------- .../web/templates/study_timing.html | 48 ++++ src/soa_builder/web/templates/timings.html | 207 +--------------- 13 files changed, 766 insertions(+), 548 deletions(-) create mode 100644 src/soa_builder/web/templates/_instances_section.html create mode 100644 src/soa_builder/web/templates/_schedule_timelines_section.html create mode 100644 src/soa_builder/web/templates/_timings_section.html create mode 100644 src/soa_builder/web/templates/study_timing.html diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index 57eb17d..dc3685f 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -96,6 +96,10 @@ load_epoch_type_map, table_has_columns as _table_has_columns, iso_duration_to_days, + get_encounter_id, + get_epoch_uid, + get_schedule_timeline, + get_scheduled_activity_instance, ) # Audit functions @@ -3855,6 +3859,12 @@ def ui_edit(request: Request, soa_id: int): if not default_timeline and "unassigned" in instances_by_timeline: default_timeline = "unassigned" + instances_crud = instances_router.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) + instance_options = get_scheduled_activity_instance(soa_id) + return templates.TemplateResponse( request, "edit.html", @@ -3862,6 +3872,11 @@ def ui_edit(request: Request, soa_id: int): "soa_id": soa_id, "epochs": epochs, "instances": instances, + "instances_crud": instances_crud, + "encounter_options": encounter_options, + "epoch_options": epoch_options, + "schedule_timelines_options": schedule_timelines_options, + "instance_options": instance_options, "activities": activities_page, "elements": elements, "arms": arms_enriched, diff --git a/src/soa_builder/web/routers/instances.py b/src/soa_builder/web/routers/instances.py index 97742b4..e62a3e8 100644 --- a/src/soa_builder/web/routers/instances.py +++ b/src/soa_builder/web/routers/instances.py @@ -29,6 +29,19 @@ def _nz(s: Optional[str]) -> Optional[str]: return s or None +def _redirect_url(request: Request, fallback: str) -> str: + """Return the Referer URL if it's a same-origin /ui/ path, else fallback.""" + referer = request.headers.get("referer", "") + if referer: + from urllib.parse import urlparse + + parsed = urlparse(referer) + base = urlparse(str(request.base_url)) + if parsed.netloc == base.netloc and parsed.path.startswith("/ui/"): + return parsed.path + return fallback + + # API endpoint to list timeline instances for SOA @router.get("/soa/{soa_id}/instances", response_class=JSONResponse, response_model=None) def list_instances(soa_id: int): @@ -216,7 +229,9 @@ def ui_create_instance( member_of_timeline=member_of_timeline, ) create_instance(soa_id, payload) - return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/instances", status_code=303) + return RedirectResponse( + url=_redirect_url(request, f"/ui/soa/{int(soa_id)}/instances"), status_code=303 + ) # API endpoint to update a timeline instance in an SOA @@ -388,7 +403,9 @@ def ui_update_instance( 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) + return RedirectResponse( + url=_redirect_url(request, f"/ui/soa/{int(soa_id)}/instances"), status_code=303 + ) # API endpoint to delete a timeline instance @@ -438,7 +455,9 @@ def delete_instance(soa_id: int, instance_id: int): @router.post("/ui/soa/{soa_id}/instances/{instance_id}/delete") def ui_del_instance(request: Request, soa_id: int, instance_id: int): delete_instance(soa_id, instance_id) - return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/instances", status_code=303) + return RedirectResponse( + url=_redirect_url(request, f"/ui/soa/{int(soa_id)}/instances"), status_code=303 + ) # API endpoint to reorder instances diff --git a/src/soa_builder/web/routers/schedule_timelines.py b/src/soa_builder/web/routers/schedule_timelines.py index d46830a..0c6ae26 100644 --- a/src/soa_builder/web/routers/schedule_timelines.py +++ b/src/soa_builder/web/routers/schedule_timelines.py @@ -9,7 +9,16 @@ 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 +from ..utils import ( + soa_exists, + get_scheduled_activity_instance, + get_schedule_timeline, + get_encounter_id, + get_epoch_uid, + get_study_timing_type, +) +from .instances import list_instances +from .timings import list_timings router = APIRouter() @@ -24,6 +33,19 @@ def _nz(s: Optional[str]) -> Optional[str]: return s or None +def _redirect_url(request: Request, fallback: str) -> str: + """Return the Referer URL if it's a same-origin /ui/ path, else fallback.""" + referer = request.headers.get("referer", "") + if referer: + from urllib.parse import urlparse + + parsed = urlparse(referer) + base = urlparse(str(request.base_url)) + if parsed.netloc == base.netloc and parsed.path.startswith("/ui/"): + return parsed.path + return fallback + + def _to_bool(v: Optional[str]) -> bool: if v is None: return False @@ -121,6 +143,87 @@ def ui_list_schedule_timelines(request: Request, soa_id: int): ) +# UI combined study timing page (schedule timelines + instances + timings) +@router.get("/ui/soa/{soa_id}/study_timing", response_class=HTMLResponse) +def ui_study_timing(request: Request, soa_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + # Schedule timelines data + schedule_timelines = list_schedule_timelines(soa_id) + instance_options = get_scheduled_activity_instance(soa_id) + + # Instances data + 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) + + # Timings data (with code_uid -> submission_value decoding) + timings = list_timings(soa_id) + try: + sv_to_code = get_study_timing_type("C201264") + except Exception: + sv_to_code = {} + try: + sv_to_code_rtf = get_study_timing_type("C201265") + except Exception: + sv_to_code_rtf = {} + code_to_sv = {v: k for k, v in (sv_to_code or {}).items()} + code_to_sv_rtf = {v: k for k, v in (sv_to_code_rtf or {}).items()} + conn = _connect() + cur = conn.cursor() + cur.execute("SELECT code_uid, code FROM code WHERE soa_id=?", (soa_id,)) + code_uid_to_code = {r[0]: r[1] for r in cur.fetchall() if r[0]} + cur.execute( + "SELECT study_id, study_label, study_description, name, created_at FROM soa WHERE id=?", + (soa_id,), + ) + meta_row = cur.fetchone() + conn.close() + for t in timings: + sv = None + cu = t.get("type") + if cu and cu in code_uid_to_code: + sv = code_to_sv.get(str(code_uid_to_code.get(cu))) + t["type_submission_value"] = sv + rtf_sv = None + rtf_cu = t.get("relative_to_from") + if rtf_cu and rtf_cu in code_uid_to_code: + rtf_sv = code_to_sv_rtf.get(str(code_uid_to_code.get(rtf_cu))) + t["relative_to_from_submission_value"] = rtf_sv + timing_type_options = sorted(sv_to_code.keys()) if sv_to_code else [] + relative_to_from_options = sorted(sv_to_code_rtf.keys()) if sv_to_code_rtf else [] + + study_id, study_label, study_description, study_name, study_created_at = meta_row + study_meta = { + "study_id": study_id, + "study_label": study_label, + "study_description": study_description, + "study_name": study_name, + "study_created_at": study_created_at, + } + + return templates.TemplateResponse( + request, + "study_timing.html", + { + "request": request, + "soa_id": soa_id, + "schedule_timelines": schedule_timelines, + "instances": instances, + "timings": timings, + "instance_options": instance_options, + "encounter_options": encounter_options, + "epoch_options": epoch_options, + "schedule_timelines_options": schedule_timelines_options, + "timing_type_options": timing_type_options, + "relative_to_from_options": relative_to_from_options, + **study_meta, + }, + ) + + # API endpoint for creating a schedule timeline in an SOA @router.post( "/soa/{soa_id}/schedule_timelines", @@ -229,7 +332,8 @@ def ui_create_schedule_timeline( ) create_schedule_timeline(soa_id, payload) return RedirectResponse( - url=f"/ui/soa/{int(soa_id)}/schedule_timelines", status_code=303 + url=_redirect_url(request, f"/ui/soa/{int(soa_id)}/schedule_timelines"), + status_code=303, ) @@ -387,7 +491,8 @@ def ui_update_schedule_timeline( ) update_schedule_timeline(soa_id, schedule_timeline_id, payload) return RedirectResponse( - url=f"/ui/soa/{int(soa_id)}/schedule_timelines", status_code=303 + url=_redirect_url(request, f"/ui/soa/{int(soa_id)}/schedule_timelines"), + status_code=303, ) @@ -451,5 +556,6 @@ def ui_delete_schedule_timeline( ): delete_schedule_timeline(soa_id, schedule_timeline_id) return RedirectResponse( - url=f"/ui/soa/{int(soa_id)}/schedule_timelines", status_code=303 + url=_redirect_url(request, 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 f2474fc..6c5c820 100644 --- a/src/soa_builder/web/routers/timings.py +++ b/src/soa_builder/web/routers/timings.py @@ -32,6 +32,19 @@ def _nz(s: Optional[str]) -> Optional[str]: return s or None +def _redirect_url(request: Request, fallback: str) -> str: + """Return the Referer URL if it's a same-origin /ui/ path, else fallback.""" + referer = request.headers.get("referer", "") + if referer: + from urllib.parse import urlparse + + parsed = urlparse(referer) + base = urlparse(str(request.base_url)) + if parsed.netloc == base.netloc and parsed.path.startswith("/ui/"): + return parsed.path + return fallback + + # 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): @@ -332,7 +345,9 @@ def ui_create_timing( msgs = "; ".join(e["msg"] for e in exc.errors()) raise HTTPException(400, f"Validation error: {msgs}") create_timing(soa_id, payload) - return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/timings", status_code=303) + return RedirectResponse( + url=_redirect_url(request, f"/ui/soa/{int(soa_id)}/timings"), status_code=303 + ) @router.get("/soa/{soa_id}/timing_audit", response_class=JSONResponse) @@ -668,7 +683,9 @@ def ui_update_timing( msgs = "; ".join(e["msg"] for e in exc.errors()) raise HTTPException(400, f"Validation error: {msgs}") update_timing(soa_id, timing_id, payload) - return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/timings", status_code=303) + return RedirectResponse( + url=_redirect_url(request, f"/ui/soa/{int(soa_id)}/timings"), status_code=303 + ) # API endpoint to delete a timing @@ -719,4 +736,6 @@ def delete_timing(soa_id: int, timing_id: int): @router.post("/ui/soa/{soa_id}/timings/{timing_id}/delete") def ui_delete_timing(request: Request, soa_id: int, timing_id: int): delete_timing(soa_id, timing_id) - return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/timings", status_code=303) + return RedirectResponse( + url=_redirect_url(request, f"/ui/soa/{int(soa_id)}/timings"), status_code=303 + ) diff --git a/src/soa_builder/web/templates/_instances_section.html b/src/soa_builder/web/templates/_instances_section.html new file mode 100644 index 0000000..38d7c29 --- /dev/null +++ b/src/soa_builder/web/templates/_instances_section.html @@ -0,0 +1,219 @@ +

Scheduled Activity Instances for Study: {% if study_label %}{{ study_label }}{% else %}{{ study_name }}{% endif %}

+ +{% if not hide_return_link %} +
+ + ← Return to Edit Page + +
+{% endif %} + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + + {% for i in instances or [] %} + + + + + + + + + + + + + + + + + + + {% else %} + + {% endfor %} +
idOrderNameLabelDescriptionNext Instance (default condition)EpochTimeline IDTimeline Exit IDEncounterMember of TimelineSaveDelete + +
{{ i.instance_uid }}{{ i.order_index }} + + + + + + + + + + +
+ +
+
+ + +
No instances yet.
+ + diff --git a/src/soa_builder/web/templates/_schedule_timelines_section.html b/src/soa_builder/web/templates/_schedule_timelines_section.html new file mode 100644 index 0000000..5a9df5b --- /dev/null +++ b/src/soa_builder/web/templates/_schedule_timelines_section.html @@ -0,0 +1,107 @@ +

Schedule Timelines for Study: {% if study_label %}{{ study_label }}{% else %}{{ study_name }}{% endif %}

+ +{% if not hide_return_link %} +
+ + ← Return to Edit Page + +
+{% endif %} + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + + {% for st in schedule_timelines %} + + + + + + + + + + + + + + + {% else %} + + + + {% endfor %} +
idNameLabelDescriptionMain TimelineEntry ConditionEntry IDExit IDSaveDelete
{{ st.schedule_timeline_uid }} + + + + + + +
+ +
+
No instances yet.
diff --git a/src/soa_builder/web/templates/_timings_section.html b/src/soa_builder/web/templates/_timings_section.html new file mode 100644 index 0000000..c6d55cb --- /dev/null +++ b/src/soa_builder/web/templates/_timings_section.html @@ -0,0 +1,208 @@ +

Timings for Study: {% if study_label %}{{ study_label }}{% else %}{{ study_name }}{% endif %}

+ +{% if not hide_return_link %} +
+ + ← Return to Edit Page + +
+{% endif %} + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + {% for t in timings %} + + + + + + + + + + + + + + + + + + + + + {% else %} + + {% endfor %} +
idNameLabelDescriptionRel From InstanceValueValue LabelTypeRel To InstanceWindow LowerWindow UpperWindow LabelMember of TimelineRel To/FromSaveDelete
{{ t.timing_uid }} + + + + + + + + + + + + +
+ +
+
No timings yet.
+ + diff --git a/src/soa_builder/web/templates/base.html b/src/soa_builder/web/templates/base.html index b29210a..cd95642 100644 --- a/src/soa_builder/web/templates/base.html +++ b/src/soa_builder/web/templates/base.html @@ -11,9 +11,7 @@ Home {% if soa_id %} Activities - Schedule Timelines - Study Timing - Scheduled Activity Instances + Study Timing Epochs Arms Study Cells diff --git a/src/soa_builder/web/templates/edit.html b/src/soa_builder/web/templates/edit.html index 30bc2f0..14fb31e 100644 --- a/src/soa_builder/web/templates/edit.html +++ b/src/soa_builder/web/templates/edit.html @@ -82,7 +82,14 @@

Editing SoA for {% if study_label %}{{ study_label }}{% else %}{{ study_id }
- + + {% with instances=instances_crud, study_name=soa_name, hide_return_link=true %} +
+ Scheduled Activity Instances ({{ instances_crud|length }}) + {% include '_instances_section.html' %} +
+ {% endwith %} +
Activities ({{ activities|length }}) (drag to reorder)
    @@ -370,6 +377,7 @@

    Matrix: {% if timeline_instances and timeline_instances[0].timeline_name %}{ const saved = localStorage.getItem('soa_collapse_'+sectionId); if(saved === '0') el.open = false; } +persistSection('edit-instances-section'); persistSection('activities-section'); persistSection('study-cells-section'); persistSection('transition-rules-section'); diff --git a/src/soa_builder/web/templates/instances.html b/src/soa_builder/web/templates/instances.html index c167d46..be4d9a5 100644 --- a/src/soa_builder/web/templates/instances.html +++ b/src/soa_builder/web/templates/instances.html @@ -1,222 +1,4 @@ {% extends 'base.html' %} {% block content %} - -

    Scheduled Activity Instances for Study: {% if study_label %}{{ study_label }}{% else %}{{ study_name }}{% endif %}

    - - - -
    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    - -
    -
    - - - - - - - - - - - - - - - - - - - {% for i in instances or [] %} - - - - - - - - - - - - - - - - - - - {% else %} - - {% endfor %} -
    idOrderNameLabelDescriptionNext Instance (default condition)EpochTimeline IDTimeline Exit IDEncounterMember of TimelineSaveDelete - -
    {{ i.instance_uid }}{{ i.order_index }} - - - - - - - - - - -
    - -
    -
    - - -
    No instances yet.
    - - - -{% endblock %} \ No newline at end of file +{% include '_instances_section.html' %} +{% endblock %} diff --git a/src/soa_builder/web/templates/schedule_timelines.html b/src/soa_builder/web/templates/schedule_timelines.html index 3bc65b4..d06aabf 100644 --- a/src/soa_builder/web/templates/schedule_timelines.html +++ b/src/soa_builder/web/templates/schedule_timelines.html @@ -1,110 +1,4 @@ {% extends 'base.html' %} {% block content %} -

    Schedule Timelines for Study: {% if study_label %}{{ study_label }}{% else %}{{ study_name }}{% endif %}

    - - - -
    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    - -
    -
    - - - - - - - - - - - - - - - - {% for st in schedule_timelines %} - - - - - - - - - - - - - - - {% else %} - - - - {% endfor %} -
    idNameLabelDescriptionMain TimelineEntry ConditionEntry IDExit IDSaveDelete
    {{ st.schedule_timeline_uid }} - - - - - - -
    - -
    -
    No instances yet.
    - - -{% endblock %} \ No newline at end of file +{% include '_schedule_timelines_section.html' %} +{% endblock %} diff --git a/src/soa_builder/web/templates/study_timing.html b/src/soa_builder/web/templates/study_timing.html new file mode 100644 index 0000000..b66d156 --- /dev/null +++ b/src/soa_builder/web/templates/study_timing.html @@ -0,0 +1,48 @@ +{% extends 'base.html' %} +{% block content %} +

    Study Timing for Study: {% if study_label %}{{ study_label }}{% else %}{{ study_name }}{% endif %}

    + + + +{% set hide_return_link = true %} + +
    + Schedule Timelines + {% include '_schedule_timelines_section.html' %} +
    + +
    + Scheduled Activity Instances + {% include '_instances_section.html' %} +
    + +
    + Timings + {% include '_timings_section.html' %} +
    + + + + +{% endblock %} diff --git a/src/soa_builder/web/templates/timings.html b/src/soa_builder/web/templates/timings.html index 6e2813a..7923dcb 100644 --- a/src/soa_builder/web/templates/timings.html +++ b/src/soa_builder/web/templates/timings.html @@ -1,209 +1,4 @@ {% extends 'base.html' %} {% block content %} -

    Timings for Study: {% if study_label %}{{ study_label }}{% else %}{{ study_name }}{% endif %}

    - - - -
    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    - -
    -
    - - - - - - - - - - - - - - - - - - - - - {% for t in timings %} - - - - - - - - - - - - - - - - - - - - - {% else %} - - {% endfor %} -
    idNameLabelDescriptionRel From InstanceValueValue LabelTypeRel To InstanceWindow LowerWindow UpperWindow LabelMember of TimelineRel To/FromSaveDelete
    {{ t.timing_uid }} - - - - - - - - - - - - -
    - -
    -
    No timings yet.
    - - +{% include '_timings_section.html' %} {% endblock %} From b99a17f966e49dac6828a2c27b3376c95e3cd11d Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:43:00 -0500 Subject: [PATCH 2/7] Set the checkbox value to a truthy value --- src/soa_builder/web/templates/_schedule_timelines_section.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/soa_builder/web/templates/_schedule_timelines_section.html b/src/soa_builder/web/templates/_schedule_timelines_section.html index 5a9df5b..38458d5 100644 --- a/src/soa_builder/web/templates/_schedule_timelines_section.html +++ b/src/soa_builder/web/templates/_schedule_timelines_section.html @@ -25,7 +25,7 @@

    Schedule Timelines for Study: {% if study_label %}{{ study_label }}{% else %
    From 8bb494fc962fc47842268000bf740a4d8b464451 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:43:46 -0500 Subject: [PATCH 3/7] Update src/soa_builder/web/templates/edit.html Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/soa_builder/web/templates/edit.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/soa_builder/web/templates/edit.html b/src/soa_builder/web/templates/edit.html index 14fb31e..2e51fe9 100644 --- a/src/soa_builder/web/templates/edit.html +++ b/src/soa_builder/web/templates/edit.html @@ -83,7 +83,7 @@

    Editing SoA for {% if study_label %}{{ study_label }}{% else %}{{ study_id }
    - {% with instances=instances_crud, study_name=soa_name, hide_return_link=true %} + {% with instances=instances_crud, study_name=study_name, hide_return_link=true %}
    Scheduled Activity Instances ({{ instances_crud|length }}) {% include '_instances_section.html' %} From 7c69b65af2be50ecafe5ede0326727bc30bc7e72 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:45:17 -0500 Subject: [PATCH 4/7] test file following existing project conventions, asserting 200/404 behavior and that all three sections render --- tests/test_routers_study_timing.py | 61 ++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 tests/test_routers_study_timing.py diff --git a/tests/test_routers_study_timing.py b/tests/test_routers_study_timing.py new file mode 100644 index 0000000..c12cca1 --- /dev/null +++ b/tests/test_routers_study_timing.py @@ -0,0 +1,61 @@ +"""Tests for the combined study timing UI route.""" + +from fastapi.testclient import TestClient + +from soa_builder.web.app import app + +client = TestClient(app) + + +def test_ui_study_timing_200(): + """GET /ui/soa/{soa_id}/study_timing returns 200 HTML for a valid SoA.""" + r = client.post("/soa", json={"name": "Study Timing 200 Test"}) + soa_id = r.json()["id"] + + resp = client.get(f"/ui/soa/{soa_id}/study_timing") + assert resp.status_code == 200 + assert "text/html" in resp.headers.get("content-type", "") + + +def test_ui_study_timing_contains_all_sections(): + """Response HTML includes all three section containers.""" + r = client.post("/soa", json={"name": "Study Timing Sections Test"}) + soa_id = r.json()["id"] + + resp = client.get(f"/ui/soa/{soa_id}/study_timing") + assert resp.status_code == 200 + assert "study-timing-schedule-timelines" in resp.text + assert "study-timing-instances" in resp.text + assert "study-timing-timings" in resp.text + + +def test_ui_study_timing_404_nonexistent_soa(): + """GET /ui/soa/999999/study_timing returns 404 for nonexistent SoA.""" + resp = client.get("/ui/soa/999999/study_timing") + assert resp.status_code == 404 + + +def test_ui_study_timing_with_data(): + """Created entities appear in the rendered page.""" + r = client.post("/soa", json={"name": "Study Timing Data Test"}) + soa_id = r.json()["id"] + + # Create one of each entity via API + client.post( + f"/soa/{soa_id}/schedule_timelines", + json={"name": "My Test Timeline"}, + ) + client.post( + f"/soa/{soa_id}/instances", + json={"name": "My Test Instance"}, + ) + client.post( + f"/soa/{soa_id}/timings", + json={"name": "My Test Timing"}, + ) + + resp = client.get(f"/ui/soa/{soa_id}/study_timing") + assert resp.status_code == 200 + assert "My Test Timeline" in resp.text + assert "My Test Instance" in resp.text + assert "My Test Timing" in resp.text From 6582fa998e127d5089f21f995ed51064ab6fe835 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:46:42 -0500 Subject: [PATCH 5/7] Update src/soa_builder/web/routers/schedule_timelines.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/soa_builder/web/routers/schedule_timelines.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/soa_builder/web/routers/schedule_timelines.py b/src/soa_builder/web/routers/schedule_timelines.py index 0c6ae26..ae01124 100644 --- a/src/soa_builder/web/routers/schedule_timelines.py +++ b/src/soa_builder/web/routers/schedule_timelines.py @@ -37,12 +37,13 @@ def _redirect_url(request: Request, fallback: str) -> str: """Return the Referer URL if it's a same-origin /ui/ path, else fallback.""" referer = request.headers.get("referer", "") if referer: - from urllib.parse import urlparse + from urllib.parse import urlparse, urlunparse parsed = urlparse(referer) base = urlparse(str(request.base_url)) if parsed.netloc == base.netloc and parsed.path.startswith("/ui/"): - return parsed.path + # Preserve any query string and fragment from the original Referer + return urlunparse(("", "", parsed.path, "", parsed.query, parsed.fragment)) return fallback From 9c31f15fefaeb873f58f73725575b7694414d95d Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:15:47 -0500 Subject: [PATCH 6/7] _redirect_url logic moved to utils.py to provide single implementation --- src/soa_builder/web/routers/instances.py | 14 +------------- src/soa_builder/web/routers/schedule_timelines.py | 15 +-------------- src/soa_builder/web/routers/timings.py | 14 +------------- src/soa_builder/web/utils.py | 13 +++++++++++++ 4 files changed, 16 insertions(+), 40 deletions(-) diff --git a/src/soa_builder/web/routers/instances.py b/src/soa_builder/web/routers/instances.py index e62a3e8..1cef88b 100644 --- a/src/soa_builder/web/routers/instances.py +++ b/src/soa_builder/web/routers/instances.py @@ -15,6 +15,7 @@ get_epoch_uid, get_schedule_timeline, get_scheduled_activity_instance, + redirect_url_from_referer as _redirect_url, ) router = APIRouter() @@ -29,19 +30,6 @@ def _nz(s: Optional[str]) -> Optional[str]: return s or None -def _redirect_url(request: Request, fallback: str) -> str: - """Return the Referer URL if it's a same-origin /ui/ path, else fallback.""" - referer = request.headers.get("referer", "") - if referer: - from urllib.parse import urlparse - - parsed = urlparse(referer) - base = urlparse(str(request.base_url)) - if parsed.netloc == base.netloc and parsed.path.startswith("/ui/"): - return parsed.path - return fallback - - # API endpoint to list timeline instances for SOA @router.get("/soa/{soa_id}/instances", response_class=JSONResponse, response_model=None) def list_instances(soa_id: int): diff --git a/src/soa_builder/web/routers/schedule_timelines.py b/src/soa_builder/web/routers/schedule_timelines.py index ae01124..47d2e9c 100644 --- a/src/soa_builder/web/routers/schedule_timelines.py +++ b/src/soa_builder/web/routers/schedule_timelines.py @@ -16,6 +16,7 @@ get_encounter_id, get_epoch_uid, get_study_timing_type, + redirect_url_from_referer as _redirect_url, ) from .instances import list_instances from .timings import list_timings @@ -33,20 +34,6 @@ def _nz(s: Optional[str]) -> Optional[str]: return s or None -def _redirect_url(request: Request, fallback: str) -> str: - """Return the Referer URL if it's a same-origin /ui/ path, else fallback.""" - referer = request.headers.get("referer", "") - if referer: - from urllib.parse import urlparse, urlunparse - - parsed = urlparse(referer) - base = urlparse(str(request.base_url)) - if parsed.netloc == base.netloc and parsed.path.startswith("/ui/"): - # Preserve any query string and fragment from the original Referer - return urlunparse(("", "", parsed.path, "", parsed.query, parsed.fragment)) - return fallback - - def _to_bool(v: Optional[str]) -> bool: if v is None: return False diff --git a/src/soa_builder/web/routers/timings.py b/src/soa_builder/web/routers/timings.py index 6c5c820..0aa6040 100644 --- a/src/soa_builder/web/routers/timings.py +++ b/src/soa_builder/web/routers/timings.py @@ -18,6 +18,7 @@ get_schedule_timeline, get_study_timing_type, get_next_code_uid as _get_next_code_uid, + redirect_url_from_referer as _redirect_url, ) router = APIRouter() @@ -32,19 +33,6 @@ def _nz(s: Optional[str]) -> Optional[str]: return s or None -def _redirect_url(request: Request, fallback: str) -> str: - """Return the Referer URL if it's a same-origin /ui/ path, else fallback.""" - referer = request.headers.get("referer", "") - if referer: - from urllib.parse import urlparse - - parsed = urlparse(referer) - base = urlparse(str(request.base_url)) - if parsed.netloc == base.netloc and parsed.path.startswith("/ui/"): - return parsed.path - return fallback - - # 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): diff --git a/src/soa_builder/web/utils.py b/src/soa_builder/web/utils.py index b6fdf8b..2a60580 100644 --- a/src/soa_builder/web/utils.py +++ b/src/soa_builder/web/utils.py @@ -3,6 +3,8 @@ import re import requests import time +from urllib.parse import urlparse, urlunparse +from fastapi import Request from .db import _connect _epoch_type_cache: dict[str, Any] = { @@ -57,6 +59,17 @@ """ +def redirect_url_from_referer(request: Request, fallback: str) -> str: + """Return the Referer URL if it's a same-origin /ui/ path, else fallback.""" + referer = request.headers.get("referer", "") + if referer: + parsed = urlparse(referer) + base = urlparse(str(request.base_url)) + if parsed.netloc == base.netloc and parsed.path.startswith("/ui/"): + return urlunparse(("", "", parsed.path, "", parsed.query, parsed.fragment)) + return fallback + + def iso_duration_to_days(iso_duration: str) -> float: """ Convert an ISO-8601 duration (e.g. 'P1D', 'P2W', 'P1Y2M3D', 'P1DT12H') From ab8dde43764a999e820402c567c13f3b32447d7d Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:18:35 -0500 Subject: [PATCH 7/7] schedule timelines specified for empty list --- src/soa_builder/web/templates/_schedule_timelines_section.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/soa_builder/web/templates/_schedule_timelines_section.html b/src/soa_builder/web/templates/_schedule_timelines_section.html index 38458d5..0e3ece4 100644 --- a/src/soa_builder/web/templates/_schedule_timelines_section.html +++ b/src/soa_builder/web/templates/_schedule_timelines_section.html @@ -101,7 +101,7 @@

    Schedule Timelines for Study: {% if study_label %}{{ study_label }}{% else % {% else %} - No instances yet. + No schedule timelines yet. {% endfor %}