Files
KeepItGoingServer/tasks/views.py
T
Keith SmithandClaude Sonnet 4.5 8d5faa8a6e Add error handling for timezone conversion
Fixes 500 error when user timezone is invalid or missing.
Now gracefully falls back to UTC if timezone conversion fails.

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

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

757 lines
25 KiB
Python

"""
Task views for KeepItGoing.
"""
import logging
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.utils.http import url_has_allowed_host_and_scheme
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,
)
from .permissions import IsOwnerOrReadOnlyIfShared
logger = logging.getLogger(__name__)
def safe_redirect(request, next_url, fallback='dashboard'):
"""
Safely redirect to next_url after validating it's not an open redirect.
Only allows relative URLs or URLs to the same host.
"""
if next_url and url_has_allowed_host_and_scheme(
url=next_url,
allowed_hosts={request.get_host()},
require_https=request.is_secure()
):
return redirect(next_url)
return redirect(fallback)
# =============================================================================
# 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, IsOwnerOrReadOnlyIfShared]
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, IsOwnerOrReadOnlyIfShared]
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')
# Get today's date in user's timezone
import pytz
try:
user_tz = pytz.timezone(request.user.timezone)
user_now = timezone.now().astimezone(user_tz)
today = user_now.date()
except (pytz.UnknownTimeZoneError, AttributeError):
# Fall back to UTC if user timezone is invalid or missing
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):
# Define priority order for sorting
from django.db.models import Case, When, IntegerField, F
priority_order_case = Case(
When(priority='urgent', then=4),
When(priority='high', then=3),
When(priority='medium', then=2),
When(priority='low', then=1),
default=0,
output_field=IntegerField(),
)
if current_sort == 'due_date':
# Sort by due date (nulls last), then priority
tasks = tasks.annotate(priority_order=priority_order_case).order_by(
F('due_date').asc(nulls_last=True), '-priority_order'
)
elif current_sort == 'due_date_desc':
# Sort by due date descending (nulls last), then priority
tasks = tasks.annotate(priority_order=priority_order_case).order_by(
F('due_date').desc(nulls_last=True), '-priority_order'
)
elif current_sort == 'priority':
# Sort by priority (high to low), then due date
tasks = tasks.annotate(priority_order=priority_order_case).order_by(
'-priority_order', 'due_date'
)
elif current_sort == 'priority_low':
# Sort by priority (low to high), then due date
tasks = tasks.annotate(priority_order=priority_order_case).order_by(
'priority_order', '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 = {'urgent': 4, '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', '')
# Track old status to detect completion
old_status = task.status
# Validate status against allowed choices
status = request.POST.get('status', task.status)
valid_statuses = [choice[0] for choice in Task.STATUS_CHOICES]
if status in valid_statuses:
task.status = status
# Validate priority against allowed choices
priority = request.POST.get('priority', task.priority)
valid_priorities = [choice[0] for choice in Task.PRIORITY_CHOICES]
if priority in valid_priorities:
task.priority = 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
# Validate recurrence against allowed choices
recurrence = request.POST.get('recurrence', 'none')
valid_recurrences = [choice[0] for choice in Task.RECURRENCE_CHOICES]
if recurrence in valid_recurrences:
task.recurrence = recurrence
else:
task.recurrence = 'none'
# Create next recurrence if task is being marked as completed
if old_status != 'completed' and task.status == 'completed' and task.recurrence != 'none':
task.create_next_recurrence()
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 safe_redirect(request, next_url, fallback=f'/task/{task.id}/')
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')
# Validate status against allowed choices
status = request.POST.get('status', 'pending')
valid_statuses = [choice[0] for choice in Task.STATUS_CHOICES]
if status not in valid_statuses:
status = 'pending'
# Validate priority against allowed choices
priority = request.POST.get('priority', 'medium')
valid_priorities = [choice[0] for choice in Task.PRIORITY_CHOICES]
if priority not in valid_priorities:
priority = 'medium'
# Validate recurrence against allowed choices
recurrence = request.POST.get('recurrence', 'none')
valid_recurrences = [choice[0] for choice in Task.RECURRENCE_CHOICES]
if recurrence not in valid_recurrences:
recurrence = 'none'
task = Task.objects.create(
user=request.user,
title=request.POST.get('title'),
description=request.POST.get('description', ''),
status=status,
priority=priority,
due_date=request.POST.get('due_date') or None,
due_time=request.POST.get('due_time') or None,
recurrence=recurrence,
)
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 safe_redirect(request, next_url, fallback='dashboard')
@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 safe_redirect(request, next_url, fallback='dashboard')
@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:
# Mark as completed
task.status = 'completed'
# Create next recurrence if this is a recurring task
if task.recurrence != 'none':
task.create_next_recurrence()
task.save()
next_url = request.POST.get('next', 'dashboard')
return safe_redirect(request, next_url, fallback='dashboard')
@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 safe_redirect(request, next_url, fallback='dashboard')
@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)
logger.debug(f"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
logger.debug(f"Stopped {stopped_count} existing timers")
# Create new timer
entry = TimeEntry.objects.create(
task=task,
user=request.user,
started_at=timezone.now()
)
logger.debug(f"Created new timer entry: {entry.id}")
next_url = request.POST.get('next', 'dashboard')
return safe_redirect(request, next_url, fallback='dashboard')
@login_required
@require_POST
def web_timer_stop(request, task_id):
"""Stop a timer for a task (web view)."""
logger.debug(f"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
logger.debug(f"Stopped {stopped_count} timer entries")
next_url = request.POST.get('next', 'dashboard')
return safe_redirect(request, next_url, fallback='dashboard')
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 safe_redirect(request, next_url, fallback='tag-list')
@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 safe_redirect(request, next_url, fallback='tag-list')