""" CMS ACCESS API — Unalignment service. Submits the $unalign operation for a Beneficiary (submit-only). The polling worker applies the result via complete_unalignment(), which updates CarePeriod status and stores the outcome in the Unalignment model. """ import logging from datetime import datetime, timezone from fhirclient.models.identifier import Identifier 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, record_submission, ) from access_core.identity.models import Beneficiary from access_core.intake.models import IntakeRecord from access_core.monitoring.models import CarePeriod, Unalignment 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 CmsUnalignmentError(Exception): """Raised on unalignment failures.""" def has_in_flight_unalignment(beneficiary: Beneficiary) -> bool: """True if a $unalign was submitted for this beneficiary and the polling worker has not produced its result yet. cms_response stays null until complete_unalignment() applies the final outcome (or the submission failed), so it is the in-flight indicator — unaligned_at is set at row creation and cannot distinguish the two. """ return Unalignment.objects.filter( beneficiary=beneficiary, cms_response__isnull=True ).exists() def _get_cms_client(): """Process-wide shared CMS client (token + session cached).""" return get_shared_cms_client() def build_unalignment_parameters( beneficiary: Beneficiary, ) -> Parameters: """ Build FHIR R4 Parameters resource for $unalign operation. Args: beneficiary: Beneficiary instance to unalign Returns: Typed fhirclient Parameters resource; serialize with .as_json() before POST. """ # MBI needs no special-casing here: FHIRBuilder._patient_data already emits # the MBI identifier (from a cmsMBI-metadata identifier or the deterministic # synthetic fallback), and system-created beneficiaries never carry an # identifier_type="mbi" row (alignment copies only email/phone/address). patient = FHIRBuilder.beneficiary_to_patient(beneficiary) external_ids = beneficiary.external_ids.filter(system_name="cms") for ext_id in external_ids: if not any(i.value == ext_id.external_id for i in patient.identifier): account_identifier = Identifier() account_identifier.system = "http://cms.gov/access/account-id" account_identifier.value = ext_id.external_id patient.identifier.append(account_identifier) resource_param = ParametersParameter() resource_param.name = "_resource" resource_param.resource = patient params = Parameters() params.parameter = [resource_param] return params def unalign_beneficiary( beneficiary: Beneficiary, reason: str | None = None, ) -> Unalignment: """ Submit unalignment to CMS ACCESS API (submit-only; no inline polling). Workflow: 1. Validate Beneficiary has required data 2. Build US Core Patient + Parameters resource 3. POST $unalign operation 4. Record the durable CMSApiSubmissionStatus row and return; cms_polling_worker polls $submission-status and applies the outcome via complete_unalignment() (updates CarePeriod status on success) Args: beneficiary: Beneficiary instance to unalign reason: Optional human-readable reason for unalignment Returns: Unalignment row; cms_response stays None until the worker applies it. """ client = _get_cms_client() if not beneficiary.first_name or not beneficiary.last_name: raise CmsUnalignmentError(f"Beneficiary {beneficiary.id} missing name") # A duplicate $unalign while one is still awaiting its polled result is # never valid — enforce it here (not just in the signal guard) so manual # command runs are protected too. blocking = Unalignment.objects.filter( beneficiary=beneficiary, cms_response__isnull=True ).first() if blocking: raise CmsUnalignmentError( f"Beneficiary {beneficiary.id} already has an in-flight $unalign " f"(unalignment={blocking.id}) awaiting the polling worker; " "refusing to submit a duplicate until it completes" ) unalignment = Unalignment.objects.create( beneficiary=beneficiary, unaligned_at=datetime.now(timezone.utc), reason=reason, ) logger.info(f"Unalignment started for Beneficiary {beneficiary.id}") request_row = None try: # Build parameters resource (typed fhirclient Parameters) and # serialize once for transport + the audit trail. params = build_unalignment_parameters(beneficiary) 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="unalignment", endpoint="$unalign", method="POST", request_payload=payload, request_headers={"Content-Type": "application/fhir+json"}, beneficiary=beneficiary, ) submit_response = client.post_operation( resource_type="Patient", operation="$unalign", parameters=payload, ) request_row.status_code = submit_response.pop("_http_status", 200) request_row.save(update_fields=["status_code"]) submission_id = _extract_submission_id(submit_response) if not submission_id: raise CmsUnalignmentError( "Unalignment response missing submission-id " + describe_response_params(submit_response) ) logger.info(f"Unalignment 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 # complete_unalignment(). record_submission( api_category=CMSApiRequest.ApiCategory.UNALIGNMENT, endpoint="$unalign", submission_id=submission_id, target_table="Unalignment", target_record_id=unalignment.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"Unalignment failed for Beneficiary {beneficiary.id}: {exc}") unalignment.unaligned_at = datetime.now(timezone.utc) unalignment.cms_response = {"error": str(exc)} unalignment.save() raise CmsUnalignmentError(str(exc)) from exc return unalignment def complete_unalignment(unalignment: Unalignment, status_response: dict) -> None: """Apply a final $unalign outcome to its Unalignment row. Called by cms_polling_worker via poll_cms_submissions (idempotent: skips if an outcome was already applied). """ if unalignment.cms_response is not None: return # already completed (idempotent resume guard) beneficiary = unalignment.beneficiary outcome = _parse_unalignment_outcome(status_response) unalignment.cms_response = status_response unalignment.save() if outcome == "unaligned": CarePeriod.objects.filter( beneficiary=beneficiary, operational_status="active", ).update(operational_status="unaligned") # $unalign is patient-level: when no active care period survives the # unalignment, drop the intake record out of active-beneficiary status. if ( beneficiary.intake_record_id and not CarePeriod.objects.filter( beneficiary=beneficiary, operational_status="active" ).exists() ): # Conditional update from the expected pre-state only — a manual # status change (rejected/withdrawn) is never clobbered. updated = IntakeRecord.objects.filter( pk=beneficiary.intake_record_id, status="active_access_beneficiary", ).update(status="inactive") if not updated: logger.warning( f"Unalignment applied for Beneficiary {beneficiary.id} but " f"IntakeRecord {beneficiary.intake_record_id} is no longer an " "active_access_beneficiary; status left as-is." ) logger.info(f"Unalignment completed: Beneficiary {beneficiary.id}") else: # CMS rejected the unalign (e.g. patient-not-aligned). Local state is # intentionally unchanged — but say so, or this looks like a silent no-op. # Full body stays in Unalignment.cms_response; only param names go to logs. logger.warning( f"Unalignment FAILED for Beneficiary {beneficiary.id} — CMS reported " f"a non-success outcome; CarePeriod/IntakeRecord left unchanged. " + describe_response_params(status_response) ) _extract_submission_id = CmsClient._extract_submission_id def _parse_unalignment_outcome(response: dict) -> str: """Parse unalignment outcome. Returns 'unaligned' or 'failed'. Bodies that validate as FHIR Parameters are walked via the fhirclient model; anything else falls through to raw-dict handling (mock server quirks). """ 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 []: code = (part.valueCode or "").lower() value = (part.valueString or "").lower() text = f"{code} {value}" if any(kw in text for kw in ("unalign", "disenroll", "remove")): return "unaligned" # Nested resource format (mock server) elif name == "result": 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 or "").lower() if cc and cc.coding else "" ) if any(kw in code for kw in ("unaligned", "unalign")): return "unaligned" 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") or "").lower() value = (part.get("valueString") or "").lower() text = f"{code} {value}" if any(kw in text for kw in ("unalign", "disenroll", "remove")): return "unaligned" # Nested resource format (mock server) if name == "result": 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") or "").lower() if any(kw in code for kw in ("unaligned", "unalign")): return "unaligned" outcome = CmsClient._extract_operation_outcome(response) if outcome: severity = outcome.get("severity", "") message = (outcome.get("message") or "").lower() if severity == "informational": return "unaligned" if "unalign" in message and "fail" not in message: return "unaligned" return "failed"