Skip to content

Dss selector#115

Merged
pendingintent merged 17 commits intomasterfrom
dss-selector
Feb 20, 2026
Merged

Dss selector#115
pendingintent merged 17 commits intomasterfrom
dss-selector

Conversation

@pendingintent
Copy link
Owner

A number of additions to support selection of data set specializations to associate with Biomedical Concepts

@pendingintent pendingintent added this to the v1.0 milestone Feb 20, 2026
@pendingintent pendingintent self-assigned this Feb 20, 2026
@pendingintent pendingintent added the enhancement New feature or request label Feb 20, 2026
Copilot AI review requested due to automatic review settings February 20, 2026 20:28
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds UI and persistence support for associating SDTM Dataset Specializations (DSS) with Biomedical Concepts on activities, and introduces a full USDM document generator that composes existing per-entity USDM outputs.

Changes:

  • Add an Activities UI page with concept selection and DSS assignment controls (including DSS detail view).
  • Extend activity_concept with DSS fields via migration and wire new UI routes into the FastAPI app.
  • Add src/usdm/generate_usdm.py to export a complete USDM Study document by combining existing generators.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
src/usdm/generate_usdm.py New “full document” USDM generator composing existing entity generators.
src/usdm/generate_encounters.py Adjusts codeSystemVersion parsing to handle URL-like code system strings.
src/soa_builder/web/templates/sdtm_specialization_detail.html Enhances DSS detail rendering with summary/variables tables and back links.
src/soa_builder/web/templates/edit.html UI tweak to widen the Concepts header column.
src/soa_builder/web/templates/dss_cell.html New HTMX partial to assign/clear DSS per concept on an activity.
src/soa_builder/web/templates/concepts_cell.html UI sizing/ellipsis improvements for concepts display and controls.
src/soa_builder/web/templates/base.html Adds an “Activities” link in the sidenav when soa_id is set.
src/soa_builder/web/templates/activities.html New Activities page with CRUD, reorder, concept selection, and DSS assignment UI.
src/soa_builder/web/routers/activities.py Adds Activities UI list/create/delete routes and DSS assignment/detail endpoints.
src/soa_builder/web/migrate_database.py Adds migration to introduce dss_title/dss_href columns.
src/soa_builder/web/initialize_database.py Adds order_index + UNIQUE constraint to study_cell table definition.
src/soa_builder/web/app.py Runs the new migration and mounts the new activities ui_router.

Comment on lines +18 to +28
{% for ac in dss_concepts %}
<div style="display:flex;align-items:center;gap:4px;margin:2px 0;font-size:0.8em;">
<span title="{{ ac.title }}" style="background:#eef;color:#224;padding:1px 4px;border-radius:3px;white-space:nowrap;min-width:70px;text-align:center;display:inline-block;">{{ ac.code }}</span>
{% if ac.dss_title %}
{# Assigned — show DSS name as pill + clear/properties controls #}
{% set ns = namespace(dss_display=ac.dss_title) %}
{% for d in sdtm_specializations %}
{% if d.href.rstrip('/').split('/')|last == ac.dss_title %}
{% set ns.dss_display = d.title %}
{% endif %}
{% endfor %}
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

Within each assigned concept row, the template scans the full sdtm_specializations list to resolve a display title. With many specializations this becomes expensive; consider passing an id→title mapping from the server (or computing one once per template) and doing O(1) lookups in the per-row loop.

Suggested change
{% for ac in dss_concepts %}
<div style="display:flex;align-items:center;gap:4px;margin:2px 0;font-size:0.8em;">
<span title="{{ ac.title }}" style="background:#eef;color:#224;padding:1px 4px;border-radius:3px;white-space:nowrap;min-width:70px;text-align:center;display:inline-block;">{{ ac.code }}</span>
{% if ac.dss_title %}
{# Assigned — show DSS name as pill + clear/properties controls #}
{% set ns = namespace(dss_display=ac.dss_title) %}
{% for d in sdtm_specializations %}
{% if d.href.rstrip('/').split('/')|last == ac.dss_title %}
{% set ns.dss_display = d.title %}
{% endif %}
{% endfor %}
{# Build a mapping from DSS id (last href segment) to title once per template #}
{% set dss_ns = namespace(dss_map={}) %}
{% for d in sdtm_specializations %}
{% set dss_id = d.href.rstrip('/').split('/')|last %}
{% set dss_ns.dss_map = dss_ns.dss_map | combine({dss_id: d.title}) %}
{% endfor %}
{% for ac in dss_concepts %}
<div style="display:flex;align-items:center;gap:4px;margin:2px 0;font-size:0.8em;">
<span title="{{ ac.title }}" style="background:#eef;color:#224;padding:1px 4px;border-radius:3px;white-space:nowrap;min-width:70px;text-align:center;display:inline-block;">{{ ac.code }}</span>
{% if ac.dss_title %}
{# Assigned — show DSS name as pill + clear/properties controls #}
{% set ns = namespace(dss_display=dss_ns.dss_map.get(ac.dss_title, ac.dss_title)) %}

Copilot uses AI. Check for mistakes.
Comment on lines +21 to +29
{% if ac.dss_title %}
{# Assigned — show DSS name as pill + clear/properties controls #}
{% set ns = namespace(dss_display=ac.dss_title) %}
{% for d in sdtm_specializations %}
{% if d.href.rstrip('/').split('/')|last == ac.dss_title %}
{% set ns.dss_display = d.title %}
{% endif %}
{% endfor %}
<span title="{{ ac.dss_title }}" style="background:#eef;color:#224;padding:2px 5px;border-radius:3px;min-width:160px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ ns.dss_display }}</span>
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

dss_title is treated as a datasetSpecializationId (identifier) rather than a human-readable title (it’s also passed as the "title" query param in the properties link). This naming makes the template/context hard to reason about; consider renaming the context fields (e.g., dss_id) and/or storing both id and display title separately.

Copilot uses AI. Check for mistakes.
cur.execute(
"DELETE FROM matrix_cells WHERE soa_id=? AND activity_id=?",
(soa_id, activity_id),
)
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

This delete handler removes matrix_cells and activity rows but does not delete related activity_concept rows, leaving orphaned concept/DSS mappings in the DB. Please also delete from activity_concept for this activity (scoped by soa_id when available) or enforce referential integrity via a foreign key with ON DELETE CASCADE.

Suggested change
)
)
# Also remove any concept/DSS mappings linked to this activity for this SoA
cur.execute(
"DELETE FROM activity_concept WHERE soa_id=? AND activity_id=?",
(soa_id, activity_id),
)

Copilot uses AI. Check for mistakes.
Comment on lines +796 to +800
new_title, new_href = None, None
selection = dss_selection.strip()
if selection and "||" in selection:
parts = selection.split("||", 1)
new_title, new_href = parts[0], parts[1]
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

Here new_title/new_href are actually datasetSpecializationId + href, but they’re stored in columns named dss_title/dss_href. Even if the DB column name stays for compatibility, renaming these locals (e.g., new_dss_id/new_dss_href) and documenting the stored semantics will prevent confusion and template bugs.

Copilot uses AI. Check for mistakes.
Comment on lines +756 to +760
@ui_router.post(
"/ui/soa/{soa_id}/activity/{activity_id}/concept/{concept_code}/dss",
response_class=HTMLResponse,
)
def ui_save_dss_assignment(
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

The new DSS assignment UI endpoint is not covered by tests (no test exercises assign/clear flows or validates allowed hrefs). Since this changes persisted data and adds security-sensitive URL fetching, please add router tests for assignment, clearing, and href validation.

Copilot uses AI. Check for mistakes.
</tr>
{% for a in activities %}
<tr data-activity-id="{{ a.id }}">
<form method="post" action="/ui/soa/{{ soa_id }}/activities/{{ a.id }}/update">
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

The update form posts to /ui/soa/{soa_id}/activities/{activity_id}/update, but there is no matching route in this PR (the existing update UI handler is /soa/{soa_id}/activities/{activity_id}/update). This will cause the Save button to 404; either change the form action to the existing route or add a ui_router handler at the /ui/... path.

Suggested change
<form method="post" action="/ui/soa/{{ soa_id }}/activities/{{ a.id }}/update">
<form method="post" action="/soa/{{ soa_id }}/activities/{{ a.id }}/update">

Copilot uses AI. Check for mistakes.
}
}
};
xhr.send(JSON.stringify({ order: order }));
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

The reorder call sends {"order": [...]} as the JSON body, but the /soa/{soa_id}/activities/reorder endpoint expects a raw JSON array (List[int]) as the body (per existing tests). This will likely return 422; send the array directly or update the endpoint to accept an object with an order field.

Suggested change
xhr.send(JSON.stringify({ order: order }));
xhr.send(JSON.stringify(order));

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings February 20, 2026 21:03
@pendingintent pendingintent merged commit fbc7a80 into master Feb 20, 2026
8 checks passed
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 8 comments.

Comment on lines +57 to +65
<form method="post" action="/ui/soa/{{ soa_id }}/activities/{{ a.id }}/update">
<td style="padding:4px;white-space:nowrap;"><strong>{{ a.activity_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;">
<button style="background:#607d8b;color:#fff;border:none;padding:2px 6px;border-radius:3px;cursor:pointer;">Save</button>
</td>
</form>
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

The update form posts to /ui/soa/{soa_id}/activities/{id}/update, but there is no matching UI route; the existing handler is /soa/{soa_id}/activities/{activity_id}/update (router prefix) so saves from this page will 404. Either change the form action to the existing route or add a ui_router.post('/ui/soa/{soa_id}/activities/{activity_id}/update') handler that redirects back to the activities page.

Copilot uses AI. Check for mistakes.
Comment on lines +177 to +185
study = {
"id": None,
"extensionAttributes": [],
"name": meta["study_id"] or meta["name"] or "",
"description": _nz(meta["study_description"]),
"label": _nz(meta["study_label"]),
"versions": [study_version],
"documentedBy": [],
"instanceType": "Study",
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

The generated top-level Study object sets id to None (JSON null). USDM objects typically require stable string IDs; emitting null is likely to produce an invalid document. Consider generating a deterministic Study id (e.g., Study_1 or based on study_id) instead.

Copilot uses AI. Check for mistakes.
Comment on lines +67 to +74
def _safe(label: str, fn, *args) -> List[Dict[str, Any]]:
try:
return fn(*args)
except Exception:
logger.warning(
"Failed to build %s for soa_id=%s, using empty list", label, soa_id
)
return []
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

_safe() swallows all exceptions and returns an empty list, which can silently generate incomplete USDM output while still succeeding. If partial output is intended, log the exception with stack trace (exc_info=True) and consider propagating (or collecting) failures so callers can detect an incomplete export.

Copilot uses AI. Check for mistakes.
Comment on lines +524 to +532
for idx, _id in enumerate(ids, start=1):
cur.execute("UPDATE activity SET order_index=? WHERE id=?", (idx, _id))
cur.execute(
"UPDATE activity SET activity_uid = 'TMP_' || id WHERE soa_id=?", (soa_id,)
)
cur.execute(
"UPDATE activity SET activity_uid = 'Activity_' || order_index WHERE soa_id=?",
(soa_id,),
)
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

_reindex_activities() rewrites activity.activity_uid based on the new order_index, but it does not update activity_concept.activity_uid. Since USDM generators (and some queries) look up concepts by activity_uid, reindexing after a delete can orphan existing concept/DSS assignments. Update the related activity_concept rows (or switch downstream lookups to join by activity_id) when activity_uid is regenerated.

Copilot uses AI. Check for mistakes.
Comment on lines +883 to +885
"dss_detail.html",
{
"request": request,
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

This early-return uses templates.TemplateResponse('dss_detail.html', ...) but dss_detail.html does not exist (the detail template in this PR is sdtm_specialization_detail.html). Also, TemplateResponse should be called with the request as the first argument (as done below), otherwise rendering will fail.

Suggested change
"dss_detail.html",
{
"request": request,
request,
"sdtm_specialization_detail.html",
{

Copilot uses AI. Check for mistakes.
Comment on lines 135 to +140
payload = json.dumps(activities, indent=args.indent)
if args.output in ("-", "/dev/stdout"):
sys.stdout.write(payload + "\n")
sys.stdout.write(
"Output suppressed: this document may contain sensitive data. "
"Use an explicit -o <file> path to export.\n"
)
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

This changes the CLI contract: when -o - is used, the generator no longer prints JSON to stdout. That’s a breaking change and is inconsistent with other src/usdm/generate_*.py scripts which still emit JSON to stdout; consider either keeping the old behavior, adding an explicit --suppress-stdout flag, or applying the same policy across all generators.

Copilot uses AI. Check for mistakes.
Comment on lines +537 to +545
@ui_router.get("/ui/soa/{soa_id}/activities", response_class=HTMLResponse)
def ui_list_activities(request: Request, soa_id: int):
if not soa_exists(soa_id):
raise HTTPException(404, "SOA not found")

conn = _connect()
cur = conn.cursor()
cur.execute(
"SELECT id,name,order_index,activity_uid,label,description FROM activity WHERE soa_id=? ORDER BY order_index",
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

New UI surface area is introduced here (/ui/soa/{soa_id}/activities plus DSS assignment/detail endpoints), but there are no corresponding tests in tests/test_routers_activities.py. Adding coverage for the new UI list page and the DSS assign/clear + detail fetch flows would help prevent regressions (route wiring, DB column presence, and href validation).

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +687 to +690
cur.execute(
"DELETE FROM activity_concept WHERE activity_id=? AND soa_id=?",
(activity_id, soa_id),
)
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

This delete path assumes activity_concept has a soa_id column (DELETE ... WHERE activity_id=? AND soa_id=?). Elsewhere in this module you explicitly handle legacy schemas where soa_id may be missing; if that’s a supported scenario, this endpoint should mirror the same _table_has_columns check to avoid SQL errors.

Suggested change
cur.execute(
"DELETE FROM activity_concept WHERE activity_id=? AND soa_id=?",
(activity_id, soa_id),
)
if _table_has_columns(conn, "activity_concept", ["soa_id"]):
cur.execute(
"DELETE FROM activity_concept WHERE activity_id=? AND soa_id=?",
(activity_id, soa_id),
)
else:
cur.execute(
"DELETE FROM activity_concept WHERE activity_id=?",
(activity_id,),
)

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants