From 459e78ca8c9cb31c357cbe1e4847fe5811d70804 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Mon, 24 Nov 2025 14:24:57 -0500 Subject: [PATCH 01/20] Updated docstrings for endpoints & included in documentation --- docs/api_endpoints.xlsx | Bin 12205 -> 14635 bytes src/soa_builder/web/app.py | 51 +++++++++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/docs/api_endpoints.xlsx b/docs/api_endpoints.xlsx index 903fce87162a39c673b3feaea10ecb02c1b10c6d..f4df9493743e82c08a9c82d458e2459cc1199dc6 100644 GIT binary patch delta 8733 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 delta 6306 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.location='/ui/soa/{soa_id}/edit';") @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.""" delete_epoch(soa_id, epoch_id) return HTMLResponse(f"") @@ -3998,7 +4032,7 @@ def ui_reorder_activities(request: Request, soa_id: int, order: str = Form("")): @app.post("/ui/soa/{soa_id}/reorder_epochs", response_class=HTMLResponse) def ui_reorder_epochs(request: Request, soa_id: int, order: str = Form("")): - """Persist new epoch ordering.""" + """Form handler to persist new epoch ordering.""" if not _soa_exists(soa_id): raise HTTPException(404, "SOA not found") ids = [int(x) for x in order.split(",") if x.strip().isdigit()] @@ -4333,6 +4367,7 @@ def ui_ddf_terminology( uploaded: Optional[str] = None, error: Optional[str] = None, ): + """Detail page to display loaded DDF terminology from the SQLite table""" data = get_ddf_terminology( search=search, code=code, @@ -4498,6 +4533,7 @@ def _get_ddf_sources() -> List[str]: def get_ddf_audit( source: Optional[str] = None, start: Optional[str] = None, end: Optional[str] = None ): + """Return audit report of DDF Terminology loads.""" conn = _connect() cur = conn.cursor() cur.execute( @@ -4556,6 +4592,7 @@ def _valid_date(d: str) -> bool: def export_ddf_audit_csv( source: Optional[str] = None, start: Optional[str] = None, end: Optional[str] = None ): + """Export DDF terminology audit report in CSV format.""" rows = get_ddf_audit(source=source, start=start, end=end) import csv import io @@ -4603,6 +4640,7 @@ def export_ddf_audit_csv( def export_ddf_audit_json( source: Optional[str] = None, start: Optional[str] = None, end: Optional[str] = None ): + """Export DDF terminology audit report in JSON format.""" return get_ddf_audit(source=source, start=start, end=end) @@ -4613,6 +4651,7 @@ def ui_ddf_audit( start: Optional[str] = None, end: Optional[str] = None, ): + """Display audit report of DDF Terminology loads""" rows = get_ddf_audit(source=source, start=start, end=end) sources = _get_ddf_sources() return templates.TemplateResponse( @@ -4756,6 +4795,7 @@ def load_protocol_terminology( def admin_load_protocol( file_path: Optional[str] = None, sheet_name: str = "Protocol Terminology 2025-09-26" ): + """Load new Protocol Terminology XLS.""" project_root = os.path.dirname( os.path.dirname(os.path.dirname(os.path.dirname(__file__))) ) @@ -4802,6 +4842,7 @@ def get_protocol_terminology( limit: int = 50, offset: int = 0, ): + """Return latest rotocol Terminology loaded into SQLite database.""" limit = max(1, min(limit, 200)) offset = max(0, offset) conn = _connect() @@ -4893,6 +4934,7 @@ def ui_protocol_terminology( uploaded: Optional[str] = None, error: Optional[str] = None, ): + """Form handler to display the latest loaded Protocol Terminology from the SQLite database.""" data = get_protocol_terminology( search=search, code=code, @@ -4922,6 +4964,7 @@ def ui_protocol_upload( sheet_name: str = Form("Protocol Terminology 2025-09-26"), file: UploadFile = File(...), ): + """Form handler for the upload of Protocol Terminology XLS.""" filename = file.filename or "uploaded.xls" if not (filename.lower().endswith(".xls") or filename.lower().endswith(".xlsx")): return HTMLResponse( @@ -5051,6 +5094,7 @@ def _get_protocol_sources() -> List[str]: def get_protocol_audit( source: Optional[str] = None, start: Optional[str] = None, end: Optional[str] = None ): + """Return the Protocol Terminology audit report.""" conn = _connect() cur = conn.cursor() cur.execute( @@ -5108,6 +5152,7 @@ def _valid_date(d: str) -> bool: def export_protocol_audit_csv( source: Optional[str] = None, start: Optional[str] = None, end: Optional[str] = None ): + """Export the latest Protocol Terminology from the SQLite database in CSV format.""" rows = get_protocol_audit(source=source, start=start, end=end) import csv import io @@ -5155,6 +5200,7 @@ def export_protocol_audit_csv( def export_protocol_audit_json( source: Optional[str] = None, start: Optional[str] = None, end: Optional[str] = None ): + """Export the latest Protocol Terminology from the SQLite database in JSON format.""" return get_protocol_audit(source=source, start=start, end=end) @@ -5165,6 +5211,7 @@ def ui_protocol_audit( start: Optional[str] = None, end: Optional[str] = None, ): + """Form handler for display of the Protocol Terminology audit report.""" rows = get_protocol_audit(source=source, start=start, end=end) sources = _get_protocol_sources() return templates.TemplateResponse( From ca7e46d6f3c00bb995943b1c26b9ad9cd09001fc Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Tue, 2 Dec 2025 14:06:51 -0500 Subject: [PATCH 02/20] Reordered object lists --- src/soa_builder/web/static/style.css | 2 +- src/soa_builder/web/templates/edit.html | 64 ++++++++++++------------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/src/soa_builder/web/static/style.css b/src/soa_builder/web/static/style.css index 04ae631..6c3d410 100644 --- a/src/soa_builder/web/static/style.css +++ b/src/soa_builder/web/static/style.css @@ -1,4 +1,4 @@ -body { font-family: system-ui, Arial, sans-serif; margin: 1.5rem; } +body { font-family: Arial, Helvetica, sans-serif; margin: 1.5rem; } header h1 { margin: 0; } nav a { margin-right: 1rem; } .table, table { border-collapse: collapse; } diff --git a/src/soa_builder/web/templates/edit.html b/src/soa_builder/web/templates/edit.html index d670aa0..2b03dbb 100644 --- a/src/soa_builder/web/templates/edit.html +++ b/src/soa_builder/web/templates/edit.html @@ -82,34 +82,6 @@

Editing SoA {{ soa_id }}

-
- Epochs ({{ epochs|length }}) (drag to reorder) -
    - {% for e in epochs %} -
  • - {{ e.order_index }}. E{{ e.epoch_seq }} {{ e.name }} - {% if e.epoch_label %}[{{ e.epoch_label }}]{% endif %} -
    - - -
    -
    - - - - - -
    -
  • - {% endfor %} -
-
- - - - -
-
Visits ({{ visits|length }}) (drag to reorder)
    @@ -145,8 +117,6 @@

    Editing SoA {{ soa_id }}

-
-
Activities ({{ activities|length }}) (drag to reorder)
    @@ -164,7 +134,37 @@

    Editing SoA {{ soa_id }}

-
+
+
+
+ Epochs ({{ epochs|length }}) (drag to reorder) +
    + {% for e in epochs %} +
  • + {{ e.order_index }}. E{{ e.epoch_seq }} {{ e.name }} + {% if e.epoch_label %}[{{ e.epoch_label }}]{% endif %} +
    + + +
    +
    + + + + + +
    +
  • + {% endfor %} +
+
+ + + + +
+
+
Elements ({{ elements|length }}) (drag to reorder)
    {% for el in elements %} @@ -197,7 +197,7 @@

    Editing SoA {{ soa_id }}

-
+
Arms ({{ arms|length }}) (drag to reorder)
    {% for arm in arms %} From 672df4a0f52f8a8a4f644bbeb20205ea2f670001 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Tue, 2 Dec 2025 14:11:52 -0500 Subject: [PATCH 03/20] Added docs for 2 new endpoints for viewing categories --- docs/api_endpoints.xlsx | Bin 14635 -> 14693 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/api_endpoints.xlsx b/docs/api_endpoints.xlsx index f4df9493743e82c08a9c82d458e2459cc1199dc6..f49a3326db24d7e4e11e27dee63df6e3422634cb 100644 GIT binary patch delta 8037 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 delta 7993 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ℜ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 NB}C{k5kmjL{trlp7PbHY From 0318e3f2684bb1ad6d8b4051adb5b2aa561944f7 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Tue, 2 Dec 2025 14:33:24 -0500 Subject: [PATCH 04/20] Added concept category cache and corresponding tests --- src/soa_builder/web/app.py | 41 +++++- .../web/templates/concept_categories.html | 9 +- .../templates/concept_category_detail.html | 5 + tests/test_categories_cache.py | 118 ++++++++++++++++++ tests/test_categories_ui_force.py | 89 +++++++++++++ tests/test_concept_categories.py | 16 ++- tests/test_concept_category_force_refresh.py | 66 ++++++++++ tests/test_concepts_by_category_ui_force.py | 88 +++++++++++++ 8 files changed, 418 insertions(+), 14 deletions(-) create mode 100644 tests/test_categories_cache.py create mode 100644 tests/test_categories_ui_force.py create mode 100644 tests/test_concept_category_force_refresh.py create mode 100644 tests/test_concepts_by_category_ui_force.py diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index 7cf95e8..db8c493 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -78,6 +78,12 @@ # SDTM dataset specializations cache (similar TTL) _sdtm_specializations_cache = {"data": None, "fetched_at": 0} _SDTM_SPECIALIZATIONS_CACHE_TTL = 60 * 60 +# Category-specific biomedical concepts cache (per category key) +_category_concepts_cache: dict[str, dict] = {} +_CATEGORY_CONCEPTS_CACHE_TTL = 60 * 60 # 1 hour +# Biomedical concept categories cache (whole list) +_bc_categories_cache = {"data": None, "fetched_at": 0} +_BC_CATEGORIES_CACHE_TTL = 60 * 60 # 1 hour app = FastAPI(title="SoA Builder API", version="0.1.0") logger = logging.getLogger("soa_builder.concepts") if not logger.handlers: @@ -1000,7 +1006,7 @@ def _fetch_matrix(soa_id: int): return visits, activities, cells -def fetch_biomedical_concept_categories() -> list[dict]: +def fetch_biomedical_concept_categories(force: bool = False) -> list[dict]: """Return list of Biomedical Concept Categories from CDISC Library. Normalized shape: @@ -1027,6 +1033,15 @@ def _normalize_href(h: Optional[str]) -> Optional[str]: return base_prefix + h return base_prefix + "/" + h + # Cache lookup + now = time.time() + if ( + not force + and _bc_categories_cache.get("data") + and now - _bc_categories_cache.get("fetched_at", 0) < _BC_CATEGORIES_CACHE_TTL + ): + return _bc_categories_cache.get("data") or [] + try: resp = requests.get(url, headers=headers, timeout=15) if resp.status_code != 200: @@ -1069,13 +1084,15 @@ def _normalize_href(h: Optional[str]) -> Optional[str]: ) categories.sort(key=lambda c: (c["title"] or "").lower()) logger.info("Fetched %d BC categories from remote API", len(categories)) + _bc_categories_cache["data"] = categories + _bc_categories_cache["fetched_at"] = now return categories except Exception as e: # pragma: no cover logger.error("BC categories fetch error: %s", e) return [] -def fetch_biomedical_concepts_by_category(name: str) -> list[dict]: +def fetch_biomedical_concepts_by_category(name: str, force: bool = False) -> list[dict]: """Return biomedical concepts for a given category name. Uses category-specific endpoint: /mdr/bc/biomedicalconcepts?category= @@ -1108,6 +1125,14 @@ def _normalize_href(h: Optional[str]) -> Optional[str]: return base_prefix + h return base_prefix + "/" + h + # Cache lookup + now = time.time() + ckey = category.lower() + if not force: + cached = _category_concepts_cache.get(ckey) + if cached and now - cached.get("fetched_at", 0) < _CATEGORY_CONCEPTS_CACHE_TTL: + return cached.get("data", []) or [] + concepts: list[dict] = [] try: resp = requests.get(url, headers=headers, timeout=20) @@ -1205,6 +1230,8 @@ def _normalize_href(h: Optional[str]) -> Optional[str]: logger.info( "Fetched %d biomedical concepts for category '%s'", len(concepts), category ) + # Populate cache + _category_concepts_cache[ckey] = {"data": concepts, "fetched_at": now} return concepts except Exception as e: # pragma: no cover logger.error("BC concepts by category fetch error for '%s': %s", category, e) @@ -3061,9 +3088,9 @@ def ui_concepts_list(request: Request): @app.get("/ui/concept_categories", response_class=HTMLResponse) -def ui_categories_list(request: Request): +def ui_categories_list(request: Request, force: bool = False): """Render table listing biomedical concept categories (name + title + href).""" - categories = fetch_biomedical_concept_categories() or [] + categories = fetch_biomedical_concept_categories(force=force) or [] rows = [ { "name": c.get("name"), @@ -3077,6 +3104,7 @@ def ui_categories_list(request: Request): request, "concept_categories.html", { + "force": force, "rows": rows, "count": len(rows), "missing_key": subscription_key is None, @@ -3085,7 +3113,7 @@ def ui_categories_list(request: Request): @app.get("/ui/concept_categories/view", response_class=HTMLResponse) -def ui_category_detail(request: Request, name: str = ""): +def ui_category_detail(request: Request, name: str = "", force: bool = False): """Render list of biomedical concepts within a given category name. Query params: @@ -3096,7 +3124,7 @@ def ui_category_detail(request: Request, name: str = ""): return HTMLResponse( "

    Category name required.

    Back

    " ) - concepts = fetch_biomedical_concepts_by_category(category_name) or [] + concepts = fetch_biomedical_concepts_by_category(category_name, force=force) or [] rows = [ { "code": c.get("code"), @@ -3110,6 +3138,7 @@ def ui_category_detail(request: Request, name: str = ""): "concept_category_detail.html", { "category": category_name, + "force": force, "rows": rows, "count": len(rows), }, diff --git a/src/soa_builder/web/templates/concept_categories.html b/src/soa_builder/web/templates/concept_categories.html index b59342f..83852ba 100644 --- a/src/soa_builder/web/templates/concept_categories.html +++ b/src/soa_builder/web/templates/concept_categories.html @@ -5,6 +5,10 @@

    Biomedical Concept Categories ({{ count }}) +

    + Refresh Categories (bypass cache) + {% if force %}Cache refreshed{% endif %} +

    @@ -14,7 +18,7 @@

    Biomedical Concept Categories ({{ count }}) - + Concepts Name Title @@ -25,11 +29,12 @@

    Biomedical Concept Categories ({{ count }}) {% if r.name %} - {{ r.name }} + View Concepts {% else %} n/a {% endif %} + {{ r.name }} {{ r.title }} {% endfor %} diff --git a/src/soa_builder/web/templates/concept_category_detail.html b/src/soa_builder/web/templates/concept_category_detail.html index 16f63b5..df02857 100644 --- a/src/soa_builder/web/templates/concept_category_detail.html +++ b/src/soa_builder/web/templates/concept_category_detail.html @@ -1,6 +1,11 @@ {% extends 'base.html' %} {% block content %}

    Biomedical Concepts in Category: {{ category }}

    +

    + Refresh (bypass cache) + {% if force %}Cache bypassed{% endif %} + | Back to Categories +

    Total concepts: {{ count }}

    {% if rows %} diff --git a/tests/test_categories_cache.py b/tests/test_categories_cache.py new file mode 100644 index 0000000..c5864ff --- /dev/null +++ b/tests/test_categories_cache.py @@ -0,0 +1,118 @@ +from types import SimpleNamespace + +import pytest + +from soa_builder.web.app import ( + fetch_biomedical_concept_categories, + _bc_categories_cache, +) + + +class DummyResponse: + def __init__(self, payload, status_code=200): + self._payload = payload + self.status_code = status_code + + def json(self): + return self._payload + + +@pytest.fixture(autouse=True) +def clear_categories_cache(): + _bc_categories_cache.clear() + + +def test_categories_cache_hit(monkeypatch): + call_count = SimpleNamespace(n=0) + + payload = { + "_links": { + "categories": [ + { + "name": "CategoryA", + "_links": { + "self": {"href": "/mdr/bc/categories/A", "title": "TitleA"} + }, + }, + { + "name": "CategoryB", + "_links": { + "self": {"href": "/mdr/bc/categories/B", "title": "TitleB"} + }, + }, + ] + } + } + + def fake_get(url, headers=None, timeout=None): + call_count.n += 1 + return DummyResponse(payload) + + monkeypatch.setattr("requests.get", fake_get) + + # First call populates cache + cats1 = fetch_biomedical_concept_categories(force=False) + assert call_count.n == 1 + assert len(cats1) == 2 + + # Second call within TTL should hit cache, no new HTTP call + cats2 = fetch_biomedical_concept_categories(force=False) + assert call_count.n == 1 + assert cats2 == cats1 + + +def test_categories_cache_force_bypass(monkeypatch): + call_count = SimpleNamespace(n=0) + + payload1 = { + "_links": { + "categories": [ + { + "name": "CategoryA", + "_links": { + "self": {"href": "/mdr/bc/categories/A", "title": "TitleA"} + }, + } + ] + } + } + payload2 = { + "_links": { + "categories": [ + { + "name": "CategoryA", + "_links": { + "self": {"href": "/mdr/bc/categories/A2", "title": "TitleA2"} + }, + }, + { + "name": "CategoryC", + "_links": { + "self": {"href": "/mdr/bc/categories/C", "title": "TitleC"} + }, + }, + ] + } + } + + def fake_get(url, headers=None, timeout=None): + call_count.n += 1 + # Return payload1 first, payload2 thereafter + return DummyResponse(payload1 if call_count.n == 1 else payload2) + + monkeypatch.setattr("requests.get", fake_get) + + # Populate cache + cats1 = fetch_biomedical_concept_categories(force=False) + assert call_count.n == 1 + assert [c["name"] for c in cats1] == ["CategoryA"] + + # Force bypass should trigger a new HTTP call and new content + cats2 = fetch_biomedical_concept_categories(force=True) + assert call_count.n == 2 + assert [c["name"] for c in cats2] == ["CategoryA", "CategoryC"] + + # Regular call after force should use cached latest content (no extra HTTP) + cats3 = fetch_biomedical_concept_categories(force=False) + assert call_count.n == 2 + assert cats3 == cats2 diff --git a/tests/test_categories_ui_force.py b/tests/test_categories_ui_force.py new file mode 100644 index 0000000..194cfb1 --- /dev/null +++ b/tests/test_categories_ui_force.py @@ -0,0 +1,89 @@ +from types import SimpleNamespace + +import pytest +from fastapi.testclient import TestClient + +from soa_builder.web.app import app, _bc_categories_cache + +client = TestClient(app) + + +class DummyResponse: + def __init__(self, payload, status_code=200, text=""): + self._payload = payload + self.status_code = status_code + self.text = text or "" + + def json(self): + return self._payload + + +@pytest.fixture(autouse=True) +def clear_categories_cache(): + _bc_categories_cache.clear() + + +def test_ui_categories_force_bypass(monkeypatch): + call_count = SimpleNamespace(n=0) + + payload1 = { + "_links": { + "categories": [ + { + "name": "CategoryA", + "_links": { + "self": {"href": "/mdr/bc/categories/A", "title": "TitleA"} + }, + }, + ] + } + } + payload2 = { + "_links": { + "categories": [ + { + "name": "CategoryA", + "_links": { + "self": {"href": "/mdr/bc/categories/A2", "title": "TitleA2"} + }, + }, + { + "name": "CategoryC", + "_links": { + "self": {"href": "/mdr/bc/categories/C", "title": "TitleC"} + }, + }, + ] + } + } + + def fake_get(url, headers=None, timeout=None): + call_count.n += 1 + return DummyResponse(payload1 if call_count.n == 1 else payload2, text="ok") + + monkeypatch.setattr("requests.get", fake_get) + + # Initial request populates cache, shows TitleA only + r1 = client.get("/ui/concept_categories") + assert r1.status_code == 200 + html1 = r1.text + assert "TitleA" in html1 + assert "TitleA2" not in html1 + assert "TitleC" not in html1 + assert call_count.n == 1 + + # Force bypass should fetch again and render updated titles + r2 = client.get("/ui/concept_categories?force=1") + assert r2.status_code == 200 + html2 = r2.text + assert "TitleA2" in html2 + assert "TitleC" in html2 + assert call_count.n == 2 + + # Subsequent non-force call should serve cached updated content (no new HTTP) + r3 = client.get("/ui/concept_categories") + assert r3.status_code == 200 + html3 = r3.text + assert "TitleA2" in html3 + assert "TitleC" in html3 + assert call_count.n == 2 diff --git a/tests/test_concept_categories.py b/tests/test_concept_categories.py index 4c07769..13b90a7 100644 --- a/tests/test_concept_categories.py +++ b/tests/test_concept_categories.py @@ -42,7 +42,7 @@ def fake_get(url, headers=None, timeout=0): return DummyResp(200, {"items": []}) monkeypatch.setattr("requests.get", fake_get) - fetch_biomedical_concepts_by_category(raw_category) + fetch_biomedical_concepts_by_category(raw_category, force=True) assert len(captured_urls) == 1 assert expected_url_fragment in captured_urls[0] @@ -68,7 +68,7 @@ def fake_get(url, headers=None, timeout=0): return DummyResp(200, payload) monkeypatch.setattr("requests.get", fake_get) - concepts = fetch_biomedical_concepts_by_category("Liver Findings") + concepts = fetch_biomedical_concepts_by_category("Liver Findings", force=True) assert {c["code"] for c in concepts} == {"ALT", "AST"} assert all( c["href"].startswith( @@ -100,7 +100,7 @@ def fake_get(url, headers=None, timeout=0): return DummyResp(200, payload) monkeypatch.setattr("requests.get", fake_get) - concepts = fetch_biomedical_concepts_by_category("Liver Findings") + concepts = fetch_biomedical_concepts_by_category("Liver Findings", force=True) codes = {c["code"] for c in concepts} assert codes == {"ALT", "AST"} # Titles preserved @@ -114,7 +114,7 @@ def fake_get(url, headers=None, timeout=0): return DummyResp(404, {}, text="Not Found") monkeypatch.setattr("requests.get", fake_get) - concepts = fetch_biomedical_concepts_by_category("Liver Findings") + concepts = fetch_biomedical_concepts_by_category("Liver Findings", force=True) assert concepts == [] @@ -139,7 +139,9 @@ def fake_get(url, headers=None, timeout=0): return DummyResp(200, payload) monkeypatch.setattr("requests.get", fake_get) - resp = client.get("/ui/concept_categories/view", params={"name": "Liver Findings"}) + resp = client.get( + "/ui/concept_categories/view", params={"name": "Liver Findings", "force": True} + ) assert resp.status_code == 200 text = resp.text # Internal links to concept detail page @@ -156,6 +158,8 @@ def fake_get(url, headers=None, timeout=0): return DummyResp(200, {"items": []}) monkeypatch.setattr("requests.get", fake_get) - resp = client.get("/ui/concept_categories/view", params={"name": "Liver Findings"}) + resp = client.get( + "/ui/concept_categories/view", params={"name": "Liver Findings", "force": True} + ) assert resp.status_code == 200 assert "No concepts found for this category." in resp.text diff --git a/tests/test_concept_category_force_refresh.py b/tests/test_concept_category_force_refresh.py new file mode 100644 index 0000000..c0c512f --- /dev/null +++ b/tests/test_concept_category_force_refresh.py @@ -0,0 +1,66 @@ +import json +from typing import List +from fastapi.testclient import TestClient + +from soa_builder.web.app import app, _category_concepts_cache + +client = TestClient(app) + + +class DummyResp: + def __init__(self, status_code: int, json_data=None, text: str = ""): + self.status_code = status_code + self._json = json_data + self.text = text or json.dumps(json_data or {}) + + def json(self): + if self._json is None: + raise ValueError("No JSON") + return self._json + + +def test_ui_category_force_refresh(monkeypatch): + _category_concepts_cache.clear() + calls: List[str] = [] + payload1 = { + "items": [ + { + "code": "ALT", + "title": "Alanine", + "href": "/mdr/bc/biomedicalconcepts/ALT", + } + ] + } + payload2 = { + "items": [ + { + "code": "AST", + "title": "Aspartate", + "href": "/mdr/bc/biomedicalconcepts/AST", + } + ] + } + + def fake_get(url, headers=None, timeout=0): + # First call returns ALT, subsequent calls return AST + if not calls: + calls.append(url) + return DummyResp(200, payload1) + calls.append(url) + return DummyResp(200, payload2) + + monkeypatch.setattr("requests.get", fake_get) + # Initial request populates cache with ALT + r1 = client.get("/ui/concept_categories/view", params={"name": "Force Test"}) + assert r1.status_code == 200 + assert "/ui/concepts/ALT" in r1.text + # Second request without force still uses cache (ALT) + r2 = client.get("/ui/concept_categories/view", params={"name": "Force Test"}) + assert r2.status_code == 200 + assert "/ui/concepts/ALT" in r2.text + # Third request with force bypasses cache and shows AST + r3 = client.get( + "/ui/concept_categories/view", params={"name": "Force Test", "force": True} + ) + assert r3.status_code == 200 + assert "/ui/concepts/AST" in r3.text diff --git a/tests/test_concepts_by_category_ui_force.py b/tests/test_concepts_by_category_ui_force.py new file mode 100644 index 0000000..c036973 --- /dev/null +++ b/tests/test_concepts_by_category_ui_force.py @@ -0,0 +1,88 @@ +from types import SimpleNamespace + +import pytest +from fastapi.testclient import TestClient + +from soa_builder.web.app import app, _category_concepts_cache + +client = TestClient(app) + + +class DummyResponse: + def __init__(self, payload, status_code=200, text=""): + self._payload = payload + self.status_code = status_code + self.text = text or "" + + def json(self): + return self._payload + + +@pytest.fixture(autouse=True) +def clear_concepts_cache(): + _category_concepts_cache.clear() + + +def test_ui_concepts_by_category_force_bypass(monkeypatch): + call_count = SimpleNamespace(n=0) + + # The app queries: /mdr/bc/biomedicalconcepts?category=CategoryA (encoded) + # Provide payloads representing concepts in direct 'items' list form. + payload1 = { + "items": [ + { + "code": "C100", + "title": "Alpha", + "href": "/mdr/bc/biomedicalconcepts/C100", + }, + ] + } + payload2 = { + "items": [ + { + "code": "C100", + "title": "Alpha v2", + "href": "/mdr/bc/biomedicalconcepts/C100", + }, + { + "code": "C200", + "title": "Beta", + "href": "/mdr/bc/biomedicalconcepts/C200", + }, + ] + } + + def fake_get(url, headers=None, timeout=None): + # Ensure we are mocking the concepts-by-category endpoint + assert "/mdr/bc/biomedicalconcepts?category=" in url + call_count.n += 1 + return DummyResponse(payload1 if call_count.n == 1 else payload2, text="ok") + + monkeypatch.setattr("requests.get", fake_get) + + # Initial request populates cache, shows Alpha only + r1 = client.get("/ui/concept_categories/view", params={"name": "CategoryA"}) + assert r1.status_code == 200 + html1 = r1.text + assert "Alpha" in html1 + assert "Alpha v2" not in html1 + assert "Beta" not in html1 + assert call_count.n == 1 + + # Force bypass should fetch again and render updated concept titles + r2 = client.get( + "/ui/concept_categories/view", params={"name": "CategoryA", "force": 1} + ) + assert r2.status_code == 200 + html2 = r2.text + assert "Alpha v2" in html2 + assert "Beta" in html2 + assert call_count.n == 2 + + # Subsequent non-force call should serve cached updated content (no new HTTP) + r3 = client.get("/ui/concept_categories/view", params={"name": "CategoryA"}) + assert r3.status_code == 200 + html3 = r3.text + assert "Alpha v2" in html3 + assert "Beta" in html3 + assert call_count.n == 2 From ab410d24db6c7344cadff467b0f2a7b096b0c1e1 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Tue, 2 Dec 2025 17:14:34 -0500 Subject: [PATCH 05/20] Arm type now chosen from dropdown from codelist C174222 --- src/soa_builder/web/app.py | 164 +++++++++++++++++++-- src/soa_builder/web/initialize_database.py | 13 ++ src/soa_builder/web/templates/edit.html | 16 ++ 3 files changed, 184 insertions(+), 9 deletions(-) diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index db8c493..49aafdc 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -65,8 +65,8 @@ from .routers import rollback as rollback_router from .routers import visits as visits_router from .routers.arms import create_arm # re-export for backward compatibility -from .routers.arms import delete_arm, update_arm -from .schemas import ArmCreate, ArmUpdate, SOACreate, SOAMetadataUpdate +from .routers.arms import delete_arm +from .schemas import ArmCreate, SOACreate, SOAMetadataUpdate load_dotenv() # must come BEFORE reading env-based configuration so values are populated DB_PATH = os.environ.get("SOA_BUILDER_DB", "soa_builder_web.db") @@ -881,7 +881,7 @@ def _fetch_arms_for_edit(soa_id: int) -> list[dict]: conn = _connect() cur = conn.cursor() cur.execute( - "SELECT id,name,label,description,order_index FROM arm WHERE soa_id=? ORDER BY order_index", + "SELECT id,name,label,description,order_index,COALESCE(type,'') FROM arm WHERE soa_id=? ORDER BY order_index", (soa_id,), ) rows = [ @@ -891,6 +891,7 @@ def _fetch_arms_for_edit(soa_id: int) -> list[dict]: "label": r[2], "description": r[3], "order_index": r[4], + "type": r[5] or None, } for r in cur.fetchall() ] @@ -3036,6 +3037,16 @@ def ui_edit(request: Request, soa_id: int): "study_label": meta_row[1] if meta_row else None, "study_description": meta_row[2] if meta_row else None, } + # Protocol terminology options for Arm Type (C174222) + conn_pt = _connect() + cur_pt = conn_pt.cursor() + cur_pt.execute( + "SELECT cdisc_submission_value FROM protocol_terminology WHERE codelist_code='C174222' ORDER BY cdisc_submission_value" + ) + protocol_terminology_C174222 = [ + {"cdisc_submission_value": r[0] or ""} for r in cur_pt.fetchall() + ] + conn_pt.close() return templates.TemplateResponse( request, "edit.html", @@ -3045,7 +3056,64 @@ def ui_edit(request: Request, soa_id: int): "visits": visits, "activities": activities_page, "elements": elements, - "arms": _fetch_arms_for_edit(soa_id), + # Enrich arms with current type display (map Code_N -> cdisc_submission_value via protocol code) + "arms": ( + lambda _arms: ( + ( + lambda mapping, submission_values: [ + ( + lambda _type: ( + { + **a, + "type_display": ( + mapping.get(_type) + if mapping.get(_type) is not None + else ( + _type + if (_type in submission_values) + else None + ) + ), + } + ) + )(a.get("type")) + for a in _arms + ] + )( + ( + lambda: ( + ( + lambda conn: ( + ( + lambda cur, rows: ( + (lambda m: (conn.close(), m)[1])( + {row[0]: row[2] for row in rows} + ) + ) + )( + conn.cursor(), + conn.cursor() + .execute( + "SELECT c.code_uid, c.code, pt.cdisc_submission_value " + "FROM code c JOIN protocol_terminology pt ON pt.code = c.code " + "WHERE c.soa_id=? AND c.codelist_code='C174222'", + (soa_id,), + ) + .fetchall(), + ) + ) + )(_connect()) + ) + )(), + set( + [ + opt.get("cdisc_submission_value") or "" + for opt in protocol_terminology_C174222 + ] + ), + ) + ) + )(_fetch_arms_for_edit(soa_id)), "cell_map": cell_map, "concepts": concepts, "activity_concepts": activity_concepts, @@ -3057,6 +3125,7 @@ def ui_edit(request: Request, soa_id: int): "freeze_count": len(freeze_list), "last_frozen_at": last_frozen_at, **study_meta, + "protocol_terminology_C174222": protocol_terminology_C174222, }, ) @@ -3413,7 +3482,7 @@ def ui_add_arm( @app.post("/ui/soa/{soa_id}/update_arm", response_class=HTMLResponse) -def ui_update_arm( +async def ui_update_arm( request: Request, soa_id: int, arm_id: int = Form(...), @@ -3425,10 +3494,87 @@ def ui_update_arm( """Form handler to update an existing Arm.""" if not _soa_exists(soa_id): raise HTTPException(404, "SOA not found") - # Coerce possible blank element selection to None; avoid 422 validation error from string "" into Optional[int]. - eid = int(element_id) if element_id and element_id.strip().isdigit() else None - payload = ArmUpdate(name=name, label=label, description=description, element_id=eid) - update_arm(soa_id, arm_id, payload) + + # Read raw form to capture field name with hyphen: 'arm-type' + try: + form_data = await request.form() + arm_type_submission = (form_data.get("arm-type") or "").strip() + except Exception: + arm_type_submission = "" + + # Fetch current arm (including existing type code_uid if any) + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT id, name, label, description, COALESCE(type,''), COALESCE(data_origin_type,'') FROM arm WHERE id=? AND soa_id=?", + (arm_id, soa_id), + ) + row = cur.fetchone() + if not row: + conn.close() + raise HTTPException(404, "Arm not found") + current_code_uid = row[4] or None + + # Resolve submission value to protocol terminology code (C174222) + resolved_code: Optional[str] = None + if arm_type_submission: + cur.execute( + "SELECT code FROM protocol_terminology WHERE codelist_code='C174222' AND (cdisc_submission_value=? OR LOWER(TRIM(cdisc_submission_value))=LOWER(TRIM(?)))", + (arm_type_submission, arm_type_submission), + ) + r = cur.fetchone() + resolved_code = r[0] if r else None + if resolved_code is None: + conn.close() + return HTMLResponse( + f"", + status_code=400, + ) + + # Maintain code table row with immutable code_uid (Code_N unique per SoA) + new_code_uid = current_code_uid + if resolved_code is not None: + if current_code_uid: + # Update existing junction row for this code_uid + cur.execute( + "UPDATE code SET code=?, codelist_code='C174222', codelist_table='protocol_terminology' WHERE soa_id=? AND code_uid=?", + (resolved_code, soa_id, current_code_uid), + ) + else: + # Create new Code_N within this SoA + cur.execute( + "SELECT code_uid FROM code WHERE soa_id=? AND code_uid LIKE 'Code_%'", + (soa_id,), + ) + existing = [x[0] for x in cur.fetchall() if x[0]] + n = 1 + if existing: + try: + n = max(int(x.split("_")[1]) for x in existing) + 1 + except Exception: + n = len(existing) + 1 + new_code_uid = f"Code_{n}" + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + new_code_uid, + "protocol_terminology", + "C174222", + resolved_code, + ), + ) + + # Apply arm field updates (including setting type to code_uid if resolved) + new_name = name if name is not None else row[1] + new_label = label if label is not None else row[2] + new_desc = description if description is not None else row[3] + cur.execute( + "UPDATE arm SET name=?, label=?, description=?, type=? WHERE id=? AND soa_id=?", + (new_name, new_label, new_desc, new_code_uid, arm_id, soa_id), + ) + conn.commit() + conn.close() return HTMLResponse(f"") diff --git a/src/soa_builder/web/initialize_database.py b/src/soa_builder/web/initialize_database.py index fde39eb..d4c0366 100644 --- a/src/soa_builder/web/initialize_database.py +++ b/src/soa_builder/web/initialize_database.py @@ -146,5 +146,18 @@ def _init_db(): performed_at TEXT NOT NULL )""" ) + + # create the code table to store unique Code_uid values associated with study objects + cur.execute( + """CREATE TABLE IF NOT EXISTS code ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + soa_id INTEGER NOT NULL, + code_uid TEXT, -- immutable Code_N identifier unique within an SOA + codelist_table TEXT, + codelist_code TEXT NOT NULL, + code TEXT NOT NULL + )""" + ) + conn.commit() conn.close() diff --git a/src/soa_builder/web/templates/edit.html b/src/soa_builder/web/templates/edit.html index 2b03dbb..579df09 100644 --- a/src/soa_builder/web/templates/edit.html +++ b/src/soa_builder/web/templates/edit.html @@ -213,6 +213,22 @@

    Editing SoA {{ soa_id }}

    + {# Type selection from protocol terminology C174222 #} + {% if protocol_terminology_C174222 %} + + {% if arm.type_display %} + [type: {{ arm.type_display }}] + {% else %} + [type: n/a] + {% endif %} + {% endif %} From 7ecf23f3294ab4b6cbb801e7cf4f1456bc65f114 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Wed, 3 Dec 2025 08:39:08 -0500 Subject: [PATCH 06/20] Commented out arm.type debug --- src/soa_builder/web/templates/edit.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/soa_builder/web/templates/edit.html b/src/soa_builder/web/templates/edit.html index 579df09..87baba8 100644 --- a/src/soa_builder/web/templates/edit.html +++ b/src/soa_builder/web/templates/edit.html @@ -223,11 +223,13 @@

    Editing SoA {{ soa_id }}

    {% endfor %} + {# --DEBUG {% if arm.type_display %} [type: {{ arm.type_display }}] {% else %} [type: n/a] {% endif %} + --END_DEBUG #} {% endif %} From 9b5c8ec7bb0cdeff43aa43b5541c9bf5df49d9dd Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Wed, 3 Dec 2025 11:05:23 -0500 Subject: [PATCH 07/20] Added arm type and dataTypeOrigin properties and auditing --- soa_builder_web.db-shm | Bin 0 -> 32768 bytes soa_builder_web.db-wal | Bin 0 -> 300792 bytes src/soa_builder/web/app.py | 265 ++++++++++++++++++------ src/soa_builder/web/db.py | 10 +- src/soa_builder/web/templates/edit.html | 16 +- 5 files changed, 222 insertions(+), 69 deletions(-) create mode 100644 soa_builder_web.db-shm create mode 100644 soa_builder_web.db-wal diff --git a/soa_builder_web.db-shm b/soa_builder_web.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..a44f49a673708edcdfd5b1a239507569b96a65da GIT binary patch literal 32768 zcmeI)M@oc25J2H)&N}8X=N!hIbM_h@M8uVsaO>U+xbPb81eb2SfOd<&h>U`uAj2yt zI#5)1e+TIF{RA<3Lgqp0IE=R;PcqjB_nFh9%ZL8KtMj&tyR(k-k*Bf8^bhMptJjEq zpFhOg_WQ6HW-B2JLdNssm~AiC+=NVOlXa}mHvg_i?2}}2Zd@nxukMiC@8fyN-goCE zeY4eM+xy%p*>h5`yG@OJ_Q6j4e!)zs2J3vJ;Kq<#h&VT=i;m|;HrK(xXJTkNvW(cfLg z&I%}?fC36Apnw7jD4>7>3Mim}0tzUgfC36Apnw7j??hk0tzUg zfC36A5C!rnp@J%EsH2fI?F=)?980XT!+}T&D4>7>3Mim}0t)1WKqV>aX`+Kpy6C2l V49gtm#M^v?0tzUgfCB$f;05+oN(TS{ literal 0 HcmV?d00001 diff --git a/soa_builder_web.db-wal b/soa_builder_web.db-wal new file mode 100644 index 0000000000000000000000000000000000000000..0bf8247151b13ba8c17468a993d65db00ecd3794 GIT binary patch literal 300792 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 literal 0 HcmV?d00001 diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index 49aafdc..1221362 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -881,7 +881,7 @@ def _fetch_arms_for_edit(soa_id: int) -> list[dict]: conn = _connect() cur = conn.cursor() cur.execute( - "SELECT id,name,label,description,order_index,COALESCE(type,'') FROM arm WHERE soa_id=? ORDER BY order_index", + "SELECT id,name,label,description,order_index,COALESCE(type,''),COALESCE(data_origin_type,'') FROM arm WHERE soa_id=? ORDER BY order_index", (soa_id,), ) rows = [ @@ -892,6 +892,7 @@ def _fetch_arms_for_edit(soa_id: int) -> list[dict]: "description": r[3], "order_index": r[4], "type": r[5] or None, + "data_origin_type": r[6] or None, } for r in cur.fetchall() ] @@ -3047,6 +3048,71 @@ def ui_edit(request: Request, soa_id: int): {"cdisc_submission_value": r[0] or ""} for r in cur_pt.fetchall() ] conn_pt.close() + # Build mapping code_uid -> submission value (Arm Type C174222) + conn_map = _connect() + cur_map = conn_map.cursor() + cur_map.execute( + "SELECT c.code_uid, pt.cdisc_submission_value " + "FROM code c JOIN protocol_terminology pt ON pt.code = c.code " + "WHERE c.soa_id=? AND c.codelist_code='C174222'", + (soa_id,), + ) + code_to_submission = {row[0]: row[1] for row in cur_map.fetchall()} + conn_map.close() + submission_values = { + opt.get("cdisc_submission_value") or "" for opt in protocol_terminology_C174222 + } + + # DDF Terminology options for Arm type (C188727) + conn_ddft = _connect() + cur_ddft = conn_ddft.cursor() + cur_ddft.execute( + "SELECT cdisc_submission_value FROM ddf_terminology WHERE codelist_code = 'C188727' ORDER BY cdisc_submission_value" + ) + ddf_terminology_C188727 = [ + {"cdisc_submission_value": r[0] or ""} for r in cur_ddft.fetchall() + ] + conn_ddft.close() + # logger.info("DDF Terminology values: ", ddf_terminology_C188727) + + # Build mapping code_uid -> submission value (Arm dataOriginType C188727) + conn_ddf_map = _connect() + cur_ddf_map = conn_ddf_map.cursor() + cur_ddf_map.execute( + "SELECT c.code_uid, dt.cdisc_submission_value " + "FROM code c JOIN ddf_terminology dt ON dt.code = c.code " + "WHERE c.soa_id=? AND c.codelist_code='C188727'", + (soa_id,), + ) + ddf_code_to_submission = {row[0]: row[1] for row in cur_ddf_map.fetchall()} + conn_ddf_map.close() + ddf_submission_values = { + ddf_opt.get("cdisc_submission_value") or "" + for ddf_opt in ddf_terminology_C188727 + } + # logger.info("DDF Data Origin Type submission values", ddf_submission_values) + + base_arms = _fetch_arms_for_edit(soa_id) + arms_enriched = [] + for a in base_arms: + type = a.get("type") + type_display = code_to_submission.get(type) + data_origin_type = a.get("data_origin_type") + data_origin_type_display = ddf_code_to_submission.get(data_origin_type) + if type_display is None and type: + type_display = type if type in submission_values else None + if data_origin_type_display is None and data_origin_type: + data_origin_type_display = ( + data_origin_type if data_origin_type in ddf_submission_values else None + ) + arms_enriched.append( + { + **a, + "type_display": type_display, + "data_origin_type_display": data_origin_type_display, + } + ) + return templates.TemplateResponse( request, "edit.html", @@ -3056,64 +3122,7 @@ def ui_edit(request: Request, soa_id: int): "visits": visits, "activities": activities_page, "elements": elements, - # Enrich arms with current type display (map Code_N -> cdisc_submission_value via protocol code) - "arms": ( - lambda _arms: ( - ( - lambda mapping, submission_values: [ - ( - lambda _type: ( - { - **a, - "type_display": ( - mapping.get(_type) - if mapping.get(_type) is not None - else ( - _type - if (_type in submission_values) - else None - ) - ), - } - ) - )(a.get("type")) - for a in _arms - ] - )( - ( - lambda: ( - ( - lambda conn: ( - ( - lambda cur, rows: ( - (lambda m: (conn.close(), m)[1])( - {row[0]: row[2] for row in rows} - ) - ) - )( - conn.cursor(), - conn.cursor() - .execute( - "SELECT c.code_uid, c.code, pt.cdisc_submission_value " - "FROM code c JOIN protocol_terminology pt ON pt.code = c.code " - "WHERE c.soa_id=? AND c.codelist_code='C174222'", - (soa_id,), - ) - .fetchall(), - ) - ) - )(_connect()) - ) - )(), - set( - [ - opt.get("cdisc_submission_value") or "" - for opt in protocol_terminology_C174222 - ] - ), - ) - ) - )(_fetch_arms_for_edit(soa_id)), + "arms": arms_enriched, "cell_map": cell_map, "concepts": concepts, "activity_concepts": activity_concepts, @@ -3126,6 +3135,7 @@ def ui_edit(request: Request, soa_id: int): "last_frozen_at": last_frozen_at, **study_meta, "protocol_terminology_C174222": protocol_terminology_C174222, + "ddf_terminology_C188727": ddf_terminology_C188727, }, ) @@ -3495,12 +3505,14 @@ async def ui_update_arm( if not _soa_exists(soa_id): raise HTTPException(404, "SOA not found") - # Read raw form to capture field name with hyphen: 'arm-type' + # Read raw form to capture field names with hyphens: 'arm-type' and 'data-origin-type' try: form_data = await request.form() arm_type_submission = (form_data.get("arm-type") or "").strip() + data_origin_type_submission = (form_data.get("data-origin-type") or "").strip() except Exception: arm_type_submission = "" + data_origin_type_submission = "" # Fetch current arm (including existing type code_uid if any) conn = _connect() @@ -3514,6 +3526,32 @@ async def ui_update_arm( conn.close() raise HTTPException(404, "Arm not found") current_code_uid = row[4] or None + current_data_origin_uid = row[5] or None + # Capture prior code values for audits when code mapping changes without uid change + prior_arm_type_code_value: Optional[str] = None + prior_data_origin_code_value: Optional[str] = None + if current_code_uid: + cur.execute( + "SELECT code FROM code WHERE soa_id=? AND code_uid=?", + (soa_id, current_code_uid), + ) + rcv = cur.fetchone() + prior_arm_type_code_value = rcv[0] if rcv else None + if current_data_origin_uid: + cur.execute( + "SELECT code FROM code WHERE soa_id=? AND code_uid=?", + (soa_id, current_data_origin_uid), + ) + rdv = cur.fetchone() + prior_data_origin_code_value = rdv[0] if rdv else None + before_state = { + "id": row[0], + "name": row[1], + "label": row[2], + "description": row[3], + "type": current_code_uid, + "data_origin_type": current_data_origin_uid, + } # Resolve submission value to protocol terminology code (C174222) resolved_code: Optional[str] = None @@ -3565,15 +3603,118 @@ async def ui_update_arm( ), ) + # Resolve Data Origin Type submission value to DDF terminology code (C188727) + resolved_ddf_code: Optional[str] = None + new_data_origin_uid = current_data_origin_uid + if data_origin_type_submission: + cur.execute( + "SELECT code FROM ddf_terminology WHERE codelist_code='C188727' AND (cdisc_submission_value=? OR LOWER(TRIM(cdisc_submission_value))=LOWER(TRIM(?)))", + (data_origin_type_submission, data_origin_type_submission), + ) + r2 = cur.fetchone() + resolved_ddf_code = r2[0] if r2 else None + if resolved_ddf_code is None: + conn.close() + return HTMLResponse( + f"", + status_code=400, + ) + # Maintain/Upsert immutable Code_N for DDF mapping + if current_data_origin_uid: + cur.execute( + "UPDATE code SET code=?, codelist_code='C188727', codelist_table='ddf_terminology' WHERE soa_id=? AND code_uid=?", + (resolved_ddf_code, soa_id, current_data_origin_uid), + ) + new_data_origin_uid = current_data_origin_uid + else: + # Create new Code_N, ensuring unique across this SoA + cur.execute( + "SELECT code_uid FROM code WHERE soa_id=? AND code_uid LIKE 'Code_%'", + (soa_id,), + ) + existing = [x[0] for x in cur.fetchall() if x[0]] + n = 1 + if existing: + try: + n = max(int(x.split("_")[1]) for x in existing) + 1 + except Exception: + n = len(existing) + 1 + new_data_origin_uid = f"Code_{n}" + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + new_data_origin_uid, + "ddf_terminology", + "C188727", + resolved_ddf_code, + ), + ) + # Apply arm field updates (including setting type to code_uid if resolved) new_name = name if name is not None else row[1] new_label = label if label is not None else row[2] new_desc = description if description is not None else row[3] cur.execute( - "UPDATE arm SET name=?, label=?, description=?, type=? WHERE id=? AND soa_id=?", - (new_name, new_label, new_desc, new_code_uid, arm_id, soa_id), + "UPDATE arm SET name=?, label=?, description=?, type=?, data_origin_type=? WHERE id=? AND soa_id=?", + ( + new_name, + new_label, + new_desc, + new_code_uid, + new_data_origin_uid, + arm_id, + soa_id, + ), ) conn.commit() + # Capture post-update code values + post_arm_type_code_value: Optional[str] = None + post_data_origin_code_value: Optional[str] = None + if new_code_uid: + cur.execute( + "SELECT code FROM code WHERE soa_id=? AND code_uid=?", + (soa_id, new_code_uid), + ) + rav = cur.fetchone() + post_arm_type_code_value = rav[0] if rav else None + if new_data_origin_uid: + cur.execute( + "SELECT code FROM code WHERE soa_id=? AND code_uid=?", + (soa_id, new_data_origin_uid), + ) + rdv2 = cur.fetchone() + post_data_origin_code_value = rdv2[0] if rdv2 else None + after_state = { + "id": arm_id, + "name": new_name, + "label": new_label, + "description": new_desc, + "type": new_code_uid, + "data_origin_type": new_data_origin_uid, + "type_code": post_arm_type_code_value, + "data_origin_type_code": post_data_origin_code_value, + } + # Record audit if any relevant fields or underlying code mappings changed + if ( + before_state["type"] != after_state["type"] + or before_state["data_origin_type"] != after_state["data_origin_type"] + or prior_arm_type_code_value != post_arm_type_code_value + or prior_data_origin_code_value != post_data_origin_code_value + or before_state["name"] != after_state["name"] + or before_state["label"] != after_state["label"] + or before_state["description"] != after_state["description"] + ): + try: + _record_arm_audit( + soa_id, + "update", + arm_id=arm_id, + before=before_state, + after=after_state, + ) + except Exception: + pass conn.close() return HTMLResponse(f"") diff --git a/src/soa_builder/web/db.py b/src/soa_builder/web/db.py index 2b3e336..681a2b5 100644 --- a/src/soa_builder/web/db.py +++ b/src/soa_builder/web/db.py @@ -8,4 +8,12 @@ def _connect(): - return sqlite3.connect(DB_PATH) + conn = sqlite3.connect(DB_PATH, timeout=5.0, check_same_thread=False) + try: + # Improve concurrency and reduce lock errors + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + conn.execute("PRAGMA busy_timeout=3000") + except Exception: + pass + return conn diff --git a/src/soa_builder/web/templates/edit.html b/src/soa_builder/web/templates/edit.html index 87baba8..a243423 100644 --- a/src/soa_builder/web/templates/edit.html +++ b/src/soa_builder/web/templates/edit.html @@ -223,13 +223,17 @@

    Editing SoA {{ soa_id }}

    {% endfor %} - {# --DEBUG - {% if arm.type_display %} - [type: {{ arm.type_display }}] - {% else %} - [type: n/a] {% endif %} - --END_DEBUG #} + {# Type selection from ddf terminology C188727 #} + {% if ddf_terminology_C188727 %} + {% endif %} From 6240e8edc502cc374ad97f0a211f579289df6352 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Wed, 3 Dec 2025 11:10:33 -0500 Subject: [PATCH 08/20] Removed temp shared memory and write-ahead logging database files from git --- .gitignore | 2 ++ soa_builder_web.db-shm | Bin 32768 -> 0 bytes soa_builder_web.db-wal | Bin 300792 -> 0 bytes 3 files changed, 2 insertions(+) delete mode 100644 soa_builder_web.db-shm delete mode 100644 soa_builder_web.db-wal diff --git a/.gitignore b/.gitignore index b6ae2a7..19300fd 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,8 @@ Thumbs.db # SQLite / local DBs *.db +*.db-shm +*.db-wal *.sqlite # Environment variables / secrets (add if created) diff --git a/soa_builder_web.db-shm b/soa_builder_web.db-shm deleted file mode 100644 index a44f49a673708edcdfd5b1a239507569b96a65da..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeI)M@oc25J2H)&N}8X=N!hIbM_h@M8uVsaO>U+xbPb81eb2SfOd<&h>U`uAj2yt zI#5)1e+TIF{RA<3Lgqp0IE=R;PcqjB_nFh9%ZL8KtMj&tyR(k-k*Bf8^bhMptJjEq zpFhOg_WQ6HW-B2JLdNssm~AiC+=NVOlXa}mHvg_i?2}}2Zd@nxukMiC@8fyN-goCE zeY4eM+xy%p*>h5`yG@OJ_Q6j4e!)zs2J3vJ;Kq<#h&VT=i;m|;HrK(xXJTkNvW(cfLg z&I%}?fC36Apnw7jD4>7>3Mim}0tzUgfC36Apnw7j??hk0tzUg zfC36A5C!rnp@J%EsH2fI?F=)?980XT!+}T&D4>7>3Mim}0t)1WKqV>aX`+Kpy6C2l V49gtm#M^v?0tzUgfCB$f;05+oN(TS{ diff --git a/soa_builder_web.db-wal b/soa_builder_web.db-wal deleted file mode 100644 index 0bf8247151b13ba8c17468a993d65db00ecd3794..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 300792 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 From b59752327059a67428e574bee6651b0722f1a400 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Wed, 3 Dec 2025 11:28:57 -0500 Subject: [PATCH 09/20] Added temp shared memory and write-ahead logging to sqlite db --- src/soa_builder/web/db.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/soa_builder/web/db.py b/src/soa_builder/web/db.py index 681a2b5..2b3e336 100644 --- a/src/soa_builder/web/db.py +++ b/src/soa_builder/web/db.py @@ -8,12 +8,4 @@ def _connect(): - conn = sqlite3.connect(DB_PATH, timeout=5.0, check_same_thread=False) - try: - # Improve concurrency and reduce lock errors - conn.execute("PRAGMA journal_mode=WAL") - conn.execute("PRAGMA synchronous=NORMAL") - conn.execute("PRAGMA busy_timeout=3000") - except Exception: - pass - return conn + return sqlite3.connect(DB_PATH) From 31582eb0cebb7e3a95ece894761acb8ec91544f2 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Wed, 3 Dec 2025 12:11:59 -0500 Subject: [PATCH 10/20] Arm audit added to edit.html --- src/soa_builder/web/app.py | 21 +++++++++++++++++ src/soa_builder/web/templates/edit.html | 30 +++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index 1221362..fde0b73 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -3113,6 +3113,26 @@ def ui_edit(request: Request, soa_id: int): } ) + # Admin audit view: recent arm audits for this SOA + conn_audit = _connect() + cur_audit = conn_audit.cursor() + cur_audit.execute( + "SELECT id, arm_id, action, before_json, after_json, performed_at FROM arm_audit WHERE soa_id=? ORDER BY id DESC LIMIT 50", + (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 cur_audit.fetchall() + ] + conn_audit.close() + return templates.TemplateResponse( request, "edit.html", @@ -3136,6 +3156,7 @@ 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, }, ) diff --git a/src/soa_builder/web/templates/edit.html b/src/soa_builder/web/templates/edit.html index a243423..e408716 100644 --- a/src/soa_builder/web/templates/edit.html +++ b/src/soa_builder/web/templates/edit.html @@ -247,6 +247,36 @@

    Editing SoA {{ soa_id }}

+ +
+
+
+ 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 %} +

From e66ad609404063106771ddba1393b7dcdb10fe0e Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Wed, 3 Dec 2025 12:33:18 -0500 Subject: [PATCH 11/20] Arm create includes type & data_origin_type --- src/soa_builder/web/app.py | 225 +++++++++++++++++++++++- src/soa_builder/web/templates/edit.html | 18 ++ 2 files changed, 241 insertions(+), 2 deletions(-) diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index fde0b73..0d87a4d 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -3494,7 +3494,7 @@ def ui_add_visit( @app.post("/ui/soa/{soa_id}/add_arm", response_class=HTMLResponse) -def ui_add_arm( +async def ui_add_arm( request: Request, soa_id: int, name: str = Form(...), @@ -3508,7 +3508,168 @@ def ui_add_arm( # Accept blank/empty element selection gracefully. The form may submit "" which would 422 with Optional[int]. eid = int(element_id) if element_id and element_id.strip().isdigit() else None payload = ArmCreate(name=name, label=label, description=description, element_id=eid) - create_arm(soa_id, payload) + # Create base arm (function may not return id; fetch if needed) + created = create_arm(soa_id, payload) + # routers.arms.create_arm returns a row dict; extract id + new_arm_id = None + try: + if isinstance(created, dict): + new_arm_id = created.get("id") + elif isinstance(created, int): + new_arm_id = created + except Exception: + new_arm_id = None + if not new_arm_id: + try: + conn_tmp = _connect() + cur_tmp = conn_tmp.cursor() + cur_tmp.execute( + "SELECT id FROM arm WHERE soa_id=? ORDER BY id DESC LIMIT 1", + (soa_id,), + ) + rtmp = cur_tmp.fetchone() + new_arm_id = rtmp[0] if rtmp else None + conn_tmp.close() + except Exception: + new_arm_id = None + if not new_arm_id: + return HTMLResponse( + f"", + status_code=500, + ) + # Read optional type fields with hyphenated names + try: + form_data = await request.form() + arm_type_submission = (form_data.get("arm-type") or "").strip() + data_origin_type_submission = (form_data.get("data-origin-type") or "").strip() + except Exception: + arm_type_submission = "" + data_origin_type_submission = "" + + # If type selections provided, resolve to terminology codes and persist via junction table + if arm_type_submission or data_origin_type_submission: + conn = _connect() + cur = conn.cursor() + logger.info( + "ui_add_arm: received type selections arm-type='%s', data-origin-type='%s' for soa_id=%s arm_id=%s", + arm_type_submission, + data_origin_type_submission, + soa_id, + new_arm_id, + ) + new_type_uid: Optional[str] = None + new_data_origin_uid: Optional[str] = None + if arm_type_submission: + cur.execute( + "SELECT code FROM protocol_terminology WHERE codelist_code='C174222' AND (cdisc_submission_value=? OR LOWER(TRIM(cdisc_submission_value))=LOWER(TRIM(?)))", + (arm_type_submission, arm_type_submission), + ) + r = cur.fetchone() + resolved_code = r[0] if r else None + if resolved_code is None: + logger.warning( + "ui_add_arm: unknown arm type submission '%s' for soa_id=%s", + arm_type_submission, + soa_id, + ) + conn.close() + return HTMLResponse( + f"", + status_code=400, + ) + # Create Code_N + cur.execute( + "SELECT code_uid FROM code WHERE soa_id=? AND code_uid LIKE 'Code_%'", + (soa_id,), + ) + existing = [x[0] for x in cur.fetchall() if x[0]] + n = 1 + if existing: + try: + n = max(int(x.split("_")[1]) for x in existing) + 1 + except Exception: + n = len(existing) + 1 + new_type_uid = f"Code_{n}" + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + new_type_uid, + "protocol_terminology", + "C174222", + resolved_code, + ), + ) + logger.info( + "ui_add_arm: created code junction %s -> table=%s list=%s code=%s", + new_type_uid, + "protocol_terminology", + "C174222", + resolved_code, + ) + if data_origin_type_submission: + cur.execute( + "SELECT code FROM ddf_terminology WHERE codelist_code='C188727' AND (cdisc_submission_value=? OR LOWER(TRIM(cdisc_submission_value))=LOWER(TRIM(?)))", + (data_origin_type_submission, data_origin_type_submission), + ) + r2 = cur.fetchone() + resolved_ddf_code = r2[0] if r2 else None + if resolved_ddf_code is None: + logger.warning( + "ui_add_arm: unknown data origin type submission '%s' for soa_id=%s", + data_origin_type_submission, + soa_id, + ) + conn.close() + return HTMLResponse( + f"", + status_code=400, + ) + # Create Code_N (continue numbering) + cur.execute( + "SELECT code_uid FROM code WHERE soa_id=? AND code_uid LIKE 'Code_%'", + (soa_id,), + ) + existing = [x[0] for x in cur.fetchall() if x[0]] + n = 1 + if existing: + try: + n = max(int(x.split("_")[1]) for x in existing) + 1 + except Exception: + n = len(existing) + 1 + new_data_origin_uid = f"Code_{n}" + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + new_data_origin_uid, + "ddf_terminology", + "C188727", + resolved_ddf_code, + ), + ) + logger.info( + "ui_add_arm: created code junction %s -> table=%s list=%s code=%s", + new_data_origin_uid, + "ddf_terminology", + "C188727", + resolved_ddf_code, + ) + # Update arm row with new code_uids + if new_type_uid or new_data_origin_uid: + cur.execute( + "UPDATE arm SET type=COALESCE(?, type), data_origin_type=COALESCE(?, data_origin_type) WHERE id=? AND soa_id=?", + (new_type_uid, new_data_origin_uid, new_arm_id, soa_id), + ) + logger.info( + "ui_add_arm: updated arm id=%s set type=%s data_origin_type=%s", + new_arm_id, + new_type_uid, + new_data_origin_uid, + ) + conn.commit() + # routers.arms.create_arm already records a create audit; avoid duplicating here + conn.close() return HTMLResponse(f"") @@ -3534,6 +3695,13 @@ async def ui_update_arm( except Exception: arm_type_submission = "" data_origin_type_submission = "" + logger.info( + "ui_update_arm: arm_id=%s soa_id=%s incoming arm-type='%s' data-origin-type='%s'", + arm_id, + soa_id, + arm_type_submission, + data_origin_type_submission, + ) # Fetch current arm (including existing type code_uid if any) conn = _connect() @@ -3584,6 +3752,12 @@ async def ui_update_arm( r = cur.fetchone() resolved_code = r[0] if r else None if resolved_code is None: + logger.warning( + "ui_update_arm: unknown arm type submission '%s' for soa_id=%s arm_id=%s", + arm_type_submission, + soa_id, + arm_id, + ) conn.close() return HTMLResponse( f"", @@ -3599,6 +3773,13 @@ async def ui_update_arm( "UPDATE code SET code=?, codelist_code='C174222', codelist_table='protocol_terminology' WHERE soa_id=? AND code_uid=?", (resolved_code, soa_id, current_code_uid), ) + logger.info( + "ui_update_arm: updated junction code_uid=%s -> table=%s list=%s code=%s", + current_code_uid, + "protocol_terminology", + "C174222", + resolved_code, + ) else: # Create new Code_N within this SoA cur.execute( @@ -3623,6 +3804,13 @@ async def ui_update_arm( resolved_code, ), ) + logger.info( + "ui_update_arm: created junction code_uid=%s -> table=%s list=%s code=%s", + new_code_uid, + "protocol_terminology", + "C174222", + resolved_code, + ) # Resolve Data Origin Type submission value to DDF terminology code (C188727) resolved_ddf_code: Optional[str] = None @@ -3635,6 +3823,12 @@ async def ui_update_arm( r2 = cur.fetchone() resolved_ddf_code = r2[0] if r2 else None if resolved_ddf_code is None: + logger.warning( + "ui_update_arm: unknown data origin type submission '%s' for soa_id=%s arm_id=%s", + data_origin_type_submission, + soa_id, + arm_id, + ) conn.close() return HTMLResponse( f"", @@ -3647,6 +3841,13 @@ async def ui_update_arm( (resolved_ddf_code, soa_id, current_data_origin_uid), ) new_data_origin_uid = current_data_origin_uid + logger.info( + "ui_update_arm: updated junction code_uid=%s -> table=%s list=%s code=%s", + current_data_origin_uid, + "ddf_terminology", + "C188727", + resolved_ddf_code, + ) else: # Create new Code_N, ensuring unique across this SoA cur.execute( @@ -3671,6 +3872,13 @@ async def ui_update_arm( resolved_ddf_code, ), ) + logger.info( + "ui_update_arm: created junction code_uid=%s -> table=%s list=%s code=%s", + new_data_origin_uid, + "ddf_terminology", + "C188727", + resolved_ddf_code, + ) # Apply arm field updates (including setting type to code_uid if resolved) new_name = name if name is not None else row[1] @@ -3688,6 +3896,14 @@ async def ui_update_arm( soa_id, ), ) + logger.info( + "ui_update_arm: applied UPDATE arm id=%s set name='%s' label='%s' type=%s data_origin_type=%s", + arm_id, + new_name, + new_label, + new_code_uid, + new_data_origin_uid, + ) conn.commit() # Capture post-update code values post_arm_type_code_value: Optional[str] = None @@ -3736,6 +3952,11 @@ async def ui_update_arm( ) except Exception: pass + else: + logger.info( + "ui_update_arm: no-op update detected for arm_id=%s (no field or code changes)", + arm_id, + ) conn.close() return HTMLResponse(f"") diff --git a/src/soa_builder/web/templates/edit.html b/src/soa_builder/web/templates/edit.html index e408716..31ec5eb 100644 --- a/src/soa_builder/web/templates/edit.html +++ b/src/soa_builder/web/templates/edit.html @@ -244,6 +244,24 @@

Editing SoA {{ soa_id }}

+ {% if protocol_terminology_C174222 %} + + {% endif %} + {% if ddf_terminology_C188727 %} + + {% endif %} From 211eaa720ddc5faaf5af8ed376e2984289444ff7 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Wed, 3 Dec 2025 12:50:47 -0500 Subject: [PATCH 12/20] Update src/soa_builder/web/initialize_database.py code_uid column should be 'unique within an SOA' so added a uniqueness constraint on (soa_id, code_uid) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/soa_builder/web/initialize_database.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/soa_builder/web/initialize_database.py b/src/soa_builder/web/initialize_database.py index d4c0366..8ccd3ac 100644 --- a/src/soa_builder/web/initialize_database.py +++ b/src/soa_builder/web/initialize_database.py @@ -155,7 +155,8 @@ def _init_db(): code_uid TEXT, -- immutable Code_N identifier unique within an SOA codelist_table TEXT, codelist_code TEXT NOT NULL, - code TEXT NOT NULL + code TEXT NOT NULL, + UNIQUE(soa_id, code_uid) )""" ) From 65309acbb63aa1404f23a228d14acad03164987b Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Wed, 3 Dec 2025 12:51:08 -0500 Subject: [PATCH 13/20] Update src/soa_builder/web/app.py removed logger info statement Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/soa_builder/web/app.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index 0d87a4d..431a2d3 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -3073,8 +3073,6 @@ def ui_edit(request: Request, soa_id: int): {"cdisc_submission_value": r[0] or ""} for r in cur_ddft.fetchall() ] conn_ddft.close() - # logger.info("DDF Terminology values: ", ddf_terminology_C188727) - # Build mapping code_uid -> submission value (Arm dataOriginType C188727) conn_ddf_map = _connect() cur_ddf_map = conn_ddf_map.cursor() From 7f09a34361d77b60261d35fce8e9397ef235db17 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Wed, 3 Dec 2025 12:51:27 -0500 Subject: [PATCH 14/20] Update src/soa_builder/web/app.py removed logger INFO statement Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/soa_builder/web/app.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index 431a2d3..74d113f 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -3088,7 +3088,6 @@ def ui_edit(request: Request, soa_id: int): ddf_opt.get("cdisc_submission_value") or "" for ddf_opt in ddf_terminology_C188727 } - # logger.info("DDF Data Origin Type submission values", ddf_submission_values) base_arms = _fetch_arms_for_edit(soa_id) arms_enriched = [] From 12dcc0e02bc0455cfca1d9d612c4b2909c0459e3 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Wed, 3 Dec 2025 12:51:45 -0500 Subject: [PATCH 15/20] Update src/soa_builder/web/app.py corrected spelling Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/soa_builder/web/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index 74d113f..2721b6a 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -5397,7 +5397,7 @@ def get_protocol_terminology( limit: int = 50, offset: int = 0, ): - """Return latest rotocol Terminology loaded into SQLite database.""" + """Return latest Protocol Terminology loaded into SQLite database.""" limit = max(1, min(limit, 200)) offset = max(0, offset) conn = _connect() From b7909b376927e6085ee0e4786397ed0c9249f6c5 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Wed, 3 Dec 2025 13:20:10 -0500 Subject: [PATCH 16/20] Added helper _get_next_code_uid and refactor --- normalize_soa.py | 2 - src/soa_builder/web/app.py | 89 ++++++++++++++++++------------- src/soa_builder/web/db.py | 10 +++- tests/test_code_uid_generation.py | 80 +++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 39 deletions(-) create mode 100644 tests/test_code_uid_generation.py diff --git a/normalize_soa.py b/normalize_soa.py index e1ad818..11f7431 100644 --- a/normalize_soa.py +++ b/normalize_soa.py @@ -37,7 +37,6 @@ import csv import os import re -import sys from dataclasses import asdict, dataclass from typing import Any, Dict, List, Optional @@ -259,7 +258,6 @@ def build_visit_activities( vas: List[VisitActivity] = [] next_id = 1 for a_idx, r in enumerate(rows, start=1): - activity_name = r[0] for v_idx, visit in enumerate(visits, start=1): if v_idx >= len(r): continue diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index 2721b6a..cad5ce2 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -138,6 +138,24 @@ def _get_concepts_override(): return os.environ.get("CDISC_CONCEPTS_JSON") +def _get_next_code_uid(cur, soa_id: int) -> str: + """Compute next unique Code_N for the given SOA. + Assumes `cur` is a sqlite cursor within an open transaction. + """ + cur.execute( + "SELECT code_uid FROM code WHERE soa_id=? AND code_uid LIKE 'Code_%'", + (soa_id,), + ) + existing = [x[0] for x in cur.fetchall() if x[0]] + n = 1 + if existing: + try: + n = max(int(x.split("_")[1]) for x in existing) + 1 + except Exception: + n = len(existing) + 1 + return f"Code_{n}" + + # Audit functions def _record_element_audit( soa_id: int, @@ -2250,23 +2268,23 @@ def export_xlsx(soa_id: int, left: Optional[int] = None, right: Optional[int] = conn_info = _connect() cur_info = conn_info.cursor() cur_info.execute( - "SELECT name, created_at, study_id, study_label, study_description FROM soa WHERE id=?", + "SELECT name, study_id, study_label, study_description, created_at FROM soa WHERE id=?", (soa_id,), ) - info_row = cur_info.fetchone() - conn_info.close() - if info_row: - soa_name_val, created_at_val, study_id_val, study_label_val, study_desc_val = ( - info_row + row_info = cur_info.fetchone() + if row_info: + soa_name_val, study_id_val, study_label_val, study_desc_val, created_at_val = ( + row_info ) else: - soa_name_val, created_at_val, study_id_val, study_label_val, study_desc_val = ( + soa_name_val, study_id_val, study_label_val, study_desc_val, created_at_val = ( f"SOA {soa_id}", None, None, None, None, ) + conn_info.close() freezes = _list_freezes(soa_id) last_freeze_label = freezes[0]["version_label"] if freezes else None last_freeze_time = freezes[0]["created_at"] if freezes else None @@ -3029,16 +3047,37 @@ def ui_edit(request: Request, soa_id: int): conn_meta = _connect() cur_meta = conn_meta.cursor() cur_meta.execute( - "SELECT study_id, study_label, study_description FROM soa WHERE id=?", (soa_id,) + "SELECT study_id, study_label, study_description, name, created_at FROM soa WHERE id=?", + (soa_id,), ) meta_row = cur_meta.fetchone() conn_meta.close() + if meta_row: + study_id_val, study_label_val, study_desc_val, soa_name_val, created_at_val = ( + meta_row + ) + else: + study_id_val, study_label_val, study_desc_val, soa_name_val, created_at_val = ( + None, + None, + None, + f"SOA {soa_id}", + None, + ) study_meta = { - "study_id": meta_row[0] if meta_row else None, - "study_label": meta_row[1] if meta_row else None, - "study_description": meta_row[2] if meta_row else None, + "study_id": study_id_val, + "study_label": study_label_val, + "study_description": study_desc_val, + "soa_name": soa_name_val, + "created_at": created_at_val, } - # Protocol terminology options for Arm Type (C174222) + # Compute next Code_N using a fresh cursor + conn_codes = _connect() + cur_codes = conn_codes.cursor() + # Precompute next Code_N if needed for UI defaults (currently not displayed) + _ = _get_next_code_uid(cur_codes, soa_id) + conn_codes.close() + # Load Protocol Terminology (C174222) options conn_pt = _connect() cur_pt = conn_pt.cursor() cur_pt.execute( @@ -3779,18 +3818,7 @@ async def ui_update_arm( ) else: # Create new Code_N within this SoA - cur.execute( - "SELECT code_uid FROM code WHERE soa_id=? AND code_uid LIKE 'Code_%'", - (soa_id,), - ) - existing = [x[0] for x in cur.fetchall() if x[0]] - n = 1 - if existing: - try: - n = max(int(x.split("_")[1]) for x in existing) + 1 - except Exception: - n = len(existing) + 1 - new_code_uid = f"Code_{n}" + new_code_uid = _get_next_code_uid(cur, soa_id) cur.execute( "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", ( @@ -3847,18 +3875,7 @@ async def ui_update_arm( ) else: # Create new Code_N, ensuring unique across this SoA - cur.execute( - "SELECT code_uid FROM code WHERE soa_id=? AND code_uid LIKE 'Code_%'", - (soa_id,), - ) - existing = [x[0] for x in cur.fetchall() if x[0]] - n = 1 - if existing: - try: - n = max(int(x.split("_")[1]) for x in existing) + 1 - except Exception: - n = len(existing) + 1 - new_data_origin_uid = f"Code_{n}" + new_data_origin_uid = _get_next_code_uid(cur, soa_id) cur.execute( "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", ( diff --git a/src/soa_builder/web/db.py b/src/soa_builder/web/db.py index 2b3e336..681a2b5 100644 --- a/src/soa_builder/web/db.py +++ b/src/soa_builder/web/db.py @@ -8,4 +8,12 @@ def _connect(): - return sqlite3.connect(DB_PATH) + conn = sqlite3.connect(DB_PATH, timeout=5.0, check_same_thread=False) + try: + # Improve concurrency and reduce lock errors + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + conn.execute("PRAGMA busy_timeout=3000") + except Exception: + pass + return conn diff --git a/tests/test_code_uid_generation.py b/tests/test_code_uid_generation.py new file mode 100644 index 0000000..6fda689 --- /dev/null +++ b/tests/test_code_uid_generation.py @@ -0,0 +1,80 @@ +from soa_builder.web.db import _connect +from soa_builder.web.initialize_database import _init_db +from soa_builder.web.app import _get_next_code_uid + + +def setup_module(module): + # Ensure test DB is initialized + _init_db() + + +def test_get_next_code_uid_empty_soA(): + conn = _connect() + cur = conn.cursor() + # create a dummy SOA + cur.execute( + "INSERT INTO soa (name, created_at) VALUES (?, datetime('now'))", ("TestStudy",) + ) + soa_id = cur.lastrowid + conn.commit() + # No existing codes -> should return Code_1 + code_uid = _get_next_code_uid(cur, soa_id) + assert code_uid == "Code_1" + conn.close() + + +def test_get_next_code_uid_mixed_existing(): + conn = _connect() + cur = conn.cursor() + # create a new SOA + cur.execute( + "INSERT INTO soa (name, created_at) VALUES (?, datetime('now'))", + ("TestStudy2",), + ) + soa_id = cur.lastrowid + conn.commit() + # Insert mixed existing code_uids + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + (soa_id, "Code_1", "protocol_terminology", "C174222", "X"), + ) + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + (soa_id, "Code_3", "ddf_terminology", "C188727", "Y"), + ) + # Malformed tail should be ignored in max() and trigger fallback only if parsing fails for all + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + (soa_id, "Code_X", "protocol_terminology", "C174222", "Z"), + ) + conn.commit() + # Expect next to be max numeric + 1 -> Code_4 + next_uid = _get_next_code_uid(cur, soa_id) + assert next_uid == "Code_4" + conn.close() + + +def test_get_next_code_uid_all_invalid_tails(): + conn = _connect() + cur = conn.cursor() + # create a new SOA + cur.execute( + "INSERT INTO soa (name, created_at) VALUES (?, datetime('now'))", + ("TestStudy3",), + ) + soa_id = cur.lastrowid + conn.commit() + # Insert only invalid tails that cannot be parsed as integers + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + (soa_id, "Code_A", "protocol_terminology", "C174222", "X"), + ) + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + (soa_id, "Code_B", "ddf_terminology", "C188727", "Y"), + ) + conn.commit() + # Fallback should use len(existing)+1 -> 3 + next_uid = _get_next_code_uid(cur, soa_id) + assert next_uid == "Code_3" + conn.close() From cb79e60693ad456a8ba14b1a4946a66b7dc1a072 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Wed, 3 Dec 2025 14:17:17 -0500 Subject: [PATCH 17/20] Moved _get_next_code_uid to utils.py and removed duplicate logic for Code_N --- src/soa_builder/web/app.py | 45 +++--------------------------------- src/soa_builder/web/utils.py | 20 ++++++++++++++++ 2 files changed, 23 insertions(+), 42 deletions(-) create mode 100644 src/soa_builder/web/utils.py diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index cad5ce2..97b78ab 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -67,6 +67,7 @@ from .routers.arms import create_arm # re-export for backward compatibility from .routers.arms import delete_arm from .schemas import ArmCreate, SOACreate, SOAMetadataUpdate +from .utils import get_next_code_uid as _get_next_code_uid load_dotenv() # must come BEFORE reading env-based configuration so values are populated DB_PATH = os.environ.get("SOA_BUILDER_DB", "soa_builder_web.db") @@ -138,24 +139,6 @@ def _get_concepts_override(): return os.environ.get("CDISC_CONCEPTS_JSON") -def _get_next_code_uid(cur, soa_id: int) -> str: - """Compute next unique Code_N for the given SOA. - Assumes `cur` is a sqlite cursor within an open transaction. - """ - cur.execute( - "SELECT code_uid FROM code WHERE soa_id=? AND code_uid LIKE 'Code_%'", - (soa_id,), - ) - existing = [x[0] for x in cur.fetchall() if x[0]] - n = 1 - if existing: - try: - n = max(int(x.split("_")[1]) for x in existing) + 1 - except Exception: - n = len(existing) + 1 - return f"Code_{n}" - - # Audit functions def _record_element_audit( soa_id: int, @@ -3614,18 +3597,7 @@ async def ui_add_arm( status_code=400, ) # Create Code_N - cur.execute( - "SELECT code_uid FROM code WHERE soa_id=? AND code_uid LIKE 'Code_%'", - (soa_id,), - ) - existing = [x[0] for x in cur.fetchall() if x[0]] - n = 1 - if existing: - try: - n = max(int(x.split("_")[1]) for x in existing) + 1 - except Exception: - n = len(existing) + 1 - new_type_uid = f"Code_{n}" + new_type_uid = _get_next_code_uid(cur, soa_id) cur.execute( "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", ( @@ -3662,18 +3634,7 @@ async def ui_add_arm( status_code=400, ) # Create Code_N (continue numbering) - cur.execute( - "SELECT code_uid FROM code WHERE soa_id=? AND code_uid LIKE 'Code_%'", - (soa_id,), - ) - existing = [x[0] for x in cur.fetchall() if x[0]] - n = 1 - if existing: - try: - n = max(int(x.split("_")[1]) for x in existing) + 1 - except Exception: - n = len(existing) + 1 - new_data_origin_uid = f"Code_{n}" + new_data_origin_uid = _get_next_code_uid(cur, soa_id) cur.execute( "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", ( diff --git a/src/soa_builder/web/utils.py b/src/soa_builder/web/utils.py new file mode 100644 index 0000000..87e37f7 --- /dev/null +++ b/src/soa_builder/web/utils.py @@ -0,0 +1,20 @@ +from typing import Any + + +def get_next_code_uid(cur: Any, soa_id: int) -> str: + """Compute next unique Code_N for the given SOA. + + Assumes `cur` is a sqlite cursor within an open transaction. + """ + cur.execute( + "SELECT code_uid FROM code WHERE soa_id=? AND code_uid LIKE 'Code_%'", + (soa_id,), + ) + existing = [x[0] for x in cur.fetchall() if x[0]] + n = 1 + if existing: + try: + n = max(int(x.split("_")[1]) for x in existing) + 1 + except Exception: + n = len(existing) + 1 + return f"Code_{n}" From dd2aceb8c1c38092054c3135e8551453b548e2ce Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Wed, 3 Dec 2025 14:39:47 -0500 Subject: [PATCH 18/20] Removed cross-site and scripting vulnerabilities --- src/soa_builder/web/app.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index 97b78ab..b76864a 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -27,6 +27,7 @@ from contextlib import asynccontextmanager from datetime import datetime, timezone from typing import Any, List, Optional +import html import pandas as pd import requests @@ -3553,7 +3554,7 @@ async def ui_add_arm( new_arm_id = None if not new_arm_id: return HTMLResponse( - f"", + f"", status_code=500, ) # Read optional type fields with hyphenated names @@ -3593,7 +3594,7 @@ async def ui_add_arm( ) conn.close() return HTMLResponse( - f"", + f"", status_code=400, ) # Create Code_N @@ -3629,8 +3630,14 @@ async def ui_add_arm( soa_id, ) conn.close() + # Properly escape the value for safety in HTML/JS context + escaped_selection = ( + html.escape(data_origin_type_submission, quote=True) + .replace("\\", "\\\\") + .replace("'", "\\'") + ) return HTMLResponse( - f"", + f"", status_code=400, ) # Create Code_N (continue numbering) @@ -3757,7 +3764,7 @@ async def ui_update_arm( ) conn.close() return HTMLResponse( - f"", + f"", status_code=400, ) @@ -3817,7 +3824,7 @@ async def ui_update_arm( ) conn.close() return HTMLResponse( - f"", + f"", status_code=400, ) # Maintain/Upsert immutable Code_N for DDF mapping From 7967c112f0fe788c21a67f2cbd52d9fa799358ed Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Wed, 3 Dec 2025 14:51:35 -0500 Subject: [PATCH 19/20] Update tests/test_code_uid_generation.py Consistent capitalization Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/test_code_uid_generation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_code_uid_generation.py b/tests/test_code_uid_generation.py index 6fda689..bfe7437 100644 --- a/tests/test_code_uid_generation.py +++ b/tests/test_code_uid_generation.py @@ -8,7 +8,7 @@ def setup_module(module): _init_db() -def test_get_next_code_uid_empty_soA(): +def test_get_next_code_uid_empty_soa(): conn = _connect() cur = conn.cursor() # create a dummy SOA From 76d8ee2507778a4056b249254ee4cc8dbbaa1b86 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Wed, 3 Dec 2025 14:54:23 -0500 Subject: [PATCH 20/20] Removed unused import --- src/soa_builder/web/app.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index b76864a..9c473a7 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -27,7 +27,6 @@ from contextlib import asynccontextmanager from datetime import datetime, timezone from typing import Any, List, Optional -import html import pandas as pd import requests @@ -3115,12 +3114,12 @@ def ui_edit(request: Request, soa_id: int): base_arms = _fetch_arms_for_edit(soa_id) arms_enriched = [] for a in base_arms: - type = a.get("type") - type_display = code_to_submission.get(type) + arm_type = a.get("type") + type_display = code_to_submission.get(arm_type) data_origin_type = a.get("data_origin_type") data_origin_type_display = ddf_code_to_submission.get(data_origin_type) - if type_display is None and type: - type_display = type if type in submission_values else None + if type_display is None and arm_type: + type_display = arm_type if arm_type in submission_values else None if data_origin_type_display is None and data_origin_type: data_origin_type_display = ( data_origin_type if data_origin_type in ddf_submission_values else None @@ -3594,7 +3593,7 @@ async def ui_add_arm( ) conn.close() return HTMLResponse( - f"", + f"", status_code=400, ) # Create Code_N @@ -3631,13 +3630,9 @@ async def ui_add_arm( ) conn.close() # Properly escape the value for safety in HTML/JS context - escaped_selection = ( - html.escape(data_origin_type_submission, quote=True) - .replace("\\", "\\\\") - .replace("'", "\\'") - ) + escaped_selection = json.dumps(data_origin_type_submission) return HTMLResponse( - f"", + f"", status_code=400, ) # Create Code_N (continue numbering)