Files
thrillwiki_django_no_react/backend/apps/moderation/serializers.py
pacnpal 4140a0d8e7 Add @extend_schema decorators to moderation ViewSet actions
- Add drf_spectacular imports (extend_schema, OpenApiResponse, inline_serializer)
- Annotate claim action with response schemas for 200/404/409/400
- Annotate unclaim action with response schemas for 200/403/400
- Annotate approve action with request=None and response schemas
- Annotate reject action with reason request body schema
- Annotate escalate action with reason request body schema
- All actions tagged with 'Moderation' for API docs grouping
2026-01-13 19:34:41 -05:00

1186 lines
41 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,
)
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()
# Photo URL for frontend compatibility (Cloudflare Images)
photo_url = serializers.SerializerMethodField()
cloudflare_image_id = 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",
"photo_url", # Cloudflare image URL for frontend
"cloudflare_image_id",
"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",
"photo_url",
"cloudflare_image_id",
]
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"
def get_photo_url(self, obj) -> str | None:
"""Return Cloudflare image URL for photo submissions."""
if obj.photo:
return getattr(obj.photo, "image_url", None) or getattr(obj.photo, "url", None)
return None
def get_cloudflare_image_id(self, obj) -> str | None:
"""Expose Cloudflare image id for clients expecting Supabase-like fields."""
return getattr(obj.photo, "id", None) if obj.photo else None
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")
caption = serializers.CharField(required=False, allow_blank=True)
date_taken = serializers.DateField(required=False, allow_null=True)
class Meta:
model = EditSubmission
fields = [
"entity_type",
"object_id",
"submission_type",
"changes",
"photo",
"caption",
"date_taken",
"reason",
"source",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Add photo field with lazy import to avoid app loading cycles
from django_cloudflareimages_toolkit.models import CloudflareImage
self.fields["photo"] = serializers.PrimaryKeyRelatedField(
queryset=CloudflareImage.objects.all(),
required=False,
allow_null=True,
help_text="CloudflareImage id for photo submissions",
)
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 value is None:
return {}
if not isinstance(value, dict):
raise serializers.ValidationError("Changes must be a JSON object")
return value
def validate(self, attrs):
"""Cross-field validation."""
submission_type = attrs.get("submission_type", "EDIT")
object_id = attrs.get("object_id")
changes = attrs.get("changes") or {}
# 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"}
)
# For PHOTO submissions, enforce required fields and allow empty changes
if submission_type == "PHOTO":
if not object_id:
raise serializers.ValidationError({"object_id": "object_id is required for PHOTO submissions"})
if not attrs.get("photo"):
raise serializers.ValidationError({"photo": "photo is required for PHOTO submissions"})
else:
if not changes:
raise serializers.ValidationError({"changes": "Changes cannot be empty"})
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)
class CreatePhotoSubmissionSerializer(serializers.ModelSerializer):
"""
Serializer for creating photo submissions with backward compatibility.
This is a specialized serializer for the /photos endpoint that:
- Makes entity_type optional (can be inferred from content_type_id if provided)
- Automatically sets submission_type to "PHOTO"
- Allows empty changes (photos don't have field changes)
Supports both new format (entity_type) and legacy format (content_type_id + object_id).
"""
entity_type = serializers.CharField(
write_only=True,
required=False, # Optional for backward compatibility
allow_blank=True,
help_text="Entity type: park, ride, company, ride_model (optional if content_type provided)"
)
content_type_id = serializers.IntegerField(
write_only=True,
required=False,
help_text="Legacy: ContentType ID (alternative to entity_type)"
)
caption = serializers.CharField(required=False, allow_blank=True, default="")
date_taken = serializers.DateField(required=False, allow_null=True)
reason = serializers.CharField(required=False, allow_blank=True, default="Photo submission")
class Meta:
model = EditSubmission
fields = [
"entity_type",
"content_type_id",
"object_id",
"photo",
"caption",
"date_taken",
"reason",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Add photo field with lazy import to avoid app loading cycles
from django_cloudflareimages_toolkit.models import CloudflareImage
self.fields["photo"] = serializers.PrimaryKeyRelatedField(
queryset=CloudflareImage.objects.all(),
required=True, # Photo is required for photo submissions
help_text="CloudflareImage id for photo submissions",
)
def validate(self, attrs):
"""Validate and resolve content_type."""
entity_type = attrs.get("entity_type")
content_type_id = attrs.get("content_type_id")
object_id = attrs.get("object_id")
# Must have object_id
if not object_id:
raise serializers.ValidationError({"object_id": "object_id is required for photo submissions"})
# Must have either entity_type or content_type_id
if not entity_type and not content_type_id:
raise serializers.ValidationError({
"entity_type": "Either entity_type or content_type_id is required"
})
return attrs
def create(self, validated_data):
"""Create a photo submission."""
entity_type = validated_data.pop("entity_type", None)
content_type_id = validated_data.pop("content_type_id", None)
# Resolve ContentType
if 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"),
}
entity_lower = entity_type.lower()
if entity_lower not in entity_type_map:
raise serializers.ValidationError({
"entity_type": f"Invalid entity_type. Must be one of: {', '.join(entity_type_map.keys())}"
})
app_label, model_name = entity_type_map[entity_lower]
content_type = ContentType.objects.get(app_label=app_label, model=model_name)
elif content_type_id:
# Legacy: Use content_type_id directly
try:
content_type = ContentType.objects.get(pk=content_type_id)
except ContentType.DoesNotExist:
raise serializers.ValidationError({"content_type_id": "Invalid content_type_id"})
else:
raise serializers.ValidationError({"entity_type": "entity_type or content_type_id is required"})
# Set automatic fields for photo submission
validated_data["user"] = self.context["request"].user
validated_data["content_type"] = content_type
validated_data["submission_type"] = "PHOTO"
validated_data["changes"] = {} # Photos don't have field changes
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
# ============================================================================
# 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",
]