Files
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

395 lines
14 KiB
Python

from rest_framework import serializers
from apps.accounts.serializers import UserSerializer
from apps.core.choices.serializers import RichChoiceSerializerField
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",
"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",
"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
request = self.context.get("request")
if request and not request.user.is_authenticated and not data.get("email"):
raise serializers.ValidationError({"email": "Email is required for guests."})
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."""
reporter_username = serializers.CharField(source="reporter.username", read_only=True, allow_null=True)
resolved_by_username = serializers.CharField(source="resolved_by.username", read_only=True, allow_null=True)
report_type_display = serializers.CharField(source="get_report_type_display", read_only=True)
status_display = serializers.CharField(source="get_status_display", read_only=True)
content_type_name = serializers.CharField(source="content_type.model", read_only=True)
is_resolved = serializers.BooleanField(read_only=True)
class Meta:
from .models import Report
model = Report
fields = [
"id",
"reporter",
"reporter_username",
"content_type",
"content_type_name",
"object_id",
"report_type",
"report_type_display",
"reason",
"status",
"status_display",
"resolved_at",
"resolved_by",
"resolved_by_username",
"resolution_notes",
"is_resolved",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"reporter",
"resolved_at",
"resolved_by",
"created_at",
"updated_at",
]
class ReportCreateSerializer(serializers.ModelSerializer):
"""Serializer for creating reports with entity type as string."""
entity_type = serializers.CharField(write_only=True, help_text="Type of entity: park, ride, review, etc.")
entity_id = serializers.CharField(write_only=True, help_text="ID of the entity being reported")
class Meta:
from .models import Report
model = Report
fields = [
"entity_type",
"entity_id",
"report_type",
"reason",
]
def validate(self, data):
from django.contrib.contenttypes.models import ContentType
entity_type = data.pop("entity_type")
entity_id = data.pop("entity_id")
# Map common entity types to app.model
type_mapping = {
"park": ("parks", "park"),
"ride": ("rides", "ride"),
"review": ("reviews", "review"),
"user": ("accounts", "user"),
}
if entity_type in type_mapping:
app_label, model_name = type_mapping[entity_type]
else:
# Try to parse as app.model
parts = entity_type.split(".")
if len(parts) != 2:
raise serializers.ValidationError(
{"entity_type": f"Unknown entity type: {entity_type}. Use 'park', 'ride', 'review', or 'app.model'."}
)
app_label, model_name = parts
try:
content_type = ContentType.objects.get(app_label=app_label, model=model_name)
except ContentType.DoesNotExist:
raise serializers.ValidationError({"entity_type": f"Unknown entity type: {entity_type}"})
data["content_type"] = content_type
data["object_id"] = entity_id
return data
class ReportResolveSerializer(serializers.Serializer):
"""Serializer for resolving reports."""
# 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()