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..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() @@ -216,7 +217,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 +391,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 +443,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..47d2e9c 100644 --- a/src/soa_builder/web/routers/schedule_timelines.py +++ b/src/soa_builder/web/routers/schedule_timelines.py @@ -9,7 +9,17 @@ 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, + redirect_url_from_referer as _redirect_url, +) +from .instances import list_instances +from .timings import list_timings router = APIRouter() @@ -121,6 +131,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 +320,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 +479,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 +544,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..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() @@ -332,7 +333,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 +671,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 +724,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..0e3ece4 --- /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 schedule timelines 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..2e51fe9 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=study_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 %} 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') 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