Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2889f92
Removed 8 unused dependencies
pendingintent Feb 16, 2026
c5e30e2
Added help page for creation of SOA Matrix
pendingintent Feb 16, 2026
50713b3
Issue #100: Added study label to header of UI pages
pendingintent Feb 16, 2026
739a20a
order_index is used to display the list of instances/columns
pendingintent Feb 16, 2026
c84acfc
Issue #93: Added reordering of encounters
pendingintent Feb 16, 2026
bb17406
Reorder API endpoint is no longer deprecated
pendingintent Feb 17, 2026
0b55d3b
Issue 99: Removed study cell endpoints
pendingintent Feb 17, 2026
6ed8fbf
Issue 99: Removed study cell endpoints and moved to dedicated PY file…
pendingintent Feb 17, 2026
f313fd0
Issue #99: Added reorder functionality to study cells
pendingintent Feb 17, 2026
02b808d
Added xlrd
pendingintent Feb 17, 2026
85499a6
Update src/soa_builder/web/templates/study_cells.html
pendingintent Feb 17, 2026
e046366
Update src/soa_builder/web/templates/encounters.html
pendingintent Feb 17, 2026
bf89e43
Update src/soa_builder/web/templates/study_cells.html
pendingintent Feb 17, 2026
985e844
Update src/soa_builder/web/routers/cells.py
pendingintent Feb 17, 2026
ba4d42f
Update src/soa_builder/web/app.py
pendingintent Feb 17, 2026
164ec23
Update src/soa_builder/web/templates/help.html
pendingintent Feb 17, 2026
14f9ee4
Update src/soa_builder/web/templates/help.html
pendingintent Feb 17, 2026
7ec0a52
Update src/soa_builder/web/templates/help.html
pendingintent Feb 17, 2026
d6100d9
Update src/soa_builder/web/templates/help.html
pendingintent Feb 17, 2026
91b7e01
Removed visits/reorder endpoint
pendingintent Feb 17, 2026
59e9769
Pass an explicit context (at least an empty dict) so the help page re…
pendingintent Feb 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 1 addition & 9 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,38 +1,30 @@
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
xlrd==2.0.1
pydantic==2.12.5
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
271 changes: 22 additions & 249 deletions src/soa_builder/web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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


Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -247,7 +251,8 @@ def _record_activity_audit(
logger.warning("Failed recording activity audit: %s", e)


# API functions for reordering Encounters/Visits
# API functions for reordering Encounters/Visits <- Deprecated; now included in routers/visits.py
'''
@app.post("/soa/{soa_id}/visits/reorder", response_class=JSONResponse)
Comment on lines +254 to 256
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

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

Deprecated code is being "commented out" via a standalone triple-quoted string. This leaves a pointless string literal statement in the module body and makes it easy to accidentally re-enable/merge conflicts. Prefer removing the dead endpoint entirely, or guard it with an explicit if False: block and a clear comment.

Copilot uses AI. Check for mistakes.
def reorder_visits_api(soa_id: int, order: List[int]):
"""JSON reorder endpoint for visits (parity with elements). Body is array of visit IDs in desired order."""
Expand All @@ -270,6 +275,7 @@ def reorder_visits_api(soa_id: int, order: List[int]):
conn.close()
_record_reorder_audit(soa_id, "visit", old_order, order)
return JSONResponse({"ok": True, "old_order": old_order, "new_order": order})
'''


# API functions for reordering Activities
Expand Down Expand Up @@ -1081,6 +1087,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.

Expand Down Expand Up @@ -1978,7 +1985,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,),
)
Expand Down Expand Up @@ -3325,6 +3332,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."""
Expand Down Expand Up @@ -3354,6 +3362,17 @@ 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(...)):
Expand Down Expand Up @@ -3764,7 +3783,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,),
)
Expand Down Expand Up @@ -4603,252 +4622,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"<script>alert('Arm, Epoch and at least one Element are required');window.location='/ui/soa/{int(soa_id)}/edit';</script>",
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"<script>alert('Arm not found');window.location='/ui/soa/{int(soa_id)}/edit';</script>",
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"<script>alert('Epoch not found');window.location='/ui/soa/{int(soa_id)}/edit';</script>",
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"<script>window.location='/ui/soa/{int(soa_id)}/edit';</script>"
)


# 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"<script>alert('Duplicate Study Cell exists');window.location='/ui/soa/{int(soa_id)}/edit';</script>",
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"<script>window.location='/ui/soa/{int(soa_id)}/edit';</script>"
)


# 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"<script>window.location='/ui/soa/{int(soa_id)}/edit';</script>"
)


# 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.
Expand Down
Loading