""" Integrations Celery tasks This module contains the core event processing logic that: 1. Processes inbound events from external systems 2. Finds matching journey instances 3. Completes journey stages 4. Triggers survey creation 5. Fetches surveys from HIS systems (every 5 minutes) """ import logging from celery import shared_task from django.db import transaction from django.utils import timezone logger = logging.getLogger("apps.integrations") @shared_task(bind=True, max_retries=3) def process_inbound_event(self, event_id): """ Process an inbound integration event. This is the core event processing task that: 1. Finds the journey instance by encounter_id 2. Finds the matching stage by trigger_event_code 3. Completes the stage 4. Creates survey instance if configured 5. Logs audit events Args: event_id: UUID of the InboundEvent to process Returns: dict: Processing result with status and details """ from apps.core.services import create_audit_log from apps.integrations.models import InboundEvent from apps.journeys.models import PatientJourneyInstance, PatientJourneyStageInstance, StageStatus from apps.organizations.models import Department, Staff try: # Get the event event = InboundEvent.objects.get(id=event_id) event.mark_processing() logger.info(f"Processing event {event.id}: {event.event_code} for encounter {event.encounter_id}") # Find journey instance by encounter_id try: journey_instance = PatientJourneyInstance.objects.select_related( "journey_template", "patient", "hospital" ).get(encounter_id=event.encounter_id) except PatientJourneyInstance.DoesNotExist: error_msg = f"No journey instance found for encounter {event.encounter_id}" logger.warning(error_msg) event.mark_ignored(error_msg) return {"status": "ignored", "reason": error_msg} # Find matching stage by trigger_event_code matching_stages = journey_instance.stage_instances.filter( stage_template__trigger_event_code=event.event_code, status__in=[StageStatus.PENDING, StageStatus.IN_PROGRESS], ).select_related("stage_template") if not matching_stages.exists(): error_msg = f"No pending stage found with trigger {event.event_code}" logger.warning(error_msg) event.mark_ignored(error_msg) return {"status": "ignored", "reason": error_msg} # Get the first matching stage stage_instance = matching_stages.first() # Extract staff and department from event payload staff = None department = None if event.physician_license: try: staff = Staff.objects.get(license_number=event.physician_license, hospital=journey_instance.hospital) except Staff.DoesNotExist: logger.warning(f"Staff member not found with license: {event.physician_license}") if event.department_code: try: department = Department.objects.get(code=event.department_code, hospital=journey_instance.hospital) except Department.DoesNotExist: logger.warning(f"Department not found: {event.department_code}") # Complete the stage with transaction.atomic(): success = stage_instance.complete( event=event, staff=staff, department=department, metadata=event.payload_json ) if success: # Log stage completion create_audit_log( event_type="stage_completed", description=f"Stage {stage_instance.stage_template.name} completed for encounter {event.encounter_id}", content_object=stage_instance, metadata={ "event_code": event.event_code, "stage_name": stage_instance.stage_template.name, "journey_type": journey_instance.journey_template.journey_type, }, ) # Check if this is a discharge event if event.event_code.upper() == "PATIENT_DISCHARGED": logger.info(f"Discharge event received for encounter {event.encounter_id}") # Mark journey as completed journey_instance.status = "completed" journey_instance.completed_at = timezone.now() journey_instance.save() # Check if post-discharge survey is enabled if journey_instance.journey_template.send_post_discharge_survey: logger.info( f"Post-discharge survey enabled for journey {journey_instance.id}. " f"Will send in {journey_instance.journey_template.post_discharge_survey_delay_hours} hour(s)" ) # Queue post-discharge survey creation task with delay from apps.surveys.tasks import create_post_discharge_survey delay_hours = journey_instance.journey_template.post_discharge_survey_delay_hours delay_seconds = delay_hours * 3600 create_post_discharge_survey.apply_async( args=[str(journey_instance.id)], countdown=delay_seconds ) logger.info( f"Queued post-discharge survey for journey {journey_instance.id} (delay: {delay_hours}h)" ) else: logger.info(f"Post-discharge survey disabled for journey {journey_instance.id}") # Mark event as processed event.mark_processed() logger.info( f"Successfully processed event {event.id}: Completed stage {stage_instance.stage_template.name}" ) return { "status": "processed", "stage_completed": stage_instance.stage_template.name, "journey_completion": journey_instance.get_completion_percentage(), } else: error_msg = "Failed to complete stage" event.mark_failed(error_msg) return {"status": "failed", "reason": error_msg} except InboundEvent.DoesNotExist: error_msg = f"Event {event_id} not found" logger.error(error_msg) return {"status": "error", "reason": error_msg} except Exception as e: error_msg = f"Error processing event: {str(e)}" logger.error(error_msg, exc_info=True) try: event.mark_failed(error_msg) except: pass # Retry the task raise self.retry(exc=e, countdown=60 * (self.request.retries + 1)) @shared_task def process_pending_events(): """ Periodic task to process pending events. This task runs every minute (configured in config/celery.py) and processes all pending events. """ from apps.integrations.models import InboundEvent pending_events = InboundEvent.objects.filter(status="pending").order_by("received_at")[ :100 ] # Process max 100 at a time processed_count = 0 for event in pending_events: # Queue individual event for processing process_inbound_event.delay(str(event.id)) processed_count += 1 if processed_count > 0: logger.info(f"Queued {processed_count} pending events for processing") return {"queued": processed_count} # ============================================================================= # HIS Survey Fetching Tasks # ============================================================================= @shared_task def fetch_his_surveys(): """ Periodic task to fetch surveys from HIS system every 5 minutes. This task: 1. Connects to configured HIS API endpoints 2. Fetches discharged patients for the last 5-minute window 3. Processes each patient using HISAdapter 4. Creates and sends surveys via SMS Scheduled to run every 5 minutes via Celery Beat. Returns: dict: Summary of fetched and processed surveys """ from datetime import timedelta from apps.integrations.models import IntegrationConfig, SourceSystem from apps.integrations.services.his_adapter import HISAdapter from apps.integrations.services.his_client import HISClient, HISClientFactory logger.info("Starting HIS survey fetch task") result = { "success": False, "clients_processed": 0, "patients_fetched": 0, "surveys_created": 0, "surveys_sent": 0, "errors": [], "details": [], } try: # Get all active HIS clients clients = HISClientFactory.get_all_active_clients() if not clients: msg = "No active HIS configurations found" logger.warning(msg) result["errors"].append(msg) return result # Calculate fetch window (last 5 minutes) # This ensures we don't miss any patients due to timing issues fetch_since = timezone.now() - timedelta(minutes=5) logger.info(f"Fetching discharged patients since {fetch_since}") for client in clients: client_result = { "config": client.config.name if client.config else "Default", "patients_fetched": 0, "surveys_created": 0, "surveys_sent": 0, "errors": [], } try: # Test connection first connection_test = client.test_connection() if not connection_test["success"]: error_msg = f"HIS connection failed for {client_result['config']}: {connection_test['message']}" logger.error(error_msg) client_result["errors"].append(error_msg) result["details"].append(client_result) continue logger.info(f"Fetching discharged patients from {client_result['config']} since {fetch_since}") # Fetch discharged patients for the 5-minute window patients = client.fetch_discharged_patients( since=fetch_since, limit=100, # Max 100 patients per fetch ) client_result["patients_fetched"] = len(patients) result["patients_fetched"] += len(patients) if not patients: logger.info(f"No new discharged patients found in {client_result['config']}") result["details"].append(client_result) continue logger.info(f"Fetched {len(patients)} patients from {client_result['config']}") # Process each patient for patient_data in patients: try: # Ensure patient data is in proper HIS format if isinstance(patient_data, dict): if "FetchPatientDataTimeStampList" not in patient_data: # Wrap in proper format if needed patient_data = { "FetchPatientDataTimeStampList": [patient_data], "FetchPatientDataTimeStampVisitDataList": [], "Code": 200, "Status": "Success", } # Process using HISAdapter process_result = HISAdapter.process_his_data(patient_data) if process_result["success"]: client_result["surveys_created"] += 1 result["surveys_created"] += 1 if process_result.get("survey_sent"): client_result["surveys_sent"] += 1 result["surveys_sent"] += 1 else: error_msg = f"Failed to process patient: {process_result.get('message', 'Unknown error')}" logger.warning(error_msg) client_result["errors"].append(error_msg) except Exception as e: error_msg = f"Error processing patient data: {str(e)}" logger.error(error_msg, exc_info=True) client_result["errors"].append(error_msg) # Update last sync timestamp if client.config: client.config.last_sync_at = timezone.now() client.config.save(update_fields=["last_sync_at"]) result["clients_processed"] += 1 except Exception as e: error_msg = f"Error processing HIS client {client_result['config']}: {str(e)}" logger.error(error_msg, exc_info=True) client_result["errors"].append(error_msg) result["details"].append(client_result) result["success"] = True logger.info( f"HIS survey fetch completed: {result['clients_processed']} clients, " f"{result['patients_fetched']} patients, " f"{result['surveys_created']} surveys created, " f"{result['surveys_sent']} surveys sent" ) except Exception as e: error_msg = f"Fatal error in fetch_his_surveys task: {str(e)}" logger.error(error_msg, exc_info=True) result["errors"].append(error_msg) return result @shared_task def test_his_connection(config_id=None): """ Test connectivity to HIS system. Args: config_id: Optional IntegrationConfig ID. If not provided, tests the default HIS configuration. Returns: dict: Connection test results """ from apps.integrations.models import IntegrationConfig, SourceSystem from apps.integrations.services.his_client import HISClient logger.info(f"Testing HIS connection for config_id={config_id}") try: if config_id: config = IntegrationConfig.objects.filter(id=config_id, source_system=SourceSystem.HIS).first() if not config: return {"success": False, "message": f"HIS configuration with ID {config_id} not found"} client = HISClient(config) else: client = HISClient() result = client.test_connection() logger.info(f"HIS connection test result: {result}") return result except Exception as e: error_msg = f"Error testing HIS connection: {str(e)}" logger.error(error_msg, exc_info=True) return {"success": False, "message": error_msg} @shared_task def sync_his_survey_mappings(): """ Sync survey template mappings from HIS system. This task can be scheduled periodically to sync any mapping configurations from the HIS system. Returns: dict: Sync results """ from apps.integrations.models import SurveyTemplateMapping from apps.organizations.models import Hospital logger.info("Starting HIS survey mappings sync") result = {"success": True, "mappings_synced": 0, "errors": []} try: # Get all active mappings mappings = SurveyTemplateMapping.objects.filter(is_active=True) for mapping in mappings: try: # Validate mapping if not mapping.survey_template: result["errors"].append(f"Mapping {mapping.id} has no survey template") continue # Additional sync logic can be added here # e.g., fetching updated mapping rules from HIS result["mappings_synced"] += 1 except Exception as e: result["errors"].append(f"Error syncing mapping {mapping.id}: {str(e)}") logger.info(f"HIS survey mappings sync completed: {result['mappings_synced']} mappings") except Exception as e: error_msg = f"Error syncing HIS mappings: {str(e)}" logger.error(error_msg, exc_info=True) result["success"] = False result["errors"].append(error_msg) return result