Internal
Public Access
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:
co-authored by
Claude Sonnet 4.5
parent
842b958b43
commit
03cfdea42d
+4
-8
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user