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

@@ -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()