""" CMS ACCESS API — Application integration signals. Wires CMS services into the existing intake/care-period/consent flows so that: - Consent grant → triggers CMS alignment automatically - Care plan deactivation / withdrawal → triggers CMS unalignment - Approved OAP observation → auto-reports to CMS """ import logging import threading import time import uuid from datetime import timedelta from django.db.models.signals import post_save, pre_save from django.utils import timezone as django_timezone logger = logging.getLogger(__name__) def _corr_id() -> str: """Generate a short correlation ID for tracing signal → CMS API call chains.""" return uuid.uuid4().hex[:12] def _log_signal_error( operation: str, entity_type: str, entity_id: str | int, exc: Exception, corr_id: str ) -> None: """Structured error logging for signal handlers. Includes correlation ID, exception type, retryability hint, and full traceback. """ from access_core.services.cms_client import CmsApiError retryable = ( getattr(exc, "retryable", False) if isinstance(exc, CmsApiError) else "unknown" ) logger.error( "[cms-signal] %s failed for %s #%s | corr=%s | type=%s | retryable=%s", operation, entity_type, entity_id, corr_id, type(exc).__name__, retryable, exc_info=exc, ) def _log_signal_success( operation: str, entity_type: str, entity_id: str | int, corr_id: str, detail: str = "", ) -> None: """Structured success logging for signal handlers.""" extra = f" — {detail}" if detail else "" logger.info( "[cms-signal] %s succeeded for %s #%s | corr=%s%s", operation, entity_type, entity_id, corr_id, extra, ) def _log_signal_skipped( operation: str, entity_type: str, entity_id: str | int, reason: str ) -> None: """Log when a signal trigger is intentionally skipped (idempotency, guard).""" logger.info( "[cms-signal] %s skipped for %s #%s — %s", operation, entity_type, entity_id, reason, ) def _log_signal_action( operation: str, entity_type: str, entity_id: str | int, corr_id: str ) -> None: """Log when a signal trigger begins execution.""" logger.info( "[cms-signal] %s triggered for %s #%s | corr=%s", operation, entity_type, entity_id, corr_id, ) def _log_status_change( entity_type: str, entity_id: str | int, old_val: str | None, new_val: str ) -> None: """Log a model status change detected by signals.""" logger.info( "[cms-signal] %s #%s status changed: %s → %s", entity_type, entity_id, old_val or "new", new_val, ) # ------------------------------------------------------------------ # State tracking — capture old status in pre_save, act on change in post_save # ------------------------------------------------------------------ # Module-level dict keyed by (model_type, pk) mapping to (old_value, stored_at). # Thread-safe via lock; entries are popped in post_save handlers and expire by TTL. _old_status_store: dict[tuple[str, str], tuple[str | None, float]] = {} _old_status_store_lock = threading.Lock() def clear_old_status_store() -> None: """Clear the old status store. Call in test tearDown or app shutdown.""" with _old_status_store_lock: _old_status_store.clear() # TTL for stale entries in seconds (1 hour) — prevents unbounded growth on failed saves _STATUS_STORE_TTL_SECONDS = 3600 def _set_old_status_with_ttl(key: tuple[str, str], value: str) -> None: """Store old status with a timestamp for TTL-based cleanup.""" entry = (value, time.time()) if value is not None else (None, time.time()) with _old_status_store_lock: _old_status_store[key] = entry def _get_old_status_with_ttl(key: tuple[str, str]) -> str | None: """Retrieve old status, skipping stale entries.""" cutoff = time.time() - _STATUS_STORE_TTL_SECONDS with _old_status_store_lock: entry = _old_status_store.get(key) if entry is None: return None value, ts = entry if ts < cutoff: del _old_status_store[key] return None return value def _pop_old_status_with_ttl(key: tuple[str, str]) -> str | None: """Pop and return old status, skipping stale entries.""" cutoff = time.time() - _STATUS_STORE_TTL_SECONDS with _old_status_store_lock: entry = _old_status_store.pop(key, None) if entry is None: return None value, ts = entry if ts < cutoff: return None return value def _pre_save_capture_intake(sender, instance, raw=False, using=None, **kwargs): """Capture IntakeRecord status BEFORE the save.""" from access_core.intake.models import IntakeRecord if not isinstance(instance, IntakeRecord) or not instance.pk: return key = ("IntakeRecord", str(instance.pk)) try: old = sender.objects.get(pk=instance.pk) _set_old_status_with_ttl(key, old.status) except sender.DoesNotExist: # New record — no previous status to capture; post_save handler will handle it pass except Exception as exc: # noqa: BLE001 - status capture must not break save() logger.warning( f"Failed to read IntakeRecord {instance.pk} for status capture: {exc}" ) def _post_save_act_intake(sender, instance, created, **kwargs): """Act on IntakeRecord status change AFTER the save.""" from access_core.intake.models import IntakeRecord if not isinstance(instance, IntakeRecord) or created: return key = ("IntakeRecord", str(instance.pk)) old_status = _pop_old_status_with_ttl(key) new_status = instance.status if old_status == new_status: return logger.info( f"IntakeRecord {instance.id} status changed: " f"{old_status or 'new'} -> {new_status}" ) _log_status_change("IntakeRecord", instance.id, old_status, new_status) # pending_consent → pending_alignment: trigger CMS alignment if old_status == "pending_consent" and new_status == "pending_alignment": _trigger_cms_alignment(instance) def _pre_save_capture_care_period(sender, instance, raw=False, using=None, **kwargs): """Capture CarePeriod operational_status BEFORE the save.""" from access_core.monitoring.models import CarePeriod if not isinstance(instance, CarePeriod) or not instance.pk: return key = ("CarePeriod", str(instance.pk)) try: old = sender.objects.get(pk=instance.pk) _set_old_status_with_ttl(key, old.operational_status) except sender.DoesNotExist: pass except Exception as exc: # noqa: BLE001 - status capture must not break save() logger.warning( f"Failed to read CarePeriod {instance.pk} for status capture: {exc}" ) def _post_save_act_care_period(sender, instance, created, **kwargs): """Act on CarePeriod operational_status change AFTER the save.""" from access_core.monitoring.models import CarePeriod if not isinstance(instance, CarePeriod) or created: return key = ("CarePeriod", str(instance.pk)) old_op_status = _pop_old_status_with_ttl(key) new_op_status = instance.operational_status if old_op_status == "active" and new_op_status in ( "unaligned", "ended", "withdrawn", ): logger.info( f"CarePeriod {instance.id} operational status changed: " f"{old_op_status} -> {new_op_status}" ) _log_status_change("CarePeriod", instance.id, old_op_status, new_op_status) _trigger_cms_unalignment( instance.beneficiary, reason=f"Care period status changed: {old_op_status} -> {new_op_status}", ) def _pre_save_capture_oap(sender, instance, raw=False, using=None, **kwargs): """Capture OAPMeasureObservation status BEFORE the save.""" from access_core.clinical.models import OAPMeasureObservation if not isinstance(instance, OAPMeasureObservation) or not instance.pk: return key = ("OAPMeasureObservation", str(instance.pk)) try: old = sender.objects.get(pk=instance.pk) _set_old_status_with_ttl(key, old.status) except sender.DoesNotExist: pass except Exception as exc: # noqa: BLE001 - status capture must not break save() logger.warning( f"Failed to read OAP observation {instance.pk} for status capture: {exc}" ) def _post_save_act_oap(sender, instance, created, **kwargs): """Act on OAP observation status change AFTER the save.""" from access_core.clinical.models import OAPMeasureObservation if not isinstance(instance, OAPMeasureObservation) or created: return key = ("OAPMeasureObservation", str(instance.pk)) old_status = _pop_old_status_with_ttl(key) if old_status != "approved" and instance.status == "approved": logger.info( f"OAP observation {instance.id} status changed to approved — queuing for CMS reporting" ) _log_status_change("OAPMeasureObservation", instance.id, old_status, "approved") _queue_for_reporting(instance) # ------------------------------------------------------------------ # Service trigger functions # ------------------------------------------------------------------ def _trigger_cms_alignment(intake_record): """Start CMS alignment for an IntakeRecord that just moved to pending_alignment.""" corr_id = _corr_id() try: elig_checks = intake_record.eligibility_checks.filter(check_status="eligible") if not elig_checks.exists(): logger.warning( f"No eligible eligibility check for IntakeRecord {intake_record.id} — " f"cannot determine track for alignment" ) return track = elig_checks.latest("checked_at").requested_track.track_name _log_signal_action("alignment", "IntakeRecord", intake_record.id, corr_id) from access_core.services.cms_alignment import ( CmsAlignmentError, align_beneficiary, ) try: result = align_beneficiary(intake_record, requested_track=track) if result.beneficiary: logger.info( f"CMS alignment auto-triggered: IntakeRecord {intake_record.id} " f"-> Beneficiary {result.beneficiary.id}" ) _log_signal_success( "alignment", "IntakeRecord", intake_record.id, corr_id, detail=f"Beneficiary={result.beneficiary.id}", ) except CmsAlignmentError as exc: logger.error( f"CMS alignment failed for IntakeRecord {intake_record.id}: {exc}" ) _log_signal_error( "alignment", "IntakeRecord", intake_record.id, exc, corr_id ) except Exception: logger.exception(f"Error in CMS alignment trigger for {intake_record.id}") def _trigger_cms_unalignment(beneficiary, reason: str | None = None): """Start CMS unalignment for a Beneficiary.""" corr_id = _corr_id() try: from access_core.monitoring.models import Unalignment from access_core.services.cms_unalignment import ( CmsUnalignmentError, has_in_flight_unalignment, unalign_beneficiary, ) # Idempotency guards. In-flight = submitted but the polling worker has # not applied the outcome yet — this check is unbounded because an # in-flight $unalign must never be duplicated, even after hours (the # service raises if we race past it). The 5-minute window only dedupes # recently *completed* unalignments. if has_in_flight_unalignment(beneficiary): logger.info( f"CMS unalignment skipped for Beneficiary {beneficiary.id}: " "unalignment already in flight" ) _log_signal_skipped( "unalignment", "Beneficiary", beneficiary.id, "unalignment in progress", ) return cutoff = django_timezone.now() - timedelta(minutes=5) if Unalignment.objects.filter( beneficiary=beneficiary, created_at__gte=cutoff, ).exists(): logger.info( f"CMS unalignment skipped for Beneficiary {beneficiary.id}: " "recent completed unalignment exists" ) _log_signal_skipped( "unalignment", "Beneficiary", beneficiary.id, "recent unalignment exists", ) return if not beneficiary.care_periods.filter(operational_status="active").exists(): logger.info( f"CMS unalignment skipped for Beneficiary {beneficiary.id}: no active care periods" ) _log_signal_skipped( "unalignment", "Beneficiary", beneficiary.id, "no active care periods" ) return _log_signal_action("unalignment", "Beneficiary", beneficiary.id, corr_id) try: unalign_beneficiary(beneficiary, reason=reason) logger.info(f"CMS unalignment auto-triggered: Beneficiary {beneficiary.id}") _log_signal_success("unalignment", "Beneficiary", beneficiary.id, corr_id) except CmsUnalignmentError as exc: logger.error( f"CMS unalignment failed for Beneficiary {beneficiary.id}: {exc}" ) _log_signal_error( "unalignment", "Beneficiary", beneficiary.id, exc, corr_id ) except Exception: logger.exception( f"Error in CMS unalignment trigger for Beneficiary {beneficiary.id}" ) def _queue_for_reporting(observation): """Queue an approved OAP observation for CMS data reporting.""" corr_id = _corr_id() try: from access_core.services.cms_reporting import report_oap_observations result = report_oap_observations(observations=[observation]) if result["submitted"] > 0: measure = (result.get("details") or [{}])[0].get("measure", "unknown") logger.info( f"OAP observation {observation.id} auto-reported to CMS " f"(measure={measure})" ) _log_signal_success( "reporting", "OAPMeasureObservation", observation.id, corr_id, detail=f"measure={measure}", ) except Exception: logger.exception( f"Error in CMS reporting trigger for OAP observation {observation.id}" ) # ------------------------------------------------------------------ # Signal registration — called from apps.py ready() # ------------------------------------------------------------------ def _register_intake_signals(): """Register pre_save/post_save signals on IntakeRecord.""" from access_core.intake.models import IntakeRecord pre_save.connect( _pre_save_capture_intake, sender=IntakeRecord, dispatch_uid="cms_intake_pre_save", ) post_save.connect( _post_save_act_intake, sender=IntakeRecord, dispatch_uid="cms_intake_post_save" ) def _register_care_period_signals(): """Register pre_save/post_save signals on CarePeriod.""" from access_core.monitoring.models import CarePeriod pre_save.connect( _pre_save_capture_care_period, sender=CarePeriod, dispatch_uid="cms_cp_pre_save" ) post_save.connect( _post_save_act_care_period, sender=CarePeriod, dispatch_uid="cms_cp_post_save" ) def _register_oap_signals(): """Register pre_save/post_save signals on OAPMeasureObservation.""" from access_core.clinical.models import OAPMeasureObservation pre_save.connect( _pre_save_capture_oap, sender=OAPMeasureObservation, dispatch_uid="cms_oap_pre_save", ) post_save.connect( _post_save_act_oap, sender=OAPMeasureObservation, dispatch_uid="cms_oap_post_save", ) def register_all_cms_signals(): """Register all CMS integration signals. Call from AppConfig.ready().""" _register_intake_signals() _register_care_period_signals() _register_oap_signals() logger.info("CMS ACCESS API integration signals registered")