mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 02:35:18 -05:00
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.
443 lines
16 KiB
Python
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)
|
|
|