From 316ea49d1a90e9bdee8db9162af84995977db91d Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:25:19 -0400 Subject: [PATCH 01/27] Issue #153: Reviewed database operations and addressed identified issues --- src/soa_builder/web/app.py | 17 ++--- src/soa_builder/web/audit.py | 69 ------------------ src/soa_builder/web/db.py | 8 ++- src/soa_builder/web/migrate_database.py | 29 ++++++++ src/soa_builder/web/routers/activities.py | 61 ++++++++++------ src/soa_builder/web/routers/freezes.py | 7 +- src/soa_builder/web/routers/rollback.py | 1 - src/soa_builder/web/routers/visits.py | 6 +- src/usdm/generate_activities.py | 13 +++- src/usdm/generate_encounters.py | 85 ++++++++++++++++------- tests/test_routers_activities.py | 6 +- 11 files changed, 155 insertions(+), 147 deletions(-) diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index 01b73ea4..d01f9e09 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -69,6 +69,7 @@ _migrate_study_cell_add_order_index, _migrate_biomedical_concept_audit, _migrate_backfill_biomedical_concept_codes, + _migrate_add_soa_id_indexes, ) from .routers import activities as activities_router from .routers import arms as arms_router @@ -199,6 +200,7 @@ def _configure_logging(): _backfill_dataset_date("protocol_terminology", "protocol_terminology_audit") _migrate_biomedical_concept_audit() _migrate_backfill_biomedical_concept_codes() +_migrate_add_soa_id_indexes() # Include routers @@ -4037,7 +4039,7 @@ def import_matrix(soa_id: int, payload: MatrixImport): next_order += 1 if has_activity_uid: cols.append("activity_uid") - vals.append(f"Activity_{soa_id}_{next_order}") + vals.append(activities_router._next_activity_uid(cur, soa_id)) cur.execute( f"INSERT INTO activity ({','.join(cols)}) VALUES ({','.join(['?'] * len(vals))})", vals, @@ -4076,17 +4078,6 @@ def _reindex(table: str, soa_id: int): ids = [r[0] for r in cur.fetchall()] for idx, _id in enumerate(ids, start=1): cur.execute(f"UPDATE {table} SET order_index=? WHERE id=?", (idx, _id)) - # Maintain activity_uid after any activity reindex - if table == "activity": - # Two-phase UID refresh to satisfy UNIQUE(soa_id, activity_uid) without transient collisions - cur.execute( - "UPDATE activity SET activity_uid = 'TMP_' || id WHERE soa_id=?", - (soa_id,), - ) - cur.execute( - "UPDATE activity SET activity_uid = 'Activity_' || order_index WHERE soa_id=?", - (soa_id,), - ) conn.commit() conn.close() @@ -4179,7 +4170,7 @@ def ui_add_activity(request: Request, soa_id: int, name: str = Form(...)): order_index = cur.fetchone()[0] + 1 cur.execute( "INSERT INTO activity (soa_id,name,order_index,activity_uid) VALUES (?,?,?,?)", - (soa_id, nm, order_index, f"Activity_{order_index}"), + (soa_id, nm, order_index, activities_router._next_activity_uid(cur, soa_id)), ) aid = cur.lastrowid conn.commit() diff --git a/src/soa_builder/web/audit.py b/src/soa_builder/web/audit.py index e0835f6c..b66ccadc 100644 --- a/src/soa_builder/web/audit.py +++ b/src/soa_builder/web/audit.py @@ -45,18 +45,6 @@ def _record_element_audit( try: conn = _connect() cur = conn.cursor() - # Ensure table exists (defensive for migrated databases) - cur.execute( - """CREATE TABLE IF NOT EXISTS element_audit ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - soa_id INTEGER NOT NULL, - element_id INTEGER, - action TEXT NOT NULL, - before_json TEXT, - after_json TEXT, - performed_at TEXT NOT NULL - )""" - ) cur.execute( "INSERT INTO element_audit (soa_id, element_id, action, before_json, after_json, performed_at) VALUES (?,?,?,?,?,?)", ( @@ -163,18 +151,6 @@ def _record_study_cell_audit( try: conn = _connect() cur = conn.cursor() - # Ensure table exists (defensive for migrated databases) - cur.execute( - """CREATE TABLE IF NOT EXISTS study_cell_audit ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - soa_id INTEGER NOT NULL, - study_cell_id INTEGER, - action TEXT NOT NULL, - before_json TEXT, - after_json TEXT, - performed_at TEXT NOT NULL - )""" - ) cur.execute( "INSERT INTO study_cell_audit (soa_id, study_cell_id, action, before_json, after_json, performed_at) VALUES (?,?,?,?,?,?)", ( @@ -256,18 +232,6 @@ def _record_instance_audit( try: conn = _connect() cur = conn.cursor() - # Ensure table exists defensively - cur.execute( - """CREATE TABLE IF NOT EXISTS instance_audit ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - soa_id INTEGER NOT NULL, - instance_id INTEGER, - action TEXT NOT NULL, - before_json TEXT, - after_json TEXT, - performed_at TEXT NOT NULL - )""" - ) cur.execute( "INSERT INTO instance_audit (soa_id, instance_id, action, before_json, after_json, performed_at) VALUES (?,?,?,?,?,?)", ( @@ -295,17 +259,6 @@ def _record_decision_instance_audit( try: conn = _connect() cur = conn.cursor() - cur.execute( - """CREATE TABLE IF NOT EXISTS decision_instance_audit ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - soa_id INTEGER NOT NULL, - decision_instance_id INTEGER, - action TEXT NOT NULL, - before_json TEXT, - after_json TEXT, - performed_at TEXT NOT NULL - )""" - ) cur.execute( "INSERT INTO decision_instance_audit (soa_id, decision_instance_id, action, before_json, after_json, performed_at) VALUES (?,?,?,?,?,?)", ( @@ -333,17 +286,6 @@ def _record_condition_assignment_audit( try: conn = _connect() cur = conn.cursor() - cur.execute( - """CREATE TABLE IF NOT EXISTS condition_assignment_audit ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - soa_id INTEGER NOT NULL, - condition_assignment_id INTEGER, - action TEXT NOT NULL, - before_json TEXT, - after_json TEXT, - performed_at TEXT NOT NULL - )""" - ) cur.execute( "INSERT INTO condition_assignment_audit (soa_id, condition_assignment_id, action, before_json, after_json, performed_at) VALUES (?,?,?,?,?,?)", ( @@ -403,17 +345,6 @@ def _record_biomedical_concept_audit( if own_conn: conn = _connect() cur = conn.cursor() - cur.execute( - """CREATE TABLE IF NOT EXISTS biomedical_concept_audit ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - soa_id INTEGER NOT NULL, - biomedical_concept_id INTEGER, - action TEXT NOT NULL, - before_json TEXT, - after_json TEXT, - performed_at TEXT NOT NULL - )""" - ) cur.execute( "INSERT INTO biomedical_concept_audit" " (soa_id, biomedical_concept_id, action, before_json, after_json, performed_at)" diff --git a/src/soa_builder/web/db.py b/src/soa_builder/web/db.py index 17fc22c3..6061237f 100644 --- a/src/soa_builder/web/db.py +++ b/src/soa_builder/web/db.py @@ -48,6 +48,10 @@ def _connect(): conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA synchronous=NORMAL") conn.execute("PRAGMA busy_timeout=3000") - except Exception: - pass + except Exception as e: + import logging + + logging.getLogger("soa_builder.db").warning( + "PRAGMA configuration failed on %s: %s", db_path, e + ) return conn diff --git a/src/soa_builder/web/migrate_database.py b/src/soa_builder/web/migrate_database.py index 71c678ed..ccefd840 100644 --- a/src/soa_builder/web/migrate_database.py +++ b/src/soa_builder/web/migrate_database.py @@ -1122,3 +1122,32 @@ def _migrate_biomedical_concept_property_add_uid(): conn.close() except Exception as e: logger.warning("_migrate_biomedical_concept_property_add_uid: %s", e) + + +def _migrate_add_soa_id_indexes(): + """Add standalone soa_id indexes on high-traffic tables. + + The existing UNIQUE constraints cover (soa_id, uid) lookups, but bare + WHERE soa_id=? list queries do full table scans without a leading index. + These indexes cover the ~259 soa_id filter sites in the codebase. + """ + try: + conn = _connect() + cur = conn.cursor() + indexes = [ + ("idx_activity_soa", "activity", "soa_id"), + ("idx_visit_soa", "visit", "soa_id"), + ("idx_matrix_cells_soa", "matrix_cells", "soa_id"), + ("idx_activity_concept_soa", "activity_concept", "soa_id"), + ("idx_instances_soa", "instances", "soa_id"), + ("idx_timing_soa", "timing", "soa_id"), + ] + created = [] + for idx_name, table, col in indexes: + cur.execute(f"CREATE INDEX IF NOT EXISTS {idx_name} ON {table}({col})") + created.append(idx_name) + conn.commit() + conn.close() + logger.info("_migrate_add_soa_id_indexes: ensured indexes %s", created) + except Exception as e: + logger.warning("_migrate_add_soa_id_indexes: %s", e) diff --git a/src/soa_builder/web/routers/activities.py b/src/soa_builder/web/routers/activities.py index 48d675ce..4603da5f 100644 --- a/src/soa_builder/web/routers/activities.py +++ b/src/soa_builder/web/routers/activities.py @@ -6,7 +6,7 @@ import time from typing import List -from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Form +from fastapi import APIRouter, BackgroundTasks, Body, HTTPException, Request, Form from fastapi.responses import JSONResponse, HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates @@ -137,6 +137,41 @@ def get_activity(soa_id: int, activity_id: int): } +def _next_activity_uid(cur, soa_id: int) -> str: + """Return the next Activity_N UID, never reusing a deleted one. + + Scans both the live table and the audit trail so deleted UIDs are + never recycled — matching the pattern used by _next_study_cell_uid. + """ + max_n = 0 + cur.execute("SELECT activity_uid FROM activity WHERE soa_id=?", (soa_id,)) + for (uid,) in cur.fetchall(): + if isinstance(uid, str) and uid.startswith("Activity_"): + try: + n = int(uid.split("_")[-1]) + if n > max_n: + max_n = n + except (ValueError, IndexError): + pass + cur.execute( + "SELECT before_json, after_json FROM activity_audit WHERE soa_id=?", + (soa_id,), + ) + for before_raw, after_raw in cur.fetchall(): + for raw in (before_raw, after_raw): + if not raw: + continue + try: + uid = json.loads(raw).get("activity_uid", "") + if isinstance(uid, str) and uid.startswith("Activity_"): + n = int(uid.split("_")[-1]) + if n > max_n: + max_n = n + except Exception: + pass + return f"Activity_{max_n + 1}" + + @router.post("/activities", response_class=JSONResponse) def add_activity(soa_id: int, payload: ActivityCreate): if not soa_exists(soa_id): @@ -148,8 +183,7 @@ def add_activity(soa_id: int, payload: ActivityCreate): "SELECT COALESCE(MAX(order_index),0) FROM activity WHERE soa_id=?", (soa_id,) ) order_index = (cur.fetchone() or [0])[0] + 1 - # Compute activity_uid from order_index (keeps list stable after inserts) - activity_uid = f"Activity_{order_index}" + activity_uid = _next_activity_uid(cur, soa_id) name = (payload.name or "").strip() label = (payload.label or "").strip() or None @@ -313,7 +347,7 @@ def ui_update_activity( @router.post("/activities/reorder", response_class=JSONResponse) -def reorder_activities_api(soa_id: int, order: List[int]): +def reorder_activities_api(soa_id: int, order: List[int] = Body(..., embed=True)): if not soa_exists(soa_id): raise HTTPException(404, "SOA not found") if not order: @@ -355,14 +389,6 @@ def reorder_activities_api(soa_id: int, order: List[int]): ).fetchall() } - # Reassign activity_uid from order_index - cur.execute( - "UPDATE activity SET activity_uid='TMP_' || id WHERE soa_id=?", (soa_id,) - ) - cur.execute( - "UPDATE activity SET activity_uid='Activity_' || order_index WHERE soa_id=?", - (soa_id,), - ) conn.commit() conn.close() @@ -411,7 +437,7 @@ def add_activities_bulk(soa_id: int, payload: BulkActivities): order_index += 1 cur.execute( "INSERT INTO activity (soa_id,name,order_index,activity_uid) VALUES (?,?,?,?)", - (soa_id, name, order_index, f"Activity_{order_index}"), + (soa_id, name, order_index, _next_activity_uid(cur, soa_id)), ) added.append(name) existing.add(lname) @@ -554,7 +580,7 @@ def set_activity_concepts( def _reindex_activities(soa_id: int): - """Re-number order_index and activity_uid after a delete.""" + """Re-number order_index after a delete. activity_uid is immutable and never changed.""" conn = _connect() cur = conn.cursor() cur.execute( @@ -563,13 +589,6 @@ def _reindex_activities(soa_id: int): ids = [r[0] for r in cur.fetchall()] for idx, _id in enumerate(ids, start=1): cur.execute("UPDATE activity SET order_index=? WHERE id=?", (idx, _id)) - cur.execute( - "UPDATE activity SET activity_uid = 'TMP_' || id WHERE soa_id=?", (soa_id,) - ) - cur.execute( - "UPDATE activity SET activity_uid = 'Activity_' || order_index WHERE soa_id=?", - (soa_id,), - ) conn.commit() conn.close() diff --git a/src/soa_builder/web/routers/freezes.py b/src/soa_builder/web/routers/freezes.py index 839738e2..62704520 100644 --- a/src/soa_builder/web/routers/freezes.py +++ b/src/soa_builder/web/routers/freezes.py @@ -1,14 +1,13 @@ import json import logging import os -import sqlite3 from fastapi import APIRouter, Form, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse from fastapi.templating import Jinja2Templates +from ..db import _connect from ..utils import soa_exists -DB_PATH = os.environ.get("SOA_BUILDER_DB", "soa_builder_web.db") TEMPLATES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates") templates = Jinja2Templates(directory=TEMPLATES_DIR) @@ -16,10 +15,6 @@ logger = logging.getLogger("soa_builder.web.routers.freezes") -def _connect(): - return sqlite3.connect(DB_PATH) - - # Removed local _soa_exists; using shared utils.soa_exists diff --git a/src/soa_builder/web/routers/rollback.py b/src/soa_builder/web/routers/rollback.py index 01a3d509..1a6f66a5 100644 --- a/src/soa_builder/web/routers/rollback.py +++ b/src/soa_builder/web/routers/rollback.py @@ -9,7 +9,6 @@ from ..utils import soa_exists -DB_PATH = os.environ.get("SOA_BUILDER_DB", "soa_builder_web.db") TEMPLATES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates") templates = Jinja2Templates(directory=TEMPLATES_DIR) diff --git a/src/soa_builder/web/routers/visits.py b/src/soa_builder/web/routers/visits.py index f40dcd21..aa4f059e 100644 --- a/src/soa_builder/web/routers/visits.py +++ b/src/soa_builder/web/routers/visits.py @@ -457,7 +457,6 @@ def update_visit(soa_id: int, visit_id: int, payload: VisitUpdate): soa_id, ), ) - conn.commit() if new_environmental_value is not None: if not env_code_uid: @@ -498,8 +497,6 @@ def update_visit(soa_id: int, visit_id: int, payload: VisitUpdate): (env_code_uid, visit_id, soa_id), ) - conn.commit() - if new_contact_mode is not None: if not contact_mode_code_uid: contact_mode_code_uid = _get_next_code_uid(cur, soa_id) @@ -539,7 +536,7 @@ def update_visit(soa_id: int, visit_id: int, payload: VisitUpdate): (contact_mode_code_uid, visit_id, soa_id), ) - conn.commit() + conn.commit() cur.execute( """ @@ -643,6 +640,7 @@ def delete_visit(soa_id: int, visit_id: int): ) row = cur.fetchone() if not row: + conn.close() raise HTTPException(404, f"Encounter id={int(visit_id)} not found") before = { diff --git a/src/usdm/generate_activities.py b/src/usdm/generate_activities.py index 16d2d6de..e1620f13 100755 --- a/src/usdm/generate_activities.py +++ b/src/usdm/generate_activities.py @@ -2,7 +2,6 @@ from typing import List, Dict, Any from soa_builder.web.db import _connect from soa_builder.web.utils import _nz -from .usdm_utils import _get_biomedical_concept_ids def build_usdm_activities(soa_id: int) -> List[Dict[str, Any]]: @@ -42,10 +41,18 @@ def build_usdm_activities(soa_id: int) -> List[Dict[str, Any]]: (soa_id,), ) rows = cur.fetchall() + + # Pre-fetch all concept mappings for this SOA in one query to avoid N+1 + cur.execute( + "SELECT activity_uid, concept_uid FROM activity_concept WHERE soa_id=?", + (soa_id,), + ) + bc_map: dict[str, list[str]] = {} + for act_uid, concept_uid in cur.fetchall(): + bc_map.setdefault(act_uid, []).append(concept_uid) conn.close() # Build simple linear previous/next links by list order - # ids = [f"Activity_{r[0]}" for r in rows] uids = [r[1] for r in rows] id_by_index = {i: uid for i, uid in enumerate(uids)} @@ -55,7 +62,7 @@ def build_usdm_activities(soa_id: int) -> List[Dict[str, Any]]: aid = activity_uid prev_id = id_by_index.get(i - 1) next_id = id_by_index.get(i + 1) - bcs = _get_biomedical_concept_ids(soa_id, aid) + bcs = bc_map.get(aid, []) activity = { "id": aid, diff --git a/src/usdm/generate_encounters.py b/src/usdm/generate_encounters.py index e2488b7b..6a596ac7 100644 --- a/src/usdm/generate_encounters.py +++ b/src/usdm/generate_encounters.py @@ -2,12 +2,6 @@ from typing import List, Dict, Any, Tuple from soa_builder.web.utils import get_submission_value_for_code, _nz from soa_builder.web.db import _connect -from .usdm_utils import ( - _get_timing_name, - _get_transition_start_rule, - _get_transition_end_rule, - _get_code_tuple, -) # Override the definition in usdm_utils.py @@ -81,6 +75,54 @@ def build_usdm_encounters(soa_id: int) -> List[Dict[str, Any]]: (soa_id,), ) rows = cur.fetchall() + + # Pre-fetch code_association + ddf_terminology for type codes (keyed by code_uid) + cur.execute( + "SELECT DISTINCT c.code_uid, c.codelist_table, p.code, p.cdisc_submission_value, p.dataset_date " + "FROM code_association c INNER JOIN ddf_terminology p ON c.codelist_code = p.codelist_code " + "AND c.code = p.code WHERE c.soa_id=?", + (soa_id,), + ) + type_code_map: dict = {} + for code_uid, codelist_table, code, decode, dataset_date in cur.fetchall(): + type_code_map.setdefault(code_uid, ([], [], [], [])) + type_code_map[code_uid][0].append(code) + type_code_map[code_uid][1].append(decode) + type_code_map[code_uid][2].append(codelist_table) + type_code_map[code_uid][3].append(dataset_date) + + # Pre-fetch code_association for env/contact codes (keyed by code_uid) + cur.execute( + "SELECT DISTINCT code_uid, codelist_table, code FROM code_association WHERE soa_id=?", + (soa_id,), + ) + code_tuple_map: dict = {} + for code_uid, codelist_table, code in cur.fetchall(): + code_tuple_map.setdefault(code_uid, ([], [])) + code_tuple_map[code_uid][0].append(code) + code_tuple_map[code_uid][1].append(codelist_table) + + # Pre-fetch all transition rules for this SOA (keyed by transition_rule_uid) + cur.execute( + "SELECT transition_rule_uid, name, label, description, text FROM transition_rule WHERE soa_id=?", + (soa_id,), + ) + transition_rule_map: dict = {} + for tr_uid, tr_name, tr_label, tr_desc, tr_text in cur.fetchall(): + transition_rule_map[tr_uid] = { + "id": tr_uid, + "extensionAttributes": [], + "name": tr_name or None, + "label": tr_label or None, + "description": tr_desc or None, + "text": tr_text or None, + "instanceType": "TransitionRule", + } + + # Pre-fetch all timing UIDs for this SOA (keyed by timing id) + cur.execute("SELECT id, timing_uid FROM timing WHERE soa_id=?", (soa_id,)) + timing_id_map: dict = {row[0]: row[1] for row in cur.fetchall()} + conn.close() uids = [r[3] for r in rows] @@ -116,42 +158,33 @@ def build_usdm_encounters(soa_id: int) -> List[Dict[str, Any]]: r[10], ) eid = encounter_uid - t_code, t_decode, t_codeSystem, t_codeSystemVersion = _get_type_code_tuple( - soa_id, type - ) + _type_entry = type_code_map.get(type, ([], [], [], [])) + t_code, t_decode, t_codeSystem, t_codeSystemVersion = _type_entry e_code: List[str] = [] e_codesystem: List[str] = [] if environmentalSettings: - e_code, e_codesystem = _get_code_tuple(soa_id, environmentalSettings) + e_code, e_codesystem = code_tuple_map.get(environmentalSettings, ([], [])) c_code: List[str] = [] c_codesystem: List[str] = [] if contactModes: - c_code, c_codesystem = _get_code_tuple(soa_id, contactModes) + c_code, c_codesystem = code_tuple_map.get(contactModes, ([], [])) - # print(e_code, e_codesystem) prev_id = id_by_index.get(i - 1) next_id = id_by_index.get(i + 1) - timing_uid = _get_timing_name( - soa_id, - ( - int(scheduledAtId) - if (scheduledAtId is not None and str(scheduledAtId).isdigit()) - else None - ), + _sched_id = ( + int(scheduledAtId) + if (scheduledAtId is not None and str(scheduledAtId).isdigit()) + else None ) + timing_uid = timing_id_map.get(_sched_id) if _sched_id is not None else None - transition_start_rule_obj = _get_transition_start_rule( - soa_id, transition_start_rule_uid - ) - - transition_end_rule_obj = _get_transition_end_rule( - soa_id, transition_end_rule_uid - ) + transition_start_rule_obj = transition_rule_map.get(transition_start_rule_uid) + transition_end_rule_obj = transition_rule_map.get(transition_end_rule_uid) # Build optional environmentalSettings array env_settings: List[Dict[str, Any]] = [] diff --git a/tests/test_routers_activities.py b/tests/test_routers_activities.py index 19147ca0..ac9e9c2a 100644 --- a/tests/test_routers_activities.py +++ b/tests/test_routers_activities.py @@ -178,7 +178,9 @@ def test_reorder_activities(): ] # Reorder - resp = client.post(f"/soa/{soa_id}/activities/reorder", json=[a3, a1, a2]) + resp = client.post( + f"/soa/{soa_id}/activities/reorder", json={"order": [a3, a1, a2]} + ) assert resp.status_code == 200 @@ -196,7 +198,7 @@ def test_reorder_activities_router(): ] # Reorder via router - resp = client.post(f"/soa/{soa_id}/activities/reorder", json=[a2, a1]) + resp = client.post(f"/soa/{soa_id}/activities/reorder", json={"order": [a2, a1]}) assert resp.status_code == 200 From 5ea155691717b45deeb6e471457d30050af98409 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:54:21 -0400 Subject: [PATCH 02/27] Added lines for .scripts directory and interim USDM --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 72f6bf2a..912b269f 100644 --- a/.gitignore +++ b/.gitignore @@ -102,5 +102,7 @@ CLAUDE.md edit-column-collapse.html .claude api_test.py +.scripts +files/pilot_LZZT_narrative_2026MAR10.json # End of file From ece559e0d2e6898b41723e556a3e6313e7aab863 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:54:50 -0400 Subject: [PATCH 03/27] Fixed 404 on activity save --- src/soa_builder/web/templates/activities.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/soa_builder/web/templates/activities.html b/src/soa_builder/web/templates/activities.html index 98ef8d11..13dd5085 100644 --- a/src/soa_builder/web/templates/activities.html +++ b/src/soa_builder/web/templates/activities.html @@ -61,7 +61,7 @@