Files
thrillwiki_django_no_react/backend/apps/support/views.py
pacnpal d631f3183c Based on the git diff provided, here's a concise and descriptive commit message:
feat: add passkey authentication and enhance user preferences

- Add passkey login security event type with fingerprint icon
- Include request and site context in email confirmation for backend
- Add user_id exact match filter to prevent incorrect user lookups
- Enable PATCH method for updating user preferences via API
- Add moderation_preferences support to user settings
- Optimize ticket queries with select_related and prefetch_related

This commit introduces passkey authentication tracking, improves user
profile filtering accuracy, and extends the preferences API to support
updates. Query optimizations reduce database hits for ticket listings.
2026-01-12 19:13:05 -05:00

443 lines
16 KiB
Python

from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import EmailThread, Report, Ticket
from .serializers import (
EmailThreadSerializer,
ReportCreateSerializer,
ReportResolveSerializer,
ReportSerializer,
TicketSerializer,
)
class TicketViewSet(viewsets.ModelViewSet):
"""
Standard users/guests can CREATE.
Only Staff can LIST/RETRIEVE/UPDATE all.
Users can LIST/RETRIEVE their own.
"""
queryset = Ticket.objects.all()
serializer_class = TicketSerializer
permission_classes = [permissions.AllowAny] # We handle granular perms in get_queryset/perform_create
filter_backends = [DjangoFilterBackend, filters.OrderingFilter, filters.SearchFilter]
filterset_fields = ["status", "category", "archived_at"]
search_fields = ["name", "email", "subject", "ticket_number"]
ordering_fields = ["created_at", "status", "ticket_number"]
ordering = ["-created_at"]
def get_queryset(self):
user = self.request.user
qs = Ticket.objects.select_related(
"user",
"user__profile",
"assigned_to",
"resolved_by",
"archived_by",
).prefetch_related("email_threads")
if user.is_staff:
return qs
if user.is_authenticated:
return qs.filter(user=user)
return Ticket.objects.none() # Guests can't list tickets
def perform_create(self, serializer):
if self.request.user.is_authenticated:
serializer.save(user=self.request.user, email=self.request.user.email)
else:
serializer.save()
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAdminUser])
def send_reply(self, request, pk=None):
"""
Send an email reply to the ticket submitter.
Creates an EmailThread record and sends the email via ForwardEmail.
Optionally updates the ticket status.
"""
from typing import Any
from django.conf import settings
from django.contrib.sites.models import Site
from django_forwardemail.services import EmailService
from .serializers import SendReplySerializer
# Validate input
serializer = SendReplySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
validated_data: dict[str, Any] = serializer.validated_data
ticket = self.get_object()
if not ticket.email:
return Response(
{"detail": "Ticket has no email address to reply to"},
status=status.HTTP_400_BAD_REQUEST,
)
reply_body: str = validated_data["reply_body"]
new_status: str | None = validated_data.get("new_status")
# Build email subject with ticket number for threading
subject = f"Re: [{ticket.ticket_number}] {ticket.subject}"
# Get the support from email with proper formatting
from_email: str = getattr(settings, "DEFAULT_FROM_EMAIL", "ThrillWiki Support <support@thrillwiki.com>")
try:
# Get the current site for ForwardEmail configuration
# ForwardEmail requires a Site object, not RequestSite
try:
site = Site.objects.get_current()
except Site.DoesNotExist:
site = Site.objects.first()
if site is None:
return Response(
{"detail": "No site configured for email sending"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
# Send email via ForwardEmail service
EmailService.send_email(
to=ticket.email,
subject=subject,
text=reply_body,
from_email=from_email,
reply_to=from_email, # Ensure replies come back to support
site=site,
)
# Create EmailThread record for audit trail
email_thread = EmailThread.objects.create(
ticket=ticket,
from_email=from_email,
to_email=ticket.email,
subject=subject,
body_text=reply_body,
direction="outbound",
sent_by=request.user,
)
# Update ticket status if provided
if new_status and new_status != ticket.status:
ticket.status = new_status
if new_status in ("resolved", "closed"):
ticket.resolved_at = timezone.now()
ticket.resolved_by = request.user
ticket.save()
return Response({
"detail": "Reply sent successfully",
"thread_id": str(email_thread.id),
"ticket_number": ticket.ticket_number,
})
except Exception as e:
# Log the error for debugging
import logging
logger = logging.getLogger(__name__)
logger.exception("Failed to send ticket reply email")
return Response(
{"detail": f"Failed to send email: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@action(detail=False, methods=["post"], permission_classes=[permissions.IsAdminUser])
def merge(self, request):
"""
Merge multiple tickets into a primary ticket.
Moves all EmailThread records to the primary ticket and archives merged tickets.
"""
from typing import Any
from uuid import UUID
from .serializers import MergeTicketsSerializer
# Validate input
serializer = MergeTicketsSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
validated_data: dict[str, Any] = serializer.validated_data
primary_id: UUID = validated_data["primary_ticket_id"]
merge_ids: list[UUID] = validated_data["merge_ticket_ids"]
reason: str = validated_data.get("merge_reason", "")
# Get primary ticket
try:
primary_ticket = Ticket.objects.get(pk=primary_id)
except Ticket.DoesNotExist:
return Response(
{"detail": f"Primary ticket {primary_id} not found"},
status=status.HTTP_404_NOT_FOUND,
)
# Get tickets to merge (exclud primary if accidentally included)
tickets_to_merge = Ticket.objects.filter(pk__in=merge_ids).exclude(pk=primary_id)
if tickets_to_merge.count() == 0:
return Response(
{"detail": "No valid tickets found to merge"},
status=status.HTTP_400_BAD_REQUEST,
)
merged_count = 0
threads_consolidated = 0
merged_ticket_numbers: list[str] = []
for ticket in tickets_to_merge:
# Move all email threads to primary ticket
thread_count = EmailThread.objects.filter(ticket=ticket).update(ticket=primary_ticket)
threads_consolidated += thread_count
# Record the merged ticket number
merged_ticket_numbers.append(ticket.ticket_number)
# Archive the merged ticket with merge history
ticket.archived_at = timezone.now()
ticket.archived_by = request.user
existing_notes = ticket.admin_notes or ""
ticket.admin_notes = (
f"{existing_notes}\n\n"
f"[MERGED] Merged into {primary_ticket.ticket_number} by {request.user.username} "
f"on {timezone.now().isoformat()}. Reason: {reason or 'Not specified'}"
).strip()
ticket.save()
merged_count += 1
# Update primary ticket with merge history
existing_merged = primary_ticket.admin_notes or ""
merge_note = (
f"\n\n[MERGE HISTORY] Absorbed tickets: {', '.join(merged_ticket_numbers)} "
f"({threads_consolidated} threads consolidated) by {request.user.username}"
)
primary_ticket.admin_notes = existing_merged + merge_note
primary_ticket.save()
return Response({
"detail": "Tickets merged successfully",
"primaryTicketNumber": primary_ticket.ticket_number,
"mergedCount": merged_count,
"threadsConsolidated": threads_consolidated,
})
# =========================================================================
# FSM Transition Endpoints
# =========================================================================
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAdminUser])
def start_progress(self, request, pk=None):
"""
Start working on a ticket.
Transition: open -> in_progress
"""
from django.core.exceptions import ValidationError as DjangoValidationError
ticket = self.get_object()
try:
ticket.start_progress(user=request.user)
return Response({
"detail": "Ticket marked as in progress",
"ticketNumber": ticket.ticket_number,
"status": ticket.status,
})
except DjangoValidationError as e:
return Response(
{"detail": str(e.message if hasattr(e, 'message') else e)},
status=status.HTTP_400_BAD_REQUEST,
)
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAdminUser], url_path="close")
def close_ticket(self, request, pk=None):
"""
Close/resolve a ticket.
Transition: open|in_progress -> closed
"""
from django.core.exceptions import ValidationError as DjangoValidationError
ticket = self.get_object()
notes = request.data.get("notes", "")
try:
ticket.close(user=request.user, notes=notes)
return Response({
"detail": "Ticket closed successfully",
"ticketNumber": ticket.ticket_number,
"status": ticket.status,
"resolvedAt": ticket.resolved_at.isoformat() if ticket.resolved_at else None,
})
except DjangoValidationError as e:
return Response(
{"detail": str(e.message if hasattr(e, 'message') else e)},
status=status.HTTP_400_BAD_REQUEST,
)
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAdminUser])
def reopen(self, request, pk=None):
"""
Reopen a closed ticket.
Transition: closed -> open
"""
from django.core.exceptions import ValidationError as DjangoValidationError
ticket = self.get_object()
reason = request.data.get("reason", "")
try:
ticket.reopen(user=request.user, reason=reason)
return Response({
"detail": "Ticket reopened",
"ticketNumber": ticket.ticket_number,
"status": ticket.status,
})
except DjangoValidationError as e:
return Response(
{"detail": str(e.message if hasattr(e, 'message') else e)},
status=status.HTTP_400_BAD_REQUEST,
)
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAdminUser])
def archive(self, request, pk=None):
"""Archive a ticket."""
from django.core.exceptions import ValidationError as DjangoValidationError
ticket = self.get_object()
reason = request.data.get("reason", "")
try:
ticket.archive(user=request.user, reason=reason)
return Response({
"detail": "Ticket archived",
"ticketNumber": ticket.ticket_number,
"archivedAt": ticket.archived_at.isoformat() if ticket.archived_at else None,
})
except DjangoValidationError as e:
return Response(
{"detail": str(e.message if hasattr(e, 'message') else e)},
status=status.HTTP_400_BAD_REQUEST,
)
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAdminUser])
def unarchive(self, request, pk=None):
"""Restore an archived ticket."""
from django.core.exceptions import ValidationError as DjangoValidationError
ticket = self.get_object()
try:
ticket.unarchive(user=request.user)
return Response({
"detail": "Ticket unarchived",
"ticketNumber": ticket.ticket_number,
})
except DjangoValidationError as e:
return Response(
{"detail": str(e.message if hasattr(e, 'message') else e)},
status=status.HTTP_400_BAD_REQUEST,
)
@action(detail=True, methods=["get"], permission_classes=[permissions.IsAdminUser])
def available_transitions(self, request, pk=None):
"""
Get available transitions for a ticket.
Uses StateMachineMixin to return FSM-aware transition metadata.
"""
ticket = self.get_object()
transitions = ticket.get_available_user_transitions(request.user)
return Response({
"ticketNumber": ticket.ticket_number,
"currentStatus": ticket.status,
"currentStatusDisplay": ticket.get_status_display(),
"availableTransitions": transitions,
})
class EmailThreadViewSet(viewsets.ModelViewSet):
"""
ViewSet for email thread entries.
Staff only for full access.
"""
queryset = EmailThread.objects.select_related("ticket", "sent_by").all()
serializer_class = EmailThreadSerializer
permission_classes = [permissions.IsAdminUser]
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ["ticket", "direction"]
ordering_fields = ["created_at"]
ordering = ["created_at"]
def get_queryset(self):
# Support filtering by submission_id (which is ticket_id in our model)
qs = super().get_queryset()
submission_id = self.request.query_params.get("submission_id")
if submission_id:
qs = qs.filter(ticket_id=submission_id)
return qs
class ReportViewSet(viewsets.ModelViewSet):
"""
ViewSet for handling user-submitted content reports.
- Authenticated users can CREATE reports
- Staff can LIST/RETRIEVE all reports
- Users can LIST/RETRIEVE their own reports
- Staff can RESOLVE reports
"""
queryset = Report.objects.select_related("reporter", "resolved_by", "content_type").all()
permission_classes = [permissions.IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.OrderingFilter, filters.SearchFilter]
filterset_fields = ["status", "report_type"]
search_fields = ["reason", "resolution_notes"]
ordering_fields = ["created_at", "status", "report_type"]
ordering = ["-created_at"]
def get_serializer_class(self):
if self.action == "create":
return ReportCreateSerializer
if self.action == "resolve":
return ReportResolveSerializer
return ReportSerializer
def get_queryset(self):
user = self.request.user
if user.is_staff:
return Report.objects.select_related("reporter", "resolved_by", "content_type").all()
return Report.objects.select_related("reporter", "resolved_by", "content_type").filter(reporter=user)
def perform_create(self, serializer):
serializer.save(reporter=self.request.user)
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAdminUser])
def resolve(self, request, pk=None):
"""Mark a report as resolved or dismissed."""
report = self.get_object()
if report.is_resolved:
return Response(
{"detail": "Report is already resolved"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = ReportResolveSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
report.status = serializer.validated_data.get("status", "resolved")
report.resolved_at = timezone.now()
report.resolved_by = request.user
report.resolution_notes = serializer.validated_data.get("notes", "")
report.save()
return Response(ReportSerializer(report).data)