Files
KeepItGoingServer/notifications/tasks.py
T
Keith SmithandClaude Sonnet 4.5 1d6b07bfed Implement recurring tasks functionality
Adds automatic creation of next task instance when recurring tasks are completed.

- Add calculate_next_due_date() and create_next_recurrence() methods to Task model
- Update task completion handlers in views and sync API to create next recurrence
- Add hourly Celery task to process any missed recurring tasks
- Support daily, weekly, biweekly, monthly, yearly recurrence patterns
- Respect recurrence_end_date limits

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-04 10:00:34 -07:00

287 lines
8.5 KiB
Python

"""
Celery tasks for notifications.
"""
import logging
from celery import shared_task
from django.utils import timezone
from datetime import timedelta
logger = logging.getLogger(__name__)
@shared_task
def send_due_reminders():
"""
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.
"""
from django.contrib.auth import get_user_model
from users.models import DeviceToken
User = get_user_model()
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
return
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)
except Exception as e:
# Log error but don't fail
logger.error(f"FCM notification failed: {e}")
@shared_task
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(
timezone.datetime.combine(task.due_date, timezone.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
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
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)
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
# 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
existing = Task.objects.filter(
user=task.user,
title=task.title,
due_date=next_due_date,
recurrence=task.recurrence,
status='pending'
).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