"""Celery tasks for the QI Projects app.""" import logging from datetime import datetime, time as datetime_time, timedelta from celery import shared_task from django.conf import settings from django.utils import timezone logger = logging.getLogger(__name__) FIRST_REMINDER_HOURS_BEFORE = 24 SECOND_REMINDER_HOURS_BEFORE = 6 @shared_task def send_qi_task_reminders(): """Send deadline-approaching reminders for assigned QI tasks. Runs every 15 minutes via Celery Beat. Sends a first reminder 24h before the due date and a second reminder 6h before. Each reminder fires at most once per task (deduped via `reminder_sent_at` / `second_reminder_sent_at`). Tasks with no email-capable assignee or whose send fails are retried on subsequent runs. """ from apps.notifications.services import NotificationService from .models import QIProjectTask now = timezone.now() tz = timezone.get_current_timezone() site_url = getattr(settings, "SITE_URL", "http://localhost:8000").rstrip("/") def _deadline(task): # Interpret due_date (a date) as end-of-day in the current tz. return timezone.make_aware(datetime.combine(task.due_date, datetime_time(23, 59, 59)), tz) def _send(task, hours_before, label): project_url = f"{site_url}/projects/{task.project.id}/" message = ( f"Reminder ({label}): The task '{task.title}' in project " f"'{task.project.name}' is due on {task.due_date.strftime('%Y-%m-%d')} " f"({hours_before}h remaining)." ) NotificationService.send_email( email=task.assigned_to.user.email, subject=f"QI Task Reminder: {task.title}", message=message, related_object=task, metadata={ "notification_type": "qi_task_reminder", "task_id": str(task.id), "project_id": str(task.project.id), "reminder": label, "hours_before": hours_before, }, notification_type="qi_task_reminder", user=task.assigned_to.user, ) logger.info( f"QI task {label} reminder sent for task {task.id} " f"(assignee {task.assigned_to_id}, due {task.due_date})" ) first_count = 0 second_count = 0 # First reminders: due within 24h, not yet reminded for task in QIProjectTask.objects.filter( assigned_to__isnull=False, status__in=["pending", "active"], due_date__isnull=False, reminder_sent_at__isnull=True, project__is_template=False, ).select_related("assigned_to__user", "project"): deadline = _deadline(task) if not (deadline - timedelta(hours=FIRST_REMINDER_HOURS_BEFORE) <= now < deadline): continue user = task.assigned_to.user if not user or not getattr(user, "email", None): logger.warning(f"QI task {task.id} assignee has no email; skipping first reminder.") continue try: _send(task, FIRST_REMINDER_HOURS_BEFORE, "first") task.reminder_sent_at = now task.save(update_fields=["reminder_sent_at"]) first_count += 1 except Exception as e: logger.warning(f"Failed to send first QI reminder for task {task.id}: {e}") # Second reminders: due within 6h, first sent >=1h ago, not yet second-reminded for task in QIProjectTask.objects.filter( assigned_to__isnull=False, status__in=["pending", "active"], due_date__isnull=False, second_reminder_sent_at__isnull=True, reminder_sent_at__isnull=False, reminder_sent_at__lt=now - timedelta(hours=1), project__is_template=False, ).select_related("assigned_to__user", "project"): deadline = _deadline(task) if not (deadline - timedelta(hours=SECOND_REMINDER_HOURS_BEFORE) <= now < deadline): continue user = task.assigned_to.user if not user or not getattr(user, "email", None): logger.warning(f"QI task {task.id} assignee has no email; skipping second reminder.") continue try: _send(task, SECOND_REMINDER_HOURS_BEFORE, "second") task.second_reminder_sent_at = now task.save(update_fields=["second_reminder_sent_at"]) second_count += 1 except Exception as e: logger.warning(f"Failed to send second QI reminder for task {task.id}: {e}") logger.info(f"QI task reminders: {first_count} first, {second_count} second") return {"first_reminder_count": first_count, "second_reminder_count": second_count}