diff --git a/.gitignore b/.gitignore index 093fae6..e52d566 100644 --- a/.gitignore +++ b/.gitignore @@ -96,5 +96,6 @@ old-tests/ docs/~* files/~* output/* +SOA Workbench Wishlist.docx # End of file diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index 109edaf..bb11c74 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -2,7 +2,7 @@ """FastAPI web application for interactive Schedule of Activities creation. -Endpoints: +Endpoints (deprecated): POST /soa {name} -> create SOA container GET /soa/{id} -> summary POST /soa/{id}/visits {name, label} -> add visit @@ -60,6 +60,7 @@ _migrate_rename_cell_table, _migrate_rollback_add_elements_restored, _migrate_add_epoch_type, + _migrate_visit_columns, ) from .routers import activities as activities_router from .routers import arms as arms_router @@ -69,7 +70,7 @@ from .routers import freezes as freezes_router from .routers import rollback as rollback_router from .routers import visits as visits_router - +from .routers import audits as audits_router from .routers import timings as timings_router from .routers import instances as instances_router @@ -92,6 +93,8 @@ MatrixImport, ) from .utils import ( + get_cdisc_api_key as _get_cdisc_api_key, + get_concepts_override as _get_concepts_override, get_next_code_uid as _get_next_code_uid, get_next_concept_uid as _get_next_concept_uid, load_epoch_type_options, @@ -155,6 +158,7 @@ def _configure_logging(): # Database migration steps +_migrate_visit_columns() _migrate_add_epoch_type() _migrate_add_arm_uid() _migrate_drop_arm_element_link() @@ -177,7 +181,7 @@ def _configure_logging(): _backfill_dataset_date("ddf_terminology", "ddf_terminology_audit") _backfill_dataset_date("protocol_terminology", "protocol_terminology_audit") -# routers +# Include routers app.include_router(arms_router.router) app.include_router(elements_router.router) app.include_router(visits_router.router) @@ -187,17 +191,10 @@ def _configure_logging(): app.include_router(rollback_router.router) app.include_router(timings_router.router) app.include_router(instances_router.router) +app.include_router(audits_router.router) -# Utility functions -def _get_cdisc_api_key(): - return os.environ.get("CDISC_API_KEY") - - -def _get_concepts_override(): - return os.environ.get("CDISC_CONCEPTS_JSON") - - +# Create Audit record functions def _record_transition_rule_audit( soa_id: int, action: str, @@ -334,6 +331,7 @@ def _record_arm_audit( logger.warning("Failed recording arm audit: %s", e) +# API functions for reordering Activities and 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.""" @@ -1809,7 +1807,7 @@ def _normalize_href(h: Optional[str]) -> Optional[str]: @asynccontextmanager -async def lifespan(app: FastAPI): # pragma: no cover +async def lifespan(app: FastAPI): """FastAPI lifespan context replacing deprecated startup event. Preloads cached terminology datasets (biomedical concepts and SDTM dataset @@ -1834,6 +1832,7 @@ async def lifespan(app: FastAPI): # pragma: no cover app.router.lifespan_context = lifespan +# UI endpoint to refrech the biomedical concepts cache @app.post("/ui/soa/{soa_id}/concepts_refresh") def ui_refresh_concepts(request: Request, soa_id: int): """Fetch Biomedical Concepts; refresh cache""" @@ -1849,9 +1848,6 @@ def ui_refresh_concepts(request: Request, soa_id: int): ) -"""Freeze & rollback endpoints moved to routers/freezes.py and routers/rollback.py""" - - @app.get("/soa/{soa_id}/reorder_audit/export/csv") def export_reorder_audit_csv(soa_id: int): """Export reorder audit history to CSV.""" @@ -2002,9 +1998,7 @@ def _matrix_arrays(soa_id: int): return visit_headers, rows -# --------------------- API Endpoints --------------------- - - +# API endpoint for creating new Study/SOA @app.post("/soa") def create_soa(payload: SOACreate): """Create new Schedule of Activities""" @@ -2038,6 +2032,7 @@ def create_soa(payload: SOACreate): } +# API endpoint for returning Study/SOA metadata @app.get("/soa/{soa_id}") def get_soa(soa_id: int): """Return SoA by ID""" @@ -2090,6 +2085,7 @@ def get_soa(soa_id: int): } +# API endpoint for updating Study/SOA metadata @app.post("/soa/{soa_id}/metadata") def update_soa_metadata(soa_id: int, payload: SOAMetadataUpdate): """Update metadata for SoA/Study.""" @@ -2132,15 +2128,7 @@ def update_soa_metadata(soa_id: int, payload: SOAMetadataUpdate): return {"id": soa_id, "updated": True} -"""Visit creation handled in routers/visits.py""" -"""Visit update handled in routers/visits.py""" -"""Visit detail handled in routers/visits.py""" -"""Activity creation handled in routers/activities.py""" -"""Activity update handled in routers/activities.py""" -"""Activity detail handled in routers/activities.py""" -"""Epoch CRUD and reorder endpoints refactored into epochs_router.""" - - +# API endpont for assigning BC to activity @app.post("/soa/{soa_id}/activities/{activity_id}/concepts") def set_activity_concepts(soa_id: int, activity_id: int, payload: ConceptsUpdate): """Update Biomedical Concept assigned to an Activity.""" @@ -2227,6 +2215,7 @@ def set_activity_concepts(soa_id: int, activity_id: int, payload: ConceptsUpdate return {"activity_id": activity_id, "concepts_set": inserted} +# API endpoint for returning BC associated with an Activity def _get_activity_concepts(activity_id: int): """Return list of concepts (immutable: stored snapshot).""" conn = _connect() @@ -2246,6 +2235,7 @@ def _get_activity_concepts(activity_id: int): return rows +# API endpoint for adding a BC to an activity @app.post( "/ui/soa/{soa_id}/activity/{activity_id}/concepts/add", response_class=HTMLResponse ) @@ -2348,6 +2338,7 @@ def ui_add_activity_concept( return HTMLResponse(html) +# UI endpoint for removing a BC from an Activity @app.post( "/ui/soa/{soa_id}/activity/{activity_id}/concepts/remove", response_class=HTMLResponse, @@ -2394,9 +2385,7 @@ def ui_remove_activity_concept( return HTMLResponse(html) -"""Activity bulk creation handled in routers/activities.py""" - - +# API endpoint for marking a matrix cell association between an Activity and Encounter/Visit @app.post("/soa/{soa_id}/cells") def set_cell(soa_id: int, payload: CellCreate): """Set 'X' in SoA Matrix cell.""" @@ -2438,6 +2427,7 @@ def set_cell(soa_id: int, payload: CellCreate): return {"cell_id": cid, "status": payload.status} +# API endpoint fr returning a matrix for a Study/SOA @app.get("/soa/{soa_id}/matrix") def get_matrix(soa_id: int): """Return SoA Matrix for Visits, Activities and assigned Matrix Cells.""" @@ -2447,6 +2437,7 @@ def get_matrix(soa_id: int): return {"visits": visits, "activities": activities, "cells": cells} +# API endpoint for exporting the Matrix as XLSX @app.get("/soa/{soa_id}/export/xlsx") def export_xlsx(soa_id: int, left: Optional[int] = None, right: Optional[int] = None): """Export SoA Matrix to XLSX.""" @@ -2721,6 +2712,7 @@ def export_xlsx(soa_id: int, left: Optional[int] = None, right: Optional[int] = ) +# API endpoint for exporting the Matrix as PDF (Deprecated) @app.get("/soa/{soa_id}/export/pdf") def export_pdf(soa_id: int): """Export lightweight PDF summary of the SOA (arms, visits, activities, concept mappings). @@ -2895,6 +2887,7 @@ def add(line: str): ) +# API endpoint for normalizing a Study/SOA (Not Used) @app.get("/soa/{soa_id}/normalized") def get_normalized(soa_id: int): if not soa_exists(soa_id): @@ -2908,6 +2901,7 @@ def get_normalized(soa_id: int): return {"summary": summary, "artifacts_dir": out_dir} +# API endpoint for importing a Matrix (Not Used) @app.post("/soa/{soa_id}/matrix/import") def import_matrix(soa_id: int, payload: MatrixImport): """Import SoA Matrix.""" @@ -2980,9 +2974,6 @@ def import_matrix(soa_id: int, payload: MatrixImport): } -# --------------------- Deletion API Endpoints --------------------- - - def _reindex(table: str, soa_id: int): conn = _connect() cur = conn.cursor() @@ -3007,6 +2998,7 @@ def _reindex(table: str, soa_id: int): conn.close() +# API endpoint for deleting an Activity @app.delete("/soa/{soa_id}/activities/{activity_id}") def delete_activity(soa_id: int, activity_id: int): """Delete Activity from an SoA.""" @@ -3038,6 +3030,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.""" @@ -3089,9 +3082,6 @@ def delete_epoch(soa_id: int, epoch_id: int): return {"deleted_epoch_id": epoch_id} -# --------------------- HTML UI Endpoints --------------------- - - @app.get("/", response_class=HTMLResponse) def ui_index(request: Request): """Render home page for the SoA Workbench.""" @@ -3121,6 +3111,7 @@ def ui_index(request: Request): ) +# 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(...)): """Add an Activity to an SoA.""" @@ -3160,6 +3151,7 @@ def ui_add_activity(request: Request, soa_id: int, name: str = Form(...)): ) +# UI endpoint for creating a new Study/SOA @app.post("/ui/soa/create", response_class=HTMLResponse) def ui_create_soa( request: Request, @@ -3195,6 +3187,7 @@ def ui_create_soa( return HTMLResponse(f"") +# UI endpoint for updating the metadata of a Study/SOA @app.post("/ui/soa/{soa_id}/update_meta", response_class=HTMLResponse) def ui_update_meta( request: Request, @@ -3250,7 +3243,8 @@ def ui_update_meta( ) -# Helper to fetch element audit rows with legacy-safe columns +# Helper to fetch element audit rows with legacy-safe columns -> Moved to audits.py, audits.html +""" def _fetch_element_audits(soa_id: int): conn_ea = _connect() cur_ea = conn_ea.cursor() @@ -3278,8 +3272,10 @@ def _fetch_element_audits(soa_id: int): element_audits.append(item) conn_ea.close() return element_audits +""" +# UI endpoint for rendering SOA edit page @app.get("/ui/soa/{soa_id}/edit", response_class=HTMLResponse) def ui_edit(request: Request, soa_id: int): """Render edit HTML page for an SoA.""" @@ -3483,6 +3479,7 @@ def ui_edit(request: Request, soa_id: int): ) # Admin audit view: recent activity audits for this SOA + """ conn_activity_audit = _connect() cur_activity_audit = conn_activity_audit.cursor() cur_activity_audit.execute( @@ -3501,8 +3498,10 @@ def ui_edit(request: Request, soa_id: int): for r in cur_activity_audit.fetchall() ] conn_activity_audit.close() + """ # Admin audit view: recent arm audits for this SOA + """ conn_arm_audit = _connect() cur_arm_audit = conn_arm_audit.cursor() cur_arm_audit.execute( @@ -3521,8 +3520,10 @@ def ui_edit(request: Request, soa_id: int): for r in cur_arm_audit.fetchall() ] conn_arm_audit.close() + """ # Admin audit view: recent epoch audits for this SoA + """ conn_epoch_audit = _connect() cur_epoch_audit = conn_epoch_audit.cursor() cur_epoch_audit.execute( @@ -3541,8 +3542,10 @@ def ui_edit(request: Request, soa_id: int): for r in cur_epoch_audit.fetchall() ] conn_epoch_audit.close() + """ - # Admin audit view: recent study cell audits for this SoA + # Admin audit view: recent study cell audits for this SoA -> moved to audits.py, audits.html + """ conn_sc_audit = _connect() cur_sc_audit = conn_sc_audit.cursor() cur_sc_audit.execute( @@ -3561,6 +3564,7 @@ def ui_edit(request: Request, soa_id: int): for r in cur_sc_audit.fetchall() ] conn_sc_audit.close() + """ # Enrich epochs using API-only map: code -> submissionValue # Resolve stored epoch.type (code_uid) to terminology code via code table, then map to submissionValue. @@ -3634,7 +3638,7 @@ def ui_edit(request: Request, soa_id: int): conn_tr.close() # Element audit list - element_audits = _fetch_element_audits(soa_id) + # element_audits = _fetch_element_audits(soa_id) -> Moved to audits.py, audits.html return templates.TemplateResponse( request, @@ -3659,11 +3663,11 @@ def ui_edit(request: Request, soa_id: int): **study_meta, "protocol_terminology_C174222": protocol_terminology_C174222, "ddf_terminology_C188727": ddf_terminology_C188727, - "arm_audits": arm_audits, - "epoch_audits": epoch_audits, - "activity_audits": activity_audits, - "study_cell_audits": study_cell_audits, - "element_audits": element_audits, + # "arm_audits": arm_audits, + # "epoch_audits": epoch_audits, + # "activity_audits": activity_audits, + # "study_cell_audits": study_cell_audits, + # "element_audits": element_audits, # Epoch Type options (C99079) "epoch_type_options": epoch_type_options, # Study Cells @@ -3673,6 +3677,7 @@ def ui_edit(request: Request, soa_id: int): ) +# UI endpoint for listing BCs @app.get("/ui/concepts", response_class=HTMLResponse) def ui_concepts_list(request: Request): """Render table listing biomedical concepts (title + href).""" @@ -3699,6 +3704,7 @@ def ui_concepts_list(request: Request): ) +# UI endpoint for listing BC Categories @app.get("/ui/concept_categories", response_class=HTMLResponse) def ui_categories_list(request: Request, force: bool = False): """Render table listing biomedical concept categories (name + title + href).""" @@ -3724,6 +3730,7 @@ def ui_categories_list(request: Request, force: bool = False): ) +# UI endpint for displaying BC Category @app.get("/ui/concept_categories/view", response_class=HTMLResponse) def ui_category_detail(request: Request, name: str = "", force: bool = False): """Render list of biomedical concepts within a given category name. @@ -3757,6 +3764,7 @@ def ui_category_detail(request: Request, name: str = "", force: bool = False): ) +# UI endpoint for displaying SDTM specializations @app.get("/ui/sdtm/specializations", response_class=HTMLResponse) def ui_sdtm_specializations_list(request: Request, code: Optional[str] = None): """Render table listing SDTM dataset specializations (title + API link). @@ -3789,6 +3797,7 @@ def ui_sdtm_specializations_list(request: Request, code: Optional[str] = None): ) +# UI endpoint for displaying a selected SDTM specialization @app.get("/ui/sdtm/specializations/{idx}", response_class=HTMLResponse) def ui_sdtm_specialization_detail( idx: int, @@ -3861,6 +3870,7 @@ def ui_sdtm_specialization_detail( ) +# UI endpoint for displaying a selected BC @app.get("/ui/concepts/{code}", response_class=HTMLResponse) def ui_concept_detail(code: str, request: Request): """Detail page for a single biomedical concept. Fetches concept JSON from CDISC Library API, @@ -3936,6 +3946,7 @@ def ui_concept_detail(code: str, request: Request): ) +# UI endpoint for creating an Encounter/Visit @app.post("/ui/soa/{soa_id}/add_visit", response_class=HTMLResponse) def ui_add_visit( request: Request, @@ -3945,15 +3956,19 @@ def ui_add_visit( epoch_id: Optional[str] = Form(None), description: Optional[str] = Form(None), ): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + # Coerce empty epoch_id from form to None, otherwise to int parsed_epoch_id: Optional[int] = None if epoch_id is not None: eid = str(epoch_id).strip() - if eid != "": + if eid: try: parsed_epoch_id = int(eid) except ValueError: parsed_epoch_id = None + payload = VisitCreate( name=name, label=label, @@ -3964,7 +3979,6 @@ def ui_add_visit( try: visits_router.add_visit(soa_id, payload) except Exception: - # Swallow and continue redirect; detailed errors are handled by API logs pass return HTMLResponse( @@ -3972,6 +3986,7 @@ def ui_add_visit( ) +# UI endpoint for adding a new Arm @app.post("/ui/soa/{soa_id}/add_arm", response_class=HTMLResponse) async def ui_add_arm( request: Request, @@ -4129,9 +4144,12 @@ async def ui_add_arm( conn.commit() # routers.arms.create_arm already records a create audit; avoid duplicating here conn.close() - return HTMLResponse(f"") + return HTMLResponse( + f"" + ) +# UI endpoint for updating an Arm @app.post("/ui/soa/{soa_id}/update_arm", response_class=HTMLResponse) async def ui_update_arm( request: Request, @@ -4400,6 +4418,7 @@ async def ui_update_arm( ) +# UI endpoint for deleting an Arm @app.post("/ui/soa/{soa_id}/delete_arm", response_class=HTMLResponse) def ui_delete_arm(request: Request, soa_id: int, arm_id: int = Form(...)): delete_arm(soa_id, arm_id) @@ -4408,6 +4427,7 @@ def ui_delete_arm(request: Request, soa_id: int, arm_id: int = Form(...)): ) +# UI endpoint for reordering Arms @app.post("/ui/soa/{soa_id}/reorder_arms", response_class=HTMLResponse) def ui_reorder_arms(request: Request, soa_id: int, order: str = Form("")): """Form handler to reorder existing Arms.""" @@ -4440,6 +4460,7 @@ def ui_reorder_arms(request: Request, soa_id: int, order: str = Form("")): return HTMLResponse("OK") +# UI endpoint for adding an element @app.post("/ui/soa/{soa_id}/add_element", response_class=HTMLResponse) def ui_add_element( request: Request, @@ -4526,6 +4547,7 @@ def ui_add_element( ) +# UI endpoint for updating an element @app.post("/ui/soa/{soa_id}/update_element", response_class=HTMLResponse) def ui_update_element( request: Request, @@ -4626,6 +4648,7 @@ def ui_update_element( ) +# UI endpoint for deleting an element @app.post("/ui/soa/{soa_id}/delete_element", response_class=HTMLResponse) def ui_delete_element(request: Request, soa_id: int, element_id: int = Form(...)): """Form handler to delete an existing Element.""" @@ -4680,7 +4703,7 @@ def ui_delete_element(request: Request, soa_id: int, element_id: int = Form(...) ) -# --------------------- Study Cell UI Endpoints --------------------- +# 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,)) @@ -4696,9 +4719,7 @@ def _next_study_cell_uid(cur, soa_id: int) -> str: return f"StudyCell_{max_n + 1}" -# _next_element_identifier is defined in routers/elements.py; imported above - - +# 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, @@ -4809,6 +4830,7 @@ def ui_add_study_cell( ) +# 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, @@ -4883,6 +4905,7 @@ def ui_update_study_cell( ) +# 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.""" @@ -4926,6 +4949,7 @@ def ui_delete_study_cell(request: Request, soa_id: int, study_cell_id: int = For ) +# UI endpoint for adding a new Epoch @app.post("/ui/soa/{soa_id}/add_epoch", response_class=HTMLResponse) def ui_add_epoch( request: Request, @@ -5014,6 +5038,7 @@ def ui_add_epoch( ) +# UI endpoint for updating an Epoch @app.post("/ui/soa/{soa_id}/update_epoch", response_class=HTMLResponse) def ui_update_epoch( request: Request, @@ -5172,7 +5197,7 @@ def ui_update_epoch( ) -# --------------------------- Transition Rules ----------------------# +# 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. Considers existing transition_rule rows and any prior UIDs found in transition_rule_audit. @@ -5220,6 +5245,7 @@ def _next_transition_rule_uid(soa_id: int) -> str: return f"TransitionRule_{max_n + 1}" +# UI endpoint for adding a new Transition Rule @app.post("/ui/soa/{soa_id}/add_transition_rule", response_class=HTMLResponse) def ui_add_transition_rule( request: Request, @@ -5285,6 +5311,7 @@ def ui_add_transition_rule( ) +# UI endpoint for updating a Transition Rule @app.post("/ui/soa/{soa_id}/update_transition_rule", response_class=HTMLResponse) def ui_transition_rule_update( request: Request, @@ -5394,6 +5421,7 @@ def ui_transition_rule_update( ) +# UI endpoint for deleting a Transition Rule @app.post("/ui/soa/{soa_id}/delete_transition_rule") def ui_delete_transition_rule( request: Request, soa_id: int, transition_rule_uid: str = Form(...) @@ -5464,7 +5492,7 @@ def ui_delete_transition_rule( ) -# -------------------------- Biomedical Concepts --------------------# +# UI endpoint for setting a BC to an Activity @app.post( "/ui/soa/{soa_id}/activity/{activity_id}/concepts", response_class=HTMLResponse ) @@ -5509,6 +5537,7 @@ def ui_set_activity_concepts( ) +# UI endpoint for displaying BC(s) assigned to an Activity @app.get( "/ui/soa/{soa_id}/activity/{activity_id}/concepts_cell", response_class=HTMLResponse ) @@ -5550,6 +5579,7 @@ def ui_activity_concepts_cell( ) +# UI endpoint for assigning an Activity to an Encounter/Visit @app.post("/ui/soa/{soa_id}/set_cell", response_class=HTMLResponse) def ui_set_cell( request: Request, @@ -5565,6 +5595,7 @@ def ui_set_cell( return HTMLResponse(result.get("status", "")) +# UI endpoint for toggling assignment of an Activity to an Encounter/Visit @app.post("/ui/soa/{soa_id}/toggle_cell", response_class=HTMLResponse) def ui_toggle_cell( request: Request, @@ -5611,7 +5642,7 @@ def ui_toggle_cell( return HTMLResponse(cell_html) -# UI code to delete an encounter from an SOA +# 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): @@ -5631,28 +5662,7 @@ def ui_delete_visit(request: Request, soa_id: int, visit_id: int = Form(...)): ) -''' -def ui_delete_visit(request: Request, soa_id: int, visit_id: int): - """Form handler to delete a visit.""" - # Use API logic to delete and log - try: - visits_router.delete_visit(soa_id, visit_id) - logger.info( - "ui_delete_visit deleted visit id=%s soa_id=%s db_path=%s", - visit_id, - soa_id, - DB_PATH, - ) - except Exception as e: - logger.error( - "ui_delete_visit failed visit_id=%s soa_id=%s error=%s", visit_id, soa_id, e - ) - return HTMLResponse( - f"" - ) -''' - - +# UI endpoint for associating an Epoch with a Visit/Encounter @app.post("/ui/soa/{soa_id}/set_visit_epoch", response_class=HTMLResponse) def ui_set_visit_epoch( request: Request, @@ -5739,6 +5749,7 @@ def ui_set_visit_epoch( ) +# UI endpoint for updating an Encounter/Visit @app.post("/ui/soa/{soa_id}/update_visit", response_class=HTMLResponse) def ui_update_visit( request: Request, @@ -5765,6 +5776,7 @@ def ui_update_visit( ) +# UI endpoint for deleting an Activity @app.post("/ui/soa/{soa_id}/delete_activity", response_class=HTMLResponse) def ui_delete_activity(request: Request, soa_id: int, activity_id: int = Form(...)): """Form handler to delete an Activity""" @@ -5774,6 +5786,7 @@ def ui_delete_activity(request: Request, soa_id: int, activity_id: int = Form(.. ) +# UI endpoint for deleting an Epoch @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.""" @@ -5783,6 +5796,7 @@ def ui_delete_epoch(request: Request, soa_id: int, epoch_id: int = Form(...)): ) +# UI endpoint for reordering Encounters/Visits @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.""" @@ -5811,6 +5825,7 @@ def ui_reorder_visits(request: Request, soa_id: int, order: str = Form("")): return HTMLResponse("OK") +# UI endpoint for reordering Activities @app.post("/ui/soa/{soa_id}/reorder_activities", response_class=HTMLResponse) def ui_reorder_activities(request: Request, soa_id: int, order: str = Form("")): """Persist new activity ordering. 'order' is a comma-separated list of activity IDs in desired order.""" @@ -5839,6 +5854,7 @@ def ui_reorder_activities(request: Request, soa_id: int, order: str = Form("")): return HTMLResponse("OK") +# # UI endpoint for reordering Epochs @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.""" @@ -5887,6 +5903,7 @@ def _epoch_types_snapshot(soa_id_int: int) -> list[dict]: return HTMLResponse("OK") +# Sanitize column headers in the XLSX export def _sanitize_column(name: str) -> str: """Sanitize Excel column header to safe SQLite identifier: lowercase, replace spaces & non-alnum with underscore, collapse repeats.""" import re @@ -5899,7 +5916,7 @@ def _sanitize_column(name: str) -> str: return s -# ------------------------- DDF Terminology ----------------------# +# API to load new DDF Terminology spreadsheet def load_ddf_terminology( file_path: str, sheet_name: str = "DDF Terminology 2025-09-26", @@ -6030,6 +6047,7 @@ def load_ddf_terminology( return {"columns": sanitized, "row_count": len(records)} +# UI endpoint to load DDF Terminology @app.post("/admin/load_ddf_terminology") def admin_load_ddf( file_path: Optional[str] = None, sheet_name: str = "DDF Terminology 2025-09-26" @@ -6082,6 +6100,7 @@ def admin_load_ddf( ) +# API endpoint to display DDF Terminology from the `ddf_terminology`` database table @app.get("/ddf/terminology") def get_ddf_terminology( search: Optional[str] = None, @@ -6179,6 +6198,7 @@ def get_ddf_terminology( } +# UI endpoint to display DDF Terminology @app.get("/ui/ddf/terminology", response_class=HTMLResponse) def ui_ddf_terminology( request: Request, @@ -6215,6 +6235,7 @@ def ui_ddf_terminology( ) +# UI endpoint to load new DDF Terminology @app.post("/ui/ddf/terminology/upload", response_class=HTMLResponse) def ui_ddf_upload( request: Request, @@ -6265,6 +6286,7 @@ def ui_ddf_upload( ) +# API endpoint to record DDF Terminology Load def _record_ddf_audit( file_path: str, sheet_name: str, @@ -6336,6 +6358,7 @@ def _record_ddf_audit( logger.warning("Failed recording DDF audit: %s", e) +# Helper function to return SQLite DDF Terminology table def _get_ddf_sources() -> List[str]: conn = _connect() cur = conn.cursor() @@ -6353,6 +6376,7 @@ def _get_ddf_sources() -> List[str]: return sources +# API endpoint to return DDF Terminology audits @app.get("/ddf/terminology/audit") def get_ddf_audit( source: Optional[str] = None, start: Optional[str] = None, end: Optional[str] = None @@ -6412,6 +6436,7 @@ def _valid_date(d: str) -> bool: return {"rows": rows} +# API endpoint to export DDF Terminology audit report in XLSX format @app.get("/ddf/terminology/audit/export.csv") def export_ddf_audit_csv( source: Optional[str] = None, start: Optional[str] = None, end: Optional[str] = None @@ -6460,6 +6485,7 @@ def export_ddf_audit_csv( ) +# API endpoint to export DDF Terminology audit report in JSON format @app.get("/ddf/terminology/audit/export.json") def export_ddf_audit_json( source: Optional[str] = None, start: Optional[str] = None, end: Optional[str] = None @@ -6468,6 +6494,7 @@ def export_ddf_audit_json( return get_ddf_audit(source=source, start=start, end=end) +# UI endpoint to display DDF terminology audits @app.get("/ui/ddf/terminology/audit", response_class=HTMLResponse) def ui_ddf_audit( request: Request, @@ -6492,7 +6519,7 @@ def ui_ddf_audit( ) -# ------------------------ Protocol Terminology ----------------------# +# API endpoint to load protocol terminology into the `protocol_terminology` database table def load_protocol_terminology( file_path: str, sheet_name: str = "Protocol Terminology 2025-09-26", @@ -6615,6 +6642,7 @@ def load_protocol_terminology( return {"columns": sanitized, "row_count": len(records)} +# UI endpoint to load new protocol terminology @app.post("/admin/load_protocol_terminology") def admin_load_protocol( file_path: Optional[str] = None, sheet_name: str = "Protocol Terminology 2025-09-26" @@ -6657,6 +6685,7 @@ def admin_load_protocol( ) +# API endpoint to list protocol terminology @app.get("/protocol/terminology") def get_protocol_terminology( search: Optional[str] = None, @@ -6746,6 +6775,7 @@ def get_protocol_terminology( } +# UI endpoint to return protocol terminology @app.get("/ui/protocol/terminology", response_class=HTMLResponse) def ui_protocol_terminology( request: Request, @@ -6782,6 +6812,7 @@ def ui_protocol_terminology( ) +# UI endpoint to upload new protocol terminology @app.post("/ui/protocol/terminology/upload", response_class=HTMLResponse) def ui_protocol_upload( request: Request, @@ -6829,6 +6860,7 @@ def ui_protocol_upload( ) +# API endpoint to record a protocol terminology upload audit def _record_protocol_audit( file_path: str, sheet_name: str, @@ -6897,6 +6929,7 @@ def _record_protocol_audit( logger.warning("Failed recording Protocol audit: %s", e) +# Helper function to return SQLite Protocol Terminology table def _get_protocol_sources() -> List[str]: conn = _connect() cur = conn.cursor() @@ -6914,6 +6947,7 @@ def _get_protocol_sources() -> List[str]: return sources +# UI endpoint to display protocol terminology audits @app.get("/protocol/terminology/audit") def get_protocol_audit( source: Optional[str] = None, start: Optional[str] = None, end: Optional[str] = None @@ -6972,6 +7006,7 @@ def _valid_date(d: str) -> bool: return {"rows": rows} +# API endpoint to export Protocol Terminology audit report in XLSX format @app.get("/protocol/terminology/audit/export.csv") def export_protocol_audit_csv( source: Optional[str] = None, start: Optional[str] = None, end: Optional[str] = None @@ -7020,6 +7055,7 @@ def export_protocol_audit_csv( ) +# API endpoint to export Protocol Terminology audit report in JSON format @app.get("/protocol/terminology/audit/export.json") def export_protocol_audit_json( source: Optional[str] = None, start: Optional[str] = None, end: Optional[str] = None @@ -7028,6 +7064,7 @@ def export_protocol_audit_json( return get_protocol_audit(source=source, start=start, end=end) +# UI endpoint to export Protocol Terminology audit report @app.get("/ui/protocol/terminology/audit", response_class=HTMLResponse) def ui_protocol_audit( request: Request, @@ -7052,11 +7089,12 @@ def ui_protocol_audit( ) -def main(): # pragma: no cover +def main(): import uvicorn uvicorn.run("soa_builder.web.app:app", host="0.0.0.0", port=8000, reload=True) -if __name__ == "__main__": # pragma: no cover +if __name__ == "__main__": + main() diff --git a/src/soa_builder/web/migrate_database.py b/src/soa_builder/web/migrate_database.py index 30b3396..d820d0d 100644 --- a/src/soa_builder/web/migrate_database.py +++ b/src/soa_builder/web/migrate_database.py @@ -871,3 +871,49 @@ def _backfill_dataset_date(table: str, audit_table: str): conn.close() except Exception as e: # pragma: no cover logger.warning("dataset_date backfill for %s failed: %s", table, e) + + +def _migrate_visit_columns(): + """Add missing columns to the database table `visit` + New columns: + - description: string + - type: string + - environmentalSettings: string[] + - contactModes: string[] + - transitionStartRule: string + - transitionEndRule: string + + (environmentalSettings & contactModes are officially list but + are only single string values in the first iteration of the app + """ + try: + conn = _connect() + cur = conn.cursor() + cur.execute("PRAGMA table_info(visit)") + cols = {r[1] for r in cur.fetchall()} + alters = [] + if "description" not in cols: + alters.append("ALTER TABLE visit ADD COLUMN description TEXT") + if "type" not in cols: + alters.append("ALTER TABLE visit ADD COLUMN type TEXT") + if "environmentalSettings" not in cols: + alters.append("ALTER TABLE visit ADD COLUMN environmentalSettings TEXT") + if "contactModes" not in cols: + alters.append("ALTER TABLE visit ADD COLUMN contactModes TEXT") + if "transitionStartRule" not in cols: + alters.append("ALTER TABLE visit ADD COLUMN transitionStartRule TEXT") + if "transitionEndRule" not in cols: + alters.append("ALTER TABLE visit ADD COLUMN transitionEndRule TEXT") + if "scheduledAtId" not in cols: + alters.append("ALTER TABLE visit ADD COLUMN scheduledAtId TEXT") + for stmt in alters: + try: + cur.execute(stmt) + except Exception as e: # pragma: no cover + logger.warning("Failed visit field migration '%s': %s", stmt, e) + if alters: + conn.commit() + logger.info("Applied visit column migration: %s", ", ".join(alters)) + conn.close() + except Exception as e: # pragma: no cover + logger.warning("visit table migration failed: %s", e) diff --git a/src/soa_builder/web/routers/arms.py b/src/soa_builder/web/routers/arms.py index 53f2a7c..f5330f6 100644 --- a/src/soa_builder/web/routers/arms.py +++ b/src/soa_builder/web/routers/arms.py @@ -72,10 +72,11 @@ def create_arm(soa_id: int, payload: ArmCreate): "Invalid arm_uid format encountered (ignored for numbering): %s", uid, ) - next_n = 1 - while next_n in used_nums: - next_n += 1 + + # Always pick max(existing) + 1, do not fill gaps + next_n = (max(used_nums) if used_nums else 0) + 1 new_uid = f"StudyArm_{next_n}" + cur.execute( """INSERT INTO arm (soa_id,name,label,description,type,data_origin_type,order_index,arm_uid) VALUES (?,?,?,?,?,?,?,?)""", diff --git a/src/soa_builder/web/routers/audits.py b/src/soa_builder/web/routers/audits.py new file mode 100644 index 0000000..f939d75 --- /dev/null +++ b/src/soa_builder/web/routers/audits.py @@ -0,0 +1,212 @@ +import logging +import os + +from fastapi import APIRouter, HTTPException, Request +from fastapi.templating import Jinja2Templates +from fastapi.responses import HTMLResponse +from ..utils import soa_exists +from ..db import _connect + + +router = APIRouter() +logger = logging.getLogger("soa_builder.web.routers.audits") +templates = Jinja2Templates( + directory=os.path.join(os.path.dirname(__file__), "..", "templates") +) + + +@router.get("/ui/soa/{soa_id}/audits", response_class=HTMLResponse) +def ui_list_audits(request: Request, soa_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + conn = _connect() + # print("Connection made...{}".format(soa_id)) + + # Get the Activity Audits + activity_cur = conn.cursor() + activity_cur.execute( + "SELECT id, activity_id, action, before_json, after_json, performed_at FROM activity_audit WHERE soa_id=? ORDER BY id DESC LIMIT 20", + (soa_id,), + ) + activity_audits = [ + { + "id": r[0], + "activity_id": r[1], + "action": r[2], + "before_json": r[3], + "after_json": r[4], + "performed_at": r[5], + } + for r in activity_cur.fetchall() + ] + activity_cur.close() + + # Get the Arm Audits + arm_cur = conn.cursor() + arm_cur.execute( + "SELECT id, arm_id, action, before_json, after_json, performed_at FROM arm_audit WHERE soa_id=? ORDER BY id DESC LIMIT 20", + (soa_id,), + ) + arm_audits = [ + { + "id": r[0], + "arm_id": r[1], + "action": r[2], + "before_json": r[3], + "after_json": r[4], + "performed_at": r[5], + } + for r in arm_cur.fetchall() + ] + arm_cur.close() + + # Get Epoch Audits + epoch_cur = conn.cursor() + epoch_cur.execute( + "SELECT id, epoch_id, action, before_json, after_json, performed_at FROM epoch_audit WHERE soa_id=? ORDER BY id DESC LIMIT 20", + (soa_id,), + ) + epoch_audits = [ + { + "id": r[0], + "epoch_id": r[1], + "action": r[2], + "before_json": r[3], + "after_json": r[4], + "performed_at": r[5], + } + for r in epoch_cur.fetchall() + ] + epoch_cur.close() + + # Get Element Audits + element_cur = conn.cursor() + element_cur.execute( + "SELECT id,element_id,action,before_json,after_json,performed_at FROM element_audit WHERE soa_id=? ORDER BY id DESC LIMIT 20", + (soa_id,), + ) + element_audits = [ + { + "id": r[0], + "element_id": r[1], + "action": r[2], + "before_json": r[3], + "after_json": r[4], + "performed_at": r[5], + } + for r in element_cur.fetchall() + ] + element_cur.close() + + # Get StudyCell Audits + study_cell_cur = conn.cursor() + study_cell_cur.execute( + "SELECT id,study_cell_id,action,before_json,after_json,performed_at FROM study_cell_audit WHERE soa_id=? ORDER BY id DESC LIMIT 20", + (soa_id,), + ) + study_cell_audits = [ + { + "id": r[0], + "study_cell_id": r[1], + "action": r[2], + "before_json": r[3], + "after_json": r[4], + "performed_at": r[5], + } + for r in study_cell_cur.fetchall() + ] + study_cell_cur.close() + + # Get Transition Rule Audits + transition_rule_cur = conn.cursor() + transition_rule_cur.execute( + "SELECT id,transition_rule_id,action,before_json,after_json,performed_at FROM transition_rule_audit WHERE soa_id=? ORDER BY id DESC LIMIT 20", + (soa_id,), + ) + transition_rule_audits = [ + { + "id": r[0], + "transition_rule_id": r[1], + "action": r[2], + "before_json": r[3], + "after_json": r[4], + "performed_at": r[5], + } + for r in transition_rule_cur.fetchall() + ] + transition_rule_cur.close() + + # Get Visit Audits + visit_cur = conn.cursor() + visit_cur.execute( + "SELECT id,visit_id,action,before_json,after_json,performed_at FROM visit_audit WHERE soa_id=? ORDER BY id DESC LIMIT 20", + (soa_id,), + ) + visit_audits = [ + { + "id": r[0], + "visit_id": r[1], + "action": r[2], + "before_json": r[3], + "after_json": r[4], + "performed_at": r[5], + } + for r in visit_cur.fetchall() + ] + visit_cur.close() + + # Get Timing Audits + timing_cur = conn.cursor() + timing_cur.execute( + "SELECT id,timing_id,action,before_json,after_json,performed_at FROM timing_audit WHERE soa_id=? ORDER BY id DESC LIMIT 20", + (soa_id,), + ) + timing_audits = [ + { + "id": r[0], + "timing_id": r[1], + "action": r[2], + "before_json": r[3], + "after_json": r[4], + "performed_at": r[5], + } + for r in timing_cur.fetchall() + ] + timing_cur.close() + + # Get Scheduled Activity Instances Audits + instance_cur = conn.cursor() + instance_cur.execute( + "SELECT id,instance_id,action,before_json,after_json,performed_at FROM instance_audit WHERE soa_id=? ORDER BY id DESC LIMIT 20", + (soa_id,), + ) + instance_audits = [ + { + "id": r[0], + "instance_id": r[1], + "action": r[2], + "before_json": r[3], + "after_json": r[4], + "performed_at": r[5], + } + for r in instance_cur.fetchall() + ] + instance_cur.close() + + return templates.TemplateResponse( + request, + "audits.html", + { + "activity_audits": activity_audits, + "arm_audits": arm_audits, + "epoch_audits": epoch_audits, + "element_audits": element_audits, + "study_cell_audits": study_cell_audits, + "transition_rule_audits": transition_rule_audits, + "visit_audits": visit_audits, + "timing_audits": timing_audits, + "instance_audits": instance_audits, + "soa_id": soa_id, + }, + ) diff --git a/src/soa_builder/web/routers/visits.py b/src/soa_builder/web/routers/visits.py index ad10ff9..0a0e241 100644 --- a/src/soa_builder/web/routers/visits.py +++ b/src/soa_builder/web/routers/visits.py @@ -6,7 +6,7 @@ from ..audit import _record_reorder_audit, _record_visit_audit from ..db import _connect -from ..utils import soa_exists +from ..utils import soa_exists, get_next_code_uid as _get_next_code_uid from ..schemas import VisitCreate, VisitUpdate router = APIRouter(prefix="/soa/{soa_id}") @@ -84,11 +84,8 @@ def add_visit(soa_id: int, payload: VisitCreate): conn = _connect() cur = conn.cursor() - # Replace existing block with new block to create new encounter_uid and increment order_index - # cur.execute("SELECT COUNT(*) FROM visit WHERE soa_id=?", (soa_id,)) - # order_index = cur.fetchone()[0] + 1 - # New code to calculate order_index + # order_index cur.execute( "SELECT COALESCE(MAX(order_index),0) FROM visit WHERE soa_id=?", (soa_id,), @@ -123,8 +120,40 @@ def add_visit(soa_id: int, payload: VisitCreate): conn.close() raise HTTPException(400, "Invalid epoch_id for this SOA") + # Generate Code_{N} for encounter.type + type_uid = _get_next_code_uid(cur, soa_id) + logger.info("type_uid=%s", type_uid) + + if type_uid: + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + type_uid, + "ddf_terminology", + "C188728", + "C25716", + ), + ) + + # Generate Code_{N} for environmentalSettings.type + es_uid = _get_next_code_uid(cur, soa_id) + logger.info("es_uid=%s", es_uid) + + if es_uid: + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + es_uid, + "http://www.cdisc.org", + "C127262", + "C51282", + ), + ) + cur.execute( - "INSERT INTO visit (soa_id,name,label,order_index,epoch_id,encounter_uid,description) VALUES (?,?,?,?,?,?,?)", + "INSERT INTO visit (soa_id,name,label,order_index,epoch_id,encounter_uid,description,type,environmentalSettings) VALUES (?,?,?,?,?,?,?,?,?)", ( soa_id, name, @@ -133,6 +162,8 @@ def add_visit(soa_id: int, payload: VisitCreate): payload.epoch_id, new_uid, _nz(payload.description), + type_uid, + es_uid, ), ) encounter_id = cur.lastrowid diff --git a/src/soa_builder/web/static/style.css b/src/soa_builder/web/static/style.css index 6c3d410..06e23ea 100644 --- a/src/soa_builder/web/static/style.css +++ b/src/soa_builder/web/static/style.css @@ -9,3 +9,22 @@ th { background: #f2f2f2; } .matrix td.cell:hover { background: #ffe; } form input { margin-right: 0.5rem; } button { cursor: pointer; } +.delete-btn { background:#c62828; color:#fff; border:none; padding:0 6px; cursor:pointer; } +.delete-btn:hover { background:#b71c1c; } +.export-buttons { margin-top:8px; } +.export-buttons .btn { display:inline-block; margin-right:10px; background:#1976d2; color:#fff; padding:4px 10px; text-decoration:none; border-radius:3px; font-size:0.9em; } +.export-buttons .btn:hover { background:#125aa0; } +.small-btn { background:#2e7d32; color:#fff; border:none; padding:2px 8px; margin-top:4px; cursor:pointer; font-size:0.75em; } +.small-btn:hover { background:#1b5e20; } +/* Removed per-activity concept editor styles */ +details.collapsible { border:1px solid #ddd; padding:6px 8px; border-radius:4px; background:#fafafa; } +details.collapsible summary { cursor:pointer; font-weight:600; } +details.collapsible[open] { background:#fdfdfd; } +.drag-list { list-style:none; margin:0; padding:0; } +.drag-item { padding:3px 4px; margin:2px 0; background:#fff; border:1px solid #e0e0e0; border-radius:3px; cursor:grab; display:flex; align-items:center; gap:4px; } +.drag-item.dragging { opacity:0.5; } +.drag-item.over { border-color:#1976d2; background:#e3f2fd; } +.drag-item form { margin-left:0; } +.visit-actions { display:inline-flex; align-items:center; gap:8px; margin-left:auto; } +.activity-actions { display:inline-flex; align-items:center; gap:8px; margin-left:auto; } +.hint { font-weight:400; font-size:0.7em; color:#666; } \ No newline at end of file diff --git a/src/soa_builder/web/templates/audits.html b/src/soa_builder/web/templates/audits.html index e69de29..c2776a4 100644 --- a/src/soa_builder/web/templates/audits.html +++ b/src/soa_builder/web/templates/audits.html @@ -0,0 +1,236 @@ +{% extends 'base.html' %} +{% block content %} +

Entity Audits for {{ soa_id }}

+ + +
+
+
+ Visit Audit (latest {{ visit_audits|length }}) + {% if visit_audits %} + + + + + + + + + + {% for va in visit_audits %} + + + + + + + + + {% endfor %} +
IDVisitActionPerformedBeforeAfter
{{ va.id }}{{ va.visit_id }}{{ va.action }}{{ va.performed_at }}{{ va.before_json or '' }}{{ va.after_json or '' }}
+ {% else %} +
No visit audit entries yet.
+ {% endif %} +
+
+ Activity Audit (latest {{ activity_audits|length }}) + {% if activity_audits %} + + + + + + + + + + {% for aa in activity_audits %} + + + + + + + + + {% endfor %} +
IDActivityActionPerformedBeforeAfter
{{ aa.id }}{{ aa.activity_id }}{{ aa.action }}{{ aa.performed_at }}{{ aa.before_json or '' }}{{ aa.after_json or '' }}
+ {% else %} +
No activity audit entries yet.
+ {% endif %} +
+
+ Arm Audit (latest {{ arm_audits|length }}) + {% if arm_audits %} + + + + + + + + + + {% for au in arm_audits %} + + + + + + + + + {% endfor %} +
IDArmActionPerformedBeforeAfter
{{ au.id }}{{ au.arm_id }}{{ au.action }}{{ au.performed_at }}{{ au.before_json or '' }}{{ au.after_json or '' }}
+ {% else %} +
No arm audit entries yet.
+ {% endif %} +
+
+ Epoch Audit (latest {{ epoch_audits|length }}) + {% if epoch_audits %} + + + + + + + + + + {% for au in epoch_audits %} + + + + + + + + + {% endfor %} +
IDEpochActionPerformedBeforeAfter
{{ au.id }}{{ au.epoch_id }}{{ au.action }}{{ au.performed_at }}{{ au.before_json or '' }}{{ au.after_json or '' }}
+ {% else %} +
No epoch audit entries yet.
+ {% endif %} +
+
+ Study Cell Audit (latest {{ study_cell_audits|length }}) + {% if study_cell_audits %} + + + + + + + + + + {% for au in study_cell_audits %} + + + + + + + + + {% endfor %} +
IDStudy CellActionPerformedBeforeAfter
{{ au.id }}{{ au.study_cell_id }}{{ au.action }}{{ au.performed_at }}{{ au.before_json or '' }}{{ au.after_json or '' }}
+ {% else %} +
No study cell audit entries yet.
+ {% endif %} +
+
+ Transition Rule Audit (latest {{ transition_rule_audits|length }}) + {% if transition_rule_audits %} + + + + + + + + + + {% for tra in transition_rule_audits %} + + + + + + + + + {% endfor %} +
IDTransition RuleActionPerformedBeforeAfter
{{ tra.id }}{{ tra.transition_rule_id }}{{ tra.action }}{{ tra.performed_at }}{{ tra.before_json or '' }}{{ tra.after_json or '' }}
+ {% else %} +
No transition rule audit entries yet.
+ {% endif %} +
+
+ Timing Audit (latest {{ timing_audits|length }}) + {% if timing_audits %} + + + + + + + + + + {% for ta in timing_audits %} + + + + + + + + + {% endfor %} +
IDTimingActionPerformedBeforeAfter
{{ ta.id }}{{ ta.timing_id }}{{ ta.action }}{{ ta.performed_at }}{{ ta.before_json or '' }}{{ ta.after_json or '' }}
+ {% else %} +
No timing audit entries yet.
+ {% endif %} +
+
+ Scheduled Activity Instances Audit (latest {{ instance_audits|length }}) + {% if instance_audits %} + + + + + + + + + + {% for ia in instance_audits %} + + + + + + + + + {% endfor %} +
IDScheduled Activity InstanceActionPerformedBeforeAfter
{{ ia.id }}{{ ia.instance_id }}{{ ia.action }}{{ ia.performed_at }}{{ ia.before_json or '' }}{{ ia.after_json or '' }}
+ {% else %} +
No Scheduled Activity Instances audit entries yet.
+ {% endif %} +
+
+ + + +
+ + + +{% endblock %} \ No newline at end of file diff --git a/src/soa_builder/web/templates/edit.html b/src/soa_builder/web/templates/edit.html index e06f94e..e066cd0 100644 --- a/src/soa_builder/web/templates/edit.html +++ b/src/soa_builder/web/templates/edit.html @@ -86,14 +86,14 @@

Editing SoA {{ soa_id }}

Visits ({{ visits|length }}) (drag to reorder) @@ -236,41 +243,44 @@

Editing SoA {{ soa_id }}

@@ -369,7 +379,29 @@

Editing SoA {{ soa_id }}

Transition Rules ({{ transition_rules|length }}) - {% include 'transition_rules_list.html' %} +
@@ -379,117 +411,7 @@

Editing SoA {{ soa_id }}

-
-
- Activity Audit (latest {{ activity_audits|length }}) - {% if activity_audits %} - - - - - - - - - - {% for au in activity_audits %} - - - - - - - - - {% endfor %} -
IDActivityActionPerformedBeforeAfter
{{ au.id }}{{ au.activity_id }}{{ au.action }}{{ au.performed_at }}{{ au.before_json or '' }}{{ au.after_json or '' }}
- {% else %} -
No activity audit entries yet.
- {% endif %} -
-
- Arm Audit (latest {{ arm_audits|length }}) - {% if arm_audits %} - - - - - - - - - - {% for au in arm_audits %} - - - - - - - - - {% endfor %} -
IDArmActionPerformedBeforeAfter
{{ au.id }}{{ au.arm_id }}{{ au.action }}{{ au.performed_at }}{{ au.before_json or '' }}{{ au.after_json or '' }}
- {% else %} -
No arm audit entries yet.
- {% endif %} -
-
- Epoch Audit (latest {{ epoch_audits|length }}) - {% if epoch_audits %} - - - - - - - - - - {% for au in epoch_audits %} - - - - - - - - - {% endfor %} -
IDEpochActionPerformedBeforeAfter
{{ au.id }}{{ au.epoch_id }}{{ au.action }}{{ au.performed_at }}{{ au.before_json or '' }}{{ au.after_json or '' }}
- {% else %} -
No epoch audit entries yet.
- {% endif %} -
-
- Study Cell Audit (latest {{ study_cell_audits|length }}) - {% if study_cell_audits %} - - - - - - - - - - {% for au in study_cell_audits %} - - - - - - - - - {% endfor %} -
IDStudy CellActionPerformedBeforeAfter
{{ au.id }}{{ au.study_cell_id }}{{ au.action }}{{ au.performed_at }}{{ au.before_json or '' }}{{ au.after_json or '' }}
- {% else %} -
No study cell audit entries yet.
- {% endif %} -
- {% include 'element_audit_section.html' %} -
+

Matrix

@@ -546,6 +468,11 @@

Matrix

.drag-item.over { border-color:#1976d2; background:#e3f2fd; } .drag-item form { margin-left:0; } .visit-actions { display:inline-flex; align-items:center; gap:8px; margin-left:auto; } + .activity-actions { display:inline-flex; align-items:center; gap:8px; margin-left:auto; } + .epochs-actions { display:inline-flex; align-items:center; gap:8px; margin-left:auto; } + .element-actions { display:inline-flex; align-items:center; gap:8px; margin-left:auto; } + .arm-actions { display:inline-flex; align-items:center; gap:8px; margin-left:auto; } + .transition-rule-actions { display:inline-flex; align-items:center; gap:8px; margin-left:auto; } .hint { font-weight:400; font-size:0.7em; color:#666; }