Internal
Public Access
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>
This commit is contained in:
co-authored by
Claude Sonnet 4.5
parent
c51fd846a2
commit
1d6b07bfed
@@ -30,6 +30,10 @@ app.conf.beat_schedule = {
|
|||||||
'task': 'notifications.tasks.check_overdue_tasks',
|
'task': 'notifications.tasks.check_overdue_tasks',
|
||||||
'schedule': crontab(hour=8, minute=0), # Daily at 8 AM
|
'schedule': crontab(hour=8, minute=0), # Daily at 8 AM
|
||||||
},
|
},
|
||||||
|
'process-recurring-tasks': {
|
||||||
|
'task': 'notifications.tasks.process_recurring_tasks',
|
||||||
|
'schedule': crontab(minute=0), # Every hour
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -236,3 +236,51 @@ def schedule_task_reminder(task_id):
|
|||||||
task=task,
|
task=task,
|
||||||
remind_at=remind_at
|
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
|
||||||
|
|||||||
@@ -291,6 +291,9 @@ def process_time_entry_changes(user, entry_changes, last_sync_at):
|
|||||||
|
|
||||||
def update_task_from_data(task, data):
|
def update_task_from_data(task, data):
|
||||||
"""Update a task from sync data."""
|
"""Update a task from sync data."""
|
||||||
|
# Track old status to detect completion
|
||||||
|
old_status = task.status
|
||||||
|
|
||||||
task.title = data.get('title', task.title)
|
task.title = data.get('title', task.title)
|
||||||
task.description = data.get('description', task.description)
|
task.description = data.get('description', task.description)
|
||||||
task.status = data.get('status', task.status)
|
task.status = data.get('status', task.status)
|
||||||
@@ -302,6 +305,10 @@ def update_task_from_data(task, data):
|
|||||||
task.recurrence_rule = data.get('recurrence_rule', task.recurrence_rule)
|
task.recurrence_rule = data.get('recurrence_rule', task.recurrence_rule)
|
||||||
task.sort_order = data.get('sort_order', task.sort_order)
|
task.sort_order = data.get('sort_order', task.sort_order)
|
||||||
|
|
||||||
|
# Create next recurrence if task is being marked as completed
|
||||||
|
if old_status != 'completed' and task.status == 'completed' and task.recurrence != 'none':
|
||||||
|
task.create_next_recurrence()
|
||||||
|
|
||||||
task.save()
|
task.save()
|
||||||
|
|
||||||
# Handle tags
|
# Handle tags
|
||||||
|
|||||||
@@ -174,6 +174,79 @@ class Task(models.Model):
|
|||||||
secs = seconds % 60
|
secs = seconds % 60
|
||||||
return f"{hours}:{minutes:02d}:{secs:02d}"
|
return f"{hours}:{minutes:02d}:{secs:02d}"
|
||||||
|
|
||||||
|
def calculate_next_due_date(self, from_date=None):
|
||||||
|
"""
|
||||||
|
Calculate the next due date based on recurrence pattern.
|
||||||
|
Returns None if task doesn't recur or has reached end date.
|
||||||
|
"""
|
||||||
|
if self.recurrence == 'none':
|
||||||
|
return None
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
base_date = from_date or self.due_date
|
||||||
|
if not base_date:
|
||||||
|
from django.utils import timezone
|
||||||
|
base_date = timezone.now().date()
|
||||||
|
|
||||||
|
# Calculate next date based on recurrence type
|
||||||
|
if self.recurrence == 'daily':
|
||||||
|
next_date = base_date + timedelta(days=1)
|
||||||
|
elif self.recurrence == 'weekly':
|
||||||
|
next_date = base_date + timedelta(weeks=1)
|
||||||
|
elif self.recurrence == 'biweekly':
|
||||||
|
next_date = base_date + timedelta(weeks=2)
|
||||||
|
elif self.recurrence == 'monthly':
|
||||||
|
next_date = base_date + relativedelta(months=1)
|
||||||
|
elif self.recurrence == 'yearly':
|
||||||
|
next_date = base_date + relativedelta(years=1)
|
||||||
|
elif self.recurrence == 'custom' and self.recurrence_rule:
|
||||||
|
# TODO: Implement RRULE parsing for custom recurrence
|
||||||
|
# For now, default to weekly
|
||||||
|
next_date = base_date + timedelta(weeks=1)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if we've passed the end date
|
||||||
|
if self.recurrence_end_date and next_date > self.recurrence_end_date:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return next_date
|
||||||
|
|
||||||
|
def create_next_recurrence(self):
|
||||||
|
"""
|
||||||
|
Create the next instance of this recurring task.
|
||||||
|
Returns the new task or None if no recurrence should be created.
|
||||||
|
"""
|
||||||
|
if self.recurrence == 'none':
|
||||||
|
return None
|
||||||
|
|
||||||
|
next_due_date = self.calculate_next_due_date()
|
||||||
|
if not next_due_date:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Create new task with same properties
|
||||||
|
new_task = Task.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
parent=self.parent,
|
||||||
|
title=self.title,
|
||||||
|
description=self.description,
|
||||||
|
status='pending',
|
||||||
|
priority=self.priority,
|
||||||
|
due_date=next_due_date,
|
||||||
|
due_time=self.due_time,
|
||||||
|
recurrence=self.recurrence,
|
||||||
|
recurrence_rule=self.recurrence_rule,
|
||||||
|
recurrence_end_date=self.recurrence_end_date,
|
||||||
|
sort_order=self.sort_order,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Copy tags
|
||||||
|
new_task.tags.set(self.tags.all())
|
||||||
|
|
||||||
|
return new_task
|
||||||
|
|
||||||
|
|
||||||
class TimeEntry(models.Model):
|
class TimeEntry(models.Model):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -425,6 +425,9 @@ class TaskDetailView(View):
|
|||||||
task.title = request.POST.get('title', task.title)
|
task.title = request.POST.get('title', task.title)
|
||||||
task.description = request.POST.get('description', '')
|
task.description = request.POST.get('description', '')
|
||||||
|
|
||||||
|
# Track old status to detect completion
|
||||||
|
old_status = task.status
|
||||||
|
|
||||||
# Validate status against allowed choices
|
# Validate status against allowed choices
|
||||||
status = request.POST.get('status', task.status)
|
status = request.POST.get('status', task.status)
|
||||||
valid_statuses = [choice[0] for choice in Task.STATUS_CHOICES]
|
valid_statuses = [choice[0] for choice in Task.STATUS_CHOICES]
|
||||||
@@ -451,6 +454,10 @@ class TaskDetailView(View):
|
|||||||
else:
|
else:
|
||||||
task.recurrence = 'none'
|
task.recurrence = 'none'
|
||||||
|
|
||||||
|
# Create next recurrence if task is being marked as completed
|
||||||
|
if old_status != 'completed' and task.status == 'completed' and task.recurrence != 'none':
|
||||||
|
task.create_next_recurrence()
|
||||||
|
|
||||||
task.save()
|
task.save()
|
||||||
|
|
||||||
# Handle tags
|
# Handle tags
|
||||||
@@ -564,7 +571,13 @@ def task_toggle_status(request, task_id):
|
|||||||
if task.status == 'completed':
|
if task.status == 'completed':
|
||||||
task.status = 'pending'
|
task.status = 'pending'
|
||||||
else:
|
else:
|
||||||
|
# Mark as completed
|
||||||
task.status = 'completed'
|
task.status = 'completed'
|
||||||
|
|
||||||
|
# Create next recurrence if this is a recurring task
|
||||||
|
if task.recurrence != 'none':
|
||||||
|
task.create_next_recurrence()
|
||||||
|
|
||||||
task.save()
|
task.save()
|
||||||
|
|
||||||
next_url = request.POST.get('next', 'dashboard')
|
next_url = request.POST.get('next', 'dashboard')
|
||||||
|
|||||||
Reference in New Issue
Block a user