Initial commit: KeepItGoing task management server

Features:
- Django-based REST API with web interface
- Task management with tags, priorities, and due dates
- Time tracking with start/stop timers
- Subtasks support
- Task filtering (all, today, upcoming, overdue, completed)
- Tag-based organization with color coding
- Sorting by due date and priority
- Auto-assign tags when filtering
- Responsive 3-pane layout (sidebar, task list, detail panel)
- Task sharing between users
- Mobile-responsive design with dark mode support

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Keith Smith
2025-12-18 17:41:29 -07:00
co-authored by Claude Sonnet 4.5
commit 6a0b35c39c
84 changed files with 7763 additions and 0 deletions
View File
+50
View File
@@ -0,0 +1,50 @@
"""
Task admin configuration.
"""
from django.contrib import admin
from .models import Task, Tag, TimeEntry, TaskShare
@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
"""Admin for Tag."""
list_display = ['name', 'user', 'color', 'is_archived', 'created_at']
list_filter = ['is_archived', 'created_at']
search_fields = ['name', 'user__email']
ordering = ['sort_order', 'name']
@admin.register(Task)
class TaskAdmin(admin.ModelAdmin):
"""Admin for Task."""
list_display = ['title', 'user', 'status', 'priority', 'due_date', 'created_at']
list_filter = ['status', 'priority', 'recurrence', 'created_at']
search_fields = ['title', 'description', 'user__email']
ordering = ['-created_at']
raw_id_fields = ['user', 'parent']
filter_horizontal = ['tags']
@admin.register(TimeEntry)
class TimeEntryAdmin(admin.ModelAdmin):
"""Admin for TimeEntry."""
list_display = ['task', 'user', 'started_at', 'ended_at', 'duration_seconds']
list_filter = ['started_at']
search_fields = ['task__title', 'user__email']
ordering = ['-started_at']
raw_id_fields = ['task', 'user']
@admin.register(TaskShare)
class TaskShareAdmin(admin.ModelAdmin):
"""Admin for TaskShare."""
list_display = ['owner', 'shared_with', 'task', 'tag', 'permission', 'created_at']
list_filter = ['permission', 'created_at']
search_fields = ['owner__email', 'shared_with__email']
ordering = ['-created_at']
raw_id_fields = ['owner', 'shared_with', 'task', 'tag']
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class TasksConfig(AppConfig):
name = 'tasks'
+104
View File
@@ -0,0 +1,104 @@
# Generated by Django 5.2.9 on 2025-12-10 00:05
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Tag',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)),
('color', models.CharField(default='#6B7280', max_length=7)),
('sync_id', models.UUIDField(default=uuid.uuid4, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'tags',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Task',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('title', models.CharField(max_length=500)),
('description', models.TextField(blank=True)),
('status', models.CharField(choices=[('pending', 'Pending'), ('in_progress', 'In Progress'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)),
('priority', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('urgent', 'Urgent')], default='medium', max_length=20)),
('due_date', models.DateField(blank=True, null=True)),
('due_time', models.TimeField(blank=True, null=True)),
('reminder_at', models.DateTimeField(blank=True, null=True)),
('completed_at', models.DateTimeField(blank=True, null=True)),
('recurrence', models.CharField(choices=[('none', 'None'), ('daily', 'Daily'), ('weekly', 'Weekly'), ('biweekly', 'Bi-weekly'), ('monthly', 'Monthly'), ('yearly', 'Yearly'), ('custom', 'Custom')], default='none', max_length=20)),
('recurrence_rule', models.CharField(blank=True, max_length=500)),
('recurrence_end_date', models.DateField(blank=True, null=True)),
('sort_order', models.IntegerField(default=0)),
('sync_id', models.UUIDField(default=uuid.uuid4, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'tasks',
'ordering': ['sort_order', '-priority', 'due_date', 'created_at'],
},
),
migrations.CreateModel(
name='TaskGroup',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255)),
('description', models.TextField(blank=True)),
('color', models.CharField(default='#3B82F6', max_length=7)),
('icon', models.CharField(blank=True, max_length=50)),
('sort_order', models.IntegerField(default=0)),
('is_archived', models.BooleanField(default=False)),
('sync_id', models.UUIDField(default=uuid.uuid4, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'task_groups',
'ordering': ['sort_order', 'name'],
},
),
migrations.CreateModel(
name='TaskShare',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('permission', models.CharField(choices=[('viewer', 'Viewer'), ('editor', 'Editor')], default='viewer', max_length=20)),
('sync_id', models.UUIDField(default=uuid.uuid4, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'task_shares',
},
),
migrations.CreateModel(
name='TimeEntry',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('started_at', models.DateTimeField()),
('ended_at', models.DateTimeField(blank=True, null=True)),
('duration_seconds', models.IntegerField(blank=True, null=True)),
('notes', models.TextField(blank=True)),
('sync_id', models.UUIDField(default=uuid.uuid4, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'time_entries',
'ordering': ['-started_at'],
},
),
]
+86
View File
@@ -0,0 +1,86 @@
# Generated by Django 5.2.9 on 2025-12-10 00:05
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('tasks', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='tag',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tags', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='task',
name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subtasks', to='tasks.task'),
),
migrations.AddField(
model_name='task',
name='tags',
field=models.ManyToManyField(blank=True, related_name='tasks', to='tasks.tag'),
),
migrations.AddField(
model_name='task',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='tag',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tags', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='task',
name='group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tasks', to='tasks.tag'),
),
migrations.AddField(
model_name='taskshare',
name='group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='shares', to='tasks.tag'),
),
migrations.AddField(
model_name='taskshare',
name='owner',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shares_given', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='taskshare',
name='shared_with',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shares_received', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='taskshare',
name='task',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='shares', to='tasks.task'),
),
migrations.AddField(
model_name='timeentry',
name='task',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='time_entries', to='tasks.task'),
),
migrations.AddField(
model_name='timeentry',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='time_entries', to=settings.AUTH_USER_MODEL),
),
migrations.AlterUniqueTogether(
name='tag',
unique_together={('user', 'name')},
),
migrations.AddConstraint(
model_name='taskshare',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('group__isnull', True), ('task__isnull', False)), models.Q(('group__isnull', False), ('task__isnull', True)), _connector='OR'), name='share_task_or_group_not_both'),
),
]
@@ -0,0 +1,135 @@
# Migration to merge TaskGroup into Tag and support multiple tags per task
from django.db import migrations, models
import django.db.models.deletion
def migrate_groups_to_tags(apps, schema_editor):
"""Migrate TaskGroup data to Tag and convert task.group_id to task_tags."""
TaskGroup = apps.get_model('tasks', 'TaskGroup')
Tag = apps.get_model('tasks', 'Tag')
Task = apps.get_model('tasks', 'Task')
# First, migrate all TaskGroups to Tags
group_to_tag_map = {}
for group in TaskGroup.objects.all():
# Check if a tag with the same sync_id already exists
existing_tag = Tag.objects.filter(sync_id=group.sync_id).first()
if existing_tag:
# Update existing tag with group data
existing_tag.description = group.description
existing_tag.icon = group.icon
existing_tag.sort_order = group.sort_order
existing_tag.is_archived = group.is_archived
existing_tag.save()
group_to_tag_map[group.id] = existing_tag
else:
# Create new tag from group
tag = Tag.objects.create(
user=group.user,
name=group.name,
description=group.description,
color=group.color,
icon=group.icon,
sort_order=group.sort_order,
is_archived=group.is_archived,
sync_id=group.sync_id,
)
group_to_tag_map[group.id] = tag
# Convert task.group_id to task_tags entries
for task in Task.objects.exclude(group_id__isnull=True):
if task.group_id in group_to_tag_map:
tag = group_to_tag_map[task.group_id]
task.tags.add(tag)
def reverse_migration(apps, schema_editor):
"""Reverse migration - not fully reversible, just a placeholder."""
pass
class Migration(migrations.Migration):
dependencies = [
('tasks', '0002_initial'),
]
operations = [
# Step 1: Add new fields to Tag model
migrations.AddField(
model_name='tag',
name='description',
field=models.TextField(blank=True, default=''),
preserve_default=False,
),
migrations.AddField(
model_name='tag',
name='icon',
field=models.CharField(blank=True, default='', max_length=50),
preserve_default=False,
),
migrations.AddField(
model_name='tag',
name='sort_order',
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name='tag',
name='is_archived',
field=models.BooleanField(default=False),
),
# Step 2: Remove unique_together constraint on Tag (user, name)
# to allow migration of groups with same names
migrations.AlterUniqueTogether(
name='tag',
unique_together=set(),
),
# Step 3: Change Tag ordering
migrations.AlterModelOptions(
name='tag',
options={'ordering': ['sort_order', 'name']},
),
# Step 4: Run data migration
migrations.RunPython(migrate_groups_to_tags, reverse_migration),
# Step 5: Remove group field from Task
migrations.RemoveField(
model_name='task',
name='group',
),
# Step 6: Update TaskShare - remove old constraint first
migrations.RemoveConstraint(
model_name='taskshare',
name='share_task_or_group_not_both',
),
# Step 7: Rename group field to tag in TaskShare
migrations.RenameField(
model_name='taskshare',
old_name='group',
new_name='tag',
),
# Step 8: Add new constraint for TaskShare
migrations.AddConstraint(
model_name='taskshare',
constraint=models.CheckConstraint(
check=models.Q(
models.Q(('tag__isnull', True), ('task__isnull', False)),
models.Q(('tag__isnull', False), ('task__isnull', True)),
_connector='OR'
),
name='share_task_or_tag_not_both'
),
),
# Step 9: Delete TaskGroup model
migrations.DeleteModel(
name='TaskGroup',
),
]
@@ -0,0 +1,29 @@
# Generated by Django 5.2.9 on 2025-12-13 00:19
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tasks', '0003_merge_groups_to_tags'),
]
operations = [
migrations.AlterField(
model_name='tag',
name='color',
field=models.CharField(default='#3B82F6', max_length=7),
),
migrations.AlterField(
model_name='tag',
name='name',
field=models.CharField(max_length=255),
),
migrations.AlterField(
model_name='taskshare',
name='tag',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='shares', to='tasks.tag'),
),
]
@@ -0,0 +1,46 @@
# Generated by Django 5.2.9 on 2025-12-13 06:45
from django.db import migrations
def fix_tasks_tags_table(apps, schema_editor):
"""
Fix the tasks_tags junction table to reference the correct 'tags' table
instead of the old 'task_groups' table.
"""
db_alias = schema_editor.connection.alias
# Drop and recreate the tasks_tags table with correct foreign keys
schema_editor.execute(
"DROP TABLE IF EXISTS tasks_tags;"
)
schema_editor.execute(
"""
CREATE TABLE tasks_tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id CHAR(32) NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
tag_id CHAR(32) NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
UNIQUE (task_id, tag_id)
);
"""
)
# Create indexes for performance
schema_editor.execute(
"CREATE INDEX tasks_tags_task_id_idx ON tasks_tags(task_id);"
)
schema_editor.execute(
"CREATE INDEX tasks_tags_tag_id_idx ON tasks_tags(tag_id);"
)
class Migration(migrations.Migration):
dependencies = [
('tasks', '0004_alter_tag_color_alter_tag_name_alter_taskshare_tag'),
]
operations = [
migrations.RunPython(fix_tasks_tags_table, migrations.RunPython.noop),
]
@@ -0,0 +1,61 @@
# Generated by Django 5.2.9 on 2025-12-13 07:00
from django.db import migrations
def fix_task_shares_table(apps, schema_editor):
"""
Fix the task_shares table to reference the correct 'tags' table
instead of the old 'task_groups' table.
"""
db_alias = schema_editor.connection.alias
# Drop and recreate the task_shares table with correct foreign keys
schema_editor.execute(
"DROP TABLE IF EXISTS task_shares;"
)
schema_editor.execute(
"""
CREATE TABLE task_shares (
id CHAR(32) NOT NULL PRIMARY KEY,
permission VARCHAR(20) NOT NULL,
sync_id CHAR(32) NOT NULL UNIQUE,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
owner_id CHAR(32) NOT NULL REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED,
shared_with_id CHAR(32) NOT NULL REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED,
task_id CHAR(32) NULL REFERENCES tasks(id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
tag_id CHAR(32) NULL REFERENCES tags(id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
CONSTRAINT share_task_or_tag_not_both CHECK (
(tag_id IS NULL AND task_id IS NOT NULL) OR
(tag_id IS NOT NULL AND task_id IS NULL)
)
);
"""
)
# Create indexes for foreign keys
schema_editor.execute(
"CREATE INDEX task_shares_owner_id_idx ON task_shares(owner_id);"
)
schema_editor.execute(
"CREATE INDEX task_shares_shared_with_id_idx ON task_shares(shared_with_id);"
)
schema_editor.execute(
"CREATE INDEX task_shares_task_id_idx ON task_shares(task_id);"
)
schema_editor.execute(
"CREATE INDEX task_shares_tag_id_idx ON task_shares(tag_id);"
)
class Migration(migrations.Migration):
dependencies = [
('tasks', '0005_fix_tasks_tags_table'),
]
operations = [
migrations.RunPython(fix_task_shares_table, migrations.RunPython.noop),
]
@@ -0,0 +1,28 @@
# Generated by Django 5.2.9 on 2025-12-14 03:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tasks', '0006_fix_task_shares_table'),
]
operations = [
migrations.AddField(
model_name='tag',
name='is_deleted',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='task',
name='is_deleted',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='timeentry',
name='is_deleted',
field=models.BooleanField(default=False),
),
]
View File
+283
View File
@@ -0,0 +1,283 @@
"""
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
return self.due_date < timezone.now().date()
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}"
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}"
+203
View File
@@ -0,0 +1,203 @@
"""
Task serializers for KeepItGoing API.
"""
from rest_framework import serializers
from django.contrib.auth import get_user_model
from .models import Task, Tag, TimeEntry, TaskShare
User = get_user_model()
class TagSerializer(serializers.ModelSerializer):
"""Serializer for tags."""
task_count = serializers.SerializerMethodField()
class Meta:
model = Tag
fields = [
'id', 'name', 'description', 'color', 'icon', 'sort_order',
'is_archived', 'task_count', 'sync_id', 'created_at', 'updated_at'
]
read_only_fields = ['id', 'sync_id', 'created_at', 'updated_at']
def get_task_count(self, obj):
return obj.tasks.filter(status__in=['pending', 'in_progress']).count()
def create(self, validated_data):
validated_data['user'] = self.context['request'].user
return super().create(validated_data)
class TimeEntrySerializer(serializers.ModelSerializer):
"""Serializer for time entries."""
is_running = serializers.ReadOnlyField()
class Meta:
model = TimeEntry
fields = [
'id', 'task', 'started_at', 'ended_at', 'duration_seconds',
'notes', 'is_running', 'sync_id', 'created_at', 'updated_at'
]
read_only_fields = ['id', 'sync_id', 'created_at', 'updated_at', 'duration_seconds']
def create(self, validated_data):
validated_data['user'] = self.context['request'].user
return super().create(validated_data)
class TaskSerializer(serializers.ModelSerializer):
"""Serializer for tasks."""
tags = TagSerializer(many=True, read_only=True)
tag_ids = serializers.PrimaryKeyRelatedField(
queryset=Tag.objects.all(),
many=True,
write_only=True,
required=False,
source='tags'
)
tag_sync_ids = serializers.SerializerMethodField()
subtasks = serializers.SerializerMethodField()
is_overdue = serializers.ReadOnlyField()
total_time_spent = serializers.ReadOnlyField()
parent_sync_id = serializers.UUIDField(source='parent.sync_id', read_only=True, allow_null=True)
class Meta:
model = Task
fields = [
'id', 'parent', 'parent_sync_id',
'title', 'description', 'status', 'priority', 'due_date', 'due_time',
'reminder_at', 'completed_at', 'recurrence', 'recurrence_rule',
'recurrence_end_date', 'tags', 'tag_ids', 'tag_sync_ids', 'subtasks',
'sort_order', 'is_overdue', 'total_time_spent', 'sync_id',
'created_at', 'updated_at'
]
read_only_fields = ['id', 'sync_id', 'created_at', 'updated_at', 'completed_at']
def get_tag_sync_ids(self, obj):
"""Return list of tag sync_ids for sync purposes."""
return [str(tag.sync_id) for tag in obj.tags.all()]
def get_subtasks(self, obj):
# Only include subtasks for top-level tasks to avoid recursion
if obj.parent is None:
subtasks = obj.subtasks.all()
return TaskSerializer(subtasks, many=True, context=self.context).data
return []
def create(self, validated_data):
tags = validated_data.pop('tags', [])
validated_data['user'] = self.context['request'].user
task = super().create(validated_data)
task.tags.set(tags)
return task
def update(self, instance, validated_data):
tags = validated_data.pop('tags', None)
task = super().update(instance, validated_data)
if tags is not None:
task.tags.set(tags)
return task
class TaskListSerializer(serializers.ModelSerializer):
"""Lightweight serializer for task lists."""
tags = TagSerializer(many=True, read_only=True)
subtask_count = serializers.SerializerMethodField()
is_overdue = serializers.ReadOnlyField()
class Meta:
model = Task
fields = [
'id', 'title', 'status', 'priority', 'due_date', 'due_time',
'tags', 'subtask_count', 'is_overdue', 'sort_order', 'sync_id', 'updated_at'
]
def get_subtask_count(self, obj):
return obj.subtasks.count()
class TaskSyncSerializer(serializers.ModelSerializer):
"""Serializer for tasks in sync responses - uses sync_id as id."""
id = serializers.UUIDField(source='sync_id', read_only=True)
tag_sync_ids = serializers.SerializerMethodField()
parent_sync_id = serializers.UUIDField(source='parent.sync_id', read_only=True, allow_null=True)
class Meta:
model = Task
fields = [
'id', 'parent', 'parent_sync_id',
'title', 'description', 'status', 'priority', 'due_date', 'due_time',
'reminder_at', 'completed_at', 'recurrence', 'recurrence_rule',
'recurrence_end_date', 'tag_sync_ids',
'sort_order', 'is_deleted', 'created_at', 'updated_at'
]
def get_tag_sync_ids(self, obj):
"""Return list of tag sync_ids for sync purposes."""
return [str(tag.sync_id) for tag in obj.tags.all()]
class TagSyncSerializer(serializers.ModelSerializer):
"""Serializer for tags in sync responses - uses sync_id as id."""
id = serializers.UUIDField(source='sync_id', read_only=True)
class Meta:
model = Tag
fields = ['id', 'name', 'description', 'color', 'icon', 'sort_order',
'is_archived', 'is_deleted', 'created_at', 'updated_at']
class TimeEntrySyncSerializer(serializers.ModelSerializer):
"""Serializer for time entries in sync responses - uses sync_id as id."""
id = serializers.UUIDField(source='sync_id', read_only=True)
task = serializers.UUIDField(source='task.sync_id', read_only=True)
class Meta:
model = TimeEntry
fields = ['id', 'task', 'started_at', 'ended_at', 'notes',
'is_deleted', 'created_at', 'updated_at']
class TaskShareSerializer(serializers.ModelSerializer):
"""Serializer for task sharing."""
shared_with_email = serializers.EmailField(write_only=True)
shared_with_username = serializers.CharField(source='shared_with.username', read_only=True)
class Meta:
model = TaskShare
fields = [
'id', 'task', 'tag', 'shared_with_email', 'shared_with_username',
'permission', 'sync_id', 'created_at'
]
read_only_fields = ['id', 'sync_id', 'created_at']
def validate_shared_with_email(self, value):
try:
user = User.objects.get(email=value)
if user == self.context['request'].user:
raise serializers.ValidationError("Cannot share with yourself.")
return value
except User.DoesNotExist:
raise serializers.ValidationError("User with this email does not exist.")
def validate(self, attrs):
if not attrs.get('task') and not attrs.get('tag'):
raise serializers.ValidationError("Must share either a task or a tag.")
if attrs.get('task') and attrs.get('tag'):
raise serializers.ValidationError("Cannot share both a task and a tag.")
return attrs
def create(self, validated_data):
email = validated_data.pop('shared_with_email')
validated_data['shared_with'] = User.objects.get(email=email)
validated_data['owner'] = self.context['request'].user
return super().create(validated_data)
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+39
View File
@@ -0,0 +1,39 @@
"""
Task URLs for KeepItGoing.
"""
from django.urls import path
from . import views
# API URLs (to be included under /api/tasks/)
api_urlpatterns = [
path('', views.TaskListCreateAPIView.as_view(), name='api-task-list'),
path('<uuid:pk>/', views.TaskDetailAPIView.as_view(), name='api-task-detail'),
path('tags/', views.TagListCreateAPIView.as_view(), name='api-tag-list'),
path('tags/<uuid:pk>/', views.TagDetailAPIView.as_view(), name='api-tag-detail'),
path('time-entries/', views.TimeEntryListCreateAPIView.as_view(), name='api-time-entry-list'),
path('time-entries/<uuid:pk>/', views.TimeEntryDetailAPIView.as_view(), name='api-time-entry-detail'),
path('<uuid:task_id>/start-timer/', views.start_timer, name='api-start-timer'),
path('time-entries/<uuid:entry_id>/stop/', views.stop_timer, name='api-stop-timer'),
path('shares/', views.TaskShareListCreateAPIView.as_view(), name='api-share-list'),
path('shares/<uuid:pk>/', views.TaskShareDeleteAPIView.as_view(), name='api-share-delete'),
]
# Web URLs (to be included at root level)
web_urlpatterns = [
path('', views.DashboardView.as_view(), name='dashboard'),
path('tasks/', views.TaskListView.as_view(), name='task-list'),
path('tasks/new/', views.TaskCreateView.as_view(), name='task-create'),
path('tasks/<uuid:task_id>/', views.TaskDetailView.as_view(), name='task-detail'),
path('tasks/<uuid:task_id>/detail-partial/', views.task_detail_partial, name='task-detail-partial'),
path('tasks/quick-add/', views.task_quick_add, name='task-quick-add'),
path('tasks/<uuid:task_id>/toggle/', views.task_toggle_status, name='task-toggle'),
path('tasks/<uuid:task_id>/delete/', views.task_delete, name='task-delete'),
path('tasks/<uuid:task_id>/subtask/', views.subtask_create, name='subtask-create'),
path('tasks/<uuid:task_id>/timer/start/', views.web_timer_start, name='timer-start'),
path('tasks/<uuid:task_id>/timer/stop/', views.web_timer_stop, name='timer-stop'),
path('tags/', views.TagListView.as_view(), name='tag-list'),
path('tags/new/', views.TagListView.as_view(), name='tag-create'),
path('tags/<uuid:tag_id>/', views.TagDetailView.as_view(), name='tag-detail'),
path('tags/<uuid:tag_id>/delete/', views.tag_delete, name='tag-delete'),
]
+664
View File
@@ -0,0 +1,664 @@
"""
Task views for KeepItGoing.
"""
from django.contrib.auth.decorators import login_required
from django.db.models import Q
from django.shortcuts import render, redirect, get_object_or_404
from django.utils import timezone
from django.views import View
from django.views.decorators.http import require_POST
from rest_framework import generics, permissions, status, filters
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from django_filters.rest_framework import DjangoFilterBackend
from .models import Task, Tag, TimeEntry, TaskShare
from .serializers import (
TaskSerializer,
TaskListSerializer,
TagSerializer,
TimeEntrySerializer,
TaskShareSerializer,
)
# =============================================================================
# API Views
# =============================================================================
class TaskListCreateAPIView(generics.ListCreateAPIView):
"""API endpoint for listing and creating tasks."""
permission_classes = [permissions.IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['status', 'priority', 'parent']
search_fields = ['title', 'description']
ordering_fields = ['due_date', 'priority', 'created_at', 'sort_order']
def get_serializer_class(self):
if self.request.method == 'GET':
return TaskListSerializer
return TaskSerializer
def get_queryset(self):
user = self.request.user
# Include own tasks and shared tasks
own_tasks = Task.objects.filter(user=user)
shared_task_ids = TaskShare.objects.filter(
shared_with=user, task__isnull=False
).values_list('task_id', flat=True)
shared_tag_ids = TaskShare.objects.filter(
shared_with=user, tag__isnull=False
).values_list('tag_id', flat=True)
return Task.objects.filter(
Q(id__in=own_tasks) |
Q(id__in=shared_task_ids) |
Q(tags__id__in=shared_tag_ids)
).prefetch_related('tags').distinct()
class TaskDetailAPIView(generics.RetrieveUpdateDestroyAPIView):
"""API endpoint for task details."""
serializer_class = TaskSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
user = self.request.user
return Task.objects.filter(
Q(user=user) |
Q(shares__shared_with=user) |
Q(tags__shares__shared_with=user)
).distinct()
class TagListCreateAPIView(generics.ListCreateAPIView):
"""API endpoint for listing and creating tags."""
serializer_class = TagSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
user = self.request.user
return Tag.objects.filter(
Q(user=user) | Q(shares__shared_with=user)
).distinct()
class TagDetailAPIView(generics.RetrieveUpdateDestroyAPIView):
"""API endpoint for tag details."""
serializer_class = TagSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
user = self.request.user
return Tag.objects.filter(
Q(user=user) | Q(shares__shared_with=user)
).distinct()
class TimeEntryListCreateAPIView(generics.ListCreateAPIView):
"""API endpoint for time entries."""
serializer_class = TimeEntrySerializer
permission_classes = [permissions.IsAuthenticated]
filterset_fields = ['task']
def get_queryset(self):
return TimeEntry.objects.filter(user=self.request.user)
class TimeEntryDetailAPIView(generics.RetrieveUpdateDestroyAPIView):
"""API endpoint for time entry details."""
serializer_class = TimeEntrySerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return TimeEntry.objects.filter(user=self.request.user)
@api_view(['POST'])
@permission_classes([permissions.IsAuthenticated])
def start_timer(request, task_id):
"""Start a timer for a task."""
task = get_object_or_404(Task, id=task_id, user=request.user)
# Check for existing running timer
running = TimeEntry.objects.filter(
user=request.user,
ended_at__isnull=True
).first()
if running:
return Response(
{'error': 'You already have a running timer. Stop it first.'},
status=status.HTTP_400_BAD_REQUEST
)
entry = TimeEntry.objects.create(
task=task,
user=request.user,
started_at=timezone.now()
)
return Response(TimeEntrySerializer(entry).data, status=status.HTTP_201_CREATED)
@api_view(['POST'])
@permission_classes([permissions.IsAuthenticated])
def stop_timer(request, entry_id):
"""Stop a running timer."""
entry = get_object_or_404(
TimeEntry,
id=entry_id,
user=request.user,
ended_at__isnull=True
)
entry.ended_at = timezone.now()
entry.save()
return Response(TimeEntrySerializer(entry).data)
class TaskShareListCreateAPIView(generics.ListCreateAPIView):
"""API endpoint for sharing tasks."""
serializer_class = TaskShareSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return TaskShare.objects.filter(
Q(owner=self.request.user) | Q(shared_with=self.request.user)
)
class TaskShareDeleteAPIView(generics.DestroyAPIView):
"""API endpoint for removing a share."""
serializer_class = TaskShareSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return TaskShare.objects.filter(owner=self.request.user)
# =============================================================================
# Web Views (Django Templates)
# =============================================================================
class DashboardView(View):
"""Main dashboard view with 3-pane layout."""
def get(self, request):
if not request.user.is_authenticated:
return redirect('login')
today = timezone.now().date()
# Get filter parameters
current_filter = request.GET.get('filter', 'all')
current_tag_id = request.GET.get('tag')
selected_task_id = request.GET.get('selected')
current_sort = request.GET.get('sort', 'default')
# Base query
tasks = Task.objects.filter(
user=request.user,
parent__isnull=True,
).prefetch_related('tags', 'subtasks')
# Apply status filter (exclude completed by default unless showing all)
if current_filter != 'completed':
tasks = tasks.exclude(status='completed')
# Apply filters (can combine tag + date filter)
page_title_parts = []
# Apply date/status filter first
if current_filter == 'today':
tasks = tasks.filter(due_date=today)
page_title_parts.append("Today")
elif current_filter == 'upcoming':
tasks = tasks.filter(due_date__gt=today)
page_title_parts.append("Upcoming")
elif current_filter == 'overdue':
tasks = [t for t in tasks if t.is_overdue]
page_title_parts.append("Overdue")
elif current_filter == 'completed':
tasks = tasks.filter(status='completed')
page_title_parts.append("Completed")
else:
page_title_parts.append("All Tasks")
# Apply tag filter
if current_tag_id:
# Filter for tasks with specific tag
if isinstance(tasks, list):
tasks = [t for t in tasks if t.tags.filter(id=current_tag_id).exists()]
else:
tasks = tasks.filter(tags__id=current_tag_id)
tag = Tag.objects.filter(id=current_tag_id, user=request.user).first()
if tag:
page_title_parts.append(tag.name)
# Build page title
page_title = "".join(page_title_parts) if len(page_title_parts) > 1 else page_title_parts[0]
# Apply sorting before converting to list
if not isinstance(tasks, list):
if current_sort == 'due_date':
# Sort by due date (nulls last), then priority
from django.db.models import F
tasks = tasks.order_by(F('due_date').asc(nulls_last=True), '-priority')
elif current_sort == 'due_date_desc':
# Sort by due date descending (nulls last), then priority
from django.db.models import F
tasks = tasks.order_by(F('due_date').desc(nulls_last=True), '-priority')
elif current_sort == 'priority':
# Sort by priority, then due date
tasks = tasks.order_by('-priority', 'due_date')
elif current_sort == 'priority_low':
# Sort by priority (low to high), then due date
tasks = tasks.order_by('priority', 'due_date')
# else: default ordering from model (sort_order, -priority, due_date, created_at)
# Convert to list if not already (for overdue filter)
if not isinstance(tasks, list):
tasks = list(tasks)
# Apply sorting to lists (for overdue filter case)
else:
priority_order = {'high': 3, 'medium': 2, 'low': 1}
if current_sort == 'due_date':
tasks = sorted(tasks, key=lambda t: (t.due_date or timezone.now().date() + timezone.timedelta(days=9999), -priority_order.get(t.priority, 0)))
elif current_sort == 'due_date_desc':
tasks = sorted(tasks, key=lambda t: (t.due_date or timezone.now().date() - timezone.timedelta(days=9999), -priority_order.get(t.priority, 0)), reverse=True)
elif current_sort == 'priority':
tasks = sorted(tasks, key=lambda t: (-priority_order.get(t.priority, 0), t.due_date or timezone.now().date() + timezone.timedelta(days=9999)))
elif current_sort == 'priority_low':
tasks = sorted(tasks, key=lambda t: (priority_order.get(t.priority, 0), t.due_date or timezone.now().date() + timezone.timedelta(days=9999)))
# Get all tags for sidebar with task counts
from django.db.models import Count, Q
tags = Tag.objects.filter(
user=request.user,
is_archived=False
).annotate(
task_count=Count(
'tasks',
filter=Q(tasks__parent__isnull=True) & ~Q(tasks__status='completed'),
distinct=True
)
)
# Task counts for sidebar
all_tasks = Task.objects.filter(
user=request.user,
parent__isnull=True
).exclude(status='completed')
completed_tasks = Task.objects.filter(
user=request.user,
parent__isnull=True,
status='completed'
)
task_counts = {
'all': all_tasks.count(),
'today': all_tasks.filter(due_date=today).count(),
'upcoming': all_tasks.filter(due_date__gt=today).count(),
'overdue': sum(1 for t in all_tasks if t.is_overdue),
'completed': completed_tasks.count(),
}
# Check for running timer
running_timer = TimeEntry.objects.filter(
user=request.user,
ended_at__isnull=True
).select_related('task').first()
# Selected task for detail panel
selected_task = None
if selected_task_id:
selected_task = Task.objects.filter(
id=selected_task_id,
user=request.user
).prefetch_related('tags').first()
return render(request, 'tasks/dashboard.html', {
'tasks': tasks,
'page_title': page_title,
'tags': tags,
'task_counts': task_counts,
'current_filter': current_filter,
'current_tag_id': current_tag_id,
'current_sort': current_sort,
'selected_task_id': selected_task_id,
'selected_task': selected_task,
'running_timer': running_timer,
})
class TaskListView(View):
"""Task list view with filtering."""
def get(self, request):
if not request.user.is_authenticated:
return redirect('login')
tasks = Task.objects.filter(
user=request.user,
parent__isnull=True
).prefetch_related('tags', 'subtasks')
# Apply filters
status_filter = request.GET.get('status')
tag_filter = request.GET.get('tag')
priority_filter = request.GET.get('priority')
if status_filter:
tasks = tasks.filter(status=status_filter)
if tag_filter:
tasks = tasks.filter(tags__id=tag_filter)
if priority_filter:
tasks = tasks.filter(priority=priority_filter)
tags = Tag.objects.filter(user=request.user, is_archived=False)
return render(request, 'tasks/task_list.html', {
'tasks': tasks,
'tags': tags,
'current_status': status_filter,
'current_tag': tag_filter,
'current_priority': priority_filter,
})
class TaskDetailView(View):
"""Task detail view."""
def get(self, request, task_id):
if not request.user.is_authenticated:
return redirect('login')
task = get_object_or_404(Task, id=task_id, user=request.user)
time_entries = task.time_entries.all()[:10]
tags = Tag.objects.filter(user=request.user)
return render(request, 'tasks/task_detail.html', {
'task': task,
'time_entries': time_entries,
'tags': tags,
})
def post(self, request, task_id):
if not request.user.is_authenticated:
return redirect('login')
task = get_object_or_404(Task, id=task_id, user=request.user)
task.title = request.POST.get('title', task.title)
task.description = request.POST.get('description', '')
task.status = request.POST.get('status', task.status)
task.priority = request.POST.get('priority', task.priority)
due_date = request.POST.get('due_date')
task.due_date = due_date if due_date else None
due_time = request.POST.get('due_time')
task.due_time = due_time if due_time else None
task.recurrence = request.POST.get('recurrence', 'none')
task.save()
# Handle tags
tag_ids = request.POST.getlist('tags')
task.tags.set(tag_ids)
# Redirect to next URL if provided, otherwise back to task detail
next_url = request.POST.get('next')
if next_url:
return redirect(next_url)
return redirect('task-detail', task_id=task.id)
class TaskCreateView(View):
"""Create new task."""
def get(self, request):
if not request.user.is_authenticated:
return redirect('login')
tags = Tag.objects.filter(user=request.user, is_archived=False)
return render(request, 'tasks/task_create.html', {
'tags': tags,
})
def post(self, request):
if not request.user.is_authenticated:
return redirect('login')
task = Task.objects.create(
user=request.user,
title=request.POST.get('title'),
description=request.POST.get('description', ''),
status=request.POST.get('status', 'pending'),
priority=request.POST.get('priority', 'medium'),
due_date=request.POST.get('due_date') or None,
due_time=request.POST.get('due_time') or None,
recurrence=request.POST.get('recurrence', 'none'),
)
tag_ids = request.POST.getlist('tags')
task.tags.set(tag_ids)
return redirect('task-detail', task_id=task.id)
@login_required
@require_POST
def task_quick_add(request):
"""Quick add task from dashboard."""
task = Task.objects.create(
user=request.user,
title=request.POST.get('title'),
due_date=request.POST.get('due_date') or None,
)
# Handle optional tag
tag_id = request.POST.get('tag')
if tag_id:
task.tags.add(tag_id)
next_url = request.POST.get('next', 'dashboard')
return redirect(next_url)
@login_required
@require_POST
def subtask_create(request, task_id):
"""Create a subtask for a task."""
parent_task = get_object_or_404(Task, id=task_id, user=request.user)
title = request.POST.get('title', '').strip()
if title:
subtask = Task.objects.create(
user=request.user,
title=title,
parent=parent_task,
)
# Inherit tags from parent
subtask.tags.set(parent_task.tags.all())
next_url = request.POST.get('next', request.META.get('HTTP_REFERER', 'dashboard'))
return redirect(next_url)
@login_required
@require_POST
def task_toggle_status(request, task_id):
"""Toggle task completion status."""
task = get_object_or_404(Task, id=task_id, user=request.user)
if task.status == 'completed':
task.status = 'pending'
else:
task.status = 'completed'
task.save()
next_url = request.POST.get('next', 'dashboard')
return redirect(next_url)
@login_required
@require_POST
def task_delete(request, task_id):
"""Delete a task (soft delete)."""
task = get_object_or_404(Task, id=task_id, user=request.user)
task.is_deleted = True
task.save()
next_url = request.POST.get('next', 'dashboard')
return redirect(next_url)
@login_required
def task_detail_partial(request, task_id):
"""Return task detail HTML partial for AJAX requests."""
task = get_object_or_404(Task, id=task_id, user=request.user)
tags = Tag.objects.filter(user=request.user, is_archived=False)
# Check for running timer
running_timer = TimeEntry.objects.filter(
user=request.user,
ended_at__isnull=True
).select_related('task').first()
return render(request, 'tasks/_task_detail.html', {
'task': task,
'tags': tags,
'running_timer': running_timer,
})
@login_required
@require_POST
def web_timer_start(request, task_id):
"""Start a timer for a task (web view)."""
task = get_object_or_404(Task, id=task_id, user=request.user)
print(f"[WEB TIMER DEBUG] Starting timer for task: {task.title} (id: {task_id})")
# Stop any existing running timer first (properly to calculate duration)
running_entries = TimeEntry.objects.filter(
user=request.user,
ended_at__isnull=True
)
stopped_count = 0
for entry in running_entries:
entry.ended_at = timezone.now()
entry.save() # This will calculate duration_seconds
stopped_count += 1
print(f"[WEB TIMER DEBUG] Stopped {stopped_count} existing timers")
# Create new timer
entry = TimeEntry.objects.create(
task=task,
user=request.user,
started_at=timezone.now()
)
print(f"[WEB TIMER DEBUG] Created new timer entry: {entry.id}")
next_url = request.POST.get('next', 'dashboard')
return redirect(next_url)
@login_required
@require_POST
def web_timer_stop(request, task_id):
"""Stop a timer for a task (web view)."""
print(f"[WEB TIMER DEBUG] Stopping timer for task_id: {task_id}")
# Get the running timer and stop it properly (to trigger save() and calculate duration)
entries = TimeEntry.objects.filter(
user=request.user,
task_id=task_id,
ended_at__isnull=True
)
stopped_count = 0
for entry in entries:
entry.ended_at = timezone.now()
entry.save() # This will calculate duration_seconds
stopped_count += 1
print(f"[WEB TIMER DEBUG] Stopped {stopped_count} timer entries")
next_url = request.POST.get('next', 'dashboard')
return redirect(next_url)
class TagListView(View):
"""List tags."""
def get(self, request):
if not request.user.is_authenticated:
return redirect('login')
tags = Tag.objects.filter(user=request.user)
return render(request, 'tasks/tag_list.html', {'tags': tags})
def post(self, request):
if not request.user.is_authenticated:
return redirect('login')
Tag.objects.create(
user=request.user,
name=request.POST.get('name'),
color=request.POST.get('color', '#3B82F6'),
)
return redirect('tag-list')
class TagDetailView(View):
"""Edit a tag."""
def get(self, request, tag_id):
if not request.user.is_authenticated:
return redirect('login')
tag = get_object_or_404(Tag, id=tag_id, user=request.user)
return render(request, 'tasks/tag_edit.html', {'tag': tag})
def post(self, request, tag_id):
if not request.user.is_authenticated:
return redirect('login')
tag = get_object_or_404(Tag, id=tag_id, user=request.user)
tag.name = request.POST.get('name', tag.name)
tag.color = request.POST.get('color', tag.color)
tag.description = request.POST.get('description', tag.description)
tag.save()
next_url = request.POST.get('next', 'tag-list')
return redirect(next_url)
@login_required
@require_POST
def tag_delete(request, tag_id):
"""Delete a tag (soft delete)."""
tag = get_object_or_404(Tag, id=tag_id, user=request.user)
tag.is_deleted = True
tag.save()
next_url = request.POST.get('next', 'tag-list')
return redirect(next_url)