Internal
Public Access
Fixed open redirect vulnerability in 9 locations throughout tasks/views.py. Changes: - Added url_has_allowed_host_and_scheme import from django.utils.http - Created safe_redirect() helper function to validate redirect URLs - Only allows relative URLs or URLs to the same host - Prevents attackers from redirecting users to phishing/malicious sites Fixed locations: - Line 447: TaskUpdateView - task update redirect - Line 501: task_quick_add - quick add redirect - Line 521: subtask_create - subtask creation redirect - Line 537: task_toggle_status - status toggle redirect - Line 549: task_delete - task deletion redirect - Line 601: web_timer_start - timer start redirect - Line 626: web_timer_stop - timer stop redirect - Line 672: TagUpdateView - tag update redirect - Line 684: tag_delete - tag deletion redirect Security impact: - Prevents phishing attacks via malicious redirect URLs - Blocks cache poisoning attacks - Ensures users stay within the application domain 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
685 lines
22 KiB
Python
685 lines
22 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')
|
|
|
|
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')
|