Internal
Public Access
- Fix import error in notifications/tasks.py (timezone.datetime -> datetime) - Add healthchecks to celery-worker and celery-beat containers - Add procps package to Dockerfile for pgrep command - Add email environment variables to celery containers The import error was causing celery workers to crash when loading tasks. Missing healthchecks prevented proper container health monitoring. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
287 lines
8.5 KiB
Python
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(
|
|
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
|
|
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
|