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.
This commit is contained in:
pacnpal
2026-01-12 19:13:05 -05:00
parent 2b66814d82
commit d631f3183c
56 changed files with 5860 additions and 264 deletions

View File

@@ -145,6 +145,179 @@ TICKET_CATEGORIES = [
),
]
# ============================================================================
# Report Type Choices (for user-submitted reports about content issues)
# ============================================================================
REPORT_TYPES = [
RichChoice(
value="inaccurate",
label="Inaccurate Information",
description="Information is factually incorrect",
metadata={
"color": "orange",
"icon": "alert-circle",
"css_class": "bg-orange-100 text-orange-800 border-orange-200",
"sort_order": 1,
"requires_evidence": True,
},
category=ChoiceCategory.CLASSIFICATION,
),
RichChoice(
value="inappropriate",
label="Inappropriate Content",
description="Content is offensive or inappropriate",
metadata={
"color": "red",
"icon": "flag",
"css_class": "bg-red-100 text-red-800 border-red-200",
"sort_order": 2,
"requires_evidence": False,
},
category=ChoiceCategory.CLASSIFICATION,
),
RichChoice(
value="spam",
label="Spam",
description="Content is spam or promotional",
metadata={
"color": "purple",
"icon": "mail-x",
"css_class": "bg-purple-100 text-purple-800 border-purple-200",
"sort_order": 3,
"requires_evidence": False,
},
category=ChoiceCategory.CLASSIFICATION,
),
RichChoice(
value="copyright",
label="Copyright Violation",
description="Content violates copyright",
metadata={
"color": "indigo",
"icon": "shield-alert",
"css_class": "bg-indigo-100 text-indigo-800 border-indigo-200",
"sort_order": 4,
"requires_evidence": True,
},
category=ChoiceCategory.CLASSIFICATION,
),
RichChoice(
value="duplicate",
label="Duplicate Content",
description="Content duplicates existing entry",
metadata={
"color": "yellow",
"icon": "copy",
"css_class": "bg-yellow-100 text-yellow-800 border-yellow-200",
"sort_order": 5,
"requires_evidence": True,
},
category=ChoiceCategory.CLASSIFICATION,
),
RichChoice(
value="other",
label="Other",
description="Other issue not covered above",
metadata={
"color": "gray",
"icon": "help-circle",
"css_class": "bg-gray-100 text-gray-800 border-gray-200",
"sort_order": 6,
"requires_evidence": False,
},
category=ChoiceCategory.CLASSIFICATION,
),
]
# ============================================================================
# Report Status Choices
# ============================================================================
REPORT_STATUSES = [
RichChoice(
value="pending",
label="Pending",
description="Report is awaiting review",
metadata={
"color": "yellow",
"icon": "clock",
"css_class": "bg-yellow-100 text-yellow-800 border-yellow-200",
"sort_order": 1,
"is_active": True,
},
category=ChoiceCategory.STATUS,
),
RichChoice(
value="investigating",
label="Investigating",
description="Report is being investigated",
metadata={
"color": "blue",
"icon": "search",
"css_class": "bg-blue-100 text-blue-800 border-blue-200",
"sort_order": 2,
"is_active": True,
},
category=ChoiceCategory.STATUS,
),
RichChoice(
value="resolved",
label="Resolved",
description="Report has been resolved with action taken",
metadata={
"color": "green",
"icon": "check-circle",
"css_class": "bg-green-100 text-green-800 border-green-200",
"sort_order": 3,
"is_active": False,
},
category=ChoiceCategory.STATUS,
),
RichChoice(
value="dismissed",
label="Dismissed",
description="Report was dismissed as invalid or duplicate",
metadata={
"color": "gray",
"icon": "x-circle",
"css_class": "bg-gray-100 text-gray-800 border-gray-200",
"sort_order": 4,
"is_active": False,
},
category=ChoiceCategory.STATUS,
),
]
# ============================================================================
# Email Direction Choices
# ============================================================================
EMAIL_DIRECTIONS = [
RichChoice(
value="inbound",
label="Inbound",
description="Email received from user",
metadata={
"color": "blue",
"icon": "arrow-down-left",
"css_class": "bg-blue-100 text-blue-800 border-blue-200",
"sort_order": 1,
},
category=ChoiceCategory.CLASSIFICATION,
),
RichChoice(
value="outbound",
label="Outbound",
description="Email sent to user",
metadata={
"color": "green",
"icon": "arrow-up-right",
"css_class": "bg-green-100 text-green-800 border-green-200",
"sort_order": 2,
},
category=ChoiceCategory.CLASSIFICATION,
),
]
def register_support_choices() -> None:
"""Register all support domain choices with the global registry."""
@@ -160,7 +333,26 @@ def register_support_choices() -> None:
domain="support",
description="Category options for support tickets",
)
register_choices(
"report_types",
REPORT_TYPES,
domain="support",
description="Type options for user-submitted reports",
)
register_choices(
"report_statuses",
REPORT_STATUSES,
domain="support",
description="Status options for user-submitted reports",
)
register_choices(
"email_directions",
EMAIL_DIRECTIONS,
domain="support",
description="Direction options for email threads",
)
# Auto-register choices when module is imported
register_support_choices()

View File

@@ -0,0 +1,53 @@
# Generated by Django 5.2.10 on 2026-01-10 22:01
import apps.core.choices.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("support", "0004_alter_ticket_category_alter_ticket_status"),
]
operations = [
migrations.AlterField(
model_name="report",
name="report_type",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="report_types",
choices=[
("inaccurate", "Inaccurate Information"),
("inappropriate", "Inappropriate Content"),
("spam", "Spam"),
("copyright", "Copyright Violation"),
("duplicate", "Duplicate Content"),
("other", "Other"),
],
db_index=True,
domain="support",
help_text="Type of issue being reported",
max_length=20,
),
),
migrations.AlterField(
model_name="report",
name="status",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="report_statuses",
choices=[
("pending", "Pending"),
("investigating", "Investigating"),
("resolved", "Resolved"),
("dismissed", "Dismissed"),
],
db_index=True,
default="pending",
domain="support",
help_text="Current status of the report",
max_length=20,
),
),
]

View File

@@ -0,0 +1,147 @@
# Generated by Django 5.2.10 on 2026-01-11 20:42
import apps.core.choices.fields
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("support", "0005_alter_report_report_type_alter_report_status"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="EmailThread",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"message_id",
models.CharField(blank=True, help_text="Email message ID for threading", max_length=255),
),
("from_email", models.EmailField(help_text="Sender email address", max_length=254)),
("to_email", models.EmailField(help_text="Recipient email address", max_length=254)),
("subject", models.CharField(max_length=255)),
("body_text", models.TextField(help_text="Plain text email body")),
(
"direction",
apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="email_directions",
choices=[("inbound", "Inbound"), ("outbound", "Outbound")],
domain="support",
help_text="Whether email is inbound or outbound",
max_length=10,
),
),
],
options={
"verbose_name": "Email Thread",
"verbose_name_plural": "Email Threads",
"ordering": ["created_at"],
"abstract": False,
},
),
migrations.AddField(
model_name="ticket",
name="admin_notes",
field=models.TextField(blank=True, help_text="Internal notes for administrators"),
),
migrations.AddField(
model_name="ticket",
name="archived_at",
field=models.DateTimeField(blank=True, help_text="When the ticket was archived", null=True),
),
migrations.AddField(
model_name="ticket",
name="archived_by",
field=models.ForeignKey(
blank=True,
help_text="Staff member who archived this ticket",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="archived_tickets",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="ticket",
name="assigned_to",
field=models.ForeignKey(
blank=True,
help_text="Staff member assigned to this ticket",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="assigned_tickets",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="ticket",
name="name",
field=models.CharField(
blank=True, help_text="Name of the submitter (for anonymous tickets)", max_length=255
),
),
migrations.AddField(
model_name="ticket",
name="resolved_at",
field=models.DateTimeField(blank=True, help_text="When the ticket was resolved", null=True),
),
migrations.AddField(
model_name="ticket",
name="resolved_by",
field=models.ForeignKey(
blank=True,
help_text="Staff member who resolved this ticket",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="resolved_tickets",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="ticket",
name="ticket_number",
field=models.CharField(blank=True, help_text="Human-readable ticket number", max_length=20, unique=True),
),
migrations.AddIndex(
model_name="ticket",
index=models.Index(fields=["status", "created_at"], name="support_tic_status_d0b46e_idx"),
),
migrations.AddIndex(
model_name="ticket",
index=models.Index(fields=["ticket_number"], name="support_tic_ticket__d87f40_idx"),
),
migrations.AddIndex(
model_name="ticket",
index=models.Index(fields=["archived_at"], name="support_tic_archive_8fe8c5_idx"),
),
migrations.AddField(
model_name="emailthread",
name="sent_by",
field=models.ForeignKey(
blank=True,
help_text="Staff member who sent this email (for outbound)",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="sent_email_threads",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="emailthread",
name="ticket",
field=models.ForeignKey(
help_text="Associated support ticket",
on_delete=django.db.models.deletion.CASCADE,
related_name="email_threads",
to="support.ticket",
),
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 5.2.10 on 2026-01-11 21:19
import apps.core.state_machine.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("support", "0006_add_ticket_admin_fields_and_email_threads"),
]
operations = [
migrations.AlterField(
model_name="ticket",
name="status",
field=apps.core.state_machine.fields.RichFSMField(
allow_deprecated=False,
choice_group="ticket_statuses",
choices=[("open", "Open"), ("in_progress", "In Progress"), ("closed", "Closed")],
db_index=True,
default="open",
domain="support",
max_length=20,
),
),
]

View File

@@ -1,14 +1,27 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
from apps.core.choices.fields import RichChoiceField
from apps.core.history import TrackedModel
from apps.core.state_machine import RichFSMField, StateMachineMixin
# Import choices to ensure registration on app load
from . import choices # noqa: F401
class Ticket(TrackedModel):
class Ticket(StateMachineMixin, TrackedModel):
"""
Support ticket model with FSM-managed status transitions.
Status workflow:
open -> in_progress -> closed
-> open (reopen)
"""
state_field_name = "status"
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
@@ -18,6 +31,13 @@ class Ticket(TrackedModel):
help_text="User who submitted the ticket (optional)",
)
# Submitter info (for anonymous/guest tickets)
name = models.CharField(
max_length=255,
blank=True,
help_text="Name of the submitter (for anonymous tickets)",
)
category = RichChoiceField(
choice_group="ticket_categories",
domain="support",
@@ -30,7 +50,8 @@ class Ticket(TrackedModel):
message = models.TextField()
email = models.EmailField(help_text="Contact email", blank=True)
status = RichChoiceField(
# FSM-managed status field
status = RichFSMField(
choice_group="ticket_statuses",
domain="support",
max_length=20,
@@ -38,20 +59,250 @@ class Ticket(TrackedModel):
db_index=True,
)
# Human-readable ticket number (e.g., TKT-00001)
ticket_number = models.CharField(
max_length=20,
unique=True,
blank=True,
help_text="Human-readable ticket number",
)
# Admin management fields
admin_notes = models.TextField(
blank=True,
help_text="Internal notes for administrators",
)
assigned_to = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="assigned_tickets",
help_text="Staff member assigned to this ticket",
)
# Resolution tracking
resolved_at = models.DateTimeField(
null=True,
blank=True,
help_text="When the ticket was resolved",
)
resolved_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="resolved_tickets",
help_text="Staff member who resolved this ticket",
)
# Archive functionality
archived_at = models.DateTimeField(
null=True,
blank=True,
help_text="When the ticket was archived",
)
archived_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="archived_tickets",
help_text="Staff member who archived this ticket",
)
class Meta(TrackedModel.Meta):
verbose_name = "Ticket"
verbose_name_plural = "Tickets"
ordering = ["-created_at"]
indexes = [
models.Index(fields=["status", "created_at"]),
models.Index(fields=["ticket_number"]),
models.Index(fields=["archived_at"]),
]
def __str__(self):
return f"[{self.get_category_display()}] {self.subject}"
return f"[{self.ticket_number}] {self.subject}"
def save(self, *args, **kwargs):
# If user is set but email is empty, autofill from user
if self.user and not self.email:
self.email = self.user.email
# Generate ticket number if not set
if not self.ticket_number:
self.ticket_number = self._generate_ticket_number()
super().save(*args, **kwargs)
def _generate_ticket_number(self):
"""Generate a unique ticket number like TKT-00001."""
import uuid
# Use last 8 chars of a UUID for uniqueness
suffix = uuid.uuid4().hex[:8].upper()
return f"TKT-{suffix}"
# =========================================================================
# FSM Transition Methods
# =========================================================================
def start_progress(self, user=None) -> None:
"""
Start working on this ticket.
Transition: open -> in_progress
Args:
user: The staff member starting work on the ticket
"""
if self.status != "open":
raise ValidationError(
f"Cannot start progress: current status is {self.status}, expected open"
)
self.status = "in_progress"
if user and user.is_staff:
self.assigned_to = user
self.save()
def close(self, user=None, notes: str = "") -> None:
"""
Close/resolve this ticket.
Transition: in_progress -> closed
Args:
user: The staff member closing the ticket
notes: Optional resolution notes
"""
if self.status not in ("open", "in_progress"):
raise ValidationError(
f"Cannot close: current status is {self.status}, expected open or in_progress"
)
self.status = "closed"
self.resolved_at = timezone.now()
if user:
self.resolved_by = user
if notes:
self.admin_notes = f"{self.admin_notes}\n\n[CLOSED] {notes}".strip()
self.save()
def reopen(self, user=None, reason: str = "") -> None:
"""
Reopen a closed ticket.
Transition: closed -> open
Args:
user: The staff member reopening the ticket
reason: Reason for reopening
"""
if self.status != "closed":
raise ValidationError(
f"Cannot reopen: current status is {self.status}, expected closed"
)
self.status = "open"
self.resolved_at = None
self.resolved_by = None
if reason:
self.admin_notes = f"{self.admin_notes}\n\n[REOPENED] {reason}".strip()
self.save()
def archive(self, user=None, reason: str = "") -> None:
"""
Archive this ticket.
Can be called from any status.
Args:
user: The staff member archiving the ticket
reason: Reason for archiving
"""
if self.archived_at:
raise ValidationError("Ticket is already archived")
self.archived_at = timezone.now()
if user:
self.archived_by = user
if reason:
self.admin_notes = f"{self.admin_notes}\n\n[ARCHIVED] {reason}".strip()
self.save()
def unarchive(self, user=None) -> None:
"""
Restore an archived ticket.
Args:
user: The staff member unarchiving the ticket
"""
if not self.archived_at:
raise ValidationError("Ticket is not archived")
self.archived_at = None
self.archived_by = None
self.admin_notes = f"{self.admin_notes}\n\n[UNARCHIVED] by {user.username if user else 'system'}".strip()
self.save()
# =========================================================================
# Computed Properties
# =========================================================================
@property
def thread_id(self):
"""Return the ID for email thread association."""
return str(self.id)
@property
def response_count(self):
"""Return number of email thread responses."""
return self.email_threads.count()
@property
def last_admin_response_at(self):
"""Return timestamp of last admin response."""
last_outbound = self.email_threads.filter(direction="outbound").order_by("-created_at").first()
return last_outbound.created_at if last_outbound else None
class EmailThread(TrackedModel):
"""Email thread entries for ticket conversations."""
ticket = models.ForeignKey(
Ticket,
on_delete=models.CASCADE,
related_name="email_threads",
help_text="Associated support ticket",
)
message_id = models.CharField(
max_length=255,
blank=True,
help_text="Email message ID for threading",
)
from_email = models.EmailField(help_text="Sender email address")
to_email = models.EmailField(help_text="Recipient email address")
subject = models.CharField(max_length=255)
body_text = models.TextField(help_text="Plain text email body")
direction = RichChoiceField(
choice_group="email_directions",
domain="support",
max_length=10,
help_text="Whether email is inbound or outbound",
)
sent_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="sent_email_threads",
help_text="Staff member who sent this email (for outbound)",
)
class Meta(TrackedModel.Meta):
verbose_name = "Email Thread"
verbose_name_plural = "Email Threads"
ordering = ["created_at"]
def __str__(self):
return f"[{self.direction}] {self.subject}"
class Report(TrackedModel):
"""
@@ -61,20 +312,6 @@ class Report(TrackedModel):
(parks, rides, reviews, etc.) for moderator review.
"""
class ReportType(models.TextChoices):
INACCURATE = "inaccurate", "Inaccurate Information"
INAPPROPRIATE = "inappropriate", "Inappropriate Content"
SPAM = "spam", "Spam"
COPYRIGHT = "copyright", "Copyright Violation"
DUPLICATE = "duplicate", "Duplicate Content"
OTHER = "other", "Other"
class Status(models.TextChoices):
PENDING = "pending", "Pending"
INVESTIGATING = "investigating", "Investigating"
RESOLVED = "resolved", "Resolved"
DISMISSED = "dismissed", "Dismissed"
# Reporter (optional for anonymous reports)
reporter = models.ForeignKey(
settings.AUTH_USER_MODEL,
@@ -99,20 +336,22 @@ class Report(TrackedModel):
# It's a convenience for accessing the related object
# content_object = GenericForeignKey("content_type", "object_id")
# Report details
report_type = models.CharField(
# Report details - now using RichChoiceField
report_type = RichChoiceField(
choice_group="report_types",
domain="support",
max_length=20,
choices=ReportType.choices,
db_index=True,
help_text="Type of issue being reported",
)
reason = models.TextField(
help_text="Detailed description of the issue",
)
status = models.CharField(
status = RichChoiceField(
choice_group="report_statuses",
domain="support",
max_length=20,
choices=Status.choices,
default=Status.PENDING,
default="pending",
db_index=True,
help_text="Current status of the report",
)
@@ -151,5 +390,6 @@ class Report(TrackedModel):
@property
def is_resolved(self) -> bool:
return self.status in (self.Status.RESOLVED, self.Status.DISMISSED)
return self.status in ("resolved", "dismissed")

View File

@@ -1,31 +1,126 @@
from rest_framework import serializers
from apps.accounts.serializers import UserSerializer
from apps.core.choices.serializers import RichChoiceSerializerField
from .models import Ticket
from .models import EmailThread, Ticket
class SubmitterProfileSerializer(serializers.Serializer):
"""Nested serializer for submitter profile data."""
display_name = serializers.CharField(source="profile.display_name", allow_null=True)
created_at = serializers.DateTimeField(source="date_joined", allow_null=True)
coaster_count = serializers.IntegerField(source="profile.coaster_credit_count", allow_null=True, default=0)
ride_count = serializers.IntegerField(source="profile.ride_credit_count", allow_null=True, default=0)
park_count = serializers.IntegerField(source="profile.park_credit_count", allow_null=True, default=0)
review_count = serializers.IntegerField(source="profile.review_count", allow_null=True, default=0)
avatar_url = serializers.CharField(source="profile.avatar_url", allow_null=True)
class TicketSerializer(serializers.ModelSerializer):
"""Serializer for Ticket model with full frontend compatibility."""
# User fields
user = UserSerializer(read_only=True)
user_id = serializers.UUIDField(source="user.id", read_only=True, allow_null=True)
submitter_username = serializers.CharField(source="user.username", read_only=True, allow_null=True)
submitter_reputation = serializers.SerializerMethodField()
submitter_profile = serializers.SerializerMethodField()
# Choice display fields
category_display = serializers.CharField(source="get_category_display", read_only=True)
status_display = serializers.CharField(source="get_status_display", read_only=True)
# Computed fields
thread_id = serializers.CharField(read_only=True)
response_count = serializers.IntegerField(read_only=True)
last_admin_response_at = serializers.DateTimeField(read_only=True, allow_null=True)
# Resolution tracking (alias resolved_by username)
resolved_by_username = serializers.CharField(source="resolved_by.username", read_only=True, allow_null=True)
# Admin fields
assigned_to_username = serializers.CharField(source="assigned_to.username", read_only=True, allow_null=True)
archived_by_username = serializers.CharField(source="archived_by.username", read_only=True, allow_null=True)
class Meta:
model = Ticket
fields = [
# Core fields
"id",
"user",
"category",
"category_display",
"subject",
"message",
"email",
"status",
"status_display",
"created_at",
"updated_at",
# User/submitter fields
"user",
"user_id",
"submitter_username",
"submitter_reputation",
"submitter_profile",
"name",
"email",
# Ticket content
"subject",
"message",
"category",
"category_display",
# Status
"status",
"status_display",
# Ticket number
"ticket_number",
# Admin management
"admin_notes",
"assigned_to",
"assigned_to_username",
# Resolution
"resolved_at",
"resolved_by",
"resolved_by_username",
# Thread info
"thread_id",
"last_admin_response_at",
"response_count",
# Archive
"archived_at",
"archived_by",
"archived_by_username",
]
read_only_fields = ["id", "status", "created_at", "updated_at", "user"]
read_only_fields = [
"id",
"created_at",
"updated_at",
"user",
"user_id",
"submitter_username",
"submitter_reputation",
"submitter_profile",
"ticket_number",
"thread_id",
"response_count",
"last_admin_response_at",
]
def get_submitter_reputation(self, obj):
"""Get the submitter's reputation score."""
if obj.user and hasattr(obj.user, "profile"):
return getattr(obj.user.profile, "reputation", 0)
return None
def get_submitter_profile(self, obj):
"""Get a subset of profile data for display."""
if not obj.user or not hasattr(obj.user, "profile"):
return None
profile = obj.user.profile
return {
"display_name": getattr(profile, "display_name", None),
"created_at": obj.user.date_joined.isoformat() if obj.user.date_joined else None,
"coaster_count": getattr(profile, "coaster_credit_count", 0),
"ride_count": getattr(profile, "ride_credit_count", 0),
"park_count": getattr(profile, "park_credit_count", 0),
"review_count": getattr(profile, "review_count", 0),
"avatar_url": getattr(profile, "avatar_url", None),
}
def validate(self, data):
# Ensure email is provided if user is anonymous
@@ -35,6 +130,31 @@ class TicketSerializer(serializers.ModelSerializer):
return data
class EmailThreadSerializer(serializers.ModelSerializer):
"""Serializer for EmailThread model."""
# NOTE: Frontend uses submission_id, we provide ticket as that field
submission_id = serializers.UUIDField(source="ticket.id", read_only=True)
sent_by_username = serializers.CharField(source="sent_by.username", read_only=True, allow_null=True)
class Meta:
model = EmailThread
fields = [
"id",
"created_at",
"submission_id",
"message_id",
"from_email",
"to_email",
"subject",
"body_text",
"direction",
"sent_by",
"sent_by_username",
]
read_only_fields = ["id", "created_at", "submission_id"]
class ReportSerializer(serializers.ModelSerializer):
"""Serializer for Report model."""
@@ -134,14 +254,141 @@ class ReportCreateSerializer(serializers.ModelSerializer):
class ReportResolveSerializer(serializers.Serializer):
"""Serializer for resolving reports."""
from .models import Report
status = serializers.ChoiceField(
choices=[
(Report.Status.RESOLVED, "Resolved"),
(Report.Status.DISMISSED, "Dismissed"),
],
default=Report.Status.RESOLVED,
# Use RichChoiceSerializerField with only resolution statuses
status = RichChoiceSerializerField(
choice_group="report_statuses",
domain="support",
default="resolved",
)
notes = serializers.CharField(required=False, allow_blank=True)
# =============================================================================
# Support Ticket Action Serializers
# =============================================================================
class SendReplySerializer(serializers.Serializer):
"""
Input serializer for send_reply action.
Validates the request body for sending an email reply to a ticket.
Supports both snake_case and camelCase field names for frontend compatibility.
"""
# Primary fields (required=False because we validate manually to support camelCase)
reply_body = serializers.CharField(
required=False,
min_length=1,
max_length=50000,
help_text="The body text of the email reply",
)
new_status = RichChoiceSerializerField(
choice_group="ticket_statuses",
domain="support",
required=False,
allow_null=True,
help_text="Optionally update the ticket status after sending reply",
)
# camelCase aliases for frontend compatibility
replyBody = serializers.CharField(required=False, write_only=True)
newStatus = serializers.CharField(required=False, write_only=True, allow_null=True)
def validate(self, data: dict) -> dict:
"""Normalize camelCase to snake_case and validate required fields."""
# Normalize camelCase to snake_case
if "replyBody" in data and data["replyBody"]:
data["reply_body"] = data.pop("replyBody")
elif "replyBody" in data:
data.pop("replyBody")
if "newStatus" in data and data["newStatus"]:
data["new_status"] = data.pop("newStatus")
elif "newStatus" in data:
data.pop("newStatus")
# Validate required fields
if not data.get("reply_body"):
raise serializers.ValidationError({"reply_body": "This field is required."})
return data
class SendReplyResponseSerializer(serializers.Serializer):
"""Response serializer for send_reply action."""
detail = serializers.CharField()
thread_id = serializers.UUIDField()
ticket_number = serializers.CharField()
class MergeTicketsSerializer(serializers.Serializer):
"""
Input serializer for merge tickets action.
Validates the request body for merging multiple tickets into one.
Supports both snake_case and camelCase field names for frontend compatibility.
"""
# Primary fields (required=False because we validate manually to support camelCase)
primary_ticket_id = serializers.UUIDField(
required=False,
help_text="UUID of the primary ticket that will absorb others",
)
merge_ticket_ids = serializers.ListField(
child=serializers.UUIDField(),
required=False,
min_length=1,
help_text="List of ticket UUIDs to merge into the primary",
)
merge_reason = serializers.CharField(
required=False,
allow_blank=True,
max_length=500,
help_text="Optional reason for the merge",
)
# camelCase aliases for frontend compatibility
primaryTicketId = serializers.UUIDField(required=False, write_only=True)
mergeTicketIds = serializers.ListField(
child=serializers.UUIDField(),
required=False,
write_only=True,
)
mergeReason = serializers.CharField(required=False, write_only=True, allow_blank=True)
def validate(self, data: dict) -> dict:
"""Normalize camelCase to snake_case and validate required fields."""
# Normalize camelCase to snake_case
if "primaryTicketId" in data and data["primaryTicketId"]:
data["primary_ticket_id"] = data.pop("primaryTicketId")
elif "primaryTicketId" in data:
data.pop("primaryTicketId")
if "mergeTicketIds" in data and data["mergeTicketIds"]:
data["merge_ticket_ids"] = data.pop("mergeTicketIds")
elif "mergeTicketIds" in data:
data.pop("mergeTicketIds")
if "mergeReason" in data and data["mergeReason"]:
data["merge_reason"] = data.pop("mergeReason")
elif "mergeReason" in data:
data.pop("mergeReason")
# Validate required fields
if not data.get("primary_ticket_id"):
raise serializers.ValidationError({"primary_ticket_id": "This field is required."})
if not data.get("merge_ticket_ids"):
raise serializers.ValidationError({"merge_ticket_ids": "This field is required."})
return data
class MergeTicketsResponseSerializer(serializers.Serializer):
"""Response serializer for merge tickets action."""
detail = serializers.CharField()
primaryTicketNumber = serializers.CharField()
mergedCount = serializers.IntegerField()
threadsConsolidated = serializers.IntegerField()

View File

@@ -1,13 +1,13 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import ReportViewSet, TicketViewSet
from .views import EmailThreadViewSet, ReportViewSet, TicketViewSet
router = DefaultRouter()
router.register(r"tickets", TicketViewSet, basename="ticket")
router.register(r"reports", ReportViewSet, basename="report")
router.register(r"threads", EmailThreadViewSet, basename="email-thread")
urlpatterns = [
path("", include(router.urls)),
]

View File

@@ -4,8 +4,9 @@ from rest_framework import filters, permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import Report, Ticket
from .models import EmailThread, Report, Ticket
from .serializers import (
EmailThreadSerializer,
ReportCreateSerializer,
ReportResolveSerializer,
ReportSerializer,
@@ -23,17 +24,26 @@ class TicketViewSet(viewsets.ModelViewSet):
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]
filterset_fields = ["status", "category"]
ordering_fields = ["created_at", "status"]
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 Ticket.objects.all()
return qs
if user.is_authenticated:
return Ticket.objects.filter(user=user)
return qs.filter(user=user)
return Ticket.objects.none() # Guests can't list tickets
def perform_create(self, serializer):
@@ -42,6 +52,337 @@ class TicketViewSet(viewsets.ModelViewSet):
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):
"""