""" 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)" DAY_CASE = "day_case", "Day Case" 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"]), ] 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, 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=["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