Skip to content
Merged
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -95,5 +95,6 @@ old-tests/
*.db-corrupt
docs/~*
files/~*
output/*

# End of file
435 changes: 380 additions & 55 deletions src/soa_builder/web/app.py

Large diffs are not rendered by default.

57 changes: 50 additions & 7 deletions src/soa_builder/web/initialize_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,31 @@ def _init_db():
conn = _connect()
cur = conn.cursor()
cur.execute(
"""CREATE TABLE IF NOT EXISTS soa (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, created_at TEXT)"""
"""CREATE TABLE IF NOT EXISTS soa (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
created_at TEXT
)"""
)
cur.execute(
"""CREATE TABLE IF NOT EXISTS visit (id INTEGER PRIMARY KEY AUTOINCREMENT, soa_id INTEGER, name TEXT, raw_header TEXT, order_index INTEGER)"""
"""CREATE TABLE IF NOT EXISTS visit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
soa_id INTEGER,
name TEXT,
raw_header TEXT,
order_index INTEGER
)"""
)
cur.execute(
"""CREATE TABLE IF NOT EXISTS activity (id INTEGER PRIMARY KEY AUTOINCREMENT, soa_id INTEGER, name TEXT, order_index INTEGER, activity_uid TEXT)"""
"""CREATE TABLE IF NOT EXISTS activity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
soa_id INTEGER,
name TEXT,
order_index INTEGER,
activity_uid TEXT, -- immutable Activity_N identifier unique within an SOA
label TEXT,
description TEXT
)"""
)
# Arms: groupings similar to Visits. (Legacy element linkage removed; schema now only stores intrinsic fields.)
cur.execute(
Expand Down Expand Up @@ -127,19 +145,44 @@ def _init_db():
)
# 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)"""
"""CREATE TABLE IF NOT EXISTS epoch (
id INTEGER PRIMARY KEY AUTOINCREMENT,
soa_id INTEGER,
name TEXT,
order_index INTEGER
)"""
)
# Matrix cells table (renamed from legacy 'cell')
cur.execute(
"""CREATE TABLE IF NOT EXISTS matrix_cells (id INTEGER PRIMARY KEY AUTOINCREMENT, soa_id INTEGER, visit_id INTEGER, activity_id INTEGER, status TEXT)"""
"""CREATE TABLE IF NOT EXISTS matrix_cells (
id INTEGER PRIMARY KEY AUTOINCREMENT,
soa_id INTEGER,
visit_id INTEGER,
activity_id INTEGER,
status TEXT
)"""
)
# Mapping table linking activities to biomedical concepts (concept_code + title stored for snapshot purposes)
cur.execute(
"""CREATE TABLE IF NOT EXISTS activity_concept (id INTEGER PRIMARY KEY AUTOINCREMENT, activity_id INTEGER, concept_code TEXT, concept_title TEXT)"""
"""CREATE TABLE IF NOT EXISTS activity_concept (
id INTEGER PRIMARY KEY AUTOINCREMENT,
activity_id INTEGER,
concept_code TEXT,
concept_title TEXT,
concept_uid TEXT, -- immutable BiomedicalConcept_N identifier unique within an SOA
activity_uid TEXT, -- joins to the activity table using this uid unique within an SOA
soa_id INT
)"""
)
# Frozen versions (snapshot JSON of current matrix & concepts)
cur.execute(
"""CREATE TABLE IF NOT EXISTS soa_freeze (id INTEGER PRIMARY KEY AUTOINCREMENT, soa_id INTEGER, version_label TEXT, created_at TEXT, snapshot_json TEXT)"""
"""CREATE TABLE IF NOT EXISTS soa_freeze (
id INTEGER PRIMARY KEY AUTOINCREMENT,
soa_id INTEGER,
version_label TEXT,
created_at TEXT,
snapshot_json TEXT
)"""
)
# Unique index to enforce one label per SoA
cur.execute(
Expand Down
82 changes: 82 additions & 0 deletions src/soa_builder/web/migrate_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,88 @@ def _migrate_add_epoch_type():
logger.warning("Epoch type migration failed: %s", e)


# Migration: add epoch_uid to epoch
def _migrate_add_epoch_uid():
"""Ensure epoch_uid column exists and is populated as StudyEpoch_<n> unique per SoA.
Uses epoch_seq when available to keep numbering stable; otherwise falls back to id order.
Creates unique index (soa_id, epoch_uid).
"""
try:
conn = _connect()
cur = conn.cursor()
cur.execute("PRAGMA table_info(epoch)")
cols = {r[1] for r in cur.fetchall()}
if "epoch_uid" not in cols:
cur.execute("ALTER TABLE epoch ADD COLUMN epoch_uid TEXT")
conn.commit()
logger.info("Added epoch_uid column to epoch table")
# Backfill any NULL epoch_uid values
cur.execute("SELECT DISTINCT soa_id FROM epoch")
soa_ids = [r[0] for r in cur.fetchall()]
for sid in soa_ids:
# Prefer ordering by epoch_seq if present to make UIDs deterministic
order_col = "epoch_seq" if "epoch_seq" in cols else "id"
cur.execute(
f"SELECT id, COALESCE(epoch_seq, 0) FROM epoch WHERE soa_id=? AND epoch_uid IS NULL ORDER BY {order_col}",
(sid,),
)
rows = cur.fetchall()
if not rows:
continue
# Determine used numbers to avoid collisions when partially populated
cur.execute(
"SELECT epoch_uid FROM epoch WHERE soa_id=? AND epoch_uid IS NOT NULL",
(sid,),
)
used_nums = set()
for (uid,) in cur.fetchall():
if isinstance(uid, str) and uid.startswith("StudyEpoch_"):
try:
used_nums.add(int(uid.split("StudyEpoch_")[-1]))
except Exception:
pass
for eid, seq in rows:
n = int(seq) if int(seq) > 0 and int(seq) not in used_nums else None
if n is None:
# pick next available number
n = 1
while n in used_nums:
n += 1
uid = f"StudyEpoch_{n}"
used_nums.add(n)
cur.execute("UPDATE epoch SET epoch_uid=? WHERE id=?", (uid, eid))
# Create unique index
try:
cur.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_epoch_soaid_uid ON epoch(soa_id, epoch_uid)"
)
conn.commit()
except Exception:
pass
# Create trigger to auto-fill epoch_uid on insert when NULL
try:
cur.execute(
"""
CREATE TRIGGER IF NOT EXISTS tr_epoch_uid_autofill
AFTER INSERT ON epoch
FOR EACH ROW
WHEN NEW.epoch_uid IS NULL
BEGIN
UPDATE epoch
SET epoch_uid = 'StudyEpoch_' || COALESCE(NEW.epoch_seq, NEW.id)
WHERE id = NEW.id;
END;
"""
)
conn.commit()
except Exception:
pass
conn.commit()
conn.close()
except Exception as e:
logger.warning("epoch_uid migration failed: %s", e)


# Migration: create code_junction table
def _migrate_create_code_junction():
"""Create code_junction linking table if absent.
Expand Down
Loading
Loading