From c8593785dfb85deb15489e8309fe887b834b75f4 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Wed, 14 Jan 2026 12:48:13 -0500 Subject: [PATCH 1/7] Added separate template for create/update/delete/reorder of Epochs --- src/soa_builder/web/app.py | 145 +++--- src/soa_builder/web/audit.py | 2 +- src/soa_builder/web/routers/epochs.py | 510 ++++++++++++++++++---- src/soa_builder/web/routers/visits.py | 24 +- src/soa_builder/web/schemas.py | 2 + src/soa_builder/web/templates/base.html | 1 + src/soa_builder/web/templates/edit.html | 44 -- src/soa_builder/web/templates/epochs.html | 147 +++++++ tests/test_epoch_reorder_audit_api.py | 49 ++- tests/test_epoch_type_audit.py | 118 ----- 10 files changed, 690 insertions(+), 352 deletions(-) create mode 100644 src/soa_builder/web/templates/epochs.html delete mode 100644 tests/test_epoch_type_audit.py diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index a21c473..d2d283a 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -285,6 +285,8 @@ def _record_activity_audit( logger.warning("Failed recording activity audit: %s", e) +# Moved to routers/epochs.py +""" def _record_epoch_audit( soa_id: int, action: str, @@ -310,6 +312,7 @@ def _record_epoch_audit( conn.close() except Exception as e: # pragma: no cover logger.warning("Failed recording epoch audit: %s", e) +""" def _record_arm_audit( @@ -339,7 +342,7 @@ def _record_arm_audit( logger.warning("Failed recording arm audit: %s", e) -# API functions for reordering Activities and Encounters/Visits +# API functions for reordering Encounters/Visits @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.""" @@ -364,6 +367,7 @@ def reorder_visits_api(soa_id: int, order: List[int]): return JSONResponse({"ok": True, "old_order": old_order, "new_order": order}) +# API functions for reordering Activities @app.post("/soa/{soa_id}/activities/reorder", response_class=JSONResponse) def reorder_activities_api(soa_id: int, order: List[int]): """JSON reorder endpoint for activities.""" @@ -3197,56 +3201,7 @@ def delete_activity(soa_id: int, activity_id: int): return {"deleted_activity_id": activity_id} -# API endpoint for deleting an Epoch -@app.delete("/soa/{soa_id}/epochs/{epoch_id}") -def delete_epoch(soa_id: int, epoch_id: int): - """Delete an Epoch from an SoA.""" - if not soa_exists(soa_id): - raise HTTPException(404, "SOA not found") - conn = _connect() - cur = conn.cursor() - cur.execute("SELECT 1 FROM epoch WHERE id=? AND soa_id=?", (epoch_id, soa_id)) - if not cur.fetchone(): - conn.close() - raise HTTPException(404, "Epoch not found") - cur.execute( - "SELECT id,name,order_index,epoch_seq,epoch_label,epoch_description FROM epoch WHERE id=?", - (epoch_id,), - ) - b = cur.fetchone() - before = None - if b: - before = { - "id": b[0], - "name": b[1], - "order_index": b[2], - "epoch_seq": b[3], - "epoch_label": b[4], - "epoch_description": b[5], - } - # Include current type in before snapshot - try: - cur.execute("SELECT type FROM epoch WHERE id=?", (epoch_id,)) - tr = cur.fetchone() - if before is not None: - before["type"] = tr[0] if tr else None - except Exception: - pass - # Clear visit epoch references to avoid dangling links - try: - cur.execute( - "UPDATE visit SET epoch_id=NULL WHERE soa_id=? AND epoch_id=?", - (soa_id, epoch_id), - ) - except Exception: - pass - # Delete the epoch row - cur.execute("DELETE FROM epoch WHERE id=?", (epoch_id,)) - conn.commit() - conn.close() - _reindex("epoch", soa_id) - _record_epoch_audit(soa_id, "delete", epoch_id, before=before, after=None) - return {"deleted_epoch_id": epoch_id} +# API endpoint for deleting an Epoch <- moved to routers/epoch.py @app.get("/", response_class=HTMLResponse) @@ -4036,8 +3991,8 @@ def ui_concept_detail(code: str, request: Request): ) +# UI endpoint for creating an Encounter/Visit <- moved to routers/visits.py """ -# UI endpoint for creating an Encounter/Visit @app.post("/ui/soa/{soa_id}/add_visit", response_class=HTMLResponse) def ui_add_visit( request: Request, @@ -4075,7 +4030,7 @@ def ui_add_visit( return HTMLResponse( f"" ) - """ +""" # UI endpoint for adding a new Arm @@ -5263,7 +5218,8 @@ def ui_delete_study_cell(request: Request, soa_id: int, study_cell_id: int = For ) -# UI endpoint for adding a new Epoch +# UI endpoint for adding a new Epoch <- moved to routers/epochs.py +''' @app.post("/ui/soa/{soa_id}/add_epoch", response_class=HTMLResponse) def ui_add_epoch( request: Request, @@ -5350,9 +5306,10 @@ def ui_add_epoch( return HTMLResponse( f"" ) +''' - -# UI endpoint for updating an Epoch +# UI endpoint for updating an Epoch <- moved to routers/epochs.py +''' @app.post("/ui/soa/{soa_id}/update_epoch", response_class=HTMLResponse) def ui_update_epoch( request: Request, @@ -5509,6 +5466,7 @@ def ui_update_epoch( return HTMLResponse( f"" ) +''' # Function to compute next available TransitionRule_{N} @@ -5980,8 +5938,8 @@ def ui_toggle_cell( return HTMLResponse(cell_html) +# UI code to delete an Encounter/Visit from an SOA <- moved to routers/visits.py """ -# UI code to delete an Encounter/Visit from an SOA @app.post("/ui/soa/{soa_id}/delete_visit", response_class=HTMLResponse) def ui_delete_visit(request: Request, soa_id: int, visit_id: int = Form(...)): if not soa_exists(soa_id): @@ -6002,7 +5960,8 @@ def ui_delete_visit(request: Request, soa_id: int, visit_id: int = Form(...)): """ -# UI endpoint for associating an Epoch with a Visit/Encounter +# UI endpoint for associating an Epoch with a Visit/Encounter <- Deprecated (Visits are not directly related to an Epoch) +''' @app.post("/ui/soa/{soa_id}/set_visit_epoch", response_class=HTMLResponse) def ui_set_visit_epoch( request: Request, @@ -6089,6 +6048,7 @@ def ui_set_visit_epoch( return HTMLResponse( f"" ) +''' # UI endpoint for associating a Transition Start Rule with Visit/Encounter (visit.transitionStartRule) @@ -6346,8 +6306,8 @@ def ui_set_timing( ) +# UI endpoint for updating an Encounter/Visit <- moved to routers/visits.py ''' -# UI endpoint for updating an Encounter/Visit @app.post("/ui/soa/{soa_id}/update_visit", response_class=HTMLResponse) def ui_update_visit( request: Request, @@ -6385,7 +6345,8 @@ def ui_delete_activity(request: Request, soa_id: int, activity_id: int = Form(.. ) -# UI endpoint for deleting an Epoch +# UI endpoint for deleting an Epoch <- moved to routers/epochs.py +''' @app.post("/ui/soa/{soa_id}/delete_epoch", response_class=HTMLResponse) def ui_delete_epoch(request: Request, soa_id: int, epoch_id: int = Form(...)): """Form handler to delete an Epoch.""" @@ -6393,9 +6354,10 @@ def ui_delete_epoch(request: Request, soa_id: int, epoch_id: int = Form(...)): return HTMLResponse( f"" ) +''' - -# UI endpoint for reordering Encounters/Visits +# UI endpoint for reordering Encounters/Visits <- Deprecated +''' @app.post("/ui/soa/{soa_id}/reorder_visits", response_class=HTMLResponse) def ui_reorder_visits(request: Request, soa_id: int, order: str = Form("")): """Persist new visit ordering. 'order' is a comma-separated list of visit IDs in desired order.""" @@ -6422,6 +6384,7 @@ def ui_reorder_visits(request: Request, soa_id: int, order: str = Form("")): conn.close() _record_reorder_audit(soa_id, "visit", old_order, ids) return HTMLResponse("OK") +''' # UI endpoint for reordering Activities @@ -6453,7 +6416,8 @@ def ui_reorder_activities(request: Request, soa_id: int, order: str = Form("")): return HTMLResponse("OK") -# # UI endpoint for reordering Epochs +# # UI endpoint for reordering Epochs <- moved to routers/epochs.py +''' @app.post("/ui/soa/{soa_id}/reorder_epochs", response_class=HTMLResponse) def ui_reorder_epochs(request: Request, soa_id: int, order: str = Form("")): """Form handler to persist new epoch ordering.""" @@ -6500,6 +6464,7 @@ def _epoch_types_snapshot(soa_id_int: int) -> list[dict]: after={"new_order": ids}, ) return HTMLResponse("OK") +''' # Sanitize column headers in the XLSX export @@ -7688,6 +7653,60 @@ def ui_protocol_audit( ) +# Moved to routers/epochs.py +''' +@app.delete("/soa/{soa_id}/epochs/{epoch_id}") +def delete_epoch(soa_id: int, epoch_id: int): + """Delete an Epoch from an SoA.""" + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + conn = _connect() + cur = conn.cursor() + cur.execute("SELECT 1 FROM epoch WHERE id=? AND soa_id=?", (epoch_id, soa_id)) + if not cur.fetchone(): + conn.close() + raise HTTPException(404, "Epoch not found") + cur.execute( + "SELECT id,name,order_index,epoch_seq,epoch_label,epoch_description FROM epoch WHERE id=?", + (epoch_id,), + ) + b = cur.fetchone() + before = None + if b: + before = { + "id": b[0], + "name": b[1], + "order_index": b[2], + "epoch_seq": b[3], + "epoch_label": b[4], + "epoch_description": b[5], + } + # Include current type in before snapshot + try: + cur.execute("SELECT type FROM epoch WHERE id=?", (epoch_id,)) + tr = cur.fetchone() + if before is not None: + before["type"] = tr[0] if tr else None + except Exception: + pass + # Clear visit epoch references to avoid dangling links + try: + cur.execute( + "UPDATE visit SET epoch_id=NULL WHERE soa_id=? AND epoch_id=?", + (soa_id, epoch_id), + ) + except Exception: + pass + # Delete the epoch row + cur.execute("DELETE FROM epoch WHERE id=?", (epoch_id,)) + conn.commit() + conn.close() + _reindex("epoch", soa_id) + _record_epoch_audit(soa_id, "delete", epoch_id, before=before, after=None) + return {"deleted_epoch_id": epoch_id} +''' + + def main(): import uvicorn diff --git a/src/soa_builder/web/audit.py b/src/soa_builder/web/audit.py index ab36d21..1dc7936 100644 --- a/src/soa_builder/web/audit.py +++ b/src/soa_builder/web/audit.py @@ -5,7 +5,7 @@ from .db import _connect -logger = logging.getLogger("soa_builder.concepts") +logger = logging.getLogger("soa_builder.audit") def _record_arm_audit( diff --git a/src/soa_builder/web/routers/epochs.py b/src/soa_builder/web/routers/epochs.py index 501d3e8..3660ab6 100644 --- a/src/soa_builder/web/routers/epochs.py +++ b/src/soa_builder/web/routers/epochs.py @@ -1,24 +1,35 @@ import json import logging import os -import sqlite3 from datetime import datetime, timezone from typing import List, Optional -from fastapi import APIRouter, HTTPException -from fastapi.responses import JSONResponse +from fastapi import APIRouter, HTTPException, Request, Form, Body +from fastapi.responses import JSONResponse, HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates from ..schemas import EpochCreate, EpochUpdate -from ..utils import soa_exists, table_has_columns as _table_has_columns +from ..utils import ( + load_epoch_type_map, + get_next_code_uid as _get_next_code_uid, + soa_exists, + get_latest_sdtm_ct_href, + table_has_columns as _table_has_columns, +) +from ..db import _connect as _connect DB_PATH = os.environ.get("SOA_BUILDER_DB", "soa_builder_web.db") router = APIRouter() logger = logging.getLogger("soa_builder.web.routers.epochs") +templates = Jinja2Templates( + directory=os.path.join(os.path.dirname(__file__), "..", "templates") +) -def _connect(): - return sqlite3.connect(DB_PATH) +def _nz(s: Optional[str]) -> Optional[str]: + s = (s or "").strip() + return s or None def _record_epoch_audit( @@ -54,114 +65,413 @@ def _record_epoch_audit( ) -@router.post("/soa/{soa_id}/epochs") +# API endpoint for listing epochs +@router.get("/soa/{soa_id}/epochs", response_class=JSONResponse, response_model=None) +def list_epochs(soa_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id,name,order_index,epoch_seq,epoch_label,epoch_description,epoch_uid,type FROM epoch WHERE soa_id=? ORDER BY order_index", + (soa_id,), + ) + rows = [ + { + "id": r[0], + "name": r[1], + "order_index": r[2], + "epoch_seq": r[3], + "epoch_label": r[4], + "epoch_description": r[5], + "epoch_uid": r[6], + "type": r[7], + } + for r in cur.fetchall() + ] + conn.close() + return rows + + +# UI code for listing epochs +@router.get("/ui/soa/{soa_id}/epochs", response_class=HTMLResponse) +def ui_list_epochs(request: Request, soa_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + epochs = list_epochs(soa_id) + + # resolve epoch.type (code_uid) -> conceptId from code table + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT code_uid, code FROM code WHERE soa_id=? AND codelist_code='C99079'", + (soa_id,), + ) + type_rows = cur.fetchall() + conn.close() + type_code_map = {row[0]: row[1] for row in type_rows if row[0]} + for e in epochs: + code_uid = e.get("type") + concept_id = type_code_map.get(code_uid, "") + if not concept_id and code_uid: + concept_id = code_uid + e["type_concept_id"] = type_code_map.get(code_uid, "") + + # Epoch Type options (C99079) must come from CDISC API only + epoch_type_options = load_epoch_type_map() + + return templates.TemplateResponse( + request, + "epochs.html", + { + "request": request, + "soa_id": soa_id, + "epochs": epochs, + "epoch_type_options": epoch_type_options, + }, + ) + + +# API endpoint for creating epoch +@router.post( + "/soa/{soa_id}/epochs", + response_class=JSONResponse, + status_code=200, + response_model=None, +) def add_epoch(soa_id: int, payload: EpochCreate): if not soa_exists(soa_id): raise HTTPException(404, "SOA not found") + + name = (payload.name or "").strip() + if not name: + raise HTTPException(400, "Epoch name required") + conn = _connect() cur = conn.cursor() - cur.execute("SELECT COUNT(*) FROM epoch WHERE soa_id=?", (soa_id,)) - order_index = cur.fetchone()[0] + 1 + + # epoch_seq cur.execute("SELECT MAX(epoch_seq) FROM epoch WHERE soa_id=?", (soa_id,)) row = cur.fetchone() next_seq = (row[0] or 0) + 1 - # Determine if epoch_uid column exists and prepare values - has_uid = _table_has_columns(cur, "epoch", ("epoch_uid",)) - epoch_uid_val = f"StudyEpoch_{next_seq}" - if has_uid: - cur.execute( - "INSERT INTO epoch (soa_id,name,order_index,epoch_seq,epoch_label,epoch_description,epoch_uid) VALUES (?,?,?,?,?,?,?)", - ( - soa_id, - payload.name, - order_index, - next_seq, - (payload.epoch_label or "").strip() or None, - (payload.epoch_description or "").strip() or None, - epoch_uid_val, - ), + + # order index + cur.execute( + "SELECT COALESCE(MAX(order_index),0) FROM epoch WHERE soa_id=?", + (soa_id,), + ) + next_ord = (cur.fetchone() or [0])[0] + 1 + + # Code to create epoch_uid and increment order_index + cur.execute( + "SELECT epoch_uid FROM epoch WHERE soa_id=? AND epoch_uid LIKE 'StudyEpoch_%'", + (soa_id,), + ) + existing_uids = [r[0] for r in cur.fetchall() if r[0]] + used_nums = set() + for uid in existing_uids: + if uid.startswith("StudyEpoch_"): + tail = uid[len("StudyEpoch_") :] + if tail.isdigit(): + used_nums.add(int(tail)) + else: + logger.warning( + "Invalid epoch_uid format encountered (ignored): %s", uid + ) + # Always pick max(existing) + 1, do not fill gaps + next_n = (max(used_nums) if used_nums else 0) + 1 + new_uid = f"StudyEpoch_{next_n}" + # Generate Code_{N} got type **only if value selected + epoch_type_value = (payload.type or "").strip() + epoch_type = None + if epoch_type_value: + epoch_type = _get_next_code_uid(cur, soa_id) + logger.info("epoch type'%s", epoch_type) + epoch_type_slug = get_latest_sdtm_ct_href() or "" + epoch_type_codelist_table = ( + f"/mdr/ct/packages/{epoch_type_slug}" + if epoch_type_slug + else "/mdr/ct/packages" ) - else: cur.execute( - "INSERT INTO epoch (soa_id,name,order_index,epoch_seq,epoch_label,epoch_description) VALUES (?,?,?,?,?,?)", + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", ( soa_id, - payload.name, - order_index, - next_seq, - (payload.epoch_label or "").strip() or None, - (payload.epoch_description or "").strip() or None, + epoch_type, + epoch_type_codelist_table, + "C99079", + epoch_type_value, ), ) - eid = cur.lastrowid + + cur.execute( + """ + INSERT INTO epoch (soa_id,name,epoch_label,epoch_description,order_index,epoch_seq,epoch_uid,type) VALUES (?,?,?,?,?,?,?,?) + """, + ( + soa_id, + name, + _nz(payload.epoch_label), + _nz(payload.epoch_description), + next_ord, + next_seq, + new_uid, + epoch_type, + ), + ) + epoch_id = cur.lastrowid conn.commit() conn.close() + after = { + "id": epoch_id, + "name": name, + "epoch_uid": new_uid, + "label": (payload.epoch_label or "").strip() or None, + "description": (payload.epoch_description or "").strip() or None, + "type": (epoch_type or "").strip() or None, + "order_index": next_ord, + "epoch_seq": next_seq, + } + _record_epoch_audit(soa_id, "create", epoch_id, before=None, after=after) + return after - # Correct audit for create (type not set via JSON API) + +# UI code for creating epoch +@router.post("/ui/soa/{soa_id}/epochs/create") +def ui_create_epoch( + request: Request, + soa_id: int, + name: str = Form(...), + label: Optional[str] = Form(None), + description: Optional[str] = Form(None), + type: Optional[str] = Form(None), +): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + payload = EpochCreate( + name=name, + epoch_label=label, + epoch_description=description, + type=type, + ) + add_epoch(soa_id, payload) + return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/epochs", status_code=303) + + +# API endpoint for updating epoch +@router.patch("/soa/{soa_id}/epochs/{epoch_id}", response_class=JSONResponse) +def update_epoch(soa_id: int, epoch_id: int, payload: EpochUpdate): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + conn = _connect() + cur = conn.cursor() + cur.execute( + """ + SELECT id,epoch_uid,name,order_index,epoch_seq,epoch_label,epoch_description,type FROM epoch WHERE id=? AND soa_id=? + """, + (epoch_id, soa_id), + ) + row = cur.fetchone() + if not row: + raise HTTPException(404, f"Epoch id={int(epoch_id)} not found") + + before = { + "id": row[0], + "epoch_uid": row[1], + "name": row[2], + "order_index": row[3], + "epoch_seq": row[4], + "label": row[5], + "description": row[6], + "type": row[7], + } + new_name = (payload.name if payload.name is not None else before["name"]) or "" + new_label = ( + payload.epoch_label if payload.epoch_label is not None else before["label"] + ) + new_description = ( + payload.epoch_description + if payload.epoch_description is not None + else before["description"] + ) + + cur.execute( + """ + UPDATE epoch SET name=?, epoch_label=?, epoch_description=? WHERE id=? AND soa_id=? + """, + ( + _nz(new_name), + _nz(new_label), + _nz(new_description), + epoch_id, + soa_id, + ), + ) + conn.commit() + + new_type = payload.type if payload.type is not None else None + type_uid = before["type"] + type_package_slug = get_latest_sdtm_ct_href() or "" + type_codelist_table = ( + f"/mdr/ct/packages/{type_package_slug}" + if type_package_slug + else "/mdr/ct/packages" + ) + + if new_type is not None: + if not type_uid: + type_uid = _get_next_code_uid(cur, soa_id) + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + type_uid, + type_codelist_table, + "C99079", + new_type, + ), + ) + cur.execute( + "UPDATE epoch SET type=? WHERE id=? AND soa_id=?", + (type_uid, epoch_id, soa_id), + ) + else: + cur.execute( + "UPDATE code SET code=? WHERE soa_id=? AND code_uid=?", + (new_type, soa_id, type_uid), + ) + if cur.rowcount == 0: + type_uid = _get_next_code_uid(cur, soa_id) + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + type_uid, + type_codelist_table, + "C99079", + new_type, + ), + ) + cur.execute( + "UPDATE epoch SET type=? WHERE id=? AND soa_id=?", + (type_uid, epoch_id, soa_id), + ) + conn.commit() + + cur.execute( + """ + SELECT id,epoch_uid,name,order_index,epoch_seq,epoch_label,epoch_description,type FROM epoch WHERE id=? AND soa_id=? + """, + (epoch_id, soa_id), + ) + r = cur.fetchone() + conn.close() + after = { + "id": r[0], + "epoch_uid": r[1], + "name": r[2], + "order_index": r[3], + "epoch_seq": r[4], + "label": r[5], + "description": r[6], + "type": r[7], + } + mutable = { + "name", + "label", + "description", + "type", + } + updated_fields = [ + f for f in mutable if (before.get(f) or None) != (after.get(f) or None) + ] _record_epoch_audit( soa_id, - "create", - eid, - before={"type": None}, - after={ - "id": eid, - "name": payload.name, - "order_index": order_index, - "epoch_seq": next_seq, - "epoch_label": (payload.epoch_label or "").strip() or None, - "epoch_description": (payload.epoch_description or "").strip() or None, - "epoch_uid": epoch_uid_val if has_uid else f"StudyEpoch_{next_seq}", - }, + "udpate", + epoch_id, + before=before, + after={**after, "updated_fields": updated_fields}, ) + return {**after, "updated_fields": updated_fields} -@router.get("/soa/{soa_id}/epochs") -def list_epochs(soa_id: int): +# UI code to update epoch +@router.post("/ui/soa/{soa_id}/epochs/{epoch_id}/update") +def ui_update_epoch( + request: Request, + soa_id: int, + epoch_id: int, + name: Optional[str] = Form(None), + label: Optional[str] = Form(None), + description: Optional[str] = Form(None), + type: Optional[str] = Form(None), +): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + payload = EpochUpdate( + name=name, + epoch_label=label, + epoch_description=description, + type=type, + ) + update_epoch(soa_id, epoch_id, payload) + return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/epochs", status_code=303) + + +# API endpoint for deleting epoch +@router.delete( + "/soa/{soa_id}/epochs/{epoch_id}", response_class=JSONResponse, response_model=None +) +def delete_epoch(soa_id: int, epoch_id: int): if not soa_exists(soa_id): raise HTTPException(404, "SOA not found") + conn = _connect() cur = conn.cursor() - has_uid = _table_has_columns(cur, "epoch", ("epoch_uid",)) - if has_uid: - cur.execute( - "SELECT id,name,order_index,epoch_seq,epoch_label,epoch_description,epoch_uid FROM epoch WHERE soa_id=? ORDER BY order_index", - (soa_id,), - ) - rows = [ - { - "id": r[0], - "name": r[1], - "order_index": r[2], - "epoch_seq": r[3], - "epoch_label": r[4], - "epoch_description": r[5], - "epoch_uid": r[6], - } - for r in cur.fetchall() - ] - else: - cur.execute( - "SELECT id,name,order_index,epoch_seq,epoch_label,epoch_description FROM epoch WHERE soa_id=? ORDER BY order_index", - (soa_id,), - ) - rows = [] - for r in cur.fetchall(): - eid, name, order_index, epoch_seq, epoch_label, epoch_description = r - rows.append( - { - "id": eid, - "name": name, - "order_index": order_index, - "epoch_seq": epoch_seq, - "epoch_label": epoch_label, - "epoch_description": epoch_description, - "epoch_uid": f"StudyEpoch_{epoch_seq or eid}", - } - ) + cur.execute( + "SELECT id,name,epoch_label,type FROM epoch WHERE soa_id=? AND id=?", + (soa_id, epoch_id), + ) + row = cur.fetchone() + + before = { + "id": row[0], + "name": row[1], + "label": row[2], + "type": row[3], + } + cur.execute( + "DELETE FROM epoch WHERE soa_id=? AND id=?", + (soa_id, epoch_id), + ) + conn.commit() + # reindex remaining epochs' epoch_seq sequentially + cur.execute( + "SELECT id FROM epoch WHERE soa_id=? ORDER BY epoch_seq", + (soa_id,), + ) + remaining = [r[0] for r in cur.fetchall()] + for idx, eid in enumerate(remaining, start=1): + cur.execute("UPDATE epoch SET epoch_seq=? WHERE id=?", (idx, eid)) + conn.commit() conn.close() - return {"soa_id": soa_id, "epochs": rows} + _record_epoch_audit(soa_id, "delete", epoch_id, before=before, after=None) + return {"deleted": True, "id": epoch_id} + + +# UI code to delete epoch +@router.post("/ui/soa/{soa_id}/epochs/{epoch_id}/delete") +def ui_delete_epoch(request: Request, soa_id: int, epoch_id: int): + delete_epoch(soa_id, epoch_id) + return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/epochs", status_code=303) +# Deprecated @router.get("/soa/{soa_id}/epochs/{epoch_id}") def get_epoch(soa_id: int, epoch_id: int): if not soa_exists(soa_id): @@ -212,6 +522,7 @@ def get_epoch(soa_id: int, epoch_id: int): } +# Deprecated @router.post("/soa/{soa_id}/epochs/{epoch_id}/metadata") def update_epoch_metadata(soa_id: int, epoch_id: int, payload: EpochUpdate): if not soa_exists(soa_id): @@ -292,20 +603,31 @@ def update_epoch_metadata(soa_id: int, epoch_id: int, payload: EpochUpdate): @router.post("/soa/{soa_id}/epochs/reorder", response_class=JSONResponse) -def reorder_epochs_api(soa_id: int, order: List[int]): +def reorder_epochs_api( + soa_id: int, + order: List[int] = Body(..., embed=True), # <‑- read JSON body: {"order":[...]} +): 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 epoch WHERE soa_id=? ORDER BY order_index", (soa_id,)) - old_order = [r[0] for r in cur.fetchall()] - cur.execute("SELECT id FROM epoch WHERE soa_id=?", (soa_id,)) + cur.execute( + "SELECT id,name FROM epoch WHERE soa_id=? ORDER BY order_index", (soa_id,) + ) + rows = cur.fetchall() + old_order = [r[0] for r in rows] # IDs for API response + id_to_name = {r[0]: r[1] for r in rows} + old_order_names = [r[1] for r in rows] # Names for audit + + cur.execute("SELECT id,name FROM epoch WHERE soa_id=?", (soa_id,)) existing = {r[0] for r in cur.fetchall()} if set(order) - existing: conn.close() raise HTTPException(400, "Order contains invalid epoch id") + for idx, eid in enumerate(order, start=1): cur.execute("UPDATE epoch SET order_index=? WHERE id=?", (idx, eid)) conn.commit() @@ -318,18 +640,20 @@ def _epoch_types_snapshot_router(soa_id_int: int) -> List[dict]: "SELECT id,type FROM epoch WHERE soa_id=? ORDER BY order_index", (soa_id_int,), ) - rows = cur_s.fetchall() + rows_s = cur_s.fetchall() conn_s.close() - return [{"id": rid, "type": rtype} for rid, rtype in rows] + return [{"id": rid, "type": rtype} for rid, rtype in rows_s] + + new_order_names = [id_to_name.get(eid, str(eid)) for eid in order] _record_epoch_audit( soa_id, "reorder", epoch_id=None, before={ - "old_order": old_order, + "old_order": old_order_names, "types": _epoch_types_snapshot_router(soa_id), }, - after={"new_order": order}, + after={"new_order": new_order_names}, ) return JSONResponse({"ok": True, "old_order": old_order, "new_order": order}) diff --git a/src/soa_builder/web/routers/visits.py b/src/soa_builder/web/routers/visits.py index 45059a9..fa1288e 100644 --- a/src/soa_builder/web/routers/visits.py +++ b/src/soa_builder/web/routers/visits.py @@ -32,6 +32,18 @@ def _nz(s: Optional[str]) -> Optional[str]: return s or None +def _load_code_value_map(soa_id: int) -> dict[str, str]: + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT code_uid, code FROM code WHERE soa_id=?", + (soa_id,), + ) + rows = cur.fetchall() + conn.close() + return {row[0]: row[1] for row in rows if row[0]} + + # API endpoint to list encounters for an SOA @router.get("/soa/{soa_id}/visits", response_class=JSONResponse, response_model=None) def list_visits(soa_id: int): @@ -68,18 +80,6 @@ def list_visits(soa_id: int): return rows -def _load_code_value_map(soa_id: int) -> dict[str, str]: - conn = _connect() - cur = conn.cursor() - cur.execute( - "SELECT code_uid, code FROM code WHERE soa_id=?", - (soa_id,), - ) - rows = cur.fetchall() - conn.close() - return {row[0]: row[1] for row in rows if row[0]} - - # UI code to list encounters in an SOA @router.get("/ui/soa/{soa_id}/visits", response_class=HTMLResponse) def ui_list_visits(request: Request, soa_id: int): diff --git a/src/soa_builder/web/schemas.py b/src/soa_builder/web/schemas.py index 32ce262..44ef8c9 100644 --- a/src/soa_builder/web/schemas.py +++ b/src/soa_builder/web/schemas.py @@ -115,12 +115,14 @@ class EpochCreate(BaseModel): name: str epoch_label: Optional[str] = None epoch_description: Optional[str] = None + type: Optional[str] = None class EpochUpdate(BaseModel): name: Optional[str] = None epoch_label: Optional[str] = None epoch_description: Optional[str] = None + type: Optional[str] = None class VisitCreate(BaseModel): diff --git a/src/soa_builder/web/templates/base.html b/src/soa_builder/web/templates/base.html index b9c1d96..a757a39 100644 --- a/src/soa_builder/web/templates/base.html +++ b/src/soa_builder/web/templates/base.html @@ -13,6 +13,7 @@ Schedule Timelines Study Timing Scheduled Activity Instances + Epochs Encounters {% endif %} Biomedical Concept Categories diff --git a/src/soa_builder/web/templates/edit.html b/src/soa_builder/web/templates/edit.html index 3f5158b..6cc1dcb 100644 --- a/src/soa_builder/web/templates/edit.html +++ b/src/soa_builder/web/templates/edit.html @@ -114,50 +114,6 @@

Editing SoA {{ soa_id }}

- -
- Epochs ({{ epochs|length }}) (drag to reorder) - -
- - - - - -
-
Elements ({{ elements|length }}) (drag to reorder)