HH/apps/journeys/models.py
2026-04-08 17:13:35 +03:00

293 lines
10 KiB
Python

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