Skip to content
15 changes: 15 additions & 0 deletions src/soa_builder/web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -3855,13 +3859,24 @@ 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",
{
"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,
Expand Down
13 changes: 10 additions & 3 deletions src/soa_builder/web/routers/instances.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
get_epoch_uid,
get_schedule_timeline,
get_scheduled_activity_instance,
redirect_url_from_referer as _redirect_url,
)

router = APIRouter()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
102 changes: 98 additions & 4 deletions src/soa_builder/web/routers/schedule_timelines.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Comment on lines +139 to +151
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This module calls list_instances() / list_timings() imported from other router modules to build the UI page. Those are route handlers (decorated endpoints), so reusing them as internal data access couples this page to HTTP-layer code and makes refactors risky. Prefer extracting the underlying DB/query logic into a shared function (or utils layer) and have both the API and UI routes call that.

Copilot uses AI. Check for mistakes.
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",
Expand Down Expand Up @@ -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,
)


Expand Down Expand Up @@ -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,
)


Expand Down Expand Up @@ -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,
)
13 changes: 10 additions & 3 deletions src/soa_builder/web/routers/timings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
)
Loading