Internal
Public Access
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:
@@ -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']
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TasksConfig(AppConfig):
|
||||
name = 'tasks'
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
+283
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -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
@@ -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)
|
||||
Reference in New Issue
Block a user