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
+464
View File
@@ -0,0 +1,464 @@
"""
Sync views for KeepItGoing.
Handles data synchronization between clients and server.
"""
import uuid
from datetime import datetime
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from tasks.models import Task, Tag, TimeEntry
from tasks.serializers import (
TaskSerializer,
TagSerializer,
TimeEntrySerializer,
TaskSyncSerializer,
TagSyncSerializer,
TimeEntrySyncSerializer,
)
from .models import SyncLog, SyncConflict
from .serializers import SyncConflictSerializer
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def sync(request):
"""
Main sync endpoint.
Accepts client changes and returns server changes since last sync.
Request body:
{
"device_id": "unique-device-id",
"last_sync_token": "previous-sync-token-or-null",
"changes": {
"tasks": [...],
"tags": [...],
"time_entries": [...]
}
}
Response:
{
"sync_token": "new-sync-token",
"server_changes": {
"tasks": [...],
"tags": [...],
"time_entries": [...]
},
"conflicts": [...]
}
"""
user = request.user
device_id = request.data.get('device_id')
last_sync_token = request.data.get('last_sync_token')
changes = request.data.get('changes', {})
# Debug logging
task_changes = changes.get('tasks', [])
deleted_task_count = sum(1 for t in task_changes if t.get('is_deleted', False))
print(f"[SERVER SYNC DEBUG] Received sync request with {len(task_changes)} tasks, {deleted_task_count} marked as deleted")
if not device_id:
return Response(
{'error': 'device_id is required'},
status=status.HTTP_400_BAD_REQUEST
)
# Get last sync time
last_sync_at = None
if last_sync_token:
try:
sync_log = SyncLog.objects.get(sync_token=last_sync_token, user=user)
last_sync_at = sync_log.last_sync_at
except SyncLog.DoesNotExist:
pass # Full sync
# Process incoming changes
conflicts = []
conflicts.extend(process_tag_changes(user, changes.get('tags', []), last_sync_at))
conflicts.extend(process_task_changes(user, changes.get('tasks', []), last_sync_at))
conflicts.extend(process_time_entry_changes(user, changes.get('time_entries', []), last_sync_at))
# Get server changes since last sync
server_changes = get_server_changes(user, last_sync_at)
# Generate new sync token
new_sync_token = str(uuid.uuid4())
current_time = timezone.now()
# Update or create sync log
SyncLog.objects.update_or_create(
user=user,
device_id=device_id,
defaults={
'last_sync_at': current_time,
'sync_token': new_sync_token,
}
)
return Response({
'sync_token': new_sync_token,
'server_time': current_time.isoformat(),
'server_changes': server_changes,
'conflicts': SyncConflictSerializer(conflicts, many=True).data,
})
def process_task_changes(user, task_changes, last_sync_at):
"""Process task changes from client."""
conflicts = []
for task_data in task_changes:
sync_id = task_data.get('sync_id')
is_deleted = task_data.get('is_deleted', False)
# Debug logging
if is_deleted:
print(f"[SERVER SYNC DEBUG] Received deletion for task sync_id: {sync_id}")
try:
existing = Task.all_objects.get(sync_id=sync_id, user=user)
if is_deleted:
# Soft delete the task
print(f"[SERVER SYNC DEBUG] Soft deleting task: {existing.title} (id: {existing.id})")
existing.is_deleted = True
existing.save()
print(f"[SERVER SYNC DEBUG] Task soft deleted successfully")
continue
# Check for conflict
if last_sync_at and existing.updated_at > last_sync_at:
# Server version was modified since last sync
conflict = SyncConflict.objects.create(
user=user,
entity_type='task',
entity_id=existing.id,
local_data=task_data,
server_data=TaskSerializer(existing).data,
)
conflicts.append(conflict)
else:
# Safe to update
update_task_from_data(existing, task_data)
except Task.DoesNotExist:
if not is_deleted:
# Create new task
create_task_from_data(user, task_data)
return conflicts
def process_tag_changes(user, tag_changes, last_sync_at):
"""Process tag changes from client."""
conflicts = []
for tag_data in tag_changes:
sync_id = tag_data.get('sync_id')
is_deleted = tag_data.get('is_deleted', False)
try:
existing = Tag.all_objects.get(sync_id=sync_id, user=user)
if is_deleted:
existing.is_deleted = True
existing.save()
continue
if last_sync_at and existing.updated_at > last_sync_at:
conflict = SyncConflict.objects.create(
user=user,
entity_type='tag',
entity_id=existing.id,
local_data=tag_data,
server_data=TagSerializer(existing).data,
)
conflicts.append(conflict)
else:
existing.name = tag_data.get('name', existing.name)
existing.description = tag_data.get('description', existing.description)
existing.color = tag_data.get('color', existing.color)
existing.icon = tag_data.get('icon', existing.icon)
existing.sort_order = tag_data.get('sort_order', existing.sort_order)
existing.is_archived = tag_data.get('is_archived', existing.is_archived)
existing.save()
except Tag.DoesNotExist:
if not is_deleted:
Tag.objects.create(
user=user,
sync_id=sync_id,
name=tag_data.get('name'),
description=tag_data.get('description', ''),
color=tag_data.get('color', '#3B82F6'),
icon=tag_data.get('icon', ''),
sort_order=tag_data.get('sort_order', 0),
is_archived=tag_data.get('is_archived', False),
)
return conflicts
def process_time_entry_changes(user, entry_changes, last_sync_at):
"""Process time entry changes from client."""
conflicts = []
print(f"[SERVER SYNC DEBUG] Processing {len(entry_changes)} time entry changes")
for entry_data in entry_changes:
sync_id = entry_data.get('sync_id')
is_deleted = entry_data.get('is_deleted', False)
print(f"[SERVER SYNC DEBUG] Time entry sync_id: {sync_id}, is_deleted: {is_deleted}")
try:
existing = TimeEntry.all_objects.get(sync_id=sync_id, user=user)
if is_deleted:
existing.is_deleted = True
existing.save()
continue
if last_sync_at and existing.updated_at > last_sync_at:
conflict = SyncConflict.objects.create(
user=user,
entity_type='time_entry',
entity_id=existing.id,
local_data=entry_data,
server_data=TimeEntrySerializer(existing).data,
)
conflicts.append(conflict)
else:
# Parse datetime strings if needed
started_at = entry_data.get('started_at', existing.started_at)
if isinstance(started_at, str):
started_at = parse_datetime(started_at)
ended_at = entry_data.get('ended_at', existing.ended_at)
if ended_at and isinstance(ended_at, str):
ended_at = parse_datetime(ended_at)
existing.started_at = started_at
existing.ended_at = ended_at
existing.notes = entry_data.get('notes', existing.notes)
existing.save()
except TimeEntry.DoesNotExist:
if not is_deleted:
task_sync_id = entry_data.get('task_sync_id')
print(f"[SERVER SYNC DEBUG] Creating new time entry for task_sync_id: {task_sync_id}")
print(f"[SERVER SYNC DEBUG] started_at: {entry_data.get('started_at')}, ended_at: {entry_data.get('ended_at')}")
try:
task = Task.objects.get(sync_id=task_sync_id, user=user)
# Parse datetime strings to datetime objects
started_at = entry_data.get('started_at')
if isinstance(started_at, str):
started_at = parse_datetime(started_at)
ended_at = entry_data.get('ended_at')
if ended_at and isinstance(ended_at, str):
ended_at = parse_datetime(ended_at)
new_entry = TimeEntry.objects.create(
user=user,
task=task,
sync_id=sync_id,
started_at=started_at,
ended_at=ended_at,
notes=entry_data.get('notes', ''),
)
print(f"[SERVER SYNC DEBUG] Created time entry: {new_entry.id}, duration_seconds: {new_entry.duration_seconds}")
except Task.DoesNotExist:
print(f"[SERVER SYNC DEBUG] Task not found for sync_id: {task_sync_id}")
pass # Skip if task doesn't exist
return conflicts
def update_task_from_data(task, data):
"""Update a task from sync data."""
task.title = data.get('title', task.title)
task.description = data.get('description', task.description)
task.status = data.get('status', task.status)
task.priority = data.get('priority', task.priority)
task.due_date = data.get('due_date', task.due_date)
task.due_time = data.get('due_time', task.due_time)
task.reminder_at = data.get('reminder_at', task.reminder_at)
task.recurrence = data.get('recurrence', task.recurrence)
task.recurrence_rule = data.get('recurrence_rule', task.recurrence_rule)
task.sort_order = data.get('sort_order', task.sort_order)
task.save()
# Handle tags
tag_sync_ids = data.get('tag_sync_ids', [])
if tag_sync_ids is not None:
tags = Tag.objects.filter(sync_id__in=tag_sync_ids, user=task.user)
task.tags.set(tags)
def create_task_from_data(user, data):
"""Create a new task from sync data."""
parent = None
parent_sync_id = data.get('parent_sync_id')
if parent_sync_id:
try:
parent = Task.all_objects.get(sync_id=parent_sync_id, user=user)
except Task.DoesNotExist:
pass
# Get sync_id and convert to UUID if provided as string
sync_id = data.get('sync_id')
if sync_id and isinstance(sync_id, str):
sync_id = uuid.UUID(sync_id)
task = Task.objects.create(
user=user,
sync_id=sync_id,
parent=parent,
title=data.get('title'),
description=data.get('description', ''),
status=data.get('status', 'pending'),
priority=data.get('priority', 'medium'),
due_date=data.get('due_date'),
due_time=data.get('due_time'),
reminder_at=data.get('reminder_at'),
recurrence=data.get('recurrence', 'none'),
recurrence_rule=data.get('recurrence_rule', ''),
sort_order=data.get('sort_order', 0),
)
# Handle tags
tag_sync_ids = data.get('tag_sync_ids', [])
if tag_sync_ids:
tags = Tag.objects.filter(sync_id__in=tag_sync_ids, user=user)
task.tags.set(tags)
return task
def get_server_changes(user, last_sync_at):
"""Get all changes on server since last sync."""
print(f"[SERVER SYNC DEBUG] get_server_changes: last_sync_at={last_sync_at}")
filter_kwargs = {'user': user}
# For incremental sync, filter by updated_at
# IMPORTANT: Use all_objects to include deleted items so clients can delete them too
if last_sync_at:
filter_kwargs['updated_at__gt'] = last_sync_at
tasks = Task.all_objects.filter(**filter_kwargs).prefetch_related('tags')
tags = Tag.all_objects.filter(**filter_kwargs)
time_entries = TimeEntry.all_objects.filter(**filter_kwargs)
else:
# Full sync - send everything (including deleted items for cleanup)
tasks = Task.all_objects.filter(user=user).prefetch_related('tags')
tags = Tag.all_objects.filter(user=user)
time_entries = TimeEntry.all_objects.filter(user=user)
print(f"[SERVER SYNC DEBUG] Returning {len(tasks)} tasks, {len(tags)} tags, {len(time_entries)} time entries")
if time_entries:
print(f"[SERVER SYNC DEBUG] Time entries being sent to client:")
for entry in time_entries:
print(f"[SERVER SYNC DEBUG] sync_id: {entry.sync_id}, task: {entry.task.title}, duration: {entry.duration_seconds}s, updated_at: {entry.updated_at}")
return {
'tasks': TaskSyncSerializer(tasks, many=True).data,
'tags': TagSyncSerializer(tags, many=True).data,
'time_entries': TimeEntrySyncSerializer(time_entries, many=True).data,
}
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def get_conflicts(request):
"""Get pending sync conflicts for user."""
conflicts = SyncConflict.objects.filter(
user=request.user,
status='pending'
)
return Response(SyncConflictSerializer(conflicts, many=True).data)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def resolve_conflict(request, conflict_id):
"""
Resolve a sync conflict.
Request body:
{
"resolution": "local" | "server" | "merged",
"merged_data": {...} // Only if resolution is "merged"
}
"""
try:
conflict = SyncConflict.objects.get(
id=conflict_id,
user=request.user,
status='pending'
)
except SyncConflict.DoesNotExist:
return Response(
{'error': 'Conflict not found'},
status=status.HTTP_404_NOT_FOUND
)
resolution = request.data.get('resolution')
if resolution == 'local':
# Apply local changes
apply_conflict_data(conflict.entity_type, conflict.entity_id, conflict.local_data)
conflict.status = 'resolved_local'
elif resolution == 'server':
# Keep server version (no action needed)
conflict.status = 'resolved_server'
elif resolution == 'merged':
merged_data = request.data.get('merged_data')
if not merged_data:
return Response(
{'error': 'merged_data is required for merged resolution'},
status=status.HTTP_400_BAD_REQUEST
)
apply_conflict_data(conflict.entity_type, conflict.entity_id, merged_data)
conflict.status = 'resolved_merged'
else:
return Response(
{'error': 'Invalid resolution type'},
status=status.HTTP_400_BAD_REQUEST
)
conflict.resolved_at = timezone.now()
conflict.save()
return Response({'status': 'resolved'})
def apply_conflict_data(entity_type, entity_id, data):
"""Apply conflict resolution data to entity."""
if entity_type == 'task':
try:
task = Task.objects.get(id=entity_id)
update_task_from_data(task, data)
except Task.DoesNotExist:
pass
elif entity_type == 'tag':
try:
tag = Tag.objects.get(id=entity_id)
tag.name = data.get('name', tag.name)
tag.description = data.get('description', tag.description)
tag.color = data.get('color', tag.color)
tag.icon = data.get('icon', tag.icon)
tag.sort_order = data.get('sort_order', tag.sort_order)
tag.is_archived = data.get('is_archived', tag.is_archived)
tag.save()
except Tag.DoesNotExist:
pass