diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index ac7c07d..12793d7 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -65,6 +65,7 @@ from .routers import rollback as rollback_router from .routers import visits as visits_router from .routers import audits as audits_router +from .routers import rules as rules_router from .routers import timings as timings_router from .routers import schedule_timelines as schedule_timelines_router @@ -186,35 +187,7 @@ def _configure_logging(): app.include_router(instances_router.router) app.include_router(audits_router.router) app.include_router(schedule_timelines_router.router) - - -# Create Audit record functions -def _record_transition_rule_audit( - soa_id: int, - action: str, - transition_rule_id: Optional[int], - before: Optional[dict] = None, - after: Optional[dict] = None, -): - try: - conn = _connect() - cur = conn.cursor() - cur.execute( - "INSERT INTO transition_rule_audit (soa_id, transition_rule_id, action, before_json, after_json, performed_at) " - "VALUES (?,?,?,?,?,?)", - ( - soa_id, - transition_rule_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 transition rule audit: %s", e) +app.include_router(rules_router.router) def _record_visit_audit( @@ -3920,7 +3893,7 @@ def ui_concept_detail(code: str, request: Request): ) -# UI endpoint for adding an element +# UI endpoint for adding an element <- Deprecated (movd to routers/elements.py) @app.post("/ui/soa/{soa_id}/add_element", response_class=HTMLResponse) def ui_add_element( request: Request, @@ -4035,7 +4008,7 @@ def ui_add_element( ''' -# UI endpoint for associating a Transition Start Rule with an Element (element.testrl) +# UI endpoint for associating a Transition Start Rule with an Element (element.testrl) <- Deprecated (movd to routers/elements.py) @app.post( "/ui/soa/{soa_id}/set_element_transition_start_rule", response_class=HTMLResponse ) @@ -4116,7 +4089,7 @@ def ui_set_element_transition_start_rule( ) -# UI endpoint for associating a Transition Start Rule with an Element (element.teenrl) +# UI endpoint for associating a Transition Start Rule with an Element (element.teenrl) <- Deprecated (movd to routers/elements.py) @app.post( "/ui/soa/{soa_id}/set_element_transition_end_rule", response_class=HTMLResponse ) @@ -4196,7 +4169,7 @@ def ui_set_element_transition_end_rule( ) -# UI endpoint for updating an element +# UI endpoint for updating an element <- Deprecated (movd to routers/elements.py) @app.post("/ui/soa/{soa_id}/update_element", response_class=HTMLResponse) def ui_update_element( request: Request, @@ -4313,7 +4286,7 @@ def ui_update_element( """ -# UI endpoint for deleting an element +# UI endpoint for deleting an element <- Deprecated (movd to routers/elements.py) @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.""" @@ -4679,253 +4652,6 @@ 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, - soa_id: int, - name: str = Form(...), - label: Optional[str] = Form(None), - description: Optional[str] = Form(None), - text: str = Form(...), -): - """Form handler to add a Transition Rule.""" - if not soa_exists(soa_id): - raise HTTPException(404, "SOA not found") - name = (name or "").strip() - if not name: - raise HTTPException(400, "Name required") - text = (text or "").strip() - if not text: - raise HTTPException(400, "Text required") - conn = _connect() - cur = conn.cursor() - # Determine next order index - cur.execute( - "SELECT COALESCE(MAX(order_index), 0) FROM transition_rule WHERE soa_id=?", - (soa_id,), - ) - next_ord = (cur.fetchone() or [0])[0] + 1 - now = datetime.now(timezone.utc).isoformat() - # Generate TransitionRule_ monotonically increasing for this SOA - transition_rule_identifier = _next_transition_rule_uid(soa_id) - cur.execute( - """INSERT INTO transition_rule (soa_id,transition_rule_uid,name,label,description,text,order_index,created_at) VALUES (?,?,?,?,?,?,?,?)""", - ( - soa_id, - transition_rule_identifier, - name, - (label or "").strip() or None, - (description or "").strip() or None, - text, - next_ord, - now, - ), - ) - eid = cur.lastrowid - conn.commit() - conn.close() - _record_transition_rule_audit( - soa_id, - "create", - eid, - before=None, - after={ - "id": eid, - "transition_rule_uid": transition_rule_identifier, - "name": name, - "label": (label or "").strip() or None, - "description": (description or "").strip() or None, - "text": text, - "order_index": next_ord, - }, - ) - return HTMLResponse( - f"" - ) - - -# 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, - soa_id: int, - transition_rule_uid: str = Form(...), - name: Optional[str] = Form(None), - label: Optional[str] = Form(None), - description: Optional[str] = Form(None), - text: Optional[str] = Form(None), -): - """Form handler to update an existing Transition Rule.""" - if not soa_exists(soa_id): - raise HTTPException(404, "SOA not found") - conn = _connect() - cur = conn.cursor() - # Verify exists and get id - cur.execute( - "SELECT id,transition_rule_uid,name,label,description,text,order_index,created_at FROM transition_rule WHERE soa_id=? AND transition_rule_uid=?", - (soa_id, transition_rule_uid), - ) - b = cur.fetchone() - if not b: - conn.close() - raise HTTPException(404, "Transition Rule not found") - before = { - "id": b[0], - "transition_rule_uid": b[1], - "name": b[2], - "label": b[3], - "description": b[4], - "text": b[5], - "order_index": b[6], - "created_at": b[7], - } - sets = [] - vals: list[Any] = [] - if name is not None: - sets.append("name=?") - vals.append((name or "").strip() or None) - if label is not None: - sets.append("label=?") - vals.append((label or "").strip() or None) - if description is not None: - sets.append("description=?") - vals.append((description or "").strip() or None) - if text is not None: - sets.append("text=?") - vals.append((text or "").strip() or None) - if sets: - vals.append(before["id"]) - cur.execute(f"UPDATE transition_rule SET {', '.join(sets)} WHERE id=?", vals) - conn.commit() - # Fetch after - cur.execute( - "SELECT id,name,label,description,text,order_index,created_at FROM transition_rule WHERE id=?", - (before["id"],), - ) - a = cur.fetchone() - conn.close() - after = { - "id": a[0], - "name": a[1], - "label": a[2], - "description": a[3], - "text": a[4], - "order_index": a[5], - "created_at": a[6], - } - mutable_fields = ["name", "label", "description", "text"] - updated_fields = [ - f for f in mutable_fields if before and before.get(f) != after.get(f) - ] - _record_transition_rule_audit( - soa_id, - "update", - before["id"], - before=before, - after={**after, "updated_fields": updated_fields}, - ) - # HTMX inline update: return refreshed list markup when requested - if request.headers.get("HX-Request") == "true": - conn_tr = _connect() - cur_tr = conn_tr.cursor() - cur_tr.execute( - "SELECT transition_rule_uid,name,label,description,text,order_index,created_at FROM transition_rule WHERE soa_id=? ORDER BY order_index", - (soa_id,), - ) - transition_rules = [ - dict( - transition_rule_uid=r[0], - name=r[1], - label=r[2], - description=r[3], - text=r[4], - order_index=r[5], - created_at=r[6], - ) - for r in cur_tr.fetchall() - ] - conn_tr.close() - html = templates.get_template("transition_rules_list.html").render( - transition_rules=transition_rules, soa_id=soa_id - ) - return HTMLResponse(html) - return HTMLResponse( - f"" - ) - - -# 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(...) -): - """Form handler to delete a transition rule""" - if not soa_exists(soa_id): - raise HTTPException(404, "SOA not found") - conn = _connect() - cur = conn.cursor() - # Capture before for audit - cur.execute( - "SELECT id,transition_rule_uid,name,label,description,text,order_index,created_at FROM transition_rule WHERE soa_id=? AND transition_rule_uid=?", - (soa_id, transition_rule_uid), - ) - b = cur.fetchone() - before = None - if b: - before = { - "id": b[0], - "transition_rule_uid": b[1], - "name": b[2], - "label": b[3], - "description": b[4], - "text": b[5], - "order_index": b[6], - "created_at": b[7], - } - cur.execute( - "DELETE FROM transition_rule WHERE transition_rule_uid=? AND soa_id=?", - (transition_rule_uid, soa_id), - ) - conn.commit() - conn.close() - _record_transition_rule_audit( - soa_id, - "delete", - before["id"] if before else None, - before=before, - after=None, - ) - # HTMX inline update: return refreshed list markup when requested - if request.headers.get("HX-Request") == "true": - conn_tr = _connect() - cur_tr = conn_tr.cursor() - cur_tr.execute( - "SELECT transition_rule_uid,name,label,description,text,order_index,created_at FROM transition_rule WHERE soa_id=? ORDER BY order_index", - (soa_id,), - ) - transition_rules = [ - dict( - transition_rule_uid=r[0], - name=r[1], - label=r[2], - description=r[3], - text=r[4], - order_index=r[5], - created_at=r[6], - ) - for r in cur_tr.fetchall() - ] - conn_tr.close() - html = templates.get_template("transition_rules_list.html").render( - transition_rules=transition_rules, soa_id=soa_id - ) - return HTMLResponse(html) - return HTMLResponse( - f"" - ) - - # UI endpoint for setting a BC to an Activity @app.post( "/ui/soa/{soa_id}/activity/{activity_id}/concepts", response_class=HTMLResponse diff --git a/src/soa_builder/web/audit.py b/src/soa_builder/web/audit.py index 1dc7936..2a60aba 100644 --- a/src/soa_builder/web/audit.py +++ b/src/soa_builder/web/audit.py @@ -283,3 +283,32 @@ def _record_instance_audit( conn.close() except Exception as e: logger.warning("Failed recording instance audit: %s", e) + + +# Transition Rule Audit +def _record_transition_rule_audit( + soa_id: int, + action: str, + transition_rule_id: Optional[int], + before: Optional[dict] = None, + after: Optional[dict] = None, +): + try: + conn = _connect() + cur = conn.cursor() + cur.execute( + "INSERT INTO transition_rule_audit (soa_id, transition_rule_id, action, before_json, after_json, performed_at) " + "VALUES (?,?,?,?,?,?)", + ( + soa_id, + transition_rule_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 transition rule audit: %s", e) diff --git a/src/soa_builder/web/initialize_database.py b/src/soa_builder/web/initialize_database.py index c971034..8386351 100644 --- a/src/soa_builder/web/initialize_database.py +++ b/src/soa_builder/web/initialize_database.py @@ -252,7 +252,7 @@ def _init_db(): name TEXT NOT NULL, label TEXT, description TEXT, - text TEXT NOT NULL, + text TEXT, order_index INTEGER, created_at TEXT, UNIQUE(soa_id, transition_rule_uid) diff --git a/src/soa_builder/web/routers/elements.py b/src/soa_builder/web/routers/elements.py index 786ffad..ec47597 100644 --- a/src/soa_builder/web/routers/elements.py +++ b/src/soa_builder/web/routers/elements.py @@ -1,18 +1,26 @@ import json import logging -from datetime import datetime, timezone -from typing import List, Optional +import os -from fastapi import APIRouter, HTTPException -from fastapi.responses import JSONResponse +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_element_audit from ..db import _connect -from ..utils import soa_exists +from ..utils import ( + soa_exists, + get_study_transition_rules, +) from ..schemas import ElementCreate, ElementUpdate -router = APIRouter(prefix="/soa/{soa_id}") +router = APIRouter() logger = logging.getLogger("soa_builder.web.routers.elements") +templates = Jinja2Templates( + directory=os.path.join(os.path.dirname(__file__), "..", "templates") +) def _nz(s: Optional[str]) -> Optional[str]: @@ -20,111 +28,333 @@ def _nz(s: Optional[str]) -> Optional[str]: return s or None -def _next_element_identifier(soa_id: int) -> str: - """Compute next monotonically increasing StudyElement_N for an SoA. - Scans current element rows and element_audit snapshots to avoid reusing numbers after deletes. - """ +# APi endpoint for listing elements +@router.get("/soa/{soa_id}/elements", response_class=JSONResponse, response_model=None) +def list_elements(soa_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + conn = _connect() cur = conn.cursor() - max_n = 0 - try: - cur.execute("SELECT element_id FROM element WHERE soa_id=?", (soa_id,)) - for (eid,) in cur.fetchall(): - if isinstance(eid, str) and eid.startswith("StudyElement_"): - tail = eid.split("StudyElement_")[-1] - if tail.isdigit(): - max_n = max(max_n, int(tail)) - except Exception as e: - logger.exception( - "_next_element_identifier scan elements failed for soa_id=%s: %s", - soa_id, - e, - ) - try: - cur.execute( - "SELECT before_json, after_json FROM element_audit WHERE soa_id=?", - (soa_id,), - ) - for bjson, ajson in cur.fetchall(): - for js in (bjson, ajson): - if not js: - continue - try: - obj = json.loads(js) - except Exception as e: - logger.debug( - "_next_element_identifier JSON parse failed soa_id=%s: %s", - soa_id, - e, - ) - obj = None - if isinstance(obj, dict): - val = obj.get("element_id") - if isinstance(val, str) and val.startswith("StudyElement_"): - tail = val.split("StudyElement_")[-1] - if tail.isdigit(): - max_n = max(max_n, int(tail)) - except Exception as e: - logger.exception( - "_next_element_identifier scan element_audit failed for soa_id=%s: %s", - soa_id, - e, - ) + cur.execute( + "SELECT id,element_id,name,label,description,order_index,testrl,teenrl FROM element WHERE soa_id=? ORDER BY order_index", + (soa_id,), + ) + rows = [ + { + "id": r[0], + "element_id": r[1], + "name": r[2], + "label": r[3], + "description": r[4], + "order_index": r[5], + "testrl": r[6], + "teenrl": r[7], + } + for r in cur.fetchall() + ] conn.close() - return f"StudyElement_{max_n + 1}" + return rows -def _get_element_uid(soa_id: int, row_id: int) -> Optional[str]: - """Return element.element_id (StudyElement_N) for row id if column exists, else None.""" - try: - conn = _connect() - cur = conn.cursor() - cur.execute("PRAGMA table_info(element)") - cols = {r[1] for r in cur.fetchall()} - if "element_id" not in cols: - conn.close() - return None - cur.execute( - "SELECT element_id FROM element WHERE id=? AND soa_id=?", - (row_id, soa_id), - ) - r = cur.fetchone() - conn.close() - return r[0] if r else None - except Exception as e: - logger.exception( - "_get_element_uid failed for soa_id=%s row_id=%s: %s", soa_id, row_id, e - ) - return None +# UI code for listing elements +@router.get("/ui/soa/{soa_id}/elements", response_class=HTMLResponse) +def ui_list_elements(request: Request, soa_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + elements = list_elements(soa_id) + transition_rule_options = get_study_transition_rules(soa_id) -@router.get("/elements", response_class=JSONResponse) -def list_elements(soa_id: int): + return templates.TemplateResponse( + request, + "elements.html", + { + "request": request, + "soa_id": soa_id, + "elements": elements, + "transition_rule_options": transition_rule_options, + }, + ) + + +# API endpoint to create an element +@router.post( + "/soa/{soa_id}/elements", + response_class=JSONResponse, + status_code=201, + response_model=None, +) +def add_element(soa_id: int, payload: ElementCreate): if not soa_exists(soa_id): raise HTTPException(404, "SOA not found") + + name = (payload.name or "").strip() + if not name: + raise HTTPException(400, "Element name required") + conn = _connect() cur = conn.cursor() + + # order index cur.execute( - "SELECT id,name,label,description,order_index,created_at,element_id FROM element WHERE soa_id=? ORDER BY order_index", + "SELECT COALESCE(MAX(order_index),0) FROM element WHERE soa_id=?", (soa_id,), ) - rows = [ - { - "id": r[0], - "name": r[1], - "label": r[2], - "description": r[3], - "order_index": r[4], - "created_at": r[5], - "element_id": r[6], - } - for r in cur.fetchall() + next_ord = (cur.fetchone() or [0])[0] + 1 + + # Create element_uid and incremenet order_index + cur.execute( + "SELECT element_id FROM element WHERE soa_id=? AND element_id LIKE 'StudyElement_%'", + (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("StudyElement_"): + tail = uid[len("StudyElement_") :] + if tail.isdigit(): + used_nums.add(int(tail)) + else: + logger.warning( + "Invalid element_id 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"StudyElement_{next_n}" + + cur.execute( + """ + INSERT into element (soa_id,element_id,name,label,description,order_index,testrl,teenrl) + VALUES (?,?,?,?,?,?,?,?) + """, + ( + soa_id, + new_uid, + name, + _nz(payload.label), + _nz(payload.description), + next_ord, + _nz(payload.testrl), + _nz(payload.teenrl), + ), + ) + id = cur.lastrowid + conn.commit() + conn.close() + after = { + "id": id, + "element_uid": new_uid, + "name": payload.name, + "label": (payload.label or "").strip() or None, + "description": (payload.description or "").strip() or None, + "testrl": (payload.testrl or "").strip() or None, + "teenrl": (payload.teenrl or "").strip() or None, + } + _record_element_audit(soa_id, "create", id, before=None, after=after) + return after + + +# UI code to create element +@router.post("/ui/soa/{soa_id}/elements/create") +def ui_create_element( + request: Request, + soa_id: int, + name: str = Form(...), + label: Optional[str] = Form(None), + description: Optional[str] = Form(None), + testrl: Optional[str] = Form(None), + teenrl: Optional[str] = Form(None), +): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + payload = ElementCreate( + name=name, + label=label, + description=description, + testrl=testrl, + teenrl=teenrl, + ) + add_element(soa_id, payload) + return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/elements", status_code=303) + + +# API endpoint to update an element +@router.patch("/soa/{soa_id}/elements/{element_id}", response_class=JSONResponse) +def update_element(soa_id: int, element_id: int, payload: ElementUpdate): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + conn = _connect() + cur = conn.cursor() + cur.execute( + """ + SELECT id,element_id,name,label,description,testrl,teenrl + FROM element WHERE id=? AND soa_id=? + """, + (element_id, soa_id), + ) + row = cur.fetchone() + if not row: + raise HTTPException(404, f"Element id={int(element_id)} not found") + + before = { + "id": row[0], + "element_uid": row[1], + "name": row[2], + "label": row[3], + "description": row[4], + "testrl": row[5], + "teenrl": row[6], + } + + 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_testrl = payload.testrl if payload.testrl is not None else before["testrl"] + new_teenrl = payload.teenrl if payload.teenrl is not None else before["teenrl"] + + cur.execute( + """ + UPDATE element SET name=?,label=?,description=?,testrl=?,teenrl=? + WHERE id=? AND soa_id=? + """, + ( + _nz(new_name), + _nz(new_label), + _nz(new_description), + _nz(new_testrl), + _nz(new_teenrl), + element_id, + soa_id, + ), + ) + conn.commit() + cur.execute( + """ + SELECT id,element_id,name,label,description,testrl,teenrl + FROM element WHERE id=? AND soa_id=? + """, + (element_id, soa_id), + ) + r = cur.fetchone() + conn.close() + after = { + "id": r[0], + "element_uid": r[1], + "name": r[2], + "label": r[3], + "description": r[4], + "testrl": r[5], + "teenrl": r[6], + } + mutable = [ + "name", + "label", + "description", + "testrl", + "teenrl", ] + updated_fields = [ + f for f in mutable if (before.get(f) or None) != (after.get(f) or None) + ] + _record_element_audit( + soa_id, + "update", + element_id, + before=before, + after={**after, "updated_fields": updated_fields}, + ) + return {**after, "updated_fields": updated_fields} + + +# UI code to update an element +@router.post("/ui/soa/{soa_id}/elements/{element_id}/update") +def ui_update_element( + request: Request, + soa_id: int, + element_id: int, + name: Optional[str] = Form(None), + label: Optional[str] = Form(None), + description: Optional[str] = Form(None), + testrl: Optional[str] = Form(None), + teenrl: Optional[str] = Form(None), +): + payload = ElementUpdate( + name=name, + label=label, + description=description, + testrl=testrl, + teenrl=teenrl, + ) + update_element(soa_id, element_id, payload) + return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/elements", status_code=303) + + +# API endpoint to delete an element +@router.delete( + "/soa/{soa_id}/elements/{element_id}", + response_class=JSONResponse, + response_model=None, +) +def delete_element(soa_id: int, element_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id,element_id,name,label FROM element WHERE soa_id=? AND id=?", + (soa_id, element_id), + ) + row = cur.fetchone() + if not row: + raise HTTPException(404, f"Element id={int(element_id)} not found") + + before = { + "id": row[0], + "element_uid": row[1], + "name": row[2], + "label": row[3], + } + cur.execute( + "DELETE FROM element WHERE soa_id=? AND id=?", + (soa_id, element_id), + ) + conn.commit() + # reindex remaining elements' order_index sequentially + cur.execute( + "SELECT id FROM element WHERE soa_id=? ORDER BY order_index", + (soa_id,), + ) + remaining = [r[0] for r in cur.fetchall()] + for idx, eid in enumerate(remaining, start=1): + cur.execute( + "UPDATE element SET order_index=? WHERE id=?", + (idx, eid), + ) + conn.commit() conn.close() - return JSONResponse(rows) + _record_element_audit(soa_id, "delete", element_id, before=before, after=None) + return {"deleted": True, "id": element_id} + + +# UI code to delete an element +@router.post("/ui/soa/{soa_id}/elements/{element_id}/delete") +def ui_delete_element(request: Request, soa_id: int, element_id: int): + delete_element(soa_id, element_id) + return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/elements", status_code=303) -@router.get("/elements/{element_id}", response_class=JSONResponse) +# Deprecated +@router.get("/soa/{soa_id}/elements/{element_id}", response_class=HTMLResponse) def get_element(soa_id: int, element_id: int): if not soa_exists(soa_id): raise HTTPException(404, "SOA not found") @@ -150,10 +380,11 @@ def get_element(soa_id: int, element_id: int): } -@router.get("/element_audit", response_class=JSONResponse) +@router.get("/soa/{soa_id}/element_audit", response_class=JSONResponse) def list_element_audit(soa_id: int): if not soa_exists(soa_id): raise HTTPException(404, "SOA not found") + conn = _connect() cur = conn.cursor() cur.execute( @@ -190,6 +421,8 @@ def list_element_audit(soa_id: int): return JSONResponse(rows) +# Deprecated +""" @router.post("/elements", response_class=JSONResponse, status_code=201) def create_element(soa_id: int, payload: ElementCreate): if not soa_exists(soa_id): @@ -285,8 +518,10 @@ def create_element(soa_id: int, payload: ElementCreate): # Audit with logical StudyElement_N when available _record_element_audit(soa_id, "create", eid, before=None, after=after) return {**after, "element_id": eid} +""" - +# Deprecated +""" @router.patch("/elements/{element_id}", response_class=JSONResponse) def update_element(soa_id: int, element_id: int, payload: ElementUpdate): if not soa_exists(soa_id): @@ -392,8 +627,11 @@ def update_element(soa_id: int, element_id: int, payload: ElementUpdate): after={**after, "updated_fields": updated_fields}, ) return JSONResponse({**after, "updated_fields": updated_fields}) +""" +# Deprecated +""" @router.delete("/elements/{element_id}", response_class=JSONResponse) def delete_element(soa_id: int, element_id: int): if not soa_exists(soa_id): @@ -445,9 +683,11 @@ def delete_element(soa_id: int, element_id: int): after=None, ) return JSONResponse({"deleted": True, "id": element_id}) +""" # Deprecated - no need to reorder elements +""" @router.post("/elements/reorder", response_class=JSONResponse) def reorder_elements_api(soa_id: int, order: List[int]): if not soa_exists(soa_id): @@ -475,3 +715,4 @@ def reorder_elements_api(soa_id: int, order: List[int]): after={"new_order": order}, ) return JSONResponse({"ok": True, "old_order": old_order, "new_order": order}) +""" diff --git a/src/soa_builder/web/routers/rules.py b/src/soa_builder/web/routers/rules.py new file mode 100644 index 0000000..b33457e --- /dev/null +++ b/src/soa_builder/web/routers/rules.py @@ -0,0 +1,339 @@ +import logging +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_transition_rule_audit +from ..db import _connect +from ..utils import ( + soa_exists, +) +from ..schemas import RuleCreate, RuleUpdate + +router = APIRouter() +logger = logging.getLogger("soa_builder.web.routers.rules") +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 + + +# API endpoint for listing transition rules +@router.get("/soa/{soa_id}/rules", response_class=JSONResponse, response_model=None) +def list_rules(soa_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + conn = _connect() + cur = conn.cursor() + cur.execute( + """ + SELECT id, transition_rule_uid,name,label,description,text,order_index + FROM transition_rule WHERE soa_id=? ORDER BY order_index + """, + (soa_id,), + ) + rows = [ + { + "id": row[0], + "transition_rule_uid": row[1], + "name": row[2], + "label": row[3], + "description": row[4], + "text": row[5], + "order_index": row[6], + } + for row in cur.fetchall() + ] + conn.close() + return rows + + +# UI code for listing transition rules +@router.get("/ui/soa/{soa_id}/rules", response_class=HTMLResponse) +def ui_list_rules(request: Request, soa_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + rules = list_rules(soa_id) + + return templates.TemplateResponse( + request, + "rules.html", + { + "request": request, + "soa_id": soa_id, + "rules": rules, + }, + ) + + +# API endpoint for creating transition rule +@router.post( + "/soa/{soa_id}/rules", + response_class=JSONResponse, + status_code=201, + response_model=None, +) +def add_rule(soa_id: int, payload: RuleCreate): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + name = (payload.name or "").strip() + if not name: + raise HTTPException(400, "Transition Rule name required") + + conn = _connect() + cur = conn.cursor() + + # order_index + cur.execute( + "SELECT COALESCE(MAX(order_index),0) FROM transition_rule WHERE soa_id=?", + (soa_id,), + ) + next_ord = (cur.fetchone() or [0])[0] + 1 + + # create transition_rule_uid and increment order_index + cur.execute( + "SELECT transition_rule_uid FROM transition_rule WHERE soa_id=? and transition_rule_uid LIKE 'TransitionRule_%'", + (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("TransitionRule_"): + tail = uid[len("TransitionRule_") :] + if tail.isdigit(): + used_nums.add(int(tail)) + else: + logger.warning( + "Invalid transition_rule_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"TransitionRule_{next_n}" + + cur.execute( + """ + INSERT INTO transition_rule (soa_id,transition_rule_uid,name,label,description,text,order_index) + VALUES (?,?,?,?,?,?,?) + """, + ( + soa_id, + new_uid, + name, + _nz(payload.label), + _nz(payload.description), + _nz(payload.text), + next_ord, + ), + ) + id = cur.lastrowid + conn.commit() + conn.close() + after = { + "id": id, + "transition_rule_uid": new_uid, + "name": payload.name, + "label": (payload.label or "").strip() or None, + "description": (payload.description or "").strip() or None, + "text": (payload.text or "").strip() or None, + } + _record_transition_rule_audit(soa_id, "create", id, before=None, after=after) + return after + + +# UI code for creating transition rule +@router.post("/ui/soa/{soa_id}/rules/create") +def ui_create_rule( + request: Request, + soa_id: int, + name: str = Form(...), + label: Optional[str] = Form(None), + description: Optional[str] = Form(None), + text: Optional[str] = Form(None), +): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + payload = RuleCreate( + name=name, + label=label, + description=description, + text=text, + ) + add_rule(soa_id, payload) + return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/rules", status_code=303) + + +# API endpoint for updating transition rule +@router.patch("/soa/{soa_id}/rules/{rule_id}", response_class=JSONResponse) +def update_rule(soa_id: int, rule_id: int, payload: RuleUpdate): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + conn = _connect() + cur = conn.cursor() + cur.execute( + """ + SELECT id,transition_rule_uid,name,label,description,text + FROM transition_rule WHERE id=? AND soa_id=? + """, + (rule_id, soa_id), + ) + row = cur.fetchone() + if not row: + raise HTTPException(404, f"Transition Rule id={int(rule_id)} not found") + + before = { + "id": row[0], + "transition_rule_uid": row[1], + "name": row[2], + "label": row[3], + "description": row[4], + "text": row[5], + } + + 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_text = payload.text if payload.text is not None else before["text"] + + cur.execute( + """ + UPDATE transition_rule SET name=?, label=?, description=?, text=? + WHERE id=? AND soa_id=? + """, + ( + _nz(new_name), + _nz(new_label), + _nz(new_description), + _nz(new_text), + rule_id, + soa_id, + ), + ) + conn.commit() + cur.execute( + """ + SELECT id,transition_rule_uid,name,label,description,text + FROM transition_rule WHERE id=? AND soa_id=? + """, + (rule_id, soa_id), + ) + r = cur.fetchone() + conn.close() + after = { + "id": r[0], + "transition_rule_uid": r[1], + "name": r[2], + "label": r[3], + "description": r[4], + "text": r[5], + } + mutable = [ + "name", + "label", + "description", + "text", + ] + updated_fields = [ + f for f in mutable if (before.get(f) or None) != (after.get(f) or None) + ] + _record_transition_rule_audit( + soa_id, + "update", + rule_id, + before=before, + after={**after, "updated_fields": updated_fields}, + ) + return {**after, "updated_fields": updated_fields} + + +# UI code for updating transition rule +@router.post("/ui/soa/{soa_id}/rules/{rule_id}/update") +def ui_update_rule( + request: Request, + soa_id: int, + rule_id: int, + name: Optional[str] = Form(None), + label: Optional[str] = Form(None), + description: Optional[str] = Form(None), + text: Optional[str] = Form(None), +): + payload = RuleUpdate( + name=name, + label=label, + description=description, + text=text, + ) + update_rule(soa_id, rule_id, payload) + return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/rules", status_code=303) + + +# API endpoint for deleting transition rule +@router.delete( + "/soa/{soa_id}/rules/{rule_id}", + response_class=JSONResponse, + response_model=None, +) +def delete_rule(soa_id: int, rule_id: int): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id, transition_rule_uid,name,label FROM transition_rule WHERE soa_id=? AND id=?", + (soa_id, rule_id), + ) + row = cur.fetchone() + if not row: + raise HTTPException(404, f"Transition Rule id={int(rule_id)} not found") + + before = { + "id": row[0], + "transition_rule_uid": row[1], + "name": row[2], + "label": row[3], + } + cur.execute( + "DELETE FROM transition_rule WHERE soa_id=? AND id=?", + (soa_id, rule_id), + ) + conn.commit() + # reindex remaining rules order_index sequentially + cur.execute( + "SELECT id FROM transition_rule WHERE soa_id=? ORDER BY order_index", + (soa_id,), + ) + remaining = [r[0] for r in cur.fetchall()] + for idx, rid in enumerate(remaining, start=1): + cur.execute( + "UPDATE transition_rule SET order_index=? WHERE id=?", + (idx, rid), + ) + conn.commit() + conn.close() + _record_transition_rule_audit(soa_id, "delete", rule_id, before=before, after=None) + return {"deleted": True, "id": rule_id} + + +# UI code for deleting transition rule +@router.post("/ui/soa/{soa_id}/rules/{rule_id}/delete", response_class=HTMLResponse) +def ui_delete_rule(request: Request, soa_id: int, rule_id: int): + delete_rule(soa_id, rule_id) + return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/rules", status_code=303) diff --git a/src/soa_builder/web/routers/visits.py b/src/soa_builder/web/routers/visits.py index fa1288e..82b834b 100644 --- a/src/soa_builder/web/routers/visits.py +++ b/src/soa_builder/web/routers/visits.py @@ -120,7 +120,7 @@ def ui_list_visits(request: Request, soa_id: int): transition_rule_options = get_study_transition_rules(soa_id) timing_options = get_timing_id(soa_id) - logger.info(environmental_setting_options) + # logger.info(environmental_setting_options) return templates.TemplateResponse( request, @@ -225,29 +225,6 @@ def add_visit(soa_id: int, payload: VisitCreate): ) # Generate Code_{N} for environmentalSettings **only if value selected - """ - environmentalSettings = _get_next_code_uid(cur, soa_id) - logger.info("environmentalSettings=%s", environmentalSettings) - env_code_value = (payload.environmentalSettings or "").strip() or None - env_package_slug = get_latest_sdtm_ct_href() or "" - env_codelist_table = ( - f"/mdr/ct/packages/{env_package_slug}" - if env_package_slug - else "/mdr/ct/packages" - ) - - if environmentalSettings: - cur.execute( - "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", - ( - soa_id, - environmentalSettings, - env_codelist_table, - "C127262", - env_code_value, - ), - ) - """ env_code_value = (payload.environmentalSettings or "").strip() environmentalSettings = None if env_code_value: @@ -271,29 +248,6 @@ def add_visit(soa_id: int, payload: VisitCreate): ) # Generate Code_{N} for contactModes **only if value selected - """ - contactModes = _get_next_code_uid(cur, soa_id) - logger.info("contactModes=%s", contactModes) - contact_mode_value = (payload.contactModes or "").strip() or None - contact_mode_slug = get_latest_sdtm_ct_href() or "" - contact_mode_codelist_table = ( - f"/mdr/ct/packages/{contact_mode_slug}" - if contact_mode_slug - else "/mdr/ct/packages" - ) - - if contactModes: - cur.execute( - "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", - ( - soa_id, - contactModes, - contact_mode_codelist_table, - "C171445", - contact_mode_value, - ), - ) - """ contact_mode_value = (payload.contactModes or "").strip() contactModes = None if contact_mode_value: diff --git a/src/soa_builder/web/schemas.py b/src/soa_builder/web/schemas.py index 44ef8c9..67695bf 100644 --- a/src/soa_builder/web/schemas.py +++ b/src/soa_builder/web/schemas.py @@ -167,6 +167,20 @@ class ArmUpdate(BaseModel): data_origin_type: Optional[str] = None +class RuleCreate(BaseModel): + name: str + label: Optional[str] = None + description: Optional[str] = None + text: Optional[str] = None + + +class RuleUpdate(BaseModel): + name: Optional[str] = None + label: Optional[str] = None + description: Optional[str] = None + text: Optional[str] = None + + class SOACreate(BaseModel): name: str study_id: Optional[str] = None diff --git a/src/soa_builder/web/templates/base.html b/src/soa_builder/web/templates/base.html index b78c5ce..c50bc86 100644 --- a/src/soa_builder/web/templates/base.html +++ b/src/soa_builder/web/templates/base.html @@ -16,6 +16,8 @@ Epochs Arms Encounters + Elements + Transition Rules {% endif %} Biomedical Concept Categories Biomedical Concepts diff --git a/src/soa_builder/web/templates/edit.html b/src/soa_builder/web/templates/edit.html index e09f56f..2e6d74d 100644 --- a/src/soa_builder/web/templates/edit.html +++ b/src/soa_builder/web/templates/edit.html @@ -114,59 +114,6 @@

Editing SoA {{ soa_id }}

-
- Elements ({{ elements|length }}) (drag to reorder) - -
- - - - - -
-
-
Study Cells ({{ study_cells|length }})
@@ -235,39 +182,6 @@

Editing SoA {{ soa_id }}

-
- Transition Rules ({{ transition_rules|length }}) - -
- - - - - -
-
@@ -333,11 +247,7 @@

Matrix

.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; } - .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; } @@ -352,16 +262,8 @@

Matrix

const saved = localStorage.getItem('soa_collapse_'+sectionId); if(saved === '0') el.open = false; } -persistSection('visits-section'); persistSection('activities-section'); -persistSection('epochs-section'); -persistSection('elements-section'); -persistSection('arms-section'); -persistSection('activity-audit-section'); -persistSection('arm-audit-section'); -persistSection('epoch-audit-section'); persistSection('study-cells-section'); -persistSection('study-cell-audit-section'); persistSection('transition-rules-section'); // --- XLSX export link updates to include optional diff parameters --- @@ -432,14 +334,9 @@

Matrix

}}).catch(err=>console.error(err)); } } -//enableDragList('epochs-list', '/ui/soa/{{ soa_id }}/reorder_epochs', '.ord', 'epoch'); + enableDragList('visits-list', '/ui/soa/{{ soa_id }}/reorder_visits', '.ord', 'visit'); enableDragList('activities-list', '/ui/soa/{{ soa_id }}/reorder_activities', '.ord', 'activity'); -//enableDragList('elements-list', '/ui/soa/{{ soa_id }}/reorder_elements', '.ord', 'element'); -//enableDragList('arms-list', '/ui/soa/{{ soa_id }}/reorder_arms', '.ord', 'arm'); -// NOTE: kind === 'epoch' intentionally has no corresponding reorderMatrixEpochs call. -// Epochs are a higher-level grouping that does not alter matrix axes (columns are visits, rows are activities). -// We still record reorder audit events for epochs, but no DOM matrix reflow is required. function reorderMatrixVisits(newOrder){ const table = document.querySelector('table.matrix'); if(!table) return; diff --git a/src/soa_builder/web/templates/elements.html b/src/soa_builder/web/templates/elements.html index 54a3919..a3bc383 100644 --- a/src/soa_builder/web/templates/elements.html +++ b/src/soa_builder/web/templates/elements.html @@ -1,64 +1,96 @@ {% extends 'base.html' %} {% block content %}

Elements for SoA {{ soa_id }}

-

Define generic elements with immutable IDs (auto-assigned as ElementId_N). Name, label, and description are required.

-
-
- - - - - - + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
-{% if elements %} - - - - - - - - - - - + +
Element IDNameLabelDescriptionStart RuleEnd RuleCreated At (UTC)Actions
+ + + + + + + + + - - - {% for el in elements %} + {% for e in elements %} - - - - - - - - + + + + + + + + - {% endfor %} - + {% else %} + + +
UIDNameLabelDescriptionTransition Start RuleTransition End RuleSaveDelete
{{ el.element_id }}{{ el.name }}{{ el.label or '' }}{{ el.description or '' }}{{ el.testrl or '' }}{{ el.teenrl or '' }}{{ el.created_at }} -
- Edit -
- - - - - - -
- - -
-
-
+
+
{{ e.element_id }} + + + + + + +
+ +
No elements yet.
-{% else %} -
No elements defined yet.
-{% endif %} -

← Back to Edit SoA

+{% endfor %} + + + {% endblock %} diff --git a/src/soa_builder/web/templates/rules.html b/src/soa_builder/web/templates/rules.html new file mode 100644 index 0000000..21e2568 --- /dev/null +++ b/src/soa_builder/web/templates/rules.html @@ -0,0 +1,63 @@ +{% extends 'base.html' %} +{% block content %} +

Transition Rules for SoA {{ soa_id }}

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + + + + + + + + + + {% for r in rules %} + + + + + + + + + + + + {% else %} + + + +
UIDNameLabelDescriptionTextSaveDelete
{{ r.transition_rule_uid }} + + +
+ +
+
No rules yet.
+{% endfor %} +{% endblock %} \ No newline at end of file diff --git a/tests/test_element_audit_endpoint.py b/tests/test_element_audit_endpoint.py index a110498..a236682 100644 --- a/tests/test_element_audit_endpoint.py +++ b/tests/test_element_audit_endpoint.py @@ -12,8 +12,8 @@ def test_element_audit_endpoint_has_rows(): soa_id = resp.json()["id"] # Create element via UI endpoint (records create audit) - r1 = client.post(f"/ui/soa/{soa_id}/add_element", data={"name": "E1"}) - assert r1.status_code == 200 + r1 = client.post(f"/ui/soa/{soa_id}/elements/create", data={"name": "E1"}) + assert 200 <= r1.status_code < 400 # Update element # Update element: get element id directly from DB to avoid UI joins @@ -28,17 +28,17 @@ def test_element_audit_endpoint_has_rows(): conn.close() r2 = client.post( - f"/ui/soa/{soa_id}/update_element", - data={"element_id": eid, "name": "E1-upd"}, + f"/ui/soa/{soa_id}/elements/{eid}/update", + data={"id": eid, "name": "E1-upd"}, ) - assert r2.status_code == 200 + assert 200 <= r2.status_code < 400 # Delete element r3 = client.post( - f"/ui/soa/{soa_id}/delete_element", - data={"element_id": eid}, + f"/ui/soa/{soa_id}/elements/{eid}/delete", + data={"id": eid}, ) - assert r3.status_code == 200 + assert 200 <= r3.status_code < 400 # Call element audit listing endpoint audit = client.get(f"/soa/{soa_id}/element_audit") diff --git a/tests/test_ui_add_element.py b/tests/test_ui_add_element.py index 63bf0ed..ff08f01 100644 --- a/tests/test_ui_add_element.py +++ b/tests/test_ui_add_element.py @@ -13,7 +13,7 @@ def _create_soa(name="ElementTest"): def test_add_element_populates_element_id_if_present(): soa_id = _create_soa() - resp = client.post(f"/ui/soa/{soa_id}/add_element", data={"name": "Elem A"}) + resp = client.post(f"/ui/soa/{soa_id}/elements/create", data={"name": "Elem A"}) assert resp.status_code == 200, resp.text conn = _connect() cur = conn.cursor()