Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
980bafc
Initial commit of UAT testing document
pendingintent Dec 9, 2025
e5de392
Create study_cell table
pendingintent Dec 9, 2025
3889bf6
vist.raw_header now the Encounter cell title with visit.name set to E…
pendingintent Dec 9, 2025
f6945f1
Merge branch 'ui-edit-update' into add-study-cell
pendingintent Dec 9, 2025
a3426ea
Removed PDF export button from test
pendingintent Dec 9, 2025
b99a141
Present epoch and arm human-friendly names in Study Cells on edit.html
pendingintent Dec 9, 2025
787343b
Persist collapsible state of study-cells-section
pendingintent Dec 9, 2025
e0e9c22
Added study_cell audits
pendingintent Dec 10, 2025
b5bf83a
Aligned terminology HTML
pendingintent Dec 10, 2025
302cdc9
Aligned terminology HTML
pendingintent Dec 10, 2025
c9a6484
Added transition rule create,update,delete endpoints and audits
pendingintent Dec 10, 2025
a0c9bab
Transition rule endpoints fixed; HTMX added
pendingintent Dec 11, 2025
dcd0b7b
Fixed element audits, added element audit viewer
pendingintent Dec 11, 2025
01c4e6b
Update src/soa_builder/web/initialize_database.py
pendingintent Dec 11, 2025
b16579f
Removed duplicate helpers and moved to utils
pendingintent Dec 11, 2025
550defb
Update src/soa_builder/web/initialize_database.py
pendingintent Dec 11, 2025
85d0142
Update src/soa_builder/web/templates/edit.html
pendingintent Dec 11, 2025
ea86359
Update src/soa_builder/web/templates/protocol_terminology.html
pendingintent Dec 11, 2025
4a556d0
Update src/soa_builder/web/templates/edit.html
pendingintent Dec 11, 2025
ef00f51
Removed the unrelated Element Audit HTMX block from ui_refresh_concep…
pendingintent Dec 11, 2025
4eb380a
Removed the Element Audit HTMX block from ui_add_activity in app.py
pendingintent Dec 11, 2025
3a2d074
emoved the unrelated Element Audit HTMX block from ui_update_meta in …
pendingintent Dec 11, 2025
ef60569
Update src/soa_builder/web/templates/edit.html
pendingintent Dec 11, 2025
1efc03c
Removed duplicated code block for fetching element audits
pendingintent Dec 11, 2025
9b00087
Merge branch 'add-study-cell' of https://github.com/pendingintent/soa…
pendingintent Dec 11, 2025
630c91b
Update src/soa_builder/web/templates/edit.html
pendingintent Dec 11, 2025
95410b8
Update src/soa_builder/web/app.py
pendingintent Dec 11, 2025
bf94532
simplify the element delete query to avoid selecting NULL as element_…
pendingintent Dec 11, 2025
d0ed1c1
replaced all _soa_exists usages in app.py with the shared soa_exists …
pendingintent Dec 11, 2025
39a0618
Update src/soa_builder/web/templates/protocol_terminology.html
pendingintent Dec 11, 2025
2248c2b
Update src/soa_builder/web/templates/ddf_terminology.html
pendingintent Dec 11, 2025
0a45514
Update src/soa_builder/web/routers/elements.py
pendingintent Dec 11, 2025
59f8d1f
Added uniform logging to database-facing helpers and validated
pendingintent Dec 11, 2025
dd50c29
Standardized loggers, prevent exception swallowing
pendingintent Dec 11, 2025
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
Binary file added docs/SoA-Workbench-UAT-Testing.docx
Binary file not shown.
853 changes: 781 additions & 72 deletions src/soa_builder/web/app.py

Large diffs are not rendered by default.

51 changes: 51 additions & 0 deletions src/soa_builder/web/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@ def _record_element_audit(
try:
conn = _connect()
cur = conn.cursor()
# Ensure table exists (defensive for migrated databases)
cur.execute(
"""CREATE TABLE IF NOT EXISTS element_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
soa_id INTEGER NOT NULL,
element_id INTEGER,
action TEXT NOT NULL,
before_json TEXT,
after_json TEXT,
performed_at TEXT NOT NULL
)"""
)
cur.execute(
"INSERT INTO element_audit (soa_id, element_id, action, before_json, after_json, performed_at) VALUES (?,?,?,?,?,?)",
(
Expand Down Expand Up @@ -139,3 +151,42 @@ def _record_activity_audit(
conn.close()
except Exception as e:
logger.warning("Failed recording activity audit: %s", e)


def _record_study_cell_audit(
soa_id: int,
action: str,
study_cell_id: int | None,
before: Optional[Dict[str, Any]] = None,
after: Optional[Dict[str, Any]] = None,
):
try:
conn = _connect()
cur = conn.cursor()
# Ensure table exists (defensive for migrated databases)
cur.execute(
"""CREATE TABLE IF NOT EXISTS study_cell_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
soa_id INTEGER NOT NULL,
study_cell_id INTEGER,
action TEXT NOT NULL,
before_json TEXT,
after_json TEXT,
performed_at TEXT NOT NULL
)"""
)
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,
action,
json.dumps(before) if before else None,
json.dumps(after) if after else None,
datetime.now(timezone.utc).isoformat(),
),
)
conn.commit()
conn.close()
except Exception as e:
logger.warning("Failed recording study_cell audit: %s", e)
52 changes: 52 additions & 0 deletions src/soa_builder/web/initialize_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,30 @@ def _init_db():
performed_at TEXT NOT NULL
)"""
)
# Study Cell audit table
cur.execute(
"""CREATE TABLE IF NOT EXISTS study_cell_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
soa_id INTEGER NOT NULL,
study_cell_id INTEGER,
action TEXT NOT NULL, -- create|update|delete
before_json TEXT,
after_json TEXT,
performed_at TEXT NOT NULL
)"""
)
# Transition rule audit table
cur.execute(
"""CREATE TABLE IF NOT EXISTS transition_rule_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
soa_id INTEGER NOT NULL,
transition_rule_id INTEGER,
action TEXT NOT NULL, -- create|update|delete
before_json TEXT,
after_json TEXT,
performed_at TEXT NOT NULL
)"""
)
# Epochs: high-level study phase grouping (optional). Behaves like visits/activities list ordering.
cur.execute(
"""CREATE TABLE IF NOT EXISTS epoch (id INTEGER PRIMARY KEY AUTOINCREMENT, soa_id INTEGER, name TEXT, order_index INTEGER)"""
Expand Down Expand Up @@ -160,5 +184,33 @@ def _init_db():
)"""
)

# create the study_cell table to store the relationship between Epoch, Arm and related elements
cur.execute(
"""CREATE TABLE IF NOT EXISTS study_cell (
id INTEGER PRIMARY KEY AUTOINCREMENT,
soa_id INTEGER NOT NULL,
study_cell_uid TEXT NOT NULL, --immutable StudyCell_N identifier unique within SOA
arm_uid TEXT NOT NULL,
epoch_uid TEXT NOT NULL,
element_uid TEXT NOT NULL
)"""
)

# create the transition_rule table to store the transition rules for elements, encounters
cur.execute(
"""CREATE TABLE IF NOT EXISTS transition_rule (
id INTEGER PRIMARY KEY AUTOINCREMENT,
soa_id INTEGER NOT NULL,
transition_rule_uid TEXT NOT NULL, --immutable TransitionRule_N identifier unique within SOA
name TEXT NOT NULL,
label TEXT,
description TEXT,
text TEXT NOT NULL,
order_index INTEGER,
created_at TEXT,
UNIQUE(soa_id, transition_rule_uid)
)"""
)

conn.commit()
conn.close()
56 changes: 56 additions & 0 deletions src/soa_builder/web/migrate_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,62 @@ def _migrate_arm_add_type_fields():
logger.warning("Arm type/data_origin_type migration failed: %s", e)


# Migration: Ensure element_audit has before_json/after_json columns
def _migrate_element_audit_columns():
"""Add missing columns before_json and after_json to element_audit.

Handles legacy schemas that only had id, soa_id, element_id, action, performed_at.
Safe to run multiple times; idempotent via schema inspection.
"""
try:
conn = _connect()
cur = conn.cursor()
# Ensure table exists; if not present, create with full schema
cur.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='element_audit'"
)
exists = cur.fetchone() is not None
if not exists:
cur.execute(
"""CREATE TABLE IF NOT EXISTS element_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
soa_id INTEGER NOT NULL,
element_id INTEGER,
action TEXT NOT NULL,
before_json TEXT,
after_json TEXT,
performed_at TEXT NOT NULL
)"""
)
conn.commit()
logger.info("Created element_audit table with full schema")
conn.close()
return
# Add columns if missing
cur.execute("PRAGMA table_info(element_audit)")
cols = {r[1] for r in cur.fetchall()}
alters = []
if "before_json" not in cols:
alters.append("ALTER TABLE element_audit ADD COLUMN before_json TEXT")
if "after_json" not in cols:
alters.append("ALTER TABLE element_audit ADD COLUMN after_json TEXT")
for stmt in alters:
try:
cur.execute(stmt)
except Exception as e: # pragma: no cover
logger.warning(
"Failed element_audit column migration '%s': %s", stmt, e
)
if alters:
conn.commit()
logger.info(
"Applied element_audit column migrations: %s", ", ".join(alters)
)
conn.close()
except Exception as e: # pragma: no cover
logger.warning("element_audit column migration failed: %s", e)


# Backfill dataset_date for existing terminology tables
def _backfill_dataset_date(table: str, audit_table: str):
"""If terminology table exists and has dataset_date (or sheet_dataset_date) column with blank values,
Expand Down
33 changes: 15 additions & 18 deletions src/soa_builder/web/routers/activities.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import logging

# Lightweight concept fetcher to avoid circular import with app.py
import os
Expand All @@ -11,10 +12,14 @@
from ..audit import _record_activity_audit, _record_reorder_audit
from ..db import _connect
from ..schemas import ActivityCreate, ActivityUpdate, BulkActivities
from ..utils import soa_exists

_ACT_CONCEPT_CACHE = {"data": None, "fetched_at": 0}
_ACT_CONCEPT_TTL = 60 * 60

router = APIRouter(prefix="/soa/{soa_id}")
logger = logging.getLogger("soa_builder.web.routers.activities")


def fetch_biomedical_concepts(force: bool = False):
override_json = os.environ.get("CDISC_CONCEPTS_JSON")
Expand Down Expand Up @@ -53,7 +58,8 @@ def fetch_biomedical_concepts(force: bool = False):
if code:
concepts.append({"code": code, "title": title})
return concepts
except Exception:
except Exception as e:
logger.debug("fetch_biomedical_concepts override JSON parse failed: %s", e)
return []
now = time.time()
if (
Expand All @@ -68,21 +74,12 @@ def fetch_biomedical_concepts(force: bool = False):
return []


router = APIRouter(prefix="/soa/{soa_id}")


def _soa_exists(soa_id: int) -> bool:
conn = _connect()
cur = conn.cursor()
cur.execute("SELECT 1 FROM soa WHERE id=?", (soa_id,))
ok = cur.fetchone() is not None
conn.close()
return ok
# Removed local _soa_exists; using shared utils.soa_exists


@router.get("/activities", response_class=JSONResponse)
def list_activities(soa_id: int):
if not _soa_exists(soa_id):
if not soa_exists(soa_id):
raise HTTPException(404, "SOA not found")
conn = _connect()
cur = conn.cursor()
Expand All @@ -100,7 +97,7 @@ def list_activities(soa_id: int):

@router.get("/activities/{activity_id}", response_class=JSONResponse)
def get_activity(soa_id: int, activity_id: int):
if not _soa_exists(soa_id):
if not soa_exists(soa_id):
raise HTTPException(404, "SOA not found")
conn = _connect()
cur = conn.cursor()
Expand All @@ -123,7 +120,7 @@ def get_activity(soa_id: int, activity_id: int):

@router.post("/activities", response_class=JSONResponse)
def add_activity(soa_id: int, payload: ActivityCreate):
if not _soa_exists(soa_id):
if not soa_exists(soa_id):
raise HTTPException(404, "SOA not found")
conn = _connect()
cur = conn.cursor()
Expand Down Expand Up @@ -152,7 +149,7 @@ def add_activity(soa_id: int, payload: ActivityCreate):

@router.patch("/activities/{activity_id}", response_class=JSONResponse)
def update_activity(soa_id: int, activity_id: int, payload: ActivityUpdate):
if not _soa_exists(soa_id):
if not soa_exists(soa_id):
raise HTTPException(404, "SOA not found")
conn = _connect()
cur = conn.cursor()
Expand Down Expand Up @@ -196,7 +193,7 @@ def update_activity(soa_id: int, activity_id: int, payload: ActivityUpdate):

@router.post("/activities/reorder", response_class=JSONResponse)
def reorder_activities_api(soa_id: int, order: List[int]):
if not _soa_exists(soa_id):
if not soa_exists(soa_id):
raise HTTPException(404, "SOA not found")
if not order:
raise HTTPException(400, "Order list required")
Expand Down Expand Up @@ -256,7 +253,7 @@ def reorder_activities_api(soa_id: int, order: List[int]):

@router.post("/activities/bulk", response_class=JSONResponse)
def add_activities_bulk(soa_id: int, payload: BulkActivities):
if not _soa_exists(soa_id):
if not soa_exists(soa_id):
raise HTTPException(404, "SOA not found")
names = [n.strip() for n in payload.names if n and n.strip()]
if not names:
Expand Down Expand Up @@ -293,7 +290,7 @@ def add_activities_bulk(soa_id: int, payload: BulkActivities):

@router.post("/activities/{activity_id}/concepts", response_class=JSONResponse)
def set_activity_concepts(soa_id: int, activity_id: int, concept_codes: List[str]):
if not _soa_exists(soa_id):
if not soa_exists(soa_id):
raise HTTPException(404, "SOA not found")
conn = _connect()
cur = conn.cursor()
Expand Down
22 changes: 9 additions & 13 deletions src/soa_builder/web/routers/arms.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,18 @@
from ..audit import _record_arm_audit, _record_reorder_audit
from ..db import _connect
from ..schemas import ArmCreate, ArmUpdate
from ..utils import soa_exists

router = APIRouter(prefix="/soa/{soa_id}")
logger = logging.getLogger("soa_builder.web.routers.arms")


def _soa_exists(soa_id: int) -> bool:
conn = _connect()
cur = conn.cursor()
cur.execute("SELECT 1 FROM soa WHERE id=?", (soa_id,))
ok = cur.fetchone() is not None
conn.close()
return ok
# Removed local _soa_exists; using shared utils.soa_exists


@router.get("/arms", response_class=JSONResponse)
def list_arms(soa_id: int):
if not _soa_exists(soa_id):
if not soa_exists(soa_id):
raise HTTPException(404, "SOA not found")
conn = _connect()
cur = conn.cursor()
Expand All @@ -49,7 +45,7 @@ def list_arms(soa_id: int):

@router.post("/arms", response_class=JSONResponse, status_code=201)
def create_arm(soa_id: int, payload: ArmCreate):
if not _soa_exists(soa_id):
if not soa_exists(soa_id):
raise HTTPException(404, "SOA not found")
name = (payload.name or "").strip()
if not name:
Expand All @@ -72,7 +68,7 @@ def create_arm(soa_id: int, payload: ArmCreate):
if tail.isdigit():
used_nums.add(int(tail))
else:
logging.getLogger("soa_builder.concepts").warning(
logger.warning(
"Invalid arm_uid format encountered (ignored for numbering): %s",
uid,
)
Expand Down Expand Up @@ -113,7 +109,7 @@ def create_arm(soa_id: int, payload: ArmCreate):

@router.patch("/arms/{arm_id}", response_class=JSONResponse)
def update_arm(soa_id: int, arm_id: int, payload: ArmUpdate):
if not _soa_exists(soa_id):
if not soa_exists(soa_id):
raise HTTPException(404, "SOA not found")
conn = _connect()
cur = conn.cursor()
Expand Down Expand Up @@ -190,7 +186,7 @@ def update_arm(soa_id: int, arm_id: int, payload: ArmUpdate):

@router.delete("/arms/{arm_id}", response_class=JSONResponse)
def delete_arm(soa_id: int, arm_id: int):
if not _soa_exists(soa_id):
if not soa_exists(soa_id):
raise HTTPException(404, "SOA not found")
conn = _connect()
cur = conn.cursor()
Expand Down Expand Up @@ -221,7 +217,7 @@ def delete_arm(soa_id: int, arm_id: int):

@router.post("/arms/reorder", response_class=JSONResponse)
def reorder_arms_api(soa_id: int, order: List[int]):
if not _soa_exists(soa_id):
if not soa_exists(soa_id):
raise HTTPException(404, "SOA not found")
if not order:
raise HTTPException(400, "Order list required")
Expand Down
Loading