mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 10:25: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.
395 lines
14 KiB
Python
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()
|