""" Journeys models - Patient journey templates and instances This module implements the core journey tracking system: - Journey templates define the stages for EMS/Inpatient/OPD pathways - Journey instances track actual patient encounters - Stage instances track completion of each stage - Integration events trigger stage completions """ from django.db import models from apps.core.models import BaseChoices, StatusChoices, TimeStampedModel, UUIDModel class JourneyType(BaseChoices): """Journey type choices""" EMS = 'ems', 'EMS (Emergency Medical Services)' INPATIENT = 'inpatient', 'Inpatient' OPD = 'opd', 'OPD (Outpatient Department)' class StageStatus(BaseChoices): """Stage instance status choices""" PENDING = 'pending', 'Pending' IN_PROGRESS = 'in_progress', 'In Progress' COMPLETED = 'completed', 'Completed' SKIPPED = 'skipped', 'Skipped' CANCELLED = 'cancelled', 'Cancelled' class PatientJourneyTemplate(UUIDModel, TimeStampedModel): """ Journey template defines the stages for a patient journey type. Example: OPD journey with stages: 1. MD Consultation (trigger: OPD_VISIT_COMPLETED) 2. Lab (trigger: LAB_ORDER_COMPLETED) 3. Radiology (trigger: RADIOLOGY_REPORT_FINALIZED) 4. Pharmacy (trigger: PHARMACY_DISPENSED) """ name = models.CharField(max_length=200) name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)") journey_type = models.CharField( max_length=20, choices=JourneyType.choices, db_index=True ) description = models.TextField(blank=True) # Configuration hospital = models.ForeignKey( 'organizations.Hospital', on_delete=models.CASCADE, related_name='journey_templates', help_text="Hospital this template belongs to" ) is_active = models.BooleanField(default=True, db_index=True) is_default = models.BooleanField( default=False, help_text="Default template for this journey type in this hospital" ) class Meta: ordering = ['hospital', 'journey_type', 'name'] unique_together = [['hospital', 'journey_type', 'name']] indexes = [ models.Index(fields=['hospital', 'journey_type', 'is_active']), ] def __str__(self): return f"{self.hospital.name} - {self.get_journey_type_display()} - {self.name}" class PatientJourneyStageTemplate(UUIDModel, TimeStampedModel): """ Stage template defines a stage within a journey. Each stage: - Has a trigger_event_code that maps to integration events - Can optionally send a survey when completed - Has an order for sequencing Example: "MD Consultation" stage - trigger_event_code: "OPD_VISIT_COMPLETED" - survey_template: MD Consultation Survey - auto_send_survey: True """ journey_template = models.ForeignKey( PatientJourneyTemplate, on_delete=models.CASCADE, related_name='stages' ) # Stage identification name = models.CharField(max_length=200) name_ar = models.CharField(max_length=200, blank=True) code = models.CharField( max_length=50, help_text="Unique code for this stage (e.g., OPD_MD_CONSULT, LAB, RADIOLOGY)" ) order = models.IntegerField( default=0, help_text="Order of this stage in the journey" ) # Event trigger configuration trigger_event_code = models.CharField( max_length=100, help_text="Event code that triggers completion of this stage (e.g., OPD_VISIT_COMPLETED)", db_index=True ) # Survey configuration survey_template = models.ForeignKey( 'surveys.SurveyTemplate', on_delete=models.SET_NULL, null=True, blank=True, related_name='journey_stages', help_text="Survey to send when this stage completes" ) auto_send_survey = models.BooleanField( default=False, help_text="Automatically send survey when stage completes" ) survey_delay_hours = models.IntegerField( default=0, help_text="Hours to wait before sending survey (0 = immediate)" ) # Requirements requires_physician = models.BooleanField( default=False, help_text="Does this stage require physician information?" ) requires_department = models.BooleanField( default=False, help_text="Does this stage require department information?" ) # Configuration is_optional = models.BooleanField( default=False, help_text="Can this stage be skipped?" ) is_active = models.BooleanField(default=True) description = models.TextField(blank=True) class Meta: ordering = ['journey_template', 'order'] unique_together = [['journey_template', 'code']] indexes = [ models.Index(fields=['journey_template', 'order']), models.Index(fields=['trigger_event_code']), ] def __str__(self): return f"{self.journey_template.name} - {self.name} (Order: {self.order})" class PatientJourneyInstance(UUIDModel, TimeStampedModel): """ Journey instance tracks an actual patient's journey through a pathway. Linked to: - Journey template (defines the stages) - Patient (who is going through the journey) - Encounter ID (from HIS system) """ journey_template = models.ForeignKey( PatientJourneyTemplate, on_delete=models.PROTECT, related_name='instances' ) # Patient and encounter information patient = models.ForeignKey( 'organizations.Patient', on_delete=models.CASCADE, related_name='journeys' ) encounter_id = models.CharField( max_length=100, unique=True, db_index=True, help_text="Unique encounter ID from HIS system" ) # Journey metadata hospital = models.ForeignKey( 'organizations.Hospital', on_delete=models.CASCADE, related_name='journey_instances' ) department = models.ForeignKey( 'organizations.Department', on_delete=models.SET_NULL, null=True, blank=True, related_name='journey_instances' ) # Status tracking status = models.CharField( max_length=20, choices=StatusChoices.choices, default=StatusChoices.ACTIVE, db_index=True ) # Timestamps started_at = models.DateTimeField(auto_now_add=True) completed_at = models.DateTimeField(null=True, blank=True) # Metadata metadata = models.JSONField( default=dict, blank=True, help_text="Additional metadata from HIS system" ) class Meta: ordering = ['-started_at'] indexes = [ models.Index(fields=['encounter_id']), models.Index(fields=['patient', '-started_at']), models.Index(fields=['hospital', 'status', '-started_at']), ] def __str__(self): return f"{self.patient.get_full_name()} - {self.journey_template.name} ({self.encounter_id})" def get_completion_percentage(self): """Calculate journey completion percentage""" total_stages = self.stage_instances.count() if total_stages == 0: return 0 completed_stages = self.stage_instances.filter(status=StageStatus.COMPLETED).count() return int((completed_stages / total_stages) * 100) def is_complete(self): """Check if all required stages are completed""" required_stages = self.stage_instances.filter( stage_template__is_optional=False ) return not required_stages.exclude(status=StageStatus.COMPLETED).exists() class PatientJourneyStageInstance(UUIDModel, TimeStampedModel): """ Stage instance tracks completion of a specific stage in a patient's journey. Status flow: 1. PENDING - Stage created but not started 2. IN_PROGRESS - Event received, processing 3. COMPLETED - Stage completed successfully 4. SKIPPED - Stage skipped (optional stages only) 5. CANCELLED - Journey cancelled When completed: - If auto_send_survey=True, create SurveyInstance - Record completion timestamp - Attach physician/department if provided """ journey_instance = models.ForeignKey( PatientJourneyInstance, on_delete=models.CASCADE, related_name='stage_instances' ) stage_template = models.ForeignKey( PatientJourneyStageTemplate, on_delete=models.PROTECT, related_name='instances' ) # Status status = models.CharField( max_length=20, choices=StageStatus.choices, default=StageStatus.PENDING, db_index=True ) # Completion details completed_at = models.DateTimeField(null=True, blank=True, db_index=True) completed_by_event = models.ForeignKey( 'integrations.InboundEvent', on_delete=models.SET_NULL, null=True, blank=True, related_name='completed_stages', help_text="Integration event that completed this stage" ) # Context from event physician = models.ForeignKey( 'organizations.Physician', on_delete=models.SET_NULL, null=True, blank=True, related_name='journey_stages', help_text="Physician associated with this stage" ) department = models.ForeignKey( 'organizations.Department', on_delete=models.SET_NULL, null=True, blank=True, related_name='journey_stages', help_text="Department where this stage occurred" ) # Survey tracking survey_instance = models.ForeignKey( 'surveys.SurveyInstance', on_delete=models.SET_NULL, null=True, blank=True, related_name='journey_stage', help_text="Survey instance created for this stage" ) survey_sent_at = models.DateTimeField(null=True, blank=True) # Metadata metadata = models.JSONField( default=dict, blank=True, help_text="Additional data from integration event" ) class Meta: ordering = ['journey_instance', 'stage_template__order'] unique_together = [['journey_instance', 'stage_template']] indexes = [ models.Index(fields=['journey_instance', 'status']), models.Index(fields=['status', 'completed_at']), ] def __str__(self): return f"{self.journey_instance.encounter_id} - {self.stage_template.name} ({self.status})" def can_complete(self): """Check if this stage can be completed""" return self.status in [StageStatus.PENDING, StageStatus.IN_PROGRESS] def complete(self, event=None, physician=None, department=None, metadata=None): """ Mark stage as completed. This method should be called by the event processing task. It will: 1. Update status to COMPLETED 2. Set completion timestamp 3. Attach event, physician, department 4. Trigger survey creation if configured """ from django.utils import timezone if not self.can_complete(): return False self.status = StageStatus.COMPLETED self.completed_at = timezone.now() self.completed_by_event = event if physician: self.physician = physician if department: self.department = department if metadata: self.metadata.update(metadata) self.save() # Check if journey is complete if self.journey_instance.is_complete(): self.journey_instance.status = StatusChoices.COMPLETED self.journey_instance.completed_at = timezone.now() self.journey_instance.save() return True