Simplify notification system to daily email only

Replace complex notification system with a simple daily email:
- Send ONE email per day between 6-9 AM in user's timezone
- Show tasks due today and overdue tasks
- Only send if user has email_notifications enabled
- Remove all push notification logic
- Keep recurring task processor (runs daily at midnight)

This makes notifications much simpler and less intrusive while
still providing value to users.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Keith Smith
2026-01-09 09:30:46 -07:00
co-authored by Claude Sonnet 4.5
parent 842b958b43
commit 03cfdea42d
2 changed files with 88 additions and 217 deletions
+4 -8
View File
@@ -22,17 +22,13 @@ app.autodiscover_tasks()
# Celery Beat schedule for periodic tasks # Celery Beat schedule for periodic tasks
app.conf.beat_schedule = { app.conf.beat_schedule = {
'send-due-reminders': { 'send-daily-task-email': {
'task': 'notifications.tasks.send_due_reminders', 'task': 'notifications.tasks.send_daily_task_email',
'schedule': crontab(minute='*/5'), # Every 5 minutes 'schedule': crontab(hour=6, minute=0), # Daily at 6 AM UTC
},
'check-overdue-tasks': {
'task': 'notifications.tasks.check_overdue_tasks',
'schedule': crontab(hour=8, minute=0), # Daily at 8 AM
}, },
'process-recurring-tasks': { 'process-recurring-tasks': {
'task': 'notifications.tasks.process_recurring_tasks', 'task': 'notifications.tasks.process_recurring_tasks',
'schedule': crontab(minute=0), # Every hour 'schedule': crontab(hour=0, minute=0), # Daily at midnight
}, },
} }
+84 -209
View File
@@ -5,252 +5,128 @@ Celery tasks for notifications.
import logging import logging
from celery import shared_task from celery import shared_task
from django.utils import timezone 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 datetime import timedelta
from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@shared_task @shared_task
def send_due_reminders(): def send_daily_task_email():
""" """
Check for tasks due soon and send reminders. Send daily email to users with their tasks due today.
Runs every 5 minutes via Celery Beat. Runs once per day and sends to users in their local morning time.
"""
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.
""" """
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from users.models import DeviceToken from tasks.models import Task
User = get_user_model() User = get_user_model()
try: # Get all users who have email notifications enabled
user = User.objects.get(id=user_id) users = User.objects.filter(
except User.DoesNotExist: email_notifications=True,
return email_verified=True,
is_active=True
)
if not user.push_notifications: emails_sent = 0
return 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: user_local_time = timezone.now().astimezone(user_tz)
if token.platform == 'android': user_local_hour = user_local_time.hour
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
# Only send if it's between 6-9 AM in the user's timezone
if not (6 <= user_local_hour < 9):
continue
@shared_task # Get today's date in user's timezone
def send_fcm_notification(token, title, body, data=None): today = user_local_time.date()
"""Send FCM notification to Android device."""
from django.conf import settings
# Only send if FCM is configured # Get tasks due today
if not getattr(settings, 'FCM_CREDENTIALS_PATH', None): tasks_due_today = Task.objects.filter(
return user=user,
due_date=today,
status__in=['pending', 'in_progress']
).order_by('due_time', 'priority', 'title')
try: # Get overdue tasks
import firebase_admin overdue_tasks = Task.objects.filter(
from firebase_admin import credentials, messaging 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 # Only send email if there are tasks
if not firebase_admin._apps: if not tasks_due_today.exists() and not overdue_tasks.exists():
cred = credentials.Certificate(settings.FCM_CREDENTIALS_PATH) continue
firebase_admin.initialize_app(cred)
message = messaging.Message( # Prepare email content
notification=messaging.Notification( subject = f"Your tasks for {today.strftime('%A, %B %d, %Y')}"
title=title,
body=body,
),
data=data or {},
token=token,
)
messaging.send(message) # Create plain text message
message_lines = [
f"Good morning! Here are your tasks for today:\n"
]
except Exception as e: if overdue_tasks.exists():
# Log error but don't fail message_lines.append(f"\n⚠️ OVERDUE TASKS ({overdue_tasks.count()}):")
logger.error(f"FCM notification failed: {e}") 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 message_lines.append(f"\n\nView all tasks: https://{settings.SITE_DOMAIN}")
def send_web_push_notification(subscription_info, title, body, data=None): message_lines.append("\nYou can change your email preferences in your profile settings.")
"""Send Web Push notification."""
from django.conf import settings
vapid_private_key = getattr(settings, 'VAPID_PRIVATE_KEY', None) message = '\n'.join(message_lines)
vapid_email = getattr(settings, 'VAPID_ADMIN_EMAIL', None)
if not vapid_private_key or not vapid_email: # Send email
return try:
send_mail(
try: subject=subject,
from pywebpush import webpush, WebPushException message=message,
import json from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[user.email],
webpush( fail_silently=False,
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
) )
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 @shared_task
def process_recurring_tasks(): def process_recurring_tasks():
""" """
Process completed recurring tasks and create next instances. 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. automatically handled when marked as completed.
Runs every hour via Celery Beat.
""" """
from tasks.models import Task from tasks.models import Task
# Find completed recurring tasks that don't have a next instance created yet # Find completed recurring tasks from the last 48 hours
# We look for tasks completed in the last 24 hours to avoid reprocessing old tasks yesterday = timezone.now() - timedelta(days=2)
yesterday = timezone.now() - timedelta(days=1)
completed_recurring_tasks = Task.objects.filter( completed_recurring_tasks = Task.objects.filter(
status='completed', status='completed',
@@ -261,7 +137,6 @@ def process_recurring_tasks():
tasks_created = 0 tasks_created = 0
for task in completed_recurring_tasks: for task in completed_recurring_tasks:
# Check if a next recurrence already exists # 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() next_due_date = task.calculate_next_due_date()
if next_due_date: if next_due_date:
# Check if we already created this recurrence # Check if we already created this recurrence