mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 11:05:17 -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.
1111 lines
37 KiB
Python
1111 lines
37 KiB
Python
"""
|
|
Moderation API Serializers
|
|
|
|
This module contains DRF serializers for the moderation system, including:
|
|
- ModerationReport serializers for content reporting
|
|
- ModerationQueue serializers for moderation workflow
|
|
- ModerationAction serializers for tracking moderation actions
|
|
- BulkOperation serializers for administrative bulk operations
|
|
|
|
All serializers include comprehensive validation and nested relationships.
|
|
"""
|
|
|
|
from datetime import timedelta
|
|
|
|
from django.contrib.auth import get_user_model
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.utils import timezone
|
|
from rest_framework import serializers
|
|
|
|
from .models import (
|
|
BulkOperation,
|
|
EditSubmission,
|
|
ModerationAction,
|
|
ModerationQueue,
|
|
ModerationReport,
|
|
PhotoSubmission,
|
|
)
|
|
|
|
User = get_user_model()
|
|
|
|
|
|
# ============================================================================
|
|
# Base Serializers
|
|
# ============================================================================
|
|
|
|
|
|
class UserBasicSerializer(serializers.ModelSerializer):
|
|
"""Basic user information for moderation contexts."""
|
|
|
|
display_name = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = User
|
|
fields = ["id", "username", "display_name", "email", "role"]
|
|
read_only_fields = ["id", "username", "display_name", "email", "role"]
|
|
|
|
def get_display_name(self, obj):
|
|
"""Get the user's display name."""
|
|
return obj.get_display_name()
|
|
|
|
|
|
class ContentTypeSerializer(serializers.ModelSerializer):
|
|
"""Content type information for generic foreign keys."""
|
|
|
|
class Meta:
|
|
model = ContentType
|
|
fields = ["id", "app_label", "model"]
|
|
read_only_fields = ["id", "app_label", "model"]
|
|
|
|
|
|
# ============================================================================
|
|
# Moderation Report Serializers
|
|
# ============================================================================
|
|
|
|
|
|
class EditSubmissionSerializer(serializers.ModelSerializer):
|
|
"""Serializer for EditSubmission with UI metadata for Nuxt frontend."""
|
|
|
|
submitted_by = UserBasicSerializer(source="user", read_only=True)
|
|
handled_by = UserBasicSerializer(read_only=True)
|
|
claimed_by = UserBasicSerializer(read_only=True)
|
|
content_type_name = serializers.CharField(source="content_type.model", read_only=True)
|
|
|
|
# UI Metadata fields for Nuxt rendering
|
|
status_color = serializers.SerializerMethodField()
|
|
status_icon = serializers.SerializerMethodField()
|
|
status_display = serializers.CharField(source="get_status_display", read_only=True)
|
|
time_since_created = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = EditSubmission
|
|
fields = [
|
|
"id",
|
|
"status",
|
|
"status_display",
|
|
"status_color",
|
|
"status_icon",
|
|
"content_type",
|
|
"content_type_name",
|
|
"object_id",
|
|
"submission_type",
|
|
"changes",
|
|
"moderator_changes",
|
|
"reason",
|
|
"source",
|
|
"notes",
|
|
"submitted_by",
|
|
"handled_by",
|
|
"claimed_by",
|
|
"claimed_at",
|
|
"created_at",
|
|
"time_since_created",
|
|
# Photo fields (used when submission_type="PHOTO")
|
|
"photo",
|
|
"caption",
|
|
"date_taken",
|
|
]
|
|
read_only_fields = [
|
|
"id",
|
|
"created_at",
|
|
"submitted_by",
|
|
"handled_by",
|
|
"claimed_by",
|
|
"claimed_at",
|
|
"status_color",
|
|
"status_icon",
|
|
"status_display",
|
|
"content_type_name",
|
|
"time_since_created",
|
|
]
|
|
|
|
def get_status_color(self, obj) -> str:
|
|
"""Return hex color based on status for UI badges."""
|
|
colors = {
|
|
"PENDING": "#f59e0b", # Amber
|
|
"CLAIMED": "#3b82f6", # Blue
|
|
"APPROVED": "#10b981", # Emerald
|
|
"REJECTED": "#ef4444", # Red
|
|
"ESCALATED": "#8b5cf6", # Violet
|
|
}
|
|
return colors.get(obj.status, "#6b7280") # Default gray
|
|
|
|
def get_status_icon(self, obj) -> str:
|
|
"""Return Heroicons icon name based on status."""
|
|
icons = {
|
|
"PENDING": "heroicons:clock",
|
|
"CLAIMED": "heroicons:user-circle",
|
|
"APPROVED": "heroicons:check-circle",
|
|
"REJECTED": "heroicons:x-circle",
|
|
"ESCALATED": "heroicons:arrow-up-circle",
|
|
}
|
|
return icons.get(obj.status, "heroicons:question-mark-circle")
|
|
|
|
def get_time_since_created(self, obj) -> str:
|
|
"""Human-readable time since creation."""
|
|
now = timezone.now()
|
|
diff = now - obj.created_at
|
|
|
|
if diff.days > 0:
|
|
return f"{diff.days} days ago"
|
|
elif diff.seconds > 3600:
|
|
hours = diff.seconds // 3600
|
|
return f"{hours} hours ago"
|
|
else:
|
|
minutes = diff.seconds // 60
|
|
return f"{minutes} minutes ago"
|
|
|
|
|
|
class EditSubmissionListSerializer(serializers.ModelSerializer):
|
|
"""Optimized serializer for EditSubmission lists."""
|
|
|
|
submitted_by_username = serializers.CharField(source="user.username", read_only=True)
|
|
claimed_by_username = serializers.CharField(source="claimed_by.username", read_only=True, allow_null=True)
|
|
content_type_name = serializers.CharField(source="content_type.model", read_only=True)
|
|
status_color = serializers.SerializerMethodField()
|
|
status_icon = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = EditSubmission
|
|
fields = [
|
|
"id",
|
|
"status",
|
|
"submission_type", # Added for frontend compatibility
|
|
"content_type_name",
|
|
"object_id",
|
|
"submitted_by_username",
|
|
"claimed_by_username",
|
|
"claimed_at",
|
|
"status_color",
|
|
"status_icon",
|
|
"created_at",
|
|
]
|
|
read_only_fields = fields
|
|
|
|
def get_status_color(self, obj) -> str:
|
|
colors = {
|
|
"PENDING": "#f59e0b",
|
|
"CLAIMED": "#3b82f6",
|
|
"APPROVED": "#10b981",
|
|
"REJECTED": "#ef4444",
|
|
"ESCALATED": "#8b5cf6",
|
|
}
|
|
return colors.get(obj.status, "#6b7280")
|
|
|
|
def get_status_icon(self, obj) -> str:
|
|
icons = {
|
|
"PENDING": "heroicons:clock",
|
|
"CLAIMED": "heroicons:user-circle",
|
|
"APPROVED": "heroicons:check-circle",
|
|
"REJECTED": "heroicons:x-circle",
|
|
"ESCALATED": "heroicons:arrow-up-circle",
|
|
}
|
|
return icons.get(obj.status, "heroicons:question-mark-circle")
|
|
|
|
|
|
class CreateEditSubmissionSerializer(serializers.ModelSerializer):
|
|
"""
|
|
Serializer for creating edit submissions.
|
|
|
|
This replaces the Supabase RPC 'create_submission_with_items' function.
|
|
Accepts entity type as a string and resolves it to ContentType.
|
|
"""
|
|
|
|
entity_type = serializers.CharField(write_only=True, help_text="Entity type: park, ride, company, ride_model")
|
|
|
|
class Meta:
|
|
model = EditSubmission
|
|
fields = [
|
|
"entity_type",
|
|
"object_id",
|
|
"submission_type",
|
|
"changes",
|
|
"reason",
|
|
"source",
|
|
]
|
|
|
|
def validate_entity_type(self, value):
|
|
"""Convert entity_type string to ContentType."""
|
|
entity_type_map = {
|
|
"park": ("parks", "park"),
|
|
"ride": ("rides", "ride"),
|
|
"company": ("parks", "company"),
|
|
"ride_model": ("rides", "ridemodel"),
|
|
"manufacturer": ("parks", "company"),
|
|
"designer": ("parks", "company"),
|
|
"operator": ("parks", "company"),
|
|
"property_owner": ("parks", "company"),
|
|
}
|
|
|
|
if value.lower() not in entity_type_map:
|
|
raise serializers.ValidationError(
|
|
f"Invalid entity_type. Must be one of: {', '.join(entity_type_map.keys())}"
|
|
)
|
|
|
|
return value.lower()
|
|
|
|
def validate_changes(self, value):
|
|
"""Validate changes is a proper JSON object."""
|
|
if not isinstance(value, dict):
|
|
raise serializers.ValidationError("Changes must be a JSON object")
|
|
if not value:
|
|
raise serializers.ValidationError("Changes cannot be empty")
|
|
return value
|
|
|
|
def validate(self, attrs):
|
|
"""Cross-field validation."""
|
|
submission_type = attrs.get("submission_type", "EDIT")
|
|
object_id = attrs.get("object_id")
|
|
|
|
# For EDIT submissions, object_id is required
|
|
if submission_type == "EDIT" and not object_id:
|
|
raise serializers.ValidationError(
|
|
{"object_id": "object_id is required for EDIT submissions"}
|
|
)
|
|
|
|
# For CREATE submissions, object_id should be null
|
|
if submission_type == "CREATE" and object_id:
|
|
raise serializers.ValidationError(
|
|
{"object_id": "object_id must be null for CREATE submissions"}
|
|
)
|
|
|
|
return attrs
|
|
|
|
def create(self, validated_data):
|
|
"""Create a new submission."""
|
|
entity_type = validated_data.pop("entity_type")
|
|
|
|
# Map entity_type to ContentType
|
|
entity_type_map = {
|
|
"park": ("parks", "park"),
|
|
"ride": ("rides", "ride"),
|
|
"company": ("parks", "company"),
|
|
"ride_model": ("rides", "ridemodel"),
|
|
"manufacturer": ("parks", "company"),
|
|
"designer": ("parks", "company"),
|
|
"operator": ("parks", "company"),
|
|
"property_owner": ("parks", "company"),
|
|
}
|
|
|
|
app_label, model_name = entity_type_map[entity_type]
|
|
content_type = ContentType.objects.get(app_label=app_label, model=model_name)
|
|
|
|
# Set automatic fields
|
|
validated_data["user"] = self.context["request"].user
|
|
validated_data["content_type"] = content_type
|
|
validated_data["status"] = "PENDING"
|
|
|
|
return super().create(validated_data)
|
|
|
|
|
|
# ============================================================================
|
|
# Moderation Report Serializers
|
|
# ============================================================================
|
|
|
|
|
|
class ModerationReportSerializer(serializers.ModelSerializer):
|
|
"""Full moderation report serializer with all details."""
|
|
|
|
reported_by = UserBasicSerializer(read_only=True)
|
|
assigned_moderator = UserBasicSerializer(read_only=True)
|
|
content_type = ContentTypeSerializer(read_only=True)
|
|
|
|
# Computed fields
|
|
is_overdue = serializers.SerializerMethodField()
|
|
time_since_created = serializers.SerializerMethodField()
|
|
priority_display = serializers.CharField(source="get_priority_display", read_only=True)
|
|
status_display = serializers.CharField(source="get_status_display", read_only=True)
|
|
report_type_display = serializers.CharField(source="get_report_type_display", read_only=True)
|
|
|
|
class Meta:
|
|
model = ModerationReport
|
|
fields = [
|
|
"id",
|
|
"report_type",
|
|
"report_type_display",
|
|
"status",
|
|
"status_display",
|
|
"priority",
|
|
"priority_display",
|
|
"reported_entity_type",
|
|
"reported_entity_id",
|
|
"reason",
|
|
"description",
|
|
"evidence_urls",
|
|
"resolved_at",
|
|
"resolution_notes",
|
|
"resolution_action",
|
|
"created_at",
|
|
"updated_at",
|
|
"reported_by",
|
|
"assigned_moderator",
|
|
"content_type",
|
|
"is_overdue",
|
|
"time_since_created",
|
|
]
|
|
read_only_fields = [
|
|
"id",
|
|
"created_at",
|
|
"updated_at",
|
|
"reported_by",
|
|
"content_type",
|
|
"is_overdue",
|
|
"time_since_created",
|
|
"report_type_display",
|
|
"status_display",
|
|
"priority_display",
|
|
]
|
|
|
|
def get_is_overdue(self, obj) -> bool:
|
|
"""Check if report is overdue based on priority."""
|
|
if obj.status in ["RESOLVED", "DISMISSED"]:
|
|
return False
|
|
|
|
now = timezone.now()
|
|
hours_since_created = (now - obj.created_at).total_seconds() / 3600
|
|
|
|
# Define SLA hours by priority
|
|
sla_hours = {"URGENT": 2, "HIGH": 8, "MEDIUM": 24, "LOW": 72}
|
|
|
|
if obj.priority in sla_hours:
|
|
threshold = sla_hours[obj.priority]
|
|
else:
|
|
raise ValueError(f"Unknown priority level: {obj.priority}")
|
|
|
|
return hours_since_created > threshold
|
|
|
|
def get_time_since_created(self, obj) -> str:
|
|
"""Human-readable time since creation."""
|
|
now = timezone.now()
|
|
diff = now - obj.created_at
|
|
|
|
if diff.days > 0:
|
|
return f"{diff.days} days ago"
|
|
elif diff.seconds > 3600:
|
|
hours = diff.seconds // 3600
|
|
return f"{hours} hours ago"
|
|
else:
|
|
minutes = diff.seconds // 60
|
|
return f"{minutes} minutes ago"
|
|
|
|
|
|
class CreateModerationReportSerializer(serializers.ModelSerializer):
|
|
"""Serializer for creating new moderation reports."""
|
|
|
|
class Meta:
|
|
model = ModerationReport
|
|
fields = [
|
|
"report_type",
|
|
"reported_entity_type",
|
|
"reported_entity_id",
|
|
"reason",
|
|
"description",
|
|
"evidence_urls",
|
|
]
|
|
|
|
def validate(self, attrs):
|
|
"""Validate the report data."""
|
|
# Validate entity type
|
|
valid_entity_types = ["park", "ride", "review", "photo", "user", "comment"]
|
|
if attrs["reported_entity_type"] not in valid_entity_types:
|
|
raise serializers.ValidationError(
|
|
{"reported_entity_type": f'Must be one of: {", ".join(valid_entity_types)}'}
|
|
)
|
|
|
|
# Validate evidence URLs
|
|
evidence_urls = attrs.get("evidence_urls", [])
|
|
if not isinstance(evidence_urls, list):
|
|
raise serializers.ValidationError({"evidence_urls": "Must be a list of URLs"})
|
|
|
|
return attrs
|
|
|
|
def create(self, validated_data):
|
|
"""Create a new moderation report."""
|
|
validated_data["reported_by"] = self.context["request"].user
|
|
validated_data["status"] = "PENDING"
|
|
validated_data["priority"] = "MEDIUM" # Default priority
|
|
|
|
# Set content type based on entity type
|
|
entity_type = validated_data["reported_entity_type"]
|
|
app_label_map = {
|
|
"park": "parks",
|
|
"ride": "rides",
|
|
"review": "rides", # Assuming ride reviews
|
|
"photo": "media",
|
|
"user": "accounts",
|
|
"comment": "core",
|
|
}
|
|
|
|
if entity_type in app_label_map:
|
|
try:
|
|
content_type = ContentType.objects.get(app_label=app_label_map[entity_type], model=entity_type)
|
|
validated_data["content_type"] = content_type
|
|
except ContentType.DoesNotExist:
|
|
pass
|
|
|
|
return super().create(validated_data)
|
|
|
|
|
|
class UpdateModerationReportSerializer(serializers.ModelSerializer):
|
|
"""Serializer for updating moderation reports."""
|
|
|
|
class Meta:
|
|
model = ModerationReport
|
|
fields = [
|
|
"status",
|
|
"priority",
|
|
"assigned_moderator",
|
|
"resolution_notes",
|
|
"resolution_action",
|
|
]
|
|
|
|
def validate_status(self, value):
|
|
"""Validate status transitions."""
|
|
if self.instance and self.instance.status == "RESOLVED" and value != "RESOLVED":
|
|
raise serializers.ValidationError("Cannot change status of resolved report")
|
|
return value
|
|
|
|
def update(self, instance, validated_data):
|
|
"""Update moderation report with automatic timestamps."""
|
|
if "status" in validated_data and validated_data["status"] == "RESOLVED":
|
|
validated_data["resolved_at"] = timezone.now()
|
|
|
|
return super().update(instance, validated_data)
|
|
|
|
|
|
# ============================================================================
|
|
# Moderation Queue Serializers
|
|
# ============================================================================
|
|
|
|
|
|
class ModerationQueueSerializer(serializers.ModelSerializer):
|
|
"""Full moderation queue item serializer."""
|
|
|
|
assigned_to = UserBasicSerializer(read_only=True)
|
|
related_report = ModerationReportSerializer(read_only=True)
|
|
content_type = ContentTypeSerializer(read_only=True)
|
|
|
|
# Computed fields
|
|
is_overdue = serializers.SerializerMethodField()
|
|
time_in_queue = serializers.SerializerMethodField()
|
|
estimated_completion = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = ModerationQueue
|
|
fields = [
|
|
"id",
|
|
"item_type",
|
|
"status",
|
|
"priority",
|
|
"title",
|
|
"description",
|
|
"entity_type",
|
|
"entity_id",
|
|
"entity_preview",
|
|
"flagged_by",
|
|
"assigned_at",
|
|
"estimated_review_time",
|
|
"created_at",
|
|
"updated_at",
|
|
"tags",
|
|
"assigned_to",
|
|
"related_report",
|
|
"content_type",
|
|
"is_overdue",
|
|
"time_in_queue",
|
|
"estimated_completion",
|
|
]
|
|
read_only_fields = [
|
|
"id",
|
|
"created_at",
|
|
"updated_at",
|
|
"content_type",
|
|
"is_overdue",
|
|
"time_in_queue",
|
|
"estimated_completion",
|
|
]
|
|
|
|
def get_is_overdue(self, obj) -> bool:
|
|
"""Check if queue item is overdue."""
|
|
if obj.status == "COMPLETED":
|
|
return False
|
|
|
|
if obj.assigned_at:
|
|
time_assigned = (timezone.now() - obj.assigned_at).total_seconds() / 60
|
|
return time_assigned > obj.estimated_review_time
|
|
|
|
# If not assigned, check time in queue
|
|
time_in_queue = (timezone.now() - obj.created_at).total_seconds() / 60
|
|
return time_in_queue > (obj.estimated_review_time * 2)
|
|
|
|
def get_time_in_queue(self, obj) -> int:
|
|
"""Minutes since item was created."""
|
|
return int((timezone.now() - obj.created_at).total_seconds() / 60)
|
|
|
|
def get_estimated_completion(self, obj) -> str:
|
|
"""Estimated completion time."""
|
|
if obj.assigned_at:
|
|
completion_time = obj.assigned_at + timedelta(minutes=obj.estimated_review_time)
|
|
else:
|
|
completion_time = timezone.now() + timedelta(minutes=obj.estimated_review_time)
|
|
|
|
return completion_time.isoformat()
|
|
|
|
|
|
class AssignQueueItemSerializer(serializers.Serializer):
|
|
"""Serializer for assigning queue items to moderators."""
|
|
|
|
moderator_id = serializers.IntegerField()
|
|
|
|
def validate_moderator_id(self, value):
|
|
"""Validate that the moderator exists and has appropriate permissions."""
|
|
try:
|
|
user = User.objects.get(id=value)
|
|
user_role = getattr(user, "role", "USER")
|
|
if user_role not in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
|
raise serializers.ValidationError("User must be a moderator, admin, or superuser")
|
|
return value
|
|
except User.DoesNotExist:
|
|
raise serializers.ValidationError("Moderator not found") from None
|
|
|
|
|
|
class CompleteQueueItemSerializer(serializers.Serializer):
|
|
"""Serializer for completing queue items."""
|
|
|
|
action = serializers.ChoiceField(
|
|
choices=[
|
|
("NO_ACTION", "No Action Required"),
|
|
("CONTENT_REMOVED", "Content Removed"),
|
|
("CONTENT_EDITED", "Content Edited"),
|
|
("USER_WARNING", "User Warning Issued"),
|
|
("USER_SUSPENDED", "User Suspended"),
|
|
("USER_BANNED", "User Banned"),
|
|
]
|
|
)
|
|
notes = serializers.CharField(required=False, allow_blank=True)
|
|
|
|
def validate(self, attrs):
|
|
"""Validate completion data."""
|
|
action = attrs["action"]
|
|
notes = attrs.get("notes", "")
|
|
|
|
# Require notes for certain actions
|
|
if action in ["USER_WARNING", "USER_SUSPENDED", "USER_BANNED"] and not notes:
|
|
raise serializers.ValidationError({"notes": f"Notes are required for action: {action}"})
|
|
|
|
return attrs
|
|
|
|
|
|
# ============================================================================
|
|
# Moderation Action Serializers
|
|
# ============================================================================
|
|
|
|
|
|
class ModerationActionSerializer(serializers.ModelSerializer):
|
|
"""Full moderation action serializer."""
|
|
|
|
moderator = UserBasicSerializer(read_only=True)
|
|
target_user = UserBasicSerializer(read_only=True)
|
|
related_report = ModerationReportSerializer(read_only=True)
|
|
|
|
# Computed fields
|
|
is_expired = serializers.SerializerMethodField()
|
|
time_remaining = serializers.SerializerMethodField()
|
|
action_type_display = serializers.CharField(source="get_action_type_display", read_only=True)
|
|
|
|
class Meta:
|
|
model = ModerationAction
|
|
fields = [
|
|
"id",
|
|
"action_type",
|
|
"action_type_display",
|
|
"reason",
|
|
"details",
|
|
"duration_hours",
|
|
"created_at",
|
|
"expires_at",
|
|
"is_active",
|
|
"moderator",
|
|
"target_user",
|
|
"related_report",
|
|
"updated_at",
|
|
"is_expired",
|
|
"time_remaining",
|
|
]
|
|
read_only_fields = [
|
|
"id",
|
|
"created_at",
|
|
"updated_at",
|
|
"moderator",
|
|
"target_user",
|
|
"related_report",
|
|
"is_expired",
|
|
"time_remaining",
|
|
"action_type_display",
|
|
]
|
|
|
|
def get_is_expired(self, obj) -> bool:
|
|
"""Check if action has expired."""
|
|
if not obj.expires_at:
|
|
return False
|
|
return timezone.now() > obj.expires_at
|
|
|
|
def get_time_remaining(self, obj) -> str | None:
|
|
"""Time remaining until expiration."""
|
|
if not obj.expires_at or not obj.is_active:
|
|
return None
|
|
|
|
now = timezone.now()
|
|
if now >= obj.expires_at:
|
|
return "Expired"
|
|
|
|
diff = obj.expires_at - now
|
|
if diff.days > 0:
|
|
return f"{diff.days} days"
|
|
elif diff.seconds > 3600:
|
|
hours = diff.seconds // 3600
|
|
return f"{hours} hours"
|
|
else:
|
|
minutes = diff.seconds // 60
|
|
return f"{minutes} minutes"
|
|
|
|
|
|
class CreateModerationActionSerializer(serializers.ModelSerializer):
|
|
"""Serializer for creating moderation actions."""
|
|
|
|
target_user_id = serializers.IntegerField()
|
|
related_report_id = serializers.IntegerField(required=False)
|
|
|
|
class Meta:
|
|
model = ModerationAction
|
|
fields = [
|
|
"action_type",
|
|
"reason",
|
|
"details",
|
|
"duration_hours",
|
|
"target_user_id",
|
|
"related_report_id",
|
|
]
|
|
|
|
def validate_target_user_id(self, value):
|
|
"""Validate target user exists."""
|
|
try:
|
|
User.objects.get(id=value)
|
|
return value
|
|
except User.DoesNotExist:
|
|
raise serializers.ValidationError("Target user not found") from None
|
|
|
|
def validate_related_report_id(self, value):
|
|
"""Validate related report exists."""
|
|
if value:
|
|
try:
|
|
ModerationReport.objects.get(id=value)
|
|
return value
|
|
except ModerationReport.DoesNotExist:
|
|
raise serializers.ValidationError("Related report not found") from None
|
|
return value
|
|
|
|
def validate(self, attrs):
|
|
"""Validate action data."""
|
|
action_type = attrs["action_type"]
|
|
duration_hours = attrs.get("duration_hours")
|
|
|
|
# Validate duration for temporary actions
|
|
temporary_actions = ["USER_SUSPENSION", "CONTENT_RESTRICTION"]
|
|
if action_type in temporary_actions and not duration_hours:
|
|
raise serializers.ValidationError({"duration_hours": f"Duration is required for {action_type}"})
|
|
|
|
# Validate duration range
|
|
if duration_hours and (duration_hours < 1 or duration_hours > 8760): # 1 hour to 1 year
|
|
raise serializers.ValidationError({"duration_hours": "Duration must be between 1 and 8760 hours (1 year)"})
|
|
|
|
return attrs
|
|
|
|
def create(self, validated_data):
|
|
"""Create moderation action with automatic fields."""
|
|
target_user_id = validated_data.pop("target_user_id")
|
|
related_report_id = validated_data.pop("related_report_id", None)
|
|
|
|
validated_data["moderator"] = self.context["request"].user
|
|
validated_data["target_user_id"] = target_user_id
|
|
validated_data["is_active"] = True
|
|
|
|
if related_report_id:
|
|
validated_data["related_report_id"] = related_report_id
|
|
|
|
# Set expiration time for temporary actions
|
|
if validated_data.get("duration_hours"):
|
|
validated_data["expires_at"] = timezone.now() + timedelta(hours=validated_data["duration_hours"])
|
|
|
|
return super().create(validated_data)
|
|
|
|
|
|
# ============================================================================
|
|
# Bulk Operation Serializers
|
|
# ============================================================================
|
|
|
|
|
|
class BulkOperationSerializer(serializers.ModelSerializer):
|
|
"""Full bulk operation serializer."""
|
|
|
|
created_by = UserBasicSerializer(read_only=True)
|
|
|
|
# Computed fields
|
|
progress_percentage = serializers.SerializerMethodField()
|
|
estimated_completion = serializers.SerializerMethodField()
|
|
operation_type_display = serializers.CharField(source="get_operation_type_display", read_only=True)
|
|
status_display = serializers.CharField(source="get_status_display", read_only=True)
|
|
|
|
class Meta:
|
|
model = BulkOperation
|
|
fields = [
|
|
"id",
|
|
"operation_type",
|
|
"operation_type_display",
|
|
"status",
|
|
"status_display",
|
|
"priority",
|
|
"parameters",
|
|
"results",
|
|
"total_items",
|
|
"processed_items",
|
|
"failed_items",
|
|
"created_at",
|
|
"started_at",
|
|
"completed_at",
|
|
"estimated_duration_minutes",
|
|
"can_cancel",
|
|
"description",
|
|
"schedule_for",
|
|
"created_by",
|
|
"updated_at",
|
|
"progress_percentage",
|
|
"estimated_completion",
|
|
]
|
|
read_only_fields = [
|
|
"id",
|
|
"created_at",
|
|
"updated_at",
|
|
"created_by",
|
|
"progress_percentage",
|
|
"estimated_completion",
|
|
"operation_type_display",
|
|
"status_display",
|
|
]
|
|
|
|
def get_progress_percentage(self, obj) -> float:
|
|
"""Calculate progress percentage."""
|
|
if obj.total_items == 0:
|
|
return 0.0
|
|
return round((obj.processed_items / obj.total_items) * 100, 2)
|
|
|
|
def get_estimated_completion(self, obj) -> str | None:
|
|
"""Estimate completion time."""
|
|
if obj.status == "COMPLETED":
|
|
return obj.completed_at.isoformat() if obj.completed_at else None
|
|
|
|
if obj.status == "RUNNING" and obj.started_at: # noqa: SIM102
|
|
# Calculate based on current progress
|
|
if obj.processed_items > 0:
|
|
elapsed_minutes = (timezone.now() - obj.started_at).total_seconds() / 60
|
|
rate = obj.processed_items / elapsed_minutes
|
|
remaining_items = obj.total_items - obj.processed_items
|
|
remaining_minutes = remaining_items / rate if rate > 0 else obj.estimated_duration_minutes
|
|
completion_time = timezone.now() + timedelta(minutes=remaining_minutes)
|
|
return completion_time.isoformat()
|
|
|
|
# Use scheduled time or estimated duration
|
|
if obj.schedule_for:
|
|
return obj.schedule_for.isoformat()
|
|
elif obj.estimated_duration_minutes:
|
|
completion_time = timezone.now() + timedelta(minutes=obj.estimated_duration_minutes)
|
|
return completion_time.isoformat()
|
|
|
|
return None
|
|
|
|
|
|
class CreateBulkOperationSerializer(serializers.ModelSerializer):
|
|
"""Serializer for creating bulk operations."""
|
|
|
|
class Meta:
|
|
model = BulkOperation
|
|
fields = [
|
|
"operation_type",
|
|
"priority",
|
|
"parameters",
|
|
"description",
|
|
"schedule_for",
|
|
"estimated_duration_minutes",
|
|
]
|
|
|
|
def validate_parameters(self, value):
|
|
"""Validate operation parameters."""
|
|
if not isinstance(value, dict):
|
|
raise serializers.ValidationError("Parameters must be a JSON object")
|
|
|
|
operation_type = getattr(self, "initial_data", {}).get("operation_type")
|
|
|
|
# Validate required parameters by operation type
|
|
required_params = {
|
|
"UPDATE_PARKS": ["park_ids", "updates"],
|
|
"UPDATE_RIDES": ["ride_ids", "updates"],
|
|
"IMPORT_DATA": ["data_type", "source"],
|
|
"EXPORT_DATA": ["data_type", "format"],
|
|
"MODERATE_CONTENT": ["content_type", "action"],
|
|
"USER_ACTIONS": ["user_ids", "action"],
|
|
}
|
|
|
|
if operation_type in required_params:
|
|
for param in required_params[operation_type]:
|
|
if param not in value:
|
|
raise serializers.ValidationError(f'Parameter "{param}" is required for {operation_type}')
|
|
|
|
return value
|
|
|
|
def create(self, validated_data):
|
|
"""Create bulk operation with automatic fields."""
|
|
validated_data["created_by"] = self.context["request"].user
|
|
validated_data["status"] = "PENDING"
|
|
validated_data["total_items"] = 0
|
|
validated_data["processed_items"] = 0
|
|
validated_data["failed_items"] = 0
|
|
validated_data["can_cancel"] = True
|
|
|
|
# Generate unique ID
|
|
import uuid
|
|
|
|
validated_data["id"] = str(uuid.uuid4())[:50]
|
|
|
|
return super().create(validated_data)
|
|
|
|
|
|
# ============================================================================
|
|
# Statistics and Summary Serializers
|
|
# ============================================================================
|
|
|
|
|
|
class ModerationStatsSerializer(serializers.Serializer):
|
|
"""Serializer for moderation statistics."""
|
|
|
|
# Report stats
|
|
total_reports = serializers.IntegerField()
|
|
pending_reports = serializers.IntegerField()
|
|
resolved_reports = serializers.IntegerField()
|
|
overdue_reports = serializers.IntegerField()
|
|
|
|
# Queue stats
|
|
queue_size = serializers.IntegerField()
|
|
assigned_items = serializers.IntegerField()
|
|
unassigned_items = serializers.IntegerField()
|
|
|
|
# Action stats
|
|
total_actions = serializers.IntegerField()
|
|
active_actions = serializers.IntegerField()
|
|
expired_actions = serializers.IntegerField()
|
|
|
|
# Bulk operation stats
|
|
running_operations = serializers.IntegerField()
|
|
completed_operations = serializers.IntegerField()
|
|
failed_operations = serializers.IntegerField()
|
|
|
|
# Performance metrics
|
|
average_resolution_time_hours = serializers.FloatField()
|
|
reports_by_priority = serializers.DictField()
|
|
reports_by_type = serializers.DictField()
|
|
|
|
|
|
class UserModerationProfileSerializer(serializers.Serializer):
|
|
"""Serializer for user moderation profile."""
|
|
|
|
user = UserBasicSerializer()
|
|
|
|
# Report history
|
|
reports_made = serializers.IntegerField()
|
|
reports_against = serializers.IntegerField()
|
|
|
|
# Action history
|
|
warnings_received = serializers.IntegerField()
|
|
suspensions_received = serializers.IntegerField()
|
|
active_restrictions = serializers.IntegerField()
|
|
|
|
# Risk assessment
|
|
risk_level = serializers.ChoiceField(
|
|
choices=[
|
|
("LOW", "Low Risk"),
|
|
("MEDIUM", "Medium Risk"),
|
|
("HIGH", "High Risk"),
|
|
("CRITICAL", "Critical Risk"),
|
|
]
|
|
)
|
|
risk_factors = serializers.ListField(child=serializers.CharField())
|
|
|
|
# Recent activity
|
|
recent_reports = ModerationReportSerializer(many=True)
|
|
recent_actions = ModerationActionSerializer(many=True)
|
|
|
|
# Account status
|
|
account_status = serializers.CharField()
|
|
last_violation_date = serializers.DateTimeField(allow_null=True)
|
|
next_review_date = serializers.DateTimeField(allow_null=True)
|
|
|
|
|
|
# ============================================================================
|
|
# FSM Transition History Serializers
|
|
# ============================================================================
|
|
|
|
|
|
class StateLogSerializer(serializers.ModelSerializer):
|
|
"""Serializer for FSM transition history."""
|
|
|
|
user = serializers.CharField(source="by.username", read_only=True)
|
|
model = serializers.CharField(source="content_type.model", read_only=True)
|
|
from_state = serializers.CharField(source="source_state", read_only=True)
|
|
to_state = serializers.CharField(source="state", read_only=True)
|
|
reason = serializers.CharField(source="description", read_only=True)
|
|
|
|
class Meta:
|
|
from django_fsm_log.models import StateLog
|
|
|
|
model = StateLog
|
|
fields = [
|
|
"id",
|
|
"timestamp",
|
|
"model",
|
|
"object_id",
|
|
"state",
|
|
"from_state",
|
|
"to_state",
|
|
"transition",
|
|
"user",
|
|
"description",
|
|
"reason",
|
|
]
|
|
read_only_fields = fields
|
|
|
|
|
|
class PhotoSubmissionSerializer(serializers.ModelSerializer):
|
|
"""Serializer for PhotoSubmission."""
|
|
|
|
submitted_by = UserBasicSerializer(source="user", read_only=True)
|
|
content_type_name = serializers.CharField(source="content_type.model", read_only=True)
|
|
photo_url = serializers.SerializerMethodField()
|
|
|
|
# UI Metadata
|
|
status_display = serializers.CharField(source="get_status_display", read_only=True)
|
|
status_color = serializers.SerializerMethodField()
|
|
status_icon = serializers.SerializerMethodField()
|
|
time_since_created = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = PhotoSubmission
|
|
fields = [
|
|
"id",
|
|
"status",
|
|
"status_display",
|
|
"status_color",
|
|
"status_icon",
|
|
"content_type",
|
|
"content_type_name",
|
|
"object_id",
|
|
"photo",
|
|
"photo_url",
|
|
"caption",
|
|
"date_taken",
|
|
"submitted_by",
|
|
"handled_by",
|
|
"handled_at",
|
|
"notes",
|
|
"created_at",
|
|
"time_since_created",
|
|
]
|
|
read_only_fields = [
|
|
"id",
|
|
"created_at",
|
|
"submitted_by",
|
|
"handled_by",
|
|
"handled_at",
|
|
"status_display",
|
|
"status_color",
|
|
"status_icon",
|
|
"content_type_name",
|
|
"photo_url",
|
|
"time_since_created",
|
|
]
|
|
|
|
def get_photo_url(self, obj) -> str | None:
|
|
if obj.photo:
|
|
return obj.photo.image_url
|
|
return None
|
|
|
|
def get_status_color(self, obj) -> str:
|
|
colors = {
|
|
"PENDING": "#f59e0b",
|
|
"APPROVED": "#10b981",
|
|
"REJECTED": "#ef4444",
|
|
}
|
|
return colors.get(obj.status, "#6b7280")
|
|
|
|
def get_status_icon(self, obj) -> str:
|
|
icons = {
|
|
"PENDING": "heroicons:clock",
|
|
"APPROVED": "heroicons:check-circle",
|
|
"REJECTED": "heroicons:x-circle",
|
|
}
|
|
return icons.get(obj.status, "heroicons:question-mark-circle")
|
|
|
|
def get_time_since_created(self, obj) -> str:
|
|
"""Human-readable time since creation."""
|
|
now = timezone.now()
|
|
diff = now - obj.created_at
|
|
|
|
if diff.days > 0:
|
|
return f"{diff.days} days ago"
|
|
elif diff.seconds > 3600:
|
|
hours = diff.seconds // 3600
|
|
return f"{hours} hours ago"
|
|
else:
|
|
minutes = diff.seconds // 60
|
|
return f"{minutes} minutes ago"
|
|
|
|
|
|
# ============================================================================
|
|
# Moderation Audit Log Serializers
|
|
# ============================================================================
|
|
|
|
|
|
class ModerationAuditLogSerializer(serializers.ModelSerializer):
|
|
"""Serializer for moderation audit logs."""
|
|
|
|
moderator = UserBasicSerializer(read_only=True)
|
|
moderator_username = serializers.CharField(source="moderator.username", read_only=True, allow_null=True)
|
|
submission_content_type = serializers.CharField(source="submission.content_type.model", read_only=True)
|
|
action_display = serializers.CharField(source="get_action_display", read_only=True)
|
|
|
|
class Meta:
|
|
from .models import ModerationAuditLog
|
|
|
|
model = ModerationAuditLog
|
|
fields = [
|
|
"id",
|
|
"submission",
|
|
"submission_content_type",
|
|
"moderator",
|
|
"moderator_username",
|
|
"action",
|
|
"action_display",
|
|
"previous_status",
|
|
"new_status",
|
|
"notes",
|
|
"is_system_action",
|
|
"is_test_data",
|
|
"created_at",
|
|
]
|
|
read_only_fields = [
|
|
"id",
|
|
"created_at",
|
|
"moderator",
|
|
"moderator_username",
|
|
"submission_content_type",
|
|
"action_display",
|
|
]
|