354 lines
11 KiB
Python
354 lines
11 KiB
Python
"""
|
|
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 = '1', 'Inpatient (Type 1)'
|
|
OPD = '2', 'Outpatient (Type 2)'
|
|
EMS = '3', 'Emergency (Type 3)'
|
|
DAYCASE = '4', 'Day Case (Type 4)'
|
|
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"
|
|
)
|
|
|
|
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 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}"
|