""" Accounts Celery tasks This module contains tasks for: - Sending onboarding reminders to provisional users - Cleaning up expired invitations - Processing user acknowledgements """ import logging from datetime import timedelta from celery import shared_task from django.contrib.auth import get_user_model from django.db.models import Q from django.utils import timezone from apps.notifications.settings_models import HospitalNotificationSettings from apps.notifications.settings_service import NotificationServiceWithSettings from .models import UserProvisionalLog from .services import EmailService logger = logging.getLogger(__name__) User = get_user_model() @shared_task(bind=True, max_retries=3) def send_onboarding_reminders(self): """ Send reminder emails to provisional users whose invitations are about to expire. Sends reminders at: - 48 hours before expiry - 24 hours before expiry Respects hospital notification settings. """ now = timezone.now() # Calculate time windows for reminders # 24h reminder: expires between 23-25 hours from now reminder_24h_start = now + timedelta(hours=23) reminder_24h_end = now + timedelta(hours=25) # 48h reminder: expires between 47-49 hours from now reminder_48h_start = now + timedelta(hours=47) reminder_48h_end = now + timedelta(hours=49) logger.info(f"Checking for onboarding reminders at {now}") # Find users needing 24h reminder users_24h = User.objects.filter( is_provisional=True, acknowledgement_completed=False, invitation_expires_at__gte=reminder_24h_start, invitation_expires_at__lte=reminder_24h_end, invitation_token__isnull=False, ).exclude( # Exclude users who already received 24h reminder provisional_logs__event_type="onboarding_reminder_sent", provisional_logs__description__contains="24h", ) # Find users needing 48h reminder users_48h = User.objects.filter( is_provisional=True, acknowledgement_completed=False, invitation_expires_at__gte=reminder_48h_start, invitation_expires_at__lte=reminder_48h_end, invitation_token__isnull=False, ).exclude( # Exclude users who already received 48h reminder provisional_logs__event_type="onboarding_reminder_sent", provisional_logs__description__contains="48h", ) sent_count_24h = 0 sent_count_48h = 0 skipped_count = 0 error_count = 0 # Send 24h reminders for user in users_24h: try: hospital_id = getattr(user, "hospital_id", None) if hospital_id: # Check notification settings settings = HospitalNotificationSettings.get_for_hospital(hospital_id) if not settings.notifications_enabled: logger.info( f"Notifications disabled for hospital {hospital_id}, skipping 24h reminder for {user.email}" ) skipped_count += 1 continue if not settings.onboarding_reminder_email: logger.info(f"Onboarding reminders disabled for hospital {hospital_id}, skipping {user.email}") skipped_count += 1 continue # Send reminder using new notification service results = NotificationServiceWithSettings.send_onboarding_reminder( user_email=user.email, provisional_user=user ) # Also send via legacy service for backward compatibility EmailService.send_reminder_email(user) # Log the reminder UserProvisionalLog.objects.create( user=user, event_type="onboarding_reminder_sent", description=f"24h reminder sent before invitation expiry", metadata={ "hours_before_expiry": 24, "expires_at": user.invitation_expires_at.isoformat(), "channels": [r[0] for r in results] if results else ["email"], }, ) sent_count_24h += 1 logger.info(f"Sent 24h onboarding reminder to {user.email}") except Exception as e: logger.error(f"Failed to send 24h reminder to {user.email}: {str(e)}") error_count += 1 # Send 48h reminders for user in users_48h: try: hospital_id = getattr(user, "hospital_id", None) if hospital_id: # Check notification settings settings = HospitalNotificationSettings.get_for_hospital(hospital_id) if not settings.notifications_enabled: logger.info( f"Notifications disabled for hospital {hospital_id}, skipping 48h reminder for {user.email}" ) skipped_count += 1 continue if not settings.onboarding_reminder_email: logger.info(f"Onboarding reminders disabled for hospital {hospital_id}, skipping {user.email}") skipped_count += 1 continue # Send reminder using new notification service results = NotificationServiceWithSettings.send_onboarding_reminder( user_email=user.email, provisional_user=user ) # Also send via legacy service for backward compatibility EmailService.send_reminder_email(user) # Log the reminder UserProvisionalLog.objects.create( user=user, event_type="onboarding_reminder_sent", description=f"48h reminder sent before invitation expiry", metadata={ "hours_before_expiry": 48, "expires_at": user.invitation_expires_at.isoformat(), "channels": [r[0] for r in results] if results else ["email"], }, ) sent_count_48h += 1 logger.info(f"Sent 48h onboarding reminder to {user.email}") except Exception as e: logger.error(f"Failed to send 48h reminder to {user.email}: {str(e)}") error_count += 1 summary = { "24h_reminders_sent": sent_count_24h, "48h_reminders_sent": sent_count_48h, "total_reminders_sent": sent_count_24h + sent_count_48h, "skipped_due_to_settings": skipped_count, "errors": error_count, } logger.info(f"Onboarding reminder task completed: {summary}") return summary @shared_task(bind=True, max_retries=3) def cleanup_expired_invitations(self): """ Clean up expired provisional user invitations. Marks users with expired invitations and logs the expiration. Optionally sends a final notification about expiration. """ now = timezone.now() # Find expired invitations expired_users = User.objects.filter( is_provisional=True, acknowledgement_completed=False, invitation_expires_at__lt=now, invitation_token__isnull=False, ).exclude( # Exclude already logged as expired provisional_logs__event_type="invitation_expired" ) expired_count = 0 error_count = 0 for user in expired_users: try: # Log the expiration UserProvisionalLog.objects.create( user=user, event_type="invitation_expired", description=f"Invitation token expired for {user.email}", metadata={ "expired_at": now.isoformat(), "original_expiry": user.invitation_expires_at.isoformat() if user.invitation_expires_at else None, }, ) # Invalidate the token user.invitation_token = None user.save(update_fields=["invitation_token"]) expired_count += 1 logger.info(f"Marked invitation as expired for {user.email}") except Exception as e: logger.error(f"Failed to process expired invitation for {user.email}: {str(e)}") error_count += 1 summary = {"expired_invitations": expired_count, "errors": error_count} logger.info(f"Expired invitation cleanup completed: {summary}") return summary @shared_task(bind=True, max_retries=3) def send_final_expiration_notice(self, user_id): """ Send a final notification to user that their invitation has expired. Args: user_id: UUID of the provisional user """ try: user = User.objects.get(id=user_id, is_provisional=True) # Check notification settings hospital_id = getattr(user, "hospital_id", None) if hospital_id: settings = HospitalNotificationSettings.get_for_hospital(hospital_id) if not settings.notifications_enabled or not settings.onboarding_reminder_email: logger.info(f"Notifications disabled, skipping expiration notice for {user.email}") return {"status": "skipped", "reason": "notifications_disabled"} # Send expiration email from django.core.mail import send_mail from django.conf import settings as django_settings from django.template.loader import render_to_string subject = "Your PX360 Invitation Has Expired" html_message = render_to_string("emails/invitation_expired.html", {"user": user}) message = f""" Dear {user.first_name}, Your invitation to join PX360 has expired. Please contact your administrator to request a new invitation. Best regards, PX360 Team """ send_mail( subject=subject, message=message, from_email=django_settings.DEFAULT_FROM_EMAIL, recipient_list=[user.email], fail_silently=True, html_message=html_message, ) logger.info(f"Sent expiration notice to {user.email}") return {"status": "sent", "user_email": user.email} except User.DoesNotExist: logger.warning(f"User {user_id} not found for expiration notice") return {"status": "error", "reason": "user_not_found"} except Exception as e: logger.error(f"Failed to send expiration notice: {str(e)}") return {"status": "error", "reason": str(e)}