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
-
+
| id |
+ Order |
Name |
Label |
Description |
@@ -103,11 +104,20 @@ Encounters for Study: {% if study_label %}{{ study_label }}{% else %}{{ stud
| Transition End Rule |
Save |
Delete |
+
+
+ |
{% for e in encounters %}
-
+
+ |
+
+
+ |
{% else %}
- | 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 }})
-
-
-
-
- | UID |
- Arm |
- Epoch |
- Element |
- Actions |
-
- {% for sc in study_cells %}
-
- | {{ 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 }} |
-
-
- |
-
- {% else %}
- | No study cells yet. |
- {% endfor %}
-
-
-
+