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 ") 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)