agdar/appointments/tasks.py
2025-11-02 14:35:35 +03:00

453 lines
14 KiB
Python

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