Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ Thumbs.db

# SQLite / local DBs
*.db
*.db-shm
*.db-wal
*.sqlite

# Environment variables / secrets (add if created)
Expand Down
Binary file modified docs/api_endpoints.xlsx
Binary file not shown.
640 changes: 621 additions & 19 deletions src/soa_builder/web/app.py

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions src/soa_builder/web/initialize_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,5 +146,19 @@ 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,
UNIQUE(soa_id, code_uid)
)"""
)

conn.commit()
conn.close()
2 changes: 1 addition & 1 deletion src/soa_builder/web/static/style.css
Original file line number Diff line number Diff line change
@@ -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; }
Expand Down
9 changes: 7 additions & 2 deletions src/soa_builder/web/templates/concept_categories.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ <h2>Biomedical Concept Categories (<span id="conceptTotal">{{ count }}</span>)</
This list is derived from the CDISC Library API. Each link points to the
category's API resource (which lists biomedical concepts in that category).
</p>
<p>
<a href="/ui/concept_categories?force=1">Refresh Categories (bypass cache)</a>
{% if force %}<small style="color:#555;">Cache refreshed</small>{% endif %}
</p>
<div style="margin:0.5em 0 1em;">
<label for="categorySearch"><strong>Search:</strong></label>
<input id="categorySearch" type="text" placeholder="Filter categories..." style="width:280px;" oninput="filterCategories()" />
Expand All @@ -14,7 +18,7 @@ <h2>Biomedical Concept Categories (<span id="conceptTotal">{{ count }}</span>)</
{% if rows %}
<table border="1" cellspacing="0" cellpadding="4" id="categoriesTable">
<thead>
<tr>
<th style="text-align:left;">Concepts</th>
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The first column header 'Concepts' is ambiguous. Since the column contains a 'View Concepts' link, consider renaming to 'Actions' or 'View' for clarity.

Suggested change
<th style="text-align:left;">Concepts</th>
<th style="text-align:left;">View</th>

Copilot uses AI. Check for mistakes.
<th style="text-align:left;">Name</th>
<th style="text-align:left;">Title</th>
</tr>
Expand All @@ -25,11 +29,12 @@ <h2>Biomedical Concept Categories (<span id="conceptTotal">{{ count }}</span>)</
<td>
{% if r.name %}
<!-- Pass raw name; backend will encode once to avoid double-encoding -->
<a href="/ui/concept_categories/view?name={{ r.name }}">{{ r.name }}</a>
<a href="/ui/concept_categories/view?name={{ r.name }}">View Concepts</a>
{% else %}
<em>n/a</em>
{% endif %}
</td>
<td>{{ r.name }}</td>
<td>{{ r.title }}</td>
</tr>
{% endfor %}
Expand Down
5 changes: 5 additions & 0 deletions src/soa_builder/web/templates/concept_category_detail.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
{% extends 'base.html' %}
{% block content %}
<h2>Biomedical Concepts in Category: {{ category }}</h2>
<p>
<a href="/ui/concept_categories/view?name={{ category }}&force=1">Refresh (bypass cache)</a>
{% if force %}<small style="color:#555;">Cache bypassed</small>{% endif %}
| <a href="/ui/concept_categories">Back to Categories</a>
</p>
<p>Total concepts: <strong>{{ count }}</strong></p>

{% if rows %}
Expand Down
134 changes: 102 additions & 32 deletions src/soa_builder/web/templates/edit.html
Original file line number Diff line number Diff line change
Expand Up @@ -82,34 +82,6 @@ <h2>Editing SoA {{ soa_id }}</h2>
</div>
<div class="flex">
<div>
<details id="epochs-section" open class="collapsible">
<summary>Epochs ({{ epochs|length }}) <span class="hint">(drag to reorder)</span></summary>
<ul id="epochs-list" class="drag-list">
{% for e in epochs %}
<li class="drag-item" draggable="true" data-id="{{ e.id }}" title="{{ e.epoch_description or '' }}">
<span class="ord">{{ e.order_index }}</span>. <strong>E{{ e.epoch_seq }}</strong> {{ e.name }}
{% if e.epoch_label %}<em style="color:#555;font-size:0.7em;">[{{ e.epoch_label }}]</em>{% endif %}
<form hx-post="/ui/soa/{{ soa_id }}/delete_epoch" hx-confirm="Delete epoch '{{ e.name }}'?" style="display:inline">
<input type="hidden" name="epoch_id" value="{{ e.id }}" />
<button type="submit" class="delete-btn">✕</button>
</form>
<form method="post" action="/ui/soa/{{ soa_id }}/update_epoch" style="display:inline;margin-left:6px;font-size:0.6em;">
<input type="hidden" name="epoch_id" value="{{ e.id }}" />
<input name="name" value="{{ e.name }}" placeholder="Name" style="width:90px;" />
<input name="epoch_label" value="{{ e.epoch_label or '' }}" placeholder="Label" style="width:70px;" />
<input name="epoch_description" value="{{ e.epoch_description or '' }}" placeholder="Desc" style="width:120px;" />
<button style="background:#607d8b;color:#fff;border:none;padding:2px 6px;border-radius:3px;cursor:pointer;">Save</button>
</form>
</li>
{% endfor %}
</ul>
<form method="post" action="/ui/soa/{{ soa_id }}/add_epoch" style="margin-top:6px;display:flex;flex-wrap:wrap;gap:4px;align-items:center;">
<input name="name" placeholder="Epoch Name" required style="flex:1;min-width:120px;" />
<input name="epoch_label" placeholder="Label (optional)" style="flex:1;min-width:110px;" />
<input name="epoch_description" placeholder="Description (optional)" style="flex:2;min-width:160px;" />
<button style="background:#1976d2;color:#fff;border:none;padding:4px 10px;border-radius:4px;cursor:pointer;">Add Epoch</button>
</form>
</details>
<details id="visits-section" open class="collapsible">
<summary>Visits ({{ visits|length }}) <span class="hint">(drag to reorder)</span></summary>
<ul id="visits-list" class="drag-list">
Expand Down Expand Up @@ -145,8 +117,6 @@ <h2>Editing SoA {{ soa_id }}</h2>
<button>Add Visit</button>
</form>
</details>
</div>
<div>
<details id="activities-section" open class="collapsible">
<summary>Activities ({{ activities|length }}) <span class="hint">(drag to reorder)</span></summary>
<ul id="activities-list" class="drag-list">
Expand All @@ -164,7 +134,37 @@ <h2>Editing SoA {{ soa_id }}</h2>
<button>Add Activity</button>
</form>
</details>
<details id="elements-section" open class="collapsible" style="margin-top:10px;">
</div>
<div>
<details id="epochs-section" open class="collapsible">
<summary>Epochs ({{ epochs|length }}) <span class="hint">(drag to reorder)</span></summary>
<ul id="epochs-list" class="drag-list">
{% for e in epochs %}
<li class="drag-item" draggable="true" data-id="{{ e.id }}" title="{{ e.epoch_description or '' }}">
<span class="ord">{{ e.order_index }}</span>. <strong>E{{ e.epoch_seq }}</strong> {{ e.name }}
{% if e.epoch_label %}<em style="color:#555;font-size:0.7em;">[{{ e.epoch_label }}]</em>{% endif %}
<form hx-post="/ui/soa/{{ soa_id }}/delete_epoch" hx-confirm="Delete epoch '{{ e.name }}'?" style="display:inline">
<input type="hidden" name="epoch_id" value="{{ e.id }}" />
<button type="submit" class="delete-btn">✕</button>
</form>
<form method="post" action="/ui/soa/{{ soa_id }}/update_epoch" style="display:inline;margin-left:6px;font-size:0.6em;">
<input type="hidden" name="epoch_id" value="{{ e.id }}" />
<input name="name" value="{{ e.name }}" placeholder="Name" style="width:90px;" />
<input name="epoch_label" value="{{ e.epoch_label or '' }}" placeholder="Label" style="width:70px;" />
<input name="epoch_description" value="{{ e.epoch_description or '' }}" placeholder="Desc" style="width:120px;" />
<button style="background:#607d8b;color:#fff;border:none;padding:2px 6px;border-radius:3px;cursor:pointer;">Save</button>
</form>
</li>
{% endfor %}
</ul>
<form method="post" action="/ui/soa/{{ soa_id }}/add_epoch" style="margin-top:6px;display:flex;flex-wrap:wrap;gap:4px;align-items:center;">
<input name="name" placeholder="Epoch Name" required style="flex:1;min-width:120px;" />
<input name="epoch_label" placeholder="Label (optional)" style="flex:1;min-width:110px;" />
<input name="epoch_description" placeholder="Description (optional)" style="flex:2;min-width:160px;" />
<button style="background:#1976d2;color:#fff;border:none;padding:4px 10px;border-radius:4px;cursor:pointer;">Add Epoch</button>
</form>
</details>
<details id="elements-section" open class="collapsible">
<summary>Elements ({{ elements|length }}) <span class="hint">(drag to reorder)</span></summary>
<ul id="elements-list" class="drag-list">
{% for el in elements %}
Expand Down Expand Up @@ -197,7 +197,7 @@ <h2>Editing SoA {{ soa_id }}</h2>
<button style="background:#1976d2;color:#fff;border:none;padding:4px 10px;border-radius:4px;cursor:pointer;">Add Element</button>
</form>
</details>
<details id="arms-section" open class="collapsible" style="margin-top:10px;">
<details id="arms-section" open class="collapsible">
<summary>Arms ({{ arms|length }}) <span class="hint">(drag to reorder)</span></summary>
<ul id="arms-list" class="drag-list">
{% for arm in arms %}
Expand All @@ -213,6 +213,28 @@ <h2>Editing SoA {{ soa_id }}</h2>
<input name="name" value="{{ arm.name }}" placeholder="Name" style="width:90px;" />
<input name="label" value="{{ arm.label or '' }}" placeholder="Label" style="width:70px;" />
<input name="description" value="{{ arm.description or '' }}" placeholder="Desc" style="width:120px;" />
{# Type selection from protocol terminology C174222 #}
{% if protocol_terminology_C174222 %}
<select name="arm-type" style="width:200px;" title="Type">
<option value="">Type</option>
{% for opt in protocol_terminology_C174222 %}
{% set text = (opt.cdisc_submission_value or '') %}
{% set td = (arm.type_display or '') %}
<option value="{{ text }}" {% if td|trim|lower == text|trim|lower and td != '' %}selected{% endif %}>{{ text }}</option>
{% endfor %}
</select>
{% endif %}
{# Type selection from ddf terminology C188727 #}
{% if ddf_terminology_C188727 %}
<select name="data-origin-type" style="width:200px;" title="Data Origin Type">
<option value="">DataOriginType</option>
{% for ddf_opt in ddf_terminology_C188727 %}
{% set text = (ddf_opt.cdisc_submission_value or '') %}
{% set td = (arm.data_origin_type_display or '') %}
<option value="{{ text }}" {% if td|trim|lower == text|trim|lower and td != '' %}selected{% endif %}>{{ text }}</option>
{% endfor %}
</select>
{% endif %}
<button style="background:#607d8b;color:#fff;border:none;padding:2px 6px;border-radius:3px;cursor:pointer;">Save</button>
</form>
</li>
Expand All @@ -222,9 +244,57 @@ <h2>Editing SoA {{ soa_id }}</h2>
<input name="name" placeholder="Arm Name" required style="flex:1;min-width:120px;" />
<input name="label" placeholder="Label" style="flex:1;min-width:90px;" />
<input name="description" placeholder="Description" style="flex:2;min-width:160px;" />
{% if protocol_terminology_C174222 %}
<select name="arm-type" style="width:200px;" title="Type">
<option value="">Type</option>
{% for opt in protocol_terminology_C174222 %}
{% set text = (opt.cdisc_submission_value or '') %}
<option value="{{ text }}">{{ text }}</option>
{% endfor %}
</select>
{% endif %}
{% if ddf_terminology_C188727 %}
<select name="data-origin-type" style="width:200px;" title="Data Origin Type">
<option value="">DataOriginType</option>
{% for ddf_opt in ddf_terminology_C188727 %}
{% set text = (ddf_opt.cdisc_submission_value or '') %}
<option value="{{ text }}">{{ text }}</option>
{% endfor %}
</select>
{% endif %}
<button style="background:#1976d2;color:#fff;border:none;padding:4px 10px;border-radius:4px;cursor:pointer;">Add Arm (auto StudyArm_N)</button>
</form>
</details>

</div>
<div>
<details id="arm-audit-section" class="collapsible">
<summary>Arm Audit (latest {{ arm_audits|length }})</summary>
{% if arm_audits %}
<table style="width:100%;font-size:0.75em;border-collapse:collapse;">
<tr style="background:#eee;">
<th style="text-align:left;padding:4px;">ID</th>
<th style="text-align:left;padding:4px;">Arm</th>
<th style="text-align:left;padding:4px;">Action</th>
<th style="text-align:left;padding:4px;">Performed</th>
<th style="text-align:left;padding:4px;">Before</th>
<th style="text-align:left;padding:4px;">After</th>
</tr>
{% for au in arm_audits %}
<tr>
<td style="padding:4px;">{{ au.id }}</td>
<td style="padding:4px;">{{ au.arm_id }}</td>
<td style="padding:4px;">{{ au.action }}</td>
<td style="padding:4px;">{{ au.performed_at }}</td>
<td style="padding:4px;white-space:pre-wrap;max-width:320px;">{{ au.before_json or '' }}</td>
<td style="padding:4px;white-space:pre-wrap;max-width:320px;">{{ au.after_json or '' }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div style="font-size:0.75em;color:#777;">No arm audit entries yet.</div>
{% endif %}
</details>
</div>
</div>
<hr />
Expand Down
118 changes: 118 additions & 0 deletions tests/test_categories_cache.py
Original file line number Diff line number Diff line change
@@ -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
Loading