Skip to content
Merged
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,648 changes: 1,314 additions & 1,334 deletions src/soa_builder/web/app.py

Large diffs are not rendered by default.

348 changes: 308 additions & 40 deletions src/soa_builder/web/routers/arms.py

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/soa_builder/web/routers/epochs.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ def update_epoch(soa_id: int, epoch_id: int, payload: EpochUpdate):
)
row = cur.fetchone()
if not row:
conn.close()
raise HTTPException(404, f"Epoch id={int(epoch_id)} not found")

before = {
Expand Down Expand Up @@ -605,6 +606,7 @@ def update_epoch_metadata(soa_id: int, epoch_id: int, payload: EpochUpdate):
return {**after, "updated_fields": updated_fields}


# Deprecated (no longer needed)
@router.post("/soa/{soa_id}/epochs/reorder", response_class=JSONResponse)
def reorder_epochs_api(
soa_id: int,
Expand Down
102 changes: 102 additions & 0 deletions src/soa_builder/web/templates/arms.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
{% extends 'base.html' %}
{% block content %}
<h2>Arms for SoA {{ soa_id }}</h2>

<div style="margin-bottom:12px">
<form method="post" action="/ui/soa/{{ soa_id }}/arms/create" style="display:flex;flex-wrap:wrap;gap:6px;align-items:flex-end;">
<div style="display:flex;flex-direction:column;gap:2px;">
<label><strong>Name *</strong></label>
<input name="name" placeholder="Arm Name" required />
</div>
<div style="display:flex;flex-direction:column;gap:2px;">
<label><strong>Label</strong></label>
<input name="label" placeholder="Arm Label (optional)" />
</div>
<div style="display:flex;flex-direction:column;gap:2px;">
<label><strong>Description</strong></label>
<input name="description" placeholder="Arm Description (optional)" />
</div>
<div style="display:flex;flex-direction:column;gap:2px;">
<label><strong>Arm Type</strong></label>
<select name="type">
<option value="">-- Select Arm Type --</option>
{% for code, sv in (arm_type_options or {}).items() %}
<option value="{{ code }}">{{ sv }}</option>
{% endfor %}
</select>
</div>
<div style="display:flex;flex-direction:column;gap:2px;">
<label><strong>Arm Data Origin Type</strong></label>
<select name="data_origin_type">
<option value="">-- Select Arm Data Origin Type --</option>
{% for code, sv in (arm_data_origin_type_options or {}).items() %}
<option value="{{ code }}">{{ sv }}</option>
{% endfor %}
</select>
</div>
<button style="background:#1976d2;color:#fff;border:none;padding:4px 10px;border-radius:4px;cursor:pointer;">Create Arm</button>
</form>
</div>

<table id="arms-table" style="width:100%;font-size:0.85em;border-collapse:collapse;">
<tr style="background:#eee;">
<th style="text-align:left;padding:4px;">UID</th>
<th style="text-align:left;padding:4px;">Name</th>
<th style="text-align:left;padding:4px;">Label</th>
<th style="text-align:left;padding:4px;">Description</th>
<th style="text-align:left;padding:4px;">Type</th>
<th style="text-align:left;padding:4px;">Data Origin Type</th>
<th style="text-align:left;padding:4px;">Save</th>
<th style="text-align:left;padding:4px;">Delete</th>
</tr>
{% for a in arms %}
<tr>
<form method="post" action="/ui/soa/{{ soa_id }}/arms/{{ a.id }}/update">
<td style="padding:4px;white-space:nowrap;"><strong>{{ a.arm_uid }}</strong></td>
<td style="padding:4px;"><input name="name" value="{{ a.name }}" required /></td>
<td style="padding:4px;"><input name="label" value="{{ a.label or '' }}" placeholder="Label" /></td>
<td style="padding:4px;"><input name="description" value="{{ a.description or '' }}" placeholder="Description" /></td>
<td style="padding:4px;">
<select name="type">
<option value="">-- Select Arm Type --</option>
{% for code, sv in (arm_type_options or {}).items() %}
<option value="{{ code }}"
{% if (a.type_concept_id | string or '') == (code | string) %}selected{% endif %}>
{{ sv }}
</option>
{% endfor %}
</select>
</td>
<td style="padding:4px;">
<select name="data_origin_type">
<option value="">-- Select Arm Data Origin Type --</option>
{% for code, sv in (arm_data_origin_type_options or {}).items() %}
<option value="{{ code }}"
{% if (a.data_origin_type_concept_id | string or '') == (code | string) %}selected{% endif %}>
{{ sv }}
</option>
{% endfor %}
</select>
</td>
<td style="padding:4px;">
<button style="background:#607d8b;color:#fff;border:none;padding:2px 6px;border-radius:3px;cursor:pointer;">Save</button>
</td>
</form>
<td style="padding:4px;">
<form method="post" action="/ui/soa/{{ soa_id }}/arms/{{ a.id }}/delete">
<button style="background:#c62828;color:#fff;border:none;padding:2px 6px;border-radius:3px;cursor:pointer;">
Delete
</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="12" style="padding:6px;color:#777;">No arms yet.</td></tr>
</tr>
</table>
{% endfor %}



{% endblock %}
1 change: 1 addition & 0 deletions src/soa_builder/web/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<a href="/ui/soa/{{ soa_id }}/timings">Study Timing</a>
<a href="/ui/soa/{{ soa_id }}/instances">Scheduled Activity Instances</a>
<a href="/ui/soa/{{ soa_id }}/epochs">Epochs</a>
<a href="/ui/soa/{{ soa_id }}/arms">Arms</a>
<a href="/ui/soa/{{ soa_id }}/visits">Encounters</a>
{% endif %}
<a href="/ui/concept_categories">Biomedical Concept Categories</a>
Expand Down
72 changes: 1 addition & 71 deletions src/soa_builder/web/templates/edit.html
Original file line number Diff line number Diff line change
Expand Up @@ -166,77 +166,7 @@ <h2>Editing SoA {{ soa_id }}</h2>
<button>Add Element</button>
</form>
</details>
<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 %}
<li class="drag-item" draggable="true" data-id="{{ arm.id }}" title="{{ arm.description or '' }}">
<span class="ord">{{ arm.order_index }}</span>. <strong>{% if arm.label %}{{ arm.label }}{% else %}{{ arm.name }}{% endif %}</strong>
<span class="arm-actions">
<form method="post" action="/ui/soa/{{ soa_id }}/update_arm" style="display:inline;margin-left:6px;font-size:0.6em;">
<input type="hidden" name="arm_id" value="{{ arm.id }}" />
<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="">-- Select Epoch Type (C174222) --</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="">-- Select Arm DataOriginType (C18872) --</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>
<form hx-post="/ui/soa/{{ soa_id }}/delete_arm" hx-confirm="Delete arm '{{ arm.name }}'?" style="display:inline">
<input type="hidden" name="arm_id" value="{{ arm.id }}" />
<button style="background:#c62828;color:#fff;border:none;padding:2px 6px;border-radius:3px;cursor:pointer;">
Delete
</button>
</form>
</span>
</li>
{% endfor %}
</ul>
<form method="post" action="/ui/soa/{{ soa_id }}/add_arm" style="margin-top:6px;display:flex;flex-wrap:wrap;gap:4px;align-items:center;">
<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>

<details id="study-cells-section" class="collapsible">
<summary>Study Cells ({{ study_cells|length }})</summary>
<form method="post" action="/ui/soa/{{ soa_id }}/add_study_cell" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:6px;align-items:start;margin-top:6px;">
Expand Down
145 changes: 145 additions & 0 deletions src/soa_builder/web/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,44 @@ def load_epoch_type_options(force: bool = False) -> list[str]:
return []


# Function for creating {code: submission_value} for Arm type selector
def load_arm_type_map() -> Dict[str, str]:
"""Fetch Arm Type term mapping from the protocol_terminology database table"""
conn = _connect()
cur = conn.cursor()
cur.execute(
"""
SELECT code,cdisc_submission_value FROM protocol_terminology
WHERE codelist_code='C174222'
ORDER BY cdisc_submission_value
"""
)
rows = cur.fetchall()
conn.close()
return {
str(code): str(sv) for (code, sv) in rows if code is not None and sv is not None
}


# Function for creating {code: submission_value} for Arm dataOriginType selector
def load_arm_data_origin_type_map() -> Dict[str, str]:
"""Fetch arm data origin type from the ddf_terminology database table"""
conn = _connect()
cur = conn.cursor()
cur.execute(
"""
SELECT code,cdisc_submission_value FROM ddf_terminology
WHERE codelist_code='C188727'
ORDER BY cdisc_submission_value
"""
)
rows = cur.fetchall()
conn.close()
return {
str(code): str(sv) for (code, sv) in rows if code is not None and sv is not None
}


def load_epoch_type_map(force: bool = False) -> Dict[str, str]:
"""Fetch Epoch Type term mapping from CDISC Library API for C99079.

Expand Down Expand Up @@ -717,6 +755,113 @@ def _extract_terms(data: Any) -> List[dict]:
return None


# Generic function to return submission value for provided codelist_code and code
def get_submission_value_for_code(soa_id: int, codelist_code: str, code_uid: str):
"""Resolve the environmental setting submission value via CDISC Library."""
if not code_uid:
return None

conn = _connect()
cur = conn.cursor()
cur.execute(
"SELECT code FROM code WHERE soa_id=? AND code_uid=?",
(soa_id, code_uid),
)
row = cur.fetchone()
conn.close()
if not row:
return None
target_code = str(row[0]).strip()

package_slug = get_latest_sdtm_ct_href()
if not package_slug:
return None

url = (
f"https://library.cdisc.org/api/mdr/ct/packages/"
f"{package_slug}/codelists/{codelist_code}"
)

headers: dict[str, str] = {"Accept": "application/json"}
subscription_key = os.environ.get("CDISC_SUBSCRIPTION_KEY")
api_key = os.environ.get("CDISC_API_KEY") or subscription_key
if subscription_key:
headers["Ocp-Apim-Subscription-Key"] = subscription_key
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
headers["api-key"] = api_key

def _match_term(term: dict[str, Any]) -> str | None:
term_id = next(
(
term.get(field)
for field in (
"conceptId",
"concept_id",
"code",
"termCode",
"term_code",
)
if term.get(field)
),
None,
)
if term_id and str(term_id).lower() == target_code.lower():
submission = term.get("submissionValue") or term.get(
"cdisc_submission_value"
)
if submission:
return str(submission).strip()
return None

def _extract_terms(data: Any) -> List[dict]:
if isinstance(data, list):
return [t for t in data if isinstance(t, dict)]
if isinstance(data, dict):
if isinstance(data.get("terms"), list):
return [t for t in data["terms"] if isinstance(t, dict)]
embedded = data.get("_embedded", {})
if isinstance(embedded, dict) and isinstance(embedded.get("terms"), list):
return [t for t in embedded["terms"] if isinstance(t, dict)]
return []

try:
resp = requests.get(url, headers=headers, timeout=10)
if resp.status_code != 200:
return None
payload = resp.json() or {}
except Exception:
return None

for term in _extract_terms(payload):
submission = _match_term(term)
if submission:
return submission

term_links = payload.get("_links", {}).get("terms") or []
if isinstance(term_links, dict):
term_links = [term_links]

for link in term_links:
href = link.get("href")
if not href:
continue
if href.startswith("/"):
href = f"https://library.cdisc.org{href}"
try:
term_resp = requests.get(href, headers=headers, timeout=10)
if term_resp.status_code != 200:
continue
term_data = term_resp.json() or {}
except Exception:
continue
submission = _match_term(term_data if isinstance(term_data, dict) else {})
if submission:
return submission

return None


# Return environmentalSettings options from CDISC Library API
def load_environmental_setting_options(force: bool = False) -> List[dict[str, str]]:
"""Return [{'submissionValue': ..., 'conceptId': ...}, ...] for env settings."""
Expand Down
Loading
Loading