""" Integrations models - Inbound events from external systems This module handles integration events from: - HIS (Hospital Information System) - Lab systems - Radiology systems - Pharmacy systems - MOH (Ministry of Health) - CHI (Council of Health Insurance) - Other external systems """ from django.db import models from apps.core.models import BaseChoices, TimeStampedModel, UUIDModel class EventStatus(BaseChoices): """Event processing status""" PENDING = "pending", "Pending" PROCESSING = "processing", "Processing" PROCESSED = "processed", "Processed" FAILED = "failed", "Failed" IGNORED = "ignored", "Ignored" class SourceSystem(BaseChoices): """Source system choices""" HIS = "his", "Hospital Information System" LAB = "lab", "Laboratory System" RADIOLOGY = "radiology", "Radiology System" PHARMACY = "pharmacy", "Pharmacy System" MOH = "moh", "Ministry of Health" CHI = "chi", "Council of Health Insurance" PXCONNECT = "pxconnect", "PX Connect" OTHER = "other", "Other" class InboundEvent(UUIDModel, TimeStampedModel): """ Inbound integration event from external systems. Events trigger journey stage completions. For example: - Event code: "OPD_VISIT_COMPLETED" → completes "MD Consultation" stage - Event code: "LAB_ORDER_COMPLETED" → completes "Lab" stage - Event code: "RADIOLOGY_REPORT_FINALIZED" → completes "Radiology" stage - Event code: "PHARMACY_DISPENSED" → completes "Pharmacy" stage Processing flow: 1. Event received via API (POST /api/integrations/events/) 2. Stored with status=PENDING 3. Celery task processes event: a. Find journey instance by encounter_id b. Find matching stage by trigger_event_code c. Complete the stage d. Create survey instance if configured e. Update event status to PROCESSED """ # Source information source_system = models.CharField( max_length=50, choices=SourceSystem.choices, db_index=True, help_text="System that sent this event" ) event_code = models.CharField( max_length=100, db_index=True, help_text="Event type code (e.g., OPD_VISIT_COMPLETED, LAB_ORDER_COMPLETED)" ) # Identifiers encounter_id = models.CharField(max_length=100, db_index=True, help_text="Encounter ID from HIS system") patient_identifier = models.CharField( max_length=100, blank=True, db_index=True, help_text="Patient MRN or other identifier" ) # Event data payload_json = models.JSONField(help_text="Full event payload from source system") # Processing status status = models.CharField(max_length=20, choices=EventStatus.choices, default=EventStatus.PENDING, db_index=True) # Timestamps received_at = models.DateTimeField(auto_now_add=True, db_index=True) processed_at = models.DateTimeField(null=True, blank=True) # Processing results error = models.TextField(blank=True, help_text="Error message if processing failed") processing_attempts = models.IntegerField(default=0, help_text="Number of processing attempts") # Extracted context (from payload) physician_license = models.CharField(max_length=100, blank=True, help_text="Physician license number from event") department_code = models.CharField(max_length=50, blank=True, help_text="Department code from event") # Metadata metadata = models.JSONField(default=dict, blank=True, help_text="Additional processing metadata") class Meta: ordering = ["-received_at"] indexes = [ models.Index(fields=["status", "-received_at"]), models.Index(fields=["encounter_id", "event_code"]), models.Index(fields=["source_system", "-received_at"]), ] def __str__(self): return f"{self.source_system} - {self.event_code} - {self.encounter_id} ({self.status})" def mark_processing(self): """Mark event as being processed""" self.status = EventStatus.PROCESSING self.processing_attempts += 1 self.save(update_fields=["status", "processing_attempts"]) def mark_processed(self): """Mark event as successfully processed""" from django.utils import timezone self.status = EventStatus.PROCESSED self.processed_at = timezone.now() self.save(update_fields=["status", "processed_at"]) def mark_failed(self, error_message): """Mark event as failed with error message""" self.status = EventStatus.FAILED self.error = error_message self.save(update_fields=["status", "error"]) def mark_ignored(self, reason): """Mark event as ignored (e.g., no matching journey)""" self.status = EventStatus.IGNORED self.error = reason self.save(update_fields=["status", "error"]) class IntegrationConfig(UUIDModel, TimeStampedModel): """ Configuration for external system integrations. Stores API endpoints, credentials, and mapping rules. """ name = models.CharField(max_length=200, unique=True) source_system = models.CharField(max_length=50, choices=SourceSystem.choices, unique=True) # Connection details api_url = models.URLField(blank=True, help_text="API endpoint URL") api_key = models.CharField(max_length=500, blank=True, help_text="API key (encrypted)") # Configuration is_active = models.BooleanField(default=True) config_json = models.JSONField( default=dict, blank=True, help_text="Additional configuration (event mappings, field mappings, etc.)" ) # Metadata description = models.TextField(blank=True) last_sync_at = models.DateTimeField(null=True, blank=True) class Meta: ordering = ["name"] def __str__(self): return f"{self.name} ({self.source_system})" class PatientType(BaseChoices): """HIS Patient Type codes""" INPATIENT = "IP", "Inpatient" OPD = "OP", "Outpatient" EMS = "ED", "Emergency" DAYCASE = "DAYCASE", "Day Case" APPOINTMENT = "APPOINTMENT", "Appointment" class SurveyTemplateMapping(UUIDModel, TimeStampedModel): """ Maps patient types to survey templates for automatic survey delivery. This replaces the search-based template selection with explicit mappings. Allows administrators to control which survey template is sent for each patient type and hospital. Example: - PatientType: "1" (Inpatient) → Inpatient Satisfaction Survey - PatientType: "2" (OPD) → Outpatient Satisfaction Survey - PatientType: "APPOINTMENT" → Appointment Satisfaction Survey """ # Mapping key patient_type = models.CharField( max_length=20, choices=PatientType.choices, db_index=True, help_text="Patient type from HIS system" ) # Target survey survey_template = models.ForeignKey( "surveys.SurveyTemplate", on_delete=models.CASCADE, related_name="patient_type_mappings", help_text="Survey template to send for this patient type", ) # Hospital specificity (null = global mapping) hospital = models.ForeignKey( "organizations.Hospital", on_delete=models.CASCADE, related_name="survey_template_mappings", null=True, blank=True, help_text="Hospital for this mapping (null = applies to all hospitals)", ) # Activation is_active = models.BooleanField(default=True, db_index=True, help_text="Whether this mapping is active") # Delay configuration send_delay_hours = models.IntegerField(default=1, help_text="Hours after discharge to send survey") class Meta: ordering = ["hospital", "patient_type"] indexes = [ models.Index(fields=["patient_type", "hospital", "is_active"]), ] # Ensure only one active mapping per patient type per hospital constraints = [ models.UniqueConstraint( fields=["patient_type", "hospital"], condition=models.Q(is_active=True), name="unique_active_mapping_per_type_hospital", ) ] def __str__(self): hospital_name = self.hospital.name if self.hospital else "All Hospitals" status = "Active" if self.is_active else "Inactive" return f"{self.get_patient_type_display()} -> {self.survey_template.name} ({hospital_name}) [{status}]" @staticmethod def get_template_for_patient_type(patient_type: str, hospital): """ Get the active survey template for a patient type and hospital. Search order: 1. Hospital-specific active mapping 2. Global active mapping (hospital is null) Args: patient_type: HIS PatientType code (e.g., "1", "2", "APPOINTMENT") hospital: Hospital instance Returns: SurveyTemplate or None if no active mapping found """ # Try hospital-specific mapping first mapping = SurveyTemplateMapping.objects.filter( patient_type=patient_type, hospital=hospital, is_active=True ).first() if mapping: return mapping.survey_template # Fall back to global mapping mapping = SurveyTemplateMapping.objects.filter( patient_type=patient_type, hospital__isnull=True, is_active=True ).first() return mapping.survey_template if mapping else None class HISPatientVisit(UUIDModel, TimeStampedModel): """ Stores patient visit data fetched from HIS system. Decoupled from survey creation - patient and visit data are saved on every fetch regardless of whether the visit is complete. Survey is created and linked only when the visit is complete. """ patient = models.ForeignKey( "organizations.Patient", on_delete=models.CASCADE, related_name="his_visits", null=True, blank=True, ) hospital = models.ForeignKey( "organizations.Hospital", on_delete=models.SET_NULL, null=True, blank=True, related_name="his_visits", ) admission_id = models.CharField(max_length=100, db_index=True) reg_code = models.CharField(max_length=100, blank=True, db_index=True) patient_id_his = models.CharField(max_length=100, blank=True, db_index=True, help_text="PatientID from HIS") patient_type = models.CharField(max_length=10, help_text="ED, IP, OP from HIS") admit_date = models.DateTimeField(null=True, blank=True) discharge_date = models.DateTimeField(null=True, blank=True, help_text="From HIS DischargeDate (null for OP)") effective_discharge_date = models.DateTimeField( null=True, blank=True, help_text="For OP: last visit timestamp when deemed complete" ) visit_data = models.JSONField(default=dict, blank=True, help_text="Full patient demographic dict from HIS") visit_timeline = models.JSONField(default=list, blank=True, help_text="Extracted visit events for this patient") primary_doctor = models.CharField( max_length=300, blank=True, help_text="From HIS PrimaryDoctor (raw text fallback)" ) primary_doctor_fk = models.ForeignKey( "organizations.Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="his_visits_as_doctor", help_text="Resolved Staff record from PrimaryDoctor ID prefix", ) consultant_id = models.CharField(max_length=50, blank=True, help_text="From HIS ConsultantID (raw text fallback)") consultant_fk = models.ForeignKey( "organizations.Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="his_visits_as_consultant", help_text="Resolved Staff record from ConsultantID", ) company_name = models.CharField(max_length=300, blank=True, help_text="From HIS CompanyName (insurance sponsor)") grade_name = models.CharField(max_length=100, blank=True, help_text="From HIS GradeName (insurance grade)") insurance_company_name = models.CharField(max_length=300, blank=True, help_text="From HIS InsuranceCompanyName") bill_type = models.CharField(max_length=20, blank=True, help_text="From HIS BillType (CS/CR)") is_vip = models.BooleanField(default=False, db_index=True, help_text="From HIS IsVIP") nationality = models.CharField(max_length=100, blank=True, help_text="From HIS PatientNationality") is_visit_complete = models.BooleanField(default=False, db_index=True) survey_instance = models.ForeignKey( "surveys.SurveyInstance", on_delete=models.SET_NULL, null=True, blank=True, related_name="his_visit", ) last_his_fetch_at = models.DateTimeField( null=True, blank=True, help_text="Last time this visit was seen in a HIS fetch" ) class Meta: ordering = ["-created_at"] indexes = [ models.Index(fields=["patient_type", "is_visit_complete"]), models.Index(fields=["patient_type", "last_his_fetch_at"]), models.Index(fields=["admission_id", "is_visit_complete"]), ] constraints = [models.UniqueConstraint(fields=["admission_id"], name="unique_his_visit_per_admission")] def __str__(self): name = self.patient.get_full_name() if self.patient else self.patient_id_his return f"{name} ({self.patient_type}) - {self.admission_id}" @property def doctor_display(self): if self.primary_doctor_fk: return str(self.primary_doctor_fk) return self.primary_doctor or "-" @property def consultant_display(self): if self.consultant_fk: return str(self.consultant_fk) return self.consultant_id or "-" class HISVisitEvent(UUIDModel, TimeStampedModel): """ Individual visit event from HIS timeline. Extracted from visit_timeline JSON for better querying and filtering. Each event represents a single touchpoint in the patient journey. """ visit = models.ForeignKey( HISPatientVisit, on_delete=models.CASCADE, related_name="visit_events", ) event_type = models.CharField(max_length=200, blank=True, help_text="Type from HIS (e.g., 'Registration')") bill_date = models.CharField(max_length=50, blank=True, help_text="Raw BillDate from HIS (DD-Mon-YYYY HH:MM)") parsed_date = models.DateTimeField(null=True, blank=True, db_index=True, help_text="Parsed bill_date") patient_type = models.CharField(max_length=10, blank=True, help_text="PatientType for this event") visit_category = models.CharField(max_length=10, blank=True, help_text="Visit category: ED, IP, OP") admission_id = models.CharField(max_length=100, blank=True, db_index=True) patient_id = models.CharField(max_length=100, blank=True, db_index=True, help_text="PatientID from HIS") reg_code = models.CharField(max_length=100, blank=True, db_index=True) ssn = models.CharField(max_length=50, blank=True, db_index=True) mobile_no = models.CharField(max_length=50, blank=True) class Meta: ordering = ["parsed_date"] indexes = [ models.Index(fields=["visit", "parsed_date"]), models.Index(fields=["visit_category", "parsed_date"]), ] def __str__(self): return f"{self.event_type} ({self.visit_category}) - {self.admission_id}" class HISEventType(UUIDModel, TimeStampedModel): """ Unique event types extracted from HIS data. Auto-populated when HISPatientVisit events are synced. Used to populate the event type dropdown in question configuration. """ event_type = models.CharField( max_length=200, unique=True, db_index=True, help_text="HIS event type name (e.g., 'Lab Bill', 'Triage')", ) patient_types = models.JSONField( default=list, blank=True, help_text="Patient types that have this event: ['OP', 'IP', 'ED']", ) event_count = models.IntegerField( default=0, help_text="Total number of times this event type has been seen", ) last_seen_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["event_type"] verbose_name = "HIS Event Type" verbose_name_plural = "HIS Event Types" def __str__(self): types = ", ".join(self.patient_types) if self.patient_types else "N/A" return f"{self.event_type} ({types})" class EventMapping(UUIDModel, TimeStampedModel): """ Maps external event codes to internal trigger codes. Example: - External: "VISIT_COMPLETE" → Internal: "OPD_VISIT_COMPLETED" - External: "LAB_RESULT_READY" → Internal: "LAB_ORDER_COMPLETED" """ integration_config = models.ForeignKey(IntegrationConfig, on_delete=models.CASCADE, related_name="event_mappings") external_event_code = models.CharField(max_length=100, help_text="Event code from external system") internal_event_code = models.CharField(max_length=100, help_text="Internal event code used in journey stages") # Field mappings field_mappings = models.JSONField( default=dict, blank=True, help_text="Maps external field names to internal field names" ) is_active = models.BooleanField(default=True) class Meta: unique_together = [["integration_config", "external_event_code"]] ordering = ["integration_config", "external_event_code"] def __str__(self): return f"{self.external_event_code} → {self.internal_event_code}" class HISTestPatient(TimeStampedModel): """Test patient data loaded from visit_data.json for testing HIS integration.""" admission_id = models.CharField(max_length=100, db_index=True) patient_id = models.CharField(max_length=100, db_index=True) patient_type = models.CharField(max_length=10, db_index=True) reg_code = models.CharField(max_length=100, blank=True, db_index=True) ssn = models.CharField(max_length=50, blank=True, db_index=True) mobile_no = models.CharField(max_length=20, blank=True, db_index=True) admit_date = models.DateTimeField(db_index=True) discharge_date = models.DateTimeField(null=True, blank=True) patient_data = models.JSONField(default=dict) hospital_id = models.CharField(max_length=20, blank=True) hospital_name = models.CharField(max_length=200, blank=True) patient_name = models.CharField(max_length=300, blank=True) class Meta: ordering = ["admit_date"] indexes = [ models.Index(fields=["patient_type", "admit_date"]), models.Index(fields=["ssn", "admit_date"]), models.Index(fields=["mobile_no", "admit_date"]), ] constraints = [models.UniqueConstraint(fields=["admission_id"], name="unique_test_patient_admission")] def __str__(self): return f"{self.patient_name} ({self.patient_type}) - {self.admission_id}" class HISTestVisit(TimeStampedModel): """Test visit events loaded from visit_data.json for testing HIS integration.""" admission_id = models.CharField(max_length=100, db_index=True) patient_id = models.CharField(max_length=100, db_index=True) visit_category = models.CharField(max_length=10, db_index=True) event_type = models.CharField(max_length=200, blank=True) bill_date = models.DateTimeField(null=True, blank=True, db_index=True) reg_code = models.CharField(max_length=100, blank=True) ssn = models.CharField(max_length=50, blank=True, db_index=True) mobile_no = models.CharField(max_length=20, blank=True, db_index=True) visit_data = models.JSONField(default=dict) class Meta: ordering = ["bill_date"] indexes = [ models.Index(fields=["admission_id", "visit_category"]), models.Index(fields=["patient_id", "visit_category"]), models.Index(fields=["admission_id", "bill_date"]), ] def __str__(self): return f"{self.event_type} ({self.visit_category}) - {self.admission_id}"