Files
KeepItGoingServer/notifications/tasks.py
T
Keith SmithandClaude Sonnet 5 5359bf243a Add per-task reminder notifications: before due, due now, and overdue
Only proactive notification was the once-a-day 6-7AM digest. Wires up
the previously-unused User.default_reminder_minutes profile setting
and ScheduledReminder model (Gitea #8) to send timely per-task alerts:

- Task.reschedule_reminders(), hooked into Task.save() via a dirty-check
  against due_date/due_time/reminder_at/status/is_deleted, (re)creates up
  to 3 ScheduledReminder rows per active due task: a "before due" reminder
  (from reminder_at if set, else default_reminder_minutes before due), a
  "due now" notification, and an "overdue" notification (mirroring
  is_overdue's day-after rule for date-only due tasks). Hooking into
  save() means every call site - web views, the API, and sync - picks
  this up automatically.
- New Celery task send_scheduled_reminders (beat schedule: every 5 min)
  sends due reminders via whichever of email/push the user has enabled,
  reusing the existing per-user channel toggles, and logs them to the
  Notification table.
- 14 new tests covering the scheduling math, due-date-change/completion/
  deletion cleanup, and the sending task's channel and timing behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 09:46:12 -06:00

304 lines
10 KiB
Python

"""
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