""" 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