""" 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') 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 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') 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 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: task.status = 'completed' 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')