""" CMS ACCESS API — Eligibility check service. Builds US Core Patient resource from IntakeRecord and submits $check-eligibility (submit-only). The polling worker stores the outcome in the EligibilityCheck model via apply_eligibility_result(), which also moves the IntakeRecord forward (eligible → pending_consent) or rejects it when no track is eligible. """ import logging from typing import Any from fhirclient.models.codeableconcept import CodeableConcept from fhirclient.models.coding import Coding from fhirclient.models.condition import Condition from fhirclient.models.fhirreference import FHIRReference from fhirclient.models.parameters import Parameters, ParametersParameter from access_core.cms_integration.models import CMSApiRequest from access_core.cms_integration.services import ( note_failed_attempt, open_target_ids, record_submission, ) from access_core.constants import ICD10_CM_SYSTEM, ACCESSTrack_SYSTEM from access_core.intake.models import IntakeRecord from access_core.monitoring.models import EligibilityCheck from access_core.services.cms_client import ( CmsApiError, CmsClient, describe_response_params, get_shared_cms_client, parse_fhir_resource, ) from access_core.services.fhir_builder import FHIRBuilder logger = logging.getLogger(__name__) class CmsEligibilityError(Exception): """Raised on eligibility check failures.""" def _get_cms_client(): """Process-wide shared CMS client (token + session cached).""" return get_shared_cms_client() def build_eligibility_parameters( intake_record, track_name: str, icd10_codes=(), ) -> Parameters: """ Build FHIR R4 Parameters resource for $check-eligibility operation. Args: intake_record: IntakeRecord instance with patient data track_name: Track code (CKM, eCKM, MSK) — one per submission icd10_codes: Zero or more (code, description|None) tuples from the request's reviewer-determined conditions. When empty no condition parameter is sent ($check-eligibility accepts zero; defaults are never invented). Returns: Typed fhirclient Parameters resource; serialize with .as_json() before POST. """ patient = FHIRBuilder.intake_to_patient(intake_record) # Bundle-local id so the condition's subject reference resolves within the payload. mbi = patient.identifier[0].value patient.id = mbi patient_param = ParametersParameter() patient_param.name = "patient" patient_param.resource = patient track_coding = Coding() track_coding.system = ACCESSTrack_SYSTEM track_coding.code = track_name track_concept = CodeableConcept() track_concept.coding = [track_coding] track_param = ParametersParameter() track_param.name = "track" track_param.valueCodeableConcept = track_concept parameters = [patient_param, track_param] for code, description in icd10_codes: coding = Coding() coding.system = ICD10_CM_SYSTEM coding.code = code concept = CodeableConcept() if description: concept.text = description concept.coding = [coding] condition = Condition() condition.code = concept subject_ref = FHIRReference() subject_ref.reference = f"Patient/{mbi}" condition.subject = subject_ref condition_param = ParametersParameter() condition_param.name = "condition" condition_param.resource = condition parameters.append(condition_param) params = Parameters() params.parameter = parameters return params def check_eligibility( eligibility_request, ) -> EligibilityCheck: """ Submit an eligibility check to CMS ACCESS API (submit-only; no inline polling). The track and ICD-10 conditions come from the reviewer-created EligibilityRequest — Healthie intake data carries neither. Workflow: 1. Validate IntakeRecord has required data (name, dob, sex) 2. Skip if an in-flight submission for this request is still resumable 3. Build US Core Patient + Parameters resource 4. POST $check-eligibility operation 5. Record the durable CMSApiSubmissionStatus row and return; cms_polling_worker polls $submission-status and applies the outcome via apply_eligibility_result() Args: eligibility_request: EligibilityRequest whose IntakeRecord is in pending_cms_eligibility status. Returns: EligibilityCheck (check_status stays "pending" until the worker applies the result). """ client = _get_cms_client() intake_record = eligibility_request.intake_record track_name = eligibility_request.clinical_track.track_name icd10_codes = list( eligibility_request.conditions.values_list("icd10_code", "icd10_description") ) # Validate required fields if not intake_record.first_name or not intake_record.last_name: raise CmsEligibilityError(f"IntakeRecord {intake_record.id} missing name") if not intake_record.dob: raise CmsEligibilityError( f"IntakeRecord {intake_record.id} missing date of birth" ) # Dedupe guard: a pending check for this request with an in-flight # submission is still resumable via the polling worker — return it instead # of posting a duplicate $check-eligibility. open_ids = open_target_ids(CMSApiRequest.ApiCategory.ELIGIBILITY) existing_check = ( EligibilityCheck.objects.filter( eligibility_request=eligibility_request, check_status="pending", id__in=open_ids, ) .order_by("-created_at") .first() ) if existing_check is not None: logger.info( f"Eligibility check skipped for IntakeRecord {intake_record.id}: " f"in-flight submission on EligibilityCheck {existing_check.id} " f"still resumable" ) return existing_check # Create EligibilityCheck record (pending) eligibility_check = EligibilityCheck.objects.create( intake_record=intake_record, eligibility_request=eligibility_request, requested_track=eligibility_request.clinical_track, check_status="pending", ) logger.info( f"Eligibility check started for IntakeRecord {intake_record.id} " f"(track={track_name}, icd10={[c for c, _ in icd10_codes] or 'none'})" ) request_row = None try: # Build parameters resource (typed fhirclient Parameters) and # serialize once for transport + the audit trail. params = build_eligibility_parameters( intake_record, track_name, icd10_codes=icd10_codes ) payload = params.as_json() # Create the audit row BEFORE posting so a failed request is recorded # too; the status code fills in once the response arrives. request_row = CMSApiRequest.objects.create( api_category="eligibility", endpoint="$check-eligibility", method="POST", request_payload=payload, request_headers={"Content-Type": "application/fhir+json"}, ) # POST $check-eligibility submit_response = client.post_operation( resource_type="Patient", operation="$check-eligibility", parameters=payload, ) request_row.status_code = submit_response.pop("_http_status", 200) request_row.save(update_fields=["status_code"]) # Extract submission ID from response submission_id = _extract_submission_id(submit_response) if not submission_id: raise CmsEligibilityError( "Eligibility check response missing submission-id " + describe_response_params(submit_response) ) logger.info(f"Eligibility submission submitted, ID={submission_id}") # Durable tracking; cms_polling_worker polls $submission-status (each # attempt is logged to the audit trail) and applies the outcome via # apply_eligibility_result(). record_submission( api_category=CMSApiRequest.ApiCategory.ELIGIBILITY, endpoint="$check-eligibility", submission_id=submission_id, target_table="EligibilityCheck", target_record_id=eligibility_check.id, ) except Exception as exc: if isinstance(exc, CmsApiError) and request_row is not None: try: note_failed_attempt(request_row, exc) except Exception: # audit recording must not mask the original error logger.warning( "Failed to record failed CMS attempt in the audit trail", exc_info=True, ) logger.error( f"Eligibility check failed for IntakeRecord {intake_record.id}: {exc}" ) eligibility_check.check_status = "error" eligibility_check.cms_response = {"error": str(exc)} eligibility_check.save(update_fields=["check_status", "cms_response"]) raise CmsEligibilityError(str(exc)) from exc return eligibility_check def apply_eligibility_result( eligibility_check: EligibilityCheck, status_response: dict ) -> None: """Apply a final $check-eligibility outcome to its EligibilityCheck row. Called by poll_cms_submissions when the worker receives the result (idempotent: re-applying the same parsed outcome is a no-op). Also moves the IntakeRecord forward: eligible on any track → pending_consent; otherwise rejected, unless another check for this intake is already eligible. """ outcome_status = _parse_eligibility_outcome(status_response) ineligibility_reason = _extract_ineligibility_reason(status_response) update_fields = ["check_status", "cms_response"] eligibility_check.check_status = outcome_status eligibility_check.cms_response = status_response if ineligibility_reason: eligibility_check.ineligibility_reason = ineligibility_reason update_fields.append("ineligibility_reason") eligibility_check.save(update_fields=update_fields) intake_record = eligibility_check.intake_record if outcome_status == "eligible": _set_pending_consent(intake_record) else: _reject_if_no_eligible_check(intake_record) logger.info( f"Eligibility check completed for IntakeRecord " f"{eligibility_check.intake_record_id}: status={outcome_status}" ) def _set_pending_consent(intake_record) -> None: """Eligible on any track moves the intake forward (idempotent). Atomic conditional update — no load-check-save, so a stale in-memory copy can never clobber a status another run just wrote. Also rescues a derived rejection: an earlier ineligible result may have rejected the intake while this check was still pending; "rejected" during eligibility is only ever set by _reject_if_no_eligible_check, so it is safe to advance from here. """ updated = IntakeRecord.objects.filter( pk=intake_record.pk, status__in=["pending_cms_eligibility", "rejected"], ).update(status="pending_consent") if not updated: logger.info( f"Eligible result for IntakeRecord {intake_record.pk} " "not applied to intake status (already moved on)." ) def _reject_if_no_eligible_check(intake_record) -> None: """Reject only when no check for this intake is eligible — a patient may be ineligible on one track while eligible on another. Atomic conditional update from pending_cms_eligibility only, so it can never clobber a status (e.g. pending_consent) written concurrently. """ already_eligible = EligibilityCheck.objects.filter( intake_record_id=intake_record.pk, check_status="eligible" ).exists() if already_eligible: return IntakeRecord.objects.filter( pk=intake_record.pk, status="pending_cms_eligibility" ).update(status="rejected") _extract_submission_id = CmsClient._extract_submission_id def _parse_eligibility_outcome(response: dict[str, Any]) -> str: """ Parse eligibility outcome from CMS API response. Returns one of: eligible, ineligible, control_group, error Falls back to "ineligible" on unrecognized responses. Bodies that validate as FHIR Parameters are walked via the fhirclient model; anything else falls through to raw-dict handling (mock server quirks). """ def _match_eligibility_code(code_str: str) -> str | None: low = code_str.lower() if any(kw in low for kw in ("eligible-pending", "pending-diagnosis")): return "eligible" # Treat pending diagnosis as eligible (condition can be added later) elif "control-group" in low or "control_group" in low: return "control_group" # Check before general ineligible elif "ineligible" in low or "not-eligible" in low: return "ineligible" elif ( "eligible" in low and "not-eligible" not in low and "ineligible" not in low ): return "eligible" elif low == "error": return "error" return None parsed = parse_fhir_resource(response) if isinstance(parsed, Parameters): for param in parsed.parameter or []: name = (param.name or "").lower() if name == "outcome": for part in param.part or []: matched = _match_eligibility_code(part.valueCode or "") if matched: return matched elif name in ("eligibility-outcome", "result"): # Direct value formats val = param.valueCode or param.valueString or "" matched = _match_eligibility_code(val) if matched: return matched # Nested resource format (mock server): result.resource.parameter[].name=result.valueCodeableConcept.coding[0].code nested = param.resource if isinstance(nested, Parameters): for np in nested.parameter or []: if (np.name or "") == "result": cc = np.valueCodeableConcept code = cc.coding[0].code if cc and cc.coding else "" matched = _match_eligibility_code(code) if matched: return matched else: # Raw fallback — body did not validate as a FHIR resource. for param in response.get("parameter", []): name = param.get("name", "").lower() if name == "outcome": parts = param.get("part", []) for part in parts: code = part.get("valueCode", "") matched = _match_eligibility_code(code) if matched: return matched if name in ("eligibility-outcome", "result"): # Direct value formats val = param.get("valueCode") or param.get("valueString", "") matched = _match_eligibility_code(val) if matched: return matched # Nested resource format (mock server): result.resource.parameter[].name=result.valueCodeableConcept.coding[0].code nested = param.get("resource", {}) for np in nested.get("parameter", []): if np.get("name") == "result": coding = np.get("valueCodeableConcept", {}).get("coding", [{}])[ 0 ] code = coding.get("code", "") matched = _match_eligibility_code(code) if matched: return matched outcome = CmsClient._extract_operation_outcome(response) if outcome: severity = outcome.get("severity", "") if severity == "error": return "ineligible" message = outcome.get("message", "").lower() # Check every negated form before the plain "eligible" match, so a # hyphenated "not-eligible" cannot fall through and mark an ineligible # patient as eligible. if ( "ineligible" in message or "not eligible" in message or "not-eligible" in message ): return "ineligible" elif "eligible" in message: return "eligible" if "control" in message: return "control_group" return "ineligible" def _extract_ineligibility_reason(response: dict[str, Any]) -> str | None: """Extract human-readable reason from an ineligible outcome.""" parsed = parse_fhir_resource(response) if isinstance(parsed, Parameters): for param in parsed.parameter or []: name = (param.name or "").lower() if name in ("ineligibility-reason", "reason", "diagnostics"): return param.valueString or "" else: # Raw fallback — body did not validate as a FHIR resource. for param in response.get("parameter", []): name = param.get("name", "").lower() if name in ("ineligibility-reason", "reason", "diagnostics"): return param.get("valueString") or "" outcome = CmsClient._extract_operation_outcome(response) if outcome and outcome.get("severity") == "error": return outcome.get("message") return None