HH/apps/journeys/models.py

383 lines
12 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)'
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
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"
)
# 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, 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 event, staff, 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 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