383 lines
12 KiB
Python
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
|