This commit is contained in:
pacnpal
2025-09-21 20:04:42 -04:00
parent 42a3dc7637
commit 75cc618c2b
610 changed files with 1719 additions and 4816 deletions

View File

@@ -0,0 +1,747 @@
"""
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 rest_framework import serializers
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from datetime import timedelta
from .models import (
ModerationReport,
ModerationQueue,
ModerationAction,
BulkOperation,
)
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 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":
if 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")
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")
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")
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:
# 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)