""" Celery tasks for notifications. """ import json import logging from celery import shared_task from django.utils import timezone from django.core.mail import send_mail from django.template.loader import render_to_string from django.conf import settings from django.db.models import Q from datetime import timedelta from zoneinfo import ZoneInfo logger = logging.getLogger(__name__) def notify_user(user, title, body, notification_type, task=None, url='/'): """ Send a notification to a user via whichever of email/push they have enabled, and record it in the Notification log. Returns True if it went out on at least one channel. """ from .models import Notification notified = False if user.email_notifications and user.email_verified: try: send_mail( subject=title, message=body, from_email=settings.DEFAULT_FROM_EMAIL, recipient_list=[user.email], fail_silently=False, ) notified = True except Exception as e: logger.error(f"Failed to email {user.email}: {e}") if user.push_notifications: send_web_push_to_user(user, title=title, body=body, url=url) notified = True if notified: Notification.objects.create( user=user, notification_type=notification_type, title=title, message=body, task=task, ) return notified def send_web_push_to_user(user, title, body, url='/'): """Send a Web Push notification to all of a user's registered web devices.""" if not settings.VAPID_PRIVATE_KEY: return from pywebpush import webpush, WebPushException from users.models import DeviceToken devices = DeviceToken.objects.filter(user=user, platform='web', is_active=True) for device in devices: try: subscription_info = json.loads(device.token) except (ValueError, TypeError): continue try: webpush( subscription_info=subscription_info, data=json.dumps({'title': title, 'body': body, 'url': url}), vapid_private_key=settings.VAPID_PRIVATE_KEY, vapid_claims={'sub': f'mailto:{settings.VAPID_ADMIN_EMAIL}'}, ) except WebPushException as e: status_code = e.response.status_code if e.response is not None else None if status_code in (404, 410): device.is_active = False device.save(update_fields=['is_active']) logger.warning(f"Web push failed for {user.email}: {e}") @shared_task def send_daily_task_email(): """ Send daily email to users with their tasks due today. Runs once per day and sends to users in their local morning time. """ from django.contrib.auth import get_user_model from tasks.models import Task User = get_user_model() # Get all users who have email and/or push notifications enabled users = User.objects.filter( Q(email_notifications=True) | Q(push_notifications=True), is_active=True ).distinct() notifications_sent = 0 current_utc_hour = timezone.now().hour for user in users: # Convert current UTC time to user's local time try: user_tz = ZoneInfo(user.timezone) except Exception: user_tz = ZoneInfo('UTC') user_local_time = timezone.now().astimezone(user_tz) user_local_hour = user_local_time.hour # Only send if it's between 6-7 AM in the user's timezone # (gives 1 hour window, task runs every hour) if not (6 <= user_local_hour < 7): continue # Get today's date in user's timezone today = user_local_time.date() # Check if we already sent an email today from .models import Notification already_sent_today = Notification.objects.filter( user=user, notification_type='daily_email', created_at__date=today ).exists() if already_sent_today: continue # Get tasks due today tasks_due_today = Task.objects.filter( user=user, due_date=today, status__in=['pending', 'in_progress'] ).order_by('due_time', 'priority', 'title') # Get overdue tasks overdue_tasks = Task.objects.filter( user=user, due_date__lt=today, status__in=['pending', 'in_progress'] ).order_by('due_date', 'due_time', 'priority', 'title') # Only send email if there are tasks if not tasks_due_today.exists() and not overdue_tasks.exists(): continue # Prepare email content subject = f"Your tasks for {today.strftime('%A, %B %d, %Y')}" # Create plain text message message_lines = [ f"Good morning! Here are your tasks for today:\n" ] if overdue_tasks.exists(): message_lines.append(f"\nāš ļø OVERDUE TASKS ({overdue_tasks.count()}):") for task in overdue_tasks[:10]: # Limit to 10 due_str = task.due_date.strftime('%b %d') message_lines.append(f" • {task.title} (due {due_str})") if overdue_tasks.count() > 10: message_lines.append(f" ... and {overdue_tasks.count() - 10} more") if tasks_due_today.exists(): message_lines.append(f"\nšŸ“… DUE TODAY ({tasks_due_today.count()}):") for task in tasks_due_today: time_str = task.due_time.strftime('%I:%M %p') if task.due_time else '' priority_icon = {'high': 'šŸ”“', 'medium': '🟔', 'low': '🟢'}.get(task.priority, '') message_lines.append(f" • {priority_icon} {task.title} {time_str}".strip()) message_lines.append(f"\n\nView all tasks: https://{settings.SITE_DOMAIN}") message_lines.append("\nYou can change your email preferences in your profile settings.") message = '\n'.join(message_lines) notified = False # Send email if user.email_notifications and user.email_verified: try: send_mail( subject=subject, message=message, from_email=settings.DEFAULT_FROM_EMAIL, recipient_list=[user.email], fail_silently=False, ) notified = True logger.info(f"Sent daily task email to {user.email}") except Exception as e: logger.error(f"Failed to send daily email to {user.email}: {e}") # Send push if user.push_notifications: send_web_push_to_user( user, title=subject, body=f"{tasks_due_today.count()} due today, {overdue_tasks.count()} overdue", url='/', ) notified = True if notified: # Record that we notified the user today (prevents duplicates) Notification.objects.create( user=user, notification_type='daily_email', title=subject, message=f"Daily notification sent with {tasks_due_today.count()} tasks due today and {overdue_tasks.count()} overdue tasks" ) notifications_sent += 1 logger.info(f"Daily task notification job completed. Notified {notifications_sent} users.") return notifications_sent REMINDER_MESSAGES = { 'reminder': lambda task: (f'Upcoming: {task.title}', f'"{task.title}" is due soon.'), 'due_soon': lambda task: (f'Due now: {task.title}', f'"{task.title}" is due now.'), 'overdue': lambda task: (f'Overdue: {task.title}', f'"{task.title}" is overdue.'), } @shared_task def send_scheduled_reminders(): """ Send "before due", "due now", and "overdue" notifications from ScheduledReminder rows created by Task.reschedule_reminders(). Runs frequently (every few minutes) since these are time-sensitive, unlike the once-a-day digest above. """ from .models import ScheduledReminder due_reminders = ScheduledReminder.objects.filter( is_sent=False, remind_at__lte=timezone.now() ).select_related('task', 'task__user') sent_count = 0 for reminder in due_reminders: task = reminder.task if not task.is_deleted and task.status not in ('completed', 'cancelled'): title, body = REMINDER_MESSAGES[reminder.reminder_type](task) if notify_user(task.user, title, body, reminder.reminder_type, task=task, url=f'/tasks/{task.id}/'): sent_count += 1 reminder.is_sent = True reminder.sent_at = timezone.now() reminder.save(update_fields=['is_sent', 'sent_at']) return sent_count @shared_task def process_recurring_tasks(): """ Process completed recurring tasks and create next instances. This runs daily as a safety net to catch any tasks that weren't automatically handled when marked as completed. """ from tasks.models import Task # Find completed recurring tasks from the last 48 hours yesterday = timezone.now() - timedelta(days=2) completed_recurring_tasks = Task.objects.filter( status='completed', recurrence__in=['daily', 'weekly', 'biweekly', 'monthly', 'yearly', 'custom'], completed_at__gte=yesterday ).exclude(recurrence='none') tasks_created = 0 for task in completed_recurring_tasks: # Check if a next recurrence already exists next_due_date = task.calculate_next_due_date() if next_due_date: # Check if we already created this recurrence # Check for any task (pending OR completed) to avoid duplicates existing = Task.objects.filter( user=task.user, title=task.title, due_date=next_due_date, recurrence=task.recurrence ).exists() if not existing: # Create the next recurrence new_task = task.create_next_recurrence() if new_task: tasks_created += 1 logger.info(f"Created recurring task: {new_task.title} (due: {new_task.due_date})") if tasks_created > 0: logger.info(f"Created {tasks_created} recurring task instances") return tasks_created