Files
KeepItGoingServer/sync/views.py
T
Keith SmithandClaude Sonnet 4.5 6a0b35c39c 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>
2025-12-18 17:41:29 -07:00

465 lines
16 KiB
Python

"""
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