Internal
Public Access
Prevent process_recurring_tasks from creating duplicate tasks for dates that already have completed instances. Previously only checked for pending tasks, causing old completed recurring tasks to reappear as pending when subsequent instances went overdue. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
183 lines
6.2 KiB
Python
183 lines
6.2 KiB
Python
"""
|
|
Celery tasks for notifications.
|
|
"""
|
|
|
|
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 datetime import timedelta
|
|
from zoneinfo import ZoneInfo
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@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 notifications enabled
|
|
users = User.objects.filter(
|
|
email_notifications=True,
|
|
email_verified=True,
|
|
is_active=True
|
|
)
|
|
|
|
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-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)
|
|
|
|
# Send email
|
|
try:
|
|
send_mail(
|
|
subject=subject,
|
|
message=message,
|
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
|
recipient_list=[user.email],
|
|
fail_silently=False,
|
|
)
|
|
|
|
# Record that we sent the email (prevents duplicates)
|
|
Notification.objects.create(
|
|
user=user,
|
|
notification_type='daily_email',
|
|
title=subject,
|
|
message=f"Daily email sent with {tasks_due_today.count()} tasks due today and {overdue_tasks.count()} overdue tasks"
|
|
)
|
|
|
|
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
|
|
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
|