HH/apps/accounts/tasks.py
2026-04-08 17:13:35 +03:00

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