303 lines
10 KiB
Python
303 lines
10 KiB
Python
"""
|
|
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)}
|