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
}, },
} }
+95 -220
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:
return
tokens = DeviceToken.objects.filter(user=user, is_active=True)
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
@shared_task
def send_fcm_notification(token, title, body, data=None):
"""Send FCM notification to Android device."""
from django.conf import settings
# Only send if FCM is configured
if not getattr(settings, 'FCM_CREDENTIALS_PATH', None):
return
try:
import firebase_admin
from firebase_admin import credentials, messaging
# Initialize Firebase if not already done
if not firebase_admin._apps:
cred = credentials.Certificate(settings.FCM_CREDENTIALS_PATH)
firebase_admin.initialize_app(cred)
message = messaging.Message(
notification=messaging.Notification(
title=title,
body=body,
),
data=data or {},
token=token,
) )
messaging.send(message) emails_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-9 AM in the user's timezone
if not (6 <= user_local_hour < 9):
continue
# Get today's date in user's timezone
today = user_local_time.date()
# 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)
# 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: except Exception as e:
# Log error but don't fail logger.error(f"Failed to send daily email to {user.email}: {e}")
logger.error(f"FCM notification failed: {e}")
logger.info(f"Daily task email job completed. Sent {emails_sent} emails.")
@shared_task return emails_sent
def send_web_push_notification(subscription_info, title, body, data=None):
"""Send Web Push notification."""
from django.conf import settings
vapid_private_key = getattr(settings, 'VAPID_PRIVATE_KEY', None)
vapid_email = getattr(settings, 'VAPID_ADMIN_EMAIL', None)
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
)
@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