2762 lines
81 KiB
Python
2762 lines
81 KiB
Python
"""
|
|
Appointments app models for hospital management system.
|
|
Provides appointment scheduling, queue management, and telemedicine functionality.
|
|
"""
|
|
|
|
import uuid
|
|
from django.db import models
|
|
from django.core.validators import RegexValidator, MinValueValidator, MaxValueValidator
|
|
from django.utils import timezone
|
|
from django.conf import settings
|
|
from datetime import timedelta, datetime, time
|
|
import json
|
|
|
|
|
|
class AppointmentRequest(models.Model):
|
|
"""
|
|
Appointment request model for scheduling patient appointments.
|
|
"""
|
|
|
|
class AppointmentType(models.TextChoices):
|
|
CONSULTATION = 'CONSULTATION', 'Consultation'
|
|
FOLLOW_UP = 'FOLLOW_UP', 'Follow-up'
|
|
PROCEDURE = 'PROCEDURE', 'Procedure'
|
|
SURGERY = 'SURGERY', 'Surgery'
|
|
DIAGNOSTIC = 'DIAGNOSTIC', 'Diagnostic'
|
|
THERAPY = 'THERAPY', 'Therapy'
|
|
VACCINATION = 'VACCINATION', 'Vaccination'
|
|
SCREENING = 'SCREENING', 'Screening'
|
|
EMERGENCY = 'EMERGENCY', 'Emergency'
|
|
TELEMEDICINE = 'TELEMEDICINE', 'Telemedicine'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
class Specialty(models.TextChoices):
|
|
FAMILY_MEDICINE = 'FAMILY_MEDICINE', 'Family Medicine'
|
|
INTERNAL_MEDICINE = 'INTERNAL_MEDICINE', 'Internal Medicine'
|
|
PEDIATRICS = 'PEDIATRICS', 'Pediatrics'
|
|
CARDIOLOGY = 'CARDIOLOGY', 'Cardiology'
|
|
DERMATOLOGY = 'DERMATOLOGY', 'Dermatology'
|
|
ENDOCRINOLOGY = 'ENDOCRINOLOGY', 'Endocrinology'
|
|
GASTROENTEROLOGY = 'GASTROENTEROLOGY', 'Gastroenterology'
|
|
NEUROLOGY = 'NEUROLOGY', 'Neurology'
|
|
ONCOLOGY = 'ONCOLOGY', 'Oncology'
|
|
ORTHOPEDICS = 'ORTHOPEDICS', 'Orthopedics'
|
|
PSYCHIATRY = 'PSYCHIATRY', 'Psychiatry'
|
|
RADIOLOGY = 'RADIOLOGY', 'Radiology'
|
|
SURGERY = 'SURGERY', 'Surgery'
|
|
UROLOGY = 'UROLOGY', 'Urology'
|
|
GYNECOLOGY = 'GYNECOLOGY', 'Gynecology'
|
|
OPHTHALMOLOGY = 'OPHTHALMOLOGY', 'Ophthalmology'
|
|
ENT = 'ENT', 'Ear, Nose & Throat'
|
|
EMERGENCY = 'EMERGENCY', 'Emergency Medicine'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
class Priority(models.TextChoices):
|
|
ROUTINE = 'ROUTINE', 'Routine'
|
|
URGENT = 'URGENT', 'Urgent'
|
|
STAT = 'STAT', 'STAT'
|
|
EMERGENCY = 'EMERGENCY', 'Emergency'
|
|
|
|
class AppointmentStatus(models.TextChoices):
|
|
PENDING = 'PENDING', 'Pending'
|
|
SCHEDULED = 'SCHEDULED', 'Scheduled'
|
|
CONFIRMED = 'CONFIRMED', 'Confirmed'
|
|
CHECKED_IN = 'CHECKED_IN', 'Checked In'
|
|
IN_PROGRESS = 'IN_PROGRESS', 'In Progress'
|
|
COMPLETED = 'COMPLETED', 'Completed'
|
|
CANCELLED = 'CANCELLED', 'Cancelled'
|
|
NO_SHOW = 'NO_SHOW', 'No Show'
|
|
RESCHEDULED = 'RESCHEDULED', 'Rescheduled'
|
|
|
|
class TelemedicinePlatform(models.TextChoices):
|
|
ZOOM = 'ZOOM', 'Zoom'
|
|
TEAMS = 'TEAMS', 'Microsoft Teams'
|
|
WEBEX = 'WEBEX', 'Cisco Webex'
|
|
DOXY = 'DOXY', 'Doxy.me'
|
|
CUSTOM = 'CUSTOM', 'Custom Platform'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
# Basic Identifiers
|
|
request_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique appointment request identifier'
|
|
)
|
|
|
|
# Tenant relationship
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='appointment_requests',
|
|
help_text='Organization tenant'
|
|
)
|
|
|
|
# Patient Information
|
|
patient = models.ForeignKey(
|
|
'patients.PatientProfile',
|
|
on_delete=models.CASCADE,
|
|
related_name='appointment_requests',
|
|
help_text='Patient requesting appointment'
|
|
)
|
|
|
|
# Provider Information
|
|
provider = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name='provider_appointments',
|
|
help_text='Healthcare provider'
|
|
)
|
|
|
|
# Appointment Details
|
|
appointment_type = models.CharField(
|
|
max_length=50,
|
|
choices=AppointmentType.choices,
|
|
help_text='Type of appointment'
|
|
)
|
|
|
|
specialty = models.CharField(
|
|
max_length=100,
|
|
choices=Specialty.choices,
|
|
help_text='Medical specialty'
|
|
)
|
|
|
|
# Scheduling Information
|
|
preferred_date = models.DateField(
|
|
help_text='Patient preferred appointment date'
|
|
)
|
|
preferred_time = models.TimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Patient preferred appointment time'
|
|
)
|
|
duration_minutes = models.PositiveIntegerField(
|
|
default=30,
|
|
validators=[MinValueValidator(15), MaxValueValidator(480)],
|
|
help_text='Appointment duration in minutes'
|
|
)
|
|
|
|
# Scheduling Flexibility
|
|
flexible_scheduling = models.BooleanField(
|
|
default=True,
|
|
help_text='Patient accepts alternative times'
|
|
)
|
|
earliest_acceptable_date = models.DateField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Earliest acceptable appointment date'
|
|
)
|
|
latest_acceptable_date = models.DateField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Latest acceptable appointment date'
|
|
)
|
|
acceptable_times = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
help_text='Acceptable time slots (JSON array)'
|
|
)
|
|
|
|
# Priority and Urgency
|
|
priority = models.CharField(
|
|
max_length=20,
|
|
choices=Priority.choices,
|
|
default=Priority.ROUTINE,
|
|
help_text='Appointment priority'
|
|
)
|
|
urgency_score = models.PositiveIntegerField(
|
|
default=1,
|
|
validators=[MinValueValidator(1), MaxValueValidator(10)],
|
|
help_text='Urgency score (1-10, 10 being most urgent)'
|
|
)
|
|
|
|
# Clinical Information
|
|
chief_complaint = models.TextField(
|
|
help_text='Patient chief complaint or reason for visit'
|
|
)
|
|
clinical_notes = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Additional clinical notes'
|
|
)
|
|
referring_provider = models.CharField(
|
|
max_length=200,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Referring provider name'
|
|
)
|
|
|
|
# Insurance and Authorization
|
|
insurance_verified = models.BooleanField(
|
|
default=False,
|
|
help_text='Insurance coverage verified'
|
|
)
|
|
authorization_required = models.BooleanField(
|
|
default=False,
|
|
help_text='Prior authorization required'
|
|
)
|
|
authorization_number = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Authorization number'
|
|
)
|
|
|
|
# Status and Workflow
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=AppointmentStatus.choices,
|
|
default=AppointmentStatus.PENDING,
|
|
help_text='Appointment status'
|
|
)
|
|
|
|
# Scheduled Information
|
|
scheduled_datetime = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Scheduled appointment date and time'
|
|
)
|
|
scheduled_end_datetime = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Scheduled appointment end time'
|
|
)
|
|
|
|
# Location Information
|
|
location = models.CharField(
|
|
max_length=200,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Appointment location'
|
|
)
|
|
room_number = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Room number'
|
|
)
|
|
|
|
# Telemedicine Information
|
|
is_telemedicine = models.BooleanField(
|
|
default=False,
|
|
help_text='Telemedicine appointment'
|
|
)
|
|
telemedicine_platform = models.CharField(
|
|
max_length=50,
|
|
choices=TelemedicinePlatform.choices,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Telemedicine platform'
|
|
)
|
|
meeting_url = models.URLField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Telemedicine meeting URL'
|
|
)
|
|
meeting_id = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Meeting ID or room number'
|
|
)
|
|
meeting_password = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Meeting password'
|
|
)
|
|
|
|
# Check-in Information
|
|
checked_in_at = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Patient check-in time'
|
|
)
|
|
checked_in_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='checked_in_appointments',
|
|
help_text='Staff member who checked in patient'
|
|
)
|
|
|
|
# Completion Information
|
|
completed_at = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Appointment completion time'
|
|
)
|
|
actual_duration_minutes = models.PositiveIntegerField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Actual appointment duration'
|
|
)
|
|
|
|
# Cancellation Information
|
|
cancelled_at = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Cancellation timestamp'
|
|
)
|
|
cancelled_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='cancelled_appointments',
|
|
help_text='User who cancelled appointment'
|
|
)
|
|
cancellation_reason = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Reason for cancellation'
|
|
)
|
|
|
|
# Rescheduling Information
|
|
rescheduled_from = models.ForeignKey(
|
|
'self',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='rescheduled_appointments',
|
|
help_text='Original appointment if rescheduled'
|
|
)
|
|
|
|
# Communication Preferences
|
|
reminder_preferences = models.JSONField(
|
|
default=dict,
|
|
blank=True,
|
|
help_text='Reminder preferences (email, SMS, phone)'
|
|
)
|
|
|
|
# Special Requirements
|
|
special_requirements = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Special requirements or accommodations'
|
|
)
|
|
interpreter_needed = models.BooleanField(
|
|
default=False,
|
|
help_text='Interpreter services needed'
|
|
)
|
|
interpreter_language = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Required interpreter language'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
created_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='created_appointments',
|
|
help_text='User who created the appointment request'
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'appointments_appointment_request'
|
|
verbose_name = 'Appointment Request'
|
|
verbose_name_plural = 'Appointment Requests'
|
|
ordering = ['-created_at']
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'status']),
|
|
models.Index(fields=['patient', 'status']),
|
|
models.Index(fields=['provider', 'scheduled_datetime']),
|
|
models.Index(fields=['scheduled_datetime']),
|
|
models.Index(fields=['priority', 'urgency_score']),
|
|
models.Index(fields=['appointment_type', 'specialty']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.patient.get_full_name()} - {self.appointment_type} ({self.status})"
|
|
|
|
@property
|
|
def is_overdue(self):
|
|
"""
|
|
Check if appointment is overdue.
|
|
"""
|
|
if self.scheduled_datetime and self.status in ['SCHEDULED', 'CONFIRMED']:
|
|
return timezone.now() > self.scheduled_datetime
|
|
return False
|
|
|
|
@property
|
|
def wait_time_minutes(self):
|
|
"""
|
|
Calculate wait time if checked in.
|
|
"""
|
|
if self.checked_in_at and self.status == 'CHECKED_IN':
|
|
return int((timezone.now() - self.checked_in_at).total_seconds() / 60)
|
|
return None
|
|
|
|
def check_in(self, checked_in_by=None):
|
|
"""
|
|
Mark appointment as checked in.
|
|
|
|
Args:
|
|
checked_in_by: User who checked in the patient
|
|
|
|
Returns:
|
|
bool: True if successful, False otherwise
|
|
"""
|
|
if self.status not in ['SCHEDULED', 'CONFIRMED']:
|
|
return False
|
|
|
|
self.status = 'CHECKED_IN'
|
|
self.checked_in_at = timezone.now()
|
|
if checked_in_by:
|
|
self.checked_in_by = checked_in_by
|
|
self.save(update_fields=['status', 'checked_in_at', 'checked_in_by', 'updated_at'])
|
|
return True
|
|
|
|
def start(self):
|
|
"""
|
|
Start the appointment.
|
|
|
|
Returns:
|
|
bool: True if successful, False otherwise
|
|
"""
|
|
if self.status not in ['CHECKED_IN', 'CONFIRMED']:
|
|
return False
|
|
|
|
self.status = 'IN_PROGRESS'
|
|
if not self.checked_in_at:
|
|
self.checked_in_at = timezone.now()
|
|
self.save(update_fields=['status', 'checked_in_at', 'updated_at'])
|
|
return True
|
|
|
|
def complete(self):
|
|
"""
|
|
Complete the appointment.
|
|
|
|
Returns:
|
|
bool: True if successful, False otherwise
|
|
"""
|
|
if self.status != 'IN_PROGRESS':
|
|
return False
|
|
|
|
self.status = 'COMPLETED'
|
|
self.completed_at = timezone.now()
|
|
|
|
# Calculate actual duration if checked in
|
|
if self.checked_in_at:
|
|
duration = (self.completed_at - self.checked_in_at).total_seconds() / 60
|
|
self.actual_duration_minutes = int(duration)
|
|
|
|
self.save(update_fields=['status', 'completed_at', 'actual_duration_minutes', 'updated_at'])
|
|
return True
|
|
|
|
def cancel(self, reason, cancelled_by=None):
|
|
"""
|
|
Cancel the appointment.
|
|
|
|
Args:
|
|
reason: Reason for cancellation
|
|
cancelled_by: User who cancelled the appointment
|
|
|
|
Returns:
|
|
bool: True if successful, False otherwise
|
|
"""
|
|
if self.status in ['COMPLETED', 'CANCELLED']:
|
|
return False
|
|
|
|
self.status = 'CANCELLED'
|
|
self.cancelled_at = timezone.now()
|
|
self.cancellation_reason = reason
|
|
if cancelled_by:
|
|
self.cancelled_by = cancelled_by
|
|
self.save(update_fields=['status', 'cancelled_at', 'cancellation_reason', 'cancelled_by', 'updated_at'])
|
|
return True
|
|
|
|
|
|
class SlotAvailability(models.Model):
|
|
"""
|
|
Provider availability slots for appointment scheduling.
|
|
"""
|
|
|
|
class AvailabilityType(models.TextChoices):
|
|
REGULAR = 'REGULAR', 'Regular Hours'
|
|
EXTENDED = 'EXTENDED', 'Extended Hours'
|
|
EMERGENCY = 'EMERGENCY', 'Emergency'
|
|
ON_CALL = 'ON_CALL', 'On Call'
|
|
TELEMEDICINE = 'TELEMEDICINE', 'Telemedicine Only'
|
|
|
|
# Tenant relationship
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='availability_slots',
|
|
help_text='Organization tenant'
|
|
)
|
|
|
|
# Provider Information
|
|
provider = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name='availability_slots',
|
|
help_text='Healthcare provider'
|
|
)
|
|
|
|
# Slot Information
|
|
slot_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique slot identifier'
|
|
)
|
|
|
|
# Date and Time
|
|
date = models.DateField(
|
|
help_text='Availability date'
|
|
)
|
|
start_time = models.TimeField(
|
|
help_text='Slot start time'
|
|
)
|
|
end_time = models.TimeField(
|
|
help_text='Slot end time'
|
|
)
|
|
duration_minutes = models.PositiveIntegerField(
|
|
help_text='Slot duration in minutes'
|
|
)
|
|
|
|
# Availability Type
|
|
availability_type = models.CharField(
|
|
max_length=20,
|
|
choices=AvailabilityType.choices,
|
|
default=AvailabilityType.REGULAR,
|
|
help_text='Type of availability'
|
|
)
|
|
|
|
# Capacity and Booking
|
|
max_appointments = models.PositiveIntegerField(
|
|
default=1,
|
|
help_text='Maximum appointments for this slot'
|
|
)
|
|
booked_appointments = models.PositiveIntegerField(
|
|
default=0,
|
|
help_text='Number of booked appointments'
|
|
)
|
|
|
|
# Location Information
|
|
location = models.CharField(
|
|
max_length=200,
|
|
help_text='Appointment location'
|
|
)
|
|
room_number = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Room number'
|
|
)
|
|
|
|
# Specialty and Services
|
|
specialty = models.CharField(
|
|
max_length=100,
|
|
help_text='Medical specialty for this slot'
|
|
)
|
|
appointment_types = models.JSONField(
|
|
default=list,
|
|
help_text='Allowed appointment types for this slot'
|
|
)
|
|
|
|
# Restrictions
|
|
patient_restrictions = models.JSONField(
|
|
default=dict,
|
|
blank=True,
|
|
help_text='Patient restrictions (age, gender, etc.)'
|
|
)
|
|
insurance_restrictions = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
help_text='Accepted insurance types'
|
|
)
|
|
|
|
# Telemedicine Support
|
|
supports_telemedicine = models.BooleanField(
|
|
default=False,
|
|
help_text='Slot supports telemedicine appointments'
|
|
)
|
|
telemedicine_only = models.BooleanField(
|
|
default=False,
|
|
help_text='Telemedicine only slot'
|
|
)
|
|
|
|
# Status
|
|
is_active = models.BooleanField(
|
|
default=True,
|
|
help_text='Slot is active and bookable'
|
|
)
|
|
is_blocked = models.BooleanField(
|
|
default=False,
|
|
help_text='Slot is temporarily blocked'
|
|
)
|
|
block_reason = models.CharField(
|
|
max_length=200,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Reason for blocking slot'
|
|
)
|
|
|
|
# Recurring Pattern
|
|
is_recurring = models.BooleanField(
|
|
default=False,
|
|
help_text='Slot is part of recurring pattern'
|
|
)
|
|
recurrence_pattern = models.JSONField(
|
|
default=dict,
|
|
blank=True,
|
|
help_text='Recurrence pattern configuration'
|
|
)
|
|
recurrence_end_date = models.DateField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='End date for recurring pattern'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
created_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='created_availability_slots',
|
|
help_text='User who created the slot'
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'appointments_slot_availability'
|
|
verbose_name = 'Slot Availability'
|
|
verbose_name_plural = 'Slot Availability'
|
|
ordering = ['date', 'start_time']
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'provider', 'date']),
|
|
models.Index(fields=['date', 'start_time']),
|
|
models.Index(fields=['specialty']),
|
|
models.Index(fields=['is_active', 'is_blocked']),
|
|
]
|
|
unique_together = ['provider', 'date', 'start_time']
|
|
|
|
def __str__(self):
|
|
return f"{self.provider.get_full_name()} - {self.date} {self.start_time}-{self.end_time}"
|
|
|
|
@property
|
|
def is_available(self):
|
|
"""
|
|
Check if slot has availability.
|
|
"""
|
|
return (
|
|
self.is_active and
|
|
not self.is_blocked and
|
|
self.booked_appointments < self.max_appointments
|
|
)
|
|
|
|
@property
|
|
def available_capacity(self):
|
|
"""
|
|
Get available capacity for slot.
|
|
"""
|
|
return max(0, self.max_appointments - self.booked_appointments)
|
|
|
|
|
|
class WaitingQueue(models.Model):
|
|
"""
|
|
Waiting queue for managing patient flow.
|
|
"""
|
|
|
|
class QueueType(models.TextChoices):
|
|
PROVIDER = 'PROVIDER', 'Provider Queue'
|
|
SPECIALTY = 'SPECIALTY', 'Specialty Queue'
|
|
LOCATION = 'LOCATION', 'Location Queue'
|
|
PROCEDURE = 'PROCEDURE', 'Procedure Queue'
|
|
EMERGENCY = 'EMERGENCY', 'Emergency Queue'
|
|
|
|
# Tenant relationship
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='waiting_queues',
|
|
help_text='Organization tenant'
|
|
)
|
|
|
|
# Queue Information
|
|
queue_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique queue identifier'
|
|
)
|
|
|
|
name = models.CharField(
|
|
max_length=200,
|
|
help_text='Queue name'
|
|
)
|
|
description = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Queue description'
|
|
)
|
|
|
|
# Queue Type and Configuration
|
|
queue_type = models.CharField(
|
|
max_length=20,
|
|
choices=QueueType.choices,
|
|
help_text='Type of queue'
|
|
)
|
|
|
|
# Associated Resources
|
|
providers = models.ManyToManyField(
|
|
settings.AUTH_USER_MODEL,
|
|
related_name='waiting_queues',
|
|
blank=True,
|
|
help_text='Providers associated with this queue'
|
|
)
|
|
specialty = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Medical specialty'
|
|
)
|
|
location = models.CharField(
|
|
max_length=200,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Queue location'
|
|
)
|
|
|
|
# Queue Management
|
|
max_queue_size = models.PositiveIntegerField(
|
|
default=50,
|
|
help_text='Maximum queue size'
|
|
)
|
|
average_service_time_minutes = models.PositiveIntegerField(
|
|
default=30,
|
|
help_text='Average service time in minutes'
|
|
)
|
|
|
|
# Priority Configuration
|
|
priority_weights = models.JSONField(
|
|
default=dict,
|
|
help_text='Priority weights for queue ordering'
|
|
)
|
|
|
|
# Status
|
|
is_active = models.BooleanField(
|
|
default=True,
|
|
help_text='Queue is active'
|
|
)
|
|
is_accepting_patients = models.BooleanField(
|
|
default=True,
|
|
help_text='Queue is accepting new patients'
|
|
)
|
|
|
|
# Operating Hours
|
|
operating_hours = models.JSONField(
|
|
default=dict,
|
|
help_text='Queue operating hours by day'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
created_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='created_waiting_queues',
|
|
help_text='User who created the queue'
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'appointments_waiting_queue'
|
|
verbose_name = 'Waiting Queue'
|
|
verbose_name_plural = 'Waiting Queues'
|
|
ordering = ['name']
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'queue_type']),
|
|
models.Index(fields=['specialty']),
|
|
models.Index(fields=['is_active']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.name} ({self.queue_type})"
|
|
|
|
@property
|
|
def current_queue_size(self):
|
|
"""
|
|
Get current queue size.
|
|
"""
|
|
return self.queue_entries.filter(status='WAITING').count()
|
|
|
|
@property
|
|
def estimated_wait_time_minutes(self):
|
|
"""
|
|
Calculate estimated wait time.
|
|
"""
|
|
queue_size = self.current_queue_size
|
|
return queue_size * self.average_service_time_minutes
|
|
|
|
def get_next_patient(self):
|
|
"""
|
|
Get the next patient in queue based on priority and wait time.
|
|
|
|
Returns:
|
|
QueueEntry: Next queue entry to be served, or None if queue is empty
|
|
"""
|
|
return self.queue_entries.filter(
|
|
status='WAITING'
|
|
).order_by('priority_score', 'joined_at').first()
|
|
|
|
def calculate_wait_time(self, position=None):
|
|
"""
|
|
Calculate estimated wait time for a given position in queue.
|
|
|
|
Args:
|
|
position: Queue position (defaults to current queue size + 1)
|
|
|
|
Returns:
|
|
int: Estimated wait time in minutes
|
|
"""
|
|
if position is None:
|
|
position = self.current_queue_size + 1
|
|
|
|
# Calculate based on average service time and queue position
|
|
estimated_minutes = (position - 1) * self.average_service_time_minutes
|
|
|
|
# Add buffer for high-priority patients
|
|
high_priority_count = self.queue_entries.filter(
|
|
status='WAITING',
|
|
priority_score__gte=5.0
|
|
).count()
|
|
|
|
if high_priority_count > 0:
|
|
estimated_minutes += high_priority_count * 5 # 5 min buffer per high-priority patient
|
|
|
|
return max(0, estimated_minutes)
|
|
|
|
|
|
class QueueEntry(models.Model):
|
|
"""
|
|
Individual entry in a waiting queue.
|
|
"""
|
|
|
|
class QueueStatus(models.TextChoices):
|
|
WAITING = 'WAITING', 'Waiting'
|
|
CALLED = 'CALLED', 'Called'
|
|
IN_SERVICE = 'IN_SERVICE', 'In Service'
|
|
COMPLETED = 'COMPLETED', 'Completed'
|
|
LEFT = 'LEFT', 'Left Queue'
|
|
NO_SHOW = 'NO_SHOW', 'No Show'
|
|
|
|
# Queue and Patient
|
|
queue = models.ForeignKey(
|
|
WaitingQueue,
|
|
on_delete=models.CASCADE,
|
|
related_name='queue_entries',
|
|
help_text='Waiting queue'
|
|
)
|
|
patient = models.ForeignKey(
|
|
'patients.PatientProfile',
|
|
on_delete=models.CASCADE,
|
|
related_name='queue_entries',
|
|
help_text='Patient in queue'
|
|
)
|
|
appointment = models.ForeignKey(
|
|
AppointmentRequest,
|
|
on_delete=models.CASCADE,
|
|
related_name='queue_entries',
|
|
help_text='Associated appointment'
|
|
)
|
|
|
|
# Entry Information
|
|
entry_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique entry identifier'
|
|
)
|
|
|
|
# Queue Position and Priority
|
|
queue_position = models.PositiveIntegerField(
|
|
help_text='Position in queue'
|
|
)
|
|
priority_score = models.FloatField(
|
|
default=1.0,
|
|
help_text='Priority score for queue ordering'
|
|
)
|
|
|
|
# Timing Information
|
|
joined_at = models.DateTimeField(
|
|
auto_now_add=True,
|
|
help_text='Time patient joined queue'
|
|
)
|
|
estimated_service_time = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Estimated service time'
|
|
)
|
|
called_at = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Time patient was called'
|
|
)
|
|
served_at = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Time patient was served'
|
|
)
|
|
|
|
# Status
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=QueueStatus.choices,
|
|
default=QueueStatus.WAITING,
|
|
help_text='Queue entry status'
|
|
)
|
|
|
|
# Provider Assignment
|
|
assigned_provider = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='assigned_queue_entries',
|
|
help_text='Assigned provider'
|
|
)
|
|
|
|
# Communication
|
|
notification_sent = models.BooleanField(
|
|
default=False,
|
|
help_text='Notification sent to patient'
|
|
)
|
|
notification_method = models.CharField(
|
|
max_length=20,
|
|
choices=[
|
|
('SMS', 'SMS'),
|
|
('EMAIL', 'Email'),
|
|
('PHONE', 'Phone Call'),
|
|
('PAGER', 'Pager'),
|
|
('APP', 'Mobile App'),
|
|
],
|
|
blank=True,
|
|
null=True,
|
|
help_text='Notification method used'
|
|
)
|
|
|
|
# Notes
|
|
notes = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Additional notes'
|
|
)
|
|
|
|
# Metadata
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
updated_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='updated_queue_entries',
|
|
help_text='User who last updated entry'
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'appointments_queue_entry'
|
|
verbose_name = 'Queue Entry'
|
|
verbose_name_plural = 'Queue Entries'
|
|
ordering = ['queue', 'priority_score', 'joined_at']
|
|
indexes = [
|
|
models.Index(fields=['queue', 'status']),
|
|
models.Index(fields=['patient']),
|
|
models.Index(fields=['priority_score']),
|
|
models.Index(fields=['joined_at']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.patient.get_full_name()} - {self.queue.name} (#{self.queue_position})"
|
|
|
|
@property
|
|
def wait_time_minutes(self):
|
|
"""
|
|
Calculate current wait time.
|
|
"""
|
|
if self.status == 'WAITING':
|
|
return int((timezone.now() - self.joined_at).total_seconds() / 60)
|
|
elif self.served_at:
|
|
return int((self.served_at - self.joined_at).total_seconds() / 60)
|
|
return None
|
|
|
|
def mark_as_called(self):
|
|
"""
|
|
Mark queue entry as called.
|
|
|
|
Returns:
|
|
bool: True if successful, False otherwise
|
|
"""
|
|
if self.status != 'WAITING':
|
|
return False
|
|
|
|
self.status = 'CALLED'
|
|
self.called_at = timezone.now()
|
|
self.save(update_fields=['status', 'called_at', 'updated_at'])
|
|
return True
|
|
|
|
def mark_as_in_progress(self):
|
|
"""
|
|
Mark queue entry as in progress/being served.
|
|
|
|
Returns:
|
|
bool: True if successful, False otherwise
|
|
"""
|
|
if self.status not in ['WAITING', 'CALLED']:
|
|
return False
|
|
|
|
self.status = 'IN_SERVICE'
|
|
self.served_at = timezone.now()
|
|
if not self.called_at:
|
|
self.called_at = self.served_at
|
|
self.save(update_fields=['status', 'served_at', 'called_at', 'updated_at'])
|
|
return True
|
|
|
|
def mark_as_completed(self):
|
|
"""
|
|
Mark queue entry as completed.
|
|
|
|
Returns:
|
|
bool: True if successful, False otherwise
|
|
"""
|
|
if self.status != 'IN_SERVICE':
|
|
return False
|
|
|
|
self.status = 'COMPLETED'
|
|
if not self.served_at:
|
|
self.served_at = timezone.now()
|
|
self.save(update_fields=['status', 'served_at', 'updated_at'])
|
|
return True
|
|
|
|
def mark_as_no_show(self):
|
|
"""
|
|
Mark queue entry as no show.
|
|
|
|
Returns:
|
|
bool: True if successful, False otherwise
|
|
"""
|
|
if self.status not in ['WAITING', 'CALLED']:
|
|
return False
|
|
|
|
self.status = 'NO_SHOW'
|
|
self.save(update_fields=['status', 'updated_at'])
|
|
return True
|
|
|
|
def mark_as_cancelled(self):
|
|
"""
|
|
Mark queue entry as cancelled/left queue.
|
|
|
|
Returns:
|
|
bool: True if successful, False otherwise
|
|
"""
|
|
if self.status in ['COMPLETED', 'NO_SHOW']:
|
|
return False
|
|
|
|
self.status = 'LEFT'
|
|
self.save(update_fields=['status', 'updated_at'])
|
|
return True
|
|
|
|
def mark_as_removed(self):
|
|
"""
|
|
Remove entry from queue (alias for mark_as_cancelled).
|
|
|
|
Returns:
|
|
bool: True if successful, False otherwise
|
|
"""
|
|
return self.mark_as_cancelled()
|
|
|
|
|
|
class TelemedicineSession(models.Model):
|
|
"""
|
|
Telemedicine session management.
|
|
"""
|
|
|
|
class Platform(models.TextChoices):
|
|
ZOOM = 'ZOOM', 'Zoom'
|
|
TEAMS = 'TEAMS', 'Microsoft Teams'
|
|
WEBEX = 'WEBEX', 'Cisco Webex'
|
|
DOXY = 'DOXY', 'Doxy.me'
|
|
CUSTOM = 'CUSTOM', 'Custom Platform'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
class SessionStatus(models.TextChoices):
|
|
SCHEDULED = 'SCHEDULED', 'Scheduled'
|
|
READY = 'READY', 'Ready to Start'
|
|
WAITING = 'WAITING', 'Waiting'
|
|
IN_PROGRESS = 'IN_PROGRESS', 'In Progress'
|
|
COMPLETED = 'COMPLETED', 'Completed'
|
|
CANCELLED = 'CANCELLED', 'Cancelled'
|
|
FAILED = 'FAILED', 'Failed'
|
|
|
|
# Session Information
|
|
session_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique session identifier'
|
|
)
|
|
|
|
# Associated Appointment
|
|
appointment = models.OneToOneField(
|
|
AppointmentRequest,
|
|
on_delete=models.CASCADE,
|
|
related_name='telemedicine_session',
|
|
help_text='Associated appointment'
|
|
)
|
|
|
|
# Platform Information
|
|
platform = models.CharField(
|
|
max_length=50,
|
|
choices=Platform.choices,
|
|
help_text='Telemedicine platform'
|
|
)
|
|
|
|
# Meeting Details
|
|
meeting_url = models.URLField(
|
|
help_text='Meeting URL'
|
|
)
|
|
meeting_id = models.CharField(
|
|
max_length=100,
|
|
help_text='Meeting ID or room number'
|
|
)
|
|
meeting_password = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Meeting password'
|
|
)
|
|
|
|
# Session Configuration
|
|
waiting_room_enabled = models.BooleanField(
|
|
default=True,
|
|
help_text='Waiting room enabled'
|
|
)
|
|
recording_enabled = models.BooleanField(
|
|
default=False,
|
|
help_text='Session recording enabled'
|
|
)
|
|
recording_consent = models.BooleanField(
|
|
default=False,
|
|
help_text='Patient consent for recording'
|
|
)
|
|
|
|
# Security Settings
|
|
encryption_enabled = models.BooleanField(
|
|
default=True,
|
|
help_text='End-to-end encryption enabled'
|
|
)
|
|
password_required = models.BooleanField(
|
|
default=True,
|
|
help_text='Password required to join'
|
|
)
|
|
|
|
# Session Status
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=SessionStatus.choices,
|
|
default=SessionStatus.SCHEDULED,
|
|
help_text='Session status'
|
|
)
|
|
|
|
# Timing Information
|
|
scheduled_start = models.DateTimeField(
|
|
help_text='Scheduled start time'
|
|
)
|
|
scheduled_end = models.DateTimeField(
|
|
help_text='Scheduled end time'
|
|
)
|
|
actual_start = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Actual start time'
|
|
)
|
|
actual_end = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Actual end time'
|
|
)
|
|
|
|
# Participants
|
|
provider_joined_at = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Provider join time'
|
|
)
|
|
patient_joined_at = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Patient join time'
|
|
)
|
|
|
|
# Technical Information
|
|
connection_quality = models.CharField(
|
|
max_length=20,
|
|
choices=[
|
|
('EXCELLENT', 'Excellent'),
|
|
('GOOD', 'Good'),
|
|
('FAIR', 'Fair'),
|
|
('POOR', 'Poor'),
|
|
],
|
|
blank=True,
|
|
null=True,
|
|
help_text='Connection quality'
|
|
)
|
|
technical_issues = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Technical issues encountered'
|
|
)
|
|
|
|
# Recording Information
|
|
recording_url = models.URLField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Recording URL'
|
|
)
|
|
recording_duration_minutes = models.PositiveIntegerField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Recording duration in minutes'
|
|
)
|
|
|
|
# Session Notes
|
|
session_notes = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Session notes'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
created_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='created_telemedicine_sessions',
|
|
help_text='User who created the session'
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'appointments_telemedicine_session'
|
|
verbose_name = 'Telemedicine Session'
|
|
verbose_name_plural = 'Telemedicine Sessions'
|
|
ordering = ['-scheduled_start']
|
|
indexes = [
|
|
models.Index(fields=['appointment']),
|
|
models.Index(fields=['status']),
|
|
models.Index(fields=['scheduled_start']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"Telemedicine - {self.appointment.patient.get_full_name()} ({self.status})"
|
|
|
|
@property
|
|
def duration_minutes(self):
|
|
"""
|
|
Calculate session duration.
|
|
"""
|
|
if self.actual_start and self.actual_end:
|
|
return int((self.actual_end - self.actual_start).total_seconds() / 60)
|
|
return None
|
|
|
|
def mark_provider_joined(self):
|
|
"""
|
|
Mark provider as joined to the telemedicine session.
|
|
|
|
Returns:
|
|
bool: True if successful, False otherwise
|
|
"""
|
|
if self.status not in ['SCHEDULED', 'READY', 'WAITING']:
|
|
return False
|
|
|
|
self.provider_joined_at = timezone.now()
|
|
|
|
# If patient already joined, start the session
|
|
if self.patient_joined_at and self.status != 'IN_PROGRESS':
|
|
self.status = 'IN_PROGRESS'
|
|
self.actual_start = timezone.now()
|
|
elif self.status == 'SCHEDULED':
|
|
self.status = 'WAITING'
|
|
|
|
self.save(update_fields=['provider_joined_at', 'status', 'actual_start', 'updated_at'])
|
|
return True
|
|
|
|
def mark_patient_joined(self):
|
|
"""
|
|
Mark patient as joined to the telemedicine session.
|
|
|
|
Returns:
|
|
bool: True if successful, False otherwise
|
|
"""
|
|
if self.status not in ['SCHEDULED', 'READY', 'WAITING']:
|
|
return False
|
|
|
|
self.patient_joined_at = timezone.now()
|
|
|
|
# If provider already joined, start the session
|
|
if self.provider_joined_at and self.status != 'IN_PROGRESS':
|
|
self.status = 'IN_PROGRESS'
|
|
self.actual_start = timezone.now()
|
|
elif self.status == 'SCHEDULED':
|
|
self.status = 'WAITING'
|
|
|
|
self.save(update_fields=['patient_joined_at', 'status', 'actual_start', 'updated_at'])
|
|
return True
|
|
|
|
def end_session(self):
|
|
"""
|
|
End the telemedicine session.
|
|
|
|
Returns:
|
|
bool: True if successful, False otherwise
|
|
"""
|
|
if self.status != 'IN_PROGRESS':
|
|
return False
|
|
|
|
self.status = 'COMPLETED'
|
|
self.actual_end = timezone.now()
|
|
|
|
# Update associated appointment
|
|
if self.appointment:
|
|
self.appointment.status = 'COMPLETED'
|
|
self.appointment.completed_at = self.actual_end
|
|
self.appointment.save(update_fields=['status', 'completed_at', 'updated_at'])
|
|
|
|
self.save(update_fields=['status', 'actual_end', 'updated_at'])
|
|
return True
|
|
|
|
def cancel_session(self):
|
|
"""
|
|
Cancel the telemedicine session.
|
|
|
|
Returns:
|
|
bool: True if successful, False otherwise
|
|
"""
|
|
if self.status in ['COMPLETED', 'CANCELLED']:
|
|
return False
|
|
|
|
self.status = 'CANCELLED'
|
|
|
|
# Update associated appointment
|
|
if self.appointment and self.appointment.status != 'CANCELLED':
|
|
self.appointment.status = 'CANCELLED'
|
|
self.appointment.cancelled_at = timezone.now()
|
|
self.appointment.save(update_fields=['status', 'cancelled_at', 'updated_at'])
|
|
|
|
self.save(update_fields=['status', 'updated_at'])
|
|
return True
|
|
|
|
|
|
class AppointmentTemplate(models.Model):
|
|
"""
|
|
Templates for common appointment types.
|
|
"""
|
|
|
|
# Tenant relationship
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='appointment_templates',
|
|
help_text='Organization tenant'
|
|
)
|
|
|
|
# Template Information
|
|
name = models.CharField(
|
|
max_length=200,
|
|
help_text='Template name'
|
|
)
|
|
description = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Template description'
|
|
)
|
|
|
|
# Appointment Configuration
|
|
appointment_type = models.CharField(
|
|
max_length=50,
|
|
help_text='Default appointment type'
|
|
)
|
|
specialty = models.CharField(
|
|
max_length=100,
|
|
help_text='Medical specialty'
|
|
)
|
|
duration_minutes = models.PositiveIntegerField(
|
|
help_text='Default duration in minutes'
|
|
)
|
|
|
|
# Scheduling Rules
|
|
advance_booking_days = models.PositiveIntegerField(
|
|
default=30,
|
|
help_text='Maximum advance booking days'
|
|
)
|
|
minimum_notice_hours = models.PositiveIntegerField(
|
|
default=24,
|
|
help_text='Minimum notice required in hours'
|
|
)
|
|
|
|
# Requirements
|
|
insurance_verification_required = models.BooleanField(
|
|
default=False,
|
|
help_text='Insurance verification required'
|
|
)
|
|
authorization_required = models.BooleanField(
|
|
default=False,
|
|
help_text='Prior authorization required'
|
|
)
|
|
|
|
# Instructions
|
|
pre_appointment_instructions = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Pre-appointment instructions for patient'
|
|
)
|
|
post_appointment_instructions = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Post-appointment instructions template'
|
|
)
|
|
|
|
# Forms and Documents
|
|
required_forms = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
help_text='Required forms for this appointment type'
|
|
)
|
|
|
|
# Status
|
|
is_active = models.BooleanField(
|
|
default=True,
|
|
help_text='Template is active'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
created_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='created_appointment_templates',
|
|
help_text='User who created the template'
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'appointments_appointment_template'
|
|
verbose_name = 'Appointment Template'
|
|
verbose_name_plural = 'Appointment Templates'
|
|
ordering = ['specialty', 'name']
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'specialty']),
|
|
models.Index(fields=['appointment_type']),
|
|
models.Index(fields=['is_active']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.name} ({self.specialty})"
|
|
|
|
|
|
class WaitingList(models.Model):
|
|
"""
|
|
Patient waiting list for appointment scheduling.
|
|
Follows healthcare industry standards for patient queue management.
|
|
"""
|
|
|
|
class AppointmentType(models.TextChoices):
|
|
CONSULTATION = 'CONSULTATION', 'Consultation'
|
|
FOLLOW_UP = 'FOLLOW_UP', 'Follow-up'
|
|
PROCEDURE = 'PROCEDURE', 'Procedure'
|
|
SURGERY = 'SURGERY', 'Surgery'
|
|
DIAGNOSTIC = 'DIAGNOSTIC', 'Diagnostic'
|
|
THERAPY = 'THERAPY', 'Therapy'
|
|
VACCINATION = 'VACCINATION', 'Vaccination'
|
|
SCREENING = 'SCREENING', 'Screening'
|
|
EMERGENCY = 'EMERGENCY', 'Emergency'
|
|
TELEMEDICINE = 'TELEMEDICINE', 'Telemedicine'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
class Specialty(models.TextChoices):
|
|
FAMILY_MEDICINE = 'FAMILY_MEDICINE', 'Family Medicine'
|
|
INTERNAL_MEDICINE = 'INTERNAL_MEDICINE', 'Internal Medicine'
|
|
PEDIATRICS = 'PEDIATRICS', 'Pediatrics'
|
|
CARDIOLOGY = 'CARDIOLOGY', 'Cardiology'
|
|
DERMATOLOGY = 'DERMATOLOGY', 'Dermatology'
|
|
ENDOCRINOLOGY = 'ENDOCRINOLOGY', 'Endocrinology'
|
|
GASTROENTEROLOGY = 'GASTROENTEROLOGY', 'Gastroenterology'
|
|
NEUROLOGY = 'NEUROLOGY', 'Neurology'
|
|
ONCOLOGY = 'ONCOLOGY', 'Oncology'
|
|
ORTHOPEDICS = 'ORTHOPEDICS', 'Orthopedics'
|
|
PSYCHIATRY = 'PSYCHIATRY', 'Psychiatry'
|
|
RADIOLOGY = 'RADIOLOGY', 'Radiology'
|
|
SURGERY = 'SURGERY', 'Surgery'
|
|
UROLOGY = 'UROLOGY', 'Urology'
|
|
GYNECOLOGY = 'GYNECOLOGY', 'Gynecology'
|
|
OPHTHALMOLOGY = 'OPHTHALMOLOGY', 'Ophthalmology'
|
|
ENT = 'ENT', 'Ear, Nose & Throat'
|
|
EMERGENCY = 'EMERGENCY', 'Emergency Medicine'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
class Priority(models.TextChoices):
|
|
ROUTINE = 'ROUTINE', 'Routine'
|
|
URGENT = 'URGENT', 'Urgent'
|
|
STAT = 'STAT', 'STAT'
|
|
EMERGENCY = 'EMERGENCY', 'Emergency'
|
|
|
|
class ContactMethod(models.TextChoices):
|
|
PHONE = 'PHONE', 'Phone'
|
|
EMAIL = 'EMAIL', 'Email'
|
|
SMS = 'SMS', 'SMS'
|
|
PORTAL = 'PORTAL', 'Patient Portal'
|
|
MAIL = 'MAIL', 'Mail'
|
|
|
|
class RequestStatus(models.TextChoices):
|
|
ACTIVE = 'ACTIVE', 'Active'
|
|
CONTACTED = 'CONTACTED', 'Contacted'
|
|
OFFERED = 'OFFERED', 'Appointment Offered'
|
|
SCHEDULED = 'SCHEDULED', 'Scheduled'
|
|
CANCELLED = 'CANCELLED', 'Cancelled'
|
|
EXPIRED = 'EXPIRED', 'Expired'
|
|
TRANSFERRED = 'TRANSFERRED', 'Transferred'
|
|
|
|
class AuthorizationStatus(models.TextChoices):
|
|
NOT_REQUIRED = 'NOT_REQUIRED', 'Not Required'
|
|
PENDING = 'PENDING', 'Pending'
|
|
APPROVED = 'APPROVED', 'Approved'
|
|
DENIED = 'DENIED', 'Denied'
|
|
EXPIRED = 'EXPIRED', 'Expired'
|
|
|
|
class ReferralUrgency(models.TextChoices):
|
|
ROUTINE = 'ROUTINE', 'Routine'
|
|
URGENT = 'URGENT', 'Urgent'
|
|
STAT = 'STAT', 'STAT'
|
|
|
|
class RemovalReason(models.TextChoices):
|
|
SCHEDULED = 'SCHEDULED', 'Appointment Scheduled'
|
|
PATIENT_CANCELLED = 'PATIENT_CANCELLED', 'Patient Cancelled'
|
|
PROVIDER_CANCELLED = 'PROVIDER_CANCELLED', 'Provider Cancelled'
|
|
NO_RESPONSE = 'NO_RESPONSE', 'No Response to Contact'
|
|
INSURANCE_ISSUE = 'INSURANCE_ISSUE', 'Insurance Issue'
|
|
TRANSFERRED = 'TRANSFERRED', 'Transferred to Another Provider'
|
|
EXPIRED = 'EXPIRED', 'Entry Expired'
|
|
DUPLICATE = 'DUPLICATE', 'Duplicate Entry'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
# Basic Identifiers
|
|
waiting_list_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique waiting list entry identifier'
|
|
)
|
|
|
|
# Tenant relationship
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='waiting_list_entries',
|
|
help_text='Organization tenant'
|
|
)
|
|
|
|
# Patient Information
|
|
patient = models.ForeignKey(
|
|
'patients.PatientProfile',
|
|
on_delete=models.CASCADE,
|
|
related_name='waiting_list_entries',
|
|
help_text='Patient on waiting list'
|
|
)
|
|
|
|
# Provider and Service Information
|
|
provider = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name='provider_waiting_list',
|
|
blank=True,
|
|
null=True,
|
|
help_text='Preferred healthcare provider'
|
|
)
|
|
|
|
department = models.ForeignKey(
|
|
'hr.Department',
|
|
on_delete=models.CASCADE,
|
|
related_name='waiting_list_entries',
|
|
help_text='Department for appointment'
|
|
)
|
|
|
|
appointment_type = models.CharField(
|
|
max_length=50,
|
|
choices=AppointmentType.choices,
|
|
help_text='Type of appointment requested'
|
|
)
|
|
|
|
specialty = models.CharField(
|
|
max_length=100,
|
|
choices=Specialty.choices,
|
|
help_text='Medical specialty required'
|
|
)
|
|
|
|
# Priority and Clinical Information
|
|
priority = models.CharField(
|
|
max_length=20,
|
|
choices=Priority.choices,
|
|
default=Priority.ROUTINE,
|
|
help_text='Clinical priority level'
|
|
)
|
|
|
|
urgency_score = models.PositiveIntegerField(
|
|
default=1,
|
|
validators=[MinValueValidator(1), MaxValueValidator(10)],
|
|
help_text='Clinical urgency score (1-10, 10 being most urgent)'
|
|
)
|
|
|
|
clinical_indication = models.TextField(
|
|
help_text='Clinical reason for appointment request'
|
|
)
|
|
|
|
diagnosis_codes = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
help_text='ICD-10 diagnosis codes'
|
|
)
|
|
|
|
# Patient Preferences
|
|
preferred_date = models.DateField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Patient preferred appointment date'
|
|
)
|
|
|
|
preferred_time = models.TimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Patient preferred appointment time'
|
|
)
|
|
|
|
flexible_scheduling = models.BooleanField(
|
|
default=True,
|
|
help_text='Patient accepts alternative dates/times'
|
|
)
|
|
|
|
earliest_acceptable_date = models.DateField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Earliest acceptable appointment date'
|
|
)
|
|
|
|
latest_acceptable_date = models.DateField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Latest acceptable appointment date'
|
|
)
|
|
|
|
acceptable_days = models.JSONField(
|
|
default=list,
|
|
null=True,
|
|
blank=True,
|
|
help_text='Acceptable days of week (0=Monday, 6=Sunday)'
|
|
)
|
|
|
|
acceptable_times = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
help_text='Acceptable time ranges'
|
|
)
|
|
|
|
# Communication Preferences
|
|
contact_method = models.CharField(
|
|
max_length=20,
|
|
choices=ContactMethod.choices,
|
|
default=ContactMethod.PHONE,
|
|
help_text='Preferred contact method'
|
|
)
|
|
|
|
contact_phone = models.CharField(
|
|
max_length=20,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Contact phone number'
|
|
)
|
|
|
|
contact_email = models.EmailField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Contact email address'
|
|
)
|
|
|
|
# Status and Workflow
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=RequestStatus.choices,
|
|
default=RequestStatus.ACTIVE,
|
|
help_text='Waiting list status'
|
|
)
|
|
|
|
# Position and Timing
|
|
position = models.PositiveIntegerField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Position in waiting list queue'
|
|
)
|
|
|
|
estimated_wait_time = models.PositiveIntegerField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Estimated wait time in days'
|
|
)
|
|
|
|
# Contact History
|
|
last_contacted = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Last contact attempt date/time'
|
|
)
|
|
|
|
contact_attempts = models.PositiveIntegerField(
|
|
default=0,
|
|
help_text='Number of contact attempts made'
|
|
)
|
|
|
|
max_contact_attempts = models.PositiveIntegerField(
|
|
default=3,
|
|
help_text='Maximum contact attempts before expiring'
|
|
)
|
|
|
|
# Appointment Offers
|
|
appointments_offered = models.PositiveIntegerField(
|
|
default=0,
|
|
help_text='Number of appointments offered'
|
|
)
|
|
|
|
appointments_declined = models.PositiveIntegerField(
|
|
default=0,
|
|
help_text='Number of appointments declined'
|
|
)
|
|
|
|
last_offer_date = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date of last appointment offer'
|
|
)
|
|
|
|
# Scheduling Constraints
|
|
requires_interpreter = models.BooleanField(
|
|
default=False,
|
|
help_text='Patient requires interpreter services'
|
|
)
|
|
|
|
interpreter_language = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Required interpreter language'
|
|
)
|
|
|
|
accessibility_requirements = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Special accessibility requirements'
|
|
)
|
|
|
|
transportation_needed = models.BooleanField(
|
|
default=False,
|
|
help_text='Patient needs transportation assistance'
|
|
)
|
|
|
|
# Insurance and Authorization
|
|
insurance_verified = models.BooleanField(
|
|
default=False,
|
|
help_text='Insurance coverage verified'
|
|
)
|
|
|
|
authorization_required = models.BooleanField(
|
|
default=False,
|
|
help_text='Prior authorization required'
|
|
)
|
|
|
|
authorization_status = models.CharField(
|
|
max_length=20,
|
|
choices=AuthorizationStatus.choices,
|
|
default=AuthorizationStatus.NOT_REQUIRED,
|
|
help_text='Authorization status'
|
|
)
|
|
|
|
authorization_number = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Authorization number'
|
|
)
|
|
|
|
# Referral Information
|
|
referring_provider = models.CharField(
|
|
max_length=200,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Referring provider name'
|
|
)
|
|
|
|
referral_date = models.DateField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date of referral'
|
|
)
|
|
|
|
referral_urgency = models.CharField(
|
|
max_length=20,
|
|
choices=ReferralUrgency.choices,
|
|
default=ReferralUrgency.ROUTINE,
|
|
help_text='Referral urgency level'
|
|
)
|
|
|
|
# Outcome Tracking
|
|
scheduled_appointment = models.ForeignKey(
|
|
'AppointmentRequest',
|
|
on_delete=models.SET_NULL,
|
|
blank=True,
|
|
null=True,
|
|
related_name='waiting_list_entry',
|
|
help_text='Scheduled appointment from waiting list'
|
|
)
|
|
|
|
removal_reason = models.CharField(
|
|
max_length=50,
|
|
choices=RemovalReason.choices,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Reason for removal from waiting list'
|
|
)
|
|
|
|
removal_notes = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Additional notes about removal'
|
|
)
|
|
|
|
removed_at = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date/time removed from waiting list'
|
|
)
|
|
|
|
removed_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
blank=True,
|
|
null=True,
|
|
related_name='removed_waiting_list_entries',
|
|
help_text='User who removed entry from waiting list'
|
|
)
|
|
|
|
# Audit Trail
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
created_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='created_waiting_list_entries',
|
|
help_text='User who created the waiting list entry'
|
|
)
|
|
|
|
# Notes and Comments
|
|
notes = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Additional notes and comments'
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'appointments_waiting_list'
|
|
verbose_name = 'Waiting List Entry'
|
|
verbose_name_plural = 'Waiting List Entries'
|
|
ordering = ['priority', 'urgency_score', 'created_at']
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'status']),
|
|
models.Index(fields=['patient', 'status']),
|
|
models.Index(fields=['department', 'specialty', 'status']),
|
|
models.Index(fields=['priority', 'urgency_score']),
|
|
models.Index(fields=['status', 'created_at']),
|
|
models.Index(fields=['provider', 'status']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.patient.get_full_name()} - {self.specialty} ({self.status})"
|
|
|
|
@property
|
|
def days_waiting(self):
|
|
"""Calculate number of days patient has been waiting."""
|
|
return (timezone.now().date() - self.created_at.date()).days
|
|
|
|
@property
|
|
def is_overdue_contact(self):
|
|
"""Check if contact is overdue based on priority."""
|
|
if not self.last_contacted:
|
|
return self.days_waiting > 1
|
|
|
|
days_since_contact = (timezone.now().date() - self.last_contacted.date()).days
|
|
|
|
if self.priority == 'EMERGENCY':
|
|
return days_since_contact > 0 # Same day contact required
|
|
elif self.priority == 'STAT':
|
|
return days_since_contact > 1 # Next day contact required
|
|
elif self.priority == 'URGENT':
|
|
return days_since_contact > 3 # 3 day contact window
|
|
else:
|
|
return days_since_contact > 7 # Weekly contact for routine
|
|
|
|
@property
|
|
def should_expire(self):
|
|
"""Check if waiting list entry should expire."""
|
|
if self.contact_attempts >= self.max_contact_attempts:
|
|
return True
|
|
|
|
# Expire after 90 days for routine, 30 days for urgent
|
|
max_days = 30 if self.priority in ['URGENT', 'STAT', 'EMERGENCY'] else 90
|
|
return self.days_waiting > max_days
|
|
|
|
def calculate_position(self):
|
|
"""Calculate position in waiting list queue."""
|
|
# Priority-based position calculation
|
|
priority_weights = {
|
|
'EMERGENCY': 1000,
|
|
'STAT': 800,
|
|
'URGENT': 600,
|
|
'ROUTINE': 400,
|
|
}
|
|
|
|
base_score = priority_weights.get(self.priority, 400)
|
|
urgency_bonus = self.urgency_score * 10
|
|
wait_time_bonus = min(self.days_waiting, 30) # Cap at 30 days
|
|
|
|
total_score = base_score + urgency_bonus + wait_time_bonus
|
|
|
|
# Count entries with higher scores
|
|
higher_priority = WaitingList.objects.filter(
|
|
department=self.department,
|
|
specialty=self.specialty,
|
|
status='ACTIVE',
|
|
tenant=self.tenant
|
|
).exclude(id=self.id)
|
|
|
|
position = 1
|
|
for entry in higher_priority:
|
|
entry_score = (
|
|
priority_weights.get(entry.priority, 400) +
|
|
entry.urgency_score * 10 +
|
|
min(entry.days_waiting, 30)
|
|
)
|
|
if entry_score > total_score:
|
|
position += 1
|
|
|
|
return position
|
|
|
|
def update_position(self):
|
|
"""Update position in waiting list."""
|
|
self.position = self.calculate_position()
|
|
self.save(update_fields=['position'])
|
|
|
|
def estimate_wait_time(self):
|
|
"""Estimate wait time based on historical data and current queue."""
|
|
# This would typically use historical scheduling data
|
|
# For now, provide basic estimation
|
|
base_wait = {
|
|
'EMERGENCY': 1,
|
|
'STAT': 3,
|
|
'URGENT': 7,
|
|
'ROUTINE': 14,
|
|
}
|
|
|
|
estimated_days = base_wait.get(self.priority, 14)
|
|
|
|
# Adjust based on queue position
|
|
if self.position:
|
|
estimated_days += max(0, (self.position - 1) * 2)
|
|
|
|
return estimated_days
|
|
|
|
|
|
class WaitingListContactLog(models.Model):
|
|
"""
|
|
Contact log for waiting list entries.
|
|
Tracks all communication attempts with patients on waiting list.
|
|
"""
|
|
|
|
class ContactMethod(models.TextChoices):
|
|
PHONE = 'PHONE', 'Phone Call'
|
|
EMAIL = 'EMAIL', 'Email'
|
|
SMS = 'SMS', 'SMS'
|
|
PORTAL = 'PORTAL', 'Patient Portal Message'
|
|
MAIL = 'MAIL', 'Mail'
|
|
IN_PERSON = 'IN_PERSON', 'In Person'
|
|
|
|
class ContactOutcome(models.TextChoices):
|
|
SUCCESSFUL = 'SUCCESSFUL', 'Successful Contact'
|
|
NO_ANSWER = 'NO_ANSWER', 'No Answer'
|
|
BUSY = 'BUSY', 'Line Busy'
|
|
VOICEMAIL = 'VOICEMAIL', 'Left Voicemail'
|
|
EMAIL_SENT = 'EMAIL_SENT', 'Email Sent'
|
|
EMAIL_BOUNCED = 'EMAIL_BOUNCED', 'Email Bounced'
|
|
SMS_SENT = 'SMS_SENT', 'SMS Sent'
|
|
SMS_FAILED = 'SMS_FAILED', 'SMS Failed'
|
|
WRONG_NUMBER = 'WRONG_NUMBER', 'Wrong Number'
|
|
DECLINED = 'DECLINED', 'Patient Declined'
|
|
|
|
class PatientResponse(models.TextChoices):
|
|
ACCEPTED = 'ACCEPTED', 'Accepted Appointment'
|
|
DECLINED = 'DECLINED', 'Declined Appointment'
|
|
REQUESTED_DIFFERENT = 'REQUESTED_DIFFERENT', 'Requested Different Time'
|
|
WILL_CALL_BACK = 'WILL_CALL_BACK', 'Will Call Back'
|
|
NO_LONGER_NEEDED = 'NO_LONGER_NEEDED', 'No Longer Needed'
|
|
INSURANCE_ISSUE = 'INSURANCE_ISSUE', 'Insurance Issue'
|
|
NO_RESPONSE = 'NO_RESPONSE', 'No Response'
|
|
|
|
waiting_list_entry = models.ForeignKey(
|
|
WaitingList,
|
|
on_delete=models.CASCADE,
|
|
related_name='contact_logs',
|
|
help_text='Associated waiting list entry'
|
|
)
|
|
|
|
contact_date = models.DateTimeField(
|
|
auto_now_add=True,
|
|
help_text='Date and time of contact attempt'
|
|
)
|
|
|
|
contact_method = models.CharField(
|
|
max_length=20,
|
|
choices=ContactMethod.choices,
|
|
help_text='Method of contact used'
|
|
)
|
|
|
|
contact_outcome = models.CharField(
|
|
max_length=20,
|
|
choices=ContactOutcome.choices,
|
|
help_text='Outcome of contact attempt'
|
|
)
|
|
|
|
appointment_offered = models.BooleanField(
|
|
default=False,
|
|
help_text='Appointment was offered during contact'
|
|
)
|
|
|
|
offered_date = models.DateField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date of offered appointment'
|
|
)
|
|
|
|
offered_time = models.TimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Time of offered appointment'
|
|
)
|
|
|
|
patient_response = models.CharField(
|
|
max_length=20,
|
|
choices=PatientResponse.choices,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Patient response to contact'
|
|
)
|
|
|
|
notes = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Notes from contact attempt'
|
|
)
|
|
|
|
next_contact_date = models.DateField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Scheduled date for next contact attempt'
|
|
)
|
|
|
|
contacted_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
help_text='Staff member who made contact'
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'appointments_waiting_list_contact_log'
|
|
verbose_name = 'Waiting List Contact Log'
|
|
verbose_name_plural = 'Waiting List Contact Logs'
|
|
ordering = ['-contact_date']
|
|
indexes = [
|
|
models.Index(fields=['waiting_list_entry', 'contact_date']),
|
|
models.Index(fields=['contact_outcome']),
|
|
models.Index(fields=['next_contact_date']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.waiting_list_entry.patient.get_full_name()} - {self.contact_method} ({self.contact_outcome})"
|
|
|
|
|
|
class SchedulingPreference(models.Model):
|
|
"""
|
|
Store patient scheduling preferences and behavioral patterns.
|
|
Used by SmartScheduler for intelligent slot recommendations.
|
|
"""
|
|
|
|
# Tenant and Patient
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='scheduling_preferences',
|
|
help_text='Organization tenant'
|
|
)
|
|
patient = models.OneToOneField(
|
|
'patients.PatientProfile',
|
|
on_delete=models.CASCADE,
|
|
related_name='scheduling_preference',
|
|
help_text='Patient profile'
|
|
)
|
|
|
|
# Preference Data
|
|
preferred_days = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
help_text='Preferred days of week (e.g., ["Monday", "Wednesday"])'
|
|
)
|
|
preferred_times = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
help_text='Preferred times of day (e.g., ["morning", "afternoon"])'
|
|
)
|
|
preferred_providers = models.ManyToManyField(
|
|
settings.AUTH_USER_MODEL,
|
|
related_name='preferred_by_patients',
|
|
blank=True,
|
|
help_text='Preferred healthcare providers'
|
|
)
|
|
|
|
# Behavioral Data
|
|
average_no_show_rate = models.DecimalField(
|
|
max_digits=5,
|
|
decimal_places=2,
|
|
default=0,
|
|
validators=[MinValueValidator(0), MaxValueValidator(100)],
|
|
help_text='Average no-show rate percentage'
|
|
)
|
|
average_late_arrival_minutes = models.IntegerField(
|
|
default=0,
|
|
help_text='Average late arrival in minutes'
|
|
)
|
|
total_appointments = models.IntegerField(
|
|
default=0,
|
|
help_text='Total number of appointments'
|
|
)
|
|
completed_appointments = models.IntegerField(
|
|
default=0,
|
|
help_text='Number of completed appointments'
|
|
)
|
|
|
|
# Geographic Data
|
|
home_address = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Patient home address'
|
|
)
|
|
travel_time_to_clinic = models.DurationField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Estimated travel time to clinic'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = 'appointments_scheduling_preference'
|
|
verbose_name = 'Scheduling Preference'
|
|
verbose_name_plural = 'Scheduling Preferences'
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'patient']),
|
|
models.Index(fields=['average_no_show_rate']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"Preferences for {self.patient.get_full_name()}"
|
|
|
|
@property
|
|
def completion_rate(self):
|
|
"""Calculate appointment completion rate."""
|
|
if self.total_appointments == 0:
|
|
return 0
|
|
return round((self.completed_appointments / self.total_appointments) * 100, 2)
|
|
|
|
|
|
class AppointmentPriorityRule(models.Model):
|
|
"""
|
|
Define rules for appointment prioritization.
|
|
Used by SmartScheduler for priority routing.
|
|
"""
|
|
|
|
# Tenant
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='appointment_priority_rules',
|
|
help_text='Organization tenant'
|
|
)
|
|
|
|
# Rule Information
|
|
name = models.CharField(
|
|
max_length=100,
|
|
help_text='Rule name'
|
|
)
|
|
description = models.TextField(
|
|
help_text='Rule description'
|
|
)
|
|
|
|
# Rule Conditions
|
|
appointment_types = models.ManyToManyField(
|
|
'AppointmentTemplate',
|
|
related_name='priority_rules',
|
|
blank=True,
|
|
help_text='Applicable appointment types'
|
|
)
|
|
specialties = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
help_text='Applicable specialties'
|
|
)
|
|
diagnosis_codes = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
help_text='ICD-10 diagnosis codes that trigger this rule'
|
|
)
|
|
|
|
# Priority Scoring
|
|
base_priority_score = models.IntegerField(
|
|
default=0,
|
|
validators=[MinValueValidator(0), MaxValueValidator(100)],
|
|
help_text='Base priority score (0-100)'
|
|
)
|
|
urgency_multiplier = models.DecimalField(
|
|
max_digits=3,
|
|
decimal_places=2,
|
|
default=1.0,
|
|
validators=[MinValueValidator(0.1), MaxValueValidator(10.0)],
|
|
help_text='Urgency multiplier for scoring'
|
|
)
|
|
|
|
# Time Constraints
|
|
max_wait_days = models.IntegerField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Maximum acceptable wait time in days'
|
|
)
|
|
requires_same_day = models.BooleanField(
|
|
default=False,
|
|
help_text='Requires same-day appointment'
|
|
)
|
|
|
|
# Status
|
|
is_active = models.BooleanField(
|
|
default=True,
|
|
help_text='Rule is active'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
created_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='created_priority_rules',
|
|
help_text='User who created the rule'
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'appointments_priority_rule'
|
|
verbose_name = 'Appointment Priority Rule'
|
|
verbose_name_plural = 'Appointment Priority Rules'
|
|
ordering = ['-base_priority_score', 'name']
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'is_active']),
|
|
models.Index(fields=['base_priority_score']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.name} (Score: {self.base_priority_score})"
|
|
|
|
|
|
class SchedulingMetrics(models.Model):
|
|
"""
|
|
Track scheduling performance metrics for providers.
|
|
Used for analytics and optimization.
|
|
"""
|
|
|
|
# Tenant and Provider
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='scheduling_metrics',
|
|
help_text='Organization tenant'
|
|
)
|
|
provider = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name='scheduling_metrics',
|
|
help_text='Healthcare provider'
|
|
)
|
|
date = models.DateField(
|
|
help_text='Metrics date'
|
|
)
|
|
|
|
# Utilization Metrics
|
|
total_slots = models.IntegerField(
|
|
default=0,
|
|
help_text='Total available slots'
|
|
)
|
|
booked_slots = models.IntegerField(
|
|
default=0,
|
|
help_text='Number of booked slots'
|
|
)
|
|
completed_appointments = models.IntegerField(
|
|
default=0,
|
|
help_text='Number of completed appointments'
|
|
)
|
|
no_shows = models.IntegerField(
|
|
default=0,
|
|
help_text='Number of no-shows'
|
|
)
|
|
cancellations = models.IntegerField(
|
|
default=0,
|
|
help_text='Number of cancellations'
|
|
)
|
|
|
|
# Time Metrics
|
|
average_appointment_duration = models.DurationField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Average appointment duration'
|
|
)
|
|
average_wait_time = models.DurationField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Average patient wait time'
|
|
)
|
|
total_overtime_minutes = models.IntegerField(
|
|
default=0,
|
|
help_text='Total overtime in minutes'
|
|
)
|
|
|
|
# Calculated Fields
|
|
utilization_rate = models.DecimalField(
|
|
max_digits=5,
|
|
decimal_places=2,
|
|
default=0,
|
|
validators=[MinValueValidator(0), MaxValueValidator(100)],
|
|
help_text='Slot utilization rate percentage'
|
|
)
|
|
no_show_rate = models.DecimalField(
|
|
max_digits=5,
|
|
decimal_places=2,
|
|
default=0,
|
|
validators=[MinValueValidator(0), MaxValueValidator(100)],
|
|
help_text='No-show rate percentage'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = 'appointments_scheduling_metrics'
|
|
verbose_name = 'Scheduling Metrics'
|
|
verbose_name_plural = 'Scheduling Metrics'
|
|
unique_together = ['tenant', 'provider', 'date']
|
|
ordering = ['-date']
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'provider', 'date']),
|
|
models.Index(fields=['date']),
|
|
models.Index(fields=['utilization_rate']),
|
|
models.Index(fields=['no_show_rate']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.provider.get_full_name()} - {self.date}"
|
|
|
|
def calculate_utilization_rate(self):
|
|
"""Calculate and update utilization rate."""
|
|
if self.total_slots > 0:
|
|
self.utilization_rate = round((self.booked_slots / self.total_slots) * 100, 2)
|
|
else:
|
|
self.utilization_rate = 0
|
|
return self.utilization_rate
|
|
|
|
def calculate_no_show_rate(self):
|
|
"""Calculate and update no-show rate."""
|
|
if self.booked_slots > 0:
|
|
self.no_show_rate = round((self.no_shows / self.booked_slots) * 100, 2)
|
|
else:
|
|
self.no_show_rate = 0
|
|
return self.no_show_rate
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""Override save to auto-calculate rates."""
|
|
self.calculate_utilization_rate()
|
|
self.calculate_no_show_rate()
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
class QueueConfiguration(models.Model):
|
|
"""
|
|
Advanced queue configuration and rules for dynamic positioning.
|
|
Extends WaitingQueue with intelligent queue management settings.
|
|
"""
|
|
|
|
# One-to-one relationship with WaitingQueue
|
|
queue = models.OneToOneField(
|
|
WaitingQueue,
|
|
on_delete=models.CASCADE,
|
|
related_name='configuration',
|
|
help_text='Associated waiting queue'
|
|
)
|
|
|
|
# Dynamic Positioning Rules
|
|
use_dynamic_positioning = models.BooleanField(
|
|
default=True,
|
|
help_text='Enable AI-powered dynamic queue positioning'
|
|
)
|
|
priority_weight = models.DecimalField(
|
|
max_digits=3,
|
|
decimal_places=2,
|
|
default=0.50,
|
|
validators=[MinValueValidator(0), MaxValueValidator(1)],
|
|
help_text='Weight for priority score factor (0.0-1.0)'
|
|
)
|
|
wait_time_weight = models.DecimalField(
|
|
max_digits=3,
|
|
decimal_places=2,
|
|
default=0.30,
|
|
validators=[MinValueValidator(0), MaxValueValidator(1)],
|
|
help_text='Weight for wait time fairness factor (0.0-1.0)'
|
|
)
|
|
appointment_time_weight = models.DecimalField(
|
|
max_digits=3,
|
|
decimal_places=2,
|
|
default=0.20,
|
|
validators=[MinValueValidator(0), MaxValueValidator(1)],
|
|
help_text='Weight for appointment time proximity factor (0.0-1.0)'
|
|
)
|
|
|
|
# Capacity Management
|
|
enable_overflow_queue = models.BooleanField(
|
|
default=False,
|
|
help_text='Enable overflow queue when capacity is reached'
|
|
)
|
|
overflow_threshold = models.IntegerField(
|
|
default=10,
|
|
validators=[MinValueValidator(1)],
|
|
help_text='Queue size threshold to trigger overflow'
|
|
)
|
|
|
|
# Wait Time Estimation
|
|
use_historical_data = models.BooleanField(
|
|
default=True,
|
|
help_text='Use historical data for wait time estimation'
|
|
)
|
|
default_service_time_minutes = models.IntegerField(
|
|
default=20,
|
|
validators=[MinValueValidator(5), MaxValueValidator(480)],
|
|
help_text='Default service time in minutes'
|
|
)
|
|
historical_data_days = models.IntegerField(
|
|
default=7,
|
|
validators=[MinValueValidator(1), MaxValueValidator(90)],
|
|
help_text='Number of days of historical data to use'
|
|
)
|
|
|
|
# Real-time Updates
|
|
enable_websocket_updates = models.BooleanField(
|
|
default=True,
|
|
help_text='Enable WebSocket real-time updates'
|
|
)
|
|
update_interval_seconds = models.IntegerField(
|
|
default=30,
|
|
validators=[MinValueValidator(5), MaxValueValidator(300)],
|
|
help_text='Update broadcast interval in seconds'
|
|
)
|
|
|
|
# Load Factor Configuration
|
|
load_factor_normal_threshold = models.DecimalField(
|
|
max_digits=3,
|
|
decimal_places=2,
|
|
default=0.50,
|
|
validators=[MinValueValidator(0), MaxValueValidator(1)],
|
|
help_text='Utilization threshold for normal load (0.0-1.0)'
|
|
)
|
|
load_factor_moderate_threshold = models.DecimalField(
|
|
max_digits=3,
|
|
decimal_places=2,
|
|
default=0.75,
|
|
validators=[MinValueValidator(0), MaxValueValidator(1)],
|
|
help_text='Utilization threshold for moderate load (0.0-1.0)'
|
|
)
|
|
load_factor_high_threshold = models.DecimalField(
|
|
max_digits=3,
|
|
decimal_places=2,
|
|
default=0.90,
|
|
validators=[MinValueValidator(0), MaxValueValidator(1)],
|
|
help_text='Utilization threshold for high load (0.0-1.0)'
|
|
)
|
|
|
|
# Auto-repositioning Settings
|
|
auto_reposition_enabled = models.BooleanField(
|
|
default=True,
|
|
help_text='Automatically reposition queue entries'
|
|
)
|
|
reposition_interval_minutes = models.IntegerField(
|
|
default=15,
|
|
validators=[MinValueValidator(1), MaxValueValidator(120)],
|
|
help_text='Interval for auto-repositioning in minutes'
|
|
)
|
|
|
|
# Notification Settings
|
|
notify_on_position_change = models.BooleanField(
|
|
default=True,
|
|
help_text='Notify patients when queue position changes significantly'
|
|
)
|
|
position_change_threshold = models.IntegerField(
|
|
default=3,
|
|
validators=[MinValueValidator(1)],
|
|
help_text='Position change threshold to trigger notification'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = 'appointments_queue_configuration'
|
|
verbose_name = 'Queue Configuration'
|
|
verbose_name_plural = 'Queue Configurations'
|
|
|
|
def __str__(self):
|
|
return f"Configuration for {self.queue.name}"
|
|
|
|
def validate_weights(self):
|
|
"""Validate that weights sum to approximately 1.0."""
|
|
total = float(self.priority_weight + self.wait_time_weight + self.appointment_time_weight)
|
|
if not (0.95 <= total <= 1.05): # Allow small floating point variance
|
|
from django.core.exceptions import ValidationError
|
|
raise ValidationError(
|
|
f'Weights must sum to 1.0 (current sum: {total:.2f})'
|
|
)
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""Override save to validate weights."""
|
|
self.validate_weights()
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
class QueueMetrics(models.Model):
|
|
"""
|
|
Track queue performance metrics for analytics and optimization.
|
|
Stores hourly granular data for trend analysis.
|
|
"""
|
|
|
|
# Queue and Time Period
|
|
queue = models.ForeignKey(
|
|
WaitingQueue,
|
|
on_delete=models.CASCADE,
|
|
related_name='metrics',
|
|
help_text='Associated waiting queue'
|
|
)
|
|
date = models.DateField(
|
|
help_text='Metrics date'
|
|
)
|
|
hour = models.IntegerField(
|
|
validators=[MinValueValidator(0), MaxValueValidator(23)],
|
|
help_text='Hour of day (0-23)'
|
|
)
|
|
|
|
# Volume Metrics
|
|
total_entries = models.IntegerField(
|
|
default=0,
|
|
help_text='Total queue entries during this hour'
|
|
)
|
|
completed_entries = models.IntegerField(
|
|
default=0,
|
|
help_text='Number of completed entries'
|
|
)
|
|
no_shows = models.IntegerField(
|
|
default=0,
|
|
help_text='Number of no-shows'
|
|
)
|
|
left_queue = models.IntegerField(
|
|
default=0,
|
|
help_text='Number of patients who left queue'
|
|
)
|
|
|
|
# Time Metrics
|
|
average_wait_time_minutes = models.DecimalField(
|
|
max_digits=6,
|
|
decimal_places=2,
|
|
default=0,
|
|
help_text='Average wait time in minutes'
|
|
)
|
|
max_wait_time_minutes = models.IntegerField(
|
|
default=0,
|
|
help_text='Maximum wait time in minutes'
|
|
)
|
|
min_wait_time_minutes = models.IntegerField(
|
|
default=0,
|
|
help_text='Minimum wait time in minutes'
|
|
)
|
|
average_service_time_minutes = models.DecimalField(
|
|
max_digits=6,
|
|
decimal_places=2,
|
|
default=0,
|
|
help_text='Average service time in minutes'
|
|
)
|
|
|
|
# Queue State Metrics
|
|
peak_queue_size = models.IntegerField(
|
|
default=0,
|
|
help_text='Peak queue size during this hour'
|
|
)
|
|
average_queue_size = models.DecimalField(
|
|
max_digits=6,
|
|
decimal_places=2,
|
|
default=0,
|
|
help_text='Average queue size during this hour'
|
|
)
|
|
min_queue_size = models.IntegerField(
|
|
default=0,
|
|
help_text='Minimum queue size during this hour'
|
|
)
|
|
|
|
# Efficiency Metrics
|
|
throughput = models.IntegerField(
|
|
default=0,
|
|
help_text='Number of patients served per hour'
|
|
)
|
|
utilization_rate = models.DecimalField(
|
|
max_digits=5,
|
|
decimal_places=2,
|
|
default=0,
|
|
validators=[MinValueValidator(0), MaxValueValidator(100)],
|
|
help_text='Queue utilization rate percentage'
|
|
)
|
|
|
|
# Quality Metrics
|
|
no_show_rate = models.DecimalField(
|
|
max_digits=5,
|
|
decimal_places=2,
|
|
default=0,
|
|
validators=[MinValueValidator(0), MaxValueValidator(100)],
|
|
help_text='No-show rate percentage'
|
|
)
|
|
abandonment_rate = models.DecimalField(
|
|
max_digits=5,
|
|
decimal_places=2,
|
|
default=0,
|
|
validators=[MinValueValidator(0), MaxValueValidator(100)],
|
|
help_text='Queue abandonment rate percentage'
|
|
)
|
|
|
|
# Dynamic Positioning Metrics
|
|
repositioning_events = models.IntegerField(
|
|
default=0,
|
|
help_text='Number of queue repositioning events'
|
|
)
|
|
average_position_changes = models.DecimalField(
|
|
max_digits=5,
|
|
decimal_places=2,
|
|
default=0,
|
|
help_text='Average position changes per entry'
|
|
)
|
|
|
|
# Load Metrics
|
|
average_load_factor = models.DecimalField(
|
|
max_digits=3,
|
|
decimal_places=2,
|
|
default=1.0,
|
|
help_text='Average load factor during this hour'
|
|
)
|
|
peak_load_factor = models.DecimalField(
|
|
max_digits=3,
|
|
decimal_places=2,
|
|
default=1.0,
|
|
help_text='Peak load factor during this hour'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = 'appointments_queue_metrics'
|
|
verbose_name = 'Queue Metrics'
|
|
verbose_name_plural = 'Queue Metrics'
|
|
unique_together = ['queue', 'date', 'hour']
|
|
ordering = ['-date', '-hour']
|
|
indexes = [
|
|
models.Index(fields=['queue', 'date']),
|
|
models.Index(fields=['date', 'hour']),
|
|
models.Index(fields=['queue', 'date', 'hour']),
|
|
models.Index(fields=['utilization_rate']),
|
|
models.Index(fields=['no_show_rate']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.queue.name} - {self.date} {self.hour:02d}:00"
|
|
|
|
def calculate_no_show_rate(self):
|
|
"""Calculate and update no-show rate."""
|
|
if self.total_entries > 0:
|
|
self.no_show_rate = round((self.no_shows / self.total_entries) * 100, 2)
|
|
else:
|
|
self.no_show_rate = 0
|
|
return self.no_show_rate
|
|
|
|
def calculate_abandonment_rate(self):
|
|
"""Calculate and update abandonment rate."""
|
|
if self.total_entries > 0:
|
|
self.abandonment_rate = round((self.left_queue / self.total_entries) * 100, 2)
|
|
else:
|
|
self.abandonment_rate = 0
|
|
return self.abandonment_rate
|
|
|
|
def calculate_utilization_rate(self):
|
|
"""Calculate and update utilization rate."""
|
|
if self.peak_queue_size > 0:
|
|
# Utilization based on how full the queue was
|
|
capacity = self.queue.max_queue_size or 50
|
|
self.utilization_rate = round((self.peak_queue_size / capacity) * 100, 2)
|
|
else:
|
|
self.utilization_rate = 0
|
|
return self.utilization_rate
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""Override save to auto-calculate rates."""
|
|
self.calculate_no_show_rate()
|
|
self.calculate_abandonment_rate()
|
|
self.calculate_utilization_rate()
|
|
super().save(*args, **kwargs)
|