""" Appointments Celery tasks for background processing. This module contains tasks for appointment reminders, confirmations, no-show checking, and schedule generation. """ import logging from datetime import datetime, timedelta from typing import List from celery import shared_task from django.conf import settings from django.utils import timezone from appointments.models import Appointment from core.tasks import ( create_notification_task, send_email_task, send_multi_channel_notification_task, send_sms_task, ) logger = logging.getLogger(__name__) @shared_task(bind=True, max_retries=3) def send_appointment_reminder(self, appointment_id: str, hours_before: int = 24) -> bool: """ Send appointment reminder to patient. Args: appointment_id: UUID of the appointment hours_before: Hours before appointment (for message customization) Returns: bool: True if reminder was sent successfully """ try: from appointments.models import AppointmentReminder from datetime import datetime, timedelta appointment = Appointment.objects.select_related( 'patient', 'provider', 'clinic', 'room' ).get(id=appointment_id) # Check if appointment is still valid for reminder if appointment.status not in ['CONFIRMED', 'BOOKED']: logger.info(f"Skipping reminder for appointment {appointment_id} - status: {appointment.status}") # Mark reminder as cancelled AppointmentReminder.objects.filter( appointment=appointment, status='SCHEDULED' ).update(status='CANCELLED') return False # Get scheduled datetime scheduled_datetime = datetime.combine( appointment.scheduled_date, appointment.scheduled_time ) # Prepare reminder message time_str = "tomorrow" if hours_before == 24 else f"in {hours_before} hours" message = ( f"Reminder: You have an appointment {time_str} at {scheduled_datetime.strftime('%I:%M %p')} " f"with {appointment.provider.user.get_full_name()} at {appointment.clinic.name_en}." ) if appointment.room: message += f" Room: {appointment.room.name}" # Send multi-channel notification success = send_multi_channel_notification_task.delay( user_id=str(appointment.patient.id) if hasattr(appointment.patient, 'user') else None, title="Appointment Reminder", message=message, channels=['email', 'sms', 'in_app'], email_subject=f"Appointment Reminder - {scheduled_datetime.strftime('%B %d, %Y')}", ) # Update reminder status reminder_time = scheduled_datetime - timedelta(hours=hours_before) AppointmentReminder.objects.filter( appointment=appointment, scheduled_for=reminder_time, status='SCHEDULED' ).update( status='SENT', sent_at=timezone.now() ) logger.info(f"Reminder sent for appointment {appointment_id}") return True except Appointment.DoesNotExist: logger.error(f"Appointment {appointment_id} not found") return False except Exception as exc: logger.error(f"Failed to send reminder for appointment {appointment_id}: {exc}") # Mark reminder as failed try: from appointments.models import AppointmentReminder AppointmentReminder.objects.filter( appointment_id=appointment_id, status='SCHEDULED' ).update(status='FAILED') except: pass raise self.retry(exc=exc, countdown=300) @shared_task(bind=True, max_retries=3) def send_appointment_confirmation(self, appointment_id: str) -> bool: """ Send appointment confirmation to patient. Args: appointment_id: UUID of the appointment Returns: bool: True if confirmation was sent successfully """ try: appointment = Appointment.objects.select_related( 'patient', 'provider', 'clinic', 'room' ).get(id=appointment_id) # Prepare confirmation message message = ( f"Your appointment has been confirmed for {appointment.start_at.strftime('%B %d, %Y at %I:%M %p')} " f"with {appointment.provider.get_full_name()} at {appointment.clinic.name_en}." ) if appointment.room: message += f" Room: {appointment.room.name}" message += f"\n\nAppointment Type: {appointment.get_appointment_type_display()}" if appointment.notes: message += f"\n\nNotes: {appointment.notes}" # Send multi-channel notification send_multi_channel_notification_task.delay( user_id=str(appointment.patient.id) if hasattr(appointment.patient, 'user') else None, title="Appointment Confirmed", message=message, channels=['email', 'sms', 'in_app'], email_subject="Appointment Confirmation", ) logger.info(f"Confirmation sent for appointment {appointment_id}") return True except Appointment.DoesNotExist: logger.error(f"Appointment {appointment_id} not found") return False except Exception as exc: logger.error(f"Failed to send confirmation for appointment {appointment_id}: {exc}") raise self.retry(exc=exc, countdown=300) @shared_task def check_no_shows() -> int: """ Check for no-show appointments and mark them accordingly. This task runs every 2 hours to check for appointments that were confirmed but the patient never arrived. Returns: int: Number of appointments marked as no-show """ # Get appointments that should have started but patient never arrived cutoff_time = timezone.now() - timedelta(hours=1) no_show_appointments = Appointment.objects.filter( status='CONFIRMED', start_at__lt=cutoff_time ) count = 0 for appointment in no_show_appointments: appointment.status = 'NO_SHOW' appointment.save() # Notify provider if appointment.provider: create_notification_task.delay( user_id=str(appointment.provider.id), title="Patient No-Show", message=f"Patient {appointment.patient.full_name_en} did not show up for " f"appointment at {appointment.start_at.strftime('%I:%M %p')}", notification_type='WARNING', related_object_type='appointment', related_object_id=str(appointment.id), ) count += 1 if count > 0: logger.info(f"Marked {count} appointments as no-show") return count @shared_task def generate_daily_schedule(date: str = None) -> dict: """ Generate daily schedule report for all providers. Args: date: Date string in YYYY-MM-DD format (defaults to tomorrow) Returns: dict: Schedule statistics """ if date: target_date = datetime.strptime(date, '%Y-%m-%d').date() else: target_date = (timezone.now() + timedelta(days=1)).date() # Get all appointments for the target date appointments = Appointment.objects.filter( start_at__date=target_date, status__in=['BOOKED', 'CONFIRMED'] ).select_related('patient', 'provider', 'clinic', 'room').order_by('start_at') # Group by provider provider_schedules = {} for appointment in appointments: provider_id = str(appointment.provider.id) if provider_id not in provider_schedules: provider_schedules[provider_id] = { 'provider': appointment.provider, 'appointments': [] } provider_schedules[provider_id]['appointments'].append(appointment) # Send schedule to each provider for provider_id, schedule_data in provider_schedules.items(): provider = schedule_data['provider'] appointments_list = schedule_data['appointments'] # Prepare schedule message message = f"Your schedule for {target_date.strftime('%B %d, %Y')}:\n\n" for apt in appointments_list: message += ( f"• {apt.start_at.strftime('%I:%M %p')} - " f"{apt.patient.full_name_en} " f"({apt.get_appointment_type_display()})\n" ) # Send email to provider if provider.email: send_email_task.delay( subject=f"Daily Schedule - {target_date.strftime('%B %d, %Y')}", message=message, recipient_list=[provider.email], ) stats = { 'date': str(target_date), 'total_appointments': appointments.count(), 'providers_with_appointments': len(provider_schedules), } logger.info(f"Generated daily schedule for {target_date}: {stats}") return stats @shared_task def send_daily_appointment_reminders() -> int: """ Send reminders for all appointments happening today. This task runs daily at 8:00 AM to send reminders for appointments happening today. Returns: int: Number of reminders sent """ today = timezone.now().date() tomorrow = today + timedelta(days=1) # Get appointments for today that are confirmed appointments = Appointment.objects.filter( start_at__date=today, status__in=['CONFIRMED', 'BOOKED'] ) count = 0 for appointment in appointments: # Calculate hours until appointment hours_until = (appointment.start_at - timezone.now()).total_seconds() / 3600 # Only send if appointment is more than 2 hours away if hours_until > 2: send_appointment_reminder.delay( appointment_id=str(appointment.id), hours_before=int(hours_until) ) count += 1 # Also schedule 24-hour reminders for tomorrow's appointments tomorrow_appointments = Appointment.objects.filter( start_at__date=tomorrow, status__in=['CONFIRMED', 'BOOKED'] ) for appointment in tomorrow_appointments: # Schedule reminder for 24 hours before eta = appointment.start_at - timedelta(hours=24) if eta > timezone.now(): send_appointment_reminder.apply_async( args=[str(appointment.id), 24], eta=eta ) count += 1 logger.info(f"Scheduled {count} appointment reminders") return count @shared_task(bind=True, max_retries=3) def cancel_appointment_reminders(self, appointment_id: str) -> bool: """ Cancel scheduled reminders for an appointment. Args: appointment_id: UUID of the appointment Returns: bool: True if reminders were cancelled """ try: from appointments.models import AppointmentReminder # Update all scheduled reminders to cancelled cancelled_count = AppointmentReminder.objects.filter( appointment_id=appointment_id, status='SCHEDULED' ).update(status='CANCELLED') logger.info(f"Cancelled {cancelled_count} reminders for appointment {appointment_id}") return True except Exception as exc: logger.error(f"Failed to cancel reminders for appointment {appointment_id}: {exc}") raise self.retry(exc=exc, countdown=60) @shared_task def sync_calendar(provider_id: str = None) -> dict: """ Sync appointments with external calendar system (future implementation). Args: provider_id: UUID of provider to sync (None for all providers) Returns: dict: Sync statistics """ # Placeholder for future calendar integration (Google Calendar, Outlook, etc.) logger.info(f"Calendar sync requested for provider {provider_id or 'all'}") return { 'synced': 0, 'errors': 0, 'message': 'Calendar sync not yet implemented' } # ============================================================================ # Phase 5: Patient Confirmation Tasks # ============================================================================ @shared_task def send_pending_confirmation_reminders(): """ Send reminders for unconfirmed appointments. This task runs daily at 9:00 AM to send reminders to patients who haven't confirmed their appointments yet. Returns: dict: Statistics about reminders sent """ from appointments.confirmation_service import ConfirmationService from appointments.models import AppointmentConfirmation # Get pending confirmations for appointments in next 3 days pending = ConfirmationService.get_pending_confirmations(days_ahead=3) stats = { 'total': len(pending), 'sent': 0, 'failed': 0, 'max_reminders_reached': 0 } for confirmation in pending: # Check if max reminders reached if confirmation.reminder_count >= 3: stats['max_reminders_reached'] += 1 continue try: # Send reminder ConfirmationService.send_confirmation_reminder( confirmation=confirmation, request=None ) stats['sent'] += 1 except Exception as exc: logger.error(f"Failed to send confirmation reminder for {confirmation.id}: {exc}") stats['failed'] += 1 logger.info( f"Confirmation reminders: {stats['sent']} sent, " f"{stats['failed']} failed, " f"{stats['max_reminders_reached']} max reminders reached" ) return stats @shared_task def expire_old_confirmation_tokens(): """ Mark expired confirmation tokens as EXPIRED. This task runs daily at midnight to clean up expired tokens. Returns: dict: Statistics about expired tokens """ from appointments.confirmation_service import ConfirmationService expired_count = ConfirmationService.expire_old_confirmations() logger.info(f"Expired {expired_count} old confirmation tokens") return { 'expired': expired_count }