diff --git a/requirements.txt b/requirements.txt
index 1f82b74..fe93688 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -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
diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py
index 56bf8a1..28c1077 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(
@@ -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)
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."""
@@ -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
@@ -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.
@@ -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,),
)
@@ -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."""
@@ -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(...)):
@@ -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,),
)
@@ -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"",
- 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/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)
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/cells.py b/src/soa_builder/web/routers/cells.py
new file mode 100644
index 0000000..7758dae
--- /dev/null
+++ b/src/soa_builder/web/routers/cells.py
@@ -0,0 +1,481 @@
+import json
+import logging
+import os
+from typing import List, Optional
+
+from fastapi import APIRouter, HTTPException, Request, Form, Body
+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")
+)
+
+
+# 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.
+
+ 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 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,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.order_index, sc.study_cell_uid
+ """,
+ (soa_id,),
+ )
+ rows = [
+ {
+ "study_cell_id": r[0],
+ "study_cell_uid": r[1],
+ "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()
+ ]
+ 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")
+
+ # 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=?",
+ (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, 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()
+ conn.close()
+
+ 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,
+ }
+ _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 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.order_index, 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)
+
+
+# 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
+ 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/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..7d24c82 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,
},
)
@@ -606,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,
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..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
@@ -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,
},
)
@@ -658,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:
@@ -677,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/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/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 %}
SoA Workbench
diff --git a/src/soa_builder/web/templates/edit.html b/src/soa_builder/web/templates/edit.html
index 373e648..422f8cb 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 %}
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 %}