diff --git a/config/celery.py b/config/celery.py index 1d6bee0..16b7b8f 100644 --- a/config/celery.py +++ b/config/celery.py @@ -30,6 +30,10 @@ app.conf.beat_schedule = { 'task': 'notifications.tasks.check_overdue_tasks', '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 + }, } diff --git a/notifications/tasks.py b/notifications/tasks.py index ffe8ca0..2ae6318 100644 --- a/notifications/tasks.py +++ b/notifications/tasks.py @@ -236,3 +236,51 @@ def schedule_task_reminder(task_id): 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 diff --git a/sync/views.py b/sync/views.py index 28b0dc4..cfdefff 100644 --- a/sync/views.py +++ b/sync/views.py @@ -291,6 +291,9 @@ def process_time_entry_changes(user, entry_changes, last_sync_at): def update_task_from_data(task, 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.description = data.get('description', task.description) 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.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() # Handle tags diff --git a/tasks/models.py b/tasks/models.py index 9f616c9..28db6a4 100644 --- a/tasks/models.py +++ b/tasks/models.py @@ -174,6 +174,79 @@ class Task(models.Model): secs = seconds % 60 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): """ diff --git a/tasks/views.py b/tasks/views.py index 1596118..04263b4 100644 --- a/tasks/views.py +++ b/tasks/views.py @@ -425,6 +425,9 @@ class TaskDetailView(View): task.title = request.POST.get('title', task.title) task.description = request.POST.get('description', '') + # Track old status to detect completion + old_status = task.status + # Validate status against allowed choices status = request.POST.get('status', task.status) valid_statuses = [choice[0] for choice in Task.STATUS_CHOICES] @@ -451,6 +454,10 @@ class TaskDetailView(View): else: 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() # Handle tags @@ -564,7 +571,13 @@ def task_toggle_status(request, task_id): if task.status == 'completed': task.status = 'pending' else: + # Mark as completed task.status = 'completed' + + # Create next recurrence if this is a recurring task + if task.recurrence != 'none': + task.create_next_recurrence() + task.save() next_url = request.POST.get('next', 'dashboard')