diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index 8adc83a..f87a697 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -68,6 +68,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 timings as timings_router from .routers.arms import create_arm # re-export for backward compatibility from .routers.arms import delete_arm from .schemas import ArmCreate, SOACreate, SOAMetadataUpdate @@ -317,6 +318,7 @@ def _record_arm_audit( app.include_router(epochs_router.router) app.include_router(freezes_router.router) app.include_router(rollback_router.router) +app.include_router(timings_router.router) @app.post("/soa/{soa_id}/visits/reorder", response_class=JSONResponse) @@ -408,14 +410,6 @@ def reorder_activities_api(soa_id: int, order: List[int]): return JSONResponse({"ok": True, "old_order": old_order, "new_order": order}) -class ConceptsUpdate(BaseModel): - concept_codes: List[str] - - -class FreezeCreate(BaseModel): - version_label: Optional[str] = None - - def _list_freezes(soa_id: int): conn = _connect() cur = conn.cursor() @@ -1052,6 +1046,15 @@ def _rollback_preview(soa_id: int, freeze_id: int) -> dict: } +# ------ Schemas -----# +class ConceptsUpdate(BaseModel): + concept_codes: List[str] + + +class FreezeCreate(BaseModel): + version_label: Optional[str] = None + + class CellCreate(BaseModel): visit_id: int activity_id: int @@ -1078,12 +1081,6 @@ class MatrixImport(BaseModel): reset: bool = True -# --------------------- Helpers --------------------- - - -# Use shared utils.soa_exists instead of local helper - - def _fetch_matrix(soa_id: int): conn = _connect() cur = conn.cursor() @@ -2151,23 +2148,11 @@ def update_soa_metadata(soa_id: int, payload: SOAMetadataUpdate): """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.""" @@ -5902,7 +5887,6 @@ def _epoch_types_snapshot(soa_id_int: int) -> list[dict]: return HTMLResponse("OK") -# --------------------- DDF Terminology Load --------------------- 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 @@ -5915,6 +5899,7 @@ def _sanitize_column(name: str) -> str: return s +# ------------------------- DDF Terminology ----------------------# def load_ddf_terminology( file_path: str, sheet_name: str = "DDF Terminology 2025-09-26", @@ -6507,7 +6492,7 @@ def ui_ddf_audit( ) -# Protocol Terminology functions +# ------------------------ Protocol Terminology ----------------------# def load_protocol_terminology( file_path: str, sheet_name: str = "Protocol Terminology 2025-09-26", diff --git a/src/soa_builder/web/audit.py b/src/soa_builder/web/audit.py index b2847ef..da3b265 100644 --- a/src/soa_builder/web/audit.py +++ b/src/soa_builder/web/audit.py @@ -190,3 +190,42 @@ def _record_study_cell_audit( conn.close() except Exception as e: logger.warning("Failed recording study_cell audit: %s", e) + + +def _record_timing_audit( + soa_id: int, + action: str, + timing_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 timing_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + soa_id INTEGER NOT NULL, + timing_id INTEGER, + action TEXT NOT NULL, + before_json TEXT, + after_json TEXT, + performed_at TEXT NOT NULL + )""" + ) + cur.execute( + "INSERT INTO timing_audit (soa_id, timing_id, action, before_json, after_json, performed_at) VALUES (?,?,?,?,?,?)", + ( + soa_id, + timing_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 timing audit: %s", e) diff --git a/src/soa_builder/web/initialize_database.py b/src/soa_builder/web/initialize_database.py index b591e34..8c7182d 100644 --- a/src/soa_builder/web/initialize_database.py +++ b/src/soa_builder/web/initialize_database.py @@ -255,5 +255,41 @@ def _init_db(): )""" ) + # create the timing table + cur.execute( + """CREATE TABLE IF NOT EXISTS timing ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + soa_id INTEGER NOT NULL, + timing_uid TEXT NOT NULL, -- immutable Timing_N identifier unique within SOA + name TEXT NOT NULL, + label TEXT, + description TEXT, + type TEXT, -- value chosen from submissionValue in codelist_code C201264 + value TEXT, + value_label TEXT, + relative_to_from TEXT, -- value chosen from submissionValue in codelist_code C201265 + relative_from_schedule_instance TEXT, + relative_to_schedule_instance TEXT, + window_label TEXT, + window_upper TEXT, + window_lower TEXT, + order_index INTEGER, + UNIQUE(soa_id, timing_uid) + )""" + ) + + # create timing_audit table + cur.execute( + """CREATE TABLE IF NOT EXISTS timing_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + soa_id INT NOT NULL, + timing_id INT NOT NULL, + action TEXT NOT NULL, -- create|update|delete + before_json TEXT, + after_json TEXT, + performed_at TEXT + )""" + ) + conn.commit() conn.close() diff --git a/src/soa_builder/web/routers/timings.py b/src/soa_builder/web/routers/timings.py new file mode 100644 index 0000000..f48d0ae --- /dev/null +++ b/src/soa_builder/web/routers/timings.py @@ -0,0 +1,653 @@ +import logging +import json +import os +from typing import Optional + +from fastapi import APIRouter, HTTPException, Request, Form +from fastapi.responses import JSONResponse, HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates + +from ..audit import _record_timing_audit +from ..db import _connect +from ..schemas import TimingCreate, TimingUpdate +from ..utils import soa_exists +from ..utils import get_study_timing_type +from ..utils import get_next_code_uid as _get_next_code_uid + +router = APIRouter() +logger = logging.getLogger("soa_builder.web.routers.timings") +templates = Jinja2Templates( + directory=os.path.join(os.path.dirname(__file__), "..", "templates") +) + + +def _nz(s: Optional[str]) -> Optional[str]: + s = (s or "").strip() + return s or None + + +# UI code to list timings in an SOA +@router.get("/ui/soa/{soa_id}/timings", response_class=HTMLResponse) +def ui_list_timings(request: Request, soa_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + timings = list_timings(soa_id) + # Build mapping: submissionValue -> code and reverse + try: + sv_to_code = get_study_timing_type("C201264") + except Exception: + sv_to_code = {} + # Mapping for Relative To/From (C201265) + try: + sv_to_code_rtf = get_study_timing_type("C201265") + except Exception: + sv_to_code_rtf = {} + code_to_sv = {v: k for k, v in (sv_to_code or {}).items()} + code_to_sv_rtf = {v: k for k, v in (sv_to_code_rtf or {}).items()} + # Map timing.type (code_uid) -> code via code table, then to submissionValue + conn = _connect() + cur = conn.cursor() + cur.execute("SELECT code_uid, code FROM code WHERE soa_id=?", (soa_id,)) + code_uid_to_code = {r[0]: r[1] for r in cur.fetchall() if r[0]} + conn.close() + for t in timings: + sv = None + cu = t.get("type") + if cu and cu in code_uid_to_code: + code_val = code_uid_to_code.get(cu) + sv = code_to_sv.get(str(code_val)) + t["type_submission_value"] = sv + # Decode relative_to_from (stores code_uid) -> submission value + rtf_sv = None + rtf_cu = t.get("relative_to_from") + if rtf_cu and rtf_cu in code_uid_to_code: + rtf_code_val = code_uid_to_code.get(rtf_cu) + rtf_sv = code_to_sv_rtf.get(str(rtf_code_val)) + t["relative_to_from_submission_value"] = rtf_sv + return templates.TemplateResponse( + request, + "timings.html", + { + "request": request, + "soa_id": soa_id, + "timings": timings, + "timing_type_options": sorted(list(sv_to_code.keys())), + "relative_to_from_options": sorted(list(sv_to_code_rtf.keys())), + }, + ) + + +# UI code to create a timing for an SOA +@router.post("/ui/soa/{soa_id}/timings/create") +def ui_create_timing( + request: Request, + soa_id: int, + name: str = Form(...), + label: Optional[str] = Form(None), + description: Optional[str] = Form(None), + type_submission_value: Optional[str] = Form(None), + value: Optional[str] = Form(None), + value_label: Optional[str] = Form(None), + relative_to_from_submission_value: Optional[str] = Form(None), + relative_from_schedule_instance: Optional[str] = Form(None), + relative_to_schedule_instance: Optional[str] = Form(None), + window_label: Optional[str] = Form(None), + window_upper: Optional[str] = Form(None), + window_lower: Optional[str] = Form(None), +): + # Map selected submission value to code_uid stored in code table + code_uid: Optional[str] = None + sv = (type_submission_value or "").strip() + if sv: + try: + sv_to_code = get_study_timing_type("C201264") + code_val = sv_to_code.get(sv) + if code_val: + conn_c = _connect() + cur_c = conn_c.cursor() + code_uid = _get_next_code_uid(cur_c, soa_id) + cur_c.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + code_uid, + "ddf_terminology", + "C201264", + str(code_val), + ), + ) + conn_c.commit() + conn_c.close() + except Exception: + code_uid = None + # Map Relative To/From submission value to code_uid + rtf_code_uid: Optional[str] = None + rsv = (relative_to_from_submission_value or "").strip() + if rsv: + try: + sv_to_code_rtf = get_study_timing_type("C201265") + rtf_code_val = sv_to_code_rtf.get(rsv) + if rtf_code_val: + conn_c2 = _connect() + cur_c2 = conn_c2.cursor() + rtf_code_uid = _get_next_code_uid(cur_c2, soa_id) + cur_c2.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + rtf_code_uid, + "ddf_terminology", + "C201265", + str(rtf_code_val), + ), + ) + conn_c2.commit() + conn_c2.close() + except Exception: + rtf_code_uid = None + payload = TimingCreate( + name=name, + label=label, + description=description, + type=code_uid, + value=value, + value_label=value_label, + relative_to_from=rtf_code_uid, + relative_from_schedule_instance=relative_from_schedule_instance, + relative_to_schedule_instance=relative_to_schedule_instance, + window_label=window_label, + window_upper=window_upper, + window_lower=window_lower, + ) + create_timing(soa_id, payload) + return RedirectResponse(url=f"/ui/soa/{soa_id}/timings", status_code=303) + + +# API endpoint to list timings for SOA +@router.get("/soa/{soa_id}/timings", response_class=JSONResponse, response_model=None) +def list_timings(soa_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id,timing_uid,name,label,description,type, " + "value,value_label,relative_to_from,relative_from_schedule_instance, " + "relative_to_schedule_instance,window_label,window_upper,window_lower,order_index " + "FROM timing WHERE soa_id=? order by order_index, id", + (soa_id,), + ) + rows = [ + { + "id": r[0], + "timing_uid": r[1], + "name": r[2], + "label": r[3], + "description": r[4], + "type": r[5], + "value": r[6], + "value_label": r[7], + "relative_to_from": r[8], + "relative_from_schedule_instance": r[9], + "relative_to_schedule_instance": r[10], + "window_label": r[11], + "window_upper": r[12], + "window_lower": r[13], + "order_index": r[14], + } + for r in cur.fetchall() + ] + conn.close() + return rows + + +@router.get("/soa/{soa_id}/timing_audit", response_class=JSONResponse) +def list_timing_audit(soa_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + conn = _connect() + cur = conn.cursor() + try: + cur.execute( + "SELECT id, timing_id, action, before_json, after_json, performed_at FROM timing_audit WHERE soa_id=? ORDER BY id DESC", + (soa_id,), + ) + except Exception: + # If table does not exist yet, return empty list for backward compatibility + conn.close() + return JSONResponse([]) + rows = [] + for r in cur.fetchall(): + try: + before = json.loads(r[3]) if r[3] else None + except Exception: + before = None + try: + after = json.loads(r[4]) if r[4] else None + except Exception: + after = None + rows.append( + { + "id": r[0], + "timing_id": r[1], + "action": r[2], + "before": before, + "after": after, + "performed_at": r[5], + } + ) + conn.close() + return JSONResponse(rows) + + +# API endpoint for creating a timing in an SOA +@router.post( + "/soa/{soa_id}/timings", + response_class=JSONResponse, + status_code=201, + response_model=None, +) +def create_timing(soa_id: int, payload: TimingCreate): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + name = (payload.name or "").strip() + if not name: + raise HTTPException(400, "Timing name required") + + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT COALESCE(MAX(order_index),0) FROM timing WHERE soa_id=?", + (soa_id,), + ) + next_ord = (cur.fetchone() or [0])[0] + 1 + cur.execute( + "SELECT timing_uid FROM timing WHERE soa_id=? AND timing_uid LIKE 'Timing_%'", + (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("Timing_"): + tail = uid[len("Timing_") :] + if tail.isdigit(): + used_nums.add(int(tail)) + else: + logger.warning( + "Invalid timing_uid format encountered (ignored): %s", + uid, + ) + next_n = 1 + while next_n in used_nums: + next_n += 1 + new_uid = f"Timing_{next_n}" + cur.execute( + """INSERT INTO timing (soa_id,timing_uid,name,label,description,type,value,value_label, + relative_to_from,relative_from_schedule_instance,relative_to_schedule_instance,window_label, + window_upper,window_lower,order_index) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + soa_id, + new_uid, + name, + _nz(payload.label), + _nz(payload.description), + _nz(payload.type), + _nz(payload.value), + _nz(payload.value_label), + _nz(payload.relative_to_from), + _nz(payload.relative_from_schedule_instance), + _nz(payload.relative_to_schedule_instance), + _nz(payload.window_label), + _nz(payload.window_upper), + _nz(payload.window_lower), + next_ord, + ), + ) + timing_id = cur.lastrowid + conn.commit() + conn.close() + row = { + "id": timing_id, + "timing_uid": new_uid, + "name": name, + "label": (payload.label or "").strip() or None, + "description": (payload.description or "").strip() or None, + "order_index": next_ord, + } + _record_timing_audit(soa_id, "create", timing_id, before=None, after=row) + return row + + +# API endpoint to update a timing in an SOA +@router.patch( + "/soa/{soa_id}/timings/{timing_id}", + response_class=JSONResponse, + response_model=None, +) +def update_timing(soa_id: int, timing_id: int, payload: TimingUpdate): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id,timing_uid,name,label,description,type,value,value_label,relative_to_from," + "relative_from_schedule_instance,relative_to_schedule_instance,window_label,window_upper," + "window_lower,order_index FROM timing WHERE soa_id=? AND id=?", + ( + soa_id, + timing_id, + ), + ) + row = cur.fetchone() + if not row: + conn.close() + raise HTTPException(404, f"Timing id={timing_id} not found") + + before = { + "id": row[0], + "timing_uid": row[1], + "name": row[2], + "label": row[3], + "description": row[4], + "type": row[5], + "value": row[6], + "value_label": row[7], + "relative_to_from": row[8], + "relative_from_schedule_instance": row[9], + "relative_to_schedule_instance": row[10], + "window_label": row[11], + "window_upper": row[12], + "window_lower": row[13], + "order_index": row[14], + } + new_name = (payload.name if payload.name is not None else before["name"]) or "" + new_label = payload.label if payload.label is not None else before["label"] + new_description = ( + payload.description + if payload.description is not None + else before["description"] + ) + new_type = payload.type if payload.type is not None else before["type"] + new_value = payload.value if payload.value is not None else before["value"] + new_value_label = ( + payload.value_label + if payload.value_label is not None + else before["value_label"] + ) + new_relative_to_from = ( + payload.relative_to_from + if payload.relative_to_from is not None + else before["relative_to_from"] + ) + new_relative_from_schedule_instance = ( + payload.relative_from_schedule_instance + if payload.relative_from_schedule_instance is not None + else before["relative_from_schedule_instance"] + ) + new_relative_to_schedule_instance = ( + payload.relative_to_schedule_instance + if payload.relative_to_schedule_instance is not None + else before["relative_to_schedule_instance"] + ) + new_window_label = ( + payload.window_label + if payload.window_label is not None + else before["window_label"] + ) + new_window_upper = ( + payload.window_upper + if payload.window_upper is not None + else before["window_upper"] + ) + new_window_lower = ( + payload.window_lower + if payload.window_lower is not None + else before["window_lower"] + ) + + cur.execute( + "UPDATE timing SET name=?, label=?, description=?, type=?, value=?, value_label=?, " + "relative_to_from=?, relative_from_schedule_instance=?, relative_to_schedule_instance=?, " + "window_label=?, window_upper=?, window_lower=? WHERE id=? AND soa_id=?", + ( + _nz(new_name), + _nz(new_label), + _nz(new_description), + _nz(new_type), + _nz(new_value), + _nz(new_value_label), + _nz(new_relative_to_from), + _nz(new_relative_from_schedule_instance), + _nz(new_relative_to_schedule_instance), + _nz(new_window_label), + _nz(new_window_upper), + _nz(new_window_lower), + timing_id, + soa_id, + ), + ) + conn.commit() + cur.execute( + "SELECT id,timing_uid,name,label,description,type,value,value_label,relative_to_from," + "relative_from_schedule_instance,relative_to_schedule_instance,window_label,window_upper," + "window_lower,order_index FROM timing WHERE soa_id=? AND id=?", + (soa_id, timing_id), + ) + r = cur.fetchone() + conn.close() + after = { + "id": r[0], + "timing_uid": r[1], + "name": r[2], + "label": r[3], + "description": r[4], + "type": r[5], + "value": r[6], + "value_label": r[7], + "relative_to_from": r[8], + "relative_from_schedule_instance": r[9], + "relative_to_schedule_instance": r[10], + "window_label": r[11], + "window_upper": r[12], + "window_lower": r[13], + "order_index": r[14], + } + mutable = [ + "name", + "label", + "description", + "type", + "value", + "value_label", + "relative_to_from", + "relative_from_schedule_instance", + "relative_to_schedule_instance", + "window_label", + "window_upper", + "window_lower", + ] + update_fields = [ + f for f in mutable if (before.get(f) or None) != (after.get(f) or None) + ] + _record_timing_audit( + soa_id, + "update", + timing_id, + before=before, + after={**after, "updated_fields": update_fields}, + ) + return {**after, "updated_fields": update_fields} + + +# UI code to update a timing in an SOA +@router.post("/ui/soa/{soa_id}/timings/{timing_id}/update") +def ui_update_timing( + request: Request, + soa_id: int, + timing_id: int, + name: Optional[str] = Form(None), + label: Optional[str] = Form(None), + description: Optional[str] = Form(None), + type_submission_value: Optional[str] = Form(None), + value: Optional[str] = Form(None), + value_label: Optional[str] = Form(None), + relative_to_from_submission_value: Optional[str] = Form(None), + relative_from_schedule_instance: Optional[str] = Form(None), + relative_to_schedule_instance: Optional[str] = Form(None), + window_label: Optional[str] = Form(None), + window_upper: Optional[str] = Form(None), + window_lower: Optional[str] = Form(None), +): + # Determine existing code values for type and relative_to_from + mapped_type: Optional[str] = None + mapped_rtf: Optional[str] = None + try: + conn_chk = _connect() + cur_chk = conn_chk.cursor() + cur_chk.execute( + "SELECT type, relative_to_from FROM timing WHERE soa_id=? AND id=?", + (soa_id, timing_id), + ) + row_chk = cur_chk.fetchone() + existing_type_uid = row_chk[0] if row_chk else None + existing_rtf_uid = row_chk[1] if row_chk else None + # Map existing code_uids -> code values + cur_chk.execute( + "SELECT code_uid, code FROM code WHERE soa_id=?", + (soa_id,), + ) + code_rows = cur_chk.fetchall() or [] + code_uid_to_code = {r[0]: str(r[1]) for r in code_rows if r and r[0]} + # Handle type mapping only when changed + if type_submission_value is not None: + sv = (type_submission_value or "").strip() + if sv == "": + mapped_type = "" # clear field + else: + sv_to_code = get_study_timing_type("C201264") + new_code_val = sv_to_code.get(sv) + current_code_val = ( + code_uid_to_code.get(existing_type_uid) + if existing_type_uid + else None + ) + if new_code_val is None: + mapped_type = None # invalid; ignore + elif str(current_code_val) == str(new_code_val): + mapped_type = None # unchanged; do not update + else: + # Always create a fresh code_uid; do not reuse across timings + mapped_type = _get_next_code_uid(cur_chk, soa_id) + cur_chk.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + mapped_type, + "ddf_terminology", + "C201264", + str(new_code_val), + ), + ) + # Handle relative_to_from mapping only when changed + if relative_to_from_submission_value is not None: + rsv = (relative_to_from_submission_value or "").strip() + if rsv == "": + mapped_rtf = "" # clear field + else: + sv_to_code_rtf = get_study_timing_type("C201265") + new_rtf_code_val = sv_to_code_rtf.get(rsv) + current_rtf_code_val = ( + code_uid_to_code.get(existing_rtf_uid) if existing_rtf_uid else None + ) + if new_rtf_code_val is None: + mapped_rtf = None # invalid; ignore + elif str(current_rtf_code_val) == str(new_rtf_code_val): + mapped_rtf = None # unchanged; do not update + else: + mapped_rtf = _get_next_code_uid(cur_chk, soa_id) + cur_chk.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + mapped_rtf, + "ddf_terminology", + "C201265", + str(new_rtf_code_val), + ), + ) + conn_chk.commit() + conn_chk.close() + except Exception: + # On any error, fall back to previous behavior (leave fields unchanged) + mapped_type = None if type_submission_value not in ("",) else "" + mapped_rtf = None if relative_to_from_submission_value not in ("",) else "" + payload = TimingUpdate( + name=name, + label=label, + description=description, + type=mapped_type, + value=value, + value_label=value_label, + relative_to_from=mapped_rtf, + relative_from_schedule_instance=relative_from_schedule_instance, + relative_to_schedule_instance=relative_to_schedule_instance, + window_label=window_label, + window_upper=window_upper, + window_lower=window_lower, + ) + update_timing(soa_id, timing_id, payload) + return RedirectResponse(url=f"/ui/soa/{soa_id}/timings", status_code=303) + + +# API endpoint to delete a timing +@router.delete( + "/soa/{soa_id}/timings/{timing_id}", + response_class=JSONResponse, + response_model=None, +) +def delete_timing(soa_id: int, timing_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id,timing_uid,name,label,description FROM timing WHERE soa_id=? AND id=?", + ( + soa_id, + timing_id, + ), + ) + row = cur.fetchone() + if not row: + conn.close() + raise HTTPException(404, f"Timing id={timing_id} not found") + before = { + "id": row[0], + "timing_uid": row[1], + "name": row[2], + "label": row[3], + "description": row[4], + } + cur.execute( + "DELETE FROM timing WHERE id=? AND soa_id=?", + ( + timing_id, + soa_id, + ), + ) + conn.commit() + conn.close() + + _record_timing_audit(soa_id, "delete", timing_id, before=before, after=None) + return {"deleted": True, "id": timing_id} + + +# UI Code to delete timing +@router.post("/ui/soa/{soa_id}/timings/{timing_id}/delete") +def ui_delete_timing(request: Request, soa_id: int, timing_id: int): + delete_timing(soa_id, timing_id) + return RedirectResponse(url=f"/ui/soa/{soa_id}/timings", status_code=303) diff --git a/src/soa_builder/web/schemas.py b/src/soa_builder/web/schemas.py index 22adba4..f7d1652 100644 --- a/src/soa_builder/web/schemas.py +++ b/src/soa_builder/web/schemas.py @@ -3,6 +3,36 @@ from pydantic import BaseModel +class TimingCreate(BaseModel): + name: str + label: Optional[str] = None + description: Optional[str] = None + type: Optional[str] = None + value: Optional[str] = None + value_label: Optional[str] = None + relative_to_from: Optional[str] = None + relative_from_schedule_instance: Optional[str] = None + relative_to_schedule_instance: Optional[str] = None + window_label: Optional[str] = None + window_upper: Optional[str] = None + window_lower: Optional[str] = None + + +class TimingUpdate(BaseModel): + name: str + label: Optional[str] = None + description: Optional[str] = None + type: Optional[str] = None + value: Optional[str] = None + value_label: Optional[str] = None + relative_to_from: Optional[str] = None + relative_from_schedule_instance: Optional[str] = None + relative_to_schedule_instance: Optional[str] = None + window_label: Optional[str] = None + window_upper: Optional[str] = None + window_lower: Optional[str] = None + + class ActivityCreate(BaseModel): name: str label: Optional[str] = None diff --git a/src/soa_builder/web/templates/base.html b/src/soa_builder/web/templates/base.html index 797beb8..039be85 100644 --- a/src/soa_builder/web/templates/base.html +++ b/src/soa_builder/web/templates/base.html @@ -11,6 +11,9 @@

SoA Workbench