Internal
Public Access
Tasks were incorrectly showing as overdue when they were due today. The issue was that the overdue check was comparing the due date against UTC's "today" instead of the user's local "today". Now uses the user's timezone setting to determine the current date when checking if a task is overdue. This ensures tasks due "today" only become overdue when the user's local date advances to tomorrow. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
753 lines
25 KiB
Python
753 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
|
|
user_tz = pytz.timezone(request.user.timezone)
|
|
user_now = timezone.now().astimezone(user_tz)
|
|
today = user_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')
|