Refactor API structure and add comprehensive user management features

- Restructure API v1 with improved serializers organization
- Add user deletion requests and moderation queue system
- Implement bulk moderation operations and permissions
- Add user profile enhancements with display names and avatars
- Expand ride and park API endpoints with better filtering
- Add manufacturer API with detailed ride relationships
- Improve authentication flows and error handling
- Update frontend documentation and API specifications
This commit is contained in:
pacnpal
2025-08-29 16:03:51 -04:00
parent 7b9f64be72
commit bb7da85516
92 changed files with 19690 additions and 9076 deletions

View File

@@ -0,0 +1,429 @@
"""
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,
)
User = get_user_model()
class ModerationReportFilter(django_filters.FilterSet):
"""Filter for ModerationReport model."""
# Status filters
status = django_filters.ChoiceFilter(
choices=ModerationReport.STATUS_CHOICES, help_text="Filter by report status"
)
# Priority filters
priority = django_filters.ChoiceFilter(
choices=ModerationReport.PRIORITY_CHOICES, help_text="Filter by report priority"
)
# Report type filters
report_type = django_filters.ChoiceFilter(
choices=ModerationReport.REPORT_TYPE_CHOICES, 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 hours_since_created > sla_hours.get(report.priority, 24):
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=ModerationQueue.STATUS_CHOICES, help_text="Filter by queue item status"
)
# Priority filters
priority = django_filters.ChoiceFilter(
choices=ModerationQueue.PRIORITY_CHOICES,
help_text="Filter by queue item priority",
)
# Item type filters
item_type = django_filters.ChoiceFilter(
choices=ModerationQueue.ITEM_TYPE_CHOICES, 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=ModerationAction.ACTION_TYPE_CHOICES, 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=BulkOperation.STATUS_CHOICES, help_text="Filter by operation status"
)
# Operation type filters
operation_type = django_filters.ChoiceFilter(
choices=BulkOperation.OPERATION_TYPE_CHOICES,
help_text="Filter by operation type",
)
# Priority filters
priority = django_filters.ChoiceFilter(
choices=BulkOperation.PRIORITY_CHOICES, 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"])

View File

@@ -0,0 +1,782 @@
# Generated by Django 5.2.5 on 2025-08-29 19:16
import django.db.models.deletion
import django.utils.timezone
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
(
"moderation",
"0003_bulkoperation_bulkoperationevent_moderationaction_and_more",
),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelOptions(
name="moderationqueue",
options={"ordering": ["priority", "created_at"]},
),
pgtrigger.migrations.RemoveTrigger(
model_name="moderationqueue",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="moderationqueue",
name="update_update",
),
migrations.RemoveIndex(
model_name="bulkoperation",
name="moderation__operati_bc84d9_idx",
),
migrations.RemoveIndex(
model_name="moderationaction",
name="moderation__action__7d7882_idx",
),
migrations.RemoveIndex(
model_name="moderationqueue",
name="moderation__entity__7c66ff_idx",
),
migrations.RemoveIndex(
model_name="moderationqueue",
name="moderation__flagged_169834_idx",
),
migrations.RemoveIndex(
model_name="moderationreport",
name="moderation__reporte_04923f_idx",
),
migrations.AlterField(
model_name="bulkoperation",
name="can_cancel",
field=models.BooleanField(
default=True, help_text="Whether this operation can be cancelled"
),
),
migrations.AlterField(
model_name="bulkoperation",
name="description",
field=models.TextField(help_text="Description of what this operation does"),
),
migrations.AlterField(
model_name="bulkoperation",
name="estimated_duration_minutes",
field=models.PositiveIntegerField(
blank=True, help_text="Estimated duration in minutes", null=True
),
),
migrations.AlterField(
model_name="bulkoperation",
name="failed_items",
field=models.PositiveIntegerField(
default=0, help_text="Number of items that failed"
),
),
migrations.AlterField(
model_name="bulkoperation",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
migrations.AlterField(
model_name="bulkoperation",
name="operation_type",
field=models.CharField(
choices=[
("UPDATE_PARKS", "Update Parks"),
("UPDATE_RIDES", "Update Rides"),
("IMPORT_DATA", "Import Data"),
("EXPORT_DATA", "Export Data"),
("MODERATE_CONTENT", "Moderate Content"),
("USER_ACTIONS", "User Actions"),
("CLEANUP", "Cleanup"),
("OTHER", "Other"),
],
max_length=50,
),
),
migrations.AlterField(
model_name="bulkoperation",
name="parameters",
field=models.JSONField(
default=dict, help_text="Parameters for the operation"
),
),
migrations.AlterField(
model_name="bulkoperation",
name="priority",
field=models.CharField(
choices=[
("LOW", "Low"),
("MEDIUM", "Medium"),
("HIGH", "High"),
("URGENT", "Urgent"),
],
default="MEDIUM",
max_length=10,
),
),
migrations.AlterField(
model_name="bulkoperation",
name="processed_items",
field=models.PositiveIntegerField(
default=0, help_text="Number of items processed"
),
),
migrations.AlterField(
model_name="bulkoperation",
name="results",
field=models.JSONField(
blank=True,
default=dict,
help_text="Results and output from the operation",
),
),
migrations.AlterField(
model_name="bulkoperation",
name="schedule_for",
field=models.DateTimeField(
blank=True, help_text="When to run this operation", null=True
),
),
migrations.AlterField(
model_name="bulkoperation",
name="total_items",
field=models.PositiveIntegerField(
default=0, help_text="Total number of items to process"
),
),
migrations.AlterField(
model_name="bulkoperationevent",
name="can_cancel",
field=models.BooleanField(
default=True, help_text="Whether this operation can be cancelled"
),
),
migrations.AlterField(
model_name="bulkoperationevent",
name="description",
field=models.TextField(help_text="Description of what this operation does"),
),
migrations.AlterField(
model_name="bulkoperationevent",
name="estimated_duration_minutes",
field=models.PositiveIntegerField(
blank=True, help_text="Estimated duration in minutes", null=True
),
),
migrations.AlterField(
model_name="bulkoperationevent",
name="failed_items",
field=models.PositiveIntegerField(
default=0, help_text="Number of items that failed"
),
),
migrations.AlterField(
model_name="bulkoperationevent",
name="id",
field=models.BigIntegerField(),
),
migrations.AlterField(
model_name="bulkoperationevent",
name="operation_type",
field=models.CharField(
choices=[
("UPDATE_PARKS", "Update Parks"),
("UPDATE_RIDES", "Update Rides"),
("IMPORT_DATA", "Import Data"),
("EXPORT_DATA", "Export Data"),
("MODERATE_CONTENT", "Moderate Content"),
("USER_ACTIONS", "User Actions"),
("CLEANUP", "Cleanup"),
("OTHER", "Other"),
],
max_length=50,
),
),
migrations.AlterField(
model_name="bulkoperationevent",
name="parameters",
field=models.JSONField(
default=dict, help_text="Parameters for the operation"
),
),
migrations.AlterField(
model_name="bulkoperationevent",
name="priority",
field=models.CharField(
choices=[
("LOW", "Low"),
("MEDIUM", "Medium"),
("HIGH", "High"),
("URGENT", "Urgent"),
],
default="MEDIUM",
max_length=10,
),
),
migrations.AlterField(
model_name="bulkoperationevent",
name="processed_items",
field=models.PositiveIntegerField(
default=0, help_text="Number of items processed"
),
),
migrations.AlterField(
model_name="bulkoperationevent",
name="results",
field=models.JSONField(
blank=True,
default=dict,
help_text="Results and output from the operation",
),
),
migrations.AlterField(
model_name="bulkoperationevent",
name="schedule_for",
field=models.DateTimeField(
blank=True, help_text="When to run this operation", null=True
),
),
migrations.AlterField(
model_name="bulkoperationevent",
name="total_items",
field=models.PositiveIntegerField(
default=0, help_text="Total number of items to process"
),
),
migrations.AlterField(
model_name="moderationaction",
name="action_type",
field=models.CharField(
choices=[
("WARNING", "Warning"),
("USER_SUSPENSION", "User Suspension"),
("USER_BAN", "User Ban"),
("CONTENT_REMOVAL", "Content Removal"),
("CONTENT_EDIT", "Content Edit"),
("CONTENT_RESTRICTION", "Content Restriction"),
("ACCOUNT_RESTRICTION", "Account Restriction"),
("OTHER", "Other"),
],
max_length=50,
),
),
migrations.AlterField(
model_name="moderationaction",
name="details",
field=models.TextField(help_text="Detailed explanation of the action"),
),
migrations.AlterField(
model_name="moderationaction",
name="duration_hours",
field=models.PositiveIntegerField(
blank=True,
help_text="Duration in hours for temporary actions",
null=True,
),
),
migrations.AlterField(
model_name="moderationaction",
name="expires_at",
field=models.DateTimeField(
blank=True, help_text="When this action expires", null=True
),
),
migrations.AlterField(
model_name="moderationaction",
name="is_active",
field=models.BooleanField(
default=True, help_text="Whether this action is currently active"
),
),
migrations.AlterField(
model_name="moderationaction",
name="reason",
field=models.CharField(
help_text="Brief reason for the action", max_length=200
),
),
migrations.AlterField(
model_name="moderationactionevent",
name="action_type",
field=models.CharField(
choices=[
("WARNING", "Warning"),
("USER_SUSPENSION", "User Suspension"),
("USER_BAN", "User Ban"),
("CONTENT_REMOVAL", "Content Removal"),
("CONTENT_EDIT", "Content Edit"),
("CONTENT_RESTRICTION", "Content Restriction"),
("ACCOUNT_RESTRICTION", "Account Restriction"),
("OTHER", "Other"),
],
max_length=50,
),
),
migrations.AlterField(
model_name="moderationactionevent",
name="details",
field=models.TextField(help_text="Detailed explanation of the action"),
),
migrations.AlterField(
model_name="moderationactionevent",
name="duration_hours",
field=models.PositiveIntegerField(
blank=True,
help_text="Duration in hours for temporary actions",
null=True,
),
),
migrations.AlterField(
model_name="moderationactionevent",
name="expires_at",
field=models.DateTimeField(
blank=True, help_text="When this action expires", null=True
),
),
migrations.AlterField(
model_name="moderationactionevent",
name="is_active",
field=models.BooleanField(
default=True, help_text="Whether this action is currently active"
),
),
migrations.AlterField(
model_name="moderationactionevent",
name="reason",
field=models.CharField(
help_text="Brief reason for the action", max_length=200
),
),
migrations.AlterField(
model_name="moderationqueue",
name="description",
field=models.TextField(
help_text="Detailed description of what needs to be done"
),
),
migrations.AlterField(
model_name="moderationqueue",
name="entity_id",
field=models.PositiveIntegerField(
blank=True, help_text="ID of the related entity", null=True
),
),
migrations.AlterField(
model_name="moderationqueue",
name="entity_preview",
field=models.JSONField(
blank=True, default=dict, help_text="Preview data for the entity"
),
),
migrations.AlterField(
model_name="moderationqueue",
name="entity_type",
field=models.CharField(
blank=True,
help_text="Type of entity (park, ride, user, etc.)",
max_length=50,
),
),
migrations.AlterField(
model_name="moderationqueue",
name="estimated_review_time",
field=models.PositiveIntegerField(
default=30, help_text="Estimated time in minutes"
),
),
migrations.AlterField(
model_name="moderationqueue",
name="flagged_by",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="flagged_queue_items",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="moderationqueue",
name="item_type",
field=models.CharField(
choices=[
("CONTENT_REVIEW", "Content Review"),
("USER_REVIEW", "User Review"),
("BULK_ACTION", "Bulk Action"),
("POLICY_VIOLATION", "Policy Violation"),
("APPEAL", "Appeal"),
("OTHER", "Other"),
],
max_length=50,
),
),
migrations.AlterField(
model_name="moderationqueue",
name="status",
field=models.CharField(
choices=[
("PENDING", "Pending"),
("IN_PROGRESS", "In Progress"),
("COMPLETED", "Completed"),
("CANCELLED", "Cancelled"),
],
default="PENDING",
max_length=20,
),
),
migrations.AlterField(
model_name="moderationqueue",
name="tags",
field=models.JSONField(
blank=True, default=list, help_text="Tags for categorization"
),
),
migrations.AlterField(
model_name="moderationqueue",
name="title",
field=models.CharField(
help_text="Brief title for the queue item", max_length=200
),
),
migrations.AlterField(
model_name="moderationqueueevent",
name="description",
field=models.TextField(
help_text="Detailed description of what needs to be done"
),
),
migrations.AlterField(
model_name="moderationqueueevent",
name="entity_id",
field=models.PositiveIntegerField(
blank=True, help_text="ID of the related entity", null=True
),
),
migrations.AlterField(
model_name="moderationqueueevent",
name="entity_preview",
field=models.JSONField(
blank=True, default=dict, help_text="Preview data for the entity"
),
),
migrations.AlterField(
model_name="moderationqueueevent",
name="entity_type",
field=models.CharField(
blank=True,
help_text="Type of entity (park, ride, user, etc.)",
max_length=50,
),
),
migrations.AlterField(
model_name="moderationqueueevent",
name="estimated_review_time",
field=models.PositiveIntegerField(
default=30, help_text="Estimated time in minutes"
),
),
migrations.AlterField(
model_name="moderationqueueevent",
name="flagged_by",
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="moderationqueueevent",
name="item_type",
field=models.CharField(
choices=[
("CONTENT_REVIEW", "Content Review"),
("USER_REVIEW", "User Review"),
("BULK_ACTION", "Bulk Action"),
("POLICY_VIOLATION", "Policy Violation"),
("APPEAL", "Appeal"),
("OTHER", "Other"),
],
max_length=50,
),
),
migrations.AlterField(
model_name="moderationqueueevent",
name="status",
field=models.CharField(
choices=[
("PENDING", "Pending"),
("IN_PROGRESS", "In Progress"),
("COMPLETED", "Completed"),
("CANCELLED", "Cancelled"),
],
default="PENDING",
max_length=20,
),
),
migrations.AlterField(
model_name="moderationqueueevent",
name="tags",
field=models.JSONField(
blank=True, default=list, help_text="Tags for categorization"
),
),
migrations.AlterField(
model_name="moderationqueueevent",
name="title",
field=models.CharField(
help_text="Brief title for the queue item", max_length=200
),
),
migrations.AlterField(
model_name="moderationreport",
name="description",
field=models.TextField(help_text="Detailed description of the issue"),
),
migrations.AlterField(
model_name="moderationreport",
name="evidence_urls",
field=models.JSONField(
blank=True,
default=list,
help_text="URLs to evidence (screenshots, etc.)",
),
),
migrations.AlterField(
model_name="moderationreport",
name="reason",
field=models.CharField(
help_text="Brief reason for the report", max_length=200
),
),
migrations.AlterField(
model_name="moderationreport",
name="report_type",
field=models.CharField(
choices=[
("SPAM", "Spam"),
("HARASSMENT", "Harassment"),
("INAPPROPRIATE_CONTENT", "Inappropriate Content"),
("MISINFORMATION", "Misinformation"),
("COPYRIGHT", "Copyright Violation"),
("PRIVACY", "Privacy Violation"),
("HATE_SPEECH", "Hate Speech"),
("VIOLENCE", "Violence or Threats"),
("OTHER", "Other"),
],
max_length=50,
),
),
migrations.AlterField(
model_name="moderationreport",
name="reported_entity_id",
field=models.PositiveIntegerField(
help_text="ID of the entity being reported"
),
),
migrations.AlterField(
model_name="moderationreport",
name="reported_entity_type",
field=models.CharField(
help_text="Type of entity being reported (park, ride, user, etc.)",
max_length=50,
),
),
migrations.AlterField(
model_name="moderationreport",
name="resolution_action",
field=models.CharField(
blank=True,
default=django.utils.timezone.now,
help_text="Action taken to resolve",
max_length=100,
),
preserve_default=False,
),
migrations.AlterField(
model_name="moderationreport",
name="resolution_notes",
field=models.TextField(blank=True, help_text="Notes about the resolution"),
),
migrations.AlterField(
model_name="moderationreport",
name="status",
field=models.CharField(
choices=[
("PENDING", "Pending Review"),
("UNDER_REVIEW", "Under Review"),
("RESOLVED", "Resolved"),
("DISMISSED", "Dismissed"),
],
default="PENDING",
max_length=20,
),
),
migrations.AlterField(
model_name="moderationreportevent",
name="description",
field=models.TextField(help_text="Detailed description of the issue"),
),
migrations.AlterField(
model_name="moderationreportevent",
name="evidence_urls",
field=models.JSONField(
blank=True,
default=list,
help_text="URLs to evidence (screenshots, etc.)",
),
),
migrations.AlterField(
model_name="moderationreportevent",
name="reason",
field=models.CharField(
help_text="Brief reason for the report", max_length=200
),
),
migrations.AlterField(
model_name="moderationreportevent",
name="report_type",
field=models.CharField(
choices=[
("SPAM", "Spam"),
("HARASSMENT", "Harassment"),
("INAPPROPRIATE_CONTENT", "Inappropriate Content"),
("MISINFORMATION", "Misinformation"),
("COPYRIGHT", "Copyright Violation"),
("PRIVACY", "Privacy Violation"),
("HATE_SPEECH", "Hate Speech"),
("VIOLENCE", "Violence or Threats"),
("OTHER", "Other"),
],
max_length=50,
),
),
migrations.AlterField(
model_name="moderationreportevent",
name="reported_entity_id",
field=models.PositiveIntegerField(
help_text="ID of the entity being reported"
),
),
migrations.AlterField(
model_name="moderationreportevent",
name="reported_entity_type",
field=models.CharField(
help_text="Type of entity being reported (park, ride, user, etc.)",
max_length=50,
),
),
migrations.AlterField(
model_name="moderationreportevent",
name="resolution_action",
field=models.CharField(
blank=True,
default=django.utils.timezone.now,
help_text="Action taken to resolve",
max_length=100,
),
preserve_default=False,
),
migrations.AlterField(
model_name="moderationreportevent",
name="resolution_notes",
field=models.TextField(blank=True, help_text="Notes about the resolution"),
),
migrations.AlterField(
model_name="moderationreportevent",
name="status",
field=models.CharField(
choices=[
("PENDING", "Pending Review"),
("UNDER_REVIEW", "Under Review"),
("RESOLVED", "Resolved"),
("DISMISSED", "Dismissed"),
],
default="PENDING",
max_length=20,
),
),
migrations.AddIndex(
model_name="bulkoperation",
index=models.Index(
fields=["schedule_for"], name="moderation__schedul_350704_idx"
),
),
migrations.AddIndex(
model_name="bulkoperation",
index=models.Index(
fields=["created_at"], name="moderation__created_b705f4_idx"
),
),
migrations.AddIndex(
model_name="moderationaction",
index=models.Index(
fields=["moderator"], name="moderation__moderat_1c19b0_idx"
),
),
migrations.AddIndex(
model_name="moderationaction",
index=models.Index(
fields=["created_at"], name="moderation__created_6378e6_idx"
),
),
migrations.AddIndex(
model_name="moderationqueue",
index=models.Index(
fields=["created_at"], name="moderation__created_fe6dd0_idx"
),
),
migrations.AddIndex(
model_name="moderationreport",
index=models.Index(
fields=["reported_by"], name="moderation__reporte_81af56_idx"
),
),
migrations.AddIndex(
model_name="moderationreport",
index=models.Index(
fields=["created_at"], name="moderation__created_ae337c_idx"
),
),
pgtrigger.migrations.AddTrigger(
model_name="moderationqueue",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "moderation_moderationqueueevent" ("assigned_at", "assigned_to_id", "content_type_id", "created_at", "description", "entity_id", "entity_preview", "entity_type", "estimated_review_time", "flagged_by_id", "id", "item_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "priority", "related_report_id", "status", "tags", "title", "updated_at") VALUES (NEW."assigned_at", NEW."assigned_to_id", NEW."content_type_id", NEW."created_at", NEW."description", NEW."entity_id", NEW."entity_preview", NEW."entity_type", NEW."estimated_review_time", NEW."flagged_by_id", NEW."id", NEW."item_type", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."priority", NEW."related_report_id", NEW."status", NEW."tags", NEW."title", NEW."updated_at"); RETURN NULL;',
hash="55993d8cb4981feed7b3febde9e87989481a8a34",
operation="INSERT",
pgid="pgtrigger_insert_insert_cf9cb",
table="moderation_moderationqueue",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="moderationqueue",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "moderation_moderationqueueevent" ("assigned_at", "assigned_to_id", "content_type_id", "created_at", "description", "entity_id", "entity_preview", "entity_type", "estimated_review_time", "flagged_by_id", "id", "item_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "priority", "related_report_id", "status", "tags", "title", "updated_at") VALUES (NEW."assigned_at", NEW."assigned_to_id", NEW."content_type_id", NEW."created_at", NEW."description", NEW."entity_id", NEW."entity_preview", NEW."entity_type", NEW."estimated_review_time", NEW."flagged_by_id", NEW."id", NEW."item_type", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."priority", NEW."related_report_id", NEW."status", NEW."tags", NEW."title", NEW."updated_at"); RETURN NULL;',
hash="8da070419fd1efd43bfb272a431392b6244a7739",
operation="UPDATE",
pgid="pgtrigger_update_update_3b3aa",
table="moderation_moderationqueue",
when="AFTER",
),
),
),
]

View File

@@ -1,4 +1,17 @@
from typing import Any, Dict, Optional, Type, Union
"""
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
@@ -7,12 +20,17 @@ 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
UserType = Union[AbstractBaseUser, AnonymousUser]
# ============================================================================
# Original EditSubmission Model (Preserved)
# ============================================================================
@pghistory.track() # Track all changes by default
class EditSubmission(TrackedModel):
STATUS_CHOICES = [
@@ -79,7 +97,7 @@ class EditSubmission(TrackedModel):
blank=True, help_text="Notes from the moderator about this submission"
)
class Meta:
class Meta(TrackedModel.Meta):
ordering = ["-created_at"]
indexes = [
models.Index(fields=["content_type", "object_id"]),
@@ -103,128 +121,508 @@ class EditSubmission(TrackedModel):
for field_name, value in data.items():
try:
if (
(field := model_class._meta.get_field(field_name))
and isinstance(field, models.ForeignKey)
and value is not None
):
if related_model := field.related_model:
resolved_data[field_name] = related_model.objects.get(pk=value)
except (FieldDoesNotExist, ObjectDoesNotExist):
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)
resolved_data[field_name] = related_obj
except ObjectDoesNotExist:
raise ValueError(
f"Related object {field.related_model.__name__} with pk={value} does not exist"
)
except FieldDoesNotExist:
# Field doesn't exist on model, skip it
continue
return resolved_data
def _prepare_model_data(
self, data: Dict[str, Any], model_class: Type[models.Model]
) -> Dict[str, Any]:
"""Prepare data for model creation/update by filtering out auto-generated fields"""
prepared_data = data.copy()
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
# Remove fields that are auto-generated or handled by the model's save
# method
auto_fields = {"created_at", "updated_at", "slug"}
for field in auto_fields:
prepared_data.pop(field, None)
def approve(self, moderator: UserType) -> Optional[models.Model]:
"""
Approve this submission and apply the changes.
# Set default values for required fields if not provided
for field in model_class._meta.fields:
if not field.auto_created and not field.blank and not field.null:
if field.name not in prepared_data and field.has_default():
prepared_data[field.name] = field.get_default()
Args:
moderator: The user approving the submission
return prepared_data
Returns:
The created or updated model instance
def _check_duplicate_name(
self, model_class: Type[models.Model], name: str
) -> Optional[models.Model]:
"""Check if an object with the same name already exists"""
try:
return model_class.objects.filter(name=name).first()
except BaseException as e:
print(f"Error checking for duplicate name '{name}': {e}")
raise e
return None
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}")
def approve(self, user: UserType) -> Optional[models.Model]:
"""Approve the submission and apply the changes"""
if not (model_class := self.content_type.model_class()):
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:
# Use moderator_changes if available, otherwise use original
# changes
changes_to_apply = (
self.moderator_changes
if self.moderator_changes is not None
else self.changes
)
resolved_data = self._resolve_foreign_keys(changes_to_apply)
prepared_data = self._prepare_model_data(resolved_data, model_class)
# For CREATE submissions, check for duplicates by name
if self.submission_type == "CREATE" and "name" in prepared_data:
if existing_obj := self._check_duplicate_name(
model_class, prepared_data["name"]
):
self.status = "REJECTED"
self.handled_by = user # type: ignore
self.handled_at = timezone.now()
self.notes = f"A {model_class.__name__} with the name '{
prepared_data['name']
}' already exists (ID: {existing_obj.pk})"
self.save()
raise ValueError(self.notes)
self.status = "APPROVED"
self.handled_by = user # type: ignore
self.handled_at = timezone.now()
if self.submission_type == "CREATE":
# Create new object
obj = model_class(**prepared_data)
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
obj = model_class(**resolved_changes)
obj.full_clean()
obj.save()
# Update object_id after creation
self.object_id = getattr(obj, "id", None)
else:
# Apply changes to existing object
if not (obj := self.content_object):
raise ValueError("Content object not found")
for field, value in prepared_data.items():
setattr(obj, field, value)
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
# 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()
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
self.full_clean()
# Mark submission as approved
self.status = "APPROVED"
self.handled_by = moderator
self.handled_at = timezone.now()
self.save()
return obj
except Exception as e:
if (
self.status != "REJECTED"
): # Don't override if already rejected due to duplicate
self.status = "PENDING" # Reset status if approval failed
self.save()
raise ValueError(f"Error approving submission: {str(e)}") from 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}")
def reject(self, user: UserType) -> None:
"""Reject the submission"""
self.status = "REJECTED"
self.handled_by = user # type: ignore
self.handled_by = moderator
self.handled_at = timezone.now()
self.notes = f"Rejected: {reason}"
self.save()
def escalate(self, user: UserType) -> None:
"""Escalate the submission to admin"""
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 = user # type: ignore
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.
"""
STATUS_CHOICES = [
('PENDING', 'Pending Review'),
('UNDER_REVIEW', 'Under Review'),
('RESOLVED', 'Resolved'),
('DISMISSED', 'Dismissed'),
]
PRIORITY_CHOICES = [
('LOW', 'Low'),
('MEDIUM', 'Medium'),
('HIGH', 'High'),
('URGENT', 'Urgent'),
]
REPORT_TYPE_CHOICES = [
('SPAM', 'Spam'),
('HARASSMENT', 'Harassment'),
('INAPPROPRIATE_CONTENT', 'Inappropriate Content'),
('MISINFORMATION', 'Misinformation'),
('COPYRIGHT', 'Copyright Violation'),
('PRIVACY', 'Privacy Violation'),
('HATE_SPEECH', 'Hate Speech'),
('VIOLENCE', 'Violence or Threats'),
('OTHER', 'Other'),
]
# Report details
report_type = models.CharField(max_length=50, choices=REPORT_TYPE_CHOICES)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='PENDING')
priority = models.CharField(
max_length=10, choices=PRIORITY_CHOICES, 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}"
@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.
"""
STATUS_CHOICES = [
('PENDING', 'Pending'),
('IN_PROGRESS', 'In Progress'),
('COMPLETED', 'Completed'),
('CANCELLED', 'Cancelled'),
]
PRIORITY_CHOICES = [
('LOW', 'Low'),
('MEDIUM', 'Medium'),
('HIGH', 'High'),
('URGENT', 'Urgent'),
]
ITEM_TYPE_CHOICES = [
('CONTENT_REVIEW', 'Content Review'),
('USER_REVIEW', 'User Review'),
('BULK_ACTION', 'Bulk Action'),
('POLICY_VIOLATION', 'Policy Violation'),
('APPEAL', 'Appeal'),
('OTHER', 'Other'),
]
# Queue item details
item_type = models.CharField(max_length=50, choices=ITEM_TYPE_CHOICES)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='PENDING')
priority = models.CharField(
max_length=10, choices=PRIORITY_CHOICES, 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}"
@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_TYPE_CHOICES = [
('WARNING', 'Warning'),
('USER_SUSPENSION', 'User Suspension'),
('USER_BAN', 'User Ban'),
('CONTENT_REMOVAL', 'Content Removal'),
('CONTENT_EDIT', 'Content Edit'),
('CONTENT_RESTRICTION', 'Content Restriction'),
('ACCOUNT_RESTRICTION', 'Account Restriction'),
('OTHER', 'Other'),
]
# Action details
action_type = models.CharField(max_length=50, choices=ACTION_TYPE_CHOICES)
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}"
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.
"""
STATUS_CHOICES = [
('PENDING', 'Pending'),
('RUNNING', 'Running'),
('COMPLETED', 'Completed'),
('FAILED', 'Failed'),
('CANCELLED', 'Cancelled'),
]
PRIORITY_CHOICES = [
('LOW', 'Low'),
('MEDIUM', 'Medium'),
('HIGH', 'High'),
('URGENT', 'Urgent'),
]
OPERATION_TYPE_CHOICES = [
('UPDATE_PARKS', 'Update Parks'),
('UPDATE_RIDES', 'Update Rides'),
('IMPORT_DATA', 'Import Data'),
('EXPORT_DATA', 'Export Data'),
('MODERATE_CONTENT', 'Moderate Content'),
('USER_ACTIONS', 'User Actions'),
('CLEANUP', 'Cleanup'),
('OTHER', 'Other'),
]
# Operation details
operation_type = models.CharField(max_length=50, choices=OPERATION_TYPE_CHOICES)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='PENDING')
priority = models.CharField(
max_length=10, choices=PRIORITY_CHOICES, 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]}"
@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):
@@ -270,7 +668,7 @@ class PhotoSubmission(TrackedModel):
help_text="Notes from the moderator about this photo submission",
)
class Meta:
class Meta(TrackedModel.Meta):
ordering = ["-created_at"]
indexes = [
models.Index(fields=["content_type", "object_id"]),
@@ -319,7 +717,7 @@ class PhotoSubmission(TrackedModel):
self.save()
def auto_approve(self) -> None:
"""Auto-approve submissions from moderators"""
"""Auto - approve submissions from moderators"""
# Get user role safely
user_role = getattr(self.user, "role", None)

View 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

View File

@@ -27,14 +27,14 @@ def pending_submissions_for_review(
"""
queryset = (
EditSubmission.objects.filter(status="PENDING")
.select_related("submitted_by", "content_type")
.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("submitted_at")[:limit]
return queryset.order_by("created_at")[:limit]
def submissions_by_user(
@@ -50,14 +50,14 @@ def submissions_by_user(
Returns:
QuerySet of user's submissions
"""
queryset = EditSubmission.objects.filter(submitted_by_id=user_id).select_related(
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("-submitted_at")
return queryset.order_by("-created_at")
def submissions_handled_by_moderator(
@@ -79,7 +79,7 @@ def submissions_handled_by_moderator(
EditSubmission.objects.filter(
handled_by_id=moderator_id, handled_at__gte=cutoff_date
)
.select_related("submitted_by", "content_type")
.select_related("user", "content_type")
.order_by("-handled_at")
)
@@ -97,9 +97,9 @@ def recent_submissions(*, days: int = 7) -> QuerySet[EditSubmission]:
cutoff_date = timezone.now() - timedelta(days=days)
return (
EditSubmission.objects.filter(submitted_at__gte=cutoff_date)
.select_related("submitted_by", "content_type", "handled_by")
.order_by("-submitted_at")
EditSubmission.objects.filter(created_at__gte=cutoff_date)
.select_related("user", "content_type", "handled_by")
.order_by("-created_at")
)
@@ -118,12 +118,12 @@ def submissions_by_content_type(
"""
queryset = EditSubmission.objects.filter(
content_type__model=content_type.lower()
).select_related("submitted_by", "handled_by")
).select_related("user", "handled_by")
if status:
queryset = queryset.filter(status=status)
return queryset.order_by("-submitted_at")
return queryset.order_by("-created_at")
def moderation_queue_summary() -> Dict[str, Any]:
@@ -172,7 +172,7 @@ def moderation_statistics_summary(
"""
cutoff_date = timezone.now() - timedelta(days=days)
base_queryset = EditSubmission.objects.filter(submitted_at__gte=cutoff_date)
base_queryset = EditSubmission.objects.filter(created_at__gte=cutoff_date)
if moderator:
handled_queryset = base_queryset.filter(handled_by=moderator)
@@ -189,7 +189,7 @@ def moderation_statistics_summary(
handled_queryset.exclude(handled_at__isnull=True)
.extra(
select={
"response_hours": "EXTRACT(EPOCH FROM (handled_at - submitted_at)) / 3600"
"response_hours": "EXTRACT(EPOCH FROM (handled_at - created_at)) / 3600"
}
)
.values_list("response_hours", flat=True)
@@ -228,9 +228,9 @@ def submissions_needing_attention(*, hours: int = 24) -> QuerySet[EditSubmission
cutoff_time = timezone.now() - timedelta(hours=hours)
return (
EditSubmission.objects.filter(status="PENDING", submitted_at__lte=cutoff_time)
.select_related("submitted_by", "content_type")
.order_by("submitted_at")
EditSubmission.objects.filter(status="PENDING", created_at__lte=cutoff_time)
.select_related("user", "content_type")
.order_by("created_at")
)
@@ -248,7 +248,7 @@ def top_contributors(*, days: int = 30, limit: int = 10) -> QuerySet[User]:
cutoff_date = timezone.now() - timedelta(days=days)
return (
User.objects.filter(edit_submissions__submitted_at__gte=cutoff_date)
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]

View File

@@ -0,0 +1,735 @@
"""
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}
return hours_since_created > sla_hours.get(obj.priority, 24)
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",
"CONTENT_REMOVED",
"CONTENT_EDITED",
"USER_WARNING",
"USER_SUSPENDED",
"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", "MEDIUM", "HIGH", "CRITICAL"])
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)

View File

@@ -6,10 +6,11 @@ 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.contrib.auth.models import User
from django.db.models import QuerySet
from django.contrib.contenttypes.models import ContentType
from .models import EditSubmission
from apps.accounts.models import User
from .models import EditSubmission, PhotoSubmission, ModerationQueue
class ModerationService:
@@ -133,9 +134,9 @@ class ModerationService:
submission = EditSubmission(
content_object=content_object,
changes=changes,
submitted_by=submitter,
user=submitter,
submission_type=submission_type,
notes=notes or "",
reason=notes or "",
)
# Call full_clean before saving - CRITICAL STYLEGUIDE FIX
@@ -228,3 +229,415 @@ class ModerationService:
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

View File

@@ -1,58 +1,87 @@
from django.urls import path
from django.shortcuts import redirect
from django.urls import reverse_lazy
from . import views
"""
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"
def redirect_to_dashboard(request):
return redirect(reverse_lazy("moderation:dashboard"))
urlpatterns = [
# Root URL redirects to dashboard
path("", redirect_to_dashboard),
# Dashboard and Submissions
path("dashboard/", views.DashboardView.as_view(), name="dashboard"),
path("submissions/", views.submission_list, name="submission_list"),
# Search endpoints
path("search/parks/", views.search_parks, name="search_parks"),
path(
"search/ride-models/",
views.search_ride_models,
name="search_ride_models",
),
# Submission Actions
path(
"submissions/<int:submission_id>/edit/",
views.edit_submission,
name="edit_submission",
),
path(
"submissions/<int:submission_id>/approve/",
views.approve_submission,
name="approve_submission",
),
path(
"submissions/<int:submission_id>/reject/",
views.reject_submission,
name="reject_submission",
),
path(
"submissions/<int:submission_id>/escalate/",
views.escalate_submission,
name="escalate_submission",
),
# Photo Submissions
path(
"photos/<int:submission_id>/approve/",
views.approve_photo,
name="approve_photo",
),
path(
"photos/<int:submission_id>/reject/",
views.reject_photo,
name="reject_photo",
),
# 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

File diff suppressed because it is too large Load Diff