mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 09:31:09 -05:00
remove backend
This commit is contained in:
2
apps/moderation/__init__.py
Normal file
2
apps/moderation/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Import choices to trigger auto-registration with the global registry
|
||||
from . import choices # noqa: F401
|
||||
171
apps/moderation/admin.py
Normal file
171
apps/moderation/admin.py
Normal file
@@ -0,0 +1,171 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin import AdminSite
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
from .models import EditSubmission, PhotoSubmission
|
||||
|
||||
|
||||
class ModerationAdminSite(AdminSite):
|
||||
site_header = "ThrillWiki Moderation"
|
||||
site_title = "ThrillWiki Moderation"
|
||||
index_title = "Moderation Dashboard"
|
||||
|
||||
def has_permission(self, request):
|
||||
"""Only allow moderators and above to access this admin site"""
|
||||
return request.user.is_authenticated and request.user.role in [
|
||||
"MODERATOR",
|
||||
"ADMIN",
|
||||
"SUPERUSER",
|
||||
]
|
||||
|
||||
|
||||
moderation_site = ModerationAdminSite(name="moderation")
|
||||
|
||||
|
||||
class EditSubmissionAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id",
|
||||
"user_link",
|
||||
"content_type",
|
||||
"content_link",
|
||||
"status",
|
||||
"created_at",
|
||||
"handled_by",
|
||||
]
|
||||
list_filter = ["status", "content_type", "created_at"]
|
||||
search_fields = ["user__username", "reason", "source", "notes"]
|
||||
readonly_fields = [
|
||||
"user",
|
||||
"content_type",
|
||||
"object_id",
|
||||
"changes",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
def user_link(self, obj):
|
||||
url = reverse("admin:accounts_user_change", args=[obj.user.id])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.user.username)
|
||||
|
||||
user_link.short_description = "User"
|
||||
|
||||
def content_link(self, obj):
|
||||
if hasattr(obj.content_object, "get_absolute_url"):
|
||||
url = obj.content_object.get_absolute_url()
|
||||
return format_html('<a href="{}">{}</a>', url, str(obj.content_object))
|
||||
return str(obj.content_object)
|
||||
|
||||
content_link.short_description = "Content"
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if "status" in form.changed_data:
|
||||
if obj.status == "APPROVED":
|
||||
obj.approve(request.user)
|
||||
elif obj.status == "REJECTED":
|
||||
obj.reject(request.user)
|
||||
elif obj.status == "ESCALATED":
|
||||
obj.escalate(request.user)
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
class PhotoSubmissionAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id",
|
||||
"user_link",
|
||||
"content_type",
|
||||
"content_link",
|
||||
"photo_preview",
|
||||
"status",
|
||||
"created_at",
|
||||
"handled_by",
|
||||
]
|
||||
list_filter = ["status", "content_type", "created_at"]
|
||||
search_fields = ["user__username", "caption", "notes"]
|
||||
readonly_fields = [
|
||||
"user",
|
||||
"content_type",
|
||||
"object_id",
|
||||
"photo_preview",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
def user_link(self, obj):
|
||||
url = reverse("admin:accounts_user_change", args=[obj.user.id])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.user.username)
|
||||
|
||||
user_link.short_description = "User"
|
||||
|
||||
def content_link(self, obj):
|
||||
if hasattr(obj.content_object, "get_absolute_url"):
|
||||
url = obj.content_object.get_absolute_url()
|
||||
return format_html('<a href="{}">{}</a>', url, str(obj.content_object))
|
||||
return str(obj.content_object)
|
||||
|
||||
content_link.short_description = "Content"
|
||||
|
||||
def photo_preview(self, obj):
|
||||
if obj.photo:
|
||||
return format_html(
|
||||
'<img src="{}" style="max-height: 100px; max-width: 200px;" />',
|
||||
obj.photo.url,
|
||||
)
|
||||
return ""
|
||||
|
||||
photo_preview.short_description = "Photo Preview"
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if "status" in form.changed_data:
|
||||
if obj.status == "APPROVED":
|
||||
obj.approve(request.user, obj.notes)
|
||||
elif obj.status == "REJECTED":
|
||||
obj.reject(request.user, obj.notes)
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
class HistoryEventAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for viewing model history events"""
|
||||
|
||||
list_display = [
|
||||
"pgh_label",
|
||||
"pgh_created_at",
|
||||
"get_object_link",
|
||||
"get_context",
|
||||
]
|
||||
list_filter = ["pgh_label", "pgh_created_at"]
|
||||
readonly_fields = [
|
||||
"pgh_label",
|
||||
"pgh_obj_id",
|
||||
"pgh_data",
|
||||
"pgh_context",
|
||||
"pgh_created_at",
|
||||
]
|
||||
date_hierarchy = "pgh_created_at"
|
||||
|
||||
def get_object_link(self, obj):
|
||||
"""Display a link to the related object if possible"""
|
||||
if obj.pgh_obj and hasattr(obj.pgh_obj, "get_absolute_url"):
|
||||
url = obj.pgh_obj.get_absolute_url()
|
||||
return format_html('<a href="{}">{}</a>', url, str(obj.pgh_obj))
|
||||
return str(obj.pgh_obj or "")
|
||||
|
||||
get_object_link.short_description = "Object"
|
||||
|
||||
def get_context(self, obj):
|
||||
"""Format the context data nicely"""
|
||||
if not obj.pgh_context:
|
||||
return "-"
|
||||
html = ["<table>"]
|
||||
for key, value in obj.pgh_context.items():
|
||||
html.append(f"<tr><th>{key}</th><td>{value}</td></tr>")
|
||||
html.append("</table>")
|
||||
return mark_safe("".join(html))
|
||||
|
||||
get_context.short_description = "Context"
|
||||
|
||||
|
||||
# Register with moderation site only
|
||||
moderation_site.register(EditSubmission, EditSubmissionAdmin)
|
||||
moderation_site.register(PhotoSubmission, PhotoSubmissionAdmin)
|
||||
|
||||
# We will register concrete event models as they are created during migrations
|
||||
# Example: moderation_site.register(DesignerEvent, HistoryEventAdmin)
|
||||
7
apps/moderation/apps.py
Normal file
7
apps/moderation/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ModerationConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.moderation"
|
||||
verbose_name = "Content Moderation"
|
||||
935
apps/moderation/choices.py
Normal file
935
apps/moderation/choices.py
Normal file
@@ -0,0 +1,935 @@
|
||||
"""
|
||||
Rich Choice Objects for Moderation Domain
|
||||
|
||||
This module defines all choice options for the moderation system using the Rich Choice Objects pattern.
|
||||
All choices include rich metadata for UI styling, business logic, and enhanced functionality.
|
||||
"""
|
||||
|
||||
from apps.core.choices.base import RichChoice, ChoiceCategory
|
||||
from apps.core.choices.registry import register_choices
|
||||
|
||||
# ============================================================================
|
||||
# EditSubmission Choices
|
||||
# ============================================================================
|
||||
|
||||
EDIT_SUBMISSION_STATUSES = [
|
||||
RichChoice(
|
||||
value="PENDING",
|
||||
label="Pending",
|
||||
description="Submission awaiting moderator review",
|
||||
metadata={
|
||||
'color': 'yellow',
|
||||
'icon': 'clock',
|
||||
'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
'sort_order': 1,
|
||||
'can_transition_to': ['APPROVED', 'REJECTED', 'ESCALATED'],
|
||||
'requires_moderator': True,
|
||||
'is_actionable': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="APPROVED",
|
||||
label="Approved",
|
||||
description="Submission has been approved and changes applied",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'check-circle',
|
||||
'css_class': 'bg-green-100 text-green-800 border-green-200',
|
||||
'sort_order': 2,
|
||||
'can_transition_to': [],
|
||||
'requires_moderator': True,
|
||||
'is_actionable': False,
|
||||
'is_final': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="REJECTED",
|
||||
label="Rejected",
|
||||
description="Submission has been rejected and will not be applied",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'x-circle',
|
||||
'css_class': 'bg-red-100 text-red-800 border-red-200',
|
||||
'sort_order': 3,
|
||||
'can_transition_to': [],
|
||||
'requires_moderator': True,
|
||||
'is_actionable': False,
|
||||
'is_final': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="ESCALATED",
|
||||
label="Escalated",
|
||||
description="Submission has been escalated for higher-level review",
|
||||
metadata={
|
||||
'color': 'purple',
|
||||
'icon': 'arrow-up',
|
||||
'css_class': 'bg-purple-100 text-purple-800 border-purple-200',
|
||||
'sort_order': 4,
|
||||
'can_transition_to': ['APPROVED', 'REJECTED'],
|
||||
'requires_moderator': True,
|
||||
'is_actionable': True,
|
||||
'escalation_level': 'admin'
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
]
|
||||
|
||||
SUBMISSION_TYPES = [
|
||||
RichChoice(
|
||||
value="EDIT",
|
||||
label="Edit Existing",
|
||||
description="Modification to existing content",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'pencil',
|
||||
'css_class': 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
'sort_order': 1,
|
||||
'requires_existing_object': True,
|
||||
'complexity_level': 'medium'
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="CREATE",
|
||||
label="Create New",
|
||||
description="Creation of new content",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'plus-circle',
|
||||
'css_class': 'bg-green-100 text-green-800 border-green-200',
|
||||
'sort_order': 2,
|
||||
'requires_existing_object': False,
|
||||
'complexity_level': 'high'
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# ModerationReport Choices
|
||||
# ============================================================================
|
||||
|
||||
MODERATION_REPORT_STATUSES = [
|
||||
RichChoice(
|
||||
value="PENDING",
|
||||
label="Pending Review",
|
||||
description="Report awaiting initial moderator review",
|
||||
metadata={
|
||||
'color': 'yellow',
|
||||
'icon': 'clock',
|
||||
'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
'sort_order': 1,
|
||||
'can_transition_to': ['UNDER_REVIEW', 'DISMISSED'],
|
||||
'requires_assignment': False,
|
||||
'is_actionable': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="UNDER_REVIEW",
|
||||
label="Under Review",
|
||||
description="Report is actively being investigated by a moderator",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'eye',
|
||||
'css_class': 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
'sort_order': 2,
|
||||
'can_transition_to': ['RESOLVED', 'DISMISSED'],
|
||||
'requires_assignment': True,
|
||||
'is_actionable': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="RESOLVED",
|
||||
label="Resolved",
|
||||
description="Report has been resolved with appropriate action taken",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'check-circle',
|
||||
'css_class': 'bg-green-100 text-green-800 border-green-200',
|
||||
'sort_order': 3,
|
||||
'can_transition_to': [],
|
||||
'requires_assignment': True,
|
||||
'is_actionable': False,
|
||||
'is_final': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="DISMISSED",
|
||||
label="Dismissed",
|
||||
description="Report was reviewed but no action was necessary",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'x-circle',
|
||||
'css_class': 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
'sort_order': 4,
|
||||
'can_transition_to': [],
|
||||
'requires_assignment': True,
|
||||
'is_actionable': False,
|
||||
'is_final': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
]
|
||||
|
||||
PRIORITY_LEVELS = [
|
||||
RichChoice(
|
||||
value="LOW",
|
||||
label="Low",
|
||||
description="Low priority - can be handled in regular workflow",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'arrow-down',
|
||||
'css_class': 'bg-green-100 text-green-800 border-green-200',
|
||||
'sort_order': 1,
|
||||
'sla_hours': 168, # 7 days
|
||||
'escalation_threshold': 240, # 10 days
|
||||
'urgency_level': 1
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="MEDIUM",
|
||||
label="Medium",
|
||||
description="Medium priority - standard response time expected",
|
||||
metadata={
|
||||
'color': 'yellow',
|
||||
'icon': 'minus',
|
||||
'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
'sort_order': 2,
|
||||
'sla_hours': 72, # 3 days
|
||||
'escalation_threshold': 120, # 5 days
|
||||
'urgency_level': 2
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="HIGH",
|
||||
label="High",
|
||||
description="High priority - requires prompt attention",
|
||||
metadata={
|
||||
'color': 'orange',
|
||||
'icon': 'arrow-up',
|
||||
'css_class': 'bg-orange-100 text-orange-800 border-orange-200',
|
||||
'sort_order': 3,
|
||||
'sla_hours': 24, # 1 day
|
||||
'escalation_threshold': 48, # 2 days
|
||||
'urgency_level': 3
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="URGENT",
|
||||
label="Urgent",
|
||||
description="Urgent priority - immediate attention required",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'exclamation',
|
||||
'css_class': 'bg-red-100 text-red-800 border-red-200',
|
||||
'sort_order': 4,
|
||||
'sla_hours': 4, # 4 hours
|
||||
'escalation_threshold': 8, # 8 hours
|
||||
'urgency_level': 4,
|
||||
'requires_immediate_notification': True
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
]
|
||||
|
||||
REPORT_TYPES = [
|
||||
RichChoice(
|
||||
value="SPAM",
|
||||
label="Spam",
|
||||
description="Unwanted or repetitive content",
|
||||
metadata={
|
||||
'color': 'yellow',
|
||||
'icon': 'ban',
|
||||
'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
'sort_order': 1,
|
||||
'default_priority': 'MEDIUM',
|
||||
'auto_actions': ['content_review'],
|
||||
'severity_level': 2
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="HARASSMENT",
|
||||
label="Harassment",
|
||||
description="Targeted harassment or bullying behavior",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'shield-exclamation',
|
||||
'css_class': 'bg-red-100 text-red-800 border-red-200',
|
||||
'sort_order': 2,
|
||||
'default_priority': 'HIGH',
|
||||
'auto_actions': ['user_review', 'content_review'],
|
||||
'severity_level': 4,
|
||||
'requires_user_action': True
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="INAPPROPRIATE_CONTENT",
|
||||
label="Inappropriate Content",
|
||||
description="Content that violates community guidelines",
|
||||
metadata={
|
||||
'color': 'orange',
|
||||
'icon': 'exclamation-triangle',
|
||||
'css_class': 'bg-orange-100 text-orange-800 border-orange-200',
|
||||
'sort_order': 3,
|
||||
'default_priority': 'HIGH',
|
||||
'auto_actions': ['content_review'],
|
||||
'severity_level': 3
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="MISINFORMATION",
|
||||
label="Misinformation",
|
||||
description="False or misleading information",
|
||||
metadata={
|
||||
'color': 'purple',
|
||||
'icon': 'information-circle',
|
||||
'css_class': 'bg-purple-100 text-purple-800 border-purple-200',
|
||||
'sort_order': 4,
|
||||
'default_priority': 'HIGH',
|
||||
'auto_actions': ['content_review', 'fact_check'],
|
||||
'severity_level': 3,
|
||||
'requires_expert_review': True
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="COPYRIGHT",
|
||||
label="Copyright Violation",
|
||||
description="Unauthorized use of copyrighted material",
|
||||
metadata={
|
||||
'color': 'indigo',
|
||||
'icon': 'document-duplicate',
|
||||
'css_class': 'bg-indigo-100 text-indigo-800 border-indigo-200',
|
||||
'sort_order': 5,
|
||||
'default_priority': 'HIGH',
|
||||
'auto_actions': ['content_review', 'legal_review'],
|
||||
'severity_level': 4,
|
||||
'requires_legal_review': True
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="PRIVACY",
|
||||
label="Privacy Violation",
|
||||
description="Unauthorized sharing of private information",
|
||||
metadata={
|
||||
'color': 'pink',
|
||||
'icon': 'lock-closed',
|
||||
'css_class': 'bg-pink-100 text-pink-800 border-pink-200',
|
||||
'sort_order': 6,
|
||||
'default_priority': 'URGENT',
|
||||
'auto_actions': ['content_removal', 'user_review'],
|
||||
'severity_level': 5,
|
||||
'requires_immediate_action': True
|
||||
},
|
||||
category=ChoiceCategory.SECURITY
|
||||
),
|
||||
RichChoice(
|
||||
value="HATE_SPEECH",
|
||||
label="Hate Speech",
|
||||
description="Content promoting hatred or discrimination",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'fire',
|
||||
'css_class': 'bg-red-100 text-red-800 border-red-200',
|
||||
'sort_order': 7,
|
||||
'default_priority': 'URGENT',
|
||||
'auto_actions': ['content_removal', 'user_suspension'],
|
||||
'severity_level': 5,
|
||||
'requires_immediate_action': True,
|
||||
'zero_tolerance': True
|
||||
},
|
||||
category=ChoiceCategory.SECURITY
|
||||
),
|
||||
RichChoice(
|
||||
value="VIOLENCE",
|
||||
label="Violence or Threats",
|
||||
description="Content containing violence or threatening behavior",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'exclamation',
|
||||
'css_class': 'bg-red-100 text-red-800 border-red-200',
|
||||
'sort_order': 8,
|
||||
'default_priority': 'URGENT',
|
||||
'auto_actions': ['content_removal', 'user_ban', 'law_enforcement_notification'],
|
||||
'severity_level': 5,
|
||||
'requires_immediate_action': True,
|
||||
'zero_tolerance': True,
|
||||
'requires_law_enforcement': True
|
||||
},
|
||||
category=ChoiceCategory.SECURITY
|
||||
),
|
||||
RichChoice(
|
||||
value="OTHER",
|
||||
label="Other",
|
||||
description="Other issues not covered by specific categories",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'dots-horizontal',
|
||||
'css_class': 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
'sort_order': 9,
|
||||
'default_priority': 'MEDIUM',
|
||||
'auto_actions': ['manual_review'],
|
||||
'severity_level': 1,
|
||||
'requires_manual_categorization': True
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# ModerationQueue Choices
|
||||
# ============================================================================
|
||||
|
||||
MODERATION_QUEUE_STATUSES = [
|
||||
RichChoice(
|
||||
value="PENDING",
|
||||
label="Pending",
|
||||
description="Queue item awaiting assignment or action",
|
||||
metadata={
|
||||
'color': 'yellow',
|
||||
'icon': 'clock',
|
||||
'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
'sort_order': 1,
|
||||
'can_transition_to': ['IN_PROGRESS', 'CANCELLED'],
|
||||
'requires_assignment': False,
|
||||
'is_actionable': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="IN_PROGRESS",
|
||||
label="In Progress",
|
||||
description="Queue item is actively being worked on",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'play',
|
||||
'css_class': 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
'sort_order': 2,
|
||||
'can_transition_to': ['COMPLETED', 'CANCELLED'],
|
||||
'requires_assignment': True,
|
||||
'is_actionable': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="COMPLETED",
|
||||
label="Completed",
|
||||
description="Queue item has been successfully completed",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'check-circle',
|
||||
'css_class': 'bg-green-100 text-green-800 border-green-200',
|
||||
'sort_order': 3,
|
||||
'can_transition_to': [],
|
||||
'requires_assignment': True,
|
||||
'is_actionable': False,
|
||||
'is_final': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="CANCELLED",
|
||||
label="Cancelled",
|
||||
description="Queue item was cancelled and will not be completed",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'x-circle',
|
||||
'css_class': 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
'sort_order': 4,
|
||||
'can_transition_to': [],
|
||||
'requires_assignment': False,
|
||||
'is_actionable': False,
|
||||
'is_final': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
]
|
||||
|
||||
QUEUE_ITEM_TYPES = [
|
||||
RichChoice(
|
||||
value="CONTENT_REVIEW",
|
||||
label="Content Review",
|
||||
description="Review of user-submitted content for policy compliance",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'document-text',
|
||||
'css_class': 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
'sort_order': 1,
|
||||
'estimated_time_minutes': 15,
|
||||
'required_permissions': ['content_moderation'],
|
||||
'complexity_level': 'medium'
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="USER_REVIEW",
|
||||
label="User Review",
|
||||
description="Review of user account or behavior",
|
||||
metadata={
|
||||
'color': 'purple',
|
||||
'icon': 'user',
|
||||
'css_class': 'bg-purple-100 text-purple-800 border-purple-200',
|
||||
'sort_order': 2,
|
||||
'estimated_time_minutes': 30,
|
||||
'required_permissions': ['user_moderation'],
|
||||
'complexity_level': 'high'
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="BULK_ACTION",
|
||||
label="Bulk Action",
|
||||
description="Large-scale administrative operation",
|
||||
metadata={
|
||||
'color': 'indigo',
|
||||
'icon': 'collection',
|
||||
'css_class': 'bg-indigo-100 text-indigo-800 border-indigo-200',
|
||||
'sort_order': 3,
|
||||
'estimated_time_minutes': 60,
|
||||
'required_permissions': ['bulk_operations'],
|
||||
'complexity_level': 'high'
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="POLICY_VIOLATION",
|
||||
label="Policy Violation",
|
||||
description="Investigation of potential policy violations",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'shield-exclamation',
|
||||
'css_class': 'bg-red-100 text-red-800 border-red-200',
|
||||
'sort_order': 4,
|
||||
'estimated_time_minutes': 45,
|
||||
'required_permissions': ['policy_enforcement'],
|
||||
'complexity_level': 'high'
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="APPEAL",
|
||||
label="Appeal",
|
||||
description="Review of user appeal against moderation action",
|
||||
metadata={
|
||||
'color': 'orange',
|
||||
'icon': 'scale',
|
||||
'css_class': 'bg-orange-100 text-orange-800 border-orange-200',
|
||||
'sort_order': 5,
|
||||
'estimated_time_minutes': 30,
|
||||
'required_permissions': ['appeal_review'],
|
||||
'complexity_level': 'high'
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="OTHER",
|
||||
label="Other",
|
||||
description="Other moderation tasks not covered by specific types",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'dots-horizontal',
|
||||
'css_class': 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
'sort_order': 6,
|
||||
'estimated_time_minutes': 20,
|
||||
'required_permissions': ['general_moderation'],
|
||||
'complexity_level': 'medium'
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# ModerationAction Choices
|
||||
# ============================================================================
|
||||
|
||||
MODERATION_ACTION_TYPES = [
|
||||
RichChoice(
|
||||
value="WARNING",
|
||||
label="Warning",
|
||||
description="Formal warning issued to user",
|
||||
metadata={
|
||||
'color': 'yellow',
|
||||
'icon': 'exclamation-triangle',
|
||||
'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
'sort_order': 1,
|
||||
'severity_level': 1,
|
||||
'is_temporary': False,
|
||||
'affects_privileges': False,
|
||||
'escalation_path': ['USER_SUSPENSION']
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="USER_SUSPENSION",
|
||||
label="User Suspension",
|
||||
description="Temporary suspension of user account",
|
||||
metadata={
|
||||
'color': 'orange',
|
||||
'icon': 'pause',
|
||||
'css_class': 'bg-orange-100 text-orange-800 border-orange-200',
|
||||
'sort_order': 2,
|
||||
'severity_level': 3,
|
||||
'is_temporary': True,
|
||||
'affects_privileges': True,
|
||||
'requires_duration': True,
|
||||
'escalation_path': ['USER_BAN']
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="USER_BAN",
|
||||
label="User Ban",
|
||||
description="Permanent ban of user account",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'ban',
|
||||
'css_class': 'bg-red-100 text-red-800 border-red-200',
|
||||
'sort_order': 3,
|
||||
'severity_level': 5,
|
||||
'is_temporary': False,
|
||||
'affects_privileges': True,
|
||||
'is_permanent': True,
|
||||
'requires_admin_approval': True
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="CONTENT_REMOVAL",
|
||||
label="Content Removal",
|
||||
description="Removal of specific content",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'trash',
|
||||
'css_class': 'bg-red-100 text-red-800 border-red-200',
|
||||
'sort_order': 4,
|
||||
'severity_level': 2,
|
||||
'is_temporary': False,
|
||||
'affects_privileges': False,
|
||||
'is_content_action': True
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="CONTENT_EDIT",
|
||||
label="Content Edit",
|
||||
description="Modification of content to comply with policies",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'pencil',
|
||||
'css_class': 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
'sort_order': 5,
|
||||
'severity_level': 1,
|
||||
'is_temporary': False,
|
||||
'affects_privileges': False,
|
||||
'is_content_action': True,
|
||||
'preserves_content': True
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="CONTENT_RESTRICTION",
|
||||
label="Content Restriction",
|
||||
description="Restriction of content visibility or access",
|
||||
metadata={
|
||||
'color': 'purple',
|
||||
'icon': 'eye-off',
|
||||
'css_class': 'bg-purple-100 text-purple-800 border-purple-200',
|
||||
'sort_order': 6,
|
||||
'severity_level': 2,
|
||||
'is_temporary': True,
|
||||
'affects_privileges': False,
|
||||
'is_content_action': True,
|
||||
'requires_duration': True
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="ACCOUNT_RESTRICTION",
|
||||
label="Account Restriction",
|
||||
description="Restriction of specific account privileges",
|
||||
metadata={
|
||||
'color': 'indigo',
|
||||
'icon': 'lock-closed',
|
||||
'css_class': 'bg-indigo-100 text-indigo-800 border-indigo-200',
|
||||
'sort_order': 7,
|
||||
'severity_level': 3,
|
||||
'is_temporary': True,
|
||||
'affects_privileges': True,
|
||||
'requires_duration': True,
|
||||
'escalation_path': ['USER_SUSPENSION']
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="OTHER",
|
||||
label="Other",
|
||||
description="Other moderation actions not covered by specific types",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'dots-horizontal',
|
||||
'css_class': 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
'sort_order': 8,
|
||||
'severity_level': 1,
|
||||
'is_temporary': False,
|
||||
'affects_privileges': False,
|
||||
'requires_manual_review': True
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# BulkOperation Choices
|
||||
# ============================================================================
|
||||
|
||||
BULK_OPERATION_STATUSES = [
|
||||
RichChoice(
|
||||
value="PENDING",
|
||||
label="Pending",
|
||||
description="Operation is queued and waiting to start",
|
||||
metadata={
|
||||
'color': 'yellow',
|
||||
'icon': 'clock',
|
||||
'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
'sort_order': 1,
|
||||
'can_transition_to': ['RUNNING', 'CANCELLED'],
|
||||
'is_actionable': True,
|
||||
'can_cancel': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="RUNNING",
|
||||
label="Running",
|
||||
description="Operation is currently executing",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'play',
|
||||
'css_class': 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
'sort_order': 2,
|
||||
'can_transition_to': ['COMPLETED', 'FAILED', 'CANCELLED'],
|
||||
'is_actionable': True,
|
||||
'can_cancel': True,
|
||||
'shows_progress': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="COMPLETED",
|
||||
label="Completed",
|
||||
description="Operation completed successfully",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'check-circle',
|
||||
'css_class': 'bg-green-100 text-green-800 border-green-200',
|
||||
'sort_order': 3,
|
||||
'can_transition_to': [],
|
||||
'is_actionable': False,
|
||||
'can_cancel': False,
|
||||
'is_final': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="FAILED",
|
||||
label="Failed",
|
||||
description="Operation failed with errors",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'x-circle',
|
||||
'css_class': 'bg-red-100 text-red-800 border-red-200',
|
||||
'sort_order': 4,
|
||||
'can_transition_to': [],
|
||||
'is_actionable': False,
|
||||
'can_cancel': False,
|
||||
'is_final': True,
|
||||
'requires_investigation': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="CANCELLED",
|
||||
label="Cancelled",
|
||||
description="Operation was cancelled before completion",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'stop',
|
||||
'css_class': 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
'sort_order': 5,
|
||||
'can_transition_to': [],
|
||||
'is_actionable': False,
|
||||
'can_cancel': False,
|
||||
'is_final': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
]
|
||||
|
||||
BULK_OPERATION_TYPES = [
|
||||
RichChoice(
|
||||
value="UPDATE_PARKS",
|
||||
label="Update Parks",
|
||||
description="Bulk update operations on park data",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'map',
|
||||
'css_class': 'bg-green-100 text-green-800 border-green-200',
|
||||
'sort_order': 1,
|
||||
'estimated_duration_minutes': 30,
|
||||
'required_permissions': ['bulk_park_operations'],
|
||||
'affects_data': ['parks'],
|
||||
'risk_level': 'medium'
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="UPDATE_RIDES",
|
||||
label="Update Rides",
|
||||
description="Bulk update operations on ride data",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'cog',
|
||||
'css_class': 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
'sort_order': 2,
|
||||
'estimated_duration_minutes': 45,
|
||||
'required_permissions': ['bulk_ride_operations'],
|
||||
'affects_data': ['rides'],
|
||||
'risk_level': 'medium'
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="IMPORT_DATA",
|
||||
label="Import Data",
|
||||
description="Import data from external sources",
|
||||
metadata={
|
||||
'color': 'purple',
|
||||
'icon': 'download',
|
||||
'css_class': 'bg-purple-100 text-purple-800 border-purple-200',
|
||||
'sort_order': 3,
|
||||
'estimated_duration_minutes': 60,
|
||||
'required_permissions': ['data_import'],
|
||||
'affects_data': ['parks', 'rides', 'users'],
|
||||
'risk_level': 'high'
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="EXPORT_DATA",
|
||||
label="Export Data",
|
||||
description="Export data for backup or analysis",
|
||||
metadata={
|
||||
'color': 'indigo',
|
||||
'icon': 'upload',
|
||||
'css_class': 'bg-indigo-100 text-indigo-800 border-indigo-200',
|
||||
'sort_order': 4,
|
||||
'estimated_duration_minutes': 20,
|
||||
'required_permissions': ['data_export'],
|
||||
'affects_data': [],
|
||||
'risk_level': 'low'
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="MODERATE_CONTENT",
|
||||
label="Moderate Content",
|
||||
description="Bulk moderation actions on content",
|
||||
metadata={
|
||||
'color': 'orange',
|
||||
'icon': 'shield-check',
|
||||
'css_class': 'bg-orange-100 text-orange-800 border-orange-200',
|
||||
'sort_order': 5,
|
||||
'estimated_duration_minutes': 40,
|
||||
'required_permissions': ['bulk_moderation'],
|
||||
'affects_data': ['content', 'users'],
|
||||
'risk_level': 'high'
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="USER_ACTIONS",
|
||||
label="User Actions",
|
||||
description="Bulk actions on user accounts",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'users',
|
||||
'css_class': 'bg-red-100 text-red-800 border-red-200',
|
||||
'sort_order': 6,
|
||||
'estimated_duration_minutes': 50,
|
||||
'required_permissions': ['bulk_user_operations'],
|
||||
'affects_data': ['users'],
|
||||
'risk_level': 'high'
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="CLEANUP",
|
||||
label="Cleanup",
|
||||
description="System cleanup and maintenance operations",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'trash',
|
||||
'css_class': 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
'sort_order': 7,
|
||||
'estimated_duration_minutes': 25,
|
||||
'required_permissions': ['system_maintenance'],
|
||||
'affects_data': ['system'],
|
||||
'risk_level': 'low'
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="OTHER",
|
||||
label="Other",
|
||||
description="Other bulk operations not covered by specific types",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'dots-horizontal',
|
||||
'css_class': 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
'sort_order': 8,
|
||||
'estimated_duration_minutes': 30,
|
||||
'required_permissions': ['general_operations'],
|
||||
'affects_data': [],
|
||||
'risk_level': 'medium'
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# PhotoSubmission Choices (Shared with EditSubmission)
|
||||
# ============================================================================
|
||||
|
||||
# PhotoSubmission uses the same STATUS_CHOICES as EditSubmission
|
||||
PHOTO_SUBMISSION_STATUSES = EDIT_SUBMISSION_STATUSES
|
||||
|
||||
# ============================================================================
|
||||
# Choice Registration
|
||||
# ============================================================================
|
||||
|
||||
# Register all choice groups with the global registry
|
||||
register_choices("edit_submission_statuses", EDIT_SUBMISSION_STATUSES, "moderation", "Edit submission status options")
|
||||
register_choices("submission_types", SUBMISSION_TYPES, "moderation", "Submission type classifications")
|
||||
register_choices("moderation_report_statuses", MODERATION_REPORT_STATUSES, "moderation", "Moderation report status options")
|
||||
register_choices("priority_levels", PRIORITY_LEVELS, "moderation", "Priority level classifications")
|
||||
register_choices("report_types", REPORT_TYPES, "moderation", "Report type classifications")
|
||||
register_choices("moderation_queue_statuses", MODERATION_QUEUE_STATUSES, "moderation", "Moderation queue status options")
|
||||
register_choices("queue_item_types", QUEUE_ITEM_TYPES, "moderation", "Queue item type classifications")
|
||||
register_choices("moderation_action_types", MODERATION_ACTION_TYPES, "moderation", "Moderation action type classifications")
|
||||
register_choices("bulk_operation_statuses", BULK_OPERATION_STATUSES, "moderation", "Bulk operation status options")
|
||||
register_choices("bulk_operation_types", BULK_OPERATION_TYPES, "moderation", "Bulk operation type classifications")
|
||||
register_choices("photo_submission_statuses", PHOTO_SUBMISSION_STATUSES, "moderation", "Photo submission status options")
|
||||
24
apps/moderation/context_processors.py
Normal file
24
apps/moderation/context_processors.py
Normal file
@@ -0,0 +1,24 @@
|
||||
def moderation_access(request):
|
||||
"""Add moderation access check to template context"""
|
||||
context = {
|
||||
"has_moderation_access": False,
|
||||
"has_admin_access": False,
|
||||
"has_superuser_access": False,
|
||||
"user_role": None,
|
||||
}
|
||||
|
||||
if request.user.is_authenticated:
|
||||
context["user_role"] = request.user.role
|
||||
# Check both role-based and Django's built-in superuser status
|
||||
context["has_moderation_access"] = (
|
||||
request.user.role in ["MODERATOR", "ADMIN", "SUPERUSER"]
|
||||
or request.user.is_superuser
|
||||
)
|
||||
context["has_admin_access"] = (
|
||||
request.user.role in ["ADMIN", "SUPERUSER"] or request.user.is_superuser
|
||||
)
|
||||
context["has_superuser_access"] = (
|
||||
request.user.role == "SUPERUSER" or request.user.is_superuser
|
||||
)
|
||||
|
||||
return context
|
||||
442
apps/moderation/filters.py
Normal file
442
apps/moderation/filters.py
Normal file
@@ -0,0 +1,442 @@
|
||||
"""
|
||||
Moderation Filters
|
||||
|
||||
This module contains Django filter classes for the moderation system,
|
||||
providing comprehensive filtering capabilities for all moderation models.
|
||||
"""
|
||||
|
||||
import django_filters
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
from .models import (
|
||||
ModerationReport,
|
||||
ModerationQueue,
|
||||
ModerationAction,
|
||||
BulkOperation,
|
||||
)
|
||||
from apps.core.choices.registry import get_choices
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class ModerationReportFilter(django_filters.FilterSet):
|
||||
"""Filter for ModerationReport model."""
|
||||
|
||||
# Status filters
|
||||
status = django_filters.ChoiceFilter(
|
||||
choices=lambda: [(choice.value, choice.label) for choice in get_choices("moderation_report_statuses", "moderation")],
|
||||
help_text="Filter by report status"
|
||||
)
|
||||
|
||||
# Priority filters
|
||||
priority = django_filters.ChoiceFilter(
|
||||
choices=lambda: [(choice.value, choice.label) for choice in get_choices("priority_levels", "moderation")],
|
||||
help_text="Filter by report priority"
|
||||
)
|
||||
|
||||
# Report type filters
|
||||
report_type = django_filters.ChoiceFilter(
|
||||
choices=lambda: [(choice.value, choice.label) for choice in get_choices("report_types", "moderation")],
|
||||
help_text="Filter by report type"
|
||||
)
|
||||
|
||||
# User filters
|
||||
reported_by = django_filters.ModelChoiceFilter(
|
||||
queryset=User.objects.all(), help_text="Filter by user who made the report"
|
||||
)
|
||||
|
||||
assigned_moderator = django_filters.ModelChoiceFilter(
|
||||
queryset=User.objects.filter(role__in=["MODERATOR", "ADMIN", "SUPERUSER"]),
|
||||
help_text="Filter by assigned moderator",
|
||||
)
|
||||
|
||||
# Date filters
|
||||
created_after = django_filters.DateTimeFilter(
|
||||
field_name="created_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter reports created after this date",
|
||||
)
|
||||
|
||||
created_before = django_filters.DateTimeFilter(
|
||||
field_name="created_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter reports created before this date",
|
||||
)
|
||||
|
||||
resolved_after = django_filters.DateTimeFilter(
|
||||
field_name="resolved_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter reports resolved after this date",
|
||||
)
|
||||
|
||||
resolved_before = django_filters.DateTimeFilter(
|
||||
field_name="resolved_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter reports resolved before this date",
|
||||
)
|
||||
|
||||
# Content type filters
|
||||
content_type = django_filters.CharFilter(
|
||||
field_name="content_type__model",
|
||||
help_text="Filter by content type (e.g., 'park', 'ride', 'review')",
|
||||
)
|
||||
|
||||
# Special filters
|
||||
unassigned = django_filters.BooleanFilter(
|
||||
method="filter_unassigned", help_text="Filter for unassigned reports"
|
||||
)
|
||||
|
||||
overdue = django_filters.BooleanFilter(
|
||||
method="filter_overdue", help_text="Filter for overdue reports based on SLA"
|
||||
)
|
||||
|
||||
has_resolution = django_filters.BooleanFilter(
|
||||
method="filter_has_resolution",
|
||||
help_text="Filter reports with/without resolution",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ModerationReport
|
||||
fields = [
|
||||
"status",
|
||||
"priority",
|
||||
"report_type",
|
||||
"reported_by",
|
||||
"assigned_moderator",
|
||||
"content_type",
|
||||
"unassigned",
|
||||
"overdue",
|
||||
"has_resolution",
|
||||
]
|
||||
|
||||
def filter_unassigned(self, queryset, name, value):
|
||||
"""Filter for unassigned reports."""
|
||||
if value:
|
||||
return queryset.filter(assigned_moderator__isnull=True)
|
||||
return queryset.filter(assigned_moderator__isnull=False)
|
||||
|
||||
def filter_overdue(self, queryset, name, value):
|
||||
"""Filter for overdue reports based on SLA."""
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
now = timezone.now()
|
||||
sla_hours = {"URGENT": 2, "HIGH": 8, "MEDIUM": 24, "LOW": 72}
|
||||
|
||||
overdue_ids = []
|
||||
for report in queryset.filter(status__in=["PENDING", "UNDER_REVIEW"]):
|
||||
hours_since_created = (now - report.created_at).total_seconds() / 3600
|
||||
if report.priority in sla_hours:
|
||||
threshold = sla_hours[report.priority]
|
||||
else:
|
||||
raise ValueError(f"Unknown priority level: {report.priority}")
|
||||
if hours_since_created > threshold:
|
||||
overdue_ids.append(report.id)
|
||||
|
||||
return queryset.filter(id__in=overdue_ids)
|
||||
|
||||
def filter_has_resolution(self, queryset, name, value):
|
||||
"""Filter reports with/without resolution."""
|
||||
if value:
|
||||
return queryset.exclude(
|
||||
resolution_action__isnull=True, resolution_action=""
|
||||
)
|
||||
return queryset.filter(
|
||||
Q(resolution_action__isnull=True) | Q(resolution_action="")
|
||||
)
|
||||
|
||||
|
||||
class ModerationQueueFilter(django_filters.FilterSet):
|
||||
"""Filter for ModerationQueue model."""
|
||||
|
||||
# Status filters
|
||||
status = django_filters.ChoiceFilter(
|
||||
choices=lambda: [(choice.value, choice.label) for choice in get_choices("moderation_queue_statuses", "moderation")],
|
||||
help_text="Filter by queue item status"
|
||||
)
|
||||
|
||||
# Priority filters
|
||||
priority = django_filters.ChoiceFilter(
|
||||
choices=lambda: [(choice.value, choice.label) for choice in get_choices("priority_levels", "moderation")],
|
||||
help_text="Filter by queue item priority",
|
||||
)
|
||||
|
||||
# Item type filters
|
||||
item_type = django_filters.ChoiceFilter(
|
||||
choices=lambda: [(choice.value, choice.label) for choice in get_choices("queue_item_types", "moderation")],
|
||||
help_text="Filter by queue item type"
|
||||
)
|
||||
|
||||
# Assignment filters
|
||||
assigned_to = django_filters.ModelChoiceFilter(
|
||||
queryset=User.objects.filter(role__in=["MODERATOR", "ADMIN", "SUPERUSER"]),
|
||||
help_text="Filter by assigned moderator",
|
||||
)
|
||||
|
||||
unassigned = django_filters.BooleanFilter(
|
||||
method="filter_unassigned", help_text="Filter for unassigned queue items"
|
||||
)
|
||||
|
||||
# Date filters
|
||||
created_after = django_filters.DateTimeFilter(
|
||||
field_name="created_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter items created after this date",
|
||||
)
|
||||
|
||||
created_before = django_filters.DateTimeFilter(
|
||||
field_name="created_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter items created before this date",
|
||||
)
|
||||
|
||||
assigned_after = django_filters.DateTimeFilter(
|
||||
field_name="assigned_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter items assigned after this date",
|
||||
)
|
||||
|
||||
assigned_before = django_filters.DateTimeFilter(
|
||||
field_name="assigned_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter items assigned before this date",
|
||||
)
|
||||
|
||||
# Content type filters
|
||||
content_type = django_filters.CharFilter(
|
||||
field_name="content_type__model", help_text="Filter by content type"
|
||||
)
|
||||
|
||||
# Related report filters
|
||||
has_related_report = django_filters.BooleanFilter(
|
||||
method="filter_has_related_report",
|
||||
help_text="Filter items with/without related reports",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ModerationQueue
|
||||
fields = [
|
||||
"status",
|
||||
"priority",
|
||||
"item_type",
|
||||
"assigned_to",
|
||||
"unassigned",
|
||||
"content_type",
|
||||
"has_related_report",
|
||||
]
|
||||
|
||||
def filter_unassigned(self, queryset, name, value):
|
||||
"""Filter for unassigned queue items."""
|
||||
if value:
|
||||
return queryset.filter(assigned_to__isnull=True)
|
||||
return queryset.filter(assigned_to__isnull=False)
|
||||
|
||||
def filter_has_related_report(self, queryset, name, value):
|
||||
"""Filter items with/without related reports."""
|
||||
if value:
|
||||
return queryset.filter(related_report__isnull=False)
|
||||
return queryset.filter(related_report__isnull=True)
|
||||
|
||||
|
||||
class ModerationActionFilter(django_filters.FilterSet):
|
||||
"""Filter for ModerationAction model."""
|
||||
|
||||
# Action type filters
|
||||
action_type = django_filters.ChoiceFilter(
|
||||
choices=lambda: [(choice.value, choice.label) for choice in get_choices("moderation_action_types", "moderation")],
|
||||
help_text="Filter by action type"
|
||||
)
|
||||
|
||||
# User filters
|
||||
moderator = django_filters.ModelChoiceFilter(
|
||||
queryset=User.objects.filter(role__in=["MODERATOR", "ADMIN", "SUPERUSER"]),
|
||||
help_text="Filter by moderator who took the action",
|
||||
)
|
||||
|
||||
target_user = django_filters.ModelChoiceFilter(
|
||||
queryset=User.objects.all(), help_text="Filter by target user"
|
||||
)
|
||||
|
||||
# Status filters
|
||||
is_active = django_filters.BooleanFilter(help_text="Filter by active status")
|
||||
|
||||
# Date filters
|
||||
created_after = django_filters.DateTimeFilter(
|
||||
field_name="created_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter actions created after this date",
|
||||
)
|
||||
|
||||
created_before = django_filters.DateTimeFilter(
|
||||
field_name="created_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter actions created before this date",
|
||||
)
|
||||
|
||||
expires_after = django_filters.DateTimeFilter(
|
||||
field_name="expires_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter actions expiring after this date",
|
||||
)
|
||||
|
||||
expires_before = django_filters.DateTimeFilter(
|
||||
field_name="expires_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter actions expiring before this date",
|
||||
)
|
||||
|
||||
# Special filters
|
||||
expired = django_filters.BooleanFilter(
|
||||
method="filter_expired", help_text="Filter for expired actions"
|
||||
)
|
||||
|
||||
expiring_soon = django_filters.BooleanFilter(
|
||||
method="filter_expiring_soon",
|
||||
help_text="Filter for actions expiring within 24 hours",
|
||||
)
|
||||
|
||||
has_related_report = django_filters.BooleanFilter(
|
||||
method="filter_has_related_report",
|
||||
help_text="Filter actions with/without related reports",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ModerationAction
|
||||
fields = [
|
||||
"action_type",
|
||||
"moderator",
|
||||
"target_user",
|
||||
"is_active",
|
||||
"expired",
|
||||
"expiring_soon",
|
||||
"has_related_report",
|
||||
]
|
||||
|
||||
def filter_expired(self, queryset, name, value):
|
||||
"""Filter for expired actions."""
|
||||
now = timezone.now()
|
||||
if value:
|
||||
return queryset.filter(expires_at__lte=now)
|
||||
return queryset.filter(Q(expires_at__gt=now) | Q(expires_at__isnull=True))
|
||||
|
||||
def filter_expiring_soon(self, queryset, name, value):
|
||||
"""Filter for actions expiring within 24 hours."""
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
now = timezone.now()
|
||||
soon = now + timedelta(hours=24)
|
||||
return queryset.filter(expires_at__gt=now, expires_at__lte=soon, is_active=True)
|
||||
|
||||
def filter_has_related_report(self, queryset, name, value):
|
||||
"""Filter actions with/without related reports."""
|
||||
if value:
|
||||
return queryset.filter(related_report__isnull=False)
|
||||
return queryset.filter(related_report__isnull=True)
|
||||
|
||||
|
||||
class BulkOperationFilter(django_filters.FilterSet):
|
||||
"""Filter for BulkOperation model."""
|
||||
|
||||
# Status filters
|
||||
status = django_filters.ChoiceFilter(
|
||||
choices=lambda: [(choice.value, choice.label) for choice in get_choices("bulk_operation_statuses", "moderation")],
|
||||
help_text="Filter by operation status"
|
||||
)
|
||||
|
||||
# Operation type filters
|
||||
operation_type = django_filters.ChoiceFilter(
|
||||
choices=lambda: [(choice.value, choice.label) for choice in get_choices("bulk_operation_types", "moderation")],
|
||||
help_text="Filter by operation type",
|
||||
)
|
||||
|
||||
# Priority filters
|
||||
priority = django_filters.ChoiceFilter(
|
||||
choices=lambda: [(choice.value, choice.label) for choice in get_choices("priority_levels", "moderation")],
|
||||
help_text="Filter by operation priority"
|
||||
)
|
||||
|
||||
# User filters
|
||||
created_by = django_filters.ModelChoiceFilter(
|
||||
queryset=User.objects.filter(role__in=["ADMIN", "SUPERUSER"]),
|
||||
help_text="Filter by user who created the operation",
|
||||
)
|
||||
|
||||
# Date filters
|
||||
created_after = django_filters.DateTimeFilter(
|
||||
field_name="created_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter operations created after this date",
|
||||
)
|
||||
|
||||
created_before = django_filters.DateTimeFilter(
|
||||
field_name="created_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter operations created before this date",
|
||||
)
|
||||
|
||||
started_after = django_filters.DateTimeFilter(
|
||||
field_name="started_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter operations started after this date",
|
||||
)
|
||||
|
||||
started_before = django_filters.DateTimeFilter(
|
||||
field_name="started_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter operations started before this date",
|
||||
)
|
||||
|
||||
completed_after = django_filters.DateTimeFilter(
|
||||
field_name="completed_at",
|
||||
lookup_expr="gte",
|
||||
help_text="Filter operations completed after this date",
|
||||
)
|
||||
|
||||
completed_before = django_filters.DateTimeFilter(
|
||||
field_name="completed_at",
|
||||
lookup_expr="lte",
|
||||
help_text="Filter operations completed before this date",
|
||||
)
|
||||
|
||||
# Special filters
|
||||
can_cancel = django_filters.BooleanFilter(
|
||||
help_text="Filter by cancellation capability"
|
||||
)
|
||||
|
||||
has_failures = django_filters.BooleanFilter(
|
||||
method="filter_has_failures",
|
||||
help_text="Filter operations with/without failures",
|
||||
)
|
||||
|
||||
in_progress = django_filters.BooleanFilter(
|
||||
method="filter_in_progress",
|
||||
help_text="Filter for operations currently in progress",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = BulkOperation
|
||||
fields = [
|
||||
"status",
|
||||
"operation_type",
|
||||
"priority",
|
||||
"created_by",
|
||||
"can_cancel",
|
||||
"has_failures",
|
||||
"in_progress",
|
||||
]
|
||||
|
||||
def filter_has_failures(self, queryset, name, value):
|
||||
"""Filter operations with/without failures."""
|
||||
if value:
|
||||
return queryset.filter(failed_items__gt=0)
|
||||
return queryset.filter(failed_items=0)
|
||||
|
||||
def filter_in_progress(self, queryset, name, value):
|
||||
"""Filter for operations currently in progress."""
|
||||
if value:
|
||||
return queryset.filter(status__in=["PENDING", "RUNNING"])
|
||||
return queryset.exclude(status__in=["PENDING", "RUNNING"])
|
||||
0
apps/moderation/management/__init__.py
Normal file
0
apps/moderation/management/__init__.py
Normal file
0
apps/moderation/management/commands/__init__.py
Normal file
0
apps/moderation/management/commands/__init__.py
Normal file
261
apps/moderation/management/commands/seed_submissions.py
Normal file
261
apps/moderation/management/commands/seed_submissions.py
Normal file
@@ -0,0 +1,261 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from apps.moderation.models import EditSubmission, PhotoSubmission
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.models import Ride
|
||||
from datetime import date
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seeds test submissions for moderation dashboard"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
# Ensure we have a test user
|
||||
user, created = User.objects.get_or_create(
|
||||
username="test_user", email="test@example.com"
|
||||
)
|
||||
if created:
|
||||
user.set_password("testpass123")
|
||||
user.save()
|
||||
self.stdout.write(self.style.SUCCESS("Created test user"))
|
||||
|
||||
# Get content types
|
||||
park_ct = ContentType.objects.get_for_model(Park)
|
||||
ride_ct = ContentType.objects.get_for_model(Ride)
|
||||
|
||||
# Create test park for edit submissions
|
||||
test_park, created = Park.objects.get_or_create(
|
||||
name="Test Park",
|
||||
defaults={
|
||||
"description": "A test theme park located in Orlando, Florida",
|
||||
"status": "OPERATING",
|
||||
"operating_season": "Year-round",
|
||||
"size_acres": 100.50,
|
||||
"website": "https://testpark.example.com",
|
||||
},
|
||||
)
|
||||
|
||||
# Create test ride for edit submissions
|
||||
test_ride, created = Ride.objects.get_or_create(
|
||||
name="Test Coaster",
|
||||
park=test_park,
|
||||
defaults={
|
||||
"description": "A thrilling steel roller coaster with multiple inversions",
|
||||
"status": "OPERATING",
|
||||
"category": "RC",
|
||||
"capacity_per_hour": 1200,
|
||||
"ride_duration_seconds": 180,
|
||||
"min_height_in": 48,
|
||||
"opening_date": date(2020, 6, 15),
|
||||
},
|
||||
)
|
||||
|
||||
# Create EditSubmissions
|
||||
|
||||
# New park creation with detailed information
|
||||
EditSubmission.objects.create(
|
||||
user=user,
|
||||
content_type=park_ct,
|
||||
submission_type="CREATE",
|
||||
changes={
|
||||
"name": "Adventure World Orlando",
|
||||
"description": (
|
||||
"A brand new theme park coming to Orlando featuring five uniquely themed lands: "
|
||||
"Future Frontier, Ancient Mysteries, Ocean Depths, Sky Kingdom, and Fantasy Forest. "
|
||||
"The park will feature state-of-the-art attractions including 3 roller coasters, "
|
||||
"4 dark rides, and multiple family attractions in each themed area."
|
||||
),
|
||||
"status": "UNDER_CONSTRUCTION",
|
||||
"opening_date": "2024-06-01",
|
||||
"operating_season": "Year-round with extended hours during summer and holidays",
|
||||
"size_acres": 250.75,
|
||||
"website": "https://adventureworld.example.com",
|
||||
"location": {
|
||||
"street_address": "1234 Theme Park Way",
|
||||
"city": "Orlando",
|
||||
"state": "Florida",
|
||||
"country": "United States",
|
||||
"postal_code": "32819",
|
||||
"latitude": "28.538336",
|
||||
"longitude": "-81.379234",
|
||||
},
|
||||
},
|
||||
reason=(
|
||||
"Submitting new theme park details based on official press release and construction permits. "
|
||||
"The park has begun vertical construction and has announced its opening date."
|
||||
),
|
||||
source=(
|
||||
"Official press release: https://adventureworld.example.com/press/announcement\n"
|
||||
"Construction permits: Orange County Building Department #2023-12345"
|
||||
),
|
||||
status="PENDING",
|
||||
)
|
||||
|
||||
# Existing park edit with comprehensive updates
|
||||
EditSubmission.objects.create(
|
||||
user=user,
|
||||
content_type=park_ct,
|
||||
object_id=test_park.id,
|
||||
submission_type="EDIT",
|
||||
changes={
|
||||
"description": (
|
||||
"A world-class theme park featuring 12 uniquely themed areas and over 50 attractions. "
|
||||
'Recent expansion added the new "Cosmic Adventures" area with 2 roller coasters and '
|
||||
"3 family attractions. The park now offers enhanced dining options and night-time "
|
||||
'spectacular "Starlight Dreams".'
|
||||
),
|
||||
"status": "OPERATING",
|
||||
"website": "https://testpark.example.com",
|
||||
"size_acres": 120.25,
|
||||
"operating_season": (
|
||||
"Year-round with extended hours (9AM-11PM) during summer. "
|
||||
"Special events during Halloween and Christmas seasons."
|
||||
),
|
||||
"location": {
|
||||
"street_address": "5678 Park Boulevard",
|
||||
"city": "Orlando",
|
||||
"state": "Florida",
|
||||
"country": "United States",
|
||||
"postal_code": "32830",
|
||||
"latitude": "28.538336",
|
||||
"longitude": "-81.379234",
|
||||
},
|
||||
},
|
||||
reason=(
|
||||
"Updating park information to reflect recent expansion and operational changes. "
|
||||
"The new Cosmic Adventures area opened last month and operating hours have been extended."
|
||||
),
|
||||
source=(
|
||||
"Park press release: https://testpark.example.com/news/expansion\n"
|
||||
"Official park map: https://testpark.example.com/map\n"
|
||||
"Personal visit and photos from opening day of new area"
|
||||
),
|
||||
status="PENDING",
|
||||
)
|
||||
|
||||
# New ride creation with detailed specifications
|
||||
EditSubmission.objects.create(
|
||||
user=user,
|
||||
content_type=ride_ct,
|
||||
submission_type="CREATE",
|
||||
changes={
|
||||
"name": "Thunderbolt: The Ultimate Launch Coaster",
|
||||
"park": test_park.id,
|
||||
"description": (
|
||||
"A cutting-edge steel launch coaster featuring the world's tallest inversion (160 ft) "
|
||||
"and fastest launch acceleration (0-80 mph in 2 seconds). The ride features a unique "
|
||||
"triple launch system, 5 inversions including a zero-g roll and cobra roll, and a "
|
||||
"first-of-its-kind vertical helix element. Total track length is 4,500 feet with a "
|
||||
"maximum height of 375 feet."
|
||||
),
|
||||
"status": "UNDER_CONSTRUCTION",
|
||||
"category": "RC",
|
||||
"opening_date": "2024-07-01",
|
||||
"capacity_per_hour": 1400,
|
||||
"ride_duration_seconds": 210,
|
||||
"min_height_in": 52,
|
||||
"manufacturer": 1, # Assuming manufacturer ID
|
||||
"park_area": 1, # Assuming park area ID
|
||||
"stats": {
|
||||
"height_ft": 375,
|
||||
"length_ft": 4500,
|
||||
"speed_mph": 80,
|
||||
"inversions": 5,
|
||||
"propulsion_system": "LSM",
|
||||
"track_material": "STEEL",
|
||||
"roller_coaster_type": "SITDOWN",
|
||||
"trains_count": 3,
|
||||
"cars_per_train": 6,
|
||||
"seats_per_car": 4,
|
||||
},
|
||||
},
|
||||
reason=(
|
||||
"Submitting details for the new flagship roller coaster announced by the park. "
|
||||
"Construction has begun and track pieces are arriving on site."
|
||||
),
|
||||
source=(
|
||||
"Official announcement: https://testpark.example.com/thunderbolt\n"
|
||||
"Construction photos: https://coasterfan.com/thunderbolt-construction\n"
|
||||
"Manufacturer specifications sheet"
|
||||
),
|
||||
status="PENDING",
|
||||
)
|
||||
|
||||
# Existing ride edit with technical updates
|
||||
EditSubmission.objects.create(
|
||||
user=user,
|
||||
content_type=ride_ct,
|
||||
object_id=test_ride.id,
|
||||
submission_type="EDIT",
|
||||
changes={
|
||||
"description": (
|
||||
"A high-speed steel roller coaster featuring 4 inversions and a unique "
|
||||
"dual-loading station system. Recent upgrades include new magnetic braking "
|
||||
"system and enhanced on-board audio experience."
|
||||
),
|
||||
"status": "OPERATING",
|
||||
"capacity_per_hour": 1500, # Increased after station upgrades
|
||||
"ride_duration_seconds": 185,
|
||||
"min_height_in": 48,
|
||||
"max_height_in": 80,
|
||||
"stats": {
|
||||
"trains_count": 3,
|
||||
"cars_per_train": 8,
|
||||
"seats_per_car": 4,
|
||||
},
|
||||
},
|
||||
reason=(
|
||||
"Updating ride information to reflect recent upgrades including new braking system, "
|
||||
"audio system, and increased capacity due to improved loading efficiency."
|
||||
),
|
||||
source=(
|
||||
"Park operations manual\n"
|
||||
"Maintenance records\n"
|
||||
"Personal observation and timing of new ride cycle"
|
||||
),
|
||||
status="PENDING",
|
||||
)
|
||||
|
||||
# Create PhotoSubmissions with detailed captions
|
||||
|
||||
# Park photo submission
|
||||
image_data = b"GIF87a\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00ccc,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;"
|
||||
dummy_image = SimpleUploadedFile(
|
||||
"park_entrance.gif", image_data, content_type="image/gif"
|
||||
)
|
||||
|
||||
PhotoSubmission.objects.create(
|
||||
user=user,
|
||||
content_type=park_ct,
|
||||
object_id=test_park.id,
|
||||
photo=dummy_image,
|
||||
caption=(
|
||||
"Main entrance plaza of Test Park showing the newly installed digital display board "
|
||||
"and renovated ticketing area. Photo taken during morning park opening."
|
||||
),
|
||||
date_taken=date(2024, 1, 15),
|
||||
status="PENDING",
|
||||
)
|
||||
|
||||
# Ride photo submission
|
||||
dummy_image2 = SimpleUploadedFile(
|
||||
"coaster_track.gif", image_data, content_type="image/gif"
|
||||
)
|
||||
PhotoSubmission.objects.create(
|
||||
user=user,
|
||||
content_type=ride_ct,
|
||||
object_id=test_ride.id,
|
||||
photo=dummy_image2,
|
||||
caption=(
|
||||
"Test Coaster's first drop and loop element showing the new paint scheme. "
|
||||
"Photo taken from the guest pathway near Station Alpha."
|
||||
),
|
||||
date_taken=date(2024, 1, 20),
|
||||
status="PENDING",
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Successfully seeded test submissions"))
|
||||
314
apps/moderation/mixins.py
Normal file
314
apps/moderation/mixins.py
Normal file
@@ -0,0 +1,314 @@
|
||||
from typing import Any, Dict, Optional, Type, cast
|
||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.http import (
|
||||
JsonResponse,
|
||||
HttpResponseForbidden,
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
)
|
||||
from django.views.generic import DetailView
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
import json
|
||||
from .models import EditSubmission, PhotoSubmission, UserType
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class EditSubmissionMixin(DetailView):
|
||||
"""
|
||||
Mixin for handling edit submissions with proper moderation.
|
||||
"""
|
||||
|
||||
model: Optional[Type[models.Model]] = None
|
||||
|
||||
def handle_edit_submission(
|
||||
self,
|
||||
request: HttpRequest,
|
||||
changes: Dict[str, Any],
|
||||
reason: str = "",
|
||||
source: str = "",
|
||||
submission_type: str = "EDIT",
|
||||
) -> JsonResponse:
|
||||
"""
|
||||
Handle an edit submission based on user's role.
|
||||
|
||||
Args:
|
||||
request: The HTTP request
|
||||
changes: Dict of field changes {field_name: new_value}
|
||||
reason: Why this edit is needed
|
||||
source: Source of information (optional)
|
||||
submission_type: 'EDIT' or 'CREATE'
|
||||
|
||||
Returns:
|
||||
JsonResponse with status and message
|
||||
"""
|
||||
if not request.user.is_authenticated:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "You must be logged in to make edits.",
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
|
||||
if not self.model:
|
||||
raise ValueError("model attribute must be set")
|
||||
|
||||
content_type = ContentType.objects.get_for_model(self.model)
|
||||
|
||||
# Create the submission
|
||||
submission = EditSubmission(
|
||||
user=request.user,
|
||||
content_type=content_type,
|
||||
submission_type=submission_type,
|
||||
changes=changes,
|
||||
reason=reason,
|
||||
source=source,
|
||||
)
|
||||
|
||||
# For edits, set the object_id
|
||||
if submission_type == "EDIT":
|
||||
obj = self.get_object()
|
||||
submission.object_id = getattr(obj, "id", None)
|
||||
|
||||
# Auto-approve for moderators and above
|
||||
user_role = getattr(request.user, "role", None)
|
||||
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||
obj = submission.approve(cast(UserType, request.user))
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Changes saved successfully.",
|
||||
"auto_approved": True,
|
||||
"redirect_url": getattr(obj, "get_absolute_url", lambda: None)(),
|
||||
}
|
||||
)
|
||||
|
||||
# Submit for approval for regular users
|
||||
submission.save()
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Your changes have been submitted for approval.",
|
||||
"auto_approved": False,
|
||||
}
|
||||
)
|
||||
|
||||
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> JsonResponse:
|
||||
"""Handle POST requests for editing"""
|
||||
if not request.user.is_authenticated:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "You must be logged in to make edits.",
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
changes = data.get("changes", {})
|
||||
reason = data.get("reason", "")
|
||||
source = data.get("source", "")
|
||||
submission_type = data.get("submission_type", "EDIT")
|
||||
|
||||
if not changes:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "No changes provided."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
user_role = getattr(request.user, "role", None)
|
||||
if not reason and user_role == "USER":
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Please provide a reason for your changes.",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
return self.handle_edit_submission(
|
||||
request, changes, reason, source, submission_type
|
||||
)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "Invalid JSON data."},
|
||||
status=400,
|
||||
)
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
class PhotoSubmissionMixin(DetailView):
|
||||
"""
|
||||
Mixin for handling photo submissions with proper moderation.
|
||||
"""
|
||||
|
||||
model: Optional[Type[models.Model]] = None
|
||||
|
||||
def handle_photo_submission(self, request: HttpRequest) -> JsonResponse:
|
||||
"""Handle a photo submission based on user's role"""
|
||||
if not request.user.is_authenticated:
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "You must be logged in to upload photos.",
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
|
||||
if not self.model:
|
||||
raise ValueError("model attribute must be set")
|
||||
|
||||
try:
|
||||
obj = self.get_object()
|
||||
except (AttributeError, self.model.DoesNotExist):
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "Invalid object."}, status=400
|
||||
)
|
||||
|
||||
if not request.FILES.get("photo"):
|
||||
return JsonResponse(
|
||||
{"status": "error", "message": "No photo provided."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
content_type = ContentType.objects.get_for_model(obj)
|
||||
|
||||
submission = PhotoSubmission(
|
||||
user=request.user,
|
||||
content_type=content_type,
|
||||
object_id=getattr(obj, "id", None),
|
||||
photo=request.FILES["photo"],
|
||||
caption=request.POST.get("caption", ""),
|
||||
date_taken=request.POST.get("date_taken"),
|
||||
)
|
||||
|
||||
# Auto-approve for moderators and above
|
||||
user_role = getattr(request.user, "role", None)
|
||||
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||
submission.auto_approve()
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Photo uploaded successfully.",
|
||||
"auto_approved": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Submit for approval for regular users
|
||||
submission.save()
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Your photo has been submitted for approval.",
|
||||
"auto_approved": False,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ModeratorRequiredMixin(UserPassesTestMixin):
|
||||
"""Require moderator or higher role for access"""
|
||||
|
||||
request: Optional[HttpRequest] = None
|
||||
|
||||
def test_func(self) -> bool:
|
||||
if not self.request:
|
||||
return False
|
||||
user_role = getattr(self.request.user, "role", None)
|
||||
return self.request.user.is_authenticated and user_role in [
|
||||
"MODERATOR",
|
||||
"ADMIN",
|
||||
"SUPERUSER",
|
||||
]
|
||||
|
||||
def handle_no_permission(self) -> HttpResponse:
|
||||
if not self.request or not self.request.user.is_authenticated:
|
||||
return super().handle_no_permission()
|
||||
return HttpResponseForbidden("You must be a moderator to access this page.")
|
||||
|
||||
|
||||
class AdminRequiredMixin(UserPassesTestMixin):
|
||||
"""Require admin or superuser role for access"""
|
||||
|
||||
request: Optional[HttpRequest] = None
|
||||
|
||||
def test_func(self) -> bool:
|
||||
if not self.request:
|
||||
return False
|
||||
user_role = getattr(self.request.user, "role", None)
|
||||
return self.request.user.is_authenticated and user_role in [
|
||||
"ADMIN",
|
||||
"SUPERUSER",
|
||||
]
|
||||
|
||||
def handle_no_permission(self) -> HttpResponse:
|
||||
if not self.request or not self.request.user.is_authenticated:
|
||||
return super().handle_no_permission()
|
||||
return HttpResponseForbidden("You must be an admin to access this page.")
|
||||
|
||||
|
||||
class InlineEditMixin:
|
||||
"""Add inline editing context to views"""
|
||||
|
||||
request: Optional[HttpRequest] = None
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs) # type: ignore
|
||||
if self.request and self.request.user.is_authenticated:
|
||||
context["can_edit"] = True
|
||||
user_role = getattr(self.request.user, "role", None)
|
||||
context["can_auto_approve"] = user_role in [
|
||||
"MODERATOR",
|
||||
"ADMIN",
|
||||
"SUPERUSER",
|
||||
]
|
||||
|
||||
if isinstance(self, DetailView):
|
||||
obj = self.get_object() # type: ignore
|
||||
context["pending_edits"] = (
|
||||
EditSubmission.objects.filter(
|
||||
content_type=ContentType.objects.get_for_model(obj.__class__),
|
||||
object_id=getattr(obj, "id", None),
|
||||
status="NEW",
|
||||
)
|
||||
.select_related("user")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class HistoryMixin:
|
||||
"""Add edit history context to views"""
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs) # type: ignore
|
||||
|
||||
# Only add history context for DetailViews
|
||||
if isinstance(self, DetailView):
|
||||
obj = self.get_object() # type: ignore
|
||||
|
||||
# Get historical records ordered by date if available
|
||||
try:
|
||||
# Use pghistory's get_history method
|
||||
context["history"] = obj.get_history()
|
||||
except (AttributeError, TypeError):
|
||||
context["history"] = []
|
||||
|
||||
# Get related edit submissions
|
||||
content_type = ContentType.objects.get_for_model(obj.__class__)
|
||||
context["edit_submissions"] = (
|
||||
EditSubmission.objects.filter(
|
||||
content_type=content_type,
|
||||
object_id=getattr(obj, "id", None),
|
||||
)
|
||||
.exclude(status="NEW")
|
||||
.select_related("user", "handled_by")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
|
||||
return context
|
||||
692
apps/moderation/models.py
Normal file
692
apps/moderation/models.py
Normal file
@@ -0,0 +1,692 @@
|
||||
"""
|
||||
Moderation Models
|
||||
|
||||
This module contains models for the ThrillWiki moderation system, including:
|
||||
- EditSubmission: Original content submission and approval workflow
|
||||
- ModerationReport: User reports for content moderation
|
||||
- ModerationQueue: Workflow management for moderation tasks
|
||||
- ModerationAction: Actions taken against users/content
|
||||
- BulkOperation: Administrative bulk operations
|
||||
|
||||
All models use pghistory for change tracking and TrackedModel base class.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional, Union
|
||||
from django.db import models
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.core.exceptions import ObjectDoesNotExist, FieldDoesNotExist
|
||||
from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from datetime import timedelta
|
||||
import pghistory
|
||||
from apps.core.history import TrackedModel
|
||||
from apps.core.choices.fields import RichChoiceField
|
||||
|
||||
UserType = Union[AbstractBaseUser, AnonymousUser]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Original EditSubmission Model (Preserved)
|
||||
# ============================================================================
|
||||
|
||||
@pghistory.track() # Track all changes by default
|
||||
class EditSubmission(TrackedModel):
|
||||
|
||||
# Who submitted the edit
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="edit_submissions",
|
||||
)
|
||||
|
||||
# What is being edited (Park or Ride)
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
object_id = models.PositiveIntegerField(
|
||||
null=True, blank=True
|
||||
) # Null for new objects
|
||||
content_object = GenericForeignKey("content_type", "object_id")
|
||||
|
||||
# Type of submission
|
||||
submission_type = RichChoiceField(
|
||||
choice_group="submission_types",
|
||||
domain="moderation",
|
||||
max_length=10,
|
||||
default="EDIT"
|
||||
)
|
||||
|
||||
# The actual changes/data
|
||||
changes = models.JSONField(
|
||||
help_text="JSON representation of the changes or new object data"
|
||||
)
|
||||
|
||||
# Moderator's edited version of changes before approval
|
||||
moderator_changes = models.JSONField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Moderator's edited version of the changes before approval",
|
||||
)
|
||||
|
||||
# Metadata
|
||||
reason = models.TextField(help_text="Why this edit/addition is needed")
|
||||
source = models.TextField(
|
||||
blank=True, help_text="Source of information (if applicable)"
|
||||
)
|
||||
status = RichChoiceField(
|
||||
choice_group="edit_submission_statuses",
|
||||
domain="moderation",
|
||||
max_length=20,
|
||||
default="PENDING"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# Review details
|
||||
handled_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="handled_submissions",
|
||||
)
|
||||
handled_at = models.DateTimeField(null=True, blank=True)
|
||||
notes = models.TextField(
|
||||
blank=True, help_text="Notes from the moderator about this submission"
|
||||
)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["content_type", "object_id"]),
|
||||
models.Index(fields=["status"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
action = "creation" if self.submission_type == "CREATE" else "edit"
|
||||
if model_class := self.content_type.model_class():
|
||||
target = self.content_object or model_class.__name__
|
||||
else:
|
||||
target = "Unknown"
|
||||
return f"{action} by {self.user.username} on {target}"
|
||||
|
||||
def _resolve_foreign_keys(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Convert foreign key IDs to model instances"""
|
||||
if not (model_class := self.content_type.model_class()):
|
||||
raise ValueError("Could not resolve model class")
|
||||
|
||||
resolved_data = data.copy()
|
||||
|
||||
for field_name, value in data.items():
|
||||
try:
|
||||
field = model_class._meta.get_field(field_name)
|
||||
if isinstance(field, models.ForeignKey) and value is not None:
|
||||
try:
|
||||
related_obj = field.related_model.objects.get(pk=value) # type: ignore
|
||||
resolved_data[field_name] = related_obj
|
||||
except ObjectDoesNotExist:
|
||||
raise ValueError(
|
||||
f"Related object {field.related_model.__name__} with pk={value} does not exist" # type: ignore
|
||||
)
|
||||
except FieldDoesNotExist:
|
||||
# Field doesn't exist on model, skip it
|
||||
continue
|
||||
|
||||
return resolved_data
|
||||
|
||||
def _get_final_changes(self) -> Dict[str, Any]:
|
||||
"""Get the final changes to apply (moderator changes if available, otherwise original changes)"""
|
||||
return self.moderator_changes or self.changes
|
||||
|
||||
def approve(self, moderator: UserType) -> Optional[models.Model]:
|
||||
"""
|
||||
Approve this submission and apply the changes.
|
||||
|
||||
Args:
|
||||
moderator: The user approving the submission
|
||||
|
||||
Returns:
|
||||
The created or updated model instance
|
||||
|
||||
Raises:
|
||||
ValueError: If submission cannot be approved
|
||||
ValidationError: If the data is invalid
|
||||
"""
|
||||
if self.status != "PENDING":
|
||||
raise ValueError(f"Cannot approve submission with status {self.status}")
|
||||
|
||||
model_class = self.content_type.model_class()
|
||||
if not model_class:
|
||||
raise ValueError("Could not resolve model class")
|
||||
|
||||
final_changes = self._get_final_changes()
|
||||
resolved_changes = self._resolve_foreign_keys(final_changes)
|
||||
|
||||
try:
|
||||
if self.submission_type == "CREATE":
|
||||
# Create new object
|
||||
obj = model_class(**resolved_changes)
|
||||
obj.full_clean()
|
||||
obj.save()
|
||||
else:
|
||||
# Update existing object
|
||||
if not self.content_object:
|
||||
raise ValueError("Cannot update: content object not found")
|
||||
|
||||
obj = self.content_object
|
||||
for field_name, value in resolved_changes.items():
|
||||
if hasattr(obj, field_name):
|
||||
setattr(obj, field_name, value)
|
||||
|
||||
obj.full_clean()
|
||||
obj.save()
|
||||
|
||||
# Mark submission as approved
|
||||
self.status = "APPROVED"
|
||||
self.handled_by = moderator
|
||||
self.handled_at = timezone.now()
|
||||
self.save()
|
||||
|
||||
return obj
|
||||
|
||||
except Exception as e:
|
||||
# Mark as rejected on any error
|
||||
self.status = "REJECTED"
|
||||
self.handled_by = moderator
|
||||
self.handled_at = timezone.now()
|
||||
self.notes = f"Approval failed: {str(e)}"
|
||||
self.save()
|
||||
raise
|
||||
|
||||
def reject(self, moderator: UserType, reason: str) -> None:
|
||||
"""
|
||||
Reject this submission.
|
||||
|
||||
Args:
|
||||
moderator: The user rejecting the submission
|
||||
reason: Reason for rejection
|
||||
"""
|
||||
if self.status != "PENDING":
|
||||
raise ValueError(f"Cannot reject submission with status {self.status}")
|
||||
|
||||
self.status = "REJECTED"
|
||||
self.handled_by = moderator
|
||||
self.handled_at = timezone.now()
|
||||
self.notes = f"Rejected: {reason}"
|
||||
self.save()
|
||||
|
||||
def escalate(self, moderator: UserType, reason: str) -> None:
|
||||
"""
|
||||
Escalate this submission for higher-level review.
|
||||
|
||||
Args:
|
||||
moderator: The user escalating the submission
|
||||
reason: Reason for escalation
|
||||
"""
|
||||
if self.status != "PENDING":
|
||||
raise ValueError(f"Cannot escalate submission with status {self.status}")
|
||||
|
||||
self.status = "ESCALATED"
|
||||
self.handled_by = moderator
|
||||
self.handled_at = timezone.now()
|
||||
self.notes = f"Escalated: {reason}"
|
||||
self.save()
|
||||
|
||||
@property
|
||||
def submitted_by(self):
|
||||
"""Alias for user field to maintain compatibility"""
|
||||
return self.user
|
||||
|
||||
@property
|
||||
def submitted_at(self):
|
||||
"""Alias for created_at field to maintain compatibility"""
|
||||
return self.created_at
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# New Moderation System Models
|
||||
# ============================================================================
|
||||
|
||||
@pghistory.track()
|
||||
class ModerationReport(TrackedModel):
|
||||
"""
|
||||
Model for tracking user reports about content, users, or behavior.
|
||||
|
||||
This handles the initial reporting phase where users flag content
|
||||
or behavior that needs moderator attention.
|
||||
"""
|
||||
|
||||
# Report details
|
||||
report_type = RichChoiceField(
|
||||
choice_group="report_types",
|
||||
domain="moderation",
|
||||
max_length=50
|
||||
)
|
||||
status = RichChoiceField(
|
||||
choice_group="moderation_report_statuses",
|
||||
domain="moderation",
|
||||
max_length=20,
|
||||
default='PENDING'
|
||||
)
|
||||
priority = RichChoiceField(
|
||||
choice_group="priority_levels",
|
||||
domain="moderation",
|
||||
max_length=10,
|
||||
default='MEDIUM'
|
||||
)
|
||||
|
||||
# What is being reported
|
||||
reported_entity_type = models.CharField(
|
||||
max_length=50, help_text="Type of entity being reported (park, ride, user, etc.)")
|
||||
reported_entity_id = models.PositiveIntegerField(
|
||||
help_text="ID of the entity being reported")
|
||||
content_type = models.ForeignKey(
|
||||
ContentType, on_delete=models.CASCADE, null=True, blank=True)
|
||||
|
||||
# Report content
|
||||
reason = models.CharField(max_length=200, help_text="Brief reason for the report")
|
||||
description = models.TextField(help_text="Detailed description of the issue")
|
||||
evidence_urls = models.JSONField(
|
||||
default=list, blank=True, help_text="URLs to evidence (screenshots, etc.)")
|
||||
|
||||
# Users involved
|
||||
reported_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='moderation_reports_made'
|
||||
)
|
||||
assigned_moderator = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='assigned_moderation_reports'
|
||||
)
|
||||
|
||||
# Resolution
|
||||
resolution_action = models.CharField(
|
||||
max_length=100, blank=True, help_text="Action taken to resolve")
|
||||
resolution_notes = models.TextField(
|
||||
blank=True, help_text="Notes about the resolution")
|
||||
resolved_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['status', 'priority']),
|
||||
models.Index(fields=['reported_by']),
|
||||
models.Index(fields=['assigned_moderator']),
|
||||
models.Index(fields=['created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_report_type_display()} report by {self.reported_by.username}" # type: ignore
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class ModerationQueue(TrackedModel):
|
||||
"""
|
||||
Model for managing moderation workflow and task assignment.
|
||||
|
||||
This represents items in the moderation queue that need attention,
|
||||
separate from the initial reports.
|
||||
"""
|
||||
|
||||
# Queue item details
|
||||
item_type = RichChoiceField(
|
||||
choice_group="queue_item_types",
|
||||
domain="moderation",
|
||||
max_length=50
|
||||
)
|
||||
status = RichChoiceField(
|
||||
choice_group="moderation_queue_statuses",
|
||||
domain="moderation",
|
||||
max_length=20,
|
||||
default='PENDING'
|
||||
)
|
||||
priority = RichChoiceField(
|
||||
choice_group="priority_levels",
|
||||
domain="moderation",
|
||||
max_length=10,
|
||||
default='MEDIUM'
|
||||
)
|
||||
|
||||
title = models.CharField(max_length=200, help_text="Brief title for the queue item")
|
||||
description = models.TextField(
|
||||
help_text="Detailed description of what needs to be done")
|
||||
|
||||
# What entity this relates to
|
||||
entity_type = models.CharField(
|
||||
max_length=50, blank=True, help_text="Type of entity (park, ride, user, etc.)")
|
||||
entity_id = models.PositiveIntegerField(
|
||||
null=True, blank=True, help_text="ID of the related entity")
|
||||
entity_preview = models.JSONField(
|
||||
default=dict, blank=True, help_text="Preview data for the entity")
|
||||
content_type = models.ForeignKey(
|
||||
ContentType, on_delete=models.CASCADE, null=True, blank=True)
|
||||
|
||||
# Assignment and timing
|
||||
assigned_to = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='assigned_queue_items'
|
||||
)
|
||||
assigned_at = models.DateTimeField(null=True, blank=True)
|
||||
estimated_review_time = models.PositiveIntegerField(
|
||||
default=30, help_text="Estimated time in minutes")
|
||||
|
||||
# Metadata
|
||||
flagged_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='flagged_queue_items'
|
||||
)
|
||||
tags = models.JSONField(default=list, blank=True,
|
||||
help_text="Tags for categorization")
|
||||
|
||||
# Related objects
|
||||
related_report = models.ForeignKey(
|
||||
ModerationReport,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='queue_items'
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ['priority', 'created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['status', 'priority']),
|
||||
models.Index(fields=['assigned_to']),
|
||||
models.Index(fields=['created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_item_type_display()}: {self.title}" # type: ignore
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class ModerationAction(TrackedModel):
|
||||
"""
|
||||
Model for tracking actions taken against users or content.
|
||||
|
||||
This records what actions moderators have taken, including
|
||||
warnings, suspensions, content removal, etc.
|
||||
"""
|
||||
|
||||
# Action details
|
||||
action_type = RichChoiceField(
|
||||
choice_group="moderation_action_types",
|
||||
domain="moderation",
|
||||
max_length=50
|
||||
)
|
||||
reason = models.CharField(max_length=200, help_text="Brief reason for the action")
|
||||
details = models.TextField(help_text="Detailed explanation of the action")
|
||||
|
||||
# Duration (for temporary actions)
|
||||
duration_hours = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Duration in hours for temporary actions"
|
||||
)
|
||||
expires_at = models.DateTimeField(
|
||||
null=True, blank=True, help_text="When this action expires")
|
||||
is_active = models.BooleanField(
|
||||
default=True, help_text="Whether this action is currently active")
|
||||
|
||||
# Users involved
|
||||
moderator = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='moderation_actions_taken'
|
||||
)
|
||||
target_user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='moderation_actions_received'
|
||||
)
|
||||
|
||||
# Related objects
|
||||
related_report = models.ForeignKey(
|
||||
ModerationReport,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='actions_taken'
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['target_user', 'is_active']),
|
||||
models.Index(fields=['moderator']),
|
||||
models.Index(fields=['expires_at']),
|
||||
models.Index(fields=['created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_action_type_display()} against {self.target_user.username} by {self.moderator.username}" # type: ignore
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Set expiration time if duration is provided
|
||||
if self.duration_hours and not self.expires_at:
|
||||
self.expires_at = timezone.now() + timedelta(hours=self.duration_hours)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class BulkOperation(TrackedModel):
|
||||
"""
|
||||
Model for tracking bulk administrative operations.
|
||||
|
||||
This handles large-scale operations like bulk updates,
|
||||
imports, exports, or mass moderation actions.
|
||||
"""
|
||||
|
||||
# Operation details
|
||||
operation_type = RichChoiceField(
|
||||
choice_group="bulk_operation_types",
|
||||
domain="moderation",
|
||||
max_length=50
|
||||
)
|
||||
status = RichChoiceField(
|
||||
choice_group="bulk_operation_statuses",
|
||||
domain="moderation",
|
||||
max_length=20,
|
||||
default='PENDING'
|
||||
)
|
||||
priority = RichChoiceField(
|
||||
choice_group="priority_levels",
|
||||
domain="moderation",
|
||||
max_length=10,
|
||||
default='MEDIUM'
|
||||
)
|
||||
description = models.TextField(help_text="Description of what this operation does")
|
||||
|
||||
# Operation parameters and results
|
||||
parameters = models.JSONField(
|
||||
default=dict, help_text="Parameters for the operation")
|
||||
results = models.JSONField(default=dict, blank=True,
|
||||
help_text="Results and output from the operation")
|
||||
|
||||
# Progress tracking
|
||||
total_items = models.PositiveIntegerField(
|
||||
default=0, help_text="Total number of items to process")
|
||||
processed_items = models.PositiveIntegerField(
|
||||
default=0, help_text="Number of items processed")
|
||||
failed_items = models.PositiveIntegerField(
|
||||
default=0, help_text="Number of items that failed")
|
||||
|
||||
# Timing
|
||||
estimated_duration_minutes = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Estimated duration in minutes"
|
||||
)
|
||||
schedule_for = models.DateTimeField(
|
||||
null=True, blank=True, help_text="When to run this operation")
|
||||
|
||||
# Control
|
||||
can_cancel = models.BooleanField(
|
||||
default=True, help_text="Whether this operation can be cancelled")
|
||||
|
||||
# User who created the operation
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='bulk_operations_created'
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
started_at = models.DateTimeField(null=True, blank=True)
|
||||
completed_at = models.DateTimeField(null=True, blank=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['status', 'priority']),
|
||||
models.Index(fields=['created_by']),
|
||||
models.Index(fields=['schedule_for']),
|
||||
models.Index(fields=['created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_operation_type_display()}: {self.description[:50]}" # type: ignore
|
||||
|
||||
@property
|
||||
def progress_percentage(self):
|
||||
"""Calculate progress percentage."""
|
||||
if self.total_items == 0:
|
||||
return 0.0
|
||||
return round((self.processed_items / self.total_items) * 100, 2)
|
||||
|
||||
|
||||
@pghistory.track() # Track all changes by default
|
||||
class PhotoSubmission(TrackedModel):
|
||||
|
||||
# Who submitted the photo
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="photo_submissions",
|
||||
)
|
||||
|
||||
# What the photo is for (Park or Ride)
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
object_id = models.PositiveIntegerField()
|
||||
content_object = GenericForeignKey("content_type", "object_id")
|
||||
|
||||
# The photo itself
|
||||
photo = models.ForeignKey(
|
||||
'django_cloudflareimages_toolkit.CloudflareImage',
|
||||
on_delete=models.CASCADE,
|
||||
help_text="Photo submission stored on Cloudflare Images"
|
||||
)
|
||||
caption = models.CharField(max_length=255, blank=True)
|
||||
date_taken = models.DateField(null=True, blank=True)
|
||||
|
||||
# Metadata
|
||||
status = RichChoiceField(
|
||||
choice_group="photo_submission_statuses",
|
||||
domain="moderation",
|
||||
max_length=20,
|
||||
default="PENDING"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# Review details
|
||||
handled_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="handled_photos",
|
||||
)
|
||||
handled_at = models.DateTimeField(null=True, blank=True)
|
||||
notes = models.TextField(
|
||||
blank=True,
|
||||
help_text="Notes from the moderator about this photo submission",
|
||||
)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["content_type", "object_id"]),
|
||||
models.Index(fields=["status"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Photo submission by {self.user.username} for {self.content_object}"
|
||||
|
||||
def approve(self, moderator: UserType, notes: str = "") -> None:
|
||||
"""Approve the photo submission"""
|
||||
from apps.parks.models.media import ParkPhoto
|
||||
from apps.rides.models.media import RidePhoto
|
||||
|
||||
self.status = "APPROVED"
|
||||
self.handled_by = moderator # type: ignore
|
||||
self.handled_at = timezone.now()
|
||||
self.notes = notes
|
||||
|
||||
# Determine the correct photo model based on the content type
|
||||
model_class = self.content_type.model_class()
|
||||
if model_class.__name__ == "Park":
|
||||
PhotoModel = ParkPhoto
|
||||
elif model_class.__name__ == "Ride":
|
||||
PhotoModel = RidePhoto
|
||||
else:
|
||||
raise ValueError(f"Unsupported content type: {model_class.__name__}")
|
||||
|
||||
# Create the approved photo
|
||||
PhotoModel.objects.create(
|
||||
uploaded_by=self.user,
|
||||
content_object=self.content_object,
|
||||
image=self.photo,
|
||||
caption=self.caption,
|
||||
is_approved=True,
|
||||
)
|
||||
|
||||
self.save()
|
||||
|
||||
def reject(self, moderator: UserType, notes: str) -> None:
|
||||
"""Reject the photo submission"""
|
||||
self.status = "REJECTED"
|
||||
self.handled_by = moderator # type: ignore
|
||||
self.handled_at = timezone.now()
|
||||
self.notes = notes
|
||||
self.save()
|
||||
|
||||
def auto_approve(self) -> None:
|
||||
"""Auto - approve submissions from moderators"""
|
||||
# Get user role safely
|
||||
user_role = getattr(self.user, "role", None)
|
||||
|
||||
# If user is moderator or above, auto-approve
|
||||
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||
self.approve(self.user)
|
||||
|
||||
def escalate(self, moderator: UserType, notes: str = "") -> None:
|
||||
"""Escalate the photo submission to admin"""
|
||||
self.status = "ESCALATED"
|
||||
self.handled_by = moderator # type: ignore
|
||||
self.handled_at = timezone.now()
|
||||
self.notes = notes
|
||||
self.save()
|
||||
318
apps/moderation/permissions.py
Normal file
318
apps/moderation/permissions.py
Normal file
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
Moderation Permissions
|
||||
|
||||
This module contains custom permission classes for the moderation system,
|
||||
providing role-based access control for moderation operations.
|
||||
"""
|
||||
|
||||
from rest_framework import permissions
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class IsModerator(permissions.BasePermission):
|
||||
"""
|
||||
Permission that only allows moderators to access the view.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated and has moderator role."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role == "MODERATOR"
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for moderators."""
|
||||
return self.has_permission(request, view)
|
||||
|
||||
|
||||
class IsModeratorOrAdmin(permissions.BasePermission):
|
||||
"""
|
||||
Permission that allows moderators, admins, and superusers to access the view.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated and has moderator, admin, or superuser role."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for moderators and admins."""
|
||||
return self.has_permission(request, view)
|
||||
|
||||
|
||||
class IsAdminOrSuperuser(permissions.BasePermission):
|
||||
"""
|
||||
Permission that only allows admins and superusers to access the view.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated and has admin or superuser role."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role in ["ADMIN", "SUPERUSER"]
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for admins and superusers."""
|
||||
return self.has_permission(request, view)
|
||||
|
||||
|
||||
class CanViewModerationData(permissions.BasePermission):
|
||||
"""
|
||||
Permission that allows users to view moderation data based on their role.
|
||||
|
||||
- Regular users can only view their own reports
|
||||
- Moderators and above can view all moderation data
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated."""
|
||||
return request.user and request.user.is_authenticated
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for viewing moderation data."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
|
||||
# Moderators and above can view all data
|
||||
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||
return True
|
||||
|
||||
# Regular users can only view their own reports
|
||||
if hasattr(obj, "reported_by"):
|
||||
return obj.reported_by == request.user
|
||||
|
||||
# For other objects, deny access to regular users
|
||||
return False
|
||||
|
||||
|
||||
class CanModerateContent(permissions.BasePermission):
|
||||
"""
|
||||
Permission that allows users to moderate content based on their role.
|
||||
|
||||
- Only moderators and above can moderate content
|
||||
- Includes additional checks for specific moderation actions
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated and has moderation privileges."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for content moderation."""
|
||||
if not self.has_permission(request, view):
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
|
||||
# Superusers can do everything
|
||||
if user_role == "SUPERUSER":
|
||||
return True
|
||||
|
||||
# Admins can moderate most content but may have some restrictions
|
||||
if user_role == "ADMIN":
|
||||
# Add any admin-specific restrictions here if needed
|
||||
return True
|
||||
|
||||
# Moderators have basic moderation permissions
|
||||
if user_role == "MODERATOR":
|
||||
# Add any moderator-specific restrictions here if needed
|
||||
# For example, moderators might not be able to moderate admin actions
|
||||
if hasattr(obj, "moderator") and obj.moderator:
|
||||
moderator_role = getattr(obj.moderator, "role", "USER")
|
||||
if moderator_role in ["ADMIN", "SUPERUSER"]:
|
||||
return False
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class CanAssignModerationTasks(permissions.BasePermission):
|
||||
"""
|
||||
Permission that allows users to assign moderation tasks to others.
|
||||
|
||||
- Moderators can assign tasks to themselves
|
||||
- Admins can assign tasks to moderators and themselves
|
||||
- Superusers can assign tasks to anyone
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated and has assignment privileges."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for task assignment."""
|
||||
if not self.has_permission(request, view):
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
|
||||
# Superusers can assign to anyone
|
||||
if user_role == "SUPERUSER":
|
||||
return True
|
||||
|
||||
# Admins can assign to moderators and themselves
|
||||
if user_role == "ADMIN":
|
||||
return True
|
||||
|
||||
# Moderators can only assign to themselves
|
||||
if user_role == "MODERATOR":
|
||||
# Check if they're trying to assign to themselves
|
||||
assignee_id = request.data.get("moderator_id") or request.data.get(
|
||||
"assigned_to"
|
||||
)
|
||||
if assignee_id:
|
||||
return str(assignee_id) == str(request.user.id)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class CanPerformBulkOperations(permissions.BasePermission):
|
||||
"""
|
||||
Permission that allows users to perform bulk operations.
|
||||
|
||||
- Only admins and superusers can perform bulk operations
|
||||
- Includes additional safety checks for destructive operations
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated and has bulk operation privileges."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role in ["ADMIN", "SUPERUSER"]
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for bulk operations."""
|
||||
if not self.has_permission(request, view):
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
|
||||
# Superusers can perform all bulk operations
|
||||
if user_role == "SUPERUSER":
|
||||
return True
|
||||
|
||||
# Admins can perform most bulk operations
|
||||
if user_role == "ADMIN":
|
||||
# Add any admin-specific restrictions for bulk operations here
|
||||
# For example, admins might not be able to perform certain destructive operations
|
||||
operation_type = getattr(obj, "operation_type", None)
|
||||
if operation_type in ["DELETE_USERS", "PURGE_DATA"]:
|
||||
return False # Only superusers can perform these operations
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class IsOwnerOrModerator(permissions.BasePermission):
|
||||
"""
|
||||
Permission that allows object owners or moderators to access the view.
|
||||
|
||||
- Users can access their own objects
|
||||
- Moderators and above can access any object
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated."""
|
||||
return request.user and request.user.is_authenticated
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for owners or moderators."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
|
||||
# Moderators and above can access any object
|
||||
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||
return True
|
||||
|
||||
# Check if user is the owner of the object
|
||||
if hasattr(obj, "reported_by"):
|
||||
return obj.reported_by == request.user
|
||||
elif hasattr(obj, "created_by"):
|
||||
return obj.created_by == request.user
|
||||
elif hasattr(obj, "user"):
|
||||
return obj.user == request.user
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class CanManageUserRestrictions(permissions.BasePermission):
|
||||
"""
|
||||
Permission that allows users to manage user restrictions and moderation actions.
|
||||
|
||||
- Moderators can create basic restrictions (warnings, temporary suspensions)
|
||||
- Admins can create more severe restrictions (longer suspensions, content removal)
|
||||
- Superusers can create any restriction including permanent bans
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated and has restriction management privileges."""
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
return user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions for managing user restrictions."""
|
||||
if not self.has_permission(request, view):
|
||||
return False
|
||||
|
||||
user_role = getattr(request.user, "role", "USER")
|
||||
|
||||
# Superusers can manage any restriction
|
||||
if user_role == "SUPERUSER":
|
||||
return True
|
||||
|
||||
# Get the action type from request data or object
|
||||
action_type = None
|
||||
if request.method in ["POST", "PUT", "PATCH"]:
|
||||
action_type = request.data.get("action_type")
|
||||
elif hasattr(obj, "action_type"):
|
||||
action_type = obj.action_type
|
||||
|
||||
# Admins can manage most restrictions
|
||||
if user_role == "ADMIN":
|
||||
# Admins cannot create permanent bans
|
||||
if action_type == "USER_BAN" and request.data.get("duration_hours") is None:
|
||||
return False
|
||||
return True
|
||||
|
||||
# Moderators can only manage basic restrictions
|
||||
if user_role == "MODERATOR":
|
||||
allowed_actions = ["WARNING", "CONTENT_REMOVAL", "USER_SUSPENSION"]
|
||||
if action_type not in allowed_actions:
|
||||
return False
|
||||
|
||||
# Moderators can only create temporary suspensions (max 7 days)
|
||||
if action_type == "USER_SUSPENSION":
|
||||
duration_hours = request.data.get("duration_hours", 0)
|
||||
if duration_hours > 168: # 7 days = 168 hours
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
278
apps/moderation/selectors.py
Normal file
278
apps/moderation/selectors.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
Selectors for moderation-related data retrieval.
|
||||
Following Django styleguide pattern for separating data access from business logic.
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
from django.db.models import QuerySet, Count
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from .models import EditSubmission
|
||||
|
||||
|
||||
def pending_submissions_for_review(
|
||||
*, content_type: Optional[str] = None, limit: int = 50
|
||||
) -> QuerySet[EditSubmission]:
|
||||
"""
|
||||
Get pending submissions that need moderation review.
|
||||
|
||||
Args:
|
||||
content_type: Optional filter by content type name
|
||||
limit: Maximum number of submissions to return
|
||||
|
||||
Returns:
|
||||
QuerySet of pending submissions ordered by submission date
|
||||
"""
|
||||
queryset = (
|
||||
EditSubmission.objects.filter(status="PENDING")
|
||||
.select_related("user", "content_type")
|
||||
.prefetch_related("content_object")
|
||||
)
|
||||
|
||||
if content_type:
|
||||
queryset = queryset.filter(content_type__model=content_type.lower())
|
||||
|
||||
return queryset.order_by("created_at")[:limit]
|
||||
|
||||
|
||||
def submissions_by_user(
|
||||
*, user_id: int, status: Optional[str] = None
|
||||
) -> QuerySet[EditSubmission]:
|
||||
"""
|
||||
Get submissions created by a specific user.
|
||||
|
||||
Args:
|
||||
user_id: ID of the user who submitted
|
||||
status: Optional filter by submission status
|
||||
|
||||
Returns:
|
||||
QuerySet of user's submissions
|
||||
"""
|
||||
queryset = EditSubmission.objects.filter(user_id=user_id).select_related(
|
||||
"content_type", "handled_by"
|
||||
)
|
||||
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
|
||||
return queryset.order_by("-created_at")
|
||||
|
||||
|
||||
def submissions_handled_by_moderator(
|
||||
*, moderator_id: int, days: int = 30
|
||||
) -> QuerySet[EditSubmission]:
|
||||
"""
|
||||
Get submissions handled by a specific moderator in the last N days.
|
||||
|
||||
Args:
|
||||
moderator_id: ID of the moderator
|
||||
days: Number of days to look back
|
||||
|
||||
Returns:
|
||||
QuerySet of submissions handled by the moderator
|
||||
"""
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
return (
|
||||
EditSubmission.objects.filter(
|
||||
handled_by_id=moderator_id, handled_at__gte=cutoff_date
|
||||
)
|
||||
.select_related("user", "content_type")
|
||||
.order_by("-handled_at")
|
||||
)
|
||||
|
||||
|
||||
def recent_submissions(*, days: int = 7) -> QuerySet[EditSubmission]:
|
||||
"""
|
||||
Get recent submissions from the last N days.
|
||||
|
||||
Args:
|
||||
days: Number of days to look back
|
||||
|
||||
Returns:
|
||||
QuerySet of recent submissions
|
||||
"""
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
return (
|
||||
EditSubmission.objects.filter(created_at__gte=cutoff_date)
|
||||
.select_related("user", "content_type", "handled_by")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
|
||||
|
||||
def submissions_by_content_type(
|
||||
*, content_type: str, status: Optional[str] = None
|
||||
) -> QuerySet[EditSubmission]:
|
||||
"""
|
||||
Get submissions for a specific content type.
|
||||
|
||||
Args:
|
||||
content_type: Name of the content type (e.g., 'park', 'ride')
|
||||
status: Optional filter by submission status
|
||||
|
||||
Returns:
|
||||
QuerySet of submissions for the content type
|
||||
"""
|
||||
queryset = EditSubmission.objects.filter(
|
||||
content_type__model=content_type.lower()
|
||||
).select_related("user", "handled_by")
|
||||
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
|
||||
return queryset.order_by("-created_at")
|
||||
|
||||
|
||||
def moderation_queue_summary() -> Dict[str, Any]:
|
||||
"""
|
||||
Get summary statistics for the moderation queue.
|
||||
|
||||
Returns:
|
||||
Dictionary containing queue statistics
|
||||
"""
|
||||
pending_count = EditSubmission.objects.filter(status="PENDING").count()
|
||||
approved_today = EditSubmission.objects.filter(
|
||||
status="APPROVED", handled_at__date=timezone.now().date()
|
||||
).count()
|
||||
rejected_today = EditSubmission.objects.filter(
|
||||
status="REJECTED", handled_at__date=timezone.now().date()
|
||||
).count()
|
||||
|
||||
# Submissions by content type
|
||||
submissions_by_type = (
|
||||
EditSubmission.objects.filter(status="PENDING")
|
||||
.values("content_type__model")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")
|
||||
)
|
||||
|
||||
return {
|
||||
"pending_count": pending_count,
|
||||
"approved_today": approved_today,
|
||||
"rejected_today": rejected_today,
|
||||
"submissions_by_type": list(submissions_by_type),
|
||||
}
|
||||
|
||||
|
||||
def moderation_statistics_summary(
|
||||
*, days: int = 30, moderator: Optional[User] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get comprehensive moderation statistics for a time period.
|
||||
|
||||
Args:
|
||||
days: Number of days to analyze
|
||||
moderator: Optional filter by specific moderator
|
||||
|
||||
Returns:
|
||||
Dictionary containing detailed moderation statistics
|
||||
"""
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
base_queryset = EditSubmission.objects.filter(created_at__gte=cutoff_date)
|
||||
|
||||
if moderator:
|
||||
handled_queryset = base_queryset.filter(handled_by=moderator)
|
||||
else:
|
||||
handled_queryset = base_queryset
|
||||
|
||||
total_submissions = base_queryset.count()
|
||||
pending_submissions = base_queryset.filter(status="PENDING").count()
|
||||
approved_submissions = handled_queryset.filter(status="APPROVED").count()
|
||||
rejected_submissions = handled_queryset.filter(status="REJECTED").count()
|
||||
|
||||
# Response time analysis (only for handled submissions)
|
||||
handled_with_times = (
|
||||
handled_queryset.exclude(handled_at__isnull=True)
|
||||
.extra(
|
||||
select={
|
||||
"response_hours": "EXTRACT(EPOCH FROM (handled_at - created_at)) / 3600"
|
||||
}
|
||||
)
|
||||
.values_list("response_hours", flat=True)
|
||||
)
|
||||
|
||||
avg_response_time = None
|
||||
if handled_with_times:
|
||||
avg_response_time = sum(handled_with_times) / len(handled_with_times)
|
||||
|
||||
return {
|
||||
"period_days": days,
|
||||
"total_submissions": total_submissions,
|
||||
"pending_submissions": pending_submissions,
|
||||
"approved_submissions": approved_submissions,
|
||||
"rejected_submissions": rejected_submissions,
|
||||
"approval_rate": (
|
||||
(approved_submissions / (approved_submissions + rejected_submissions) * 100)
|
||||
if (approved_submissions + rejected_submissions) > 0
|
||||
else 0
|
||||
),
|
||||
"average_response_time_hours": avg_response_time,
|
||||
"moderator": moderator.username if moderator else None,
|
||||
}
|
||||
|
||||
|
||||
def submissions_needing_attention(*, hours: int = 24) -> QuerySet[EditSubmission]:
|
||||
"""
|
||||
Get pending submissions that have been waiting for more than N hours.
|
||||
|
||||
Args:
|
||||
hours: Number of hours threshold for attention
|
||||
|
||||
Returns:
|
||||
QuerySet of submissions needing attention
|
||||
"""
|
||||
cutoff_time = timezone.now() - timedelta(hours=hours)
|
||||
|
||||
return (
|
||||
EditSubmission.objects.filter(status="PENDING", created_at__lte=cutoff_time)
|
||||
.select_related("user", "content_type")
|
||||
.order_by("created_at")
|
||||
)
|
||||
|
||||
|
||||
def top_contributors(*, days: int = 30, limit: int = 10) -> QuerySet[User]:
|
||||
"""
|
||||
Get users who have submitted the most content in the last N days.
|
||||
|
||||
Args:
|
||||
days: Number of days to analyze
|
||||
limit: Maximum number of users to return
|
||||
|
||||
Returns:
|
||||
QuerySet of top contributing users
|
||||
"""
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
return (
|
||||
User.objects.filter(edit_submissions__created_at__gte=cutoff_date)
|
||||
.annotate(submission_count=Count("edit_submissions"))
|
||||
.filter(submission_count__gt=0)
|
||||
.order_by("-submission_count")[:limit]
|
||||
)
|
||||
|
||||
|
||||
def moderator_workload_summary(*, days: int = 30) -> Dict[str, Any]:
|
||||
"""
|
||||
Get workload distribution among moderators.
|
||||
|
||||
Args:
|
||||
days: Number of days to analyze
|
||||
|
||||
Returns:
|
||||
Dictionary containing moderator workload statistics
|
||||
"""
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
moderator_stats = (
|
||||
User.objects.filter(handled_submissions__handled_at__gte=cutoff_date)
|
||||
.annotate(handled_count=Count("handled_submissions"))
|
||||
.filter(handled_count__gt=0)
|
||||
.order_by("-handled_count")
|
||||
.values("username", "handled_count")
|
||||
)
|
||||
|
||||
return {"period_days": days, "moderator_stats": list(moderator_stats)}
|
||||
747
apps/moderation/serializers.py
Normal file
747
apps/moderation/serializers.py
Normal 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)
|
||||
642
apps/moderation/services.py
Normal file
642
apps/moderation/services.py
Normal file
@@ -0,0 +1,642 @@
|
||||
"""
|
||||
Services for moderation functionality.
|
||||
Following Django styleguide pattern for business logic encapsulation.
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any, Union
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django.db.models import QuerySet
|
||||
|
||||
from apps.accounts.models import User
|
||||
from .models import EditSubmission, PhotoSubmission, ModerationQueue
|
||||
|
||||
|
||||
class ModerationService:
|
||||
"""Service for handling content moderation workflows."""
|
||||
|
||||
@staticmethod
|
||||
def approve_submission(
|
||||
*, submission_id: int, moderator: User, notes: Optional[str] = None
|
||||
) -> Union[object, None]:
|
||||
"""
|
||||
Approve a content submission and apply changes.
|
||||
|
||||
Args:
|
||||
submission_id: ID of the submission to approve
|
||||
moderator: User performing the approval
|
||||
notes: Optional notes about the approval
|
||||
|
||||
Returns:
|
||||
The created/updated object or None if approval failed
|
||||
|
||||
Raises:
|
||||
EditSubmission.DoesNotExist: If submission doesn't exist
|
||||
ValidationError: If submission data is invalid
|
||||
ValueError: If submission cannot be processed
|
||||
"""
|
||||
with transaction.atomic():
|
||||
submission = EditSubmission.objects.select_for_update().get(
|
||||
id=submission_id
|
||||
)
|
||||
|
||||
if submission.status != "PENDING":
|
||||
raise ValueError(f"Submission {submission_id} is not pending approval")
|
||||
|
||||
try:
|
||||
# Call the model's approve method which handles the business
|
||||
# logic
|
||||
obj = submission.approve(moderator)
|
||||
|
||||
# Add moderator notes if provided
|
||||
if notes:
|
||||
if submission.notes:
|
||||
submission.notes += f"\n[Moderator]: {notes}"
|
||||
else:
|
||||
submission.notes = f"[Moderator]: {notes}"
|
||||
submission.save()
|
||||
|
||||
return obj
|
||||
|
||||
except Exception as e:
|
||||
# Mark as rejected on any error
|
||||
submission.status = "REJECTED"
|
||||
submission.handled_by = moderator
|
||||
submission.handled_at = timezone.now()
|
||||
submission.notes = f"Approval failed: {str(e)}"
|
||||
submission.save()
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def reject_submission(
|
||||
*, submission_id: int, moderator: User, reason: str
|
||||
) -> EditSubmission:
|
||||
"""
|
||||
Reject a content submission.
|
||||
|
||||
Args:
|
||||
submission_id: ID of the submission to reject
|
||||
moderator: User performing the rejection
|
||||
reason: Reason for rejection
|
||||
|
||||
Returns:
|
||||
Updated submission object
|
||||
|
||||
Raises:
|
||||
EditSubmission.DoesNotExist: If submission doesn't exist
|
||||
ValueError: If submission cannot be rejected
|
||||
"""
|
||||
with transaction.atomic():
|
||||
submission = EditSubmission.objects.select_for_update().get(
|
||||
id=submission_id
|
||||
)
|
||||
|
||||
if submission.status != "PENDING":
|
||||
raise ValueError(f"Submission {submission_id} is not pending review")
|
||||
|
||||
submission.status = "REJECTED"
|
||||
submission.handled_by = moderator
|
||||
submission.handled_at = timezone.now()
|
||||
submission.notes = f"Rejected: {reason}"
|
||||
|
||||
# Call full_clean before saving - CRITICAL STYLEGUIDE FIX
|
||||
submission.full_clean()
|
||||
submission.save()
|
||||
|
||||
return submission
|
||||
|
||||
@staticmethod
|
||||
def create_edit_submission(
|
||||
*,
|
||||
content_object: object,
|
||||
changes: Dict[str, Any],
|
||||
submitter: User,
|
||||
submission_type: str = "UPDATE",
|
||||
notes: Optional[str] = None,
|
||||
) -> EditSubmission:
|
||||
"""
|
||||
Create a new edit submission for moderation.
|
||||
|
||||
Args:
|
||||
content_object: The object being edited
|
||||
changes: Dictionary of field changes
|
||||
submitter: User submitting the changes
|
||||
submission_type: Type of submission ("CREATE" or "UPDATE")
|
||||
notes: Optional notes about the submission
|
||||
|
||||
Returns:
|
||||
Created EditSubmission object
|
||||
|
||||
Raises:
|
||||
ValidationError: If submission data is invalid
|
||||
"""
|
||||
submission = EditSubmission(
|
||||
content_object=content_object,
|
||||
changes=changes,
|
||||
user=submitter,
|
||||
submission_type=submission_type,
|
||||
reason=notes or "",
|
||||
)
|
||||
|
||||
# Call full_clean before saving - CRITICAL STYLEGUIDE FIX
|
||||
submission.full_clean()
|
||||
submission.save()
|
||||
|
||||
return submission
|
||||
|
||||
@staticmethod
|
||||
def update_submission_changes(
|
||||
*,
|
||||
submission_id: int,
|
||||
moderator_changes: Dict[str, Any],
|
||||
moderator: User,
|
||||
) -> EditSubmission:
|
||||
"""
|
||||
Update submission with moderator changes before approval.
|
||||
|
||||
Args:
|
||||
submission_id: ID of the submission to update
|
||||
moderator_changes: Dictionary of moderator modifications
|
||||
moderator: User making the changes
|
||||
|
||||
Returns:
|
||||
Updated submission object
|
||||
|
||||
Raises:
|
||||
EditSubmission.DoesNotExist: If submission doesn't exist
|
||||
ValueError: If submission cannot be modified
|
||||
"""
|
||||
with transaction.atomic():
|
||||
submission = EditSubmission.objects.select_for_update().get(
|
||||
id=submission_id
|
||||
)
|
||||
|
||||
if submission.status != "PENDING":
|
||||
raise ValueError(f"Submission {submission_id} is not pending review")
|
||||
|
||||
submission.moderator_changes = moderator_changes
|
||||
|
||||
# Add note about moderator changes
|
||||
note = f"[Moderator changes by {moderator.username}]"
|
||||
if submission.notes:
|
||||
submission.notes += f"\n{note}"
|
||||
else:
|
||||
submission.notes = note
|
||||
|
||||
# Call full_clean before saving - CRITICAL STYLEGUIDE FIX
|
||||
submission.full_clean()
|
||||
submission.save()
|
||||
|
||||
return submission
|
||||
|
||||
@staticmethod
|
||||
def get_pending_submissions_for_moderator(
|
||||
*,
|
||||
moderator: User,
|
||||
content_type: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> QuerySet:
|
||||
"""
|
||||
Get pending submissions for a moderator to review.
|
||||
|
||||
Args:
|
||||
moderator: The moderator user
|
||||
content_type: Optional filter by content type
|
||||
limit: Maximum number of submissions to return
|
||||
|
||||
Returns:
|
||||
QuerySet of pending submissions
|
||||
"""
|
||||
from .selectors import pending_submissions_for_review
|
||||
|
||||
return pending_submissions_for_review(content_type=content_type, limit=limit)
|
||||
|
||||
@staticmethod
|
||||
def get_submission_statistics(
|
||||
*, days: int = 30, moderator: Optional[User] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get moderation statistics for a time period.
|
||||
|
||||
Args:
|
||||
days: Number of days to analyze
|
||||
moderator: Optional filter by specific moderator
|
||||
|
||||
Returns:
|
||||
Dictionary containing moderation statistics
|
||||
"""
|
||||
from .selectors import moderation_statistics_summary
|
||||
|
||||
return moderation_statistics_summary(days=days, moderator=moderator)
|
||||
|
||||
@staticmethod
|
||||
def _is_moderator_or_above(user: User) -> bool:
|
||||
"""
|
||||
Check if user has moderator privileges or above.
|
||||
|
||||
Args:
|
||||
user: User to check
|
||||
|
||||
Returns:
|
||||
True if user is MODERATOR, ADMIN, or SUPERUSER
|
||||
"""
|
||||
return user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']
|
||||
|
||||
@staticmethod
|
||||
def create_edit_submission_with_queue(
|
||||
*,
|
||||
content_object: Optional[object],
|
||||
changes: Dict[str, Any],
|
||||
submitter: User,
|
||||
submission_type: str = "EDIT",
|
||||
reason: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create an edit submission with automatic queue routing.
|
||||
|
||||
For moderators and above: Creates submission and auto-approves
|
||||
For regular users: Creates submission and adds to moderation queue
|
||||
|
||||
Args:
|
||||
content_object: The object being edited (None for CREATE)
|
||||
changes: Dictionary of field changes
|
||||
submitter: User submitting the changes
|
||||
submission_type: Type of submission ("CREATE" or "EDIT")
|
||||
reason: Reason for the submission
|
||||
source: Source of information
|
||||
|
||||
Returns:
|
||||
Dictionary with submission info and queue status
|
||||
"""
|
||||
with transaction.atomic():
|
||||
# Create the submission
|
||||
submission = EditSubmission(
|
||||
content_object=content_object,
|
||||
changes=changes,
|
||||
user=submitter,
|
||||
submission_type=submission_type,
|
||||
reason=reason or "",
|
||||
source=source or "",
|
||||
)
|
||||
|
||||
submission.full_clean()
|
||||
submission.save()
|
||||
|
||||
# Check if user is moderator or above
|
||||
if ModerationService._is_moderator_or_above(submitter):
|
||||
# Auto-approve for moderators
|
||||
try:
|
||||
created_object = submission.approve(submitter)
|
||||
return {
|
||||
'submission': submission,
|
||||
'status': 'auto_approved',
|
||||
'created_object': created_object,
|
||||
'queue_item': None,
|
||||
'message': 'Submission auto-approved for moderator'
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'submission': submission,
|
||||
'status': 'failed',
|
||||
'created_object': None,
|
||||
'queue_item': None,
|
||||
'message': f'Auto-approval failed: {str(e)}'
|
||||
}
|
||||
else:
|
||||
# Create queue item for regular users
|
||||
queue_item = ModerationService._create_queue_item_for_submission(
|
||||
submission=submission,
|
||||
submitter=submitter
|
||||
)
|
||||
|
||||
return {
|
||||
'submission': submission,
|
||||
'status': 'queued',
|
||||
'created_object': None,
|
||||
'queue_item': queue_item,
|
||||
'message': 'Submission added to moderation queue'
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def create_photo_submission_with_queue(
|
||||
*,
|
||||
content_object: object,
|
||||
photo,
|
||||
caption: str = "",
|
||||
date_taken=None,
|
||||
submitter: User,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a photo submission with automatic queue routing.
|
||||
|
||||
For moderators and above: Creates submission and auto-approves
|
||||
For regular users: Creates submission and adds to moderation queue
|
||||
|
||||
Args:
|
||||
content_object: The object the photo is for
|
||||
photo: The photo file
|
||||
caption: Photo caption
|
||||
date_taken: Date the photo was taken
|
||||
submitter: User submitting the photo
|
||||
|
||||
Returns:
|
||||
Dictionary with submission info and queue status
|
||||
"""
|
||||
with transaction.atomic():
|
||||
# Create the photo submission
|
||||
submission = PhotoSubmission(
|
||||
content_object=content_object,
|
||||
photo=photo,
|
||||
caption=caption,
|
||||
date_taken=date_taken,
|
||||
user=submitter,
|
||||
)
|
||||
|
||||
submission.full_clean()
|
||||
submission.save()
|
||||
|
||||
# Check if user is moderator or above
|
||||
if ModerationService._is_moderator_or_above(submitter):
|
||||
# Auto-approve for moderators
|
||||
try:
|
||||
submission.auto_approve()
|
||||
return {
|
||||
'submission': submission,
|
||||
'status': 'auto_approved',
|
||||
'queue_item': None,
|
||||
'message': 'Photo submission auto-approved for moderator'
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'submission': submission,
|
||||
'status': 'failed',
|
||||
'queue_item': None,
|
||||
'message': f'Auto-approval failed: {str(e)}'
|
||||
}
|
||||
else:
|
||||
# Create queue item for regular users
|
||||
queue_item = ModerationService._create_queue_item_for_photo_submission(
|
||||
submission=submission,
|
||||
submitter=submitter
|
||||
)
|
||||
|
||||
return {
|
||||
'submission': submission,
|
||||
'status': 'queued',
|
||||
'queue_item': queue_item,
|
||||
'message': 'Photo submission added to moderation queue'
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _create_queue_item_for_submission(
|
||||
*, submission: EditSubmission, submitter: User
|
||||
) -> ModerationQueue:
|
||||
"""
|
||||
Create a moderation queue item for an edit submission.
|
||||
|
||||
Args:
|
||||
submission: The edit submission
|
||||
submitter: User who made the submission
|
||||
|
||||
Returns:
|
||||
Created ModerationQueue item
|
||||
"""
|
||||
# Determine content type and entity info
|
||||
content_type = submission.content_type
|
||||
entity_type = content_type.model if content_type else "unknown"
|
||||
entity_id = submission.object_id
|
||||
|
||||
# Create preview data
|
||||
entity_preview = {
|
||||
'submission_type': submission.submission_type,
|
||||
'changes_count': len(submission.changes) if submission.changes else 0,
|
||||
'reason': submission.reason[:100] if submission.reason else "",
|
||||
}
|
||||
|
||||
if submission.content_object:
|
||||
entity_preview['object_name'] = str(submission.content_object)
|
||||
|
||||
# Determine title and description
|
||||
action = "creation" if submission.submission_type == "CREATE" else "edit"
|
||||
title = f"{entity_type.title()} {action} by {submitter.username}"
|
||||
|
||||
description = f"Review {action} submission for {entity_type}"
|
||||
if submission.reason:
|
||||
description += f". Reason: {submission.reason}"
|
||||
|
||||
# Create queue item
|
||||
queue_item = ModerationQueue(
|
||||
item_type='CONTENT_REVIEW',
|
||||
title=title,
|
||||
description=description,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
entity_preview=entity_preview,
|
||||
content_type=content_type,
|
||||
flagged_by=submitter,
|
||||
priority='MEDIUM',
|
||||
estimated_review_time=15, # 15 minutes default
|
||||
tags=['edit_submission', submission.submission_type.lower()],
|
||||
)
|
||||
|
||||
queue_item.full_clean()
|
||||
queue_item.save()
|
||||
|
||||
return queue_item
|
||||
|
||||
@staticmethod
|
||||
def _create_queue_item_for_photo_submission(
|
||||
*, submission: PhotoSubmission, submitter: User
|
||||
) -> ModerationQueue:
|
||||
"""
|
||||
Create a moderation queue item for a photo submission.
|
||||
|
||||
Args:
|
||||
submission: The photo submission
|
||||
submitter: User who made the submission
|
||||
|
||||
Returns:
|
||||
Created ModerationQueue item
|
||||
"""
|
||||
# Determine content type and entity info
|
||||
content_type = submission.content_type
|
||||
entity_type = content_type.model if content_type else "unknown"
|
||||
entity_id = submission.object_id
|
||||
|
||||
# Create preview data
|
||||
entity_preview = {
|
||||
'caption': submission.caption,
|
||||
'date_taken': submission.date_taken.isoformat() if submission.date_taken else None,
|
||||
'photo_url': submission.photo.url if submission.photo else None,
|
||||
}
|
||||
|
||||
if submission.content_object:
|
||||
entity_preview['object_name'] = str(submission.content_object)
|
||||
|
||||
# Create title and description
|
||||
title = f"Photo submission for {entity_type} by {submitter.username}"
|
||||
description = f"Review photo submission for {entity_type}"
|
||||
if submission.caption:
|
||||
description += f". Caption: {submission.caption}"
|
||||
|
||||
# Create queue item
|
||||
queue_item = ModerationQueue(
|
||||
item_type='CONTENT_REVIEW',
|
||||
title=title,
|
||||
description=description,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
entity_preview=entity_preview,
|
||||
content_type=content_type,
|
||||
flagged_by=submitter,
|
||||
priority='LOW', # Photos typically lower priority
|
||||
estimated_review_time=5, # 5 minutes default for photos
|
||||
tags=['photo_submission'],
|
||||
)
|
||||
|
||||
queue_item.full_clean()
|
||||
queue_item.save()
|
||||
|
||||
return queue_item
|
||||
|
||||
@staticmethod
|
||||
def process_queue_item(
|
||||
*, queue_item_id: int, moderator: User, action: str, notes: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Process a moderation queue item (approve, reject, etc.).
|
||||
|
||||
Args:
|
||||
queue_item_id: ID of the queue item to process
|
||||
moderator: User processing the item
|
||||
action: Action to take ('approve', 'reject', 'escalate')
|
||||
notes: Optional notes about the action
|
||||
|
||||
Returns:
|
||||
Dictionary with processing results
|
||||
"""
|
||||
with transaction.atomic():
|
||||
queue_item = ModerationQueue.objects.select_for_update().get(
|
||||
id=queue_item_id
|
||||
)
|
||||
|
||||
if queue_item.status != 'PENDING':
|
||||
raise ValueError(f"Queue item {queue_item_id} is not pending")
|
||||
|
||||
# Find related submission
|
||||
if 'edit_submission' in queue_item.tags:
|
||||
# Find EditSubmission
|
||||
submissions = EditSubmission.objects.filter(
|
||||
user=queue_item.flagged_by,
|
||||
content_type=queue_item.content_type,
|
||||
object_id=queue_item.entity_id,
|
||||
status='PENDING'
|
||||
).order_by('-created_at')
|
||||
|
||||
if not submissions.exists():
|
||||
raise ValueError(
|
||||
"No pending edit submission found for this queue item")
|
||||
|
||||
submission = submissions.first()
|
||||
|
||||
if action == 'approve':
|
||||
try:
|
||||
created_object = submission.approve(moderator)
|
||||
queue_item.status = 'COMPLETED'
|
||||
result = {
|
||||
'status': 'approved',
|
||||
'created_object': created_object,
|
||||
'message': 'Submission approved successfully'
|
||||
}
|
||||
except Exception as e:
|
||||
queue_item.status = 'COMPLETED'
|
||||
result = {
|
||||
'status': 'failed',
|
||||
'created_object': None,
|
||||
'message': f'Approval failed: {str(e)}'
|
||||
}
|
||||
elif action == 'reject':
|
||||
submission.reject(moderator, notes or "Rejected by moderator")
|
||||
queue_item.status = 'COMPLETED'
|
||||
result = {
|
||||
'status': 'rejected',
|
||||
'created_object': None,
|
||||
'message': 'Submission rejected'
|
||||
}
|
||||
elif action == 'escalate':
|
||||
submission.escalate(moderator, notes or "Escalated for review")
|
||||
queue_item.priority = 'HIGH'
|
||||
queue_item.status = 'PENDING' # Keep in queue but escalated
|
||||
result = {
|
||||
'status': 'escalated',
|
||||
'created_object': None,
|
||||
'message': 'Submission escalated'
|
||||
}
|
||||
else:
|
||||
raise ValueError(f"Unknown action: {action}")
|
||||
|
||||
elif 'photo_submission' in queue_item.tags:
|
||||
# Find PhotoSubmission
|
||||
submissions = PhotoSubmission.objects.filter(
|
||||
user=queue_item.flagged_by,
|
||||
content_type=queue_item.content_type,
|
||||
object_id=queue_item.entity_id,
|
||||
status='PENDING'
|
||||
).order_by('-created_at')
|
||||
|
||||
if not submissions.exists():
|
||||
raise ValueError(
|
||||
"No pending photo submission found for this queue item")
|
||||
|
||||
submission = submissions.first()
|
||||
|
||||
if action == 'approve':
|
||||
try:
|
||||
submission.approve(moderator, notes or "")
|
||||
queue_item.status = 'COMPLETED'
|
||||
result = {
|
||||
'status': 'approved',
|
||||
'created_object': None,
|
||||
'message': 'Photo submission approved successfully'
|
||||
}
|
||||
except Exception as e:
|
||||
queue_item.status = 'COMPLETED'
|
||||
result = {
|
||||
'status': 'failed',
|
||||
'created_object': None,
|
||||
'message': f'Photo approval failed: {str(e)}'
|
||||
}
|
||||
elif action == 'reject':
|
||||
submission.reject(moderator, notes or "Rejected by moderator")
|
||||
queue_item.status = 'COMPLETED'
|
||||
result = {
|
||||
'status': 'rejected',
|
||||
'created_object': None,
|
||||
'message': 'Photo submission rejected'
|
||||
}
|
||||
elif action == 'escalate':
|
||||
submission.escalate(moderator, notes or "Escalated for review")
|
||||
queue_item.priority = 'HIGH'
|
||||
queue_item.status = 'PENDING' # Keep in queue but escalated
|
||||
result = {
|
||||
'status': 'escalated',
|
||||
'created_object': None,
|
||||
'message': 'Photo submission escalated'
|
||||
}
|
||||
else:
|
||||
raise ValueError(f"Unknown action: {action}")
|
||||
else:
|
||||
raise ValueError("Unknown queue item type")
|
||||
|
||||
# Update queue item
|
||||
queue_item.assigned_to = moderator
|
||||
queue_item.assigned_at = timezone.now()
|
||||
if notes:
|
||||
queue_item.description += f"\n\nModerator notes: {notes}"
|
||||
|
||||
queue_item.full_clean()
|
||||
queue_item.save()
|
||||
|
||||
result['queue_item'] = queue_item
|
||||
return result
|
||||
69
apps/moderation/templatetags/moderation_tags.py
Normal file
69
apps/moderation/templatetags/moderation_tags.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from django import template
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from typing import Optional, Dict, Any, List, Union
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def get_object_name(value: Optional[int], model_path: str) -> Optional[str]:
|
||||
"""Get object name from ID and model path."""
|
||||
if not value or not model_path or "." not in model_path:
|
||||
return None
|
||||
|
||||
app_label, model = model_path.split(".")
|
||||
try:
|
||||
content_type = ContentType.objects.get(
|
||||
app_label=app_label.lower(), model=model.lower()
|
||||
)
|
||||
model_class = content_type.model_class()
|
||||
if not model_class:
|
||||
return None
|
||||
|
||||
obj = model_class.objects.filter(id=value).first()
|
||||
return str(obj) if obj else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@register.filter
|
||||
def get_category_display(value: Optional[str]) -> Optional[str]:
|
||||
"""Get display value for ride category."""
|
||||
if not value:
|
||||
return None
|
||||
|
||||
categories = {
|
||||
"RC": "Roller Coaster",
|
||||
"DR": "Dark Ride",
|
||||
"FR": "Flat Ride",
|
||||
"WR": "Water Ride",
|
||||
"TR": "Transport",
|
||||
"OT": "Other",
|
||||
}
|
||||
return categories.get(value)
|
||||
|
||||
|
||||
@register.filter
|
||||
def get_park_area_name(value: Optional[int], park_id: Optional[int]) -> Optional[str]:
|
||||
"""Get park area name from ID and park ID."""
|
||||
if not value or not park_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
from apps.parks.models import ParkArea
|
||||
|
||||
area = ParkArea.objects.filter(id=value, park_id=park_id).first()
|
||||
return str(area) if area else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@register.filter
|
||||
def get_item(
|
||||
dictionary: Optional[Dict[str, Any]], key: Optional[Union[str, int]]
|
||||
) -> List[Any]:
|
||||
"""Get item from dictionary by key."""
|
||||
if not dictionary or not isinstance(dictionary, dict) or not key:
|
||||
return []
|
||||
|
||||
return dictionary.get(str(key), [])
|
||||
349
apps/moderation/tests.py
Normal file
349
apps/moderation/tests.py
Normal file
@@ -0,0 +1,349 @@
|
||||
from django.test import TestCase, Client
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.http import JsonResponse, HttpRequest
|
||||
from .models import EditSubmission
|
||||
from .mixins import (
|
||||
EditSubmissionMixin,
|
||||
PhotoSubmissionMixin,
|
||||
ModeratorRequiredMixin,
|
||||
AdminRequiredMixin,
|
||||
InlineEditMixin,
|
||||
HistoryMixin,
|
||||
)
|
||||
from apps.parks.models import Company as Operator
|
||||
from django.views.generic import DetailView
|
||||
from django.test import RequestFactory
|
||||
import json
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class TestView(
|
||||
EditSubmissionMixin,
|
||||
PhotoSubmissionMixin,
|
||||
InlineEditMixin,
|
||||
HistoryMixin,
|
||||
DetailView,
|
||||
):
|
||||
model = Operator
|
||||
template_name = "test.html"
|
||||
pk_url_kwarg = "pk"
|
||||
slug_url_kwarg = "slug"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
if not hasattr(self, "object"):
|
||||
self.object = self.get_object()
|
||||
return super().get_context_data(**kwargs)
|
||||
|
||||
def setup(self, request: HttpRequest, *args, **kwargs):
|
||||
super().setup(request, *args, **kwargs)
|
||||
self.request = request
|
||||
|
||||
|
||||
class ModerationMixinsTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.factory = RequestFactory()
|
||||
|
||||
# Create users with different roles
|
||||
self.user = User.objects.create_user(
|
||||
username="testuser",
|
||||
email="test@example.com",
|
||||
password="testpass123",
|
||||
)
|
||||
self.moderator = User.objects.create_user(
|
||||
username="moderator",
|
||||
email="moderator@example.com",
|
||||
password="modpass123",
|
||||
role="MODERATOR",
|
||||
)
|
||||
self.admin = User.objects.create_user(
|
||||
username="admin",
|
||||
email="admin@example.com",
|
||||
password="adminpass123",
|
||||
role="ADMIN",
|
||||
)
|
||||
|
||||
# Create test company
|
||||
self.operator = Operator.objects.create(
|
||||
name="Test Operator",
|
||||
website="http://example.com",
|
||||
description="Test Description",
|
||||
)
|
||||
|
||||
def test_edit_submission_mixin_unauthenticated(self):
|
||||
"""Test edit submission when not logged in"""
|
||||
view = TestView()
|
||||
request = self.factory.post(f"/test/{self.operator.pk}/")
|
||||
request.user = AnonymousUser()
|
||||
view.setup(request, pk=self.operator.pk)
|
||||
view.kwargs = {"pk": self.operator.pk}
|
||||
response = view.handle_edit_submission(request, {})
|
||||
self.assertIsInstance(response, JsonResponse)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_edit_submission_mixin_no_changes(self):
|
||||
"""Test edit submission with no changes"""
|
||||
view = TestView()
|
||||
request = self.factory.post(
|
||||
f"/test/{self.operator.pk}/",
|
||||
data=json.dumps({}),
|
||||
content_type="application/json",
|
||||
)
|
||||
request.user = self.user
|
||||
view.setup(request, pk=self.operator.pk)
|
||||
view.kwargs = {"pk": self.operator.pk}
|
||||
response = view.post(request)
|
||||
self.assertIsInstance(response, JsonResponse)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_edit_submission_mixin_invalid_json(self):
|
||||
"""Test edit submission with invalid JSON"""
|
||||
view = TestView()
|
||||
request = self.factory.post(
|
||||
f"/test/{self.operator.pk}/",
|
||||
data="invalid json",
|
||||
content_type="application/json",
|
||||
)
|
||||
request.user = self.user
|
||||
view.setup(request, pk=self.operator.pk)
|
||||
view.kwargs = {"pk": self.operator.pk}
|
||||
response = view.post(request)
|
||||
self.assertIsInstance(response, JsonResponse)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_edit_submission_mixin_regular_user(self):
|
||||
"""Test edit submission as regular user"""
|
||||
view = TestView()
|
||||
request = self.factory.post(f"/test/{self.operator.pk}/")
|
||||
request.user = self.user
|
||||
view.setup(request, pk=self.operator.pk)
|
||||
view.kwargs = {"pk": self.operator.pk}
|
||||
changes = {"name": "New Name"}
|
||||
response = view.handle_edit_submission(
|
||||
request, changes, "Test reason", "Test source"
|
||||
)
|
||||
self.assertIsInstance(response, JsonResponse)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.content.decode())
|
||||
self.assertFalse(data["auto_approved"])
|
||||
|
||||
def test_edit_submission_mixin_moderator(self):
|
||||
"""Test edit submission as moderator"""
|
||||
view = TestView()
|
||||
request = self.factory.post(f"/test/{self.operator.pk}/")
|
||||
request.user = self.moderator
|
||||
view.setup(request, pk=self.operator.pk)
|
||||
view.kwargs = {"pk": self.operator.pk}
|
||||
changes = {"name": "New Name"}
|
||||
response = view.handle_edit_submission(
|
||||
request, changes, "Test reason", "Test source"
|
||||
)
|
||||
self.assertIsInstance(response, JsonResponse)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.content.decode())
|
||||
self.assertTrue(data["auto_approved"])
|
||||
|
||||
def test_photo_submission_mixin_unauthenticated(self):
|
||||
"""Test photo submission when not logged in"""
|
||||
view = TestView()
|
||||
view.kwargs = {"pk": self.operator.pk}
|
||||
view.object = self.operator
|
||||
|
||||
request = self.factory.post(
|
||||
f"/test/{self.operator.pk}/", data={}, format="multipart"
|
||||
)
|
||||
request.user = AnonymousUser()
|
||||
view.setup(request, pk=self.operator.pk)
|
||||
response = view.handle_photo_submission(request)
|
||||
self.assertIsInstance(response, JsonResponse)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_photo_submission_mixin_no_photo(self):
|
||||
"""Test photo submission with no photo"""
|
||||
view = TestView()
|
||||
view.kwargs = {"pk": self.operator.pk}
|
||||
view.object = self.operator
|
||||
|
||||
request = self.factory.post(
|
||||
f"/test/{self.operator.pk}/", data={}, format="multipart"
|
||||
)
|
||||
request.user = self.user
|
||||
view.setup(request, pk=self.operator.pk)
|
||||
response = view.handle_photo_submission(request)
|
||||
self.assertIsInstance(response, JsonResponse)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_photo_submission_mixin_regular_user(self):
|
||||
"""Test photo submission as regular user"""
|
||||
view = TestView()
|
||||
view.kwargs = {"pk": self.operator.pk}
|
||||
view.object = self.operator
|
||||
|
||||
# Create a test photo file
|
||||
photo = SimpleUploadedFile(
|
||||
"test.gif",
|
||||
b"GIF87a\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00ccc,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;",
|
||||
content_type="image/gif",
|
||||
)
|
||||
|
||||
request = self.factory.post(
|
||||
f"/test/{self.operator.pk}/",
|
||||
data={
|
||||
"photo": photo,
|
||||
"caption": "Test Photo",
|
||||
"date_taken": "2024-01-01",
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
request.user = self.user
|
||||
view.setup(request, pk=self.operator.pk)
|
||||
|
||||
response = view.handle_photo_submission(request)
|
||||
self.assertIsInstance(response, JsonResponse)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.content.decode())
|
||||
self.assertFalse(data["auto_approved"])
|
||||
|
||||
def test_photo_submission_mixin_moderator(self):
|
||||
"""Test photo submission as moderator"""
|
||||
view = TestView()
|
||||
view.kwargs = {"pk": self.operator.pk}
|
||||
view.object = self.operator
|
||||
|
||||
# Create a test photo file
|
||||
photo = SimpleUploadedFile(
|
||||
"test.gif",
|
||||
b"GIF87a\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00ccc,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;",
|
||||
content_type="image/gif",
|
||||
)
|
||||
|
||||
request = self.factory.post(
|
||||
f"/test/{self.operator.pk}/",
|
||||
data={
|
||||
"photo": photo,
|
||||
"caption": "Test Photo",
|
||||
"date_taken": "2024-01-01",
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
request.user = self.moderator
|
||||
view.setup(request, pk=self.operator.pk)
|
||||
|
||||
response = view.handle_photo_submission(request)
|
||||
self.assertIsInstance(response, JsonResponse)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.content.decode())
|
||||
self.assertTrue(data["auto_approved"])
|
||||
|
||||
def test_moderator_required_mixin(self):
|
||||
"""Test moderator required mixin"""
|
||||
|
||||
class TestModeratorView(ModeratorRequiredMixin):
|
||||
pass
|
||||
|
||||
view = TestModeratorView()
|
||||
|
||||
# Test unauthenticated user
|
||||
request = self.factory.get("/test/")
|
||||
request.user = AnonymousUser()
|
||||
view.request = request
|
||||
self.assertFalse(view.test_func())
|
||||
|
||||
# Test regular user
|
||||
request.user = self.user
|
||||
view.request = request
|
||||
self.assertFalse(view.test_func())
|
||||
|
||||
# Test moderator
|
||||
request.user = self.moderator
|
||||
view.request = request
|
||||
self.assertTrue(view.test_func())
|
||||
|
||||
# Test admin
|
||||
request.user = self.admin
|
||||
view.request = request
|
||||
self.assertTrue(view.test_func())
|
||||
|
||||
def test_admin_required_mixin(self):
|
||||
"""Test admin required mixin"""
|
||||
|
||||
class TestAdminView(AdminRequiredMixin):
|
||||
pass
|
||||
|
||||
view = TestAdminView()
|
||||
|
||||
# Test unauthenticated user
|
||||
request = self.factory.get("/test/")
|
||||
request.user = AnonymousUser()
|
||||
view.request = request
|
||||
self.assertFalse(view.test_func())
|
||||
|
||||
# Test regular user
|
||||
request.user = self.user
|
||||
view.request = request
|
||||
self.assertFalse(view.test_func())
|
||||
|
||||
# Test moderator
|
||||
request.user = self.moderator
|
||||
view.request = request
|
||||
self.assertFalse(view.test_func())
|
||||
|
||||
# Test admin
|
||||
request.user = self.admin
|
||||
view.request = request
|
||||
self.assertTrue(view.test_func())
|
||||
|
||||
def test_inline_edit_mixin(self):
|
||||
"""Test inline edit mixin"""
|
||||
view = TestView()
|
||||
view.kwargs = {"pk": self.operator.pk}
|
||||
view.object = self.operator
|
||||
|
||||
# Test unauthenticated user
|
||||
request = self.factory.get(f"/test/{self.operator.pk}/")
|
||||
request.user = AnonymousUser()
|
||||
view.setup(request, pk=self.operator.pk)
|
||||
context = view.get_context_data()
|
||||
self.assertNotIn("can_edit", context)
|
||||
|
||||
# Test regular user
|
||||
request.user = self.user
|
||||
view.setup(request, pk=self.operator.pk)
|
||||
context = view.get_context_data()
|
||||
self.assertTrue(context["can_edit"])
|
||||
self.assertFalse(context["can_auto_approve"])
|
||||
|
||||
# Test moderator
|
||||
request.user = self.moderator
|
||||
view.setup(request, pk=self.operator.pk)
|
||||
context = view.get_context_data()
|
||||
self.assertTrue(context["can_edit"])
|
||||
self.assertTrue(context["can_auto_approve"])
|
||||
|
||||
def test_history_mixin(self):
|
||||
"""Test history mixin"""
|
||||
view = TestView()
|
||||
view.kwargs = {"pk": self.operator.pk}
|
||||
view.object = self.operator
|
||||
request = self.factory.get(f"/test/{self.operator.pk}/")
|
||||
request.user = self.user
|
||||
view.setup(request, pk=self.operator.pk)
|
||||
|
||||
# Create some edit submissions
|
||||
EditSubmission.objects.create(
|
||||
user=self.user,
|
||||
content_type=ContentType.objects.get_for_model(Operator),
|
||||
object_id=getattr(self.operator, "id", None),
|
||||
submission_type="EDIT",
|
||||
changes={"name": "New Name"},
|
||||
status="APPROVED",
|
||||
)
|
||||
|
||||
context = view.get_context_data()
|
||||
self.assertIn("history", context)
|
||||
self.assertIn("edit_submissions", context)
|
||||
self.assertEqual(len(context["edit_submissions"]), 1)
|
||||
87
apps/moderation/urls.py
Normal file
87
apps/moderation/urls.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Moderation URLs
|
||||
|
||||
This module defines URL patterns for the moderation API endpoints.
|
||||
All endpoints are nested under /api/moderation/ and provide comprehensive
|
||||
moderation functionality including reports, queue management, actions, and bulk operations.
|
||||
"""
|
||||
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import (
|
||||
ModerationReportViewSet,
|
||||
ModerationQueueViewSet,
|
||||
ModerationActionViewSet,
|
||||
BulkOperationViewSet,
|
||||
UserModerationViewSet,
|
||||
)
|
||||
|
||||
# Create router and register viewsets
|
||||
router = DefaultRouter()
|
||||
router.register(r"reports", ModerationReportViewSet, basename="moderation-reports")
|
||||
router.register(r"queue", ModerationQueueViewSet, basename="moderation-queue")
|
||||
router.register(r"actions", ModerationActionViewSet, basename="moderation-actions")
|
||||
router.register(r"bulk-operations", BulkOperationViewSet, basename="bulk-operations")
|
||||
router.register(r"users", UserModerationViewSet, basename="user-moderation")
|
||||
|
||||
app_name = "moderation"
|
||||
|
||||
urlpatterns = [
|
||||
# Include all router URLs
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
|
||||
# URL patterns generated by the router:
|
||||
#
|
||||
# Moderation Reports:
|
||||
# GET /api/moderation/reports/ - List all reports
|
||||
# POST /api/moderation/reports/ - Create new report
|
||||
# GET /api/moderation/reports/{id}/ - Get specific report
|
||||
# PUT /api/moderation/reports/{id}/ - Update report
|
||||
# PATCH /api/moderation/reports/{id}/ - Partial update report
|
||||
# DELETE /api/moderation/reports/{id}/ - Delete report
|
||||
# POST /api/moderation/reports/{id}/assign/ - Assign report to moderator
|
||||
# POST /api/moderation/reports/{id}/resolve/ - Resolve report
|
||||
# GET /api/moderation/reports/stats/ - Get report statistics
|
||||
#
|
||||
# Moderation Queue:
|
||||
# GET /api/moderation/queue/ - List queue items
|
||||
# POST /api/moderation/queue/ - Create queue item
|
||||
# GET /api/moderation/queue/{id}/ - Get specific queue item
|
||||
# PUT /api/moderation/queue/{id}/ - Update queue item
|
||||
# PATCH /api/moderation/queue/{id}/ - Partial update queue item
|
||||
# DELETE /api/moderation/queue/{id}/ - Delete queue item
|
||||
# POST /api/moderation/queue/{id}/assign/ - Assign queue item
|
||||
# POST /api/moderation/queue/{id}/unassign/ - Unassign queue item
|
||||
# POST /api/moderation/queue/{id}/complete/ - Complete queue item
|
||||
# GET /api/moderation/queue/my_queue/ - Get current user's queue items
|
||||
#
|
||||
# Moderation Actions:
|
||||
# GET /api/moderation/actions/ - List all actions
|
||||
# POST /api/moderation/actions/ - Create new action
|
||||
# GET /api/moderation/actions/{id}/ - Get specific action
|
||||
# PUT /api/moderation/actions/{id}/ - Update action
|
||||
# PATCH /api/moderation/actions/{id}/ - Partial update action
|
||||
# DELETE /api/moderation/actions/{id}/ - Delete action
|
||||
# POST /api/moderation/actions/{id}/deactivate/ - Deactivate action
|
||||
# GET /api/moderation/actions/active/ - Get active actions
|
||||
# GET /api/moderation/actions/expired/ - Get expired actions
|
||||
#
|
||||
# Bulk Operations:
|
||||
# GET /api/moderation/bulk-operations/ - List bulk operations
|
||||
# POST /api/moderation/bulk-operations/ - Create bulk operation
|
||||
# GET /api/moderation/bulk-operations/{id}/ - Get specific operation
|
||||
# PUT /api/moderation/bulk-operations/{id}/ - Update operation
|
||||
# PATCH /api/moderation/bulk-operations/{id}/ - Partial update operation
|
||||
# DELETE /api/moderation/bulk-operations/{id}/ - Delete operation
|
||||
# POST /api/moderation/bulk-operations/{id}/cancel/ - Cancel operation
|
||||
# POST /api/moderation/bulk-operations/{id}/retry/ - Retry failed operation
|
||||
# GET /api/moderation/bulk-operations/{id}/logs/ - Get operation logs
|
||||
# GET /api/moderation/bulk-operations/running/ - Get running operations
|
||||
#
|
||||
# User Moderation:
|
||||
# GET /api/moderation/users/{id}/ - Get user moderation profile
|
||||
# POST /api/moderation/users/{id}/moderate/ - Take action against user
|
||||
# GET /api/moderation/users/search/ - Search users for moderation
|
||||
# GET /api/moderation/users/stats/ - Get user moderation statistics
|
||||
737
apps/moderation/views.py
Normal file
737
apps/moderation/views.py
Normal file
@@ -0,0 +1,737 @@
|
||||
"""
|
||||
Moderation API Views
|
||||
|
||||
This module contains DRF viewsets for the moderation system, including:
|
||||
- ModerationReport views for content reporting
|
||||
- ModerationQueue views for moderation workflow
|
||||
- ModerationAction views for tracking moderation actions
|
||||
- BulkOperation views for administrative bulk operations
|
||||
|
||||
All views include comprehensive permissions, filtering, and pagination.
|
||||
"""
|
||||
|
||||
from rest_framework import viewsets, status, permissions
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.filters import SearchFilter, OrderingFilter
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q, Count
|
||||
from datetime import timedelta
|
||||
|
||||
from .models import (
|
||||
ModerationReport,
|
||||
ModerationQueue,
|
||||
ModerationAction,
|
||||
BulkOperation,
|
||||
)
|
||||
from .serializers import (
|
||||
ModerationReportSerializer,
|
||||
CreateModerationReportSerializer,
|
||||
UpdateModerationReportSerializer,
|
||||
ModerationQueueSerializer,
|
||||
AssignQueueItemSerializer,
|
||||
CompleteQueueItemSerializer,
|
||||
ModerationActionSerializer,
|
||||
CreateModerationActionSerializer,
|
||||
BulkOperationSerializer,
|
||||
CreateBulkOperationSerializer,
|
||||
UserModerationProfileSerializer,
|
||||
)
|
||||
from .filters import (
|
||||
ModerationReportFilter,
|
||||
ModerationQueueFilter,
|
||||
ModerationActionFilter,
|
||||
BulkOperationFilter,
|
||||
)
|
||||
from .permissions import (
|
||||
IsModeratorOrAdmin,
|
||||
IsAdminOrSuperuser,
|
||||
CanViewModerationData,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Moderation Report ViewSet
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ModerationReportViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing moderation reports.
|
||||
|
||||
Provides CRUD operations for moderation reports with comprehensive
|
||||
filtering, search, and permission controls.
|
||||
"""
|
||||
|
||||
queryset = ModerationReport.objects.select_related(
|
||||
"reported_by", "assigned_moderator", "content_type"
|
||||
).all()
|
||||
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_class = ModerationReportFilter
|
||||
search_fields = ["reason", "description", "resolution_notes"]
|
||||
ordering_fields = ["created_at", "updated_at", "priority", "status"]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Return appropriate serializer based on action."""
|
||||
if self.action == "create":
|
||||
return CreateModerationReportSerializer
|
||||
elif self.action in ["update", "partial_update"]:
|
||||
return UpdateModerationReportSerializer
|
||||
return ModerationReportSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
"""Return appropriate permissions based on action."""
|
||||
if self.action == "create":
|
||||
# Any authenticated user can create reports
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
elif self.action in ["list", "retrieve"]:
|
||||
# Moderators and above can view reports
|
||||
permission_classes = [CanViewModerationData]
|
||||
else:
|
||||
# Only moderators and above can modify reports
|
||||
permission_classes = [IsModeratorOrAdmin]
|
||||
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter queryset based on user permissions."""
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Regular users can only see their own reports
|
||||
if not self.request.user.is_authenticated:
|
||||
return queryset.none()
|
||||
|
||||
user_role = getattr(self.request.user, "role", "USER")
|
||||
if user_role == "USER":
|
||||
queryset = queryset.filter(reported_by=self.request.user)
|
||||
|
||||
return queryset
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def assign(self, request, pk=None):
|
||||
"""Assign a report to a moderator."""
|
||||
report = self.get_object()
|
||||
moderator_id = request.data.get("moderator_id")
|
||||
|
||||
try:
|
||||
moderator = User.objects.get(id=moderator_id)
|
||||
moderator_role = getattr(moderator, "role", "USER")
|
||||
|
||||
if moderator_role not in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||
return Response(
|
||||
{"error": "User must be a moderator, admin, or superuser"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
report.assigned_moderator = moderator
|
||||
report.status = "UNDER_REVIEW"
|
||||
report.save()
|
||||
|
||||
serializer = self.get_serializer(report)
|
||||
return Response(serializer.data)
|
||||
|
||||
except User.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Moderator not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def resolve(self, request, pk=None):
|
||||
"""Resolve a moderation report."""
|
||||
report = self.get_object()
|
||||
|
||||
resolution_action = request.data.get("resolution_action")
|
||||
resolution_notes = request.data.get("resolution_notes", "")
|
||||
|
||||
if not resolution_action:
|
||||
return Response(
|
||||
{"error": "resolution_action is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
report.status = "RESOLVED"
|
||||
report.resolution_action = resolution_action
|
||||
report.resolution_notes = resolution_notes
|
||||
report.resolved_at = timezone.now()
|
||||
report.save()
|
||||
|
||||
serializer = self.get_serializer(report)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=["get"], permission_classes=[CanViewModerationData])
|
||||
def stats(self, request):
|
||||
"""Get moderation report statistics."""
|
||||
queryset = self.get_queryset()
|
||||
|
||||
# Basic counts
|
||||
total_reports = queryset.count()
|
||||
pending_reports = queryset.filter(status="PENDING").count()
|
||||
resolved_reports = queryset.filter(status="RESOLVED").count()
|
||||
|
||||
# Overdue reports (based on priority SLA)
|
||||
now = timezone.now()
|
||||
overdue_reports = 0
|
||||
|
||||
for report in queryset.filter(status__in=["PENDING", "UNDER_REVIEW"]):
|
||||
sla_hours = {"URGENT": 2, "HIGH": 8, "MEDIUM": 24, "LOW": 72}
|
||||
hours_since_created = (now - report.created_at).total_seconds() / 3600
|
||||
if report.priority in sla_hours:
|
||||
threshold = sla_hours[report.priority]
|
||||
else:
|
||||
raise ValueError(f"Unknown priority level: {report.priority}")
|
||||
if hours_since_created > threshold:
|
||||
overdue_reports += 1
|
||||
|
||||
# Reports by priority and type
|
||||
reports_by_priority = dict(
|
||||
queryset.values_list("priority").annotate(count=Count("id"))
|
||||
)
|
||||
reports_by_type = dict(
|
||||
queryset.values_list("report_type").annotate(count=Count("id"))
|
||||
)
|
||||
|
||||
# Average resolution time
|
||||
resolved_queryset = queryset.filter(
|
||||
status="RESOLVED", resolved_at__isnull=False
|
||||
)
|
||||
|
||||
avg_resolution_time = 0
|
||||
if resolved_queryset.exists():
|
||||
total_time = sum(
|
||||
[
|
||||
(report.resolved_at - report.created_at).total_seconds() / 3600
|
||||
for report in resolved_queryset
|
||||
if report.resolved_at
|
||||
]
|
||||
)
|
||||
avg_resolution_time = total_time / resolved_queryset.count()
|
||||
|
||||
stats_data = {
|
||||
"total_reports": total_reports,
|
||||
"pending_reports": pending_reports,
|
||||
"resolved_reports": resolved_reports,
|
||||
"overdue_reports": overdue_reports,
|
||||
"reports_by_priority": reports_by_priority,
|
||||
"reports_by_type": reports_by_type,
|
||||
"average_resolution_time_hours": round(avg_resolution_time, 2),
|
||||
}
|
||||
|
||||
return Response(stats_data)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Moderation Queue ViewSet
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ModerationQueueViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing moderation queue items.
|
||||
|
||||
Provides workflow management for moderation tasks with assignment,
|
||||
completion, and progress tracking.
|
||||
"""
|
||||
|
||||
queryset = ModerationQueue.objects.select_related(
|
||||
"assigned_to", "related_report", "content_type"
|
||||
).all()
|
||||
|
||||
serializer_class = ModerationQueueSerializer
|
||||
permission_classes = [CanViewModerationData]
|
||||
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_class = ModerationQueueFilter
|
||||
search_fields = ["title", "description"]
|
||||
ordering_fields = ["created_at", "updated_at", "priority", "status"]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def assign(self, request, pk=None):
|
||||
"""Assign a queue item to a moderator."""
|
||||
queue_item = self.get_object()
|
||||
serializer = AssignQueueItemSerializer(data=request.data)
|
||||
|
||||
if serializer.is_valid():
|
||||
moderator_id = serializer.validated_data["moderator_id"]
|
||||
moderator = User.objects.get(id=moderator_id)
|
||||
|
||||
queue_item.assigned_to = moderator
|
||||
queue_item.assigned_at = timezone.now()
|
||||
queue_item.status = "IN_PROGRESS"
|
||||
queue_item.save()
|
||||
|
||||
response_serializer = self.get_serializer(queue_item)
|
||||
return Response(response_serializer.data)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def unassign(self, request, pk=None):
|
||||
"""Unassign a queue item."""
|
||||
queue_item = self.get_object()
|
||||
|
||||
queue_item.assigned_to = None
|
||||
queue_item.assigned_at = None
|
||||
queue_item.status = "PENDING"
|
||||
queue_item.save()
|
||||
|
||||
serializer = self.get_serializer(queue_item)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def complete(self, request, pk=None):
|
||||
"""Complete a queue item."""
|
||||
queue_item = self.get_object()
|
||||
serializer = CompleteQueueItemSerializer(data=request.data)
|
||||
|
||||
if serializer.is_valid():
|
||||
action_taken = serializer.validated_data["action"]
|
||||
notes = serializer.validated_data.get("notes", "")
|
||||
|
||||
queue_item.status = "COMPLETED"
|
||||
queue_item.save()
|
||||
|
||||
# Create moderation action if needed
|
||||
if action_taken != "NO_ACTION" and queue_item.related_report:
|
||||
ModerationAction.objects.create(
|
||||
action_type=action_taken,
|
||||
reason=f"Queue item completion: {action_taken}",
|
||||
details=notes,
|
||||
moderator=request.user,
|
||||
target_user=queue_item.related_report.reported_by,
|
||||
related_report=queue_item.related_report,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
response_serializer = self.get_serializer(queue_item)
|
||||
return Response(response_serializer.data)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=False, methods=["get"], permission_classes=[CanViewModerationData])
|
||||
def my_queue(self, request):
|
||||
"""Get queue items assigned to the current user."""
|
||||
queryset = self.get_queryset().filter(assigned_to=request.user)
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Moderation Action ViewSet
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ModerationActionViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing moderation actions.
|
||||
|
||||
Tracks actions taken against users and content with expiration
|
||||
and status management.
|
||||
"""
|
||||
|
||||
queryset = ModerationAction.objects.select_related(
|
||||
"moderator", "target_user", "related_report"
|
||||
).all()
|
||||
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_class = ModerationActionFilter
|
||||
search_fields = ["reason", "details"]
|
||||
ordering_fields = ["created_at", "expires_at", "action_type"]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Return appropriate serializer based on action."""
|
||||
if self.action == "create":
|
||||
return CreateModerationActionSerializer
|
||||
return ModerationActionSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
"""Return appropriate permissions based on action."""
|
||||
if self.action == "create":
|
||||
permission_classes = [IsModeratorOrAdmin]
|
||||
else:
|
||||
permission_classes = [CanViewModerationData]
|
||||
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def deactivate(self, request, pk=None):
|
||||
"""Deactivate a moderation action."""
|
||||
action_obj = self.get_object()
|
||||
|
||||
action_obj.is_active = False
|
||||
action_obj.save()
|
||||
|
||||
serializer = self.get_serializer(action_obj)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=["get"], permission_classes=[CanViewModerationData])
|
||||
def active(self, request):
|
||||
"""Get all active moderation actions."""
|
||||
queryset = self.get_queryset().filter(
|
||||
is_active=True, expires_at__gt=timezone.now()
|
||||
)
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=["get"], permission_classes=[CanViewModerationData])
|
||||
def expired(self, request):
|
||||
"""Get all expired moderation actions."""
|
||||
queryset = self.get_queryset().filter(
|
||||
expires_at__lte=timezone.now(), is_active=True
|
||||
)
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Bulk Operation ViewSet
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class BulkOperationViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing bulk operations.
|
||||
|
||||
Provides administrative bulk operations with progress tracking
|
||||
and cancellation support.
|
||||
"""
|
||||
|
||||
queryset = BulkOperation.objects.select_related("created_by").all()
|
||||
permission_classes = [IsAdminOrSuperuser]
|
||||
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_class = BulkOperationFilter
|
||||
search_fields = ["description"]
|
||||
ordering_fields = ["created_at", "started_at", "completed_at", "priority"]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Return appropriate serializer based on action."""
|
||||
if self.action == "create":
|
||||
return CreateBulkOperationSerializer
|
||||
return BulkOperationSerializer
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def cancel(self, request, pk=None):
|
||||
"""Cancel a bulk operation."""
|
||||
operation = self.get_object()
|
||||
|
||||
if operation.status not in ["PENDING", "RUNNING"]:
|
||||
return Response(
|
||||
{"error": "Operation cannot be cancelled"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if not operation.can_cancel:
|
||||
return Response(
|
||||
{"error": "Operation is not cancellable"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
operation.status = "CANCELLED"
|
||||
operation.completed_at = timezone.now()
|
||||
operation.save()
|
||||
|
||||
serializer = self.get_serializer(operation)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def retry(self, request, pk=None):
|
||||
"""Retry a failed bulk operation."""
|
||||
operation = self.get_object()
|
||||
|
||||
if operation.status != "FAILED":
|
||||
return Response(
|
||||
{"error": "Only failed operations can be retried"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Reset operation status
|
||||
operation.status = "PENDING"
|
||||
operation.started_at = None
|
||||
operation.completed_at = None
|
||||
operation.processed_items = 0
|
||||
operation.failed_items = 0
|
||||
operation.results = {}
|
||||
operation.save()
|
||||
|
||||
serializer = self.get_serializer(operation)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def logs(self, request, pk=None):
|
||||
"""Get logs for a bulk operation."""
|
||||
operation = self.get_object()
|
||||
|
||||
# This would typically fetch logs from a logging system
|
||||
# For now, return a placeholder response
|
||||
logs = {
|
||||
"logs": [
|
||||
{
|
||||
"timestamp": operation.created_at.isoformat(),
|
||||
"level": "INFO",
|
||||
"message": f"Operation {operation.id} created",
|
||||
"details": operation.parameters,
|
||||
}
|
||||
],
|
||||
"count": 1,
|
||||
}
|
||||
|
||||
return Response(logs)
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def running(self, request):
|
||||
"""Get all running bulk operations."""
|
||||
queryset = self.get_queryset().filter(status="RUNNING")
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# User Moderation ViewSet
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class UserModerationViewSet(viewsets.ViewSet):
|
||||
"""
|
||||
ViewSet for user moderation operations.
|
||||
|
||||
Provides user-specific moderation data, statistics, and actions.
|
||||
"""
|
||||
|
||||
permission_classes = [IsModeratorOrAdmin]
|
||||
# Default serializer for schema generation
|
||||
serializer_class = UserModerationProfileSerializer
|
||||
|
||||
def retrieve(self, request, pk=None):
|
||||
"""Get moderation profile for a specific user."""
|
||||
try:
|
||||
user = User.objects.get(pk=pk)
|
||||
except User.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "User not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
# Gather user moderation data
|
||||
reports_made = ModerationReport.objects.filter(reported_by=user).count()
|
||||
reports_against = ModerationReport.objects.filter(
|
||||
reported_entity_type="user", reported_entity_id=user.id
|
||||
).count()
|
||||
|
||||
actions_against = ModerationAction.objects.filter(target_user=user)
|
||||
warnings_received = actions_against.filter(action_type="WARNING").count()
|
||||
suspensions_received = actions_against.filter(
|
||||
action_type="USER_SUSPENSION"
|
||||
).count()
|
||||
active_restrictions = actions_against.filter(
|
||||
is_active=True, expires_at__gt=timezone.now()
|
||||
).count()
|
||||
|
||||
# Risk assessment (simplified)
|
||||
risk_factors = []
|
||||
risk_level = "LOW"
|
||||
|
||||
if reports_against > 5:
|
||||
risk_factors.append("Multiple reports against user")
|
||||
risk_level = "MEDIUM"
|
||||
|
||||
if suspensions_received > 0:
|
||||
risk_factors.append("Previous suspensions")
|
||||
risk_level = "HIGH"
|
||||
|
||||
if active_restrictions > 0:
|
||||
risk_factors.append("Active restrictions")
|
||||
risk_level = "HIGH"
|
||||
|
||||
# Recent activity
|
||||
recent_reports = ModerationReport.objects.filter(reported_by=user).order_by(
|
||||
"-created_at"
|
||||
)[:5]
|
||||
|
||||
recent_actions = actions_against.order_by("-created_at")[:5]
|
||||
|
||||
# Account status
|
||||
account_status = "ACTIVE"
|
||||
if getattr(user, "is_banned", False):
|
||||
account_status = "BANNED"
|
||||
elif active_restrictions > 0:
|
||||
account_status = "RESTRICTED"
|
||||
|
||||
last_violation = (
|
||||
actions_against.filter(
|
||||
action_type__in=["WARNING", "USER_SUSPENSION", "USER_BAN"]
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
profile_data = {
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"display_name": user.get_display_name(),
|
||||
"email": user.email,
|
||||
"role": getattr(user, "role", "USER"),
|
||||
},
|
||||
"reports_made": reports_made,
|
||||
"reports_against": reports_against,
|
||||
"warnings_received": warnings_received,
|
||||
"suspensions_received": suspensions_received,
|
||||
"active_restrictions": active_restrictions,
|
||||
"risk_level": risk_level,
|
||||
"risk_factors": risk_factors,
|
||||
"recent_reports": ModerationReportSerializer(
|
||||
recent_reports, many=True
|
||||
).data,
|
||||
"recent_actions": ModerationActionSerializer(
|
||||
recent_actions, many=True
|
||||
).data,
|
||||
"account_status": account_status,
|
||||
"last_violation_date": (
|
||||
last_violation.created_at if last_violation else None
|
||||
),
|
||||
"next_review_date": None, # Would be calculated based on business rules
|
||||
}
|
||||
|
||||
return Response(profile_data)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def moderate(self, request, pk=None):
|
||||
"""Take moderation action against a user."""
|
||||
try:
|
||||
user = User.objects.get(pk=pk)
|
||||
except User.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "User not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
serializer = CreateModerationActionSerializer(
|
||||
data=request.data, context={"request": request}
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
# Override target_user_id with the user from URL
|
||||
validated_data = serializer.validated_data.copy()
|
||||
validated_data["target_user_id"] = user.id
|
||||
|
||||
action = ModerationAction.objects.create(
|
||||
action_type=validated_data["action_type"],
|
||||
reason=validated_data["reason"],
|
||||
details=validated_data["details"],
|
||||
duration_hours=validated_data.get("duration_hours"),
|
||||
moderator=request.user,
|
||||
target_user=user,
|
||||
related_report_id=validated_data.get("related_report_id"),
|
||||
is_active=True,
|
||||
expires_at=(
|
||||
timezone.now() + timedelta(hours=validated_data["duration_hours"])
|
||||
if validated_data.get("duration_hours")
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
response_serializer = ModerationActionSerializer(action)
|
||||
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def search(self, request):
|
||||
"""Search users for moderation purposes."""
|
||||
query = request.query_params.get("query", "")
|
||||
role = request.query_params.get("role")
|
||||
has_restrictions = request.query_params.get("has_restrictions")
|
||||
|
||||
queryset = User.objects.all()
|
||||
|
||||
if query:
|
||||
queryset = queryset.filter(
|
||||
Q(username__icontains=query) | Q(email__icontains=query)
|
||||
)
|
||||
|
||||
if role:
|
||||
queryset = queryset.filter(role=role)
|
||||
|
||||
if has_restrictions == "true":
|
||||
active_action_users = ModerationAction.objects.filter(
|
||||
is_active=True, expires_at__gt=timezone.now()
|
||||
).values_list("target_user_id", flat=True)
|
||||
queryset = queryset.filter(id__in=active_action_users)
|
||||
|
||||
# Paginate results
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
users_data = []
|
||||
for user in page:
|
||||
restriction_count = ModerationAction.objects.filter(
|
||||
target_user=user, is_active=True, expires_at__gt=timezone.now()
|
||||
).count()
|
||||
|
||||
users_data.append(
|
||||
{
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"display_name": user.get_display_name(),
|
||||
"email": user.email,
|
||||
"role": getattr(user, "role", "USER"),
|
||||
"date_joined": user.date_joined,
|
||||
"last_login": user.last_login,
|
||||
"is_active": user.is_active,
|
||||
"restriction_count": restriction_count,
|
||||
"risk_level": "HIGH" if restriction_count > 0 else "LOW",
|
||||
}
|
||||
)
|
||||
|
||||
return self.get_paginated_response(users_data)
|
||||
|
||||
return Response([])
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def stats(self, request):
|
||||
"""Get overall user moderation statistics."""
|
||||
total_actions = ModerationAction.objects.count()
|
||||
active_actions = ModerationAction.objects.filter(
|
||||
is_active=True, expires_at__gt=timezone.now()
|
||||
).count()
|
||||
expired_actions = ModerationAction.objects.filter(
|
||||
expires_at__lte=timezone.now()
|
||||
).count()
|
||||
|
||||
stats_data = {
|
||||
"total_actions": total_actions,
|
||||
"active_actions": active_actions,
|
||||
"expired_actions": expired_actions,
|
||||
}
|
||||
|
||||
return Response(stats_data)
|
||||
Reference in New Issue
Block a user