From 95d1247c12908a416fae108a02fbe0456e246736 Mon Sep 17 00:00:00 2001
From: Darren <3921919+pendingintent@users.noreply.github.com>
Date: Wed, 22 Apr 2026 11:30:24 -0400
Subject: [PATCH 01/39] Issue #199: SOA Matrix only displays
ScheduledActivityInstance's that are assigned to the selected timeline
---
src/soa_builder/web/app.py | 20 ++++++++++++++++++++
src/soa_builder/web/templates/edit.html | 2 +-
2 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py
index 01ad344..dfad151 100644
--- a/src/soa_builder/web/app.py
+++ b/src/soa_builder/web/app.py
@@ -4563,6 +4563,25 @@ def ui_edit(request: Request, soa_id: int):
instances_by_timeline[timeline_key] = []
instances_by_timeline[timeline_key].append(inst)
+ # Activities per timeline: an activity is shown in timeline T's matrix
+ # if any matrix_cells row connects it to an instance whose
+ # member_of_timeline == T. The instance->timeline link (set on the
+ # study_timing page) is the authoritative criterion.
+ instance_timeline = {
+ inst["id"]: (inst.get("member_of_timeline") or "unassigned")
+ for inst in instances
+ }
+ activity_ids_by_timeline: dict = {tl: set() for tl in instances_by_timeline.keys()}
+ for c in cells:
+ tl = instance_timeline.get(c["instance_id"])
+ if tl is None or tl not in activity_ids_by_timeline:
+ continue
+ activity_ids_by_timeline[tl].add(c["activity_id"])
+ activities_by_timeline: dict = {
+ tl: [a for a in activities_page if a["id"] in ids]
+ for tl, ids in activity_ids_by_timeline.items()
+ }
+
# Determine default timeline (main_timeline or first available)
default_timeline = None
for tl in timelines:
@@ -4641,6 +4660,7 @@ def ui_edit(request: Request, soa_id: int):
"timings": timings,
"timelines": timelines,
"instances_by_timeline": instances_by_timeline,
+ "activities_by_timeline": activities_by_timeline,
"default_timeline": default_timeline,
"footnotes": footnotes,
"superscript_map": superscript_map,
diff --git a/src/soa_builder/web/templates/edit.html b/src/soa_builder/web/templates/edit.html
index 78b7f52..48938e5 100644
--- a/src/soa_builder/web/templates/edit.html
+++ b/src/soa_builder/web/templates/edit.html
@@ -256,7 +256,7 @@
Matrix: {% if timeline_instances and timeline_instances[0].timeline_name %}{
{% endfor %}
- {% for a in activities %}
+ {% for a in activities_by_timeline.get(timeline_uid, []) %}
{% if a.label %}{{ a.label }}{% else %}{{ a.name }}{% endif %}
{% set concepts_list = activity_concepts.get(a.id, []) %}
From e875fde0652ef37c3b2d41e18f785372af4bd0fa Mon Sep 17 00:00:00 2001
From: Darren <3921919+pendingintent@users.noreply.github.com>
Date: Wed, 22 Apr 2026 14:03:24 -0400
Subject: [PATCH 02/39] Added objectives and endpoints creation
---
src/soa_builder/web/app.py | 83 ++++
src/soa_builder/web/audit.py | 54 +++
src/soa_builder/web/migrate_database.py | 100 +++++
src/soa_builder/web/routers/endpoints.py | 414 ++++++++++++++++++
src/soa_builder/web/routers/objectives.py | 408 +++++++++++++++++
src/soa_builder/web/schemas.py | 36 ++
.../web/templates/_objectives_section.html | 209 +++++++++
src/soa_builder/web/templates/edit.html | 3 +
tests/test_routers_endpoints.py | 269 ++++++++++++
tests/test_routers_objectives.py | 228 ++++++++++
10 files changed, 1804 insertions(+)
create mode 100644 src/soa_builder/web/routers/endpoints.py
create mode 100644 src/soa_builder/web/routers/objectives.py
create mode 100644 src/soa_builder/web/templates/_objectives_section.html
create mode 100644 tests/test_routers_endpoints.py
create mode 100644 tests/test_routers_objectives.py
diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py
index dfad151..1b026aa 100644
--- a/src/soa_builder/web/app.py
+++ b/src/soa_builder/web/app.py
@@ -83,6 +83,10 @@
_migrate_activity_concept_dss_add_display,
_migrate_drop_protocol_terminology_tables,
_migrate_drop_ddf_terminology_tables,
+ _migrate_add_objective_table,
+ _migrate_add_objective_audit_table,
+ _migrate_add_endpoint_table,
+ _migrate_add_endpoint_audit_table,
)
from .routers import activities as activities_router
from .routers import arms as arms_router
@@ -113,6 +117,8 @@
from .routers import (
ddf_controlled_terminology as ddf_controlled_terminology_router,
)
+from .routers import objectives as objectives_router
+from .routers import endpoints as endpoints_router
from .audit import _record_element_audit
@@ -246,6 +252,10 @@ def _configure_logging():
_migrate_drop_ddf_terminology_tables()
_migrate_add_activity_concept_dss_table()
_migrate_activity_concept_dss_add_display()
+_migrate_add_objective_table()
+_migrate_add_objective_audit_table()
+_migrate_add_endpoint_table()
+_migrate_add_endpoint_audit_table()
# Include routers
@@ -278,6 +288,10 @@ def _configure_logging():
app.include_router(define_xml_terminology_router.router)
app.include_router(protocol_controlled_terminology_router.router)
app.include_router(ddf_controlled_terminology_router.router)
+app.include_router(objectives_router.router)
+app.include_router(objectives_router.ui_router)
+app.include_router(endpoints_router.router)
+app.include_router(endpoints_router.ui_router)
def _record_visit_audit(
@@ -4623,6 +4637,70 @@ def ui_edit(request: Request, soa_id: int):
schedule_timelines_options = get_schedule_timeline(soa_id)
instance_options = get_scheduled_activity_instance(soa_id)
+ # Objectives + Endpoints with DDF level decode lookups
+ c188725_map = _get_ddf_ct_codelist_map("C188725")
+ c188726_map = _get_ddf_ct_codelist_map("C188726")
+ objective_level_options = sorted({v for v in c188725_map.values() if v})
+ endpoint_level_options = sorted({v for v in c188726_map.values() if v})
+ conn_obj = _connect()
+ cur_obj = conn_obj.cursor()
+ cur_obj.execute(
+ "SELECT code_uid, code FROM code_association "
+ "WHERE soa_id=? AND codelist_code IN ('C188725','C188726')",
+ (soa_id,),
+ )
+ level_code_to_sv: dict = {}
+ for code_uid, code_val in cur_obj.fetchall():
+ sv = c188725_map.get(code_val) or c188726_map.get(code_val) or ""
+ level_code_to_sv[code_uid] = sv
+ cur_obj.execute(
+ "SELECT id,objective_uid,name,label,description,text,"
+ "level_code_uid,order_index "
+ "FROM objective WHERE soa_id=? ORDER BY order_index, id",
+ (soa_id,),
+ )
+ objectives = [
+ {
+ "id": r[0],
+ "objective_uid": r[1],
+ "name": r[2],
+ "label": r[3],
+ "description": r[4],
+ "text": r[5],
+ "level_code_uid": r[6],
+ "level": level_code_to_sv.get(r[6], ""),
+ "order_index": r[7],
+ }
+ for r in cur_obj.fetchall()
+ ]
+ cur_obj.execute(
+ "SELECT id,endpoint_uid,objective_uid,name,label,description,"
+ "text,purpose,level_code_uid,order_index "
+ "FROM endpoint WHERE soa_id=? ORDER BY order_index, id",
+ (soa_id,),
+ )
+ endpoints_by_objective: dict = {}
+ orphan_endpoints: list = []
+ for r in cur_obj.fetchall():
+ ep = {
+ "id": r[0],
+ "endpoint_uid": r[1],
+ "objective_uid": r[2],
+ "name": r[3],
+ "label": r[4],
+ "description": r[5],
+ "text": r[6],
+ "purpose": r[7],
+ "level_code_uid": r[8],
+ "level": level_code_to_sv.get(r[8], ""),
+ "order_index": r[9],
+ }
+ if ep["objective_uid"]:
+ endpoints_by_objective.setdefault(ep["objective_uid"], []).append(ep)
+ else:
+ orphan_endpoints.append(ep)
+ conn_obj.close()
+
return templates.TemplateResponse(
request,
"edit.html",
@@ -4664,6 +4742,11 @@ def ui_edit(request: Request, soa_id: int):
"default_timeline": default_timeline,
"footnotes": footnotes,
"superscript_map": superscript_map,
+ "objectives": objectives,
+ "endpoints_by_objective": endpoints_by_objective,
+ "orphan_endpoints": orphan_endpoints,
+ "objective_level_options": objective_level_options,
+ "endpoint_level_options": endpoint_level_options,
},
)
diff --git a/src/soa_builder/web/audit.py b/src/soa_builder/web/audit.py
index a45a7db..70bea7a 100644
--- a/src/soa_builder/web/audit.py
+++ b/src/soa_builder/web/audit.py
@@ -419,3 +419,57 @@ def _record_footnote_audit(
conn.close()
except Exception as e:
logger.warning("Failed recording footnote audit: %s", e)
+
+
+def _record_objective_audit(
+ soa_id: int,
+ action: str,
+ objective_id: Optional[int],
+ before: Optional[Dict[str, Any]] = None,
+ after: Optional[Dict[str, Any]] = None,
+):
+ try:
+ conn = _connect()
+ cur = conn.cursor()
+ cur.execute(
+ "INSERT INTO objective_audit (soa_id, objective_id, action, before_json, after_json, performed_at) VALUES (?,?,?,?,?,?)",
+ (
+ soa_id,
+ objective_id,
+ action,
+ json.dumps(before) if before else None,
+ json.dumps(after) if after else None,
+ datetime.now(timezone.utc).isoformat(),
+ ),
+ )
+ conn.commit()
+ conn.close()
+ except Exception as e:
+ logger.warning("Failed recording objective audit: %s", e)
+
+
+def _record_endpoint_audit(
+ soa_id: int,
+ action: str,
+ endpoint_id: Optional[int],
+ before: Optional[Dict[str, Any]] = None,
+ after: Optional[Dict[str, Any]] = None,
+):
+ try:
+ conn = _connect()
+ cur = conn.cursor()
+ cur.execute(
+ "INSERT INTO endpoint_audit (soa_id, endpoint_id, action, before_json, after_json, performed_at) VALUES (?,?,?,?,?,?)",
+ (
+ soa_id,
+ endpoint_id,
+ action,
+ json.dumps(before) if before else None,
+ json.dumps(after) if after else None,
+ datetime.now(timezone.utc).isoformat(),
+ ),
+ )
+ conn.commit()
+ conn.close()
+ except Exception as e:
+ logger.warning("Failed recording endpoint audit: %s", e)
diff --git a/src/soa_builder/web/migrate_database.py b/src/soa_builder/web/migrate_database.py
index d929949..49ff444 100644
--- a/src/soa_builder/web/migrate_database.py
+++ b/src/soa_builder/web/migrate_database.py
@@ -1750,3 +1750,103 @@ def _migrate_drop_ddf_terminology_tables():
conn.close()
except Exception as e:
logger.warning("_migrate_drop_ddf_terminology_tables failed: %s", e)
+
+
+def _migrate_add_objective_table():
+ """Create the objective table for USDM study objectives."""
+ try:
+ conn = _connect()
+ cur = conn.cursor()
+ cur.execute(
+ """CREATE TABLE IF NOT EXISTS objective (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ soa_id INTEGER NOT NULL,
+ objective_uid TEXT NOT NULL,
+ name TEXT NOT NULL,
+ label TEXT,
+ description TEXT,
+ text TEXT,
+ level_code_uid TEXT,
+ order_index INTEGER,
+ UNIQUE(soa_id, objective_uid)
+ )"""
+ )
+ conn.commit()
+ conn.close()
+ logger.info("_migrate_add_objective_table created objective table")
+ except Exception as e:
+ logger.warning("_migrate_add_objective_table failed: %s", e)
+
+
+def _migrate_add_objective_audit_table():
+ """Create objective_audit table for tracking objective mutations."""
+ try:
+ conn = _connect()
+ cur = conn.cursor()
+ cur.execute(
+ """CREATE TABLE IF NOT EXISTS objective_audit (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ soa_id INTEGER NOT NULL,
+ objective_id INTEGER,
+ action TEXT NOT NULL,
+ before_json TEXT,
+ after_json TEXT,
+ performed_at TEXT NOT NULL
+ )"""
+ )
+ conn.commit()
+ conn.close()
+ logger.info("_migrate_add_objective_audit_table created objective_audit table")
+ except Exception as e:
+ logger.warning("_migrate_add_objective_audit_table failed: %s", e)
+
+
+def _migrate_add_endpoint_table():
+ """Create the endpoint table for USDM study endpoints."""
+ try:
+ conn = _connect()
+ cur = conn.cursor()
+ cur.execute(
+ """CREATE TABLE IF NOT EXISTS endpoint (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ soa_id INTEGER NOT NULL,
+ endpoint_uid TEXT NOT NULL,
+ objective_uid TEXT,
+ name TEXT NOT NULL,
+ label TEXT,
+ description TEXT,
+ text TEXT,
+ purpose TEXT,
+ level_code_uid TEXT,
+ order_index INTEGER,
+ UNIQUE(soa_id, endpoint_uid)
+ )"""
+ )
+ conn.commit()
+ conn.close()
+ logger.info("_migrate_add_endpoint_table created endpoint table")
+ except Exception as e:
+ logger.warning("_migrate_add_endpoint_table failed: %s", e)
+
+
+def _migrate_add_endpoint_audit_table():
+ """Create endpoint_audit table for tracking endpoint mutations."""
+ try:
+ conn = _connect()
+ cur = conn.cursor()
+ cur.execute(
+ """CREATE TABLE IF NOT EXISTS endpoint_audit (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ soa_id INTEGER NOT NULL,
+ endpoint_id INTEGER,
+ action TEXT NOT NULL,
+ before_json TEXT,
+ after_json TEXT,
+ performed_at TEXT NOT NULL
+ )"""
+ )
+ conn.commit()
+ conn.close()
+ logger.info("_migrate_add_endpoint_audit_table created endpoint_audit table")
+ except Exception as e:
+ logger.warning("_migrate_add_endpoint_audit_table failed: %s", e)
diff --git a/src/soa_builder/web/routers/endpoints.py b/src/soa_builder/web/routers/endpoints.py
new file mode 100644
index 0000000..c4a99e6
--- /dev/null
+++ b/src/soa_builder/web/routers/endpoints.py
@@ -0,0 +1,414 @@
+import json
+import logging
+
+from fastapi import APIRouter, Form, HTTPException, Request
+from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
+
+from ..audit import _record_endpoint_audit
+from ..db import _connect
+from ..schemas import EndpointCreate, EndpointUpdate
+from ..utils import (
+ get_latest_ddf_ct_href,
+ get_next_code_uid,
+ soa_exists,
+)
+
+router = APIRouter(prefix="/soa/{soa_id}")
+ui_router = APIRouter()
+logger = logging.getLogger("soa_builder.web.routers.endpoints")
+
+_ENDPOINT_LEVEL_CODELIST = "C188726"
+
+
+def _next_endpoint_uid(cur, soa_id: int) -> str:
+ """Return next Endpoint_N UID, never reusing deleted UIDs."""
+ max_n = 0
+ cur.execute(
+ "SELECT endpoint_uid FROM endpoint WHERE soa_id=? "
+ "AND endpoint_uid LIKE 'Endpoint_%'",
+ (soa_id,),
+ )
+ for (uid,) in cur.fetchall():
+ if isinstance(uid, str) and uid.startswith("Endpoint_"):
+ try:
+ n = int(uid.split("_")[-1])
+ if n > max_n:
+ max_n = n
+ except (ValueError, IndexError):
+ pass
+ cur.execute(
+ "SELECT before_json, after_json FROM endpoint_audit WHERE soa_id=?",
+ (soa_id,),
+ )
+ for before_raw, after_raw in cur.fetchall():
+ for raw in (before_raw, after_raw):
+ if not raw:
+ continue
+ try:
+ uid = json.loads(raw).get("endpoint_uid", "")
+ if isinstance(uid, str) and uid.startswith("Endpoint_"):
+ n = int(uid.split("_")[-1])
+ if n > max_n:
+ max_n = n
+ except Exception:
+ pass
+ return f"Endpoint_{max_n + 1}"
+
+
+def _row_to_dict(row) -> dict:
+ keys = [
+ "id",
+ "soa_id",
+ "endpoint_uid",
+ "objective_uid",
+ "name",
+ "label",
+ "description",
+ "text",
+ "purpose",
+ "level_code_uid",
+ "order_index",
+ ]
+ return dict(zip(keys, row))
+
+
+def _objective_exists(cur, soa_id: int, objective_uid: str) -> bool:
+ cur.execute(
+ "SELECT 1 FROM objective WHERE soa_id=? AND objective_uid=?",
+ (soa_id, objective_uid),
+ )
+ return cur.fetchone() is not None
+
+
+def _insert_level_code(cur, soa_id: int, submission_value: str) -> str:
+ code_uid = get_next_code_uid(cur, soa_id)
+ slug = get_latest_ddf_ct_href() or ""
+ codelist_table = f"/mdr/ct/packages/{slug}" if slug else "/mdr/ct/packages"
+ cur.execute(
+ "INSERT INTO code_association "
+ "(soa_id, code_uid, codelist_table, codelist_code, code) "
+ "VALUES (?,?,?,?,?)",
+ (
+ soa_id,
+ code_uid,
+ codelist_table,
+ _ENDPOINT_LEVEL_CODELIST,
+ submission_value,
+ ),
+ )
+ return code_uid
+
+
+def _delete_level_code(cur, soa_id: int, code_uid: str | None) -> None:
+ if not code_uid:
+ return
+ cur.execute(
+ "DELETE FROM code_association WHERE soa_id=? AND code_uid=?",
+ (soa_id, code_uid),
+ )
+
+
+# ---------------------------------------------------------------------------
+# JSON API endpoints
+# ---------------------------------------------------------------------------
+
+
+@router.get("/endpoints", response_class=JSONResponse)
+def list_endpoints(soa_id: int):
+ if not soa_exists(soa_id):
+ raise HTTPException(404, "SOA not found")
+ conn = _connect()
+ cur = conn.cursor()
+ cur.execute(
+ "SELECT id,soa_id,endpoint_uid,objective_uid,name,label,"
+ "description,text,purpose,level_code_uid,order_index "
+ "FROM endpoint WHERE soa_id=? ORDER BY order_index, id",
+ (soa_id,),
+ )
+ rows = [_row_to_dict(r) for r in cur.fetchall()]
+ conn.close()
+ return JSONResponse(rows)
+
+
+@router.post("/endpoints", response_class=JSONResponse)
+def create_endpoint(soa_id: int, body: EndpointCreate):
+ if not soa_exists(soa_id):
+ raise HTTPException(404, "SOA not found")
+ name = (body.name or "").strip()
+ level = (body.level or "").strip()
+ objective_uid = (body.objective_uid or "").strip()
+ if not name:
+ raise HTTPException(400, "Endpoint name required")
+ if not level:
+ raise HTTPException(400, "Endpoint level required")
+ if not objective_uid:
+ raise HTTPException(400, "Parent objective_uid required")
+
+ conn = _connect()
+ cur = conn.cursor()
+ if not _objective_exists(cur, soa_id, objective_uid):
+ conn.close()
+ raise HTTPException(400, f"Objective {objective_uid!r} not found for this SOA")
+
+ cur.execute(
+ "SELECT COALESCE(MAX(order_index),0) FROM endpoint WHERE soa_id=?",
+ (soa_id,),
+ )
+ next_ord = (cur.fetchone() or [0])[0] + 1
+ endpoint_uid = _next_endpoint_uid(cur, soa_id)
+ level_code_uid = _insert_level_code(cur, soa_id, level)
+
+ label = (body.label or "").strip() or None
+ description = (body.description or "").strip() or None
+ text = (body.text or "").strip() or None
+ purpose = (body.purpose or "").strip() or None
+
+ cur.execute(
+ "INSERT INTO endpoint "
+ "(soa_id,endpoint_uid,objective_uid,name,label,description,"
+ "text,purpose,level_code_uid,order_index) "
+ "VALUES (?,?,?,?,?,?,?,?,?,?)",
+ (
+ soa_id,
+ endpoint_uid,
+ objective_uid,
+ name,
+ label,
+ description,
+ text,
+ purpose,
+ level_code_uid,
+ next_ord,
+ ),
+ )
+ endpoint_id = cur.lastrowid
+ conn.commit()
+ conn.close()
+
+ after = {
+ "id": endpoint_id,
+ "endpoint_uid": endpoint_uid,
+ "objective_uid": objective_uid,
+ "name": name,
+ "label": label,
+ "description": description,
+ "text": text,
+ "purpose": purpose,
+ "level_code_uid": level_code_uid,
+ "level": level,
+ "order_index": next_ord,
+ }
+ _record_endpoint_audit(soa_id, "create", endpoint_id, before=None, after=after)
+ return JSONResponse(after, status_code=201)
+
+
+@router.patch("/endpoints/{endpoint_id}", response_class=JSONResponse)
+def update_endpoint(soa_id: int, endpoint_id: int, body: EndpointUpdate):
+ if not soa_exists(soa_id):
+ raise HTTPException(404, "SOA not found")
+ conn = _connect()
+ cur = conn.cursor()
+ cur.execute(
+ "SELECT id,soa_id,endpoint_uid,objective_uid,name,label,"
+ "description,text,purpose,level_code_uid,order_index "
+ "FROM endpoint WHERE id=? AND soa_id=?",
+ (endpoint_id, soa_id),
+ )
+ row = cur.fetchone()
+ if not row:
+ conn.close()
+ raise HTTPException(404, "Endpoint not found")
+ before = _row_to_dict(row)
+
+ new_objective_uid = before["objective_uid"]
+ if body.objective_uid is not None:
+ candidate = body.objective_uid.strip() or None
+ if candidate is not None and not _objective_exists(cur, soa_id, candidate):
+ conn.close()
+ raise HTTPException(400, f"Objective {candidate!r} not found for this SOA")
+ new_objective_uid = candidate
+
+ new_name = body.name if body.name is not None else before["name"]
+ new_label = body.label if body.label is not None else before["label"]
+ new_desc = (
+ body.description if body.description is not None else before["description"]
+ )
+ new_text = body.text if body.text is not None else before["text"]
+ new_purpose = body.purpose if body.purpose is not None else before["purpose"]
+
+ new_level_code_uid = before["level_code_uid"]
+ if body.level is not None:
+ new_level = body.level.strip()
+ if not new_level:
+ conn.close()
+ raise HTTPException(400, "Endpoint level cannot be empty")
+ if before["level_code_uid"]:
+ cur.execute(
+ "UPDATE code_association SET code=? WHERE soa_id=? AND code_uid=?",
+ (new_level, soa_id, before["level_code_uid"]),
+ )
+ else:
+ new_level_code_uid = _insert_level_code(cur, soa_id, new_level)
+
+ cur.execute(
+ "UPDATE endpoint SET objective_uid=?, name=?, label=?, "
+ "description=?, text=?, purpose=?, level_code_uid=? "
+ "WHERE id=? AND soa_id=?",
+ (
+ new_objective_uid,
+ new_name,
+ (new_label or None) if new_label is not None else None,
+ (new_desc or None) if new_desc is not None else None,
+ (new_text or None) if new_text is not None else None,
+ (new_purpose or None) if new_purpose is not None else None,
+ new_level_code_uid,
+ endpoint_id,
+ soa_id,
+ ),
+ )
+ conn.commit()
+ conn.close()
+
+ after = {
+ **before,
+ "objective_uid": new_objective_uid,
+ "name": new_name,
+ "label": new_label,
+ "description": new_desc,
+ "text": new_text,
+ "purpose": new_purpose,
+ "level_code_uid": new_level_code_uid,
+ }
+ _record_endpoint_audit(soa_id, "update", endpoint_id, before=before, after=after)
+ return JSONResponse(after)
+
+
+@router.delete("/endpoints/{endpoint_id}", response_class=JSONResponse)
+def delete_endpoint(soa_id: int, endpoint_id: int):
+ if not soa_exists(soa_id):
+ raise HTTPException(404, "SOA not found")
+ conn = _connect()
+ cur = conn.cursor()
+ cur.execute(
+ "SELECT id,soa_id,endpoint_uid,objective_uid,name,label,"
+ "description,text,purpose,level_code_uid,order_index "
+ "FROM endpoint WHERE id=? AND soa_id=?",
+ (endpoint_id, soa_id),
+ )
+ row = cur.fetchone()
+ if not row:
+ conn.close()
+ raise HTTPException(404, "Endpoint not found")
+ before = _row_to_dict(row)
+
+ _delete_level_code(cur, soa_id, before["level_code_uid"])
+ cur.execute(
+ "DELETE FROM endpoint WHERE id=? AND soa_id=?",
+ (endpoint_id, soa_id),
+ )
+
+ # Reindex remaining endpoints
+ cur.execute(
+ "SELECT id FROM endpoint WHERE soa_id=? ORDER BY order_index, id",
+ (soa_id,),
+ )
+ remaining = [r[0] for r in cur.fetchall()]
+ for idx, eid in enumerate(remaining, start=1):
+ cur.execute("UPDATE endpoint SET order_index=? WHERE id=?", (idx, eid))
+ conn.commit()
+ conn.close()
+
+ _record_endpoint_audit(soa_id, "delete", endpoint_id, before=before, after=None)
+ return JSONResponse({"deleted": endpoint_id})
+
+
+# ---------------------------------------------------------------------------
+# UI form endpoints
+# ---------------------------------------------------------------------------
+
+
+@ui_router.post("/ui/soa/{soa_id}/endpoints/create", response_class=HTMLResponse)
+def ui_create_endpoint(
+ request: Request,
+ soa_id: int,
+ name: str = Form(...),
+ level: str = Form(...),
+ objective_uid: str = Form(...),
+ label: str | None = Form(None),
+ description: str | None = Form(None),
+ text: str | None = Form(None),
+ purpose: str | None = Form(None),
+):
+ if not soa_exists(soa_id):
+ raise HTTPException(404, "SOA not found")
+ create_endpoint(
+ soa_id,
+ EndpointCreate(
+ name=name,
+ level=level,
+ objective_uid=objective_uid,
+ label=label,
+ description=description,
+ text=text,
+ purpose=purpose,
+ ),
+ )
+ redirect_url = f"/ui/soa/{soa_id}/edit"
+ if request.headers.get("HX-Request") == "true":
+ return HTMLResponse("", headers={"HX-Redirect": redirect_url})
+ return RedirectResponse(redirect_url, status_code=303)
+
+
+@ui_router.post(
+ "/ui/soa/{soa_id}/endpoints/{endpoint_id}/update",
+ response_class=HTMLResponse,
+)
+def ui_update_endpoint(
+ request: Request,
+ soa_id: int,
+ endpoint_id: int,
+ name: str | None = Form(None),
+ level: str | None = Form(None),
+ objective_uid: str | None = Form(None),
+ label: str | None = Form(None),
+ description: str | None = Form(None),
+ text: str | None = Form(None),
+ purpose: str | None = Form(None),
+):
+ if not soa_exists(soa_id):
+ raise HTTPException(404, "SOA not found")
+ update_endpoint(
+ soa_id,
+ endpoint_id,
+ EndpointUpdate(
+ name=name,
+ level=level,
+ objective_uid=objective_uid,
+ label=label,
+ description=description,
+ text=text,
+ purpose=purpose,
+ ),
+ )
+ redirect_url = f"/ui/soa/{soa_id}/edit"
+ if request.headers.get("HX-Request") == "true":
+ return HTMLResponse("", headers={"HX-Redirect": redirect_url})
+ return RedirectResponse(redirect_url, status_code=303)
+
+
+@ui_router.post(
+ "/ui/soa/{soa_id}/endpoints/{endpoint_id}/delete",
+ response_class=HTMLResponse,
+)
+def ui_delete_endpoint(
+ request: Request,
+ soa_id: int,
+ endpoint_id: int,
+):
+ if not soa_exists(soa_id):
+ raise HTTPException(404, "SOA not found")
+ delete_endpoint(soa_id, endpoint_id)
+ redirect_url = f"/ui/soa/{soa_id}/edit"
+ if request.headers.get("HX-Request") == "true":
+ return HTMLResponse("", headers={"HX-Redirect": redirect_url})
+ return RedirectResponse(redirect_url, status_code=303)
diff --git a/src/soa_builder/web/routers/objectives.py b/src/soa_builder/web/routers/objectives.py
new file mode 100644
index 0000000..73381d3
--- /dev/null
+++ b/src/soa_builder/web/routers/objectives.py
@@ -0,0 +1,408 @@
+import json
+import logging
+
+from fastapi import APIRouter, Form, HTTPException, Request
+from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
+
+from ..audit import _record_endpoint_audit, _record_objective_audit
+from ..db import _connect
+from ..schemas import ObjectiveCreate, ObjectiveUpdate
+from ..utils import (
+ get_latest_ddf_ct_href,
+ get_next_code_uid,
+ soa_exists,
+)
+
+router = APIRouter(prefix="/soa/{soa_id}")
+ui_router = APIRouter()
+logger = logging.getLogger("soa_builder.web.routers.objectives")
+
+_OBJECTIVE_LEVEL_CODELIST = "C188725"
+
+
+def _next_objective_uid(cur, soa_id: int) -> str:
+ """Return next Objective_N UID, never reusing deleted UIDs."""
+ max_n = 0
+ cur.execute(
+ "SELECT objective_uid FROM objective WHERE soa_id=? "
+ "AND objective_uid LIKE 'Objective_%'",
+ (soa_id,),
+ )
+ for (uid,) in cur.fetchall():
+ if isinstance(uid, str) and uid.startswith("Objective_"):
+ try:
+ n = int(uid.split("_")[-1])
+ if n > max_n:
+ max_n = n
+ except (ValueError, IndexError):
+ pass
+ cur.execute(
+ "SELECT before_json, after_json FROM objective_audit WHERE soa_id=?",
+ (soa_id,),
+ )
+ for before_raw, after_raw in cur.fetchall():
+ for raw in (before_raw, after_raw):
+ if not raw:
+ continue
+ try:
+ uid = json.loads(raw).get("objective_uid", "")
+ if isinstance(uid, str) and uid.startswith("Objective_"):
+ n = int(uid.split("_")[-1])
+ if n > max_n:
+ max_n = n
+ except Exception:
+ pass
+ return f"Objective_{max_n + 1}"
+
+
+def _row_to_dict(row) -> dict:
+ keys = [
+ "id",
+ "soa_id",
+ "objective_uid",
+ "name",
+ "label",
+ "description",
+ "text",
+ "level_code_uid",
+ "order_index",
+ ]
+ return dict(zip(keys, row))
+
+
+def _insert_level_code(cur, soa_id: int, submission_value: str) -> str:
+ """Insert a code_association row for the objective level and return
+ the generated Code_N UID."""
+ code_uid = get_next_code_uid(cur, soa_id)
+ slug = get_latest_ddf_ct_href() or ""
+ codelist_table = f"/mdr/ct/packages/{slug}" if slug else "/mdr/ct/packages"
+ cur.execute(
+ "INSERT INTO code_association "
+ "(soa_id, code_uid, codelist_table, codelist_code, code) "
+ "VALUES (?,?,?,?,?)",
+ (
+ soa_id,
+ code_uid,
+ codelist_table,
+ _OBJECTIVE_LEVEL_CODELIST,
+ submission_value,
+ ),
+ )
+ return code_uid
+
+
+def _delete_level_code(cur, soa_id: int, code_uid: str | None) -> None:
+ if not code_uid:
+ return
+ cur.execute(
+ "DELETE FROM code_association WHERE soa_id=? AND code_uid=?",
+ (soa_id, code_uid),
+ )
+
+
+# ---------------------------------------------------------------------------
+# JSON API endpoints
+# ---------------------------------------------------------------------------
+
+
+@router.get("/objectives", response_class=JSONResponse)
+def list_objectives(soa_id: int):
+ if not soa_exists(soa_id):
+ raise HTTPException(404, "SOA not found")
+ conn = _connect()
+ cur = conn.cursor()
+ cur.execute(
+ "SELECT id,soa_id,objective_uid,name,label,description,text,"
+ "level_code_uid,order_index "
+ "FROM objective WHERE soa_id=? ORDER BY order_index, id",
+ (soa_id,),
+ )
+ rows = [_row_to_dict(r) for r in cur.fetchall()]
+ conn.close()
+ return JSONResponse(rows)
+
+
+@router.post("/objectives", response_class=JSONResponse)
+def create_objective(soa_id: int, body: ObjectiveCreate):
+ if not soa_exists(soa_id):
+ raise HTTPException(404, "SOA not found")
+ name = (body.name or "").strip()
+ level = (body.level or "").strip()
+ if not name:
+ raise HTTPException(400, "Objective name required")
+ if not level:
+ raise HTTPException(400, "Objective level required")
+
+ conn = _connect()
+ cur = conn.cursor()
+ cur.execute(
+ "SELECT COALESCE(MAX(order_index),0) FROM objective WHERE soa_id=?",
+ (soa_id,),
+ )
+ next_ord = (cur.fetchone() or [0])[0] + 1
+ objective_uid = _next_objective_uid(cur, soa_id)
+ level_code_uid = _insert_level_code(cur, soa_id, level)
+
+ label = (body.label or "").strip() or None
+ description = (body.description or "").strip() or None
+ text = (body.text or "").strip() or None
+
+ cur.execute(
+ "INSERT INTO objective "
+ "(soa_id,objective_uid,name,label,description,text,"
+ "level_code_uid,order_index) VALUES (?,?,?,?,?,?,?,?)",
+ (
+ soa_id,
+ objective_uid,
+ name,
+ label,
+ description,
+ text,
+ level_code_uid,
+ next_ord,
+ ),
+ )
+ objective_id = cur.lastrowid
+ conn.commit()
+ conn.close()
+
+ after = {
+ "id": objective_id,
+ "objective_uid": objective_uid,
+ "name": name,
+ "label": label,
+ "description": description,
+ "text": text,
+ "level_code_uid": level_code_uid,
+ "level": level,
+ "order_index": next_ord,
+ }
+ _record_objective_audit(soa_id, "create", objective_id, before=None, after=after)
+ return JSONResponse(after, status_code=201)
+
+
+@router.patch("/objectives/{objective_id}", response_class=JSONResponse)
+def update_objective(soa_id: int, objective_id: int, body: ObjectiveUpdate):
+ if not soa_exists(soa_id):
+ raise HTTPException(404, "SOA not found")
+ conn = _connect()
+ cur = conn.cursor()
+ cur.execute(
+ "SELECT id,soa_id,objective_uid,name,label,description,text,"
+ "level_code_uid,order_index "
+ "FROM objective WHERE id=? AND soa_id=?",
+ (objective_id, soa_id),
+ )
+ row = cur.fetchone()
+ if not row:
+ conn.close()
+ raise HTTPException(404, "Objective not found")
+ before = _row_to_dict(row)
+
+ new_name = body.name if body.name is not None else before["name"]
+ new_label = body.label if body.label is not None else before["label"]
+ new_desc = (
+ body.description if body.description is not None else before["description"]
+ )
+ new_text = body.text if body.text is not None else before["text"]
+
+ new_level_code_uid = before["level_code_uid"]
+ if body.level is not None:
+ new_level = body.level.strip()
+ if not new_level:
+ conn.close()
+ raise HTTPException(400, "Objective level cannot be empty")
+ if before["level_code_uid"]:
+ # Update the submission value in the existing Code_N row.
+ cur.execute(
+ "UPDATE code_association SET code=? WHERE soa_id=? AND code_uid=?",
+ (new_level, soa_id, before["level_code_uid"]),
+ )
+ else:
+ new_level_code_uid = _insert_level_code(cur, soa_id, new_level)
+
+ cur.execute(
+ "UPDATE objective SET name=?, label=?, description=?, text=?, "
+ "level_code_uid=? WHERE id=? AND soa_id=?",
+ (
+ new_name,
+ (new_label or None) if new_label is not None else None,
+ (new_desc or None) if new_desc is not None else None,
+ (new_text or None) if new_text is not None else None,
+ new_level_code_uid,
+ objective_id,
+ soa_id,
+ ),
+ )
+ conn.commit()
+ conn.close()
+
+ after = {
+ **before,
+ "name": new_name,
+ "label": new_label,
+ "description": new_desc,
+ "text": new_text,
+ "level_code_uid": new_level_code_uid,
+ }
+ _record_objective_audit(soa_id, "update", objective_id, before=before, after=after)
+ return JSONResponse(after)
+
+
+@router.delete("/objectives/{objective_id}", response_class=JSONResponse)
+def delete_objective(soa_id: int, objective_id: int):
+ if not soa_exists(soa_id):
+ raise HTTPException(404, "SOA not found")
+ conn = _connect()
+ cur = conn.cursor()
+ cur.execute(
+ "SELECT id,soa_id,objective_uid,name,label,description,text,"
+ "level_code_uid,order_index "
+ "FROM objective WHERE id=? AND soa_id=?",
+ (objective_id, soa_id),
+ )
+ row = cur.fetchone()
+ if not row:
+ conn.close()
+ raise HTTPException(404, "Objective not found")
+ before = _row_to_dict(row)
+
+ # Orphan child endpoints: set objective_uid to NULL
+ orphaned: list[dict] = []
+ cur.execute(
+ "SELECT id,endpoint_uid,objective_uid FROM endpoint "
+ "WHERE soa_id=? AND objective_uid=?",
+ (soa_id, before["objective_uid"]),
+ )
+ for ep_id, ep_uid, ep_parent in cur.fetchall():
+ orphaned.append(
+ {
+ "id": ep_id,
+ "endpoint_uid": ep_uid,
+ "objective_uid_before": ep_parent,
+ }
+ )
+ if orphaned:
+ cur.execute(
+ "UPDATE endpoint SET objective_uid=NULL WHERE soa_id=? AND objective_uid=?",
+ (soa_id, before["objective_uid"]),
+ )
+
+ _delete_level_code(cur, soa_id, before["level_code_uid"])
+ cur.execute(
+ "DELETE FROM objective WHERE id=? AND soa_id=?",
+ (objective_id, soa_id),
+ )
+
+ # Reindex remaining objectives
+ cur.execute(
+ "SELECT id FROM objective WHERE soa_id=? ORDER BY order_index, id",
+ (soa_id,),
+ )
+ remaining = [r[0] for r in cur.fetchall()]
+ for idx, oid in enumerate(remaining, start=1):
+ cur.execute("UPDATE objective SET order_index=? WHERE id=?", (idx, oid))
+ conn.commit()
+ conn.close()
+
+ _record_objective_audit(soa_id, "delete", objective_id, before=before, after=None)
+ for entry in orphaned:
+ _record_endpoint_audit(
+ soa_id,
+ "update",
+ entry["id"],
+ before={
+ "endpoint_uid": entry["endpoint_uid"],
+ "objective_uid": entry["objective_uid_before"],
+ },
+ after={
+ "endpoint_uid": entry["endpoint_uid"],
+ "objective_uid": None,
+ "orphaned_by_objective_delete": before["objective_uid"],
+ },
+ )
+ return JSONResponse({"deleted": objective_id, "orphaned_endpoints": len(orphaned)})
+
+
+# ---------------------------------------------------------------------------
+# UI form endpoints
+# ---------------------------------------------------------------------------
+
+
+@ui_router.post("/ui/soa/{soa_id}/objectives/create", response_class=HTMLResponse)
+def ui_create_objective(
+ request: Request,
+ soa_id: int,
+ name: str = Form(...),
+ level: str = Form(...),
+ label: str | None = Form(None),
+ description: str | None = Form(None),
+ text: str | None = Form(None),
+):
+ if not soa_exists(soa_id):
+ raise HTTPException(404, "SOA not found")
+ create_objective(
+ soa_id,
+ ObjectiveCreate(
+ name=name,
+ level=level,
+ label=label,
+ description=description,
+ text=text,
+ ),
+ )
+ redirect_url = f"/ui/soa/{soa_id}/edit"
+ if request.headers.get("HX-Request") == "true":
+ return HTMLResponse("", headers={"HX-Redirect": redirect_url})
+ return RedirectResponse(redirect_url, status_code=303)
+
+
+@ui_router.post(
+ "/ui/soa/{soa_id}/objectives/{objective_id}/update",
+ response_class=HTMLResponse,
+)
+def ui_update_objective(
+ request: Request,
+ soa_id: int,
+ objective_id: int,
+ name: str | None = Form(None),
+ level: str | None = Form(None),
+ label: str | None = Form(None),
+ description: str | None = Form(None),
+ text: str | None = Form(None),
+):
+ if not soa_exists(soa_id):
+ raise HTTPException(404, "SOA not found")
+ update_objective(
+ soa_id,
+ objective_id,
+ ObjectiveUpdate(
+ name=name,
+ level=level,
+ label=label,
+ description=description,
+ text=text,
+ ),
+ )
+ redirect_url = f"/ui/soa/{soa_id}/edit"
+ if request.headers.get("HX-Request") == "true":
+ return HTMLResponse("", headers={"HX-Redirect": redirect_url})
+ return RedirectResponse(redirect_url, status_code=303)
+
+
+@ui_router.post(
+ "/ui/soa/{soa_id}/objectives/{objective_id}/delete",
+ response_class=HTMLResponse,
+)
+def ui_delete_objective(
+ request: Request,
+ soa_id: int,
+ objective_id: int,
+):
+ if not soa_exists(soa_id):
+ raise HTTPException(404, "SOA not found")
+ delete_objective(soa_id, objective_id)
+ redirect_url = f"/ui/soa/{soa_id}/edit"
+ if request.headers.get("HX-Request") == "true":
+ return HTMLResponse("", headers={"HX-Redirect": redirect_url})
+ return RedirectResponse(redirect_url, status_code=303)
diff --git a/src/soa_builder/web/schemas.py b/src/soa_builder/web/schemas.py
index 75002d0..276acdb 100644
--- a/src/soa_builder/web/schemas.py
+++ b/src/soa_builder/web/schemas.py
@@ -267,6 +267,42 @@ class ConceptsUpdate(BaseModel):
concept_codes: List[str]
+class ObjectiveCreate(BaseModel):
+ name: str
+ level: str
+ label: Optional[str] = None
+ description: Optional[str] = None
+ text: Optional[str] = None
+
+
+class ObjectiveUpdate(BaseModel):
+ name: Optional[str] = None
+ label: Optional[str] = None
+ description: Optional[str] = None
+ text: Optional[str] = None
+ level: Optional[str] = None
+
+
+class EndpointCreate(BaseModel):
+ name: str
+ level: str
+ objective_uid: str
+ label: Optional[str] = None
+ description: Optional[str] = None
+ text: Optional[str] = None
+ purpose: Optional[str] = None
+
+
+class EndpointUpdate(BaseModel):
+ name: Optional[str] = None
+ objective_uid: Optional[str] = None
+ label: Optional[str] = None
+ description: Optional[str] = None
+ text: Optional[str] = None
+ purpose: Optional[str] = None
+ level: Optional[str] = None
+
+
class FreezeCreate(BaseModel):
version_label: Optional[str] = None
diff --git a/src/soa_builder/web/templates/_objectives_section.html b/src/soa_builder/web/templates/_objectives_section.html
new file mode 100644
index 0000000..af7e12f
--- /dev/null
+++ b/src/soa_builder/web/templates/_objectives_section.html
@@ -0,0 +1,209 @@
+
+
+
+ Objectives ({{ objectives|length }}) · Endpoints ({{ endpoints_by_objective.values()|map('length')|sum + orphan_endpoints|length }})
+
+ {% if objectives|length == 0 and orphan_endpoints|length == 0 %}
+ No objectives defined yet.
+ {% endif %}
+
+ {% for obj in objectives %}
+
+
+ {% if obj.description %}
{{ obj.description }}
{% endif %}
+ {% if obj.text %}
{{ obj.text }}
{% endif %}
+
+
+
+
+
+ {% set obj_endpoints = endpoints_by_objective.get(obj.objective_uid, []) %}
+ {% if obj_endpoints %}
+
+ {% endif %}
+
+ {% endfor %}
+
+ {% if orphan_endpoints %}
+
+
Unassigned Endpoints ({{ orphan_endpoints|length }})
+
+ {% for ep in orphan_endpoints %}
+
+ {{ ep.endpoint_uid }} — {{ ep.name }}
+ {% if ep.level %}[{{ ep.level }}] {% endif %}
+
+
+
+
+
+ {% endfor %}
+
+
+ {% endif %}
+
+
+ Create Objective
+
+
+
+
+
diff --git a/src/soa_builder/web/templates/edit.html b/src/soa_builder/web/templates/edit.html
index 48938e5..d2bdf3b 100644
--- a/src/soa_builder/web/templates/edit.html
+++ b/src/soa_builder/web/templates/edit.html
@@ -125,6 +125,9 @@ Editing SoA for {% if study_label %}{{ study_label }}{% else %}{{ study_id }
+
+{% include '_objectives_section.html' %}
+
-
-
- Objectives ({{ objectives|length }}) · Endpoints ({{ endpoints_by_objective.values()|map('length')|sum + orphan_endpoints|length }})
-
- {% if objectives|length == 0 and orphan_endpoints|length == 0 %}
- No objectives defined yet.
- {% endif %}
-
- {% for obj in objectives %}
-
-
- {% if obj.description %}
{{ obj.description }}
{% endif %}
- {% if obj.text %}
{{ obj.text }}
{% endif %}
-
-
-
-
-
- {% set obj_endpoints = endpoints_by_objective.get(obj.objective_uid, []) %}
- {% if obj_endpoints %}
-
- {% endif %}
-
- {% endfor %}
-
- {% if orphan_endpoints %}
-
-
Unassigned Endpoints ({{ orphan_endpoints|length }})
-
- {% for ep in orphan_endpoints %}
-
- {{ ep.endpoint_uid }} — {{ ep.name }}
- {% if ep.level %}[{{ ep.level }}] {% endif %}
-
-
-
-
-
- {% endfor %}
-
-
- {% endif %}
-
-
- Create Objective
-
-
-
-
-
diff --git a/src/soa_builder/web/templates/base.html b/src/soa_builder/web/templates/base.html
index df116e7..bff0819 100644
--- a/src/soa_builder/web/templates/base.html
+++ b/src/soa_builder/web/templates/base.html
@@ -17,6 +17,7 @@
✍🏻 Study Design
-
-Total Rows: {{ total_count }} | Matched: {{ matched_count }} | Showing limit {{ limit }} offset {{ offset }}
+
+
+
+{% if error %}
+
+ Error: {{ error }}
+
+{% endif %}
+
+
+
+
+
+
+ Total: {{ total_count }} | Matched: {{ matched_count }} | Showing limit {{ limit }} offset {{ offset }}
+
+
{% if rows %}
-
-
-
- {% for col in columns %}
- {{ col }}
- {% endfor %}
-
-
-
+
+
+ {% for col in columns %}{{ col }} {% endfor %}
+
{% for r in rows %}
-
- {% for col in columns %}
- {{ r[col] }}
- {% endfor %}
-
+ {% for col in columns %}{{ r[col] }} {% endfor %}
{% endfor %}
-
{% else %}
-No rows match current filters.
+No rows match current filters.
{% endif %}
+
{% if matched_count > limit %}
-
- {% set next_offset = offset + limit %}
- {% if next_offset < matched_count %}
-
- {% endif %}
-
+{% set next_offset = offset + limit %}
+{% if next_offset < matched_count %}
+
+
+
+{% endif %}
{% endif %}
{% endblock %}
diff --git a/src/soa_builder/web/templates/define_xml_terminology.html b/src/soa_builder/web/templates/define_xml_terminology.html
index 1038e2e..f6d70a1 100644
--- a/src/soa_builder/web/templates/define_xml_terminology.html
+++ b/src/soa_builder/web/templates/define_xml_terminology.html
@@ -1,57 +1,84 @@
{% extends 'base.html' %}
{% block content %}
Define-XML Controlled Terminology{% if slug %} — {{ slug }}{% else %} — (unavailable){% endif %}
-{% if error %}Error: {{ error }}
{% endif %}
-
-
-Total Rows: {{ total_count }} | Matched: {{ matched_count }} | Showing limit {{ limit }} offset {{ offset }}
+
+
+
+{% if error %}
+
+ Error: {{ error }}
+
+{% endif %}
+
+
+
+
+
+
+ Total: {{ total_count }} | Matched: {{ matched_count }} | Showing limit {{ limit }} offset {{ offset }}
+
+
{% if rows %}
-
-
-
- {% for col in columns %}
- {{ col }}
- {% endfor %}
-
-
-
+
+
+ {% for col in columns %}{{ col }} {% endfor %}
+
{% for r in rows %}
-
- {% for col in columns %}
- {{ r[col] }}
- {% endfor %}
-
+ {% for col in columns %}{{ r[col] }} {% endfor %}
{% endfor %}
-
{% else %}
-No rows match current filters.
+No rows match current filters.
{% endif %}
+
{% if matched_count > limit %}
-
- {% set next_offset = offset + limit %}
- {% if next_offset < matched_count %}
-
- {% endif %}
-
+{% set next_offset = offset + limit %}
+{% if next_offset < matched_count %}
+
+
+
+{% endif %}
{% endif %}
{% endblock %}
diff --git a/src/soa_builder/web/templates/dss_cell.html b/src/soa_builder/web/templates/dss_cell.html
index 35d92fb..074f1b1 100644
--- a/src/soa_builder/web/templates/dss_cell.html
+++ b/src/soa_builder/web/templates/dss_cell.html
@@ -37,24 +37,24 @@
hx-swap="innerHTML">
Select DSS...
- +
+ +
-
+
{% for assigned in ac.assigned_dss %}
-
+
{{ assigned.dss_display }}
+ style="flex:1;overflow:hidden;text-overflow:ellipsis;
+ white-space:nowrap;color:#224;">{{ assigned.dss_display }}
+
details
-
properties
{% endfor %}
diff --git a/src/soa_builder/web/templates/edit.html b/src/soa_builder/web/templates/edit.html
index f534842..f76fe9f 100644
--- a/src/soa_builder/web/templates/edit.html
+++ b/src/soa_builder/web/templates/edit.html
@@ -1,31 +1,31 @@
{% extends 'base.html' %}
{% block content %}
Editing SoA for {% if study_label %}{{ study_label }}{% else %}{{ study_id }}{% endif %}
-