""" 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" ) # Post-discharge survey configuration send_post_discharge_survey = models.BooleanField( default=False, help_text="Send a comprehensive survey after patient discharge" ) post_discharge_survey_delay_hours = models.IntegerField( default=1, help_text="Hours after discharge to send the survey" ) 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 # Note: survey_template is used for post-discharge survey question merging # Auto-sending surveys after each stage has been removed survey_template = models.ForeignKey( 'surveys.SurveyTemplate', on_delete=models.SET_NULL, null=True, blank=True, related_name='journey_stages', help_text="Survey template containing questions for this stage (merged into post-discharge survey)" ) # Configuration is_optional = models.BooleanField( default=False, help_text="Can this stage be skipped?" ) is_active = models.BooleanField(default=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) # Context from event staff = models.ForeignKey( 'organizations.Staff', on_delete=models.SET_NULL, null=True, blank=True, related_name='journey_stages', help_text="Staff member 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" ) # 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, staff=None, department=None, metadata=None): """ Mark stage as completed. This method should be called by event processing task. It will: 1. Update status to COMPLETED 2. Set completion timestamp 3. Attach staff, department """ from django.utils import timezone if not self.can_complete(): return False self.status = StageStatus.COMPLETED self.completed_at = timezone.now() if staff: self.staff = staff 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