HH/apps/integrations/models.py

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}"