diff --git a/config/celery.py b/config/celery.py index 16b7b8f..7f544bf 100644 --- a/config/celery.py +++ b/config/celery.py @@ -22,17 +22,13 @@ app.autodiscover_tasks() # Celery Beat schedule for periodic tasks app.conf.beat_schedule = { - 'send-due-reminders': { - 'task': 'notifications.tasks.send_due_reminders', - 'schedule': crontab(minute='*/5'), # Every 5 minutes - }, - 'check-overdue-tasks': { - 'task': 'notifications.tasks.check_overdue_tasks', - 'schedule': crontab(hour=8, minute=0), # Daily at 8 AM + 'send-daily-task-email': { + 'task': 'notifications.tasks.send_daily_task_email', + 'schedule': crontab(hour=6, minute=0), # Daily at 6 AM UTC }, 'process-recurring-tasks': { 'task': 'notifications.tasks.process_recurring_tasks', - 'schedule': crontab(minute=0), # Every hour + 'schedule': crontab(hour=0, minute=0), # Daily at midnight }, } diff --git a/notifications/tasks.py b/notifications/tasks.py index f019a6a..e567eda 100644 --- a/notifications/tasks.py +++ b/notifications/tasks.py @@ -5,252 +5,128 @@ Celery tasks for notifications. 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 datetime import timedelta +from zoneinfo import ZoneInfo logger = logging.getLogger(__name__) @shared_task -def send_due_reminders(): +def send_daily_task_email(): """ - Check for tasks due soon and send reminders. - Runs every 5 minutes via Celery Beat. - """ - from tasks.models import Task - from users.models import DeviceToken - from .models import Notification, ScheduledReminder - - now = timezone.now() - - # Find scheduled reminders that need to be sent - reminders = ScheduledReminder.objects.filter( - remind_at__lte=now, - is_sent=False, - task__status__in=['pending', 'in_progress'] - ).select_related('task', 'task__user') - - for reminder in reminders: - task = reminder.task - user = task.user - - # Create notification - Notification.objects.create( - user=user, - notification_type='reminder', - title=f'Reminder: {task.title}', - message=f'Task "{task.title}" is due soon.', - task=task, - ) - - # Send push notifications - send_push_to_user.delay( - user_id=str(user.id), - title=f'Reminder: {task.title}', - body=f'Task is due soon.', - data={'task_id': str(task.id)} - ) - - # Mark as sent - reminder.is_sent = True - reminder.sent_at = now - reminder.save() - - -@shared_task -def check_overdue_tasks(): - """ - Check for overdue tasks and notify users. - Runs daily. - """ - from tasks.models import Task - from .models import Notification - - today = timezone.now().date() - - # Find overdue tasks that haven't been notified today - overdue_tasks = Task.objects.filter( - due_date__lt=today, - status__in=['pending', 'in_progress'] - ).select_related('user') - - for task in overdue_tasks: - # Check if we already sent an overdue notification today - existing = Notification.objects.filter( - user=task.user, - task=task, - notification_type='overdue', - created_at__date=today - ).exists() - - if not existing: - Notification.objects.create( - user=task.user, - notification_type='overdue', - title=f'Overdue: {task.title}', - message=f'Task "{task.title}" is overdue.', - task=task, - ) - - send_push_to_user.delay( - user_id=str(task.user.id), - title=f'Overdue: {task.title}', - body='This task is overdue.', - data={'task_id': str(task.id)} - ) - - -@shared_task -def send_push_to_user(user_id, title, body, data=None): - """ - Send push notification to all user devices. + 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 users.models import DeviceToken + from tasks.models import Task User = get_user_model() - try: - user = User.objects.get(id=user_id) - except User.DoesNotExist: - return + # Get all users who have email notifications enabled + users = User.objects.filter( + email_notifications=True, + email_verified=True, + is_active=True + ) - if not user.push_notifications: - return + emails_sent = 0 + current_utc_hour = timezone.now().hour - tokens = DeviceToken.objects.filter(user=user, is_active=True) + 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') - for token in tokens: - if token.platform == 'android': - send_fcm_notification.delay(token.token, title, body, data) - elif token.platform == 'web': - send_web_push_notification.delay(token.token, title, body, data) - elif token.platform == 'desktop': - # Desktop notifications are handled via WebSocket - pass + user_local_time = timezone.now().astimezone(user_tz) + user_local_hour = user_local_time.hour + # Only send if it's between 6-9 AM in the user's timezone + if not (6 <= user_local_hour < 9): + continue -@shared_task -def send_fcm_notification(token, title, body, data=None): - """Send FCM notification to Android device.""" - from django.conf import settings + # Get today's date in user's timezone + today = user_local_time.date() - # Only send if FCM is configured - if not getattr(settings, 'FCM_CREDENTIALS_PATH', None): - return + # 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') - try: - import firebase_admin - from firebase_admin import credentials, messaging + # 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') - # Initialize Firebase if not already done - if not firebase_admin._apps: - cred = credentials.Certificate(settings.FCM_CREDENTIALS_PATH) - firebase_admin.initialize_app(cred) + # Only send email if there are tasks + if not tasks_due_today.exists() and not overdue_tasks.exists(): + continue - message = messaging.Message( - notification=messaging.Notification( - title=title, - body=body, - ), - data=data or {}, - token=token, - ) + # Prepare email content + subject = f"Your tasks for {today.strftime('%A, %B %d, %Y')}" - messaging.send(message) + # Create plain text message + message_lines = [ + f"Good morning! Here are your tasks for today:\n" + ] - except Exception as e: - # Log error but don't fail - logger.error(f"FCM notification failed: {e}") + 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()) -@shared_task -def send_web_push_notification(subscription_info, title, body, data=None): - """Send Web Push notification.""" - from django.conf import settings + 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.") - vapid_private_key = getattr(settings, 'VAPID_PRIVATE_KEY', None) - vapid_email = getattr(settings, 'VAPID_ADMIN_EMAIL', None) + message = '\n'.join(message_lines) - if not vapid_private_key or not vapid_email: - return - - try: - from pywebpush import webpush, WebPushException - import json - - webpush( - subscription_info=json.loads(subscription_info), - data=json.dumps({ - 'title': title, - 'body': body, - 'data': data or {} - }), - vapid_private_key=vapid_private_key, - vapid_claims={'sub': f'mailto:{vapid_email}'} - ) - - except Exception as e: - logger.error(f"Web push notification failed: {e}") - - -@shared_task -def schedule_task_reminder(task_id): - """ - Schedule a reminder for a task based on its due date and user preferences. - Called when a task is created or updated. - """ - from tasks.models import Task - from .models import ScheduledReminder - - try: - task = Task.objects.get(id=task_id) - except Task.DoesNotExist: - return - - # Remove existing scheduled reminders for this task - ScheduledReminder.objects.filter(task=task, is_sent=False).delete() - - # Only schedule if task has a reminder time or due date - if task.reminder_at: - ScheduledReminder.objects.create( - task=task, - remind_at=task.reminder_at - ) - elif task.due_date: - # Default reminder based on user preference - reminder_minutes = task.user.default_reminder_minutes - if task.due_time: - from datetime import datetime - due_datetime = datetime.combine(task.due_date, task.due_time) - due_datetime = timezone.make_aware(due_datetime) - else: - # Default to 9 AM on due date - due_datetime = timezone.make_aware( - datetime.combine(task.due_date, datetime.min.time()) - ).replace(hour=9) - - remind_at = due_datetime - timedelta(minutes=reminder_minutes) - - if remind_at > timezone.now(): - ScheduledReminder.objects.create( - task=task, - remind_at=remind_at + # Send email + try: + send_mail( + subject=subject, + message=message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[user.email], + fail_silently=False, ) + emails_sent += 1 + 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}") + + logger.info(f"Daily task email job completed. Sent {emails_sent} emails.") + return emails_sent @shared_task def process_recurring_tasks(): """ Process completed recurring tasks and create next instances. - This runs periodically as a safety net to catch any tasks that weren't + This runs daily as a safety net to catch any tasks that weren't automatically handled when marked as completed. - Runs every hour via Celery Beat. """ from tasks.models import Task - # Find completed recurring tasks that don't have a next instance created yet - # We look for tasks completed in the last 24 hours to avoid reprocessing old tasks - yesterday = timezone.now() - timedelta(days=1) + # Find completed recurring tasks from the last 48 hours + yesterday = timezone.now() - timedelta(days=2) completed_recurring_tasks = Task.objects.filter( status='completed', @@ -261,7 +137,6 @@ def process_recurring_tasks(): tasks_created = 0 for task in completed_recurring_tasks: # Check if a next recurrence already exists - # Look for pending tasks with the same title, user, and recurrence pattern next_due_date = task.calculate_next_due_date() if next_due_date: # Check if we already created this recurrence