mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 05:25:18 -05:00
- 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
1186 lines
41 KiB
Python
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",
|
|
]
|