From a78f8d0d721603e2375561f49d332dfdff325d39 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:28:32 -0500 Subject: [PATCH 01/19] New USDM JSON generator for encounters --- src/usdm/generate_encounters.py | 145 ++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 src/usdm/generate_encounters.py diff --git a/src/usdm/generate_encounters.py b/src/usdm/generate_encounters.py new file mode 100644 index 0000000..06f96b0 --- /dev/null +++ b/src/usdm/generate_encounters.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# Prefer absolute import; fallback to adding src/ to sys.path when run directly +from typing import Optional, List, Dict, Any + +try: + from soa_builder.web.app import _connect # reuse existing DB connector +except ImportError: + import sys + from pathlib import Path + + here = Path(__file__).resolve() + src_dir = here.parents[2] / "src" + if src_dir.exists() and str(src_dir) not in sys.path: + sys.path.insert(0, str(src_dir)) + from soa_builder.web.app import _connect # type: ignore + + +def _nz(s: Optional[str]) -> Optional[str]: + s = (s or "").strip() + return s or None + + +def build_usdm_encounters(soa_id: int) -> List[Dict[str, Any]]: + """ + Build USDM Encounters-Output objects for the given SOA + + USDM Encounters-Output (subset): + - id: string + - extensionAttributes?: string[] + - name: string + - label?: string + - description?: string + - type?: { + - id: string + - extensionAttributes: [] + - code: string + - codeSystem: string + - codeSystemVersion: string + - decode: string + - instanceType: "Code" + } + - previousId?: string + - nextId?: string + - scheduledAtId?: string + - environmentalSettings?: [ + { + - id: string + - extensionAttributes: [] + - code: string -- I do not know from which codelist these codes originate + - codeSystem: string + - codeSystemVersion: string + - decode: string + - instanceType: "Code" + }, + ] + - contactModes: [] + - transitionStartRule?: {} + - transitionEndRule": {} + - notes: [] + - instanceType: "Encounter" + """ + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT name,label,order_index,encounter_uid,description FROM visit WHERE soa_id=?", + (soa_id,), + ) + rows = cur.fetchall() + conn.close() + + uids = [r[3] for r in rows] + id_by_index = {i: uid for i, uid in enumerate(uids)} + print(id_by_index) + + out: List[Dict[str, Any]] = [] + + for i, r in enumerate(rows): + name, label, order_index, encounter_uid, description = ( + r[0], + r[1], + r[2], + r[3], + r[4], + ) + eid = encounter_uid + prev_id = id_by_index.get(i - 1) + next_id = id_by_index.get(i + 1) + + encounter = { + "id": eid, + "extensionAttributes": [], + "name": name, + "label": _nz(label), + "description": _nz(description), + "type": { + "id": "", + "extensionAttributes": [], + "code": "C25716", + "codeSystem": "db://ddf_terminology", + "codeSystemVersion": "2025-09-26", + "decode": "Visit", + "instanceType": "Code", + }, + "previousId": prev_id, + "nextId": next_id, + "scheduledAt": "", + "environmentSettings": [], + "contactModes": [], + "transitionStartRule": {}, + "transitionEndRule": {}, + "notes": [], + "instanceType": "Encounter", + } + out.append(encounter) + return out + + +if __name__ == "__main__": + import argparse + import json + import logging + import sys + + logger = logging.getLogger("usdm.generate_encounters") + + parser = argparse.ArgumentParser(description="Export USDM Encounters for a SOA.") + parser.add_argument("soa_id", type=int, help="SOA id to export Encounters for") + parser.add_argument( + "-o", "--output", default="-", help="Output file path or '-' for stdout" + ) + parser.add_argument("--indent", type=int, default=2, help="JSON indent") + args = parser.parse_args() + + try: + activities = build_usdm_encounters(args.soa_id) + except Exception: + logger.exception("Failed to build Encounters for soa_id=%s", args.soa_id) + sys.exit(1) + + payload = json.dumps(activities, indent=args.indent) + if args.output in ("-", "/dev/stdout"): + sys.stdout.write(payload + "\n") + else: + with open(args.output, "w", encoding="utf-8") as f: + f.write(payload + "\n") From 6623d73c49e729660f231baa0a9258679565f636 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Mon, 22 Dec 2025 08:39:30 -0500 Subject: [PATCH 02/19] Always pick max(existing) + 1, do not fill gaps --- src/soa_builder/web/routers/arms.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/soa_builder/web/routers/arms.py b/src/soa_builder/web/routers/arms.py index 53f2a7c..f5330f6 100644 --- a/src/soa_builder/web/routers/arms.py +++ b/src/soa_builder/web/routers/arms.py @@ -72,10 +72,11 @@ def create_arm(soa_id: int, payload: ArmCreate): "Invalid arm_uid format encountered (ignored for numbering): %s", uid, ) - next_n = 1 - while next_n in used_nums: - next_n += 1 + + # Always pick max(existing) + 1, do not fill gaps + next_n = (max(used_nums) if used_nums else 0) + 1 new_uid = f"StudyArm_{next_n}" + cur.execute( """INSERT INTO arm (soa_id,name,label,description,type,data_origin_type,order_index,arm_uid) VALUES (?,?,?,?,?,?,?,?)""", From fc1a5b758127a3fcf7fbcf3ddc6631486e4b2099 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Mon, 22 Dec 2025 13:39:42 -0500 Subject: [PATCH 03/19] New function for returning {conceptCode: submissionValue} for given c odelist_code --- src/soa_builder/web/utils.py | 66 ++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/src/soa_builder/web/utils.py b/src/soa_builder/web/utils.py index 6b5a1af..0296f79 100644 --- a/src/soa_builder/web/utils.py +++ b/src/soa_builder/web/utils.py @@ -332,3 +332,69 @@ def get_epoch_uid(soa_id: int) -> Dict[str, str]: rows = cur.fetchall() conn.close() return {str(name): str(epoch_uid) for (name, epoch_uid) in rows if name is not None} + + +def get_sdtm_submission_values(url: str, codelist_code: str) -> Dict[str, str]: + """Return a mapping of {conceptId: submissionValue} from the CDISC Library + for the given codelist_code. `url` should be the codelists base endpoint. + """ + full_url = f"{url.rstrip('/')}/{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 os.environ.get( + "CDISC_SUBSCRIPTION_KEY" + ) + unified_key = subscription_key or api_key + if unified_key: + headers["Ocp-Apim-Subscription-Key"] = unified_key + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + headers["api-key"] = api_key + + mapping: Dict[str, str] = {} + try: + resp = requests.get(full_url, headers=headers, timeout=10) + if resp.status_code != 200: + return {} + data = resp.json() or {} + + # Prefer top-level 'terms'; fall back to HAL-style links + terms: List[dict] = [] + if isinstance(data, dict) and isinstance(data.get("terms"), list): + terms = data.get("terms") or [] + elif isinstance(data, list): + terms = data + else: + terms = data.get("_links", {}).get("terms", []) or [] + + for t in terms: + if not isinstance(t, dict): + continue + code = t.get("conceptId") or t.get("code") or t.get("termCode") + sv = t.get("submissionValue") or t.get("cdisc_submission_value") + if code and sv: + mapping[str(code)] = str(sv).strip() + continue + + # If only a link is provided, follow it to resolve fields + href = t.get("href") or t.get("_href") + if not href: + linkself = t.get("_links", {}).get("self", {}) + href = linkself.get("href") if isinstance(linkself, dict) else None + if href: + try: + tr = requests.get(href, headers=headers, timeout=10) + if tr.status_code == 200: + tj = tr.json() or {} + code2 = tj.get("conceptId") or tj.get("code") or code + sv2 = tj.get("submissionValue") or tj.get( + "cdisc_submission_value" + ) + if code2 and sv2: + mapping[str(code2)] = str(sv2).strip() + except Exception: + pass + + return mapping + except Exception: + return {} From 7719391a5b88b20e1c65df6026cd5c52a21125fe Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Mon, 22 Dec 2025 15:15:37 -0500 Subject: [PATCH 04/19] Additional columns added to table --- src/soa_builder/web/migrate_database.py | 46 +++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/soa_builder/web/migrate_database.py b/src/soa_builder/web/migrate_database.py index 30b3396..d820d0d 100644 --- a/src/soa_builder/web/migrate_database.py +++ b/src/soa_builder/web/migrate_database.py @@ -871,3 +871,49 @@ def _backfill_dataset_date(table: str, audit_table: str): conn.close() except Exception as e: # pragma: no cover logger.warning("dataset_date backfill for %s failed: %s", table, e) + + +def _migrate_visit_columns(): + """Add missing columns to the database table `visit` + New columns: + - description: string + - type: string + - environmentalSettings: string[] + - contactModes: string[] + - transitionStartRule: string + - transitionEndRule: string + + (environmentalSettings & contactModes are officially list but + are only single string values in the first iteration of the app + """ + try: + conn = _connect() + cur = conn.cursor() + cur.execute("PRAGMA table_info(visit)") + cols = {r[1] for r in cur.fetchall()} + alters = [] + if "description" not in cols: + alters.append("ALTER TABLE visit ADD COLUMN description TEXT") + if "type" not in cols: + alters.append("ALTER TABLE visit ADD COLUMN type TEXT") + if "environmentalSettings" not in cols: + alters.append("ALTER TABLE visit ADD COLUMN environmentalSettings TEXT") + if "contactModes" not in cols: + alters.append("ALTER TABLE visit ADD COLUMN contactModes TEXT") + if "transitionStartRule" not in cols: + alters.append("ALTER TABLE visit ADD COLUMN transitionStartRule TEXT") + if "transitionEndRule" not in cols: + alters.append("ALTER TABLE visit ADD COLUMN transitionEndRule TEXT") + if "scheduledAtId" not in cols: + alters.append("ALTER TABLE visit ADD COLUMN scheduledAtId TEXT") + for stmt in alters: + try: + cur.execute(stmt) + except Exception as e: # pragma: no cover + logger.warning("Failed visit field migration '%s': %s", stmt, e) + if alters: + conn.commit() + logger.info("Applied visit column migration: %s", ", ".join(alters)) + conn.close() + except Exception as e: # pragma: no cover + logger.warning("visit table migration failed: %s", e) From 0afc0425d4b6cbd1aba514d0150385ad1f8aaaa2 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Mon, 22 Dec 2025 15:17:29 -0500 Subject: [PATCH 05/19] Created Code_N for Visit on CREATE --- src/soa_builder/web/app.py | 14 +++++++++++--- src/soa_builder/web/routers/visits.py | 25 +++++++++++++++++++------ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py index 109edaf..fb353d1 100644 --- a/src/soa_builder/web/app.py +++ b/src/soa_builder/web/app.py @@ -60,6 +60,7 @@ _migrate_rename_cell_table, _migrate_rollback_add_elements_restored, _migrate_add_epoch_type, + _migrate_visit_columns, ) from .routers import activities as activities_router from .routers import arms as arms_router @@ -155,6 +156,7 @@ def _configure_logging(): # Database migration steps +_migrate_visit_columns() _migrate_add_epoch_type() _migrate_add_arm_uid() _migrate_drop_arm_element_link() @@ -3945,15 +3947,19 @@ def ui_add_visit( epoch_id: Optional[str] = Form(None), description: Optional[str] = Form(None), ): + if not soa_exists(soa_id): + raise HTTPException(404, "SOA not found") + # Coerce empty epoch_id from form to None, otherwise to int parsed_epoch_id: Optional[int] = None if epoch_id is not None: eid = str(epoch_id).strip() - if eid != "": + if eid: try: parsed_epoch_id = int(eid) except ValueError: parsed_epoch_id = None + payload = VisitCreate( name=name, label=label, @@ -3964,7 +3970,6 @@ def ui_add_visit( try: visits_router.add_visit(soa_id, payload) except Exception: - # Swallow and continue redirect; detailed errors are handled by API logs pass return HTMLResponse( @@ -4129,7 +4134,9 @@ async def ui_add_arm( conn.commit() # routers.arms.create_arm already records a create audit; avoid duplicating here conn.close() - return HTMLResponse(f"") + return HTMLResponse( + f"" + ) @app.post("/ui/soa/{soa_id}/update_arm", response_class=HTMLResponse) @@ -7059,4 +7066,5 @@ def main(): # pragma: no cover if __name__ == "__main__": # pragma: no cover + main() diff --git a/src/soa_builder/web/routers/visits.py b/src/soa_builder/web/routers/visits.py index ad10ff9..53829d4 100644 --- a/src/soa_builder/web/routers/visits.py +++ b/src/soa_builder/web/routers/visits.py @@ -6,7 +6,7 @@ from ..audit import _record_reorder_audit, _record_visit_audit from ..db import _connect -from ..utils import soa_exists +from ..utils import soa_exists, get_next_code_uid as _get_next_code_uid from ..schemas import VisitCreate, VisitUpdate router = APIRouter(prefix="/soa/{soa_id}") @@ -84,11 +84,8 @@ def add_visit(soa_id: int, payload: VisitCreate): conn = _connect() cur = conn.cursor() - # Replace existing block with new block to create new encounter_uid and increment order_index - # cur.execute("SELECT COUNT(*) FROM visit WHERE soa_id=?", (soa_id,)) - # order_index = cur.fetchone()[0] + 1 - # New code to calculate order_index + # order_index cur.execute( "SELECT COALESCE(MAX(order_index),0) FROM visit WHERE soa_id=?", (soa_id,), @@ -123,8 +120,23 @@ def add_visit(soa_id: int, payload: VisitCreate): conn.close() raise HTTPException(400, "Invalid epoch_id for this SOA") + # Generate Code_{N} for encounter.type + type_uid = _get_next_code_uid(cur, soa_id) + + if type_uid: + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + type_uid, + "ddf_terminology", + "C188728", + "C25716", + ), + ) + cur.execute( - "INSERT INTO visit (soa_id,name,label,order_index,epoch_id,encounter_uid,description) VALUES (?,?,?,?,?,?,?)", + "INSERT INTO visit (soa_id,name,label,order_index,epoch_id,encounter_uid,description,type) VALUES (?,?,?,?,?,?,?,?)", ( soa_id, name, @@ -133,6 +145,7 @@ def add_visit(soa_id: int, payload: VisitCreate): payload.epoch_id, new_uid, _nz(payload.description), + type_uid, ), ) encounter_id = cur.lastrowid From 40d61a810de0885d7b3c01abc1fb839856551412 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Mon, 22 Dec 2025 15:53:41 -0500 Subject: [PATCH 06/19] USDM JSON populated --- src/usdm/generate_encounters.py | 42 ++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/src/usdm/generate_encounters.py b/src/usdm/generate_encounters.py index 06f96b0..cbae458 100644 --- a/src/usdm/generate_encounters.py +++ b/src/usdm/generate_encounters.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # Prefer absolute import; fallback to adding src/ to sys.path when run directly -from typing import Optional, List, Dict, Any +from typing import Optional, List, Dict, Any, Tuple try: from soa_builder.web.app import _connect # reuse existing DB connector @@ -20,6 +20,28 @@ def _nz(s: Optional[str]) -> Optional[str]: return s or None +def _get_type_code_tuple(soa_id: int, code_uid: str) -> Tuple[str, str, str, str]: + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT DISTINCT c.codelist_table, p.code,p.cdisc_submission_value,p.dataset_date " + "FROM code c INNER JOIN ddf_terminology p ON c.codelist_code = p.codelist_code " + "AND c.code = p.code WHERE c.soa_id=? AND c.code_uid=?", + ( + soa_id, + code_uid, + ), + ) + rows = cur.fetchall() + conn.close() + code_system = [r[0] for r in rows] + code_code = [r[1] for r in rows] + code_decode = [r[2] for r in rows] + code_system_version = [r[3] for r in rows] + + return code_code, code_decode, code_system, code_system_version + + def build_usdm_encounters(soa_id: int) -> List[Dict[str, Any]]: """ Build USDM Encounters-Output objects for the given SOA @@ -62,7 +84,7 @@ def build_usdm_encounters(soa_id: int) -> List[Dict[str, Any]]: conn = _connect() cur = conn.cursor() cur.execute( - "SELECT name,label,order_index,encounter_uid,description FROM visit WHERE soa_id=?", + "SELECT name,label,order_index,encounter_uid,description,type FROM visit WHERE soa_id=?", (soa_id,), ) rows = cur.fetchall() @@ -75,14 +97,18 @@ def build_usdm_encounters(soa_id: int) -> List[Dict[str, Any]]: out: List[Dict[str, Any]] = [] for i, r in enumerate(rows): - name, label, order_index, encounter_uid, description = ( + name, label, order_index, encounter_uid, description, type = ( r[0], r[1], r[2], r[3], r[4], + r[5], ) eid = encounter_uid + t_code, t_decode, t_codeSystem, t_codeSystemVersion = _get_type_code_tuple( + soa_id, type + ) prev_id = id_by_index.get(i - 1) next_id = id_by_index.get(i + 1) @@ -93,12 +119,12 @@ def build_usdm_encounters(soa_id: int) -> List[Dict[str, Any]]: "label": _nz(label), "description": _nz(description), "type": { - "id": "", + "id": type, "extensionAttributes": [], - "code": "C25716", - "codeSystem": "db://ddf_terminology", - "codeSystemVersion": "2025-09-26", - "decode": "Visit", + "code": t_code[0], + "codeSystem": "db://" + t_codeSystem[0], + "codeSystemVersion": t_codeSystemVersion[0], + "decode": t_decode[0], "instanceType": "Code", }, "previousId": prev_id, From dee2d36488529da9704d6621bbafb51199ee288d Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:10:54 -0500 Subject: [PATCH 07/19] Added SOA Workbench Wishlist.docx --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 093fae6..e52d566 100644 --- a/.gitignore +++ b/.gitignore @@ -96,5 +96,6 @@ old-tests/ docs/~* files/~* output/* +SOA Workbench Wishlist.docx # End of file From f4c813a1e5427205df0d360e1d6715675fcbf159 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:18:57 -0500 Subject: [PATCH 08/19] New helper functions for encounters --- src/soa_builder/web/utils.py | 38 ++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/soa_builder/web/utils.py b/src/soa_builder/web/utils.py index 0296f79..c330cf8 100644 --- a/src/soa_builder/web/utils.py +++ b/src/soa_builder/web/utils.py @@ -398,3 +398,41 @@ def get_sdtm_submission_values(url: str, codelist_code: str) -> Dict[str, str]: return mapping except Exception: return {} + + +def get_study_timings(soa_id: int) -> Dict[str, str]: + """Return a Dict of {name: timing_uid} from the database + `timing` table for the SOA + + """ + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT name,timing_uid from timing WHERE soa_id=? ORDER BY name", + (soa_id,), + ) + rows = cur.fetchall() + conn.close() + return { + str(name): str(timing_uid) for (name, timing_uid) in rows if name is not None + } + + +def get_study_transition_rules(soa_id: int) -> Dict[str, str]: + """Return a Dict of {name: transition_rule_uid} from teh database + `transition_rule` table for the SOA + + """ + conn = _connect() + cur = conn.cursor() + cur.execute( + "SELECT name,transition_rule_uid from transition_rule WHERE soa_id=? ORDER BY name", + (soa_id,), + ) + rows = cur.fetchall() + conn.close() + return { + str(name): str(transition_rule_uid) + for (name, transition_rule_uid) in rows + if name is not None + } From a9a7c4130f20c38af6ed8a3349948ea921b5fd74 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:32:24 -0500 Subject: [PATCH 09/19] Moved all editors in-line --- src/soa_builder/web/templates/edit.html | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/soa_builder/web/templates/edit.html b/src/soa_builder/web/templates/edit.html index e06f94e..f6b7c73 100644 --- a/src/soa_builder/web/templates/edit.html +++ b/src/soa_builder/web/templates/edit.html @@ -156,8 +156,7 @@

Editing SoA {{ soa_id }}

- -
+
Epochs ({{ epochs|length }}) (drag to reorder)
    From e52b231b8d771170601c2e97b4d797dc06ee24ce Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:49:19 -0500 Subject: [PATCH 10/19] Aligned Save,Delete to the right of editor --- src/soa_builder/web/templates/edit.html | 32 ++++++++++++++----------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/soa_builder/web/templates/edit.html b/src/soa_builder/web/templates/edit.html index f6b7c73..b092ed4 100644 --- a/src/soa_builder/web/templates/edit.html +++ b/src/soa_builder/web/templates/edit.html @@ -91,9 +91,9 @@

    Editing SoA {{ soa_id }}

    @@ -134,18 +134,21 @@

    Editing SoA {{ soa_id }}

    {% for a in activities %}
  • {{ a.order_index }}. {{ a.name }} - -
    - - -
    -
    - - - - - -
    + +
    + + + + + +
    +
    + + +
    +
  • {% endfor %}
@@ -545,6 +548,7 @@

Matrix

.drag-item.over { border-color:#1976d2; background:#e3f2fd; } .drag-item form { margin-left:0; } .visit-actions { display:inline-flex; align-items:center; gap:8px; margin-left:auto; } + .activity-actions { display:inline-flex; align-items:center; gap:8px; margin-left:auto; } .hint { font-weight:400; font-size:0.7em; color:#666; } -
+
`

SoA Workbench

-
-
- Activity Audit (latest {{ activity_audits|length }}) - {% if activity_audits %} - - - - - - - - - - {% for au in activity_audits %} - - - - - - - - - {% endfor %} -
IDActivityActionPerformedBeforeAfter
{{ au.id }}{{ au.activity_id }}{{ au.action }}{{ au.performed_at }}{{ au.before_json or '' }}{{ au.after_json or '' }}
- {% else %} -
No activity audit entries yet.
- {% endif %} -
-
- 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 %} -
-
- Epoch Audit (latest {{ epoch_audits|length }}) - {% if epoch_audits %} - - - - - - - - - - {% for au in epoch_audits %} - - - - - - - - - {% endfor %} -
IDEpochActionPerformedBeforeAfter
{{ au.id }}{{ au.epoch_id }}{{ au.action }}{{ au.performed_at }}{{ au.before_json or '' }}{{ au.after_json or '' }}
- {% else %} -
No epoch audit entries yet.
- {% endif %} -
-
- Study Cell Audit (latest {{ study_cell_audits|length }}) - {% if study_cell_audits %} - - - - - - - - - - {% for au in study_cell_audits %} - - - - - - - - - {% endfor %} -
IDStudy CellActionPerformedBeforeAfter
{{ au.id }}{{ au.study_cell_id }}{{ au.action }}{{ au.performed_at }}{{ au.before_json or '' }}{{ au.after_json or '' }}
- {% else %} -
No study cell audit entries yet.
- {% endif %} -
- {% include 'element_audit_section.html' %} -
+

Matrix

diff --git a/src/soa_builder/web/templates/index.html b/src/soa_builder/web/templates/index.html index e3b28d1..973bdd9 100644 --- a/src/soa_builder/web/templates/index.html +++ b/src/soa_builder/web/templates/index.html @@ -10,6 +10,7 @@

Existing Studies

Description Created Actions + Audits {% for s in soas %} @@ -20,6 +21,7 @@

Existing Studies

{{ s.study_description or '' }} {{ s.created_at }} Edit + Audits {% endfor %} From b9b7ad9a7af82a0e518b1574d53eda32055dba4f Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Tue, 23 Dec 2025 14:21:21 -0500 Subject: [PATCH 13/19] Formatting of transition-rules-list --- src/soa_builder/web/templates/base.html | 2 +- src/soa_builder/web/templates/edit.html | 135 +++++++++++------- .../web/templates/transition_rules_list.html | 30 ++-- 3 files changed, 100 insertions(+), 67 deletions(-) diff --git a/src/soa_builder/web/templates/base.html b/src/soa_builder/web/templates/base.html index f8ec0b6..d0ccab6 100644 --- a/src/soa_builder/web/templates/base.html +++ b/src/soa_builder/web/templates/base.html @@ -7,7 +7,7 @@ -
` +

SoA Workbench