Files
Keith SmithandClaude Sonnet 4.5 9dd5d9c154 Replace pytz with zoneinfo for timezone handling
Fixes ModuleNotFoundError by using Python's built-in zoneinfo module
instead of the external pytz dependency. zoneinfo is the standard
library solution for timezone handling in Python 3.9+.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 08:32:38 -07:00

368 lines
11 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']
def __str__(self):
return self.title
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
super().save(*args, **kwargs)
@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 in user's timezone
try:
user_tz = ZoneInfo(self.user.timezone)
user_now = timezone.now().astimezone(user_tz)
user_today = user_now.date()
except (Exception,):
# Fall back to UTC if user timezone is invalid or missing
user_today = timezone.now().date()
return self.due_date < user_today
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:
# 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):
"""
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}"