From 2889f925ed877878e0029bc67e70007b6ad734b9 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Mon, 16 Feb 2026 10:47:17 -0500 Subject: [PATCH 01/21] Removed 8 unused dependencies --- requirements.txt | 9 --------- 1 file changed, 9 deletions(-) diff --git a/requirements.txt b/requirements.txt index 1f82b74..642a2b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,22 +1,17 @@ annotated-doc==0.0.4 annotated-types==0.7.0 anyio==4.12.1 -beautifulsoup4==4.14.3 certifi==2026.1.4 charset-normalizer==3.4.4 click==8.3.0 -docraptor==3.1.0 dotenv==0.9.9 et_xmlfile==2.0.0 fastapi==0.128.5 -fhir.resources==8.2.0 -fhir_core==1.1.5 h11==0.16.0 httpcore==1.0.9 httpx==0.28.1 idna==3.11 Jinja2==3.1.6 -numpy==2.4.2 openpyxl==3.1.5 pandas==3.0.0 pydantic==2.12.5 @@ -24,15 +19,11 @@ pydantic_core==2.41.5 python-dateutil==2.9.0.post0 python-dotenv==1.2.1 python-multipart==0.0.22 -PyYAML==6.0.3 requests==2.32.5 six==1.17.0 -soupsieve==2.8.3 starlette==0.52.1 -stringcase==1.2.0 typing-inspection==0.4.2 typing_extensions==4.15.0 urllib3==2.6.3 usdm==0.66.0 uvicorn==0.38.0 -yattag==1.16.1 From c5e30e2af6b52ef0e7b035ad5a079c6f64d1ee57 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Mon, 16 Feb 2026 12:54:17 -0500 Subject: [PATCH 02/21] Added help page for creation of SOA Matrix --- src/soa_builder/web/app.py | 11 ++++++ src/soa_builder/web/templates/base.html | 1 + src/soa_builder/web/templates/edit.html | 2 +- src/soa_builder/web/templates/help.html | 48 +++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 src/soa_builder/web/templates/help.html diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index 56bf8a1..1fd238f 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -3325,6 +3325,7 @@ def delete_activity(soa_id: int, activity_id: int): return {"deleted_activity_id": activity_id} +# API endpoint for displaying the index page @app.get("/", response_class=HTMLResponse) def ui_index(request: Request): """Render home page for the SoA Workbench.""" @@ -3354,6 +3355,16 @@ def ui_index(request: Request): ) +# API endpoint for displaying the help page +@app.get("/ui/help", response_class=HTMLResponse) +def ui_help(request: Request): + """Render the help page for the SOA Workbench.""" + return templates.TemplateResponse( + request, + "help.html", + ) + + # UI endpoint for adding an Activity @app.post("/ui/soa/{soa_id}/add_activity", response_class=HTMLResponse) def ui_add_activity(request: Request, soa_id: int, name: str = Form(...)): diff --git a/src/soa_builder/web/templates/base.html b/src/soa_builder/web/templates/base.html index c50bc86..33c4edb 100644 --- a/src/soa_builder/web/templates/base.html +++ b/src/soa_builder/web/templates/base.html @@ -26,6 +26,7 @@ Protocol Terminology DDF Audit Protocol Audit + Help and How To

SoA Workbench


diff --git a/src/soa_builder/web/templates/edit.html b/src/soa_builder/web/templates/edit.html index 373e648..5c75f0e 100644 --- a/src/soa_builder/web/templates/edit.html +++ b/src/soa_builder/web/templates/edit.html @@ -1,6 +1,6 @@ {% extends 'base.html' %} {% block content %} -

Editing SoA {{ soa_id }}

+

Editing SoA for {% if study_label %}{{ study_label }}{% else %}{{ study_id }}{% endif %}

Study Metadata
diff --git a/src/soa_builder/web/templates/help.html b/src/soa_builder/web/templates/help.html new file mode 100644 index 0000000..f68e72a --- /dev/null +++ b/src/soa_builder/web/templates/help.html @@ -0,0 +1,48 @@ +{% extends 'base.html' %} +{% block content %} + +

Steps to Create an SoA Matrix

+ +
    +
  1. +

    Create a Schedule Timeline (container)

    +
      +
    • Click Scheuled Timelines in the left sidebar
    • +
    • Create a Timeline (e.g., Main Timeline)
    • +
    • Optionally mark the Timeline as Main
    • +
    +
  2. +
  3. +

    Create Encounters/Visits (Optional, but recommended)

    +
      +
    • Click Encounters in the left sidebar
    • +
    • Create visits (e.g., "Visit 1", "Vist 2", "Screening")
    • +
    • These wil appear as column headers in the Matrix
    • +
    +
  4. +
  5. +

    Create Scheduled Activity Instances (Matrix Columns)

    +
      +
    • Click Scheduled Activity Instances in the left sidebar
    • +
    • For each instance created: +
        +
      • Set a name (e.g., "Week 1", "Day 1")
      • +
      • IMPORTANT: Set Member of Timeline to the timeline created in Step 1
      • +
      • Optionally link to an encounter from Step 2
      • +
      • Optioinally link to an Epoch
      • +
      +
    • +
    • Each instance becomes a column in the Matrix
    • +
    +
  6. +
  7. +

    Return to the Edit Page

    +
      +
    • Go back to the Edit Page
    • +
    • The Matrix will now be displayed with Activities as rows and Instances as columns
    • +
    +
  8. + +
+ +{% endblock %} \ No newline at end of file From 50713b3df36404cb3b914789b61432e0c34cdda8 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:36:17 -0500 Subject: [PATCH 03/21] Issue #100: Added study label to header of UI pages --- src/soa_builder/web/routers/arms.py | 19 +++++++++++++++++++ src/soa_builder/web/routers/elements.py | 19 +++++++++++++++++++ src/soa_builder/web/routers/epochs.py | 19 +++++++++++++++++++ src/soa_builder/web/routers/instances.py | 19 +++++++++++++++++++ src/soa_builder/web/routers/rules.py | 19 +++++++++++++++++++ .../web/routers/schedule_timelines.py | 19 +++++++++++++++++++ src/soa_builder/web/routers/timings.py | 19 +++++++++++++++++++ src/soa_builder/web/routers/visits.py | 18 ++++++++++++++++++ src/soa_builder/web/templates/arms.html | 2 +- src/soa_builder/web/templates/elements.html | 2 +- src/soa_builder/web/templates/encounters.html | 2 +- src/soa_builder/web/templates/epochs.html | 2 +- src/soa_builder/web/templates/instances.html | 2 +- src/soa_builder/web/templates/rules.html | 2 +- .../web/templates/schedule_timelines.html | 2 +- src/soa_builder/web/templates/timings.html | 2 +- 16 files changed, 159 insertions(+), 8 deletions(-) diff --git a/src/soa_builder/web/routers/arms.py b/src/soa_builder/web/routers/arms.py index 8298abb..93eefd4 100644 --- a/src/soa_builder/web/routers/arms.py +++ b/src/soa_builder/web/routers/arms.py @@ -102,6 +102,24 @@ def ui_list_arms(request: Request, soa_id: int): arm_type_options = load_arm_type_map() arm_data_origin_type_options = load_arm_data_origin_type_map() + # Study metadata + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT study_id, study_label, study_description, name, created_at FROM soa WHERE id=?", + (soa_id,), + ) + meta_row = cur.fetchone() + conn.close() + 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, "arms.html", @@ -111,6 +129,7 @@ def ui_list_arms(request: Request, soa_id: int): "arms": arms, "arm_type_options": arm_type_options, "arm_data_origin_type_options": arm_data_origin_type_options, + **study_meta, }, ) diff --git a/src/soa_builder/web/routers/elements.py b/src/soa_builder/web/routers/elements.py index ec47597..696028a 100644 --- a/src/soa_builder/web/routers/elements.py +++ b/src/soa_builder/web/routers/elements.py @@ -66,6 +66,24 @@ def ui_list_elements(request: Request, soa_id: int): elements = list_elements(soa_id) transition_rule_options = get_study_transition_rules(soa_id) + # Study metadata + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT study_id, study_label, study_description, name, created_at FROM soa WHERE id=?", + (soa_id,), + ) + meta_row = cur.fetchone() + conn.close() + 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, "elements.html", @@ -74,6 +92,7 @@ def ui_list_elements(request: Request, soa_id: int): "soa_id": soa_id, "elements": elements, "transition_rule_options": transition_rule_options, + **study_meta, }, ) diff --git a/src/soa_builder/web/routers/epochs.py b/src/soa_builder/web/routers/epochs.py index 01e7d4d..1262057 100644 --- a/src/soa_builder/web/routers/epochs.py +++ b/src/soa_builder/web/routers/epochs.py @@ -122,6 +122,24 @@ def ui_list_epochs(request: Request, soa_id: int): # Epoch Type options (C99079) must come from CDISC API only epoch_type_options = load_epoch_type_map() + # Study metadata + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT study_id, study_label, study_description, name, created_at FROM soa WHERE id=?", + (soa_id,), + ) + meta_row = cur.fetchone() + conn.close() + 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, "epochs.html", @@ -130,6 +148,7 @@ def ui_list_epochs(request: Request, soa_id: int): "soa_id": soa_id, "epochs": epochs, "epoch_type_options": epoch_type_options, + **study_meta, }, ) diff --git a/src/soa_builder/web/routers/instances.py b/src/soa_builder/web/routers/instances.py index 712575d..97742b4 100644 --- a/src/soa_builder/web/routers/instances.py +++ b/src/soa_builder/web/routers/instances.py @@ -75,6 +75,24 @@ def ui_list_instances(request: Request, soa_id: int): schedule_timelines_options = get_schedule_timeline(soa_id) instance_options = get_scheduled_activity_instance(soa_id) + # Study metadata + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT study_id, study_label, study_description, name, created_at FROM soa WHERE id=?", + (soa_id,), + ) + meta_row = cur.fetchone() + conn.close() + 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, "instances.html", @@ -86,6 +104,7 @@ def ui_list_instances(request: Request, soa_id: int): "epoch_options": epoch_options, "schedule_timelines_options": schedule_timelines_options, "instance_options": instance_options, + **study_meta, }, ) diff --git a/src/soa_builder/web/routers/rules.py b/src/soa_builder/web/routers/rules.py index b33457e..24a4e1d 100644 --- a/src/soa_builder/web/routers/rules.py +++ b/src/soa_builder/web/routers/rules.py @@ -65,6 +65,24 @@ def ui_list_rules(request: Request, soa_id: int): rules = list_rules(soa_id) + # Study metadata + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT study_id, study_label, study_description, name, created_at FROM soa WHERE id=?", + (soa_id,), + ) + meta_row = cur.fetchone() + conn.close() + 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, "rules.html", @@ -72,6 +90,7 @@ def ui_list_rules(request: Request, soa_id: int): "request": request, "soa_id": soa_id, "rules": rules, + **study_meta, }, ) diff --git a/src/soa_builder/web/routers/schedule_timelines.py b/src/soa_builder/web/routers/schedule_timelines.py index 5551aad..d46830a 100644 --- a/src/soa_builder/web/routers/schedule_timelines.py +++ b/src/soa_builder/web/routers/schedule_timelines.py @@ -90,6 +90,24 @@ def ui_list_schedule_timelines(request: Request, soa_id: int): schedule_timelines = list_schedule_timelines(soa_id) instance_options = get_scheduled_activity_instance(soa_id) + # Study metadata + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT study_id, study_label, study_description, name, created_at FROM soa WHERE id=?", + (soa_id,), + ) + meta_row = cur.fetchone() + conn.close() + 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, "schedule_timelines.html", @@ -98,6 +116,7 @@ def ui_list_schedule_timelines(request: Request, soa_id: int): "soa_id": soa_id, "schedule_timelines": schedule_timelines, "instance_options": instance_options, + **study_meta, }, ) diff --git a/src/soa_builder/web/routers/timings.py b/src/soa_builder/web/routers/timings.py index ef02813..f2474fc 100644 --- a/src/soa_builder/web/routers/timings.py +++ b/src/soa_builder/web/routers/timings.py @@ -114,6 +114,24 @@ def ui_list_timings(request: Request, soa_id: int): instance_options = get_scheduled_activity_instance(soa_id) schedule_timelines_options = get_schedule_timeline(soa_id) + # Study metadata + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT study_id, study_label, study_description, name, created_at FROM soa WHERE id=?", + (soa_id,), + ) + meta_row = cur.fetchone() + conn.close() + 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, "timings.html", @@ -125,6 +143,7 @@ def ui_list_timings(request: Request, soa_id: int): "relative_to_from_options": sorted(list(sv_to_code_rtf.keys())), "instance_options": instance_options, "schedule_timelines_options": schedule_timelines_options, + **study_meta, }, ) diff --git a/src/soa_builder/web/routers/visits.py b/src/soa_builder/web/routers/visits.py index 82b834b..a24e832 100644 --- a/src/soa_builder/web/routers/visits.py +++ b/src/soa_builder/web/routers/visits.py @@ -121,6 +121,23 @@ def ui_list_visits(request: Request, soa_id: int): timing_options = get_timing_id(soa_id) # logger.info(environmental_setting_options) + # Study metadata + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT study_id, study_label, study_description, name, created_at FROM soa WHERE id=?", + (soa_id,), + ) + meta_row = cur.fetchone() + conn.close() + 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, @@ -133,6 +150,7 @@ def ui_list_visits(request: Request, soa_id: int): "timing_options": timing_options, "environmental_setting_options": environmental_setting_options, "contact_mode_options": contact_mode_options, + **study_meta, }, ) diff --git a/src/soa_builder/web/templates/arms.html b/src/soa_builder/web/templates/arms.html index a15dddf..300cf86 100644 --- a/src/soa_builder/web/templates/arms.html +++ b/src/soa_builder/web/templates/arms.html @@ -1,6 +1,6 @@ {% extends 'base.html' %} {% block content %} -

Arms for SoA {{ soa_id }}

+

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

diff --git a/src/soa_builder/web/templates/elements.html b/src/soa_builder/web/templates/elements.html index d1f56e6..8a65669 100644 --- a/src/soa_builder/web/templates/elements.html +++ b/src/soa_builder/web/templates/elements.html @@ -1,6 +1,6 @@ {% extends 'base.html' %} {% block content %} -

Elements for SoA {{ soa_id }}

+

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

diff --git a/src/soa_builder/web/templates/encounters.html b/src/soa_builder/web/templates/encounters.html index 0c526d4..6c3d796 100644 --- a/src/soa_builder/web/templates/encounters.html +++ b/src/soa_builder/web/templates/encounters.html @@ -1,6 +1,6 @@ {% extends 'base.html' %} {% block content %} -

Encounters for SoA {{ soa_id }}

+

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

diff --git a/src/soa_builder/web/templates/epochs.html b/src/soa_builder/web/templates/epochs.html index 7ca1648..1b07d45 100644 --- a/src/soa_builder/web/templates/epochs.html +++ b/src/soa_builder/web/templates/epochs.html @@ -1,6 +1,6 @@ {% extends 'base.html' %} {% block content %} -

Epochs for SoA {{ soa_id }}

+

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

diff --git a/src/soa_builder/web/templates/instances.html b/src/soa_builder/web/templates/instances.html index 93b529e..c167d46 100644 --- a/src/soa_builder/web/templates/instances.html +++ b/src/soa_builder/web/templates/instances.html @@ -1,7 +1,7 @@ {% extends 'base.html' %} {% block content %} -

Scheduled Activity Instances for SoA {{ soa_id }}

+

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

diff --git a/src/soa_builder/web/templates/rules.html b/src/soa_builder/web/templates/rules.html index 1a8893e..bd844d5 100644 --- a/src/soa_builder/web/templates/rules.html +++ b/src/soa_builder/web/templates/rules.html @@ -1,6 +1,6 @@ {% extends 'base.html' %} {% block content %} -

Transition Rules for SoA {{ soa_id }}

+

Transition Rules for Study: {% if study_label %}{{ study_label }}{% else %}{{ study_name }}{% endif %}

diff --git a/src/soa_builder/web/templates/schedule_timelines.html b/src/soa_builder/web/templates/schedule_timelines.html index 7c35f75..3bc65b4 100644 --- a/src/soa_builder/web/templates/schedule_timelines.html +++ b/src/soa_builder/web/templates/schedule_timelines.html @@ -1,6 +1,6 @@ {% extends 'base.html' %} {% block content %} -

Schedule Timelines for SoA {{ soa_id }}

+

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

diff --git a/src/soa_builder/web/templates/timings.html b/src/soa_builder/web/templates/timings.html index 2d23f9d..6e2813a 100644 --- a/src/soa_builder/web/templates/timings.html +++ b/src/soa_builder/web/templates/timings.html @@ -1,6 +1,6 @@ {% extends 'base.html' %} {% block content %} -

Timings for SoA {{ soa_id }}

+

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

From 739a20ab9c26db24716c05385802121985f43125 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:58:12 -0500 Subject: [PATCH 04/21] order_index is used to display the list of instances/columns --- src/soa_builder/web/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index 1fd238f..82fc145 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -1978,7 +1978,7 @@ def _fetch_enriched_instances(soa_id: int): LEFT JOIN epoch e ON e.epoch_uid = i.epoch_uid AND e.soa_id = i.soa_id LEFT JOIN timing tm ON tm.id = v.scheduledAtId AND tm.soa_id = v.soa_id WHERE i.soa_id=? - ORDER BY COALESCE(i.member_of_timeline, 'zzz'), LENGTH(i.instance_uid), i.instance_uid + ORDER BY COALESCE(i.member_of_timeline, 'zzz'), i.order_index, i.id """, (soa_id,), ) @@ -3775,7 +3775,7 @@ def ui_edit(request: Request, soa_id: int): LEFT JOIN epoch e ON e.epoch_uid = i.epoch_uid AND e.soa_id = i.soa_id LEFT JOIN timing tm ON tm.id = v.scheduledAtId AND tm.soa_id = v.soa_id WHERE i.soa_id=? - ORDER BY COALESCE(i.member_of_timeline, 'zzz'), LENGTH(i.instance_uid), i.instance_uid + ORDER BY COALESCE(i.member_of_timeline, 'zzz'), i.order_index, i.id """, (soa_id,), ) From c84acfc5965e212b1b76aced513c3fc72f542de2 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:59:09 -0500 Subject: [PATCH 05/21] Issue #93: Added reordering of encounters --- src/soa_builder/web/routers/visits.py | 26 ++++-- src/soa_builder/web/templates/encounters.html | 79 ++++++++++++++++++- tests/test_routers_visits.py | 10 +-- 3 files changed, 97 insertions(+), 18 deletions(-) diff --git a/src/soa_builder/web/routers/visits.py b/src/soa_builder/web/routers/visits.py index a24e832..1ba1ba8 100644 --- a/src/soa_builder/web/routers/visits.py +++ b/src/soa_builder/web/routers/visits.py @@ -1,6 +1,6 @@ from typing import List, Optional -from fastapi import APIRouter, HTTPException, Request, Form +from fastapi import APIRouter, Body, HTTPException, Request, Form import os import logging from fastapi.responses import JSONResponse, HTMLResponse, RedirectResponse @@ -676,16 +676,25 @@ def ui_delete_visit(request: Request, soa_id: int, visit_id: int): # API endpoint to reorder a visit -@router.post("/visits/reorder", response_class=JSONResponse) -def reorder_visits_api(soa_id: int, order: List[int]): +@router.post("/soa/{soa_id}/visits/reorder", response_class=JSONResponse) +def reorder_visits_api( + soa_id: int, + order: List[int] = Body(..., embed=True), +): if not soa_exists(soa_id): raise HTTPException(404, "SOA not found") if not order: raise HTTPException(400, "Order list required") conn = _connect() cur = conn.cursor() - cur.execute("SELECT id FROM visit WHERE soa_id=? ORDER BY order_index", (soa_id,)) - old_order = [r[0] for r in cur.fetchall()] + cur.execute( + "SELECT id,name FROM visit WHERE soa_id=? ORDER BY order_index", (soa_id,) + ) + rows = cur.fetchall() + old_order = [r[0] for r in rows] + id_to_name = {r[0]: r[1] for r in rows} + old_order_names = [r[1] for r in rows] + cur.execute("SELECT id FROM visit WHERE soa_id=?", (soa_id,)) existing = {r[0] for r in cur.fetchall()} if set(order) - existing: @@ -695,12 +704,15 @@ def reorder_visits_api(soa_id: int, order: List[int]): cur.execute("UPDATE visit SET order_index=? WHERE id=?", (idx, vid)) conn.commit() conn.close() + + new_order_names = [id_to_name.get(vid, str(vid)) for vid in order] + _record_reorder_audit(soa_id, "visit", old_order, order) _record_visit_audit( soa_id, "reorder", visit_id=None, - before={"old_order": old_order}, - after={"new_order": order}, + before={"old_order": old_order_names}, + after={"new_order": new_order_names}, ) return JSONResponse({"ok": True, "old_order": old_order, "new_order": order}) diff --git a/src/soa_builder/web/templates/encounters.html b/src/soa_builder/web/templates/encounters.html index 6c3d796..199fa6c 100644 --- a/src/soa_builder/web/templates/encounters.html +++ b/src/soa_builder/web/templates/encounters.html @@ -89,9 +89,10 @@

Encounters for Study: {% if study_label %}{{ study_label }}{% else %}{{ stud - +
+ @@ -103,11 +104,20 @@

Encounters for Study: {% if study_label %}{{ study_label }}{% else %}{{ stud

+ {% for e in encounters %} - + + @@ -186,9 +196,70 @@

Encounters for Study: {% if study_label %}{{ study_label }}{% else %}{{ stud +

{% else %} - -
idOrder Name Label DescriptionTransition End Rule Save Delete + +
{{ e.encounter_uid }}{{ e.order_index }} + + +
No encounters yet.
+ No encounters yet. {% endfor %} + + + {% endblock %} \ No newline at end of file diff --git a/tests/test_routers_visits.py b/tests/test_routers_visits.py index c3668a3..caebd8c 100644 --- a/tests/test_routers_visits.py +++ b/tests/test_routers_visits.py @@ -136,9 +136,7 @@ def test_reorder_visits(): v2_id = v2_resp.json()["id"] # Reorder them - resp = client.post( - "/visits/reorder", params={"soa_id": soa_id}, json=[v2_id, v1_id] - ) + resp = client.post(f"/soa/{soa_id}/visits/reorder", json={"order": [v2_id, v1_id]}) assert resp.status_code == 200 data = resp.json() assert data["new_order"] == [v2_id, v1_id] @@ -252,7 +250,7 @@ def test_reorder_empty_list(): r = client.post("/soa", json={"name": "Empty Reorder Test"}) soa_id = r.json()["id"] - resp = client.post("/visits/reorder", params={"soa_id": soa_id}, json=[]) + resp = client.post(f"/soa/{soa_id}/visits/reorder", json={"order": []}) assert resp.status_code == 400 @@ -266,9 +264,7 @@ def test_reorder_invalid_visit_id(): v1_id = v1_resp.json()["id"] # Try to reorder with invalid ID - resp = client.post( - "/visits/reorder", params={"soa_id": soa_id}, json=[v1_id, 999999] - ) + resp = client.post(f"/soa/{soa_id}/visits/reorder", json={"order": [v1_id, 999999]}) assert resp.status_code == 400 From bb17406739df68c8cbdcf3fc4dd7c9b0d5a36849 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Tue, 17 Feb 2026 09:48:09 -0500 Subject: [PATCH 06/21] Reorder API endpoint is no longer deprecated --- src/soa_builder/web/routers/epochs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/soa_builder/web/routers/epochs.py b/src/soa_builder/web/routers/epochs.py index 1262057..7d24c82 100644 --- a/src/soa_builder/web/routers/epochs.py +++ b/src/soa_builder/web/routers/epochs.py @@ -625,7 +625,7 @@ def update_epoch_metadata(soa_id: int, epoch_id: int, payload: EpochUpdate): return {**after, "updated_fields": updated_fields} -# Deprecated (no longer needed) +# API call to reorder epochs @router.post("/soa/{soa_id}/epochs/reorder", response_class=JSONResponse) def reorder_epochs_api( soa_id: int, From 0b55d3b541e2f36cb2148e8cba241e86c5d8c991 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Tue, 17 Feb 2026 09:56:24 -0500 Subject: [PATCH 07/21] Issue 99: Removed study cell endpoints --- src/soa_builder/web/migrate_database.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/soa_builder/web/migrate_database.py b/src/soa_builder/web/migrate_database.py index ae7eaa9..b869a2b 100644 --- a/src/soa_builder/web/migrate_database.py +++ b/src/soa_builder/web/migrate_database.py @@ -995,3 +995,19 @@ def _migrate_activity_concept_add_href(): conn.close() except Exception as e: logger.warning("activity_concept href migration failed: %s", e) + + +def _migrate_study_cell_add_order_index(): + """Add order_index column to study_cell table to support reordering""" + try: + conn = _connect() + cur = conn.cursor() + cur.execute("PRAGMA table_info(study_cell)") + cols = {r[1] for r in cur.fetchall()} + if "order_index" not in cols: + cur.execute("ALTER TABLE study_cell ADD COLUMN order_index INTEGER") + conn.commit() + logger.info("Added order_index column to the study_cell table") + conn.close() + except Exception as e: + logger.warning("order_index migration failed: %s", e) From 6ed8fbf583c7986a3586682729a02b272c7b4790 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Tue, 17 Feb 2026 10:01:05 -0500 Subject: [PATCH 08/21] Issue 99: Removed study cell endpoints and moved to dedicated PY file and HTML --- src/soa_builder/web/app.py | 251 +--------- src/soa_builder/web/routers/cells.py | 430 ++++++++++++++++++ src/soa_builder/web/schemas.py | 12 + src/soa_builder/web/templates/base.html | 1 + src/soa_builder/web/templates/edit.html | 69 +-- .../web/templates/study_cells.html | 70 +++ tests/test_study_cell_uid_reuse.py | 20 +- tests/test_study_cell_uid_reuse_later.py | 28 +- 8 files changed, 543 insertions(+), 338 deletions(-) create mode 100644 src/soa_builder/web/routers/cells.py create mode 100644 src/soa_builder/web/templates/study_cells.html diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index 82fc145..9309a55 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -57,6 +57,7 @@ _migrate_instances_add_member_of_timeline, _migrate_matrix_cells_add_instance_id, _migrate_activity_concept_add_href, + _migrate_study_cell_add_order_index, ) from .routers import activities as activities_router from .routers import arms as arms_router @@ -70,6 +71,7 @@ from .routers import timings as timings_router from .routers import schedule_timelines as schedule_timelines_router +from .routers import cells as cells_router from .routers import instances as instances_router @@ -151,6 +153,7 @@ def _configure_logging(): # Database migration steps +_migrate_study_cell_add_order_index() _migrate_activity_concept_add_href() _migrate_matrix_cells_add_instance_id() _migrate_instances_add_member_of_timeline() @@ -191,6 +194,7 @@ def _configure_logging(): app.include_router(audits_router.router) app.include_router(schedule_timelines_router.router) app.include_router(rules_router.router) +app.include_router(cells_router.router) def _record_visit_audit( @@ -1081,6 +1085,7 @@ def _fetch_matrix(soa_id: int): return instances, activities, cells +# Deprecated: implemented in routers/cells.py def _list_study_cells(soa_id: int) -> list[dict]: """List study_cell rows, including element and arm names filtered by soa_id. @@ -4614,252 +4619,6 @@ def ui_delete_element(request: Request, soa_id: int, element_id: int = Form(...) """ -# Function to compute next available StudyCell_{N} -def _next_study_cell_uid(cur, soa_id: int) -> str: - """Compute next StudyCell_N unique within an SoA.""" - cur.execute("SELECT study_cell_uid FROM study_cell WHERE soa_id=?", (soa_id,)) - max_n = 0 - for (uid,) in cur.fetchall(): - if isinstance(uid, str) and uid.startswith("StudyCell_"): - try: - n = int(uid.split("_")[-1]) - if n > max_n: - max_n = n - except Exception: - pass - return f"StudyCell_{max_n + 1}" - - -# UI endpoint for adding a new StudyCell -@app.post("/ui/soa/{soa_id}/add_study_cell", response_class=HTMLResponse) -def ui_add_study_cell( - request: Request, - soa_id: int, - arm_uid: str = Form(...), - epoch_uid: str = Form(...), - element_uids: List[str] = Form(...), -): - """Add one or more Study Cell rows for Arm×Epoch×Elements. - - Duplicate prevention enforced on (soa_id, arm_uid, epoch_uid, element_uid). - """ - if not soa_exists(soa_id): - raise HTTPException(404, "SOA not found") - arm_uid = (arm_uid or "").strip() - epoch_uid = (epoch_uid or "").strip() - element_ids: list[str] = [ - str(e).strip() for e in (element_uids or []) if str(e).strip() - ] - if not arm_uid or not epoch_uid or not element_ids: - return HTMLResponse( - f"", - status_code=400, - ) - conn = _connect() - cur = conn.cursor() - # basic existence checks (optional) - cur.execute("SELECT 1 FROM arm WHERE soa_id=? AND arm_uid=?", (soa_id, arm_uid)) - if not cur.fetchone(): - conn.close() - return HTMLResponse( - f"", - status_code=404, - ) - cur.execute( - "SELECT 1 FROM epoch WHERE soa_id=? AND epoch_uid=?", (soa_id, epoch_uid) - ) - if not cur.fetchone(): - conn.close() - return HTMLResponse( - f"", - status_code=404, - ) - # Allocate a single StudyCell UID for this Arm×Epoch submission, - # but reuse an existing UID if one already exists for (soa_id, arm_uid, epoch_uid) - sc_uid_global = None - try: - cur.execute( - "SELECT study_cell_uid FROM study_cell WHERE soa_id=? AND arm_uid=? AND epoch_uid=? LIMIT 1", - (soa_id, arm_uid, epoch_uid), - ) - row_existing = cur.fetchone() - if row_existing and row_existing[0]: - sc_uid_global = row_existing[0] - except Exception: - sc_uid_global = None - if not sc_uid_global: - sc_uid_global = _next_study_cell_uid(cur, soa_id) - inserted = 0 - for el_uid in element_ids: - # ensure element exists if element_id column present - cur.execute("PRAGMA table_info(element)") - cols = {r[1] for r in cur.fetchall()} - if "element_id" in cols: - cur.execute( - "SELECT 1 FROM element WHERE soa_id=? AND element_id=?", - (soa_id, el_uid), - ) - if not cur.fetchone(): - # skip silently; or alert once (keeping UX simple) - continue - # duplicate prevention - cur.execute( - "SELECT id FROM study_cell WHERE soa_id=? AND arm_uid=? AND epoch_uid=? AND element_uid=?", - (soa_id, arm_uid, epoch_uid, el_uid), - ) - if cur.fetchone(): - continue - cur.execute( - "INSERT INTO study_cell (soa_id, study_cell_uid, arm_uid, epoch_uid, element_uid) VALUES (?,?,?,?,?)", - (soa_id, sc_uid_global, arm_uid, epoch_uid, el_uid), - ) - sc_id = cur.lastrowid - # Inline audit write for reliability - cur.execute( - "INSERT INTO study_cell_audit (soa_id, study_cell_id, action, before_json, after_json, performed_at) VALUES (?,?,?,?,?,?)", - ( - soa_id, - sc_id, - "create", - None, - json.dumps( - { - "study_cell_uid": sc_uid_global, - "arm_uid": arm_uid, - "epoch_uid": epoch_uid, - "element_uid": el_uid, - } - ), - datetime.now(timezone.utc).isoformat(), - ), - ) - inserted += 1 - conn.commit() - conn.close() - return HTMLResponse( - f"" - ) - - -# UI endpoint for updating a StudyCell -@app.post("/ui/soa/{soa_id}/update_study_cell", response_class=HTMLResponse) -def ui_update_study_cell( - request: Request, - soa_id: int, - study_cell_id: int = Form(...), - arm_uid: Optional[str] = Form(None), - epoch_uid: Optional[str] = Form(None), - element_uid: Optional[str] = Form(None), -): - """Update a Study Cell's Arm/Epoch/Element values. - - Duplicate prevention enforced; if update causes a duplicate, no change is applied. - """ - if not soa_exists(soa_id): - raise HTTPException(404, "SOA not found") - conn = _connect() - cur = conn.cursor() - cur.execute( - "SELECT id, arm_uid, epoch_uid, element_uid FROM study_cell WHERE id=? AND soa_id=?", - (study_cell_id, soa_id), - ) - row = cur.fetchone() - if not row: - conn.close() - raise HTTPException(404, "Study Cell not found") - _, curr_arm, curr_epoch, curr_el = row - new_arm = (arm_uid or curr_arm or "").strip() or curr_arm - new_epoch = (epoch_uid or curr_epoch or "").strip() or curr_epoch - new_el = (element_uid or curr_el or "").strip() or curr_el - # duplicate check - cur.execute( - "SELECT id FROM study_cell WHERE soa_id=? AND arm_uid=? AND epoch_uid=? AND element_uid=? AND id<>?", - (soa_id, new_arm, new_epoch, new_el, study_cell_id), - ) - if cur.fetchone(): - conn.close() - return HTMLResponse( - f"", - status_code=400, - ) - before = { - "arm_uid": curr_arm, - "epoch_uid": curr_epoch, - "element_uid": curr_el, - } - cur.execute( - "UPDATE study_cell SET arm_uid=?, epoch_uid=?, element_uid=? WHERE id=? AND soa_id=?", - (new_arm, new_epoch, new_el, study_cell_id, soa_id), - ) - # Inline audit write for reliability - cur.execute( - "INSERT INTO study_cell_audit (soa_id, study_cell_id, action, before_json, after_json, performed_at) VALUES (?,?,?,?,?,?)", - ( - soa_id, - study_cell_id, - "update", - json.dumps(before), - json.dumps( - { - "arm_uid": new_arm, - "epoch_uid": new_epoch, - "element_uid": new_el, - } - ), - datetime.now(timezone.utc).isoformat(), - ), - ) - conn.commit() - conn.close() - return HTMLResponse( - f"" - ) - - -# UI endpoint for deleting a StudyCell -@app.post("/ui/soa/{soa_id}/delete_study_cell", response_class=HTMLResponse) -def ui_delete_study_cell(request: Request, soa_id: int, study_cell_id: int = Form(...)): - """Delete a Study Cell by id.""" - if not soa_exists(soa_id): - raise HTTPException(404, "SOA not found") - conn = _connect() - cur = conn.cursor() - # capture before state for audit - cur.execute( - "SELECT study_cell_uid, arm_uid, epoch_uid, element_uid FROM study_cell WHERE id=? AND soa_id=?", - (study_cell_id, soa_id), - ) - row = cur.fetchone() - before = None - if row: - before = { - "study_cell_uid": row[0], - "arm_uid": row[1], - "epoch_uid": row[2], - "element_uid": row[3], - } - # Inline audit write for reliability - cur.execute( - "INSERT INTO study_cell_audit (soa_id, study_cell_id, action, before_json, after_json, performed_at) VALUES (?,?,?,?,?,?)", - ( - soa_id, - study_cell_id, - "delete", - json.dumps(before) if before else None, - None, - datetime.now(timezone.utc).isoformat(), - ), - ) - cur.execute( - "DELETE FROM study_cell WHERE id=? AND soa_id=?", (study_cell_id, soa_id) - ) - conn.commit() - conn.close() - return HTMLResponse( - f"" - ) - - # Function to compute next available TransitionRule_{N} def _next_transition_rule_uid(soa_id: int) -> str: """Compute next monotonically increasing TransitionRule_N for an SoA. diff --git a/src/soa_builder/web/routers/cells.py b/src/soa_builder/web/routers/cells.py new file mode 100644 index 0000000..bbc1bb1 --- /dev/null +++ b/src/soa_builder/web/routers/cells.py @@ -0,0 +1,430 @@ +import json +import logging +import os +from typing import List, Optional + +from fastapi import APIRouter, HTTPException, Request, Form +from fastapi.responses import JSONResponse, HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates + +from ..audit import _record_study_cell_audit +from ..db import _connect +from ..schemas import StudyCellCreate, StudyCellUpdate +from ..utils import soa_exists + +router = APIRouter() +logger = logging.getLogger("soa_builder.web.routers.cells") +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 _next_study_cell_uid(cur, soa_id: int) -> str: + """Compute next StudyCell_N unique within an SoA. + + Checks both the live table and the audit trail so that UIDs from + deleted study cells are never reused. + """ + max_n = 0 + + # Current rows + cur.execute("SELECT study_cell_uid FROM study_cell WHERE soa_id=?", (soa_id,)) + for (uid,) in cur.fetchall(): + if isinstance(uid, str) and uid.startswith("StudyCell_"): + try: + n = int(uid.split("_")[-1]) + if n > max_n: + max_n = n + except Exception: + pass + + # Historically used UIDs from audit trail + cur.execute( + "SELECT before_json, after_json FROM study_cell_audit WHERE soa_id=?", + (soa_id,), + ) + for before_raw, after_raw in cur.fetchall(): + for raw in (before_raw, after_raw): + if not raw: + continue + try: + uid = json.loads(raw).get("study_cell_uid", "") + if isinstance(uid, str) and uid.startswith("StudyCell_"): + n = int(uid.split("_")[-1]) + if n > max_n: + max_n = n + except Exception: + pass + + return f"StudyCell_{max_n + 1}" + + +# ---------- API endpoints ---------- + + +# API endpoint for listing study cells +@router.get( + "/soa/{soa_id}/study_cells", response_class=JSONResponse, response_model=None +) +def list_study_cells(soa_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + conn = _connect() + cur = conn.cursor() + cur.execute( + """ + SELECT sc.id,sc.study_cell_uid,a.name,a.label,e.name,e.epoch_label,el.name,el.label + FROM study_cell sc + INNER JOIN arm a ON sc.soa_id=a.soa_id AND sc.arm_uid=a.arm_uid + INNER JOIN epoch e ON sc.soa_id=e.soa_id AND sc.epoch_uid=e.epoch_uid + INNER JOIN element el ON sc.soa_id=el.soa_id AND sc.element_uid=el.element_id + WHERE sc.soa_id=? ORDER BY sc.study_cell_uid + """, + (soa_id,), + ) + rows = [ + { + "study_cell_id": r[0], + "study_cell_uid": r[1], + "arm_name": r[2], + "arm_label": r[3], + "epoch_name": r[4], + "epoch_label": r[5], + "element_name": r[6], + "element_label": r[7], + } + for r in cur.fetchall() + ] + conn.close() + return rows + + +# API endpoint for creating study_cell +@router.post( + "/soa/{soa_id}/study_cells", + response_class=JSONResponse, + status_code=201, + response_model=None, +) +def add_study_cell(soa_id: int, payload: StudyCellCreate): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + arm_uid = (payload.arm_uid or "").strip() + epoch_uid = (payload.epoch_uid or "").strip() + element_uid = (payload.element_uid or "").strip() + if not arm_uid or not epoch_uid or not element_uid: + raise HTTPException(400, "arm_uid, epoch_uid, and element_uid are required") + + conn = _connect() + cur = conn.cursor() + + # Validate arm exists + cur.execute("SELECT 1 FROM arm WHERE soa_id=? AND arm_uid=?", (soa_id, arm_uid)) + if not cur.fetchone(): + conn.close() + raise HTTPException(404, "Arm not found") + + # Validate epoch exists + cur.execute( + "SELECT 1 FROM epoch WHERE soa_id=? AND epoch_uid=?", (soa_id, epoch_uid) + ) + if not cur.fetchone(): + conn.close() + raise HTTPException(404, "Epoch not found") + + # Validate element exists + cur.execute("PRAGMA table_info(element)") + cols = {r[1] for r in cur.fetchall()} + if "element_id" in cols: + cur.execute( + "SELECT 1 FROM element WHERE soa_id=? AND element_id=?", + (soa_id, element_uid), + ) + if not cur.fetchone(): + conn.close() + raise HTTPException(404, "Element not found") + + # Duplicate prevention + cur.execute( + "SELECT id FROM study_cell WHERE soa_id=? AND arm_uid=? AND epoch_uid=? AND element_uid=?", + (soa_id, arm_uid, epoch_uid, element_uid), + ) + if cur.fetchone(): + conn.close() + raise HTTPException( + 409, "Study cell already exists for this arm/epoch/element combination" + ) + + sc_uid = _next_study_cell_uid(cur, soa_id) + cur.execute( + "INSERT INTO study_cell (soa_id, study_cell_uid, arm_uid, epoch_uid, element_uid) VALUES (?,?,?,?,?)", + (soa_id, sc_uid, arm_uid, epoch_uid, element_uid), + ) + sc_id = cur.lastrowid + conn.commit() + conn.close() + + after = { + "study_cell_id": sc_id, + "study_cell_uid": sc_uid, + "arm_uid": arm_uid, + "epoch_uid": epoch_uid, + "element_uid": element_uid, + } + _record_study_cell_audit(soa_id, "create", sc_id, before=None, after=after) + return after + + +# API endpoint for updating study_cell +@router.patch( + "/soa/{soa_id}/study_cells/{study_cell_id}", + response_class=JSONResponse, + response_model=None, +) +def update_study_cell(soa_id: int, study_cell_id: int, payload: StudyCellUpdate): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id, arm_uid, epoch_uid, element_uid FROM study_cell WHERE id=? AND soa_id=?", + (study_cell_id, soa_id), + ) + row = cur.fetchone() + if not row: + conn.close() + raise HTTPException(404, "Study Cell not found") + + _, curr_arm, curr_epoch, curr_el = row + new_arm = ( + (payload.arm_uid if payload.arm_uid is not None else curr_arm) or "" + ).strip() or curr_arm + new_epoch = ( + (payload.epoch_uid if payload.epoch_uid is not None else curr_epoch) or "" + ).strip() or curr_epoch + new_el = ( + (payload.element_uid if payload.element_uid is not None else curr_el) or "" + ).strip() or curr_el + + # Duplicate check + cur.execute( + "SELECT id FROM study_cell WHERE soa_id=? AND arm_uid=? AND epoch_uid=? AND element_uid=? AND id<>?", + (soa_id, new_arm, new_epoch, new_el, study_cell_id), + ) + if cur.fetchone(): + conn.close() + raise HTTPException(409, "Duplicate Study Cell exists") + + before = { + "arm_uid": curr_arm, + "epoch_uid": curr_epoch, + "element_uid": curr_el, + } + cur.execute( + "UPDATE study_cell SET arm_uid=?, epoch_uid=?, element_uid=? WHERE id=? AND soa_id=?", + (new_arm, new_epoch, new_el, study_cell_id, soa_id), + ) + conn.commit() + conn.close() + + after = { + "arm_uid": new_arm, + "epoch_uid": new_epoch, + "element_uid": new_el, + } + _record_study_cell_audit( + soa_id, "update", study_cell_id, before=before, after=after + ) + return {**after, "study_cell_id": study_cell_id} + + +# API endpoint for deleting study_cell +@router.delete( + "/soa/{soa_id}/study_cells/{study_cell_id}", + response_class=JSONResponse, + response_model=None, +) +def delete_study_cell(soa_id: int, study_cell_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT study_cell_uid, arm_uid, epoch_uid, element_uid FROM study_cell WHERE id=? AND soa_id=?", + (study_cell_id, soa_id), + ) + row = cur.fetchone() + if not row: + conn.close() + raise HTTPException(404, "Study Cell not found") + + before = { + "study_cell_uid": row[0], + "arm_uid": row[1], + "epoch_uid": row[2], + "element_uid": row[3], + } + cur.execute( + "DELETE FROM study_cell WHERE id=? AND soa_id=?", (study_cell_id, soa_id) + ) + conn.commit() + conn.close() + + _record_study_cell_audit(soa_id, "delete", study_cell_id, before=before, after=None) + return {"deleted": True, "id": study_cell_id} + + +# ---------- UI endpoints ---------- + + +# UI code for listing study cells +@router.get("/ui/soa/{soa_id}/study_cells", response_class=HTMLResponse) +def ui_list_study_cells(request: Request, soa_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + conn = _connect() + cur = conn.cursor() + + # Study cells with resolved names (LEFT JOIN to handle missing references) + cur.execute( + "SELECT sc.id, sc.study_cell_uid, sc.arm_uid, sc.epoch_uid, sc.element_uid, " + " e.name AS element_name, a.name AS arm_name, ep.name AS epoch_name " + "FROM study_cell sc " + "LEFT JOIN element e ON e.element_id = sc.element_uid AND e.soa_id = sc.soa_id " + "LEFT JOIN arm a ON a.arm_uid = sc.arm_uid AND a.soa_id = sc.soa_id " + "LEFT JOIN epoch ep ON ep.epoch_uid = sc.epoch_uid AND ep.soa_id = sc.soa_id " + "WHERE sc.soa_id=? ORDER BY sc.id", + (soa_id,), + ) + study_cells = [ + { + "id": r[0], + "study_cell_uid": r[1], + "arm_uid": r[2], + "epoch_uid": r[3], + "element_uid": r[4], + "element_name": r[5], + "arm_name": r[6], + "epoch_name": r[7], + } + for r in cur.fetchall() + ] + + # Arms for dropdown + cur.execute( + "SELECT id, name, arm_uid FROM arm WHERE soa_id=? ORDER BY order_index", + (soa_id,), + ) + arms = [{"id": r[0], "name": r[1], "arm_uid": r[2]} for r in cur.fetchall()] + + # Epochs for dropdown + cur.execute( + "SELECT id, name, epoch_uid, epoch_seq FROM epoch WHERE soa_id=? ORDER BY order_index", + (soa_id,), + ) + epochs = [ + {"id": r[0], "name": r[1], "epoch_uid": r[2], "epoch_seq": r[3]} + for r in cur.fetchall() + ] + + # Elements for dropdown + cur.execute( + "SELECT id, name, element_id FROM element WHERE soa_id=? ORDER BY order_index", + (soa_id,), + ) + elements = [{"id": r[0], "name": r[1], "element_id": r[2]} for r in cur.fetchall()] + + # Study metadata + cur.execute( + "SELECT study_id, study_label, study_description, name, created_at FROM soa WHERE id=?", + (soa_id,), + ) + meta_row = cur.fetchone() + conn.close() + 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_cells.html", + { + "request": request, + "soa_id": soa_id, + "study_cells": study_cells, + "arms": arms, + "epochs": epochs, + "elements": elements, + **study_meta, + }, + ) + + +# UI code for creating study cell(s) +@router.post("/ui/soa/{soa_id}/study_cells/create") +def ui_create_study_cell( + request: Request, + soa_id: int, + arm_uid: str = Form(...), + epoch_uid: str = Form(...), + element_uids: List[str] = Form(...), +): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + for el_uid in element_uids: + el_uid = str(el_uid).strip() + if not el_uid: + continue + payload = StudyCellCreate( + arm_uid=arm_uid, epoch_uid=epoch_uid, element_uid=el_uid + ) + try: + add_study_cell(soa_id, payload) + except HTTPException as e: + if e.status_code == 409: # duplicate, skip + continue + raise + return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/study_cells", status_code=303) + + +# UI code to update study cell +@router.post("/ui/soa/{soa_id}/study_cells/{study_cell_id}/update") +def ui_update_study_cell( + request: Request, + soa_id: int, + study_cell_id: int, + arm_uid: Optional[str] = Form(None), + epoch_uid: Optional[str] = Form(None), + element_uid: Optional[str] = Form(None), +): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + payload = StudyCellUpdate( + arm_uid=arm_uid, epoch_uid=epoch_uid, element_uid=element_uid + ) + update_study_cell(soa_id, study_cell_id, payload) + return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/study_cells", status_code=303) + + +# UI code to delete study cell +@router.post("/ui/soa/{soa_id}/study_cells/{study_cell_id}/delete") +def ui_delete_study_cell(request: Request, soa_id: int, study_cell_id: int): + delete_study_cell(soa_id, study_cell_id) + return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/study_cells", status_code=303) diff --git a/src/soa_builder/web/schemas.py b/src/soa_builder/web/schemas.py index 8b837ec..e50c535 100644 --- a/src/soa_builder/web/schemas.py +++ b/src/soa_builder/web/schemas.py @@ -291,3 +291,15 @@ class MatrixImport(BaseModel): instances: List[MatrixInstance] activities: List[MatrixActivity] reset: bool = True + + +class StudyCellCreate(BaseModel): + arm_uid: Optional[str] = None + epoch_uid: Optional[str] = None + element_uid: Optional[str] = None + + +class StudyCellUpdate(BaseModel): + arm_uid: Optional[str] = None + epoch_uid: Optional[str] = None + element_uid: Optional[str] = None diff --git a/src/soa_builder/web/templates/base.html b/src/soa_builder/web/templates/base.html index 33c4edb..4ef0612 100644 --- a/src/soa_builder/web/templates/base.html +++ b/src/soa_builder/web/templates/base.html @@ -15,6 +15,7 @@
Scheduled Activity Instances Epochs Arms + Study Cells Encounters Elements Transition Rules diff --git a/src/soa_builder/web/templates/edit.html b/src/soa_builder/web/templates/edit.html index 5c75f0e..422f8cb 100644 --- a/src/soa_builder/web/templates/edit.html +++ b/src/soa_builder/web/templates/edit.html @@ -114,74 +114,7 @@

Editing SoA for {% if study_label %}{{ study_label }}{% else %}{{ study_id } -
- Study Cells ({{ study_cells|length }}) -
-
- - -
-
- - -
-
- - - Select one or more elements (Cmd/Ctrl+Click). -
-
- -
-
-
- - - - - - - - - {% for sc in study_cells %} - - - - - - - - {% else %} - - {% endfor %} -
UIDArmEpochElementActions
{{ sc.study_cell_uid }} - {% set arm_match = arms | selectattr('arm_uid', 'equalto', sc.arm_uid) | list %} - {% if arm_match and arm_match[0] and arm_match[0].name %} - {{ arm_match[0].name }} - {% else %} - {{ sc.arm_uid }} - {% endif %} - {{ sc.epoch_name or sc.epoch_uid }}{{ sc.element_name or sc.element_uid }} -
- - -
-
No study cells yet.
-
-
+

diff --git a/src/soa_builder/web/templates/study_cells.html b/src/soa_builder/web/templates/study_cells.html new file mode 100644 index 0000000..61b6b29 --- /dev/null +++ b/src/soa_builder/web/templates/study_cells.html @@ -0,0 +1,70 @@ +{% extends 'base.html' %} +{% block content %} +

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

+ + + +
+
+
+ + +
+
+ + +
+
+ + + Select one or more elements (Cmd/Ctrl+Click). +
+
+ +
+
+
+ + + + + + + + + + {% for sc in study_cells %} + + + + + + + + {% else %} + + {% endfor %} +
UIDArmEpochElementActions
{{ sc.study_cell_uid }}{{ sc.arm_name or sc.arm_uid }}{{ sc.epoch_name or sc.epoch_uid }}{{ sc.element_name or sc.element_uid }} +
+ +
+
No study cells yet.
+ +{% endblock %} diff --git a/tests/test_study_cell_uid_reuse.py b/tests/test_study_cell_uid_reuse.py index 3a894d4..817a0bc 100644 --- a/tests/test_study_cell_uid_reuse.py +++ b/tests/test_study_cell_uid_reuse.py @@ -5,9 +5,9 @@ client = TestClient(app) -def test_study_cell_uid_reuse_same_arm_epoch(): +def test_study_cell_uid_unique_per_row(): # Create study - r = client.post("/soa", json={"name": "UID Reuse Study"}) + r = client.post("/soa", json={"name": "UID Unique Study"}) assert r.status_code == 200 soa_id = r.json()["id"] @@ -58,16 +58,16 @@ def test_study_cell_uid_reuse_same_arm_epoch(): conn.close() - # Call UI endpoint to add study cells with multiple elements; reuse safeguard should apply + # Call UI endpoint to add study cells with multiple elements form = { "arm_uid": arm_uid, "epoch_uid": epoch_uid, "element_uids": [el_a, el_b], } - resp = client.post(f"/ui/soa/{soa_id}/add_study_cell", data=form) + resp = client.post(f"/ui/soa/{soa_id}/study_cells/create", data=form) assert resp.status_code in (200, 201) - # Verify rows share the same study_cell_uid + # Verify each row has a unique study_cell_uid conn = _connect() cur = conn.cursor() cur.execute( @@ -77,14 +77,14 @@ def test_study_cell_uid_reuse_same_arm_epoch(): sc_rows = cur.fetchall() conn.close() assert len(sc_rows) >= 2 - uids = {r[0] for r in sc_rows} - assert ( - len(uids) == 1 - ), "Expected all StudyCell rows to reuse the same study_cell_uid" + uids = [r[0] for r in sc_rows] + assert len(uids) == len( + set(uids) + ), "Each study_cell row must have a unique study_cell_uid" # Idempotence check: submitting the same element again should not create a duplicate row form_dup = {"arm_uid": arm_uid, "epoch_uid": epoch_uid, "element_uids": [el_b]} - resp_dup = client.post(f"/ui/soa/{soa_id}/add_study_cell", data=form_dup) + resp_dup = client.post(f"/ui/soa/{soa_id}/study_cells/create", data=form_dup) assert resp_dup.status_code in (200, 201) conn = _connect() cur = conn.cursor() diff --git a/tests/test_study_cell_uid_reuse_later.py b/tests/test_study_cell_uid_reuse_later.py index 817740b..05ce394 100644 --- a/tests/test_study_cell_uid_reuse_later.py +++ b/tests/test_study_cell_uid_reuse_later.py @@ -5,9 +5,9 @@ client = TestClient(app) -def test_study_cell_uid_reuse_on_later_addition(): +def test_study_cell_uid_unique_on_later_addition(): # Create study - r = client.post("/soa", json={"name": "UID Reuse Later Study"}) + r = client.post("/soa", json={"name": "UID Unique Later Study"}) assert r.status_code == 200 soa_id = r.json()["id"] @@ -57,26 +57,27 @@ def test_study_cell_uid_reuse_on_later_addition(): # First submission: add Vitals+ECG form1 = {"arm_uid": arm_uid, "epoch_uid": epoch_uid, "element_uids": [el_a, el_b]} - resp1 = client.post(f"/ui/soa/{soa_id}/add_study_cell", data=form1) + resp1 = client.post(f"/ui/soa/{soa_id}/study_cells/create", data=form1) assert resp1.status_code in (200, 201) - # Capture the assigned study_cell_uid + # Capture the assigned study_cell_uids conn = _connect() cur = conn.cursor() cur.execute( - "SELECT DISTINCT study_cell_uid FROM study_cell WHERE soa_id=? AND arm_uid=? AND epoch_uid=?", + "SELECT study_cell_uid FROM study_cell WHERE soa_id=? AND arm_uid=? AND epoch_uid=? ORDER BY id", (soa_id, arm_uid, epoch_uid), ) - first_uid = cur.fetchone()[0] + first_uids = [r[0] for r in cur.fetchall()] conn.close() - assert first_uid and first_uid.startswith("StudyCell_") + assert len(first_uids) == 2 + assert first_uids[0] != first_uids[1], "Each row must have a unique study_cell_uid" - # Second submission later: add PK only, should reuse the same UID + # Second submission later: add PK only, should get a new unique UID form2 = {"arm_uid": arm_uid, "epoch_uid": epoch_uid, "element_uids": [el_c]} - resp2 = client.post(f"/ui/soa/{soa_id}/add_study_cell", data=form2) + resp2 = client.post(f"/ui/soa/{soa_id}/study_cells/create", data=form2) assert resp2.status_code in (200, 201) - # Verify all three rows share the same study_cell_uid + # Verify all three rows have unique study_cell_uids conn = _connect() cur = conn.cursor() cur.execute( @@ -86,13 +87,12 @@ def test_study_cell_uid_reuse_on_later_addition(): rows = cur.fetchall() conn.close() assert len(rows) >= 3 - uids = {r[0] for r in rows} - assert len(uids) == 1 - assert first_uid in uids + uids = [r[0] for r in rows] + assert len(uids) == len(set(uids)), "All study_cell_uids must be unique" # Idempotence check: submitting the same element again should not create a duplicate row form_dup = {"arm_uid": arm_uid, "epoch_uid": epoch_uid, "element_uids": [el_c]} - resp_dup = client.post(f"/ui/soa/{soa_id}/add_study_cell", data=form_dup) + resp_dup = client.post(f"/ui/soa/{soa_id}/study_cells/create", data=form_dup) assert resp_dup.status_code in (200, 201) conn = _connect() cur = conn.cursor() From f313fd0ecb4d4fb81e8ad9d15770732c76fa74ca Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:43:56 -0500 Subject: [PATCH 09/21] Issue #99: Added reorder functionality to study cells --- src/soa_builder/web/routers/cells.py | 92 +++++++++++++++---- .../web/templates/study_cells.html | 83 +++++++++++++++-- 2 files changed, 150 insertions(+), 25 deletions(-) diff --git a/src/soa_builder/web/routers/cells.py b/src/soa_builder/web/routers/cells.py index bbc1bb1..6d4499f 100644 --- a/src/soa_builder/web/routers/cells.py +++ b/src/soa_builder/web/routers/cells.py @@ -3,7 +3,7 @@ import os from typing import List, Optional -from fastapi import APIRouter, HTTPException, Request, Form +from fastapi import APIRouter, HTTPException, Request, Form, Body from fastapi.responses import JSONResponse, HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates @@ -19,11 +19,13 @@ ) +# Helper: Normalization def _nz(s: Optional[str]) -> Optional[str]: s = (s or "").strip() return s or None +# Helper: calculate UID def _next_study_cell_uid(cur, soa_id: int) -> str: """Compute next StudyCell_N unique within an SoA. @@ -64,9 +66,6 @@ def _next_study_cell_uid(cur, soa_id: int) -> str: return f"StudyCell_{max_n + 1}" -# ---------- API endpoints ---------- - - # API endpoint for listing study cells @router.get( "/soa/{soa_id}/study_cells", response_class=JSONResponse, response_model=None @@ -79,12 +78,12 @@ def list_study_cells(soa_id: int): cur = conn.cursor() cur.execute( """ - SELECT sc.id,sc.study_cell_uid,a.name,a.label,e.name,e.epoch_label,el.name,el.label + SELECT sc.id,sc.study_cell_uid,sc.order_index,a.name,a.label,e.name,e.epoch_label,el.name,el.label FROM study_cell sc INNER JOIN arm a ON sc.soa_id=a.soa_id AND sc.arm_uid=a.arm_uid INNER JOIN epoch e ON sc.soa_id=e.soa_id AND sc.epoch_uid=e.epoch_uid INNER JOIN element el ON sc.soa_id=el.soa_id AND sc.element_uid=el.element_id - WHERE sc.soa_id=? ORDER BY sc.study_cell_uid + WHERE sc.soa_id=? ORDER BY sc.order_index, sc.study_cell_uid """, (soa_id,), ) @@ -92,12 +91,13 @@ def list_study_cells(soa_id: int): { "study_cell_id": r[0], "study_cell_uid": r[1], - "arm_name": r[2], - "arm_label": r[3], - "epoch_name": r[4], - "epoch_label": r[5], - "element_name": r[6], - "element_label": r[7], + "order_index": r[2], + "arm_name": r[3], + "arm_label": r[4], + "epoch_name": r[5], + "epoch_label": r[6], + "element_name": r[7], + "element_label": r[8], } for r in cur.fetchall() ] @@ -151,6 +151,13 @@ def add_study_cell(soa_id: int, payload: StudyCellCreate): conn.close() raise HTTPException(404, "Element not found") + # order_index + cur.execute( + "SELECT COALESCE(MAX(order_index), 0) FROM study_cell WHERE soa_id=?", + (soa_id,), + ) + next_ord = (cur.fetchone() or [0])[0] + 1 + # Duplicate prevention cur.execute( "SELECT id FROM study_cell WHERE soa_id=? AND arm_uid=? AND epoch_uid=? AND element_uid=?", @@ -164,8 +171,8 @@ def add_study_cell(soa_id: int, payload: StudyCellCreate): sc_uid = _next_study_cell_uid(cur, soa_id) cur.execute( - "INSERT INTO study_cell (soa_id, study_cell_uid, arm_uid, epoch_uid, element_uid) VALUES (?,?,?,?,?)", - (soa_id, sc_uid, arm_uid, epoch_uid, element_uid), + "INSERT INTO study_cell (soa_id, study_cell_uid, order_index, arm_uid, epoch_uid, element_uid) VALUES (?,?,?,?,?,?)", + (soa_id, sc_uid, next_ord, arm_uid, epoch_uid, element_uid), ) sc_id = cur.lastrowid conn.commit() @@ -174,6 +181,7 @@ def add_study_cell(soa_id: int, payload: StudyCellCreate): after = { "study_cell_id": sc_id, "study_cell_uid": sc_uid, + "order_index": next_ord, "arm_uid": arm_uid, "epoch_uid": epoch_uid, "element_uid": element_uid, @@ -283,9 +291,6 @@ def delete_study_cell(soa_id: int, study_cell_id: int): return {"deleted": True, "id": study_cell_id} -# ---------- UI endpoints ---------- - - # UI code for listing study cells @router.get("/ui/soa/{soa_id}/study_cells", response_class=HTMLResponse) def ui_list_study_cells(request: Request, soa_id: int): @@ -303,7 +308,7 @@ def ui_list_study_cells(request: Request, soa_id: int): "LEFT JOIN element e ON e.element_id = sc.element_uid AND e.soa_id = sc.soa_id " "LEFT JOIN arm a ON a.arm_uid = sc.arm_uid AND a.soa_id = sc.soa_id " "LEFT JOIN epoch ep ON ep.epoch_uid = sc.epoch_uid AND ep.soa_id = sc.soa_id " - "WHERE sc.soa_id=? ORDER BY sc.id", + "WHERE sc.soa_id=? ORDER BY sc.order_index, sc.id", (soa_id,), ) study_cells = [ @@ -428,3 +433,54 @@ def ui_update_study_cell( def ui_delete_study_cell(request: Request, soa_id: int, study_cell_id: int): delete_study_cell(soa_id, study_cell_id) return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/study_cells", status_code=303) + + +# API endpoint for reorder +@router.post("/soa/{soa_id}/study_cells/reorder", response_class=JSONResponse) +def reorder_study_cells_api( + soa_id: int, + order: List[int] = Body(..., embed=True), +): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + if not order: + raise HTTPException(400, "Order list required") + + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id,study_cell_uid FROM study_cell WHERE soa_id=? ORDER BY order_index", + (soa_id,), + ) + rows = cur.fetchall() + old_order = [r[0] for r in rows] # for API response + id_to_uid = {r[0]: r[1] for r in rows} + old_order_uids = [r[1] for r in rows] # for audit + + cur.execute( + "SELECT id,study_cell_uid FROM study_cell WHERE soa_id=?", + (soa_id,), + ) + existing = {r[0] for r in rows} + if set(order) - existing: + conn.close() + raise HTTPException(400, "order contains invalid study_cell id") + + for idx, scid in enumerate(order, start=1): + cur.execute("UPDATE study_cell SET order_index=? WHERE id=?", (idx, scid)) + conn.commit() + conn.close() + + new_order_uids = [id_to_uid.get(scid, str(scid)) for scid in order] + + _record_study_cell_audit( + soa_id, + "reorder", + study_cell_id=None, + before={ + "old_order": old_order_uids, + }, + after={"new_order": new_order_uids}, + ) + return JSONResponse({"ok": True, "old_order": old_order, "new_order": order}) diff --git a/src/soa_builder/web/templates/study_cells.html b/src/soa_builder/web/templates/study_cells.html index 61b6b29..d28e9c6 100644 --- a/src/soa_builder/web/templates/study_cells.html +++ b/src/soa_builder/web/templates/study_cells.html @@ -48,23 +48,92 @@

Study Cells for Study: {% if study_label %}{{ study_label }}{% else %}{{ stu Arm Epoch Element - Actions + Delete + + + {% for sc in study_cells %} - - {{ sc.study_cell_uid }} - {{ sc.arm_name or sc.arm_uid }} - {{ sc.epoch_name or sc.epoch_uid }} - {{ sc.element_name or sc.element_uid }} - + +
+ {{ sc.study_cell_uid }} + {{ sc.arm_name or sc.arm_uid }} + {{ sc.epoch_name or sc.epoch_uid }} + {{ sc.element_name or sc.element_uid }} + +
+ + + + {% else %} No study cells yet. {% endfor %} + {% endblock %} From 02b808dc77bd37506bc2ba40fa0cbb28a1108c69 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:55:39 -0500 Subject: [PATCH 10/21] Added xlrd --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 642a2b5..fe93688 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,7 @@ idna==3.11 Jinja2==3.1.6 openpyxl==3.1.5 pandas==3.0.0 +xlrd==2.0.1 pydantic==2.12.5 pydantic_core==2.41.5 python-dateutil==2.9.0.post0 From 85499a6945e674552f4d7ee2c37615e96931d1f1 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:57:54 -0500 Subject: [PATCH 11/21] Update src/soa_builder/web/templates/study_cells.html Fix the action path and restructure/remove this form Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/soa_builder/web/templates/study_cells.html | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/soa_builder/web/templates/study_cells.html b/src/soa_builder/web/templates/study_cells.html index d28e9c6..1d687ae 100644 --- a/src/soa_builder/web/templates/study_cells.html +++ b/src/soa_builder/web/templates/study_cells.html @@ -60,13 +60,11 @@

Study Cells for Study: {% if study_label %}{{ study_label }}{% else %}{{ stu {% for sc in study_cells %} -
- {{ sc.study_cell_uid }} - {{ sc.arm_name or sc.arm_uid }} - {{ sc.epoch_name or sc.epoch_uid }} - {{ sc.element_name or sc.element_uid }} - -
+ {{ sc.study_cell_uid }} + {{ sc.arm_name or sc.arm_uid }} + {{ sc.epoch_name or sc.epoch_uid }} + {{ sc.element_name or sc.element_uid }} +
From e046366472b5f32be0c626a8d87b5489dc991f93 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:59:17 -0500 Subject: [PATCH 12/21] Update src/soa_builder/web/templates/encounters.html disabling/hiding the button when there are 0 (or <2) encounters Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/soa_builder/web/templates/encounters.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/soa_builder/web/templates/encounters.html b/src/soa_builder/web/templates/encounters.html index 199fa6c..2907b59 100644 --- a/src/soa_builder/web/templates/encounters.html +++ b/src/soa_builder/web/templates/encounters.html @@ -105,12 +105,14 @@

Encounters for Study: {% if study_label %}{{ study_label }}{% else %}{{ stud Save Delete + {% if encounters|length > 1 %} + {% endif %} {% for e in encounters %} From bf89e431f8a2c83df45068e2f7f36527d3221f9b Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Tue, 17 Feb 2026 15:00:00 -0500 Subject: [PATCH 13/21] Update src/soa_builder/web/templates/study_cells.html table has 6 columns Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/soa_builder/web/templates/study_cells.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/soa_builder/web/templates/study_cells.html b/src/soa_builder/web/templates/study_cells.html index 1d687ae..2469509 100644 --- a/src/soa_builder/web/templates/study_cells.html +++ b/src/soa_builder/web/templates/study_cells.html @@ -75,7 +75,7 @@

Study Cells for Study: {% if study_label %}{{ study_label }}{% else %}{{ stu {% else %} - No study cells yet. + No study cells yet. {% endfor %}