281 lines
9.5 KiB
Python
281 lines
9.5 KiB
Python
"""
|
|
Queue Management Signals
|
|
Handles automated queue operations and metrics updates
|
|
Part of Phase 11: Advanced Queue Management
|
|
"""
|
|
|
|
from django.db.models.signals import post_save, pre_save, post_delete
|
|
from django.dispatch import receiver
|
|
from django.utils import timezone
|
|
from django.db.models import Avg, Count, Max, F
|
|
from datetime import timedelta
|
|
from ..models import QueueEntry, QueueConfiguration, QueueMetrics, WaitingQueue
|
|
|
|
|
|
@receiver(post_save, sender=QueueEntry)
|
|
def handle_queue_entry_created(sender, instance, created, **kwargs):
|
|
"""
|
|
Handle new queue entry creation.
|
|
- Auto-position patient based on configuration
|
|
- Update queue metrics
|
|
- Send notifications if configured
|
|
"""
|
|
if created:
|
|
# Get queue configuration
|
|
try:
|
|
config = QueueConfiguration.objects.get(queue=instance.queue)
|
|
|
|
# Auto-position if dynamic positioning is enabled
|
|
if config.use_dynamic_positioning:
|
|
from .queue_engine import AdvancedQueueEngine
|
|
engine = AdvancedQueueEngine(instance.queue)
|
|
# Calculate optimal position for the new entry
|
|
optimal_position = engine.calculate_optimal_position(
|
|
patient=instance.patient,
|
|
appointment=instance.appointment,
|
|
priority_score=instance.priority_score
|
|
)
|
|
instance.queue_position = optimal_position
|
|
instance.save(update_fields=['queue_position'])
|
|
|
|
except QueueConfiguration.DoesNotExist:
|
|
# Create default configuration if it doesn't exist
|
|
config = QueueConfiguration.objects.create(queue=instance.queue)
|
|
|
|
# Update queue size
|
|
update_queue_size(instance.queue)
|
|
|
|
|
|
@receiver(pre_save, sender=QueueEntry)
|
|
def handle_queue_entry_status_change(sender, instance, **kwargs):
|
|
"""
|
|
Handle queue entry status changes.
|
|
- Track timing for metrics
|
|
- Update queue statistics
|
|
"""
|
|
if instance.pk:
|
|
try:
|
|
old_instance = QueueEntry.objects.get(pk=instance.pk)
|
|
|
|
# Track when patient is called
|
|
if old_instance.status != 'CALLED' and instance.status == 'CALLED':
|
|
instance.called_at = timezone.now()
|
|
|
|
# Track when patient is served
|
|
if old_instance.status != 'SERVED' and instance.status == 'SERVED':
|
|
instance.served_at = timezone.now()
|
|
|
|
except QueueEntry.DoesNotExist:
|
|
pass
|
|
|
|
|
|
@receiver(post_save, sender=QueueEntry)
|
|
def update_queue_metrics_on_entry_change(sender, instance, **kwargs):
|
|
"""
|
|
Update queue metrics when entry changes.
|
|
"""
|
|
# Update hourly metrics
|
|
update_hourly_metrics(instance.queue)
|
|
|
|
|
|
@receiver(post_delete, sender=QueueEntry)
|
|
def handle_queue_entry_deleted(sender, instance, **kwargs):
|
|
"""
|
|
Handle queue entry deletion.
|
|
- Update queue size
|
|
- Reposition remaining patients
|
|
"""
|
|
# Update queue size
|
|
update_queue_size(instance.queue)
|
|
|
|
# Reposition remaining patients if dynamic positioning is enabled
|
|
try:
|
|
config = QueueConfiguration.objects.get(queue=instance.queue)
|
|
if config.use_dynamic_positioning:
|
|
from .queue_engine import AdvancedQueueEngine
|
|
engine = AdvancedQueueEngine(instance.queue)
|
|
engine.reposition_queue_entries()
|
|
except QueueConfiguration.DoesNotExist:
|
|
pass
|
|
|
|
|
|
@receiver(post_save, sender=QueueConfiguration)
|
|
def handle_configuration_change(sender, instance, created, **kwargs):
|
|
"""
|
|
Handle queue configuration changes.
|
|
- Reposition all patients if weights changed
|
|
- Update auto-reposition schedule
|
|
"""
|
|
if not created:
|
|
# Check if positioning weights changed
|
|
try:
|
|
old_config = QueueConfiguration.objects.get(pk=instance.pk)
|
|
weights_changed = (
|
|
old_config.priority_weight != instance.priority_weight or
|
|
old_config.wait_time_weight != instance.wait_time_weight or
|
|
old_config.appointment_time_weight != instance.appointment_time_weight
|
|
)
|
|
|
|
if weights_changed and instance.use_dynamic_positioning:
|
|
from .queue_engine import AdvancedQueueEngine
|
|
engine = AdvancedQueueEngine(instance.queue)
|
|
engine.reposition_queue_entries()
|
|
|
|
except QueueConfiguration.DoesNotExist:
|
|
pass
|
|
|
|
|
|
# ============================================================================
|
|
# HELPER FUNCTIONS
|
|
# ============================================================================
|
|
|
|
def update_queue_size(queue):
|
|
"""
|
|
Update the current queue size.
|
|
"""
|
|
active_count = QueueEntry.objects.filter(
|
|
queue=queue,
|
|
status__in=['WAITING', 'CALLED']
|
|
).count()
|
|
|
|
# Update queue model if it has a size field
|
|
# This would require adding a current_size field to WaitingQueue model
|
|
# For now, we'll just return the count
|
|
return active_count
|
|
|
|
|
|
def update_hourly_metrics(queue):
|
|
"""
|
|
Update hourly metrics for the queue.
|
|
"""
|
|
now = timezone.now()
|
|
current_date = now.date()
|
|
current_hour = now.hour
|
|
|
|
# Get or create metrics for current hour
|
|
metrics, created = QueueMetrics.objects.get_or_create(
|
|
queue=queue,
|
|
date=current_date,
|
|
hour=current_hour,
|
|
defaults={
|
|
'total_entries': 0,
|
|
'completed_entries': 0,
|
|
'no_shows': 0,
|
|
'left_queue': 0,
|
|
'average_wait_time_minutes': 0,
|
|
'max_wait_time_minutes': 0,
|
|
'min_wait_time_minutes': 0
|
|
}
|
|
)
|
|
|
|
# Calculate metrics for this hour
|
|
hour_start = timezone.make_aware(
|
|
timezone.datetime.combine(current_date, timezone.datetime.min.time())
|
|
) + timedelta(hours=current_hour)
|
|
hour_end = hour_start + timedelta(hours=1)
|
|
|
|
entries = QueueEntry.objects.filter(
|
|
queue=queue,
|
|
joined_at__gte=hour_start,
|
|
joined_at__lt=hour_end
|
|
)
|
|
|
|
# Update counts
|
|
metrics.total_entries = entries.count()
|
|
metrics.completed_entries = entries.filter(status='SERVED').count()
|
|
metrics.no_shows = entries.filter(status='NO_SHOW').count()
|
|
metrics.left_queue = entries.filter(status__in=['CANCELLED', 'LEFT']).count()
|
|
|
|
# Calculate wait times
|
|
completed = entries.filter(status='SERVED', served_at__isnull=False)
|
|
if completed.exists():
|
|
wait_times = []
|
|
for entry in completed:
|
|
wait_time = (entry.served_at - entry.joined_at).total_seconds() / 60
|
|
wait_times.append(wait_time)
|
|
|
|
metrics.average_wait_time_minutes = sum(wait_times) / len(wait_times)
|
|
metrics.max_wait_time_minutes = max(wait_times)
|
|
|
|
# Calculate service times
|
|
served = entries.filter(status='SERVED', called_at__isnull=False, served_at__isnull=False)
|
|
if served.exists():
|
|
service_times = []
|
|
for entry in served:
|
|
service_time = (entry.served_at - entry.called_at).total_seconds() / 60
|
|
service_times.append(service_time)
|
|
|
|
metrics.average_service_time_minutes = sum(service_times) / len(service_times)
|
|
|
|
# Calculate queue size metrics
|
|
# This would require tracking queue size over time
|
|
# For now, use current size as peak
|
|
current_size = update_queue_size(queue)
|
|
metrics.peak_queue_size = max(metrics.peak_queue_size, current_size)
|
|
metrics.average_queue_size = current_size # Simplified
|
|
|
|
# Calculate rates
|
|
if metrics.total_entries > 0:
|
|
metrics.utilization_rate = (metrics.completed_entries / metrics.total_entries) * 100
|
|
metrics.no_show_rate = (metrics.no_shows / metrics.total_entries) * 100
|
|
|
|
metrics.save()
|
|
|
|
|
|
def auto_reposition_queues():
|
|
"""
|
|
Automatically reposition all queues that have auto-repositioning enabled.
|
|
This should be called periodically (e.g., via Celery task).
|
|
"""
|
|
from .queue_engine import AdvancedQueueEngine
|
|
|
|
configs = QueueConfiguration.objects.filter(
|
|
auto_reposition_enabled=True,
|
|
queue__is_active=True
|
|
)
|
|
|
|
repositioned_count = 0
|
|
for config in configs:
|
|
try:
|
|
engine = AdvancedQueueEngine(config.queue)
|
|
engine.reposition_queue_entries()
|
|
repositioned_count += 1
|
|
except Exception as e:
|
|
print(f"Error repositioning queue {config.queue.name}: {e}")
|
|
|
|
return repositioned_count
|
|
|
|
|
|
def cleanup_old_metrics(days=90):
|
|
"""
|
|
Clean up old queue metrics.
|
|
This should be called periodically to prevent database bloat.
|
|
"""
|
|
cutoff_date = timezone.now().date() - timedelta(days=days)
|
|
deleted_count, _ = QueueMetrics.objects.filter(date__lt=cutoff_date).delete()
|
|
return deleted_count
|
|
|
|
|
|
def send_position_change_notification(entry, old_position, new_position):
|
|
"""
|
|
Send notification to patient about position change.
|
|
"""
|
|
try:
|
|
config = QueueConfiguration.objects.get(queue=entry.queue)
|
|
|
|
if not config.notify_on_position_change:
|
|
return
|
|
|
|
# Check if change is significant enough
|
|
position_change = abs(new_position - old_position)
|
|
if position_change < config.position_change_threshold:
|
|
return
|
|
|
|
# TODO: Implement actual notification sending
|
|
# This could use SMS, email, or push notifications
|
|
# For now, just log it
|
|
print(f"Position change notification: {entry.patient} moved from {old_position} to {new_position}")
|
|
|
|
except QueueConfiguration.DoesNotExist:
|
|
pass
|