""" 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) return due_moment, due_moment 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}"