453 lines
14 KiB
Python
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
|
|
}
|