diff --git a/scripts/health/dashboard_v5/capture_contract.py b/scripts/health/dashboard_v5/capture_contract.py index adc61f5..9d1c0ed 100644 --- a/scripts/health/dashboard_v5/capture_contract.py +++ b/scripts/health/dashboard_v5/capture_contract.py @@ -8,6 +8,8 @@ from datetime import datetime from typing import Any from zoneinfo import ZoneInfo +from dashboard_v5.medication_contract import ACTION_CONTRACT_VERSION, validate_action_data + CONTRACT_VERSION = 1 TIMEZONE = ZoneInfo("Europe/Zurich") CAPTURE_TYPES = frozenset( @@ -227,6 +229,8 @@ def validate_capture_payload(payload: Any) -> dict[str, Any]: "body_region": _text(data["body_region"], 80), "ongoing": data["ongoing"], } + elif capture_type == "medication" and data.get("contract") == ACTION_CONTRACT_VERSION: + normalized = validate_action_data(data) elif capture_type in {"medication", "supplement"}: expected = common | { "status", @@ -375,8 +379,9 @@ def validate_capture_payload(payload: Any) -> dict[str, Any]: "body_region": _text(data["body_region"], 80), "description": _text(data["description"], 160), } - normalized["title"] = _text(data["title"], 100, required=capture_type == "event") - normalized["note"] = _text(data["note"], 500) + if not (capture_type == "medication" and normalized.get("contract") == ACTION_CONTRACT_VERSION): + normalized["title"] = _text(data["title"], 100, required=capture_type == "event") + normalized["note"] = _text(data["note"], 500) if capture_type == "event" and normalized.get("event_kind") in {"sauna", "training"}: if ended is None or occurred is None: raise ValueError("duration requires end") diff --git a/scripts/health/dashboard_v5/read_api.py b/scripts/health/dashboard_v5/read_api.py index 94af38b..81afd2e 100644 --- a/scripts/health/dashboard_v5/read_api.py +++ b/scripts/health/dashboard_v5/read_api.py @@ -79,6 +79,19 @@ from dashboard_v5.supplement_read import supplement_nutrient_totals, supplements from dashboard_v5.source_status import build_source_status from dashboard_v5.observation_contract import PUBLIC_CONTRACT_VERSION, STATUS as OBSERVATION_STATUS, templates as observation_templates from dashboard_v5.observation_engine import evaluate_observation, evaluate_plan, list_observations, observation_detail +from dashboard_v5.medication_schema import assert_schema as assert_medication_schema +from dashboard_v5.medication_contract import ( + PUBLIC_CONTRACT_VERSION as MEDICATION_HISTORY_CONTRACT, + action_context_revision, + event_ref as medication_event_ref, + event_revision as medication_event_revision, + list_events as medication_event_rows, + list_prescriptions as medication_prescription_rows, + medication_ref as medication_prescription_ref, + prescription_revision as medication_prescription_revision, + public_identity_key as medication_public_identity_key, + plan_is_consumed as medication_plan_is_consumed, +) TZ_NAME = "Europe/Zurich" LOCAL_TZ = ZoneInfo(TZ_NAME) @@ -100,7 +113,7 @@ MAX_QUERY_BYTES = 512 QUERY_TIMEOUT_SECONDS = 1.0 ALLOWED_RESOLUTIONS = frozenset({"day", "week"}) ALLOWED_EVENT_TYPES = frozenset( - {"medication_administered", "supplement", "symptom_day", "health_event", "health_period", "nutrition_day", "laboratory", "document", "appointment"} + {"medication_administered", "medication", "supplement", "symptom_day", "health_event", "health_period", "nutrition_day", "laboratory", "document", "appointment"} ) NUTRIENT_ALLOWLIST = { key: (contract.label, contract.unit) @@ -1005,6 +1018,28 @@ def _events(connection: sqlite3.Connection, params: dict[str, str]) -> dict[str, result.append( {"date": day, "type": "medication_administered", "label": label} ) + if "medication" in selected and table_exists(connection, "medication_administrations"): + rows = list(connection.execute( + """SELECT datum,medication_name,event_type FROM medication_administrations + WHERE datum>=? AND datum<=? ORDER BY datum,id LIMIT ?""", + (start.isoformat(), end.isoformat(), MAX_EVENT_SOURCE_ROWS + 1), + )) + if len(rows) > MAX_EVENT_SOURCE_ROWS: + raise APIError(422, "source_row_limit_exceeded") + for row in rows: + raw = str(row["event_type"] or "").strip().casefold() + if raw in ADMINISTERED: + continue + category = ( + "planned" if raw in {"planned", "scheduled", "geplant"} + else "missed" if raw in {"missed", "verpasst", "ausgelassen"} + else "corrected" if raw in {"corrected", "korrigiert", "correction"} + else "unknown" + ) + label = safe_metadata_text(row["medication_name"], 120) + day = parse_day(row["datum"]) + if day and label: + result.append({"date": day, "type": "medication", "category": category, "label": label}) if "supplement" in selected: try: supplement_events = supplements(connection, start, end) @@ -3479,71 +3514,185 @@ def _record_medications( connection: sqlite3.Connection, params: dict[str, str] ) -> dict[str, Any]: start, end = parse_range(params) - result: dict[str, Any] = { + medication_filter = params.get("medication", "") + if medication_filter and not re.fullmatch(r"medrx_[a-f0-9]{24}", medication_filter): + raise APIError(400, "medication_filter_not_allowed") + status_filter = params.get("status", "") + source_filter = safe_metadata_text(params.get("source", ""), 80, allow_empty=True) + if status_filter and status_filter not in {"planned", "administered", "missed", "corrected", "unknown"}: + raise APIError(400, "medication_status_not_allowed") + empty: dict[str, Any] = { + "contract": MEDICATION_HISTORY_CONTRACT, + "prescriptions": [], + "current_prescriptions": [], + "other_prescriptions": [], "planned": [], "administered": [], "missed": [], "corrected": [], + "unknown": [], + "sources": [], + "truncated": False, + "truncated_sections": [], + "next_cursor": None, } - if not table_exists(connection, "medication_administrations"): - return result - groups = { - "planned": ("planned", "scheduled", "geplant"), - "administered": tuple(sorted(ADMINISTERED)), - "missed": ("missed", "verpasst", "ausgelassen"), - "corrected": ("corrected", "korrigiert", "correction"), + if not table_exists(connection, "medication_administrations") or not table_exists(connection, "medikamente"): + return empty + try: + assert_medication_schema(connection) + except RuntimeError as error: + raise APIError(503, "medication_schema_unavailable") from error + identity_key = medication_public_identity_key(connection) + prescriptions = medication_prescription_rows(connection) + prescription_by_id = {int(row["id"]): row for row in prescriptions} + prescription_by_name: dict[str, list[sqlite3.Row]] = defaultdict(list) + for row in prescriptions: + prescription_by_name[str(row["medikament_name"])].append(row) + public_prescriptions = [] + for row in prescriptions: + name = safe_metadata_text(row["medikament_name"], 120) + dose = safe_metadata_text(row["dosierung"], 80, allow_empty=True) + route = safe_metadata_text(row["anwendungsform"], 80, allow_empty=True) + source = safe_metadata_text(row["prescription_status_source"], 80, allow_empty=True) + provenance = safe_metadata_text(row["prescription_status_provenance"], 160, allow_empty=True) + status = str(row["prescription_status"] or "unknown") + if status not in {"active", "ended", "paused", "unknown"} or None in (name, dose, route, source, provenance): + status = "unknown" + item = { + "id": medication_prescription_ref(row, identity_key), + "name": name, + "documented_dose": dose, + "documented_route": route, + "status": status, + "status_source": source, + "status_provenance": provenance, + "business_revision": medication_prescription_revision(row), + "preview_revision": action_context_revision(row), + } + public_prescriptions.append(item) + empty["prescriptions"] = public_prescriptions + empty["current_prescriptions"] = [item for item in public_prescriptions if item["status"] == "active"] + empty["other_prescriptions"] = [item for item in public_prescriptions if item["status"] != "active"] + + rows = medication_event_rows(connection) + corrected_origins = { + int(row["corrects_event_id"]) + for row in rows + if str(row["event_type"] or "").strip().casefold() == "corrected" and row["corrects_event_id"] is not None + } + aliases = { + "oral": "oral", "subkutan": "subcutaneous", "subcutaneous": "subcutaneous", + "intravenös": "intravenous", "intravenous": "intravenous", + "intramuskulär": "intramuscular", "intramuscular": "intramuscular", + "äußerlich": "topical", "topical": "topical", "inhalativ": "inhaled", "inhaled": "inhaled", } - truncated_sections: list[str] = [] - for bucket, events in groups.items(): - effective = ( - "COALESCE(NULLIF(scheduled_next_date,''),datum)" + sources: set[str] = set() + truncated_sections: set[str] = set() + for row in rows: + raw_status = str(row["event_type"] or "").strip().casefold() + if raw_status in {"planned", "scheduled", "geplant"}: + bucket = "planned" + elif raw_status in ADMINISTERED: + bucket = "administered" + elif raw_status in {"missed", "verpasst", "ausgelassen"}: + bucket = "missed" + elif raw_status in {"corrected", "korrigiert", "correction"}: + bucket = "corrected" + else: + bucket = "unknown" + recorded_day = parse_day(row["datum"]) + scheduled = parse_day(row["scheduled_next_date"]) + effective_day = scheduled or recorded_day if bucket == "planned" else recorded_day + if not recorded_day or not effective_day: + continue + if start and effective_day < start.isoformat(): + continue + if end and effective_day > end.isoformat(): + continue + name = safe_metadata_text(row["medication_name"], 120) + source = safe_metadata_text(row["source"], 80, allow_empty=True) + if not name or source is None: + continue + if status_filter and bucket != status_filter: + continue + if source_filter and source != source_filter: + continue + candidates: list[sqlite3.Row] = [] + if row["medication_id"] is not None and int(row["medication_id"]) in prescription_by_id: + candidates = [prescription_by_id[int(row["medication_id"])]] + elif len(prescription_by_name.get(name, [])) == 1: + candidates = prescription_by_name[name] + prescription = candidates[0] if len(candidates) == 1 else None + if medication_filter and ( + prescription is None + or medication_prescription_ref(prescription, identity_key) != medication_filter + ): + continue + route_original = safe_metadata_text(row["route_original"] or row["route"], 60, allow_empty=True) + route_normalized = safe_metadata_text(row["route_normalized"], 24, allow_empty=True) + if not route_normalized and route_original: + route_normalized = aliases.get(route_original.casefold(), "unknown") + planned_context = None + if row["planned_event_id"] is not None: + planned_context = next((candidate for candidate in rows if int(candidate["id"]) == int(row["planned_event_id"])), None) + correction_context = None + if row["corrects_event_id"] is not None: + correction_context = next((candidate for candidate in rows if int(candidate["id"]) == int(row["corrects_event_id"])), None) + plan_consumed = ( + medication_plan_is_consumed(connection, int(row["id"])) if bucket == "planned" - else "datum" + else False ) - placeholders = ",".join("?" for _ in events) - filters = [f"lower(trim(COALESCE(event_type,''))) IN ({placeholders})"] - values: list[Any] = list(events) - if start: - filters.append(f"{effective}>=?") - values.append(start.isoformat()) - if end: - filters.append(f"{effective}<=?") - values.append(end.isoformat()) - direction = "ASC" if bucket == "planned" else "DESC" - rows = connection.execute( - "SELECT datum,medication_name,dose,route,event_type," - "scheduled_next_date,notes,source FROM medication_administrations WHERE " - + " AND ".join(filters) - + f" ORDER BY {effective} {direction},id {direction} LIMIT ?", - (*values, RECORD_MAX_ROWS + 1), - ).fetchall() - if len(rows) > RECORD_MAX_ROWS: - truncated_sections.append(bucket) - for row in rows[:RECORD_MAX_ROWS]: - recorded_day = parse_day(row["datum"]) - scheduled = parse_day(row["scheduled_next_date"]) - effective_day = ( - scheduled or recorded_day if bucket == "planned" else recorded_day - ) - name = safe_metadata_text(row["medication_name"], 120) - if not recorded_day or not effective_day or not name: - continue - result[bucket].append( - { - "date": effective_day, - "recorded_date": recorded_day, - "scheduled_next_date": scheduled if bucket == "planned" else None, - "name": name, - "dose": safe_metadata_text(row["dose"], 40, allow_empty=True), - "route": safe_metadata_text(row["route"], 40, allow_empty=True), - "note": safe_metadata_text(row["notes"], 300, allow_empty=True), - "source": safe_metadata_text(row["source"], 80, allow_empty=True), - } - ) - result["truncated"] = bool(truncated_sections) - result["truncated_sections"] = truncated_sections - result["next_cursor"] = None - return result + item = { + "id": medication_event_ref(row, identity_key), + "date": effective_day, + "recorded_date": recorded_day, + "occurred_at": safe_metadata_text(row["occurred_at"], 40, allow_empty=True), + "scheduled_next_date": scheduled if bucket == "planned" else None, + "name": name, + "status": bucket, + "effective_status": safe_metadata_text(row["corrected_target_status"], 20, allow_empty=True) if bucket == "corrected" else bucket, + "legacy_dose": safe_metadata_text(row["dose"], 80, allow_empty=True), + "planned_dose_value": safe_metadata_text(row["planned_dose_value"], 40, allow_empty=True), + "planned_dose_unit": safe_metadata_text(row["planned_dose_unit"], 30, allow_empty=True), + "actual_dose_value": safe_metadata_text(row["actual_dose_value"], 40, allow_empty=True), + "actual_dose_unit": safe_metadata_text(row["actual_dose_unit"], 30, allow_empty=True), + "route_original": route_original, + "route_normalized": route_normalized or "unknown", + "injection_region": safe_metadata_text(row["injection_region"], 80, allow_empty=True), + "injection_side": safe_metadata_text(row["injection_side"], 20, allow_empty=True), + "injection_detail": safe_metadata_text(row["injection_detail"], 120, allow_empty=True), + "lot_number": safe_metadata_text(row["lot_number"], 80, allow_empty=True), + "note": safe_metadata_text(row["notes"], 300, allow_empty=True), + "source": source, + "prescription_id": medication_prescription_ref(prescription, identity_key) if prescription is not None else None, + "planned_event_id": medication_event_ref(planned_context, identity_key) if planned_context is not None else None, + "corrects_event_id": medication_event_ref(correction_context, identity_key) if correction_context is not None else None, + "correction_reason": safe_metadata_text(row["correction_reason"], 300, allow_empty=True), + "superseded_by_correction": int(row["id"]) in corrected_origins, + "plan_consumed": plan_consumed, + "business_revision": medication_event_revision(row), + "administration_preview_revision": action_context_revision( + prescription, + row, + None, + planned_consumed=plan_consumed, + ) if prescription is not None and bucket == "planned" and not plan_consumed else None, + "correction_preview_revision": action_context_revision(prescription, None, row) if prescription is not None and int(row["id"]) not in corrected_origins else None, + } + if len(empty[bucket]) >= RECORD_MAX_ROWS: + truncated_sections.add(bucket) + continue + empty[bucket].append(item) + if source: + sources.add(source) + for bucket in ("administered", "missed", "corrected", "unknown"): + empty[bucket].sort(key=lambda item: (item["date"], item["id"]), reverse=True) + empty["planned"].sort(key=lambda item: (item["date"], item["id"])) + empty["sources"] = sorted(sources) + empty["truncated"] = bool(truncated_sections) + empty["truncated_sections"] = sorted(truncated_sections) + return empty def _record_appointments( @@ -4048,13 +4197,28 @@ def _doctor_report( else {"observations": [], "truncated": False} ) labs = lab_page["observations"] - medications = ( + medication_record = ( _record_medications( connection, {"from": start.isoformat(), "to": end.isoformat()} ) if "medications" in selected - else {"planned": [], "administered": [], "missed": [], "corrected": []} + else {"planned": [], "administered": [], "missed": [], "corrected": [], "unknown": [], "truncated": False} + ) + medication_report_fields = ( + "date", "name", "status", "effective_status", "planned_dose_value", + "planned_dose_unit", "actual_dose_value", "actual_dose_unit", "legacy_dose", ) + medications: dict[str, Any] = { + kind: [ + { + **{field: item.get(field) for field in medication_report_fields}, + "is_correction": kind == "corrected", + } + for item in medication_record.get(kind, []) + ] + for kind in ("planned", "administered", "missed", "corrected", "unknown") + } + medications["truncated"] = bool(medication_record.get("truncated")) supplement_report = ( supplements(connection, start, end) if "supplements" in selected @@ -4235,7 +4399,7 @@ def _doctor_report( "labs": labs, "medications": sum( len(medications.get(kind, [])) - for kind in ("planned", "administered", "missed", "corrected") + for kind in ("planned", "administered", "missed", "corrected", "unknown") ), "supplements": sum( len(supplement_report.get(kind, [])) @@ -4436,7 +4600,7 @@ def dispatch_api( except ValueError as error: raise APIError(422, str(error)) from error if path == "/api/v1/medications": - return _record_medications(connection, parse_query(query, {"from", "to"})) + return _record_medications(connection, parse_query(query, {"from", "to", "medication", "status", "source"})) if path == "/api/v1/appointments": return _record_appointments( connection, parse_query(query, {"from", "to", "order", "institution"}) diff --git a/scripts/health/health_dashboard_action_worker.py b/scripts/health/health_dashboard_action_worker.py index 7a2da28..5efcc3a 100644 --- a/scripts/health/health_dashboard_action_worker.py +++ b/scripts/health/health_dashboard_action_worker.py @@ -33,6 +33,11 @@ try: assert_schema as assert_observation_schema, ) from dashboard_v5.capture_contract import validate_capture_payload + from dashboard_v5.medication_schema import assert_schema as assert_medication_schema + from dashboard_v5.medication_contract import ( + ACTION_CONTRACT_VERSION as MEDICATION_ACTION_CONTRACT, + resolve_action_preview, + ) from dashboard_v5.capture_media import cleanup_expired, promote_attachment from dashboard_v5.media_validation import MediaInfo, create_safe_derivatives, media_runtime_self_test from dashboard_v5.sprint6i_b_schema import ( @@ -85,6 +90,11 @@ except ModuleNotFoundError: # direct importlib fixture execution assert_schema as assert_observation_schema, ) from dashboard_v5.capture_contract import validate_capture_payload + from dashboard_v5.medication_schema import assert_schema as assert_medication_schema + from dashboard_v5.medication_contract import ( + ACTION_CONTRACT_VERSION as MEDICATION_ACTION_CONTRACT, + resolve_action_preview, + ) from dashboard_v5.capture_media import cleanup_expired, promote_attachment from dashboard_v5.media_validation import MediaInfo, create_safe_derivatives, media_runtime_self_test from dashboard_v5.sprint6i_b_schema import ( @@ -1028,11 +1038,44 @@ def apply_capture_action(payload: dict[str, Any]) -> str: if not root_id: root_id = entry_id data = payload["data"] + medication_context: dict[str, Any] | None = None known_tables = { str(row[0]) for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'") } - if payload["capture_type"] == "medication" and "medication_administrations" in known_tables: + if payload["capture_type"] == "medication" and data.get("contract") == MEDICATION_ACTION_CONTRACT: + assert_medication_schema(connection) + expected_revision, prescription, planned, correction_target = resolve_action_preview( + connection, payload + ) + if not hmac.compare_digest(expected_revision, data["preview_revision"]): + raise RuntimeError("stale medication preview revision") + medication_id = int(prescription["id"]) + duplicate = connection.execute( + """SELECT 1 FROM medication_administrations + WHERE business_revision IS NOT NULL AND medication_id=? + AND COALESCE(planned_event_id,-1)=COALESCE(?,-1) + AND COALESCE(occurred_at,'')=? + AND lower(trim(COALESCE(event_type,'')))=? + AND COALESCE(actual_dose_value,'')=? + AND COALESCE(actual_dose_unit,'')=? LIMIT 1""", + ( + medication_id, + int(planned["id"]) if planned is not None else None, + payload["occurred_at"], + data["status"], + data["actual_dose_value"], + data["actual_dose_unit"], + ), + ).fetchone() + if duplicate and not data["duplicate_confirmed"]: + raise RuntimeError("possible duplicate medication event") + medication_context = { + "medication_id": medication_id, + "planned_event_id": int(planned["id"]) if planned is not None else None, + "corrects_event_id": int(correction_target["id"]) if correction_target is not None else None, + } + elif payload["capture_type"] == "medication" and "medication_administrations" in known_tables: known_medications = { str(row[0]) for row in connection.execute( @@ -1179,22 +1222,62 @@ def apply_capture_action(payload: dict[str, Any]) -> str: (day, label, severity, context, data["note"] or None), ) elif payload["capture_type"] == "medication" and "medication_administrations" in available_tables: - dose = " ".join( - str(part) - for part in (data["amount"], data["unit"]) - if part not in {None, ""} - ) - medication_columns = {str(row[1]) for row in connection.execute("PRAGMA table_info(medication_administrations)")} - if "occurred_at" in medication_columns: + if medication_context is not None: + actual_dose = " ".join( + part for part in (data["actual_dose_value"], data["actual_dose_unit"]) if part + ) + legacy_route = data["route_original"] or data["route_normalized"] connection.execute( - "INSERT INTO medication_administrations(datum,medication_name,dose,route,event_type,notes,source,occurred_at) VALUES(?,?,?,?,?,?,?,?)", - (day, data["name"], dose or None, data["route"] or None, data["status"], data["note"] or None, "dashboard_v5_mobile_capture", payload["occurred_at"]), + """INSERT INTO medication_administrations( + datum,medication_name,dose,route,event_type,scheduled_next_date,notes,source,occurred_at, + medication_id,planned_event_id,planned_dose_value,planned_dose_unit,actual_dose_value, + actual_dose_unit,route_original,route_normalized,injection_region,injection_side, + injection_detail,lot_number,corrects_event_id,corrected_target_status,correction_reason, + business_revision) VALUES(?,?,?,?,?,NULL,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + day, + data["name"], + actual_dose or None, + legacy_route or None, + data["status"], + data["note"] or None, + "dashboard_v5_medication_action", + payload["occurred_at"], + medication_context["medication_id"], + medication_context["planned_event_id"], + data["planned_dose_value"] or None, + data["planned_dose_unit"] or None, + data["actual_dose_value"] or None, + data["actual_dose_unit"] or None, + data["route_original"] or None, + data["route_normalized"] or None, + data["injection_region"] or None, + data["injection_side"] or None, + data["injection_detail"] or None, + data["lot_number"] or None, + medication_context["corrects_event_id"], + data["corrected_target_status"] or None, + data["correction_reason"] or None, + action_hash, + ), ) else: - connection.execute( - "INSERT INTO medication_administrations(datum,medication_name,dose,route,event_type,notes,source) VALUES(?,?,?,?,?,?,?)", - (day, data["name"], dose or None, data["route"] or None, data["status"], data["note"] or None, "dashboard_v5_mobile_capture"), + dose = " ".join( + str(part) + for part in (data["amount"], data["unit"]) + if part not in {None, ""} ) + medication_columns = {str(row[1]) for row in connection.execute("PRAGMA table_info(medication_administrations)")} + if "occurred_at" in medication_columns: + connection.execute( + "INSERT INTO medication_administrations(datum,medication_name,dose,route,event_type,notes,source,occurred_at) VALUES(?,?,?,?,?,?,?,?)", + (day, data["name"], dose or None, data["route"] or None, data["status"], data["note"] or None, "dashboard_v5_mobile_capture", payload["occurred_at"]), + ) + else: + connection.execute( + "INSERT INTO medication_administrations(datum,medication_name,dose,route,event_type,notes,source) VALUES(?,?,?,?,?,?,?)", + (day, data["name"], dose or None, data["route"] or None, data["status"], data["note"] or None, "dashboard_v5_mobile_capture"), + ) elif payload["capture_type"] == "supplement" and "supplement_intakes" in available_tables: connection.execute( "INSERT INTO supplement_intakes(plan_id,product,brand_variant,nutrient_key,amount,unit,status,occurred_at,composition_source,assignment_reliability,notes) VALUES(NULL,?,NULL,'other',?,?,?,?, 'user_documented','documented',?)", diff --git a/scripts/health/health_dashboard_server.py b/scripts/health/health_dashboard_server.py index 949948e..58e878b 100644 --- a/scripts/health/health_dashboard_server.py +++ b/scripts/health/health_dashboard_server.py @@ -33,6 +33,11 @@ from dashboard_v5.document_originals import configured_original_roots, probe_ori from dashboard_v5.supplement_contract import validate_supplement_payload from dashboard_v5.observation_contract import validate_action as validate_observation_action from dashboard_v5.capture_contract import validate_capture_payload +from dashboard_v5.medication_schema import assert_schema as assert_medication_schema +from dashboard_v5.medication_contract import ( + ACTION_CONTRACT_VERSION as MEDICATION_ACTION_CONTRACT, + resolve_action_preview, +) from dashboard_v5.capture_media import MAX_ORIGINAL_BYTES as MAX_CAPTURE_MEDIA_BYTES, quarantine_bytes from dashboard_v5.media_validation import media_runtime_self_test from dashboard_v5.document_review import discard_quarantine, quarantine_upload, validate_metadata @@ -150,6 +155,7 @@ CAPTURE_UPLOAD_ROUTE = "/api/v1/capture/upload" DOCUMENT_UPLOAD_ROUTE = "/api/v1/documents/upload" CAPTURE_ROUTE = "/health-actions/capture" +MEDICATION_PREVIEW_ROUTE = "/health-actions/medication-preview" ACTION_INBOX = Path( os.environ.get( @@ -1272,6 +1278,7 @@ class Handler(BaseHTTPRequestHandler): OBSERVATION_ROUTE, DOCUMENT_REVIEW_ROUTE, CAPTURE_ROUTE, + MEDICATION_PREVIEW_ROUTE, }: self.send_error(404) return @@ -1311,7 +1318,7 @@ class Handler(BaseHTTPRequestHandler): token = (form.get("csrf_token") or [""])[0] cookie = SimpleCookie(self.headers.get("Cookie", "")) cookie_token = cookie.get( - "health_capture_csrf" if action_path == CAPTURE_ROUTE else "health_csrf" + "health_capture_csrf" if action_path in {CAPTURE_ROUTE, MEDICATION_PREVIEW_ROUTE} else "health_csrf" ) if ( not token @@ -1320,10 +1327,11 @@ class Handler(BaseHTTPRequestHandler): ): self.send_error(403) return - if not consume_csrf_token(token): + if action_path != MEDICATION_PREVIEW_ROUTE and not consume_csrf_token(token): self.send_error(403) return mapping_queue_key_value = "" + medication_preview_revision = "" return_document = "" payload: dict[str, object] = {} @@ -1335,6 +1343,33 @@ class Handler(BaseHTTPRequestHandler): payload, return_to = validate_mapping_submission(form) mapping_queue_key_value = str(payload["queue_key"]) write_action_payload(payload) + elif action_path == MEDICATION_PREVIEW_ROUTE: + if ( + set(form) != {"csrf_token", "payload", "return_to"} + or any(len(values) != 1 for values in form.values()) + or form["return_to"][0] != "v5" + or API_DB is None + ): + raise ValueError("invalid medication preview submission") + payload = validate_capture_payload(json.loads(form["payload"][0])) + preview_data = payload.get("data") + if ( + payload["capture_type"] != "medication" + or not isinstance(preview_data, dict) + or preview_data.get("contract") != MEDICATION_ACTION_CONTRACT + ): + raise ValueError("invalid medication preview contract") + preview_connection = connect_read_only(API_DB) + try: + assert_medication_schema(preview_connection) + medication_preview_revision = resolve_action_preview( + preview_connection, payload + )[0] + except RuntimeError as error: + raise ValueError("medication preview context unavailable") from error + finally: + preview_connection.close() + return_to = "v5" elif action_path == CAPTURE_ROUTE: if ( set(form) != {"csrf_token", "payload", "return_to"} @@ -1369,6 +1404,13 @@ class Handler(BaseHTTPRequestHandler): except OSError: self.send_error(500) return + if action_path == MEDICATION_PREVIEW_ROUTE: + self._send_api_json( + 200, + {"status": "preview", "preview_revision": medication_preview_revision}, + True, + ) + return if action_path in {CHECKIN_ROUTE, NUTRITION_MAPPING_ROUTE} and "application/json" in self.headers.get( "Accept", "" ): __HERMES_CWD_8d46a20096ed__/home/agent/.hermes/repos/HealthManager__HERMES_CWD_8d46a20096ed__