293 lines
10 KiB
Python
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
|