Internal
Public Access
Two refinements from live testing of the reminder feature: - Due-time tasks scheduled "due now" and "overdue" at the exact same moment. Task._due_and_overdue_moments() now delays "overdue" by an hour so they don't arrive together. The overdue badge/styling elsewhere is unaffected - only this notification's timing changes. - Task.reschedule_reminders() only captures user.default_reminder_minutes at the moment a task's own due_date/due_time is set, so changing the profile setting didn't reach tasks whose due date was already set. User.save() now detects a change to that setting and calls the new User.reschedule_reminder_notifications(), which recomputes the "before due" reminder on active due tasks that don't have their own explicit reminder_at override. 8 new/updated tests cover the overdue delay and the retroactive rescheduling (including that unrelated profile saves and tasks with an explicit reminder_at are left untouched). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
523 lines
17 KiB
Python
523 lines
17 KiB
Python
"""
|
|
Task models for KeepItGoing.
|
|
"""
|
|
|
|
import uuid
|
|
from django.conf import settings
|
|
from django.db import models
|
|
|
|
|
|
class SoftDeleteManager(models.Manager):
|
|
"""Manager that excludes soft-deleted items by default."""
|
|
|
|
def get_queryset(self):
|
|
return super().get_queryset().filter(is_deleted=False)
|
|
|
|
|
|
class AllObjectsManager(models.Manager):
|
|
"""Manager that includes soft-deleted items."""
|
|
pass
|
|
|
|
|
|
class Tag(models.Model):
|
|
"""
|
|
Tags for organizing and categorizing tasks.
|
|
Supports multiple tags per task.
|
|
"""
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
user = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name='tags'
|
|
)
|
|
name = models.CharField(max_length=255)
|
|
description = models.TextField(blank=True)
|
|
color = models.CharField(max_length=7, default='#3B82F6') # Hex color
|
|
icon = models.CharField(max_length=50, blank=True) # Icon name
|
|
sort_order = models.IntegerField(default=0)
|
|
is_archived = models.BooleanField(default=False)
|
|
|
|
# Sync fields
|
|
sync_id = models.UUIDField(default=uuid.uuid4, unique=True)
|
|
is_deleted = models.BooleanField(default=False)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
# Managers
|
|
objects = SoftDeleteManager()
|
|
all_objects = AllObjectsManager()
|
|
|
|
class Meta:
|
|
db_table = 'tags'
|
|
ordering = ['sort_order', 'name']
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
class Task(models.Model):
|
|
"""
|
|
Main task model with support for subtasks, recurrence, and time tracking.
|
|
"""
|
|
STATUS_CHOICES = [
|
|
('pending', 'Pending'),
|
|
('in_progress', 'In Progress'),
|
|
('completed', 'Completed'),
|
|
('cancelled', 'Cancelled'),
|
|
]
|
|
|
|
PRIORITY_CHOICES = [
|
|
('low', 'Low'),
|
|
('medium', 'Medium'),
|
|
('high', 'High'),
|
|
('urgent', 'Urgent'),
|
|
]
|
|
|
|
RECURRENCE_CHOICES = [
|
|
('none', 'None'),
|
|
('daily', 'Daily'),
|
|
('weekly', 'Weekly'),
|
|
('biweekly', 'Bi-weekly'),
|
|
('monthly', 'Monthly'),
|
|
('yearly', 'Yearly'),
|
|
('custom', 'Custom'),
|
|
]
|
|
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
user = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name='tasks'
|
|
)
|
|
|
|
# Hierarchy
|
|
parent = models.ForeignKey(
|
|
'self',
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
blank=True,
|
|
related_name='subtasks'
|
|
)
|
|
|
|
# Core fields
|
|
title = models.CharField(max_length=500)
|
|
description = models.TextField(blank=True)
|
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
|
|
priority = models.CharField(max_length=20, choices=PRIORITY_CHOICES, default='medium')
|
|
|
|
# Dates and times
|
|
due_date = models.DateField(null=True, blank=True)
|
|
due_time = models.TimeField(null=True, blank=True)
|
|
reminder_at = models.DateTimeField(null=True, blank=True)
|
|
completed_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
# Recurrence (RRULE format for custom)
|
|
recurrence = models.CharField(max_length=20, choices=RECURRENCE_CHOICES, default='none')
|
|
recurrence_rule = models.CharField(max_length=500, blank=True) # RRULE string
|
|
recurrence_end_date = models.DateField(null=True, blank=True)
|
|
|
|
# Tags (multiple per task)
|
|
tags = models.ManyToManyField(Tag, blank=True, related_name='tasks')
|
|
|
|
# Ordering
|
|
sort_order = models.IntegerField(default=0)
|
|
|
|
# Sync fields
|
|
sync_id = models.UUIDField(default=uuid.uuid4, unique=True)
|
|
is_deleted = models.BooleanField(default=False)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
# Managers
|
|
objects = SoftDeleteManager()
|
|
all_objects = AllObjectsManager()
|
|
|
|
class Meta:
|
|
db_table = 'tasks'
|
|
ordering = ['sort_order', '-priority', 'due_date', 'created_at']
|
|
|
|
# Fields that affect when/whether reminder notifications should fire.
|
|
REMINDER_TRACKED_FIELDS = ('due_date', 'due_time', 'reminder_at', 'status', 'is_deleted')
|
|
|
|
def __str__(self):
|
|
return self.title
|
|
|
|
@classmethod
|
|
def from_db(cls, db, field_names, values):
|
|
instance = super().from_db(db, field_names, values)
|
|
instance._loaded_values = dict(zip(field_names, values))
|
|
return instance
|
|
|
|
def save(self, *args, **kwargs):
|
|
# Auto-set completed_at when status changes to completed
|
|
if self.status == 'completed' and self.completed_at is None:
|
|
from django.utils import timezone
|
|
self.completed_at = timezone.now()
|
|
elif self.status != 'completed':
|
|
self.completed_at = None
|
|
|
|
is_new = self._state.adding
|
|
loaded_values = getattr(self, '_loaded_values', None)
|
|
reminder_fields_changed = is_new or loaded_values is None or any(
|
|
loaded_values.get(field) != getattr(self, field) for field in self.REMINDER_TRACKED_FIELDS
|
|
)
|
|
|
|
super().save(*args, **kwargs)
|
|
self._loaded_values = {field: getattr(self, field) for field in self.REMINDER_TRACKED_FIELDS}
|
|
|
|
if reminder_fields_changed:
|
|
self.reschedule_reminders()
|
|
|
|
def _due_and_overdue_moments(self):
|
|
"""
|
|
Returns (due_moment, overdue_moment) as timezone-aware datetimes in
|
|
the owner's timezone, or (None, None) if there's nothing to schedule
|
|
against. Mirrors is_overdue's rule that a date-only due task doesn't
|
|
flip overdue until the day after due_date.
|
|
"""
|
|
if not self.due_date:
|
|
return None, None
|
|
|
|
from datetime import datetime, time as dt_time, timedelta
|
|
from zoneinfo import ZoneInfo
|
|
|
|
try:
|
|
user_tz = ZoneInfo(self.user.timezone)
|
|
except Exception:
|
|
user_tz = ZoneInfo('UTC')
|
|
|
|
if self.due_time:
|
|
due_moment = datetime.combine(self.due_date, self.due_time, tzinfo=user_tz)
|
|
# Give "overdue" some breathing room after "due now" instead of
|
|
# firing both notifications at the exact same moment.
|
|
return due_moment, due_moment + timedelta(hours=1)
|
|
|
|
overdue_moment = datetime.combine(self.due_date + timedelta(days=1), dt_time.min, tzinfo=user_tz)
|
|
return None, overdue_moment
|
|
|
|
def reschedule_reminders(self):
|
|
"""
|
|
Recompute this task's ScheduledReminder rows from its current
|
|
due_date/due_time/reminder_at/status. Safe to call any time due
|
|
info changes - clears out not-yet-sent reminders and, if the task
|
|
is still active and due, schedules fresh ones for "before due"
|
|
(from reminder_at, or user.default_reminder_minutes before due),
|
|
"due now", and "overdue".
|
|
"""
|
|
from datetime import timedelta
|
|
from notifications.models import ScheduledReminder
|
|
|
|
ScheduledReminder.objects.filter(task=self, is_sent=False).delete()
|
|
|
|
if self.is_deleted or self.status in ('completed', 'cancelled'):
|
|
return
|
|
|
|
due_moment, overdue_moment = self._due_and_overdue_moments()
|
|
reminders = []
|
|
|
|
if due_moment:
|
|
if self.reminder_at:
|
|
before_due_moment = self.reminder_at
|
|
elif self.user.default_reminder_minutes:
|
|
before_due_moment = due_moment - timedelta(minutes=self.user.default_reminder_minutes)
|
|
else:
|
|
before_due_moment = None
|
|
|
|
if before_due_moment:
|
|
reminders.append(ScheduledReminder(task=self, reminder_type='reminder', remind_at=before_due_moment))
|
|
reminders.append(ScheduledReminder(task=self, reminder_type='due_soon', remind_at=due_moment))
|
|
|
|
if overdue_moment:
|
|
reminders.append(ScheduledReminder(task=self, reminder_type='overdue', remind_at=overdue_moment))
|
|
|
|
if reminders:
|
|
ScheduledReminder.objects.bulk_create(reminders)
|
|
|
|
@property
|
|
def is_overdue(self):
|
|
"""Check if task is overdue."""
|
|
if self.due_date and self.status not in ('completed', 'cancelled'):
|
|
from django.utils import timezone
|
|
from zoneinfo import ZoneInfo
|
|
|
|
# Get current date/time in user's timezone
|
|
try:
|
|
user_tz = ZoneInfo(self.user.timezone)
|
|
user_now = timezone.now().astimezone(user_tz)
|
|
except (Exception,):
|
|
# Fall back to UTC if user timezone is invalid or missing
|
|
user_now = timezone.now()
|
|
|
|
if self.due_date < user_now.date():
|
|
return True
|
|
if self.due_date == user_now.date() and self.due_time:
|
|
return self.due_time < user_now.time()
|
|
return False
|
|
return False
|
|
|
|
@property
|
|
def total_time_spent(self):
|
|
"""Calculate total time spent on this task in seconds."""
|
|
total_seconds = sum(
|
|
entry.duration_seconds or 0
|
|
for entry in self.time_entries.all()
|
|
)
|
|
return total_seconds
|
|
|
|
@property
|
|
def total_time_formatted(self):
|
|
"""Return total time spent formatted as H:MM:SS."""
|
|
seconds = self.total_time_spent
|
|
hours = seconds // 3600
|
|
minutes = (seconds % 3600) // 60
|
|
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:
|
|
from dateutil.rrule import rrulestr
|
|
from datetime import datetime, time as dt_time
|
|
|
|
dtstart = datetime.combine(base_date, dt_time.min)
|
|
try:
|
|
next_occurrence = rrulestr(self.recurrence_rule, dtstart=dtstart).after(dtstart, inc=False)
|
|
except Exception:
|
|
return None
|
|
if not next_occurrence:
|
|
return None
|
|
next_date = next_occurrence.date()
|
|
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
|
|
|
|
@property
|
|
def parsed_custom_recurrence(self):
|
|
"""
|
|
Decompose recurrence_rule (an RRULE string) into simple fields for
|
|
prepopulating the custom recurrence builder UI. Never raises; returns
|
|
safe defaults for a blank or malformed rule.
|
|
"""
|
|
result = {
|
|
'freq': None,
|
|
'interval': 1,
|
|
'byweekday': [],
|
|
'monthly_mode': None,
|
|
'bymonthday': None,
|
|
'nth_ordinal': None,
|
|
'nth_weekday': None,
|
|
}
|
|
if not self.recurrence_rule:
|
|
return result
|
|
|
|
import re
|
|
|
|
params = {}
|
|
for part in self.recurrence_rule.split(';'):
|
|
if '=' in part:
|
|
key, value = part.split('=', 1)
|
|
params[key.strip().upper()] = value.strip()
|
|
|
|
freq = params.get('FREQ', '').upper()
|
|
if freq not in ('WEEKLY', 'MONTHLY'):
|
|
return result
|
|
result['freq'] = freq.lower()
|
|
|
|
try:
|
|
result['interval'] = int(params.get('INTERVAL', '1'))
|
|
except ValueError:
|
|
result['interval'] = 1
|
|
|
|
byday = params.get('BYDAY', '')
|
|
|
|
if freq == 'WEEKLY':
|
|
if byday:
|
|
result['byweekday'] = [d.strip() for d in byday.split(',') if d.strip()]
|
|
elif freq == 'MONTHLY':
|
|
bymonthday = params.get('BYMONTHDAY')
|
|
if bymonthday:
|
|
try:
|
|
result['bymonthday'] = int(bymonthday)
|
|
result['monthly_mode'] = 'day'
|
|
except ValueError:
|
|
pass
|
|
elif byday:
|
|
match = re.match(r'^(-?\d+)([A-Z]{2})$', byday)
|
|
if match:
|
|
result['nth_ordinal'] = int(match.group(1))
|
|
result['nth_weekday'] = match.group(2)
|
|
result['monthly_mode'] = 'nth'
|
|
|
|
return result
|
|
|
|
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):
|
|
"""
|
|
Time tracking entries for tasks.
|
|
"""
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
task = models.ForeignKey(Task, on_delete=models.CASCADE, related_name='time_entries')
|
|
user = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name='time_entries'
|
|
)
|
|
|
|
started_at = models.DateTimeField()
|
|
ended_at = models.DateTimeField(null=True, blank=True)
|
|
duration_seconds = models.IntegerField(null=True, blank=True)
|
|
notes = models.TextField(blank=True)
|
|
|
|
# Sync fields
|
|
sync_id = models.UUIDField(default=uuid.uuid4, unique=True)
|
|
is_deleted = models.BooleanField(default=False)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
# Managers
|
|
objects = SoftDeleteManager()
|
|
all_objects = AllObjectsManager()
|
|
|
|
class Meta:
|
|
db_table = 'time_entries'
|
|
ordering = ['-started_at']
|
|
|
|
def __str__(self):
|
|
return f"{self.task.title} - {self.started_at}"
|
|
|
|
def save(self, *args, **kwargs):
|
|
# Calculate duration if ended
|
|
if self.ended_at and self.started_at:
|
|
delta = self.ended_at - self.started_at
|
|
self.duration_seconds = int(delta.total_seconds())
|
|
super().save(*args, **kwargs)
|
|
|
|
@property
|
|
def is_running(self):
|
|
"""Check if this time entry is currently running."""
|
|
return self.ended_at is None
|
|
|
|
|
|
class TaskShare(models.Model):
|
|
"""
|
|
Sharing tasks or tags with other users.
|
|
"""
|
|
PERMISSION_CHOICES = [
|
|
('viewer', 'Viewer'),
|
|
('editor', 'Editor'),
|
|
]
|
|
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
owner = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name='shares_given'
|
|
)
|
|
shared_with = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name='shares_received'
|
|
)
|
|
|
|
# Can share either a task or a tag
|
|
task = models.ForeignKey(
|
|
Task,
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
blank=True,
|
|
related_name='shares'
|
|
)
|
|
tag = models.ForeignKey(
|
|
Tag,
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
blank=True,
|
|
related_name='shares'
|
|
)
|
|
|
|
permission = models.CharField(max_length=20, choices=PERMISSION_CHOICES, default='viewer')
|
|
|
|
# Sync fields
|
|
sync_id = models.UUIDField(default=uuid.uuid4, unique=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = 'task_shares'
|
|
constraints = [
|
|
models.CheckConstraint(
|
|
check=(
|
|
models.Q(task__isnull=False, tag__isnull=True) |
|
|
models.Q(task__isnull=True, tag__isnull=False)
|
|
),
|
|
name='share_task_or_tag_not_both'
|
|
)
|
|
]
|
|
|
|
def __str__(self):
|
|
shared_item = self.task or self.tag
|
|
return f"{shared_item} shared with {self.shared_with.email}"
|