diff --git a/src/soa_builder/web/routers/visits.py b/src/soa_builder/web/routers/visits.py index 81b507a..45059a9 100644 --- a/src/soa_builder/web/routers/visits.py +++ b/src/soa_builder/web/routers/visits.py @@ -11,11 +11,11 @@ soa_exists, get_next_code_uid as _get_next_code_uid, get_study_transition_rules, - get_epoch_id, get_timing_id, get_encounter_type_sv, load_environmental_setting_options, get_latest_sdtm_ct_href, + load_contact_mode_options, ) from ..schemas import VisitCreate, VisitUpdate from fastapi.templating import Jinja2Templates @@ -43,7 +43,7 @@ def list_visits(soa_id: int): cur.execute( """ SELECT id,encounter_uid,name,label,description,type,environmentalSettings,transitionStartRule, - transitionEndRule,epoch_id,scheduledAtId,order_index FROM visit WHERE soa_id=? ORDER BY order_index, id + transitionEndRule,scheduledAtId,order_index,contactModes FROM visit WHERE soa_id=? ORDER BY order_index, id """, (soa_id,), ) @@ -58,9 +58,9 @@ def list_visits(soa_id: int): "environmentalSettings": r[6], "transitionStartRule": r[7], "transitionEndRule": r[8], - "epoch_id": r[9], - "scheduledAtId": r[10], - "order_index": r[11], + "scheduledAtId": r[9], + "order_index": r[10], + "contactModes": r[11], } for r in cur.fetchall() ] @@ -93,6 +93,12 @@ def ui_list_visits(request: Request, soa_id: int): for opt in environmental_setting_options } + contact_mode_options = load_contact_mode_options() + con_mode_lookup = { + str(opt["conceptId"]).strip(): str(opt["submissionValue"]).strip() + for opt in contact_mode_options + } + encounters = list_visits(soa_id) for e in encounters: tsv = get_encounter_type_sv(soa_id, e.get("type") or "") @@ -104,8 +110,14 @@ def ui_list_visits(request: Request, soa_id: int): e["environmental_concept_id"] = concept_id e["environmental_submission_value"] = env_option_lookup.get(concept_id) + contact_code_uid = e.get("contactModes") or "" + contact_concept_id = ( + code_map.get(contact_code_uid, "") if contact_code_uid else "" + ) + e["contact_mode_concept_id"] = contact_concept_id + e["contact_mode_submission_value"] = con_mode_lookup.get(contact_concept_id) + transition_rule_options = get_study_transition_rules(soa_id) - epoch_options = get_epoch_id(soa_id) timing_options = get_timing_id(soa_id) logger.info(environmental_setting_options) @@ -118,9 +130,9 @@ def ui_list_visits(request: Request, soa_id: int): "soa_id": soa_id, "encounters": encounters, "transition_rule_options": transition_rule_options, - "epoch_options": epoch_options, "timing_options": timing_options, "environmental_setting_options": environmental_setting_options, + "contact_mode_options": contact_mode_options, }, ) @@ -133,7 +145,7 @@ def get_visit(soa_id: int, visit_id: int): conn = _connect() cur = conn.cursor() cur.execute( - "SELECT id,name,label,order_index,epoch_id,encounter_uid,description FROM visit WHERE id=? AND soa_id=?", + "SELECT id,name,label,order_index,encounter_uid,description FROM visit WHERE id=? AND soa_id=?", (visit_id, soa_id), ) row = cur.fetchone() @@ -146,9 +158,8 @@ def get_visit(soa_id: int, visit_id: int): "name": row[1], "label": row[2], "order_index": row[3], - "epoch_id": row[4], - "encounter_uid": row[5], - "description": row[6], + "encounter_uid": row[4], + "description": row[5], } @@ -197,14 +208,6 @@ def add_visit(soa_id: int, payload: VisitCreate): next_n = (max(used_nums) if used_nums else 0) + 1 new_uid = f"Encounter_{next_n}" - if payload.epoch_id is not None: - cur.execute( - "SELECT 1 FROM epoch WHERE id=? AND soa_id=?", (payload.epoch_id, soa_id) - ) - if not cur.fetchone(): - conn.close() - raise HTTPException(400, "Invalid epoch_id for this SOA") - # Generate Code_{N} for encounter.type type = _get_next_code_uid(cur, soa_id) logger.info("type=%s", type) @@ -221,7 +224,8 @@ def add_visit(soa_id: int, payload: VisitCreate): ), ) - # Generate Code_{N} for environmentalSettings.type + # Generate Code_{N} for environmentalSettings **only if value selected + """ environmentalSettings = _get_next_code_uid(cur, soa_id) logger.info("environmentalSettings=%s", environmentalSettings) env_code_value = (payload.environmentalSettings or "").strip() or None @@ -243,11 +247,79 @@ def add_visit(soa_id: int, payload: VisitCreate): env_code_value, ), ) + """ + env_code_value = (payload.environmentalSettings or "").strip() + environmentalSettings = None + if env_code_value: + environmentalSettings = _get_next_code_uid(cur, soa_id) + logger.info("environmentalSettings=%s", environmentalSettings) + env_package_slug = get_latest_sdtm_ct_href() or "" + env_codelist_table = ( + f"/mdr/ct/packages/{env_package_slug}" + if env_package_slug + else "/mdr/ct/packages" + ) + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + environmentalSettings, + env_codelist_table, + "C127262", + env_code_value, + ), + ) + + # Generate Code_{N} for contactModes **only if value selected + """ + contactModes = _get_next_code_uid(cur, soa_id) + logger.info("contactModes=%s", contactModes) + contact_mode_value = (payload.contactModes or "").strip() or None + contact_mode_slug = get_latest_sdtm_ct_href() or "" + contact_mode_codelist_table = ( + f"/mdr/ct/packages/{contact_mode_slug}" + if contact_mode_slug + else "/mdr/ct/packages" + ) + + if contactModes: + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + contactModes, + contact_mode_codelist_table, + "C171445", + contact_mode_value, + ), + ) + """ + contact_mode_value = (payload.contactModes or "").strip() + contactModes = None + if contact_mode_value: + contactModes = _get_next_code_uid(cur, soa_id) + logger.info("contactModes=%s", contactModes) + contact_mode_slug = get_latest_sdtm_ct_href() or "" + contact_mode_codelist_table = ( + f"/mdr/ct/packages/{contact_mode_slug}" + if contact_mode_slug + else "/mdr/ct/packages" + ) + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + contactModes, + contact_mode_codelist_table, + "C171445", + contact_mode_value, + ), + ) cur.execute( """ - INSERT INTO visit (soa_id,name,label,order_index,epoch_id,encounter_uid, - description,type,environmentalSettings,transitionStartRule,transitionEndRule,scheduledAtId) + INSERT INTO visit (soa_id,name,label,order_index,encounter_uid, + description,type,environmentalSettings,transitionStartRule,transitionEndRule,scheduledAtId,contactModes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) """, ( @@ -255,14 +327,14 @@ def add_visit(soa_id: int, payload: VisitCreate): name, _nz(payload.label), next_ord, - payload.epoch_id, new_uid, _nz(payload.description), type, - environmentalSettings, + environmentalSettings, # can be None _nz(payload.transitionStartRule), _nz(payload.transitionEndRule), _nz(payload.scheduledAtId), + contactModes, # can be None ), ) encounter_id = cur.lastrowid @@ -275,6 +347,7 @@ def add_visit(soa_id: int, payload: VisitCreate): "description": (payload.description or "").strip() or None, "type": (payload.type or "").strip() or None, "environmental_settings": (payload.environmentalSettings or "").strip() or None, + "contactModes": (payload.contactModes or "").strip() or None, "transitionStartRule": (payload.transitionStartRule or "").strip() or None, "transitionEndRule": (payload.transitionEndRule or "").strip() or None, "scheduledAtId": (payload.scheduledAtId or "").strip() or None, @@ -292,34 +365,24 @@ def ui_create_visit( name: str = Form(...), label: Optional[str] = Form(None), description: Optional[str] = Form(None), - epoch_id: Optional[str] = Form(None), transitionStartRule: Optional[str] = Form(None), transitionEndRule: Optional[str] = Form(None), scheduledAtId: Optional[str] = Form(None), environmentalSettings: Optional[str] = Form(None), + contactModes: 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: - try: - parsed_epoch_id = int(eid) - except ValueError: - parsed_epoch_id = None - payload = VisitCreate( name=name, label=label, description=description, - epoch_id=parsed_epoch_id, transitionStartRule=transitionStartRule, transitionEndRule=transitionEndRule, scheduledAtId=scheduledAtId, environmentalSettings=environmentalSettings, + contactModes=contactModes, ) add_visit(soa_id, payload) @@ -336,8 +399,8 @@ def update_visit(soa_id: int, visit_id: int, payload: VisitUpdate): cur = conn.cursor() cur.execute( """ - SELECT id,encounter_uid,name,label,description,type,environmentalSettings,transitionStartRule, - transitionEndRule,epoch_id,scheduledAtId,order_index FROM visit WHERE id=? AND soa_id=? ORDER BY order_index, id + SELECT id,encounter_uid,name,label,description,type,environmentalSettings,contactModes,transitionStartRule, + transitionEndRule,scheduledAtId,order_index FROM visit WHERE id=? AND soa_id=? ORDER BY order_index, id """, (visit_id, soa_id), ) @@ -354,31 +417,20 @@ def update_visit(soa_id: int, visit_id: int, payload: VisitUpdate): "description": row[4], "type": row[5], "environmentalSettings": row[6], - "transitionStartRule": row[7], - "transitionEndRule": row[8], - "epoch_id": row[9], + "contactModes": row[7], + "transitionStartRule": row[8], + "transitionEndRule": row[9], "scheduledAtId": row[10], "order_index": row[11], } new_name = (payload.name if payload.name is not None else before["name"]) or "" - if payload.epoch_id is not None: - cur.execute( - "SELECT 1 FROM epoch WHERE id=? AND soa_id=?", (payload.epoch_id, soa_id) - ) - if not cur.fetchone(): - conn.close() - raise HTTPException(400, "Invalid epoch_id for this SOA") - new_label = payload.label if payload.label is not None else before["label"] new_description = ( payload.description if payload.description is not None else before["description"] ) - new_epoch_id = ( - payload.epoch_id if payload.epoch_id is not None else before["epoch_id"] - ) new_transition_start_rule = ( payload.transitionStartRule if payload.transitionStartRule is not None @@ -407,12 +459,24 @@ def update_visit(soa_id: int, visit_id: int, payload: VisitUpdate): else "/mdr/ct/packages" ) + new_contact_mode = ( + (payload.contactModes or "").strip() + if payload.contactModes is not None + else None + ) + contact_mode_code_uid = before["contactModes"] + contact_mode_package_slug = get_latest_sdtm_ct_href() or "" + contact_mode_codelist_table = ( + f"/mdr/ct/packages/{contact_mode_package_slug}" + if contact_mode_package_slug + else "/mdr/ct/packages" + ) + cur.execute( - "UPDATE visit SET name=?, label=?, epoch_id=?, description=?,transitionStartRule=?,transitionEndRule=?,scheduledAtId=? WHERE id=? AND soa_id=?", + "UPDATE visit SET name=?, label=?, description=?,transitionStartRule=?,transitionEndRule=?,scheduledAtId=? WHERE id=? AND soa_id=?", ( _nz(new_name), _nz(new_label), - new_epoch_id, _nz(new_description), _nz(new_transition_start_rule), _nz(new_transition_end_rule), @@ -464,10 +528,51 @@ def update_visit(soa_id: int, visit_id: int, payload: VisitUpdate): conn.commit() + if new_contact_mode is not None: + if not contact_mode_code_uid: + contact_mode_code_uid = _get_next_code_uid(cur, soa_id) + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + contact_mode_code_uid, + contact_mode_codelist_table, + "C171445", + new_contact_mode, + ), + ) + cur.execute( + "UPDATE visit SET contactModes=? WHERE id=? AND soa_id=?", + (contact_mode_code_uid, visit_id, soa_id), + ) + else: + cur.execute( + "UPDATE code SET code=? WHERE soa_id=? AND code_uid=?", + (new_contact_mode, soa_id, contact_mode_code_uid), + ) + if cur.rowcount == 0: + contact_mode_code_uid = _get_next_code_uid(cur, soa_id) + cur.execute( + "INSERT INTO code (soa_id, code_uid, codelist_table, codelist_code, code) VALUES (?,?,?,?,?)", + ( + soa_id, + contact_mode_code_uid, + contact_mode_codelist_table, + "C171445", + new_contact_mode, + ), + ) + cur.execute( + "UPDATE visit SET contactModes=? WHERE id=? AND soa_id=?", + (contact_mode_code_uid, visit_id, soa_id), + ) + + conn.commit() + cur.execute( """ - SELECT id,encounter_uid,name,label,description,type,environmentalSettings,transitionStartRule, - transitionEndRule,epoch_id,scheduledAtId,order_index FROM visit WHERE id=? AND soa_id=? ORDER BY order_index, id + SELECT id,encounter_uid,name,label,description,type,environmentalSettings,contactModes,transitionStartRule, + transitionEndRule,scheduledAtId,order_index FROM visit WHERE id=? AND soa_id=? ORDER BY order_index, id """, ( visit_id, @@ -484,9 +589,9 @@ def update_visit(soa_id: int, visit_id: int, payload: VisitUpdate): "description": r[4], "type": r[5], "environmentalSettings": r[6], - "transitionStartRule": r[7], - "transitionEndRule": r[8], - "epoch_id": r[9], + "contactModes": r[7], + "transitionStartRule": r[8], + "transitionEndRule": r[9], "scheduledAtId": r[10], "order_index": r[11], } @@ -494,8 +599,9 @@ def update_visit(soa_id: int, visit_id: int, payload: VisitUpdate): mutable = [ "name", "label", - "epoch_id", "description", + "environmentalSettings", + "contactModes", "transitionStartRule", "transitionEndRule", "scheduledAtId", @@ -524,21 +630,21 @@ def ui_update_visit( name: Optional[str] = Form(None), label: Optional[str] = Form(None), description: Optional[str] = Form(None), - epoch_id: Optional[int] = Form(None), transitionStartRule: Optional[str] = Form(None), transitionEndRule: Optional[str] = Form(None), scheduledAtId: Optional[str] = Form(None), environmentalSettings: Optional[str] = Form(None), + contactModes: Optional[str] = Form(None), ): payload = VisitUpdate( name=name, label=label, description=description, - epoch_id=epoch_id, transitionStartRule=transitionStartRule, transitionEndRule=transitionEndRule, scheduledAtId=scheduledAtId, environmentalSettings=environmentalSettings, + contactModes=contactModes, ) update_visit(soa_id, visit_id, payload) return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/visits", status_code=303) diff --git a/src/soa_builder/web/schemas.py b/src/soa_builder/web/schemas.py index 77c9aeb..32ce262 100644 --- a/src/soa_builder/web/schemas.py +++ b/src/soa_builder/web/schemas.py @@ -133,6 +133,7 @@ class VisitCreate(BaseModel): transitionEndRule: Optional[str] = None scheduledAtId: Optional[str] = None environmentalSettings: Optional[str] = None + contactModes: Optional[str] = None class VisitUpdate(BaseModel): @@ -145,6 +146,7 @@ class VisitUpdate(BaseModel): transitionEndRule: Optional[str] = None scheduledAtId: Optional[str] = None environmentalSettings: Optional[str] = None + contactModes: Optional[str] = None class ArmCreate(BaseModel): diff --git a/src/soa_builder/web/templates/encounters.html b/src/soa_builder/web/templates/encounters.html index 9e8c1fa..5229fbd 100644 --- a/src/soa_builder/web/templates/encounters.html +++ b/src/soa_builder/web/templates/encounters.html @@ -16,15 +16,6 @@

Encounters for SoA {{ soa_id }}

-
- - -
{% endif %}
+
+ + {% if contact_mode_options %} + + {% else %} + + {% endif %} +
- - - - - - - - - - {{ e.type_submission_value or e.type or "-" }} (C25716) - - - {% if environmental_setting_options %} - {% set selected_env = (e.environmental_concept_id or e.environmentalSettings or '') | string %} - + + + + + + + {{ e.type_submission_value or e.type or "-" }} (C25716) + + + {% if environmental_setting_options %} + {% set selected_env = (e.environmental_concept_id or e.environmentalSettings or '') | string %} + + {% else %} + + {% endif %} + + + {% if contact_mode_options %} + {% set selected_cm_option = (e.contact_mode_concept_id or e.contactModes or '') | string %} + - {% else %} - - {% endif %} + + {% else %} + + {% endif %} + - - - - + + + + + + + + + + - +
+ +
- - - - - -
- -
- - -{% else %} - No encounters yet. - -{% endfor %} - - - - - - + + {% else %} + No encounters yet. + + {% endfor %} {% endblock %} \ No newline at end of file diff --git a/src/soa_builder/web/utils.py b/src/soa_builder/web/utils.py index 2c88fef..58c9258 100644 --- a/src/soa_builder/web/utils.py +++ b/src/soa_builder/web/utils.py @@ -21,6 +21,13 @@ } _ENV_SETTING_CACHE_TTL = 60 * 60 # 1 hour +_contact_mode_cache: dict[str, Any] = { + "options": None, + "fetched_at": 0, + "last_error": None, +} +_CONTACT_MODE_CACHE_TTL = 60 * 60 # 1 hour + def get_cdisc_api_key(): return os.environ.get("CDISC_API_KEY") @@ -710,6 +717,7 @@ def _extract_terms(data: Any) -> List[dict]: 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.""" now = time.time() @@ -797,3 +805,95 @@ def _ensure_option(term: dict) -> None: options = [] return options + + +# Return contact mode options from CDISC Library API +def load_contact_mode_options(force: bool = False) -> List[dict[str, str]]: + """Return [{'submissionValue': ..., 'conceptId': ...}] for contact modes""" + now = time.time() + if ( + not force + and _contact_mode_cache["options"] + and now - _contact_mode_cache["fetched_at"] < _ENV_SETTING_CACHE_TTL + ): + return _contact_mode_cache["options"] + + slug = get_latest_sdtm_ct_href() + if not slug: + _contact_mode_cache.update( + optoins=[], fetched_at=now, last_error="missing_slug" + ) + return [] + + url = f"https://library.cdisc.org/api/mdr/ct/packages/" f"{slug}/codelists/C171445" + 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 _collect_terms(payload: Any) -> List[dict]: + if isinstance(payload, list): + return [t for t in payload if isinstance(t, dict)] + if isinstance(payload, dict): + if isinstance(payload.get("terms"), list): + return [t for t in payload["terms"] if isinstance(t, dict)] + embedded = payload.get("_embedded", {}) + if isinstance(embedded, dict) and isinstance(embedded.get("terms"), list): + return [t for t in embedded["terms"] if isinstance(t, dict)] + return [] + + options: list[dict[str, str]] = [] + try: + resp = requests.get(url, headers=headers, timeout=10) + if resp.status_code != 200: + raise RuntimeError(f"HTTP {resp.status_code}") + data = resp.json() or {} + terms = _collect_terms(data) + + def _ensure_options(term: dict) -> None: + concept = term.get("conceptId") or term.get("code") or term.get("termCode") + submission = term.get("submissionValue") or term.get( + "cdisc_submission_value" + ) + if concept and submission: + options.append( + { + "conceptId": str(concept).strip(), + "submissionValue": str(submission).strip(), + "package": slug, + } + ) + + for term in terms: + _ensure_options(term) + + if not options: + for term in terms: + href = term.get("href") or term.get("_href") + if not href: + link_self = term.gbet("_links", {}).get("self", {}) + href = ( + link_self.get("href") if isinstance(link_self, dict) else None + ) + if not href: + continue + if href.startswith("/"): + href = f"https://library.cdisc.org{href}" + try: + t_resp = requests.get(href, headers=headers, timeout=10) + if t_resp.status_code == 200: + _ensure_options(t_resp.json() or {}) + except Exception: + continue + + options.sort(key=lambda item: item["submissionValue"]) + _contact_mode_cache.update(options=options, fetched_at=now, last_error=None) + except Exception as exc: + _contact_mode_cache.update(options=[], fetched_at=now, last_error=str(exc)) + options = [] + + return options