feat: Implement initial schema and add various API, service, and management command enhancements across the application.

This commit is contained in:
pacnpal
2026-01-01 15:13:01 -05:00
parent c95f99ca10
commit b243b17af7
413 changed files with 11164 additions and 17433 deletions

View File

@@ -51,20 +51,16 @@ class ModerationAdminSite(AdminSite):
extra_context = extra_context or {}
# Get pending counts
extra_context["pending_edits"] = EditSubmission.objects.filter(
status="PENDING"
).count()
extra_context["pending_photos"] = PhotoSubmission.objects.filter(
status="PENDING"
).count()
extra_context["pending_edits"] = EditSubmission.objects.filter(status="PENDING").count()
extra_context["pending_photos"] = PhotoSubmission.objects.filter(status="PENDING").count()
# Get recent activity
extra_context["recent_edits"] = EditSubmission.objects.select_related(
"user", "handled_by"
).order_by("-created_at")[:5]
extra_context["recent_photos"] = PhotoSubmission.objects.select_related(
"user", "handled_by"
).order_by("-created_at")[:5]
extra_context["recent_edits"] = EditSubmission.objects.select_related("user", "handled_by").order_by(
"-created_at"
)[:5]
extra_context["recent_photos"] = PhotoSubmission.objects.select_related("user", "handled_by").order_by(
"-created_at"
)[:5]
return super().index(request, extra_context)
@@ -639,9 +635,7 @@ class StateLogAdmin(admin.ModelAdmin):
output = StringIO()
writer = csv.writer(output)
writer.writerow(
["ID", "Timestamp", "Model", "Object ID", "State", "Transition", "User"]
)
writer.writerow(["ID", "Timestamp", "Model", "Object ID", "State", "Transition", "User"])
for log in queryset:
writer.writerow(

View File

@@ -82,82 +82,31 @@ class ModerationConfig(AppConfig):
)
# EditSubmission callbacks (transitions from CLAIMED state)
register_callback(
EditSubmission, 'status', 'CLAIMED', 'APPROVED',
SubmissionApprovedNotification()
)
register_callback(
EditSubmission, 'status', 'CLAIMED', 'APPROVED',
ModerationCacheInvalidation()
)
register_callback(
EditSubmission, 'status', 'CLAIMED', 'REJECTED',
SubmissionRejectedNotification()
)
register_callback(
EditSubmission, 'status', 'CLAIMED', 'REJECTED',
ModerationCacheInvalidation()
)
register_callback(
EditSubmission, 'status', 'CLAIMED', 'ESCALATED',
SubmissionEscalatedNotification()
)
register_callback(
EditSubmission, 'status', 'CLAIMED', 'ESCALATED',
ModerationCacheInvalidation()
)
register_callback(EditSubmission, "status", "CLAIMED", "APPROVED", SubmissionApprovedNotification())
register_callback(EditSubmission, "status", "CLAIMED", "APPROVED", ModerationCacheInvalidation())
register_callback(EditSubmission, "status", "CLAIMED", "REJECTED", SubmissionRejectedNotification())
register_callback(EditSubmission, "status", "CLAIMED", "REJECTED", ModerationCacheInvalidation())
register_callback(EditSubmission, "status", "CLAIMED", "ESCALATED", SubmissionEscalatedNotification())
register_callback(EditSubmission, "status", "CLAIMED", "ESCALATED", ModerationCacheInvalidation())
# PhotoSubmission callbacks (transitions from CLAIMED state)
register_callback(
PhotoSubmission, 'status', 'CLAIMED', 'APPROVED',
SubmissionApprovedNotification()
)
register_callback(
PhotoSubmission, 'status', 'CLAIMED', 'APPROVED',
ModerationCacheInvalidation()
)
register_callback(
PhotoSubmission, 'status', 'CLAIMED', 'REJECTED',
SubmissionRejectedNotification()
)
register_callback(
PhotoSubmission, 'status', 'CLAIMED', 'REJECTED',
ModerationCacheInvalidation()
)
register_callback(
PhotoSubmission, 'status', 'CLAIMED', 'ESCALATED',
SubmissionEscalatedNotification()
)
register_callback(PhotoSubmission, "status", "CLAIMED", "APPROVED", SubmissionApprovedNotification())
register_callback(PhotoSubmission, "status", "CLAIMED", "APPROVED", ModerationCacheInvalidation())
register_callback(PhotoSubmission, "status", "CLAIMED", "REJECTED", SubmissionRejectedNotification())
register_callback(PhotoSubmission, "status", "CLAIMED", "REJECTED", ModerationCacheInvalidation())
register_callback(PhotoSubmission, "status", "CLAIMED", "ESCALATED", SubmissionEscalatedNotification())
# ModerationReport callbacks
register_callback(
ModerationReport, 'status', '*', '*',
ModerationNotificationCallback()
)
register_callback(
ModerationReport, 'status', '*', '*',
ModerationCacheInvalidation()
)
register_callback(ModerationReport, "status", "*", "*", ModerationNotificationCallback())
register_callback(ModerationReport, "status", "*", "*", ModerationCacheInvalidation())
# ModerationQueue callbacks
register_callback(
ModerationQueue, 'status', '*', '*',
ModerationNotificationCallback()
)
register_callback(
ModerationQueue, 'status', '*', '*',
ModerationCacheInvalidation()
)
register_callback(ModerationQueue, "status", "*", "*", ModerationNotificationCallback())
register_callback(ModerationQueue, "status", "*", "*", ModerationCacheInvalidation())
# BulkOperation callbacks
register_callback(
BulkOperation, 'status', '*', '*',
ModerationNotificationCallback()
)
register_callback(
BulkOperation, 'status', '*', '*',
ModerationCacheInvalidation()
)
register_callback(BulkOperation, "status", "*", "*", ModerationNotificationCallback())
register_callback(BulkOperation, "status", "*", "*", ModerationCacheInvalidation())
logger.debug("Registered moderation transition callbacks")

File diff suppressed because it is too large Load Diff

View File

@@ -11,14 +11,9 @@ def moderation_access(request):
context["user_role"] = request.user.role
# Check both role-based and Django's built-in superuser status
context["has_moderation_access"] = (
request.user.role in ["MODERATOR", "ADMIN", "SUPERUSER"]
or request.user.is_superuser
)
context["has_admin_access"] = (
request.user.role in ["ADMIN", "SUPERUSER"] or request.user.is_superuser
)
context["has_superuser_access"] = (
request.user.role == "SUPERUSER" or request.user.is_superuser
request.user.role in ["MODERATOR", "ADMIN", "SUPERUSER"] or request.user.is_superuser
)
context["has_admin_access"] = request.user.role in ["ADMIN", "SUPERUSER"] or request.user.is_superuser
context["has_superuser_access"] = request.user.role == "SUPERUSER" or request.user.is_superuser
return context

View File

@@ -29,20 +29,22 @@ class ModerationReportFilter(django_filters.FilterSet):
# Status filters
status = django_filters.ChoiceFilter(
choices=lambda: [(choice.value, choice.label) for choice in get_choices("moderation_report_statuses", "moderation")],
help_text="Filter by report status"
choices=lambda: [
(choice.value, choice.label) for choice in get_choices("moderation_report_statuses", "moderation")
],
help_text="Filter by report status",
)
# Priority filters
priority = django_filters.ChoiceFilter(
choices=lambda: [(choice.value, choice.label) for choice in get_choices("priority_levels", "moderation")],
help_text="Filter by report priority"
help_text="Filter by report priority",
)
# Report type filters
report_type = django_filters.ChoiceFilter(
choices=lambda: [(choice.value, choice.label) for choice in get_choices("report_types", "moderation")],
help_text="Filter by report type"
help_text="Filter by report type",
)
# User filters
@@ -87,13 +89,9 @@ class ModerationReportFilter(django_filters.FilterSet):
)
# Special filters
unassigned = django_filters.BooleanFilter(
method="filter_unassigned", help_text="Filter for unassigned reports"
)
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"
)
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",
@@ -143,12 +141,8 @@ class ModerationReportFilter(django_filters.FilterSet):
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="")
)
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):
@@ -156,8 +150,10 @@ class ModerationQueueFilter(django_filters.FilterSet):
# Status filters
status = django_filters.ChoiceFilter(
choices=lambda: [(choice.value, choice.label) for choice in get_choices("moderation_queue_statuses", "moderation")],
help_text="Filter by queue item status"
choices=lambda: [
(choice.value, choice.label) for choice in get_choices("moderation_queue_statuses", "moderation")
],
help_text="Filter by queue item status",
)
# Priority filters
@@ -169,7 +165,7 @@ class ModerationQueueFilter(django_filters.FilterSet):
# Item type filters
item_type = django_filters.ChoiceFilter(
choices=lambda: [(choice.value, choice.label) for choice in get_choices("queue_item_types", "moderation")],
help_text="Filter by queue item type"
help_text="Filter by queue item type",
)
# Assignment filters
@@ -178,9 +174,7 @@ class ModerationQueueFilter(django_filters.FilterSet):
help_text="Filter by assigned moderator",
)
unassigned = django_filters.BooleanFilter(
method="filter_unassigned", help_text="Filter for unassigned queue items"
)
unassigned = django_filters.BooleanFilter(method="filter_unassigned", help_text="Filter for unassigned queue items")
# Date filters
created_after = django_filters.DateTimeFilter(
@@ -208,9 +202,7 @@ class ModerationQueueFilter(django_filters.FilterSet):
)
# Content type filters
content_type = django_filters.CharFilter(
field_name="content_type__model", help_text="Filter by content type"
)
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(
@@ -248,8 +240,10 @@ class ModerationActionFilter(django_filters.FilterSet):
# Action type filters
action_type = django_filters.ChoiceFilter(
choices=lambda: [(choice.value, choice.label) for choice in get_choices("moderation_action_types", "moderation")],
help_text="Filter by action type"
choices=lambda: [
(choice.value, choice.label) for choice in get_choices("moderation_action_types", "moderation")
],
help_text="Filter by action type",
)
# User filters
@@ -258,9 +252,7 @@ class ModerationActionFilter(django_filters.FilterSet):
help_text="Filter by moderator who took the action",
)
target_user = django_filters.ModelChoiceFilter(
queryset=User.objects.all(), help_text="Filter by target user"
)
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")
@@ -291,9 +283,7 @@ class ModerationActionFilter(django_filters.FilterSet):
)
# Special filters
expired = django_filters.BooleanFilter(
method="filter_expired", help_text="Filter for expired actions"
)
expired = django_filters.BooleanFilter(method="filter_expired", help_text="Filter for expired actions")
expiring_soon = django_filters.BooleanFilter(
method="filter_expiring_soon",
@@ -345,8 +335,10 @@ class BulkOperationFilter(django_filters.FilterSet):
# Status filters
status = django_filters.ChoiceFilter(
choices=lambda: [(choice.value, choice.label) for choice in get_choices("bulk_operation_statuses", "moderation")],
help_text="Filter by operation status"
choices=lambda: [
(choice.value, choice.label) for choice in get_choices("bulk_operation_statuses", "moderation")
],
help_text="Filter by operation status",
)
# Operation type filters
@@ -358,7 +350,7 @@ class BulkOperationFilter(django_filters.FilterSet):
# Priority filters
priority = django_filters.ChoiceFilter(
choices=lambda: [(choice.value, choice.label) for choice in get_choices("priority_levels", "moderation")],
help_text="Filter by operation priority"
help_text="Filter by operation priority",
)
# User filters
@@ -405,9 +397,7 @@ class BulkOperationFilter(django_filters.FilterSet):
)
# Special filters
can_cancel = django_filters.BooleanFilter(
help_text="Filter by cancellation capability"
)
can_cancel = django_filters.BooleanFilter(help_text="Filter by cancellation capability")
has_failures = django_filters.BooleanFilter(
method="filter_has_failures",

View File

@@ -16,36 +16,25 @@ from django_fsm_log.models import StateLog
class Command(BaseCommand):
help = 'Analyze state transition patterns and generate statistics'
help = "Analyze state transition patterns and generate statistics"
def add_arguments(self, parser):
parser.add_argument("--days", type=int, default=30, help="Number of days to analyze (default: 30)")
parser.add_argument("--model", type=str, help="Specific model to analyze (e.g., editsubmission)")
parser.add_argument(
'--days',
type=int,
default=30,
help='Number of days to analyze (default: 30)'
)
parser.add_argument(
'--model',
"--output",
type=str,
help='Specific model to analyze (e.g., editsubmission)'
)
parser.add_argument(
'--output',
type=str,
choices=['console', 'json', 'csv'],
default='console',
help='Output format (default: console)'
choices=["console", "json", "csv"],
default="console",
help="Output format (default: console)",
)
def handle(self, *args, **options):
days = options['days']
model_filter = options['model']
output_format = options['output']
days = options["days"]
model_filter = options["model"]
output_format = options["output"]
self.stdout.write(
self.style.SUCCESS(f'\n=== State Transition Analysis (Last {days} days) ===\n')
)
self.stdout.write(self.style.SUCCESS(f"\n=== State Transition Analysis (Last {days} days) ===\n"))
# Filter by date range
start_date = timezone.now() - timedelta(days=days)
@@ -56,173 +45,134 @@ class Command(BaseCommand):
try:
content_type = ContentType.objects.get(model=model_filter.lower())
queryset = queryset.filter(content_type=content_type)
self.stdout.write(f'Filtering for model: {model_filter}\n')
self.stdout.write(f"Filtering for model: {model_filter}\n")
except ContentType.DoesNotExist:
self.stdout.write(
self.style.ERROR(f'Model "{model_filter}" not found')
)
self.stdout.write(self.style.ERROR(f'Model "{model_filter}" not found'))
return
# Total transitions
total_transitions = queryset.count()
self.stdout.write(
self.style.SUCCESS(f'Total Transitions: {total_transitions}\n')
)
self.stdout.write(self.style.SUCCESS(f"Total Transitions: {total_transitions}\n"))
if total_transitions == 0:
self.stdout.write(
self.style.WARNING('No transitions found in the specified period.')
)
self.stdout.write(self.style.WARNING("No transitions found in the specified period."))
return
# Most common transitions
self.stdout.write(self.style.SUCCESS('\n--- Most Common Transitions ---'))
self.stdout.write(self.style.SUCCESS("\n--- Most Common Transitions ---"))
common_transitions = (
queryset.values('transition', 'content_type__model')
.annotate(count=Count('id'))
.order_by('-count')[:10]
queryset.values("transition", "content_type__model").annotate(count=Count("id")).order_by("-count")[:10]
)
for t in common_transitions:
model_name = t['content_type__model']
transition_name = t['transition'] or 'N/A'
count = t['count']
model_name = t["content_type__model"]
transition_name = t["transition"] or "N/A"
count = t["count"]
percentage = (count / total_transitions) * 100
self.stdout.write(
f" {model_name}.{transition_name}: {count} ({percentage:.1f}%)"
)
self.stdout.write(f" {model_name}.{transition_name}: {count} ({percentage:.1f}%)")
# Transitions by model
self.stdout.write(self.style.SUCCESS('\n--- Transitions by Model ---'))
by_model = (
queryset.values('content_type__model')
.annotate(count=Count('id'))
.order_by('-count')
)
self.stdout.write(self.style.SUCCESS("\n--- Transitions by Model ---"))
by_model = queryset.values("content_type__model").annotate(count=Count("id")).order_by("-count")
for m in by_model:
model_name = m['content_type__model']
count = m['count']
model_name = m["content_type__model"]
count = m["count"]
percentage = (count / total_transitions) * 100
self.stdout.write(
f" {model_name}: {count} ({percentage:.1f}%)"
)
self.stdout.write(f" {model_name}: {count} ({percentage:.1f}%)")
# Transitions by state
self.stdout.write(self.style.SUCCESS('\n--- Final States Distribution ---'))
by_state = (
queryset.values('state')
.annotate(count=Count('id'))
.order_by('-count')
)
self.stdout.write(self.style.SUCCESS("\n--- Final States Distribution ---"))
by_state = queryset.values("state").annotate(count=Count("id")).order_by("-count")
for s in by_state:
state_name = s['state']
count = s['count']
state_name = s["state"]
count = s["count"]
percentage = (count / total_transitions) * 100
self.stdout.write(
f" {state_name}: {count} ({percentage:.1f}%)"
)
self.stdout.write(f" {state_name}: {count} ({percentage:.1f}%)")
# Most active users
self.stdout.write(self.style.SUCCESS('\n--- Most Active Users ---'))
self.stdout.write(self.style.SUCCESS("\n--- Most Active Users ---"))
active_users = (
queryset.exclude(by__isnull=True)
.values('by__username', 'by__id')
.annotate(count=Count('id'))
.order_by('-count')[:10]
.values("by__username", "by__id")
.annotate(count=Count("id"))
.order_by("-count")[:10]
)
for u in active_users:
username = u['by__username']
user_id = u['by__id']
count = u['count']
self.stdout.write(
f" {username} (ID: {user_id}): {count} transitions"
)
username = u["by__username"]
user_id = u["by__id"]
count = u["count"]
self.stdout.write(f" {username} (ID: {user_id}): {count} transitions")
# System vs User transitions
system_count = queryset.filter(by__isnull=True).count()
user_count = queryset.exclude(by__isnull=True).count()
self.stdout.write(self.style.SUCCESS('\n--- Transition Attribution ---'))
self.stdout.write(self.style.SUCCESS("\n--- Transition Attribution ---"))
self.stdout.write(f" User-initiated: {user_count} ({(user_count/total_transitions)*100:.1f}%)")
self.stdout.write(f" System-initiated: {system_count} ({(system_count/total_transitions)*100:.1f}%)")
# Daily transition volume
# Security: Using Django ORM functions instead of raw SQL .extra() to prevent SQL injection
self.stdout.write(self.style.SUCCESS('\n--- Daily Transition Volume ---'))
self.stdout.write(self.style.SUCCESS("\n--- Daily Transition Volume ---"))
daily_stats = (
queryset.annotate(day=TruncDate('timestamp'))
.values('day')
.annotate(count=Count('id'))
.order_by('-day')[:7]
queryset.annotate(day=TruncDate("timestamp")).values("day").annotate(count=Count("id")).order_by("-day")[:7]
)
for day in daily_stats:
date = day['day']
count = day['count']
date = day["day"]
count = day["count"]
self.stdout.write(f" {date}: {count} transitions")
# Busiest hours
# Security: Using Django ORM functions instead of raw SQL .extra() to prevent SQL injection
self.stdout.write(self.style.SUCCESS('\n--- Busiest Hours (UTC) ---'))
self.stdout.write(self.style.SUCCESS("\n--- Busiest Hours (UTC) ---"))
hourly_stats = (
queryset.annotate(hour=ExtractHour('timestamp'))
.values('hour')
.annotate(count=Count('id'))
.order_by('-count')[:5]
queryset.annotate(hour=ExtractHour("timestamp"))
.values("hour")
.annotate(count=Count("id"))
.order_by("-count")[:5]
)
for hour in hourly_stats:
hour_val = int(hour['hour'])
count = hour['count']
hour_val = int(hour["hour"])
count = hour["count"]
self.stdout.write(f" Hour {hour_val:02d}:00: {count} transitions")
# Transition patterns (common sequences)
self.stdout.write(self.style.SUCCESS('\n--- Common Transition Patterns ---'))
self.stdout.write(' Analyzing transition sequences...')
self.stdout.write(self.style.SUCCESS("\n--- Common Transition Patterns ---"))
self.stdout.write(" Analyzing transition sequences...")
# Get recent objects and their transition sequences
recent_objects = (
queryset.values('content_type', 'object_id')
.distinct()[:100]
)
recent_objects = queryset.values("content_type", "object_id").distinct()[:100]
pattern_counts = {}
for obj in recent_objects:
transitions = list(
StateLog.objects.filter(
content_type=obj['content_type'],
object_id=obj['object_id']
)
.order_by('timestamp')
.values_list('transition', flat=True)
StateLog.objects.filter(content_type=obj["content_type"], object_id=obj["object_id"])
.order_by("timestamp")
.values_list("transition", flat=True)
)
# Create pattern from consecutive transitions
if len(transitions) >= 2:
pattern = ''.join([t or 'N/A' for t in transitions[:3]])
pattern = "".join([t or "N/A" for t in transitions[:3]])
pattern_counts[pattern] = pattern_counts.get(pattern, 0) + 1
# Display top patterns
sorted_patterns = sorted(
pattern_counts.items(),
key=lambda x: x[1],
reverse=True
)[:5]
sorted_patterns = sorted(pattern_counts.items(), key=lambda x: x[1], reverse=True)[:5]
for pattern, count in sorted_patterns:
self.stdout.write(f" {pattern}: {count} occurrences")
self.stdout.write(
self.style.SUCCESS('\n=== Analysis Complete ===\n')
)
self.stdout.write(self.style.SUCCESS("\n=== Analysis Complete ===\n"))
# Export options
if output_format == 'json':
if output_format == "json":
self._export_json(queryset, days)
elif output_format == 'csv':
elif output_format == "csv":
self._export_csv(queryset, days)
def _export_json(self, queryset, days):
@@ -231,24 +181,21 @@ class Command(BaseCommand):
from datetime import datetime
data = {
'analysis_date': datetime.now().isoformat(),
'period_days': days,
'total_transitions': queryset.count(),
'transitions': list(
"analysis_date": datetime.now().isoformat(),
"period_days": days,
"total_transitions": queryset.count(),
"transitions": list(
queryset.values(
'id', 'timestamp', 'state', 'transition',
'content_type__model', 'object_id', 'by__username'
"id", "timestamp", "state", "transition", "content_type__model", "object_id", "by__username"
)
)
),
}
filename = f'transition_analysis_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
with open(filename, 'w') as f:
with open(filename, "w") as f:
json.dump(data, f, indent=2, default=str)
self.stdout.write(
self.style.SUCCESS(f'Exported to {filename}')
)
self.stdout.write(self.style.SUCCESS(f"Exported to {filename}"))
def _export_csv(self, queryset, days):
"""Export analysis results as CSV."""
@@ -257,24 +204,21 @@ class Command(BaseCommand):
filename = f'transition_analysis_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv'
with open(filename, 'w', newline='') as f:
with open(filename, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow([
'ID', 'Timestamp', 'Model', 'Object ID',
'State', 'Transition', 'User'
])
writer.writerow(["ID", "Timestamp", "Model", "Object ID", "State", "Transition", "User"])
for log in queryset.select_related('content_type', 'by'):
writer.writerow([
log.id,
log.timestamp,
log.content_type.model,
log.object_id,
log.state,
log.transition or 'N/A',
log.by.username if log.by else 'System'
])
for log in queryset.select_related("content_type", "by"):
writer.writerow(
[
log.id,
log.timestamp,
log.content_type.model,
log.object_id,
log.state,
log.transition or "N/A",
log.by.username if log.by else "System",
]
)
self.stdout.write(
self.style.SUCCESS(f'Exported to {filename}')
)
self.stdout.write(self.style.SUCCESS(f"Exported to {filename}"))

View File

@@ -17,9 +17,7 @@ class Command(BaseCommand):
def handle(self, *args, **kwargs):
# Ensure we have a test user
user, created = User.objects.get_or_create(
username="test_user", email="test@example.com"
)
user, created = User.objects.get_or_create(username="test_user", email="test@example.com")
if created:
user.set_password("testpass123")
user.save()
@@ -215,9 +213,7 @@ class Command(BaseCommand):
"audio system, and increased capacity due to improved loading efficiency."
),
source=(
"Park operations manual\n"
"Maintenance records\n"
"Personal observation and timing of new ride cycle"
"Park operations manual\n" "Maintenance records\n" "Personal observation and timing of new ride cycle"
),
status="PENDING",
)
@@ -225,10 +221,10 @@ class Command(BaseCommand):
# Create PhotoSubmissions with detailed captions
# Park photo submission
image_data = b"GIF87a\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00ccc,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;"
dummy_image = SimpleUploadedFile(
"park_entrance.gif", image_data, content_type="image/gif"
image_data = (
b"GIF87a\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00ccc,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;"
)
dummy_image = SimpleUploadedFile("park_entrance.gif", image_data, content_type="image/gif")
PhotoSubmission.objects.create(
user=user,
@@ -244,9 +240,7 @@ class Command(BaseCommand):
)
# Ride photo submission
dummy_image2 = SimpleUploadedFile(
"coaster_track.gif", image_data, content_type="image/gif"
)
dummy_image2 = SimpleUploadedFile("coaster_track.gif", image_data, content_type="image/gif")
PhotoSubmission.objects.create(
user=user,
content_type=ride_ct,

View File

@@ -1,4 +1,5 @@
"""Management command to validate state machine configurations for moderation models."""
from django.core.management import CommandError
from django.core.management.base import BaseCommand
@@ -76,18 +77,15 @@ class Command(BaseCommand):
model_key = model_name.lower()
if model_key not in models_to_validate:
raise CommandError(
f"Unknown model: {model_name}. "
f"Valid options: {', '.join(models_to_validate.keys())}"
f"Unknown model: {model_name}. " f"Valid options: {', '.join(models_to_validate.keys())}"
)
models_to_validate = {model_key: models_to_validate[model_key]}
self.stdout.write(
self.style.SUCCESS("\nValidating State Machine Configurations\n")
)
self.stdout.write(self.style.SUCCESS("\nValidating State Machine Configurations\n"))
self.stdout.write("=" * 60 + "\n")
all_valid = True
for model_key, (
for _model_key, (
model_class,
choice_group,
domain,
@@ -101,61 +99,34 @@ class Command(BaseCommand):
result = validator.validate_choice_group()
if result.is_valid:
self.stdout.write(
self.style.SUCCESS(
f"{model_class.__name__} validation passed"
)
)
self.stdout.write(self.style.SUCCESS(f"{model_class.__name__} validation passed"))
if verbose:
self._show_transition_graph(choice_group, domain)
else:
all_valid = False
self.stdout.write(
self.style.ERROR(
f"{model_class.__name__} validation failed"
)
)
self.stdout.write(self.style.ERROR(f"{model_class.__name__} validation failed"))
for error in result.errors:
self.stdout.write(
self.style.ERROR(f" - {error.message}")
)
self.stdout.write(self.style.ERROR(f" - {error.message}"))
# Check FSM field
if not self._check_fsm_field(model_class):
all_valid = False
self.stdout.write(
self.style.ERROR(
f" - FSM field 'status' not found on "
f"{model_class.__name__}"
)
)
self.stdout.write(self.style.ERROR(f" - FSM field 'status' not found on " f"{model_class.__name__}"))
# Check mixin
if not self._check_state_machine_mixin(model_class):
all_valid = False
self.stdout.write(
self.style.WARNING(
f" - StateMachineMixin not found on "
f"{model_class.__name__}"
)
self.style.WARNING(f" - StateMachineMixin not found on " f"{model_class.__name__}")
)
self.stdout.write("\n" + "=" * 60)
if all_valid:
self.stdout.write(
self.style.SUCCESS(
"\n✓ All validations passed successfully!\n"
)
)
self.stdout.write(self.style.SUCCESS("\n✓ All validations passed successfully!\n"))
else:
self.stdout.write(
self.style.ERROR(
"\n✗ Some validations failed. "
"Please review the errors above.\n"
)
)
self.stdout.write(self.style.ERROR("\n✗ Some validations failed. " "Please review the errors above.\n"))
raise CommandError("State machine validation failed")
def _check_fsm_field(self, model_class):
@@ -177,9 +148,7 @@ class Command(BaseCommand):
self.stdout.write("\n Transition Graph:")
graph = registry_instance.export_transition_graph(
choice_group, domain
)
graph = registry_instance.export_transition_graph(choice_group, domain)
for source, targets in sorted(graph.items()):
if targets:

View File

@@ -47,9 +47,7 @@ class Migration(migrations.Migration):
),
(
"changes",
models.JSONField(
help_text="JSON representation of the changes or new object data"
),
models.JSONField(help_text="JSON representation of the changes or new object data"),
),
(
"moderator_changes",
@@ -150,9 +148,7 @@ class Migration(migrations.Migration):
),
(
"changes",
models.JSONField(
help_text="JSON representation of the changes or new object data"
),
models.JSONField(help_text="JSON representation of the changes or new object data"),
),
(
"moderator_changes",

View File

@@ -812,21 +812,15 @@ class Migration(migrations.Migration):
),
migrations.AddIndex(
model_name="bulkoperation",
index=models.Index(
fields=["status", "priority"], name="moderation__status_f11ee8_idx"
),
index=models.Index(fields=["status", "priority"], name="moderation__status_f11ee8_idx"),
),
migrations.AddIndex(
model_name="bulkoperation",
index=models.Index(
fields=["created_by"], name="moderation__created_4fe5d2_idx"
),
index=models.Index(fields=["created_by"], name="moderation__created_4fe5d2_idx"),
),
migrations.AddIndex(
model_name="bulkoperation",
index=models.Index(
fields=["operation_type"], name="moderation__operati_bc84d9_idx"
),
index=models.Index(fields=["operation_type"], name="moderation__operati_bc84d9_idx"),
),
pgtrigger.migrations.AddTrigger(
model_name="bulkoperation",
@@ -859,9 +853,7 @@ class Migration(migrations.Migration):
),
migrations.AddIndex(
model_name="moderationreport",
index=models.Index(
fields=["status", "priority"], name="moderation__status_6aa18c_idx"
),
index=models.Index(fields=["status", "priority"], name="moderation__status_6aa18c_idx"),
),
migrations.AddIndex(
model_name="moderationreport",
@@ -872,9 +864,7 @@ class Migration(migrations.Migration):
),
migrations.AddIndex(
model_name="moderationreport",
index=models.Index(
fields=["assigned_moderator"], name="moderation__assigne_c43cdf_idx"
),
index=models.Index(fields=["assigned_moderator"], name="moderation__assigne_c43cdf_idx"),
),
pgtrigger.migrations.AddTrigger(
model_name="moderationreport",
@@ -907,9 +897,7 @@ class Migration(migrations.Migration):
),
migrations.AddIndex(
model_name="moderationqueue",
index=models.Index(
fields=["status", "priority"], name="moderation__status_6f2a75_idx"
),
index=models.Index(fields=["status", "priority"], name="moderation__status_6f2a75_idx"),
),
migrations.AddIndex(
model_name="moderationqueue",
@@ -920,15 +908,11 @@ class Migration(migrations.Migration):
),
migrations.AddIndex(
model_name="moderationqueue",
index=models.Index(
fields=["assigned_to"], name="moderation__assigne_2fc958_idx"
),
index=models.Index(fields=["assigned_to"], name="moderation__assigne_2fc958_idx"),
),
migrations.AddIndex(
model_name="moderationqueue",
index=models.Index(
fields=["flagged_by"], name="moderation__flagged_169834_idx"
),
index=models.Index(fields=["flagged_by"], name="moderation__flagged_169834_idx"),
),
pgtrigger.migrations.AddTrigger(
model_name="moderationqueue",
@@ -975,9 +959,7 @@ class Migration(migrations.Migration):
),
migrations.AddIndex(
model_name="moderationaction",
index=models.Index(
fields=["expires_at"], name="moderation__expires_963efb_idx"
),
index=models.Index(fields=["expires_at"], name="moderation__expires_963efb_idx"),
),
pgtrigger.migrations.AddTrigger(
model_name="moderationaction",

View File

@@ -55,9 +55,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="bulkoperation",
name="can_cancel",
field=models.BooleanField(
default=True, help_text="Whether this operation can be cancelled"
),
field=models.BooleanField(default=True, help_text="Whether this operation can be cancelled"),
),
migrations.AlterField(
model_name="bulkoperation",
@@ -67,23 +65,17 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="bulkoperation",
name="estimated_duration_minutes",
field=models.PositiveIntegerField(
blank=True, help_text="Estimated duration in minutes", null=True
),
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"
),
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"
),
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID"),
),
migrations.AlterField(
model_name="bulkoperation",
@@ -105,9 +97,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="bulkoperation",
name="parameters",
field=models.JSONField(
default=dict, help_text="Parameters for the operation"
),
field=models.JSONField(default=dict, help_text="Parameters for the operation"),
),
migrations.AlterField(
model_name="bulkoperation",
@@ -126,9 +116,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="bulkoperation",
name="processed_items",
field=models.PositiveIntegerField(
default=0, help_text="Number of items processed"
),
field=models.PositiveIntegerField(default=0, help_text="Number of items processed"),
),
migrations.AlterField(
model_name="bulkoperation",
@@ -142,23 +130,17 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="bulkoperation",
name="schedule_for",
field=models.DateTimeField(
blank=True, help_text="When to run this operation", null=True
),
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"
),
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"
),
field=models.BooleanField(default=True, help_text="Whether this operation can be cancelled"),
),
migrations.AlterField(
model_name="bulkoperationevent",
@@ -168,16 +150,12 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="bulkoperationevent",
name="estimated_duration_minutes",
field=models.PositiveIntegerField(
blank=True, help_text="Estimated duration in minutes", null=True
),
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"
),
field=models.PositiveIntegerField(default=0, help_text="Number of items that failed"),
),
migrations.AlterField(
model_name="bulkoperationevent",
@@ -204,9 +182,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="bulkoperationevent",
name="parameters",
field=models.JSONField(
default=dict, help_text="Parameters for the operation"
),
field=models.JSONField(default=dict, help_text="Parameters for the operation"),
),
migrations.AlterField(
model_name="bulkoperationevent",
@@ -225,9 +201,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="bulkoperationevent",
name="processed_items",
field=models.PositiveIntegerField(
default=0, help_text="Number of items processed"
),
field=models.PositiveIntegerField(default=0, help_text="Number of items processed"),
),
migrations.AlterField(
model_name="bulkoperationevent",
@@ -241,16 +215,12 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="bulkoperationevent",
name="schedule_for",
field=models.DateTimeField(
blank=True, help_text="When to run this operation", null=True
),
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"
),
field=models.PositiveIntegerField(default=0, help_text="Total number of items to process"),
),
migrations.AlterField(
model_name="moderationaction",
@@ -286,23 +256,17 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="moderationaction",
name="expires_at",
field=models.DateTimeField(
blank=True, help_text="When this action expires", null=True
),
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"
),
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
),
field=models.CharField(help_text="Brief reason for the action", max_length=200),
),
migrations.AlterField(
model_name="moderationactionevent",
@@ -338,44 +302,32 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="moderationactionevent",
name="expires_at",
field=models.DateTimeField(
blank=True, help_text="When this action expires", null=True
),
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"
),
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
),
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"
),
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
),
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"
),
field=models.JSONField(blank=True, default=dict, help_text="Preview data for the entity"),
),
migrations.AlterField(
model_name="moderationqueue",
@@ -389,9 +341,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="moderationqueue",
name="estimated_review_time",
field=models.PositiveIntegerField(
default=30, help_text="Estimated time in minutes"
),
field=models.PositiveIntegerField(default=30, help_text="Estimated time in minutes"),
),
migrations.AlterField(
model_name="moderationqueue",
@@ -436,37 +386,27 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="moderationqueue",
name="tags",
field=models.JSONField(
blank=True, default=list, help_text="Tags for categorization"
),
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
),
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"
),
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
),
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"
),
field=models.JSONField(blank=True, default=dict, help_text="Preview data for the entity"),
),
migrations.AlterField(
model_name="moderationqueueevent",
@@ -480,9 +420,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="moderationqueueevent",
name="estimated_review_time",
field=models.PositiveIntegerField(
default=30, help_text="Estimated time in minutes"
),
field=models.PositiveIntegerField(default=30, help_text="Estimated time in minutes"),
),
migrations.AlterField(
model_name="moderationqueueevent",
@@ -529,16 +467,12 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="moderationqueueevent",
name="tags",
field=models.JSONField(
blank=True, default=list, help_text="Tags for categorization"
),
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
),
field=models.CharField(help_text="Brief title for the queue item", max_length=200),
),
migrations.AlterField(
model_name="moderationreport",
@@ -557,9 +491,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="moderationreport",
name="reason",
field=models.CharField(
help_text="Brief reason for the report", max_length=200
),
field=models.CharField(help_text="Brief reason for the report", max_length=200),
),
migrations.AlterField(
model_name="moderationreport",
@@ -582,9 +514,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="moderationreport",
name="reported_entity_id",
field=models.PositiveIntegerField(
help_text="ID of the entity being reported"
),
field=models.PositiveIntegerField(help_text="ID of the entity being reported"),
),
migrations.AlterField(
model_name="moderationreport",
@@ -641,9 +571,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="moderationreportevent",
name="reason",
field=models.CharField(
help_text="Brief reason for the report", max_length=200
),
field=models.CharField(help_text="Brief reason for the report", max_length=200),
),
migrations.AlterField(
model_name="moderationreportevent",
@@ -666,9 +594,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="moderationreportevent",
name="reported_entity_id",
field=models.PositiveIntegerField(
help_text="ID of the entity being reported"
),
field=models.PositiveIntegerField(help_text="ID of the entity being reported"),
),
migrations.AlterField(
model_name="moderationreportevent",
@@ -710,45 +636,31 @@ class Migration(migrations.Migration):
),
migrations.AddIndex(
model_name="bulkoperation",
index=models.Index(
fields=["schedule_for"], name="moderation__schedul_350704_idx"
),
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"
),
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"
),
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"
),
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"
),
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"
),
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"
),
index=models.Index(fields=["created_at"], name="moderation__created_ae337c_idx"),
),
pgtrigger.migrations.AddTrigger(
model_name="moderationqueue",

View File

@@ -67,9 +67,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="bulkoperation",
name="completed_at",
field=models.DateTimeField(
blank=True, help_text="When this operation completed", null=True
),
field=models.DateTimeField(blank=True, help_text="When this operation completed", null=True),
),
migrations.AlterField(
model_name="bulkoperation",
@@ -84,23 +82,17 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="bulkoperation",
name="started_at",
field=models.DateTimeField(
blank=True, help_text="When this operation started", null=True
),
field=models.DateTimeField(blank=True, help_text="When this operation started", null=True),
),
migrations.AlterField(
model_name="bulkoperation",
name="updated_at",
field=models.DateTimeField(
auto_now=True, help_text="When this operation was last updated"
),
field=models.DateTimeField(auto_now=True, help_text="When this operation was last updated"),
),
migrations.AlterField(
model_name="bulkoperationevent",
name="completed_at",
field=models.DateTimeField(
blank=True, help_text="When this operation completed", null=True
),
field=models.DateTimeField(blank=True, help_text="When this operation completed", null=True),
),
migrations.AlterField(
model_name="bulkoperationevent",
@@ -117,9 +109,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="bulkoperationevent",
name="started_at",
field=models.DateTimeField(
blank=True, help_text="When this operation started", null=True
),
field=models.DateTimeField(blank=True, help_text="When this operation started", null=True),
),
migrations.AlterField(
model_name="bulkoperationevent",
@@ -142,9 +132,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="bulkoperationevent",
name="updated_at",
field=models.DateTimeField(
auto_now=True, help_text="When this operation was last updated"
),
field=models.DateTimeField(auto_now=True, help_text="When this operation was last updated"),
),
migrations.AlterField(
model_name="editsubmission",
@@ -158,9 +146,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="editsubmission",
name="handled_at",
field=models.DateTimeField(
blank=True, help_text="When this submission was handled", null=True
),
field=models.DateTimeField(blank=True, help_text="When this submission was handled", null=True),
),
migrations.AlterField(
model_name="editsubmission",
@@ -208,9 +194,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="editsubmissionevent",
name="handled_at",
field=models.DateTimeField(
blank=True, help_text="When this submission was handled", null=True
),
field=models.DateTimeField(blank=True, help_text="When this submission was handled", null=True),
),
migrations.AlterField(
model_name="editsubmissionevent",
@@ -267,9 +251,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="moderationaction",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, help_text="When this action was created"
),
field=models.DateTimeField(auto_now_add=True, help_text="When this action was created"),
),
migrations.AlterField(
model_name="moderationaction",
@@ -306,16 +288,12 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="moderationaction",
name="updated_at",
field=models.DateTimeField(
auto_now=True, help_text="When this action was last updated"
),
field=models.DateTimeField(auto_now=True, help_text="When this action was last updated"),
),
migrations.AlterField(
model_name="moderationactionevent",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, help_text="When this action was created"
),
field=models.DateTimeField(auto_now_add=True, help_text="When this action was created"),
),
migrations.AlterField(
model_name="moderationactionevent",
@@ -358,16 +336,12 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="moderationactionevent",
name="updated_at",
field=models.DateTimeField(
auto_now=True, help_text="When this action was last updated"
),
field=models.DateTimeField(auto_now=True, help_text="When this action was last updated"),
),
migrations.AlterField(
model_name="moderationqueue",
name="assigned_at",
field=models.DateTimeField(
blank=True, help_text="When this item was assigned", null=True
),
field=models.DateTimeField(blank=True, help_text="When this item was assigned", null=True),
),
migrations.AlterField(
model_name="moderationqueue",
@@ -384,9 +358,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="moderationqueue",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, help_text="When this item was created"
),
field=models.DateTimeField(auto_now_add=True, help_text="When this item was created"),
),
migrations.AlterField(
model_name="moderationqueue",
@@ -415,16 +387,12 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="moderationqueue",
name="updated_at",
field=models.DateTimeField(
auto_now=True, help_text="When this item was last updated"
),
field=models.DateTimeField(auto_now=True, help_text="When this item was last updated"),
),
migrations.AlterField(
model_name="moderationqueueevent",
name="assigned_at",
field=models.DateTimeField(
blank=True, help_text="When this item was assigned", null=True
),
field=models.DateTimeField(blank=True, help_text="When this item was assigned", null=True),
),
migrations.AlterField(
model_name="moderationqueueevent",
@@ -443,9 +411,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="moderationqueueevent",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, help_text="When this item was created"
),
field=models.DateTimeField(auto_now_add=True, help_text="When this item was created"),
),
migrations.AlterField(
model_name="moderationqueueevent",
@@ -495,9 +461,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="moderationqueueevent",
name="updated_at",
field=models.DateTimeField(
auto_now=True, help_text="When this item was last updated"
),
field=models.DateTimeField(auto_now=True, help_text="When this item was last updated"),
),
migrations.AlterField(
model_name="moderationreport",
@@ -514,9 +478,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="moderationreport",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, help_text="When this report was created"
),
field=models.DateTimeField(auto_now_add=True, help_text="When this report was created"),
),
migrations.AlterField(
model_name="moderationreport",
@@ -531,16 +493,12 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="moderationreport",
name="resolved_at",
field=models.DateTimeField(
blank=True, help_text="When this report was resolved", null=True
),
field=models.DateTimeField(blank=True, help_text="When this report was resolved", null=True),
),
migrations.AlterField(
model_name="moderationreport",
name="updated_at",
field=models.DateTimeField(
auto_now=True, help_text="When this report was last updated"
),
field=models.DateTimeField(auto_now=True, help_text="When this report was last updated"),
),
migrations.AlterField(
model_name="moderationreportevent",
@@ -559,9 +517,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="moderationreportevent",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, help_text="When this report was created"
),
field=models.DateTimeField(auto_now_add=True, help_text="When this report was created"),
),
migrations.AlterField(
model_name="moderationreportevent",
@@ -578,9 +534,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="moderationreportevent",
name="resolved_at",
field=models.DateTimeField(
blank=True, help_text="When this report was resolved", null=True
),
field=models.DateTimeField(blank=True, help_text="When this report was resolved", null=True),
),
migrations.AlterField(
model_name="moderationreportevent",
@@ -602,16 +556,12 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="moderationreportevent",
name="updated_at",
field=models.DateTimeField(
auto_now=True, help_text="When this report was last updated"
),
field=models.DateTimeField(auto_now=True, help_text="When this report was last updated"),
),
migrations.AlterField(
model_name="photosubmission",
name="caption",
field=models.CharField(
blank=True, help_text="Photo caption", max_length=255
),
field=models.CharField(blank=True, help_text="Photo caption", max_length=255),
),
migrations.AlterField(
model_name="photosubmission",
@@ -625,16 +575,12 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="photosubmission",
name="date_taken",
field=models.DateField(
blank=True, help_text="Date the photo was taken", null=True
),
field=models.DateField(blank=True, help_text="Date the photo was taken", null=True),
),
migrations.AlterField(
model_name="photosubmission",
name="handled_at",
field=models.DateTimeField(
blank=True, help_text="When this submission was handled", null=True
),
field=models.DateTimeField(blank=True, help_text="When this submission was handled", null=True),
),
migrations.AlterField(
model_name="photosubmission",
@@ -651,9 +597,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="photosubmission",
name="object_id",
field=models.PositiveIntegerField(
help_text="ID of object this photo is for"
),
field=models.PositiveIntegerField(help_text="ID of object this photo is for"),
),
migrations.AlterField(
model_name="photosubmission",
@@ -668,9 +612,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="photosubmissionevent",
name="caption",
field=models.CharField(
blank=True, help_text="Photo caption", max_length=255
),
field=models.CharField(blank=True, help_text="Photo caption", max_length=255),
),
migrations.AlterField(
model_name="photosubmissionevent",
@@ -687,16 +629,12 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="photosubmissionevent",
name="date_taken",
field=models.DateField(
blank=True, help_text="Date the photo was taken", null=True
),
field=models.DateField(blank=True, help_text="Date the photo was taken", null=True),
),
migrations.AlterField(
model_name="photosubmissionevent",
name="handled_at",
field=models.DateTimeField(
blank=True, help_text="When this submission was handled", null=True
),
field=models.DateTimeField(blank=True, help_text="When this submission was handled", null=True),
),
migrations.AlterField(
model_name="photosubmissionevent",
@@ -715,9 +653,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="photosubmissionevent",
name="object_id",
field=models.PositiveIntegerField(
help_text="ID of object this photo is for"
),
field=models.PositiveIntegerField(help_text="ID of object this photo is for"),
),
migrations.AlterField(
model_name="photosubmissionevent",

View File

@@ -132,9 +132,7 @@ class EditSubmissionMixin(DetailView):
status=400,
)
return self.handle_edit_submission(
request, changes, reason, source, submission_type
)
return self.handle_edit_submission(request, changes, reason, source, submission_type)
except json.JSONDecodeError:
return JsonResponse(
@@ -169,9 +167,7 @@ class PhotoSubmissionMixin(DetailView):
try:
obj = self.get_object()
except (AttributeError, self.model.DoesNotExist):
return JsonResponse(
{"status": "error", "message": "Invalid object."}, status=400
)
return JsonResponse({"status": "error", "message": "Invalid object."}, status=400)
if not request.FILES.get("photo"):
return JsonResponse(

View File

@@ -17,7 +17,7 @@ are registered via the callback configuration defined in each model's Meta class
"""
from datetime import timedelta
from typing import Any, Union
from typing import Any
import pghistory
from django.conf import settings
@@ -33,7 +33,7 @@ from apps.core.choices.fields import RichChoiceField
from apps.core.history import TrackedModel
from apps.core.state_machine import RichFSMField, StateMachineMixin
UserType = Union[AbstractBaseUser, AnonymousUser]
UserType = AbstractBaseUser | AnonymousUser
# Lazy callback imports to avoid circular dependencies
@@ -45,11 +45,12 @@ def _get_notification_callbacks():
SubmissionEscalatedNotification,
SubmissionRejectedNotification,
)
return {
'approved': SubmissionApprovedNotification,
'rejected': SubmissionRejectedNotification,
'escalated': SubmissionEscalatedNotification,
'moderation': ModerationNotificationCallback,
"approved": SubmissionApprovedNotification,
"rejected": SubmissionRejectedNotification,
"escalated": SubmissionEscalatedNotification,
"moderation": ModerationNotificationCallback,
}
@@ -59,9 +60,10 @@ def _get_cache_callbacks():
CacheInvalidationCallback,
ModerationCacheInvalidation,
)
return {
'generic': CacheInvalidationCallback,
'moderation': ModerationCacheInvalidation,
"generic": CacheInvalidationCallback,
"moderation": ModerationCacheInvalidation,
}
@@ -69,6 +71,7 @@ def _get_cache_callbacks():
# Original EditSubmission Model (Preserved)
# ============================================================================
@pghistory.track() # Track all changes by default
class EditSubmission(StateMachineMixin, TrackedModel):
"""Edit submission model with FSM-managed status transitions."""
@@ -98,16 +101,11 @@ class EditSubmission(StateMachineMixin, TrackedModel):
# Type of submission
submission_type = RichChoiceField(
choice_group="submission_types",
domain="moderation",
max_length=10,
default="EDIT"
choice_group="submission_types", domain="moderation", max_length=10, default="EDIT"
)
# The actual changes/data
changes = models.JSONField(
help_text="JSON representation of the changes or new object data"
)
changes = models.JSONField(help_text="JSON representation of the changes or new object data")
# Moderator's edited version of changes before approval
moderator_changes = models.JSONField(
@@ -118,14 +116,9 @@ class EditSubmission(StateMachineMixin, TrackedModel):
# Metadata
reason = models.TextField(help_text="Why this edit/addition is needed")
source = models.TextField(
blank=True, help_text="Source of information (if applicable)"
)
source = models.TextField(blank=True, help_text="Source of information (if applicable)")
status = RichFSMField(
choice_group="edit_submission_statuses",
domain="moderation",
max_length=20,
default="PENDING"
choice_group="edit_submission_statuses", domain="moderation", max_length=20, default="PENDING"
)
created_at = models.DateTimeField(auto_now_add=True)
@@ -138,12 +131,8 @@ class EditSubmission(StateMachineMixin, TrackedModel):
related_name="handled_submissions",
help_text="Moderator who handled this submission",
)
handled_at = models.DateTimeField(
null=True, blank=True, help_text="When this submission was handled"
)
notes = models.TextField(
blank=True, help_text="Notes from the moderator about this submission"
)
handled_at = models.DateTimeField(null=True, blank=True, help_text="When this submission was handled")
notes = models.TextField(blank=True, help_text="Notes from the moderator about this submission")
# Claim tracking for concurrency control
claimed_by = models.ForeignKey(
@@ -154,9 +143,7 @@ class EditSubmission(StateMachineMixin, TrackedModel):
related_name="claimed_edit_submissions",
help_text="Moderator who has claimed this submission for review",
)
claimed_at = models.DateTimeField(
null=True, blank=True, help_text="When this submission was claimed"
)
claimed_at = models.DateTimeField(null=True, blank=True, help_text="When this submission was claimed")
class Meta(TrackedModel.Meta):
verbose_name = "Edit Submission"
@@ -187,12 +174,12 @@ class EditSubmission(StateMachineMixin, TrackedModel):
field = model_class._meta.get_field(field_name)
if isinstance(field, models.ForeignKey) and value is not None:
try:
related_obj = field.related_model.objects.get(pk=value) # type: ignore
related_obj = field.related_model.objects.get(pk=value) # type: ignore
resolved_data[field_name] = related_obj
except ObjectDoesNotExist:
raise ValueError(
f"Related object {field.related_model.__name__} with pk={value} does not exist" # type: ignore
)
f"Related object {field.related_model.__name__} with pk={value} does not exist" # type: ignore
) from None
except FieldDoesNotExist:
# Field doesn't exist on model, skip it
continue
@@ -217,9 +204,7 @@ class EditSubmission(StateMachineMixin, TrackedModel):
from django.core.exceptions import ValidationError
if self.status != "PENDING":
raise ValidationError(
f"Cannot claim submission: current status is {self.status}, expected PENDING"
)
raise ValidationError(f"Cannot claim submission: current status is {self.status}, expected PENDING")
self.transition_to_claimed(user=user)
self.claimed_by = user
@@ -240,9 +225,7 @@ class EditSubmission(StateMachineMixin, TrackedModel):
from django.core.exceptions import ValidationError
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot unclaim submission: current status is {self.status}, expected CLAIMED"
)
raise ValidationError(f"Cannot unclaim submission: current status is {self.status}, expected CLAIMED")
# Set status directly (not via FSM transition to avoid cycle)
# This is intentional - the unclaim action is a special "rollback" operation
@@ -274,9 +257,7 @@ class EditSubmission(StateMachineMixin, TrackedModel):
# Validate state - must be CLAIMED before approval
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot approve submission: must be CLAIMED first (current status: {self.status})"
)
raise ValidationError(f"Cannot approve submission: must be CLAIMED first (current status: {self.status})")
model_class = self.content_type.model_class()
if not model_class:
@@ -341,9 +322,7 @@ class EditSubmission(StateMachineMixin, TrackedModel):
# Validate state - must be CLAIMED before rejection
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot reject submission: must be CLAIMED first (current status: {self.status})"
)
raise ValidationError(f"Cannot reject submission: must be CLAIMED first (current status: {self.status})")
# Use FSM transition to update status
self.transition_to_rejected(user=rejecter)
@@ -369,9 +348,7 @@ class EditSubmission(StateMachineMixin, TrackedModel):
# Validate state - must be CLAIMED before escalation
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot escalate submission: must be CLAIMED first (current status: {self.status})"
)
raise ValidationError(f"Cannot escalate submission: must be CLAIMED first (current status: {self.status})")
# Use FSM transition to update status
self.transition_to_escalated(user=escalator)
@@ -395,6 +372,7 @@ class EditSubmission(StateMachineMixin, TrackedModel):
# New Moderation System Models
# ============================================================================
@pghistory.track()
class ModerationReport(StateMachineMixin, TrackedModel):
"""
@@ -407,43 +385,29 @@ class ModerationReport(StateMachineMixin, TrackedModel):
state_field_name = "status"
# Report details
report_type = RichChoiceField(
choice_group="report_types",
domain="moderation",
max_length=50
)
report_type = RichChoiceField(choice_group="report_types", domain="moderation", max_length=50)
status = RichFSMField(
choice_group="moderation_report_statuses",
domain="moderation",
max_length=20,
default='PENDING'
)
priority = RichChoiceField(
choice_group="priority_levels",
domain="moderation",
max_length=10,
default='MEDIUM'
choice_group="moderation_report_statuses", domain="moderation", max_length=20, default="PENDING"
)
priority = RichChoiceField(choice_group="priority_levels", domain="moderation", max_length=10, default="MEDIUM")
# What is being reported
reported_entity_type = models.CharField(
max_length=50, help_text="Type of entity being reported (park, ride, user, etc.)")
reported_entity_id = models.PositiveIntegerField(
help_text="ID of the entity being reported")
content_type = models.ForeignKey(
ContentType, on_delete=models.CASCADE, null=True, blank=True)
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.)")
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',
related_name="moderation_reports_made",
help_text="User who made this report",
)
assigned_moderator = models.ForeignKey(
@@ -451,40 +415,32 @@ class ModerationReport(StateMachineMixin, TrackedModel):
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='assigned_moderation_reports',
related_name="assigned_moderation_reports",
help_text="Moderator assigned to handle this report",
)
# 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, help_text="When this report was resolved"
)
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, help_text="When this report was resolved")
# Timestamps
created_at = models.DateTimeField(
auto_now_add=True, help_text="When this report was created"
)
updated_at = models.DateTimeField(
auto_now=True, help_text="When this report was last updated"
)
created_at = models.DateTimeField(auto_now_add=True, help_text="When this report was created")
updated_at = models.DateTimeField(auto_now=True, help_text="When this report was last updated")
class Meta(TrackedModel.Meta):
verbose_name = "Moderation Report"
verbose_name_plural = "Moderation Reports"
ordering = ['-created_at']
ordering = ["-created_at"]
indexes = [
models.Index(fields=['status', 'priority']),
models.Index(fields=['reported_by']),
models.Index(fields=['assigned_moderator']),
models.Index(fields=['created_at']),
models.Index(fields=["status", "priority"]),
models.Index(fields=["reported_by"]),
models.Index(fields=["assigned_moderator"]),
models.Index(fields=["created_at"]),
]
def __str__(self):
return f"{self.get_report_type_display()} report by {self.reported_by.username}" # type: ignore
return f"{self.get_report_type_display()} report by {self.reported_by.username}" # type: ignore
@pghistory.track()
@@ -499,37 +455,20 @@ class ModerationQueue(StateMachineMixin, TrackedModel):
state_field_name = "status"
# Queue item details
item_type = RichChoiceField(
choice_group="queue_item_types",
domain="moderation",
max_length=50
)
item_type = RichChoiceField(choice_group="queue_item_types", domain="moderation", max_length=50)
status = RichFSMField(
choice_group="moderation_queue_statuses",
domain="moderation",
max_length=20,
default='PENDING'
)
priority = RichChoiceField(
choice_group="priority_levels",
domain="moderation",
max_length=10,
default='MEDIUM'
choice_group="moderation_queue_statuses", domain="moderation", max_length=20, default="PENDING"
)
priority = RichChoiceField(choice_group="priority_levels", domain="moderation", max_length=10, default="MEDIUM")
title = models.CharField(max_length=200, help_text="Brief title for the queue item")
description = models.TextField(
help_text="Detailed description of what needs to be done")
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)
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(
@@ -537,14 +476,11 @@ class ModerationQueue(StateMachineMixin, TrackedModel):
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='assigned_queue_items',
related_name="assigned_queue_items",
help_text="Moderator assigned to this item",
)
assigned_at = models.DateTimeField(
null=True, blank=True, help_text="When this item was assigned"
)
estimated_review_time = models.PositiveIntegerField(
default=30, help_text="Estimated time in minutes")
assigned_at = models.DateTimeField(null=True, blank=True, help_text="When this item was assigned")
estimated_review_time = models.PositiveIntegerField(default=30, help_text="Estimated time in minutes")
# Metadata
flagged_by = models.ForeignKey(
@@ -552,11 +488,10 @@ class ModerationQueue(StateMachineMixin, TrackedModel):
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='flagged_queue_items',
related_name="flagged_queue_items",
help_text="User who flagged this item",
)
tags = models.JSONField(default=list, blank=True,
help_text="Tags for categorization")
tags = models.JSONField(default=list, blank=True, help_text="Tags for categorization")
# Related objects
related_report = models.ForeignKey(
@@ -564,30 +499,26 @@ class ModerationQueue(StateMachineMixin, TrackedModel):
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='queue_items',
related_name="queue_items",
help_text="Related moderation report",
)
# Timestamps
created_at = models.DateTimeField(
auto_now_add=True, help_text="When this item was created"
)
updated_at = models.DateTimeField(
auto_now=True, help_text="When this item was last updated"
)
created_at = models.DateTimeField(auto_now_add=True, help_text="When this item was created")
updated_at = models.DateTimeField(auto_now=True, help_text="When this item was last updated")
class Meta(TrackedModel.Meta):
verbose_name = "Moderation Queue Item"
verbose_name_plural = "Moderation Queue Items"
ordering = ['priority', 'created_at']
ordering = ["priority", "created_at"]
indexes = [
models.Index(fields=['status', 'priority']),
models.Index(fields=['assigned_to']),
models.Index(fields=['created_at']),
models.Index(fields=["status", "priority"]),
models.Index(fields=["assigned_to"]),
models.Index(fields=["created_at"]),
]
def __str__(self):
return f"{self.get_item_type_display()}: {self.title}" # type: ignore
return f"{self.get_item_type_display()}: {self.title}" # type: ignore
@pghistory.track()
@@ -600,36 +531,28 @@ class ModerationAction(TrackedModel):
"""
# Action details
action_type = RichChoiceField(
choice_group="moderation_action_types",
domain="moderation",
max_length=50
)
action_type = RichChoiceField(choice_group="moderation_action_types", domain="moderation", max_length=50)
reason = models.CharField(max_length=200, help_text="Brief reason for the action")
details = models.TextField(help_text="Detailed explanation of the action")
# Duration (for temporary actions)
duration_hours = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Duration in hours for temporary actions"
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")
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',
related_name="moderation_actions_taken",
help_text="Moderator who took this action",
)
target_user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='moderation_actions_received',
related_name="moderation_actions_received",
help_text="User this action was taken against",
)
@@ -639,31 +562,27 @@ class ModerationAction(TrackedModel):
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='actions_taken',
related_name="actions_taken",
help_text="Related moderation report",
)
# Timestamps
created_at = models.DateTimeField(
auto_now_add=True, help_text="When this action was created"
)
updated_at = models.DateTimeField(
auto_now=True, help_text="When this action was last updated"
)
created_at = models.DateTimeField(auto_now_add=True, help_text="When this action was created")
updated_at = models.DateTimeField(auto_now=True, help_text="When this action was last updated")
class Meta(TrackedModel.Meta):
verbose_name = "Moderation Action"
verbose_name_plural = "Moderation Actions"
ordering = ['-created_at']
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']),
models.Index(fields=["target_user", "is_active"]),
models.Index(fields=["moderator"]),
models.Index(fields=["expires_at"]),
models.Index(fields=["created_at"]),
]
def __str__(self):
return f"{self.get_action_type_display()} against {self.target_user.username} by {self.moderator.username}" # type: ignore
return f"{self.get_action_type_display()} against {self.target_user.username} by {self.moderator.username}" # type: ignore
def save(self, *args, **kwargs):
# Set expiration time if duration is provided
@@ -684,85 +603,56 @@ class BulkOperation(StateMachineMixin, TrackedModel):
state_field_name = "status"
# Operation details
operation_type = RichChoiceField(
choice_group="bulk_operation_types",
domain="moderation",
max_length=50
)
status = RichFSMField(
choice_group="bulk_operation_statuses",
domain="moderation",
max_length=20,
default='PENDING'
)
priority = RichChoiceField(
choice_group="priority_levels",
domain="moderation",
max_length=10,
default='MEDIUM'
)
operation_type = RichChoiceField(choice_group="bulk_operation_types", domain="moderation", max_length=50)
status = RichFSMField(choice_group="bulk_operation_statuses", domain="moderation", max_length=20, default="PENDING")
priority = RichChoiceField(choice_group="priority_levels", domain="moderation", max_length=10, default="MEDIUM")
description = models.TextField(help_text="Description of what this operation does")
# Operation parameters and results
parameters = models.JSONField(
default=dict, help_text="Parameters for the operation")
results = models.JSONField(default=dict, blank=True,
help_text="Results and output from the operation")
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")
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"
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")
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")
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',
related_name="bulk_operations_created",
help_text="User who created this operation",
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
started_at = models.DateTimeField(
null=True, blank=True, help_text="When this operation started"
)
completed_at = models.DateTimeField(
null=True, blank=True, help_text="When this operation completed"
)
updated_at = models.DateTimeField(
auto_now=True, help_text="When this operation was last updated"
)
started_at = models.DateTimeField(null=True, blank=True, help_text="When this operation started")
completed_at = models.DateTimeField(null=True, blank=True, help_text="When this operation completed")
updated_at = models.DateTimeField(auto_now=True, help_text="When this operation was last updated")
class Meta(TrackedModel.Meta):
verbose_name = "Bulk Operation"
verbose_name_plural = "Bulk Operations"
ordering = ['-created_at']
ordering = ["-created_at"]
indexes = [
models.Index(fields=['status', 'priority']),
models.Index(fields=['created_by']),
models.Index(fields=['schedule_for']),
models.Index(fields=['created_at']),
models.Index(fields=["status", "priority"]),
models.Index(fields=["created_by"]),
models.Index(fields=["schedule_for"]),
models.Index(fields=["created_at"]),
]
def __str__(self):
return f"{self.get_operation_type_display()}: {self.description[:50]}" # type: ignore
return f"{self.get_operation_type_display()}: {self.description[:50]}" # type: ignore
@property
def progress_percentage(self):
@@ -792,28 +682,21 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
on_delete=models.CASCADE,
help_text="Type of object this photo is for",
)
object_id = models.PositiveIntegerField(
help_text="ID of object this photo is for"
)
object_id = models.PositiveIntegerField(help_text="ID of object this photo is for")
content_object = GenericForeignKey("content_type", "object_id")
# The photo itself
photo = models.ForeignKey(
'django_cloudflareimages_toolkit.CloudflareImage',
"django_cloudflareimages_toolkit.CloudflareImage",
on_delete=models.CASCADE,
help_text="Photo submission stored on Cloudflare Images"
help_text="Photo submission stored on Cloudflare Images",
)
caption = models.CharField(max_length=255, blank=True, help_text="Photo caption")
date_taken = models.DateField(
null=True, blank=True, help_text="Date the photo was taken"
)
date_taken = models.DateField(null=True, blank=True, help_text="Date the photo was taken")
# Metadata
status = RichFSMField(
choice_group="photo_submission_statuses",
domain="moderation",
max_length=20,
default="PENDING"
choice_group="photo_submission_statuses", domain="moderation", max_length=20, default="PENDING"
)
created_at = models.DateTimeField(auto_now_add=True)
@@ -826,9 +709,7 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
related_name="handled_photos",
help_text="Moderator who handled this submission",
)
handled_at = models.DateTimeField(
null=True, blank=True, help_text="When this submission was handled"
)
handled_at = models.DateTimeField(null=True, blank=True, help_text="When this submission was handled")
notes = models.TextField(
blank=True,
help_text="Notes from the moderator about this photo submission",
@@ -843,9 +724,7 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
related_name="claimed_photo_submissions",
help_text="Moderator who has claimed this submission for review",
)
claimed_at = models.DateTimeField(
null=True, blank=True, help_text="When this submission was claimed"
)
claimed_at = models.DateTimeField(null=True, blank=True, help_text="When this submission was claimed")
class Meta(TrackedModel.Meta):
verbose_name = "Photo Submission"
@@ -873,9 +752,7 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
from django.core.exceptions import ValidationError
if self.status != "PENDING":
raise ValidationError(
f"Cannot claim submission: current status is {self.status}, expected PENDING"
)
raise ValidationError(f"Cannot claim submission: current status is {self.status}, expected PENDING")
self.transition_to_claimed(user=user)
self.claimed_by = user
@@ -896,9 +773,7 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
from django.core.exceptions import ValidationError
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot unclaim submission: current status is {self.status}, expected CLAIMED"
)
raise ValidationError(f"Cannot unclaim submission: current status is {self.status}, expected CLAIMED")
# Set status directly (not via FSM transition to avoid cycle)
# This is intentional - the unclaim action is a special "rollback" operation

View File

@@ -88,7 +88,7 @@ class PermissionGuardAdapter:
return False
# Check object permission if available
if hasattr(permission, "has_object_permission"):
if hasattr(permission, "has_object_permission"): # noqa: SIM102
if not permission.has_object_permission(mock_request, None, instance):
self._last_error_code = "OBJECT_PERMISSION_DENIED"
return False
@@ -318,9 +318,7 @@ class CanAssignModerationTasks(GuardMixin, permissions.BasePermission):
# 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"
)
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
@@ -362,7 +360,7 @@ class CanPerformBulkOperations(GuardMixin, permissions.BasePermission):
# 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"]:
if operation_type in ["DELETE_USERS", "PURGE_DATA"]: # noqa: SIM103
return False # Only superusers can perform these operations
return True

View File

@@ -14,9 +14,7 @@ from django.utils import timezone
from .models import EditSubmission
def pending_submissions_for_review(
*, content_type: str | None = None, limit: int = 50
) -> QuerySet[EditSubmission]:
def pending_submissions_for_review(*, content_type: str | None = None, limit: int = 50) -> QuerySet[EditSubmission]:
"""
Get pending submissions that need moderation review.
@@ -39,9 +37,7 @@ def pending_submissions_for_review(
return queryset.order_by("created_at")[:limit]
def submissions_by_user(
*, user_id: int, status: str | None = None
) -> QuerySet[EditSubmission]:
def submissions_by_user(*, user_id: int, status: str | None = None) -> QuerySet[EditSubmission]:
"""
Get submissions created by a specific user.
@@ -52,9 +48,7 @@ def submissions_by_user(
Returns:
QuerySet of user's submissions
"""
queryset = EditSubmission.objects.filter(user_id=user_id).select_related(
"content_type", "handled_by"
)
queryset = EditSubmission.objects.filter(user_id=user_id).select_related("content_type", "handled_by")
if status:
queryset = queryset.filter(status=status)
@@ -62,9 +56,7 @@ def submissions_by_user(
return queryset.order_by("-created_at")
def submissions_handled_by_moderator(
*, moderator_id: int, days: int = 30
) -> QuerySet[EditSubmission]:
def submissions_handled_by_moderator(*, moderator_id: int, days: int = 30) -> QuerySet[EditSubmission]:
"""
Get submissions handled by a specific moderator in the last N days.
@@ -78,9 +70,7 @@ def submissions_handled_by_moderator(
cutoff_date = timezone.now() - timedelta(days=days)
return (
EditSubmission.objects.filter(
handled_by_id=moderator_id, handled_at__gte=cutoff_date
)
EditSubmission.objects.filter(handled_by_id=moderator_id, handled_at__gte=cutoff_date)
.select_related("user", "content_type")
.order_by("-handled_at")
)
@@ -105,9 +95,7 @@ def recent_submissions(*, days: int = 7) -> QuerySet[EditSubmission]:
)
def submissions_by_content_type(
*, content_type: str, status: str | None = None
) -> QuerySet[EditSubmission]:
def submissions_by_content_type(*, content_type: str, status: str | None = None) -> QuerySet[EditSubmission]:
"""
Get submissions for a specific content type.
@@ -118,9 +106,9 @@ def submissions_by_content_type(
Returns:
QuerySet of submissions for the content type
"""
queryset = EditSubmission.objects.filter(
content_type__model=content_type.lower()
).select_related("user", "handled_by")
queryset = EditSubmission.objects.filter(content_type__model=content_type.lower()).select_related(
"user", "handled_by"
)
if status:
queryset = queryset.filter(status=status)
@@ -136,12 +124,8 @@ def moderation_queue_summary() -> dict[str, Any]:
Dictionary containing queue statistics
"""
pending_count = EditSubmission.objects.filter(status="PENDING").count()
approved_today = EditSubmission.objects.filter(
status="APPROVED", handled_at__date=timezone.now().date()
).count()
rejected_today = EditSubmission.objects.filter(
status="REJECTED", handled_at__date=timezone.now().date()
).count()
approved_today = EditSubmission.objects.filter(status="APPROVED", handled_at__date=timezone.now().date()).count()
rejected_today = EditSubmission.objects.filter(status="REJECTED", handled_at__date=timezone.now().date()).count()
# Submissions by content type
submissions_by_type = (
@@ -159,9 +143,7 @@ def moderation_queue_summary() -> dict[str, Any]:
}
def moderation_statistics_summary(
*, days: int = 30, moderator: User | None = None
) -> dict[str, Any]:
def moderation_statistics_summary(*, days: int = 30, moderator: User | None = None) -> dict[str, Any]:
"""
Get comprehensive moderation statistics for a time period.
@@ -189,8 +171,7 @@ def moderation_statistics_summary(
handled_queryset.exclude(handled_at__isnull=True)
.annotate(
response_hours=ExpressionWrapper(
Extract(F('handled_at') - F('created_at'), 'epoch') / 3600.0,
output_field=FloatField()
Extract(F("handled_at") - F("created_at"), "epoch") / 3600.0, output_field=FloatField()
)
)
.values_list("response_hours", flat=True)

View File

@@ -68,9 +68,7 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
submitted_by = UserBasicSerializer(source="user", read_only=True)
claimed_by = UserBasicSerializer(read_only=True)
content_type_name = serializers.CharField(
source="content_type.model", read_only=True
)
content_type_name = serializers.CharField(source="content_type.model", read_only=True)
# UI Metadata fields for Nuxt rendering
status_color = serializers.SerializerMethodField()
@@ -117,10 +115,10 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
def get_status_color(self, obj) -> str:
"""Return hex color based on status for UI badges."""
colors = {
"PENDING": "#f59e0b", # Amber
"CLAIMED": "#3b82f6", # Blue
"APPROVED": "#10b981", # Emerald
"REJECTED": "#ef4444", # Red
"PENDING": "#f59e0b", # Amber
"CLAIMED": "#3b82f6", # Blue
"APPROVED": "#10b981", # Emerald
"REJECTED": "#ef4444", # Red
"ESCALATED": "#8b5cf6", # Violet
}
return colors.get(obj.status, "#6b7280") # Default gray
@@ -154,15 +152,9 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
class EditSubmissionListSerializer(serializers.ModelSerializer):
"""Optimized serializer for EditSubmission lists."""
submitted_by_username = serializers.CharField(
source="user.username", read_only=True
)
claimed_by_username = serializers.CharField(
source="claimed_by.username", read_only=True, allow_null=True
)
content_type_name = serializers.CharField(
source="content_type.model", read_only=True
)
submitted_by_username = serializers.CharField(source="user.username", read_only=True)
claimed_by_username = serializers.CharField(source="claimed_by.username", read_only=True, allow_null=True)
content_type_name = serializers.CharField(source="content_type.model", read_only=True)
status_color = serializers.SerializerMethodField()
status_icon = serializers.SerializerMethodField()
@@ -218,13 +210,9 @@ class ModerationReportSerializer(serializers.ModelSerializer):
# Computed fields
is_overdue = serializers.SerializerMethodField()
time_since_created = serializers.SerializerMethodField()
priority_display = serializers.CharField(
source="get_priority_display", read_only=True
)
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
)
report_type_display = serializers.CharField(source="get_report_type_display", read_only=True)
class Meta:
model = ModerationReport
@@ -318,17 +306,13 @@ class CreateModerationReportSerializer(serializers.ModelSerializer):
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)}'
}
{"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"}
)
raise serializers.ValidationError({"evidence_urls": "Must be a list of URLs"})
return attrs
@@ -351,9 +335,7 @@ class CreateModerationReportSerializer(serializers.ModelSerializer):
if entity_type in app_label_map:
try:
content_type = ContentType.objects.get(
app_label=app_label_map[entity_type], model=entity_type
)
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
@@ -377,9 +359,7 @@ class UpdateModerationReportSerializer(serializers.ModelSerializer):
def validate_status(self, value):
"""Validate status transitions."""
if self.instance and self.instance.status == "RESOLVED" and value != "RESOLVED":
raise serializers.ValidationError(
"Cannot change status of resolved report"
)
raise serializers.ValidationError("Cannot change status of resolved report")
return value
def update(self, instance, validated_data):
@@ -462,13 +442,9 @@ class ModerationQueueSerializer(serializers.ModelSerializer):
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
)
completion_time = obj.assigned_at + timedelta(minutes=obj.estimated_review_time)
else:
completion_time = timezone.now() + timedelta(
minutes=obj.estimated_review_time
)
completion_time = timezone.now() + timedelta(minutes=obj.estimated_review_time)
return completion_time.isoformat()
@@ -484,12 +460,10 @@ class AssignQueueItemSerializer(serializers.Serializer):
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"
)
raise serializers.ValidationError("User must be a moderator, admin, or superuser")
return value
except User.DoesNotExist:
raise serializers.ValidationError("Moderator not found")
raise serializers.ValidationError("Moderator not found") from None
class CompleteQueueItemSerializer(serializers.Serializer):
@@ -514,9 +488,7 @@ class CompleteQueueItemSerializer(serializers.Serializer):
# 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}"}
)
raise serializers.ValidationError({"notes": f"Notes are required for action: {action}"})
return attrs
@@ -536,9 +508,7 @@ class ModerationActionSerializer(serializers.ModelSerializer):
# Computed fields
is_expired = serializers.SerializerMethodField()
time_remaining = serializers.SerializerMethodField()
action_type_display = serializers.CharField(
source="get_action_type_display", read_only=True
)
action_type_display = serializers.CharField(source="get_action_type_display", read_only=True)
class Meta:
model = ModerationAction
@@ -620,7 +590,7 @@ class CreateModerationActionSerializer(serializers.ModelSerializer):
User.objects.get(id=value)
return value
except User.DoesNotExist:
raise serializers.ValidationError("Target user not found")
raise serializers.ValidationError("Target user not found") from None
def validate_related_report_id(self, value):
"""Validate related report exists."""
@@ -629,7 +599,7 @@ class CreateModerationActionSerializer(serializers.ModelSerializer):
ModerationReport.objects.get(id=value)
return value
except ModerationReport.DoesNotExist:
raise serializers.ValidationError("Related report not found")
raise serializers.ValidationError("Related report not found") from None
return value
def validate(self, attrs):
@@ -640,17 +610,11 @@ class CreateModerationActionSerializer(serializers.ModelSerializer):
# 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}"}
)
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)"}
)
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
@@ -668,9 +632,7 @@ class CreateModerationActionSerializer(serializers.ModelSerializer):
# Set expiration time for temporary actions
if validated_data.get("duration_hours"):
validated_data["expires_at"] = timezone.now() + timedelta(
hours=validated_data["duration_hours"]
)
validated_data["expires_at"] = timezone.now() + timedelta(hours=validated_data["duration_hours"])
return super().create(validated_data)
@@ -688,9 +650,7 @@ class BulkOperationSerializer(serializers.ModelSerializer):
# Computed fields
progress_percentage = serializers.SerializerMethodField()
estimated_completion = serializers.SerializerMethodField()
operation_type_display = serializers.CharField(
source="get_operation_type_display", read_only=True
)
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:
@@ -741,17 +701,13 @@ class BulkOperationSerializer(serializers.ModelSerializer):
if obj.status == "COMPLETED":
return obj.completed_at.isoformat() if obj.completed_at else None
if obj.status == "RUNNING" and obj.started_at:
if obj.status == "RUNNING" and obj.started_at: # noqa: SIM102
# Calculate based on current progress
if obj.processed_items > 0:
elapsed_minutes = (timezone.now() - obj.started_at).total_seconds() / 60
rate = obj.processed_items / elapsed_minutes
remaining_items = obj.total_items - obj.processed_items
remaining_minutes = (
remaining_items / rate
if rate > 0
else obj.estimated_duration_minutes
)
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()
@@ -759,9 +715,7 @@ class BulkOperationSerializer(serializers.ModelSerializer):
if obj.schedule_for:
return obj.schedule_for.isoformat()
elif obj.estimated_duration_minutes:
completion_time = timezone.now() + timedelta(
minutes=obj.estimated_duration_minutes
)
completion_time = timezone.now() + timedelta(minutes=obj.estimated_duration_minutes)
return completion_time.isoformat()
return None
@@ -801,9 +755,7 @@ class CreateBulkOperationSerializer(serializers.ModelSerializer):
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}'
)
raise serializers.ValidationError(f'Parameter "{param}" is required for {operation_type}')
return value
@@ -902,27 +854,28 @@ class UserModerationProfileSerializer(serializers.Serializer):
class StateLogSerializer(serializers.ModelSerializer):
"""Serializer for FSM transition history."""
user = serializers.CharField(source='by.username', read_only=True)
model = serializers.CharField(source='content_type.model', read_only=True)
from_state = serializers.CharField(source='source_state', read_only=True)
to_state = serializers.CharField(source='state', read_only=True)
reason = serializers.CharField(source='description', read_only=True)
user = serializers.CharField(source="by.username", read_only=True)
model = serializers.CharField(source="content_type.model", read_only=True)
from_state = serializers.CharField(source="source_state", read_only=True)
to_state = serializers.CharField(source="state", read_only=True)
reason = serializers.CharField(source="description", read_only=True)
class Meta:
from django_fsm_log.models import StateLog
model = StateLog
fields = [
'id',
'timestamp',
'model',
'object_id',
'state',
'from_state',
'to_state',
'transition',
'user',
'description',
'reason',
"id",
"timestamp",
"model",
"object_id",
"state",
"from_state",
"to_state",
"transition",
"user",
"description",
"reason",
]
read_only_fields = fields
@@ -931,9 +884,7 @@ class PhotoSubmissionSerializer(serializers.ModelSerializer):
"""Serializer for PhotoSubmission."""
submitted_by = UserBasicSerializer(source="user", read_only=True)
content_type_name = serializers.CharField(
source="content_type.model", read_only=True
)
content_type_name = serializers.CharField(source="content_type.model", read_only=True)
photo_url = serializers.SerializerMethodField()
# UI Metadata
@@ -1012,4 +963,3 @@ class PhotoSubmissionSerializer(serializers.ModelSerializer):
else:
minutes = diff.seconds // 60
return f"{minutes} minutes ago"

View File

@@ -19,9 +19,7 @@ class ModerationService:
"""Service for handling content moderation workflows."""
@staticmethod
def approve_submission(
*, submission_id: int, moderator: User, notes: str | None = None
) -> object | None:
def approve_submission(*, submission_id: int, moderator: User, notes: str | None = None) -> object | None:
"""
Approve a content submission and apply changes.
@@ -39,9 +37,7 @@ class ModerationService:
ValueError: If submission cannot be processed
"""
with transaction.atomic():
submission = EditSubmission.objects.select_for_update().get(
id=submission_id
)
submission = EditSubmission.objects.select_for_update().get(id=submission_id)
if submission.status != "PENDING":
raise ValueError(f"Submission {submission_id} is not pending approval")
@@ -75,9 +71,7 @@ class ModerationService:
raise
@staticmethod
def reject_submission(
*, submission_id: int, moderator: User, reason: str
) -> EditSubmission:
def reject_submission(*, submission_id: int, moderator: User, reason: str) -> EditSubmission:
"""
Reject a content submission.
@@ -94,9 +88,7 @@ class ModerationService:
ValueError: If submission cannot be rejected
"""
with transaction.atomic():
submission = EditSubmission.objects.select_for_update().get(
id=submission_id
)
submission = EditSubmission.objects.select_for_update().get(id=submission_id)
if submission.status != "PENDING":
raise ValueError(f"Submission {submission_id} is not pending review")
@@ -175,9 +167,7 @@ class ModerationService:
ValueError: If submission cannot be modified
"""
with transaction.atomic():
submission = EditSubmission.objects.select_for_update().get(
id=submission_id
)
submission = EditSubmission.objects.select_for_update().get(id=submission_id)
if submission.status != "PENDING":
raise ValueError(f"Submission {submission_id} is not pending review")
@@ -220,9 +210,7 @@ class ModerationService:
return pending_submissions_for_review(content_type=content_type, limit=limit)
@staticmethod
def get_submission_statistics(
*, days: int = 30, moderator: User | None = None
) -> dict[str, Any]:
def get_submission_statistics(*, days: int = 30, moderator: User | None = None) -> dict[str, Any]:
"""
Get moderation statistics for a time period.
@@ -248,7 +236,7 @@ class ModerationService:
Returns:
True if user is MODERATOR, ADMIN, or SUPERUSER
"""
return user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']
return user.role in ["MODERATOR", "ADMIN", "SUPERUSER"]
@staticmethod
def create_edit_submission_with_queue(
@@ -297,33 +285,32 @@ class ModerationService:
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'
"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)}'
"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
submission=submission, submitter=submitter
)
return {
'submission': submission,
'status': 'queued',
'created_object': None,
'queue_item': queue_item,
'message': 'Submission added to moderation queue'
"submission": submission,
"status": "queued",
"created_object": None,
"queue_item": queue_item,
"message": "Submission added to moderation queue",
}
@staticmethod
@@ -370,36 +357,33 @@ class ModerationService:
try:
submission.auto_approve()
return {
'submission': submission,
'status': 'auto_approved',
'queue_item': None,
'message': 'Photo submission auto-approved for moderator'
"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)}'
"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
submission=submission, submitter=submitter
)
return {
'submission': submission,
'status': 'queued',
'queue_item': queue_item,
'message': 'Photo submission added to moderation queue'
"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:
def _create_queue_item_for_submission(*, submission: EditSubmission, submitter: User) -> ModerationQueue:
"""
Create a moderation queue item for an edit submission.
@@ -417,13 +401,13 @@ class ModerationService:
# 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 "",
"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)
entity_preview["object_name"] = str(submission.content_object)
# Determine title and description
action = "creation" if submission.submission_type == "CREATE" else "edit"
@@ -435,7 +419,7 @@ class ModerationService:
# Create queue item
queue_item = ModerationQueue(
item_type='CONTENT_REVIEW',
item_type="CONTENT_REVIEW",
title=title,
description=description,
entity_type=entity_type,
@@ -443,9 +427,9 @@ class ModerationService:
entity_preview=entity_preview,
content_type=content_type,
flagged_by=submitter,
priority='MEDIUM',
priority="MEDIUM",
estimated_review_time=15, # 15 minutes default
tags=['edit_submission', submission.submission_type.lower()],
tags=["edit_submission", submission.submission_type.lower()],
)
queue_item.full_clean()
@@ -454,9 +438,7 @@ class ModerationService:
return queue_item
@staticmethod
def _create_queue_item_for_photo_submission(
*, submission: PhotoSubmission, submitter: User
) -> ModerationQueue:
def _create_queue_item_for_photo_submission(*, submission: PhotoSubmission, submitter: User) -> ModerationQueue:
"""
Create a moderation queue item for a photo submission.
@@ -474,13 +456,13 @@ class ModerationService:
# 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,
"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)
entity_preview["object_name"] = str(submission.content_object)
# Create title and description
title = f"Photo submission for {entity_type} by {submitter.username}"
@@ -490,7 +472,7 @@ class ModerationService:
# Create queue item
queue_item = ModerationQueue(
item_type='CONTENT_REVIEW',
item_type="CONTENT_REVIEW",
title=title,
description=description,
entity_type=entity_type,
@@ -498,9 +480,9 @@ class ModerationService:
entity_preview=entity_preview,
content_type=content_type,
flagged_by=submitter,
priority='LOW', # Photos typically lower priority
priority="LOW", # Photos typically lower priority
estimated_review_time=5, # 5 minutes default for photos
tags=['photo_submission'],
tags=["photo_submission"],
)
queue_item.full_clean()
@@ -525,11 +507,9 @@ class ModerationService:
Dictionary with processing results
"""
with transaction.atomic():
queue_item = ModerationQueue.objects.select_for_update().get(
id=queue_item_id
)
queue_item = ModerationQueue.objects.select_for_update().get(id=queue_item_id)
if queue_item.status != 'PENDING':
if queue_item.status != "PENDING":
raise ValueError(f"Queue item {queue_item_id} is not pending")
# Transition queue item into an active state before processing
@@ -542,7 +522,7 @@ class ModerationService:
pass
except AttributeError:
# Fallback for environments without the generated transition method
queue_item.status = 'IN_PROGRESS'
queue_item.status = "IN_PROGRESS"
moved_to_in_progress = True
if moved_to_in_progress:
@@ -554,116 +534,94 @@ class ModerationService:
try:
queue_item.transition_to_completed(user=moderator)
except TransitionNotAllowed:
queue_item.status = 'COMPLETED'
queue_item.status = "COMPLETED"
except AttributeError:
queue_item.status = 'COMPLETED'
queue_item.status = "COMPLETED"
# Find related submission
if 'edit_submission' in queue_item.tags:
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')
status="PENDING",
).order_by("-created_at")
if not submissions.exists():
raise ValueError(
"No pending edit submission found for this queue item")
raise ValueError("No pending edit submission found for this queue item")
submission = submissions.first()
if action == 'approve':
if action == "approve":
try:
created_object = submission.approve(moderator)
# Use FSM transition for queue status
_complete_queue_item()
result = {
'status': 'approved',
'created_object': created_object,
'message': 'Submission approved successfully'
"status": "approved",
"created_object": created_object,
"message": "Submission approved successfully",
}
except Exception as e:
# Use FSM transition for queue status
_complete_queue_item()
result = {
'status': 'failed',
'created_object': None,
'message': f'Approval failed: {str(e)}'
}
elif action == 'reject':
result = {"status": "failed", "created_object": None, "message": f"Approval failed: {str(e)}"}
elif action == "reject":
submission.reject(moderator, notes or "Rejected by moderator")
# Use FSM transition for queue status
_complete_queue_item()
result = {
'status': 'rejected',
'created_object': None,
'message': 'Submission rejected'
}
elif action == 'escalate':
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.priority = "HIGH"
# Keep status as PENDING for escalation
result = {
'status': 'escalated',
'created_object': None,
'message': 'Submission escalated'
}
result = {"status": "escalated", "created_object": None, "message": "Submission escalated"}
else:
raise ValueError(f"Unknown action: {action}")
elif 'photo_submission' in queue_item.tags:
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')
status="PENDING",
).order_by("-created_at")
if not submissions.exists():
raise ValueError(
"No pending photo submission found for this queue item")
raise ValueError("No pending photo submission found for this queue item")
submission = submissions.first()
if action == 'approve':
if action == "approve":
try:
submission.approve(moderator, notes or "")
# Use FSM transition for queue status
_complete_queue_item()
result = {
'status': 'approved',
'created_object': None,
'message': 'Photo submission approved successfully'
"status": "approved",
"created_object": None,
"message": "Photo submission approved successfully",
}
except Exception as e:
# Use FSM transition for queue status
_complete_queue_item()
result = {
'status': 'failed',
'created_object': None,
'message': f'Photo approval failed: {str(e)}'
"status": "failed",
"created_object": None,
"message": f"Photo approval failed: {str(e)}",
}
elif action == 'reject':
elif action == "reject":
submission.reject(moderator, notes or "Rejected by moderator")
# Use FSM transition for queue status
_complete_queue_item()
result = {
'status': 'rejected',
'created_object': None,
'message': 'Photo submission rejected'
}
elif action == 'escalate':
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.priority = "HIGH"
# Keep status as PENDING for escalation
result = {
'status': 'escalated',
'created_object': None,
'message': 'Photo submission escalated'
}
result = {"status": "escalated", "created_object": None, "message": "Photo submission escalated"}
else:
raise ValueError(f"Unknown action: {action}")
else:
@@ -678,5 +636,5 @@ class ModerationService:
queue_item.full_clean()
queue_item.save()
result['queue_item'] = queue_item
result["queue_item"] = queue_item
return result

View File

@@ -48,12 +48,10 @@ def handle_submission_claimed(instance, source, target, user, context=None, **kw
user: The user who claimed.
context: Optional TransitionContext.
"""
if target != 'CLAIMED':
if target != "CLAIMED":
return
logger.info(
f"Submission {instance.pk} claimed by {user.username if user else 'system'}"
)
logger.info(f"Submission {instance.pk} claimed by {user.username if user else 'system'}")
# Broadcast for real-time dashboard updates
_broadcast_submission_status_change(instance, source, target, user)
@@ -72,12 +70,10 @@ def handle_submission_unclaimed(instance, source, target, user, context=None, **
user: The user who unclaimed.
context: Optional TransitionContext.
"""
if source != 'CLAIMED' or target != 'PENDING':
if source != "CLAIMED" or target != "PENDING":
return
logger.info(
f"Submission {instance.pk} unclaimed by {user.username if user else 'system'}"
)
logger.info(f"Submission {instance.pk} unclaimed by {user.username if user else 'system'}")
# Broadcast for real-time dashboard updates
_broadcast_submission_status_change(instance, source, target, user)
@@ -96,25 +92,21 @@ def handle_submission_approved(instance, source, target, user, context=None, **k
user: The user who approved.
context: Optional TransitionContext.
"""
if target != 'APPROVED':
if target != "APPROVED":
return
logger.info(
f"Submission {instance.pk} approved by {user if user else 'system'}"
)
logger.info(f"Submission {instance.pk} approved by {user if user else 'system'}")
# Trigger notification (handled by NotificationCallback)
# Invalidate cache (handled by CacheInvalidationCallback)
# Apply the submission changes if applicable
if hasattr(instance, 'apply_changes'):
if hasattr(instance, "apply_changes"):
try:
instance.apply_changes()
logger.info(f"Applied changes for submission {instance.pk}")
except Exception as e:
logger.exception(
f"Failed to apply changes for submission {instance.pk}: {e}"
)
logger.exception(f"Failed to apply changes for submission {instance.pk}: {e}")
def handle_submission_rejected(instance, source, target, user, context=None, **kwargs):
@@ -130,13 +122,12 @@ def handle_submission_rejected(instance, source, target, user, context=None, **k
user: The user who rejected.
context: Optional TransitionContext.
"""
if target != 'REJECTED':
if target != "REJECTED":
return
reason = context.extra_data.get('reason', '') if context else ''
reason = context.extra_data.get("reason", "") if context else ""
logger.info(
f"Submission {instance.pk} rejected by {user if user else 'system'}"
f"{f': {reason}' if reason else ''}"
f"Submission {instance.pk} rejected by {user if user else 'system'}" f"{f': {reason}' if reason else ''}"
)
@@ -153,13 +144,12 @@ def handle_submission_escalated(instance, source, target, user, context=None, **
user: The user who escalated.
context: Optional TransitionContext.
"""
if target != 'ESCALATED':
if target != "ESCALATED":
return
reason = context.extra_data.get('reason', '') if context else ''
reason = context.extra_data.get("reason", "") if context else ""
logger.info(
f"Submission {instance.pk} escalated by {user if user else 'system'}"
f"{f': {reason}' if reason else ''}"
f"Submission {instance.pk} escalated by {user if user else 'system'}" f"{f': {reason}' if reason else ''}"
)
# Create escalation task if task system is available
@@ -179,15 +169,13 @@ def handle_report_resolved(instance, source, target, user, context=None, **kwarg
user: The user who resolved.
context: Optional TransitionContext.
"""
if target != 'RESOLVED':
if target != "RESOLVED":
return
logger.info(
f"ModerationReport {instance.pk} resolved by {user if user else 'system'}"
)
logger.info(f"ModerationReport {instance.pk} resolved by {user if user else 'system'}")
# Update related queue items
_update_related_queue_items(instance, 'COMPLETED')
_update_related_queue_items(instance, "COMPLETED")
def handle_queue_completed(instance, source, target, user, context=None, **kwargs):
@@ -203,12 +191,10 @@ def handle_queue_completed(instance, source, target, user, context=None, **kwarg
user: The user who completed.
context: Optional TransitionContext.
"""
if target != 'COMPLETED':
if target != "COMPLETED":
return
logger.info(
f"ModerationQueue {instance.pk} completed by {user if user else 'system'}"
)
logger.info(f"ModerationQueue {instance.pk} completed by {user if user else 'system'}")
# Update moderation statistics
_update_moderation_stats(instance, user)
@@ -227,18 +213,17 @@ def handle_bulk_operation_status(instance, source, target, user, context=None, *
user: The user who initiated the change.
context: Optional TransitionContext.
"""
logger.info(
f"BulkOperation {instance.pk} transitioned: {source}{target}"
)
logger.info(f"BulkOperation {instance.pk} transitioned: {source}{target}")
if target == 'COMPLETED':
if target == "COMPLETED":
_finalize_bulk_operation(instance, success=True)
elif target == 'FAILED':
elif target == "FAILED":
_finalize_bulk_operation(instance, success=False)
# Helper functions
def _create_escalation_task(instance, user, reason):
"""Create an escalation task for admin review."""
try:
@@ -247,7 +232,7 @@ def _create_escalation_task(instance, user, reason):
# Create a queue item for the escalated submission
ModerationQueue.objects.create(
content_object=instance,
priority='HIGH',
priority="HIGH",
reason=f"Escalated: {reason}" if reason else "Escalated for review",
created_by=user,
)
@@ -287,10 +272,10 @@ def _update_moderation_stats(instance, user):
try:
# Update user's moderation count if they have a profile
profile = getattr(user, 'profile', None)
if profile and hasattr(profile, 'moderation_count'):
profile = getattr(user, "profile", None)
if profile and hasattr(profile, "moderation_count"):
profile.moderation_count += 1
profile.save(update_fields=['moderation_count'])
profile.save(update_fields=["moderation_count"])
logger.debug(f"Updated moderation count for {user}")
except Exception as e:
logger.warning(f"Failed to update moderation stats: {e}")
@@ -302,7 +287,7 @@ def _finalize_bulk_operation(instance, success):
from django.utils import timezone
instance.completed_at = timezone.now()
instance.save(update_fields=['completed_at'])
instance.save(update_fields=["completed_at"])
if success:
logger.info(
@@ -312,8 +297,7 @@ def _finalize_bulk_operation(instance, success):
)
else:
logger.warning(
f"BulkOperation {instance.pk} failed: "
f"{getattr(instance, 'error_message', 'Unknown error')}"
f"BulkOperation {instance.pk} failed: " f"{getattr(instance, 'error_message', 'Unknown error')}"
)
except Exception as e:
logger.warning(f"Failed to finalize bulk operation: {e}")
@@ -355,9 +339,9 @@ def _broadcast_submission_status_change(instance, source, target, user):
}
# Add claim information if available
if hasattr(instance, 'claimed_by') and instance.claimed_by:
if hasattr(instance, "claimed_by") and instance.claimed_by:
payload["locked_by"] = instance.claimed_by.username
if hasattr(instance, 'claimed_at') and instance.claimed_at:
if hasattr(instance, "claimed_at") and instance.claimed_at:
payload["locked_at"] = instance.claimed_at.isoformat()
# Emit the signal for downstream notification handlers
@@ -371,16 +355,14 @@ def _broadcast_submission_status_change(instance, source, target, user):
payload=payload,
)
logger.debug(
f"Broadcast status change: {submission_type}#{instance.pk} "
f"{source} -> {target}"
)
logger.debug(f"Broadcast status change: {submission_type}#{instance.pk} " f"{source} -> {target}")
except Exception as e:
logger.warning(f"Failed to broadcast submission status change: {e}")
# Signal handler registration
def register_moderation_signal_handlers():
"""
Register all moderation signal handlers.
@@ -399,70 +381,31 @@ def register_moderation_signal_handlers():
)
# EditSubmission handlers
register_transition_handler(
EditSubmission, '*', 'APPROVED',
handle_submission_approved, stage='post'
)
register_transition_handler(
EditSubmission, '*', 'REJECTED',
handle_submission_rejected, stage='post'
)
register_transition_handler(
EditSubmission, '*', 'ESCALATED',
handle_submission_escalated, stage='post'
)
register_transition_handler(EditSubmission, "*", "APPROVED", handle_submission_approved, stage="post")
register_transition_handler(EditSubmission, "*", "REJECTED", handle_submission_rejected, stage="post")
register_transition_handler(EditSubmission, "*", "ESCALATED", handle_submission_escalated, stage="post")
# PhotoSubmission handlers
register_transition_handler(
PhotoSubmission, '*', 'APPROVED',
handle_submission_approved, stage='post'
)
register_transition_handler(
PhotoSubmission, '*', 'REJECTED',
handle_submission_rejected, stage='post'
)
register_transition_handler(
PhotoSubmission, '*', 'ESCALATED',
handle_submission_escalated, stage='post'
)
register_transition_handler(PhotoSubmission, "*", "APPROVED", handle_submission_approved, stage="post")
register_transition_handler(PhotoSubmission, "*", "REJECTED", handle_submission_rejected, stage="post")
register_transition_handler(PhotoSubmission, "*", "ESCALATED", handle_submission_escalated, stage="post")
# ModerationReport handlers
register_transition_handler(
ModerationReport, '*', 'RESOLVED',
handle_report_resolved, stage='post'
)
register_transition_handler(ModerationReport, "*", "RESOLVED", handle_report_resolved, stage="post")
# ModerationQueue handlers
register_transition_handler(
ModerationQueue, '*', 'COMPLETED',
handle_queue_completed, stage='post'
)
register_transition_handler(ModerationQueue, "*", "COMPLETED", handle_queue_completed, stage="post")
# BulkOperation handlers
register_transition_handler(
BulkOperation, '*', '*',
handle_bulk_operation_status, stage='post'
)
register_transition_handler(BulkOperation, "*", "*", handle_bulk_operation_status, stage="post")
# Claim/Unclaim handlers for EditSubmission
register_transition_handler(
EditSubmission, 'PENDING', 'CLAIMED',
handle_submission_claimed, stage='post'
)
register_transition_handler(
EditSubmission, 'CLAIMED', 'PENDING',
handle_submission_unclaimed, stage='post'
)
register_transition_handler(EditSubmission, "PENDING", "CLAIMED", handle_submission_claimed, stage="post")
register_transition_handler(EditSubmission, "CLAIMED", "PENDING", handle_submission_unclaimed, stage="post")
# Claim/Unclaim handlers for PhotoSubmission
register_transition_handler(
PhotoSubmission, 'PENDING', 'CLAIMED',
handle_submission_claimed, stage='post'
)
register_transition_handler(
PhotoSubmission, 'CLAIMED', 'PENDING',
handle_submission_unclaimed, stage='post'
)
register_transition_handler(PhotoSubmission, "PENDING", "CLAIMED", handle_submission_claimed, stage="post")
register_transition_handler(PhotoSubmission, "CLAIMED", "PENDING", handle_submission_unclaimed, stage="post")
logger.info("Registered moderation signal handlers")
@@ -471,14 +414,14 @@ def register_moderation_signal_handlers():
__all__ = [
'submission_status_changed',
'register_moderation_signal_handlers',
'handle_submission_approved',
'handle_submission_rejected',
'handle_submission_escalated',
'handle_submission_claimed',
'handle_submission_unclaimed',
'handle_report_resolved',
'handle_queue_completed',
'handle_bulk_operation_status',
"submission_status_changed",
"register_moderation_signal_handlers",
"handle_submission_approved",
"handle_submission_rejected",
"handle_submission_escalated",
"handle_submission_claimed",
"handle_submission_unclaimed",
"handle_report_resolved",
"handle_queue_completed",
"handle_bulk_operation_status",
]

View File

@@ -4,6 +4,7 @@ Server-Sent Events (SSE) endpoint for real-time moderation dashboard updates.
This module provides a streaming HTTP response that broadcasts submission status
changes to connected moderators in real-time.
"""
import json
import logging
import queue
@@ -103,6 +104,7 @@ class ModerationSSEView(APIView):
Sends a heartbeat every 30 seconds to keep the connection alive.
"""
def event_stream() -> Generator[str]:
client_queue = sse_broadcaster.subscribe()
@@ -124,13 +126,10 @@ class ModerationSSEView(APIView):
finally:
sse_broadcaster.unsubscribe(client_queue)
response = StreamingHttpResponse(
event_stream(),
content_type='text/event-stream'
)
response['Cache-Control'] = 'no-cache'
response['X-Accel-Buffering'] = 'no' # Disable nginx buffering
response['Connection'] = 'keep-alive'
response = StreamingHttpResponse(event_stream(), content_type="text/event-stream")
response["Cache-Control"] = "no-cache"
response["X-Accel-Buffering"] = "no" # Disable nginx buffering
response["Connection"] = "keep-alive"
return response
@@ -168,15 +167,17 @@ class ModerationSSETestView(APIView):
sse_broadcaster.broadcast(test_payload)
return JsonResponse({
"status": "ok",
"message": f"Test event broadcast to {len(sse_broadcaster._subscribers)} clients",
"payload": test_payload,
})
return JsonResponse(
{
"status": "ok",
"message": f"Test event broadcast to {len(sse_broadcaster._subscribers)} clients",
"payload": test_payload,
}
)
__all__ = [
'ModerationSSEView',
'ModerationSSETestView',
'sse_broadcaster',
"ModerationSSEView",
"ModerationSSETestView",
"sse_broadcaster",
]

View File

@@ -14,9 +14,7 @@ def get_object_name(value: int | None, model_path: str) -> str | None:
app_label, model = model_path.split(".")
try:
content_type = ContentType.objects.get(
app_label=app_label.lower(), model=model.lower()
)
content_type = ContentType.objects.get(app_label=app_label.lower(), model=model.lower())
model_class = content_type.model_class()
if not model_class:
return None
@@ -60,9 +58,7 @@ def get_park_area_name(value: int | None, park_id: int | None) -> str | None:
@register.filter
def get_item(
dictionary: dict[str, Any] | None, key: str | int | None
) -> list[Any]:
def get_item(dictionary: dict[str, Any] | None, key: str | int | None) -> list[Any]:
"""Get item from dictionary by key."""
if not dictionary or not isinstance(dictionary, dict) or not key:
return []

File diff suppressed because it is too large Load Diff

View File

@@ -43,24 +43,15 @@ class TestModerationAdminSite(TestCase):
assert moderation_site.has_permission(request) is False
# Regular user
request.user = type("obj", (object,), {
"is_authenticated": True,
"role": "USER"
})()
request.user = type("obj", (object,), {"is_authenticated": True, "role": "USER"})()
assert moderation_site.has_permission(request) is False
# Moderator
request.user = type("obj", (object,), {
"is_authenticated": True,
"role": "MODERATOR"
})()
request.user = type("obj", (object,), {"is_authenticated": True, "role": "MODERATOR"})()
assert moderation_site.has_permission(request) is True
# Admin
request.user = type("obj", (object,), {
"is_authenticated": True,
"role": "ADMIN"
})()
request.user = type("obj", (object,), {"is_authenticated": True, "role": "ADMIN"})()
assert moderation_site.has_permission(request) is True
@@ -146,6 +137,7 @@ class TestStateLogAdmin(TestCase):
self.site = AdminSite()
# Note: StateLog is from django_fsm_log
from django_fsm_log.models import StateLog
self.admin = StateLogAdmin(model=StateLog, admin_site=self.site)
def test_readonly_permissions(self):
@@ -215,4 +207,5 @@ class TestRegisteredModels(TestCase):
def test_state_log_registered(self):
"""Verify StateLog is registered with moderation site."""
from django_fsm_log.models import StateLog
assert StateLog in moderation_site._registry

View File

@@ -9,7 +9,6 @@ This module tests end-to-end moderation workflows including:
- Bulk operation workflow
"""
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
@@ -25,22 +24,13 @@ class SubmissionApprovalWorkflowTests(TestCase):
def setUpTestData(cls):
"""Set up test data for all tests."""
cls.regular_user = User.objects.create_user(
username='regular_user',
email='user@example.com',
password='testpass123',
role='USER'
username="regular_user", email="user@example.com", password="testpass123", role="USER"
)
cls.moderator = User.objects.create_user(
username='moderator',
email='mod@example.com',
password='testpass123',
role='MODERATOR'
username="moderator", email="mod@example.com", password="testpass123", role="MODERATOR"
)
cls.admin = User.objects.create_user(
username='admin',
email='admin@example.com',
password='testpass123',
role='ADMIN'
username="admin", email="admin@example.com", password="testpass123", role="ADMIN"
)
def test_edit_submission_approval_workflow(self):
@@ -53,10 +43,7 @@ class SubmissionApprovalWorkflowTests(TestCase):
from apps.parks.models import Company
# Create target object
company = Company.objects.create(
name='Test Company',
description='Original description'
)
company = Company.objects.create(name="Test Company", description="Original description")
# User submits an edit
content_type = ContentType.objects.get_for_model(company)
@@ -64,13 +51,13 @@ class SubmissionApprovalWorkflowTests(TestCase):
user=self.regular_user,
content_type=content_type,
object_id=company.id,
submission_type='EDIT',
changes={'description': 'Updated description'},
status='PENDING',
reason='Fixing typo'
submission_type="EDIT",
changes={"description": "Updated description"},
status="PENDING",
reason="Fixing typo",
)
self.assertEqual(submission.status, 'PENDING')
self.assertEqual(submission.status, "PENDING")
self.assertIsNone(submission.handled_by)
self.assertIsNone(submission.handled_at)
@@ -81,7 +68,7 @@ class SubmissionApprovalWorkflowTests(TestCase):
submission.save()
submission.refresh_from_db()
self.assertEqual(submission.status, 'APPROVED')
self.assertEqual(submission.status, "APPROVED")
self.assertEqual(submission.handled_by, self.moderator)
self.assertIsNotNone(submission.handled_at)
@@ -95,16 +82,9 @@ class SubmissionApprovalWorkflowTests(TestCase):
from apps.parks.models import Company, Park
# Create target park
operator = Company.objects.create(
name='Test Operator',
roles=['OPERATOR']
)
operator = Company.objects.create(name="Test Operator", roles=["OPERATOR"])
park = Park.objects.create(
name='Test Park',
slug='test-park',
operator=operator,
status='OPERATING',
timezone='America/New_York'
name="Test Park", slug="test-park", operator=operator, status="OPERATING", timezone="America/New_York"
)
# User submits a photo
@@ -113,12 +93,12 @@ class SubmissionApprovalWorkflowTests(TestCase):
user=self.regular_user,
content_type=content_type,
object_id=park.id,
status='PENDING',
photo_type='GENERAL',
description='Beautiful park entrance'
status="PENDING",
photo_type="GENERAL",
description="Beautiful park entrance",
)
self.assertEqual(submission.status, 'PENDING')
self.assertEqual(submission.status, "PENDING")
# Moderator approves
submission.transition_to_approved(user=self.moderator)
@@ -127,7 +107,7 @@ class SubmissionApprovalWorkflowTests(TestCase):
submission.save()
submission.refresh_from_db()
self.assertEqual(submission.status, 'APPROVED')
self.assertEqual(submission.status, "APPROVED")
class SubmissionRejectionWorkflowTests(TestCase):
@@ -136,16 +116,10 @@ class SubmissionRejectionWorkflowTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.regular_user = User.objects.create_user(
username='user_rej',
email='user_rej@example.com',
password='testpass123',
role='USER'
username="user_rej", email="user_rej@example.com", password="testpass123", role="USER"
)
cls.moderator = User.objects.create_user(
username='mod_rej',
email='mod_rej@example.com',
password='testpass123',
role='MODERATOR'
username="mod_rej", email="mod_rej@example.com", password="testpass123", role="MODERATOR"
)
def test_edit_submission_rejection_with_reason(self):
@@ -157,32 +131,29 @@ class SubmissionRejectionWorkflowTests(TestCase):
from apps.moderation.models import EditSubmission
from apps.parks.models import Company
company = Company.objects.create(
name='Test Company',
description='Original'
)
company = Company.objects.create(name="Test Company", description="Original")
content_type = ContentType.objects.get_for_model(company)
submission = EditSubmission.objects.create(
user=self.regular_user,
content_type=content_type,
object_id=company.id,
submission_type='EDIT',
changes={'name': 'Spam Content'},
status='PENDING',
reason='Name change request'
submission_type="EDIT",
changes={"name": "Spam Content"},
status="PENDING",
reason="Name change request",
)
# Moderator rejects
submission.transition_to_rejected(user=self.moderator)
submission.handled_by = self.moderator
submission.handled_at = timezone.now()
submission.notes = 'Rejected: Content appears to be spam'
submission.notes = "Rejected: Content appears to be spam"
submission.save()
submission.refresh_from_db()
self.assertEqual(submission.status, 'REJECTED')
self.assertIn('spam', submission.notes.lower())
self.assertEqual(submission.status, "REJECTED")
self.assertIn("spam", submission.notes.lower())
class SubmissionEscalationWorkflowTests(TestCase):
@@ -191,22 +162,13 @@ class SubmissionEscalationWorkflowTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.regular_user = User.objects.create_user(
username='user_esc',
email='user_esc@example.com',
password='testpass123',
role='USER'
username="user_esc", email="user_esc@example.com", password="testpass123", role="USER"
)
cls.moderator = User.objects.create_user(
username='mod_esc',
email='mod_esc@example.com',
password='testpass123',
role='MODERATOR'
username="mod_esc", email="mod_esc@example.com", password="testpass123", role="MODERATOR"
)
cls.admin = User.objects.create_user(
username='admin_esc',
email='admin_esc@example.com',
password='testpass123',
role='ADMIN'
username="admin_esc", email="admin_esc@example.com", password="testpass123", role="ADMIN"
)
def test_escalation_workflow(self):
@@ -218,28 +180,25 @@ class SubmissionEscalationWorkflowTests(TestCase):
from apps.moderation.models import EditSubmission
from apps.parks.models import Company
company = Company.objects.create(
name='Sensitive Company',
description='Original'
)
company = Company.objects.create(name="Sensitive Company", description="Original")
content_type = ContentType.objects.get_for_model(company)
submission = EditSubmission.objects.create(
user=self.regular_user,
content_type=content_type,
object_id=company.id,
submission_type='EDIT',
changes={'name': 'New Sensitive Name'},
status='PENDING',
reason='Major name change'
submission_type="EDIT",
changes={"name": "New Sensitive Name"},
status="PENDING",
reason="Major name change",
)
# Moderator escalates
submission.transition_to_escalated(user=self.moderator)
submission.notes = 'Escalated: Major change needs admin review'
submission.notes = "Escalated: Major change needs admin review"
submission.save()
self.assertEqual(submission.status, 'ESCALATED')
self.assertEqual(submission.status, "ESCALATED")
# Admin approves
submission.transition_to_approved(user=self.admin)
@@ -248,7 +207,7 @@ class SubmissionEscalationWorkflowTests(TestCase):
submission.save()
submission.refresh_from_db()
self.assertEqual(submission.status, 'APPROVED')
self.assertEqual(submission.status, "APPROVED")
self.assertEqual(submission.handled_by, self.admin)
@@ -258,16 +217,10 @@ class ReportHandlingWorkflowTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.reporter = User.objects.create_user(
username='reporter',
email='reporter@example.com',
password='testpass123',
role='USER'
username="reporter", email="reporter@example.com", password="testpass123", role="USER"
)
cls.moderator = User.objects.create_user(
username='mod_report',
email='mod_report@example.com',
password='testpass123',
role='MODERATOR'
username="mod_report", email="mod_report@example.com", password="testpass123", role="MODERATOR"
)
def test_report_resolution_workflow(self):
@@ -279,45 +232,42 @@ class ReportHandlingWorkflowTests(TestCase):
from apps.moderation.models import ModerationReport
from apps.parks.models import Company
reported_company = Company.objects.create(
name='Problematic Company',
description='Some inappropriate content'
)
reported_company = Company.objects.create(name="Problematic Company", description="Some inappropriate content")
content_type = ContentType.objects.get_for_model(reported_company)
# User reports content
report = ModerationReport.objects.create(
report_type='CONTENT',
status='PENDING',
priority='HIGH',
reported_entity_type='company',
report_type="CONTENT",
status="PENDING",
priority="HIGH",
reported_entity_type="company",
reported_entity_id=reported_company.id,
content_type=content_type,
reason='INAPPROPRIATE',
description='This content is inappropriate',
reported_by=self.reporter
reason="INAPPROPRIATE",
description="This content is inappropriate",
reported_by=self.reporter,
)
self.assertEqual(report.status, 'PENDING')
self.assertEqual(report.status, "PENDING")
# Moderator claims and starts review
report.transition_to_under_review(user=self.moderator)
report.assigned_moderator = self.moderator
report.save()
self.assertEqual(report.status, 'UNDER_REVIEW')
self.assertEqual(report.status, "UNDER_REVIEW")
self.assertEqual(report.assigned_moderator, self.moderator)
# Moderator resolves
report.transition_to_resolved(user=self.moderator)
report.resolution_action = 'CONTENT_REMOVED'
report.resolution_notes = 'Content was removed'
report.resolution_action = "CONTENT_REMOVED"
report.resolution_notes = "Content was removed"
report.resolved_at = timezone.now()
report.save()
report.refresh_from_db()
self.assertEqual(report.status, 'RESOLVED')
self.assertEqual(report.status, "RESOLVED")
self.assertIsNotNone(report.resolved_at)
def test_report_dismissal_workflow(self):
@@ -329,23 +279,20 @@ class ReportHandlingWorkflowTests(TestCase):
from apps.moderation.models import ModerationReport
from apps.parks.models import Company
company = Company.objects.create(
name='Valid Company',
description='Normal content'
)
company = Company.objects.create(name="Valid Company", description="Normal content")
content_type = ContentType.objects.get_for_model(company)
report = ModerationReport.objects.create(
report_type='CONTENT',
status='PENDING',
priority='LOW',
reported_entity_type='company',
report_type="CONTENT",
status="PENDING",
priority="LOW",
reported_entity_type="company",
reported_entity_id=company.id,
content_type=content_type,
reason='OTHER',
description='I just do not like this',
reported_by=self.reporter
reason="OTHER",
description="I just do not like this",
reported_by=self.reporter,
)
# Moderator claims
@@ -355,12 +302,12 @@ class ReportHandlingWorkflowTests(TestCase):
# Moderator dismisses as invalid
report.transition_to_dismissed(user=self.moderator)
report.resolution_notes = 'Report does not violate any guidelines'
report.resolution_notes = "Report does not violate any guidelines"
report.resolved_at = timezone.now()
report.save()
report.refresh_from_db()
self.assertEqual(report.status, 'DISMISSED')
self.assertEqual(report.status, "DISMISSED")
class BulkOperationWorkflowTests(TestCase):
@@ -369,10 +316,7 @@ class BulkOperationWorkflowTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.admin = User.objects.create_user(
username='admin_bulk',
email='admin_bulk@example.com',
password='testpass123',
role='ADMIN'
username="admin_bulk", email="admin_bulk@example.com", password="testpass123", role="ADMIN"
)
def test_bulk_operation_success_workflow(self):
@@ -384,22 +328,22 @@ class BulkOperationWorkflowTests(TestCase):
from apps.moderation.models import BulkOperation
operation = BulkOperation.objects.create(
operation_type='APPROVE_SUBMISSIONS',
status='PENDING',
operation_type="APPROVE_SUBMISSIONS",
status="PENDING",
total_items=10,
processed_items=0,
created_by=self.admin,
parameters={'submission_ids': list(range(1, 11))}
parameters={"submission_ids": list(range(1, 11))},
)
self.assertEqual(operation.status, 'PENDING')
self.assertEqual(operation.status, "PENDING")
# Start operation
operation.transition_to_running(user=self.admin)
operation.started_at = timezone.now()
operation.save()
self.assertEqual(operation.status, 'RUNNING')
self.assertEqual(operation.status, "RUNNING")
# Simulate progress
for i in range(1, 11):
@@ -409,11 +353,11 @@ class BulkOperationWorkflowTests(TestCase):
# Complete operation
operation.transition_to_completed(user=self.admin)
operation.completed_at = timezone.now()
operation.results = {'approved': 10, 'failed': 0}
operation.results = {"approved": 10, "failed": 0}
operation.save()
operation.refresh_from_db()
self.assertEqual(operation.status, 'COMPLETED')
self.assertEqual(operation.status, "COMPLETED")
self.assertEqual(operation.processed_items, 10)
def test_bulk_operation_failure_workflow(self):
@@ -425,12 +369,12 @@ class BulkOperationWorkflowTests(TestCase):
from apps.moderation.models import BulkOperation
operation = BulkOperation.objects.create(
operation_type='DELETE_CONTENT',
status='PENDING',
operation_type="DELETE_CONTENT",
status="PENDING",
total_items=5,
processed_items=0,
created_by=self.admin,
parameters={'content_ids': list(range(1, 6))}
parameters={"content_ids": list(range(1, 6))},
)
operation.transition_to_running(user=self.admin)
@@ -442,11 +386,11 @@ class BulkOperationWorkflowTests(TestCase):
operation.failed_items = 3
operation.transition_to_failed(user=self.admin)
operation.completed_at = timezone.now()
operation.results = {'error': 'Database connection lost', 'processed': 2}
operation.results = {"error": "Database connection lost", "processed": 2}
operation.save()
operation.refresh_from_db()
self.assertEqual(operation.status, 'FAILED')
self.assertEqual(operation.status, "FAILED")
self.assertEqual(operation.failed_items, 3)
def test_bulk_operation_cancellation_workflow(self):
@@ -458,13 +402,13 @@ class BulkOperationWorkflowTests(TestCase):
from apps.moderation.models import BulkOperation
operation = BulkOperation.objects.create(
operation_type='BATCH_UPDATE',
status='PENDING',
operation_type="BATCH_UPDATE",
status="PENDING",
total_items=100,
processed_items=0,
created_by=self.admin,
parameters={'update_field': 'status'},
can_cancel=True
parameters={"update_field": "status"},
can_cancel=True,
)
operation.transition_to_running(user=self.admin)
@@ -477,11 +421,11 @@ class BulkOperationWorkflowTests(TestCase):
# Admin cancels
operation.transition_to_cancelled(user=self.admin)
operation.completed_at = timezone.now()
operation.results = {'cancelled_at': 30, 'reason': 'User requested cancellation'}
operation.results = {"cancelled_at": 30, "reason": "User requested cancellation"}
operation.save()
operation.refresh_from_db()
self.assertEqual(operation.status, 'CANCELLED')
self.assertEqual(operation.status, "CANCELLED")
self.assertEqual(operation.processed_items, 30)
@@ -491,10 +435,7 @@ class ModerationQueueWorkflowTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.moderator = User.objects.create_user(
username='mod_queue',
email='mod_queue@example.com',
password='testpass123',
role='MODERATOR'
username="mod_queue", email="mod_queue@example.com", password="testpass123", role="MODERATOR"
)
def test_queue_completion_workflow(self):
@@ -506,14 +447,14 @@ class ModerationQueueWorkflowTests(TestCase):
from apps.moderation.models import ModerationQueue
queue_item = ModerationQueue.objects.create(
queue_type='SUBMISSION_REVIEW',
status='PENDING',
priority='MEDIUM',
item_type='edit_submission',
item_id=123
queue_type="SUBMISSION_REVIEW",
status="PENDING",
priority="MEDIUM",
item_type="edit_submission",
item_id=123,
)
self.assertEqual(queue_item.status, 'PENDING')
self.assertEqual(queue_item.status, "PENDING")
# Moderator claims
queue_item.transition_to_in_progress(user=self.moderator)
@@ -521,7 +462,7 @@ class ModerationQueueWorkflowTests(TestCase):
queue_item.assigned_at = timezone.now()
queue_item.save()
self.assertEqual(queue_item.status, 'IN_PROGRESS')
self.assertEqual(queue_item.status, "IN_PROGRESS")
# Work completed
queue_item.transition_to_completed(user=self.moderator)
@@ -529,4 +470,4 @@ class ModerationQueueWorkflowTests(TestCase):
queue_item.save()
queue_item.refresh_from_db()
self.assertEqual(queue_item.status, 'COMPLETED')
self.assertEqual(queue_item.status, "COMPLETED")

View File

@@ -26,6 +26,7 @@ from .views import (
class ModerationDashboardView(TemplateView):
"""Moderation dashboard view with HTMX integration."""
template_name = "moderation/dashboard.html"
def get_context_data(self, **kwargs):
@@ -38,6 +39,7 @@ class ModerationDashboardView(TemplateView):
class SubmissionListView(TemplateView):
"""Submission list view with filtering."""
template_name = "moderation/partials/dashboard_content.html"
def get_context_data(self, **kwargs):
@@ -63,8 +65,10 @@ class SubmissionListView(TemplateView):
class HistoryPageView(TemplateView):
"""Main history page view."""
template_name = "moderation/history.html"
# Create router and register viewsets
router = DefaultRouter()
router.register(r"reports", ModerationReportViewSet, basename="moderation-reports")

View File

@@ -86,9 +86,7 @@ class ModerationReportViewSet(viewsets.ModelViewSet):
filtering, search, and permission controls.
"""
queryset = ModerationReport.objects.select_related(
"reported_by", "assigned_moderator", "content_type"
).all()
queryset = ModerationReport.objects.select_related("reported_by", "assigned_moderator", "content_type").all()
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_class = ModerationReportFilter
@@ -207,9 +205,7 @@ class ModerationReportViewSet(viewsets.ModelViewSet):
return Response(serializer.data)
except User.DoesNotExist:
return Response(
{"error": "Moderator not found"}, status=status.HTTP_404_NOT_FOUND
)
return Response({"error": "Moderator not found"}, status=status.HTTP_404_NOT_FOUND)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def resolve(self, request, pk=None):
@@ -313,17 +309,11 @@ class ModerationReportViewSet(viewsets.ModelViewSet):
overdue_reports += 1
# Reports by priority and type
reports_by_priority = dict(
queryset.values_list("priority").annotate(count=Count("id"))
)
reports_by_type = dict(
queryset.values_list("report_type").annotate(count=Count("id"))
)
reports_by_priority = dict(queryset.values_list("priority").annotate(count=Count("id")))
reports_by_type = dict(queryset.values_list("report_type").annotate(count=Count("id")))
# Average resolution time
resolved_queryset = queryset.filter(
status="RESOLVED", resolved_at__isnull=False
)
resolved_queryset = queryset.filter(status="RESOLVED", resolved_at__isnull=False)
avg_resolution_time = 0
if resolved_queryset.exists():
@@ -430,9 +420,7 @@ class ModerationReportViewSet(viewsets.ModelViewSet):
"log": None,
},
)
return Response(
{"error": "Log not found"}, status=status.HTTP_404_NOT_FOUND
)
return Response({"error": "Log not found"}, status=status.HTTP_404_NOT_FOUND)
# Filter by model type with app_label support for correct ContentType resolution
model_type = request.query_params.get("model_type")
@@ -441,9 +429,7 @@ class ModerationReportViewSet(viewsets.ModelViewSet):
try:
if app_label:
# Use both app_label and model for precise matching
content_type = ContentType.objects.get_by_natural_key(
app_label, model_type
)
content_type = ContentType.objects.get_by_natural_key(app_label, model_type)
else:
# Map common model names to their app_labels for correct resolution
model_app_mapping = {
@@ -457,9 +443,7 @@ class ModerationReportViewSet(viewsets.ModelViewSet):
}
mapped_app_label = model_app_mapping.get(model_type.lower())
if mapped_app_label:
content_type = ContentType.objects.get_by_natural_key(
mapped_app_label, model_type.lower()
)
content_type = ContentType.objects.get_by_natural_key(mapped_app_label, model_type.lower())
else:
# Fallback to model-only lookup
content_type = ContentType.objects.get(model=model_type)
@@ -576,9 +560,7 @@ class ModerationQueueViewSet(viewsets.ModelViewSet):
completion, and progress tracking.
"""
queryset = ModerationQueue.objects.select_related(
"assigned_to", "related_report", "content_type"
).all()
queryset = ModerationQueue.objects.select_related("assigned_to", "related_report", "content_type").all()
serializer_class = ModerationQueueSerializer
permission_classes = [CanViewModerationData]
@@ -871,9 +853,7 @@ class ModerationActionViewSet(viewsets.ModelViewSet):
and status management.
"""
queryset = ModerationAction.objects.select_related(
"moderator", "target_user", "related_report"
).all()
queryset = ModerationAction.objects.select_related("moderator", "target_user", "related_report").all()
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_class = ModerationActionFilter
@@ -907,9 +887,7 @@ class ModerationActionViewSet(viewsets.ModelViewSet):
@action(detail=False, methods=["get"], permission_classes=[CanViewModerationData])
def active(self, request):
"""Get all active moderation actions."""
queryset = self.get_queryset().filter(
is_active=True, expires_at__gt=timezone.now()
)
queryset = self.get_queryset().filter(is_active=True, expires_at__gt=timezone.now())
page = self.paginate_queryset(queryset)
if page is not None:
@@ -922,9 +900,7 @@ class ModerationActionViewSet(viewsets.ModelViewSet):
@action(detail=False, methods=["get"], permission_classes=[CanViewModerationData])
def expired(self, request):
"""Get all expired moderation actions."""
queryset = self.get_queryset().filter(
expires_at__lte=timezone.now(), is_active=True
)
queryset = self.get_queryset().filter(expires_at__lte=timezone.now(), is_active=True)
page = self.paginate_queryset(queryset)
if page is not None:
@@ -1173,9 +1149,7 @@ class UserModerationViewSet(viewsets.ViewSet):
if not query:
return Response([])
queryset = User.objects.filter(
Q(username__icontains=query) | Q(email__icontains=query)
)[:20]
queryset = User.objects.filter(Q(username__icontains=query) | Q(email__icontains=query))[:20]
users_data = [
{
@@ -1194,9 +1168,7 @@ class UserModerationViewSet(viewsets.ViewSet):
try:
user = User.objects.get(pk=pk)
except User.DoesNotExist:
return Response(
{"error": "User not found"}, status=status.HTTP_404_NOT_FOUND
)
return Response({"error": "User not found"}, status=status.HTTP_404_NOT_FOUND)
# Gather user moderation data
reports_made = ModerationReport.objects.filter(reported_by=user).count()
@@ -1206,12 +1178,8 @@ class UserModerationViewSet(viewsets.ViewSet):
actions_against = ModerationAction.objects.filter(target_user=user)
warnings_received = actions_against.filter(action_type="WARNING").count()
suspensions_received = actions_against.filter(
action_type="USER_SUSPENSION"
).count()
active_restrictions = actions_against.filter(
is_active=True, expires_at__gt=timezone.now()
).count()
suspensions_received = actions_against.filter(action_type="USER_SUSPENSION").count()
active_restrictions = actions_against.filter(is_active=True, expires_at__gt=timezone.now()).count()
# Risk assessment (simplified)
risk_factors = []
@@ -1230,9 +1198,7 @@ class UserModerationViewSet(viewsets.ViewSet):
risk_level = "HIGH"
# Recent activity
recent_reports = ModerationReport.objects.filter(reported_by=user).order_by(
"-created_at"
)[:5]
recent_reports = ModerationReport.objects.filter(reported_by=user).order_by("-created_at")[:5]
recent_actions = actions_against.order_by("-created_at")[:5]
@@ -1244,9 +1210,7 @@ class UserModerationViewSet(viewsets.ViewSet):
account_status = "RESTRICTED"
last_violation = (
actions_against.filter(
action_type__in=["WARNING", "USER_SUSPENSION", "USER_BAN"]
)
actions_against.filter(action_type__in=["WARNING", "USER_SUSPENSION", "USER_BAN"])
.order_by("-created_at")
.first()
)
@@ -1266,16 +1230,10 @@ class UserModerationViewSet(viewsets.ViewSet):
"active_restrictions": active_restrictions,
"risk_level": risk_level,
"risk_factors": risk_factors,
"recent_reports": ModerationReportSerializer(
recent_reports, many=True
).data,
"recent_actions": ModerationActionSerializer(
recent_actions, many=True
).data,
"recent_reports": ModerationReportSerializer(recent_reports, many=True).data,
"recent_actions": ModerationActionSerializer(recent_actions, many=True).data,
"account_status": account_status,
"last_violation_date": (
last_violation.created_at if last_violation else None
),
"last_violation_date": (last_violation.created_at if last_violation else None),
"next_review_date": None, # Would be calculated based on business rules
}
@@ -1287,13 +1245,9 @@ class UserModerationViewSet(viewsets.ViewSet):
try:
user = User.objects.get(pk=pk)
except User.DoesNotExist:
return Response(
{"error": "User not found"}, status=status.HTTP_404_NOT_FOUND
)
return Response({"error": "User not found"}, status=status.HTTP_404_NOT_FOUND)
serializer = CreateModerationActionSerializer(
data=request.data, context={"request": request}
)
serializer = CreateModerationActionSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
# Override target_user_id with the user from URL
@@ -1331,9 +1285,7 @@ class UserModerationViewSet(viewsets.ViewSet):
queryset = User.objects.all()
if query:
queryset = queryset.filter(
Q(username__icontains=query) | Q(email__icontains=query)
)
queryset = queryset.filter(Q(username__icontains=query) | Q(email__icontains=query))
if role:
queryset = queryset.filter(role=role)
@@ -1376,12 +1328,8 @@ class UserModerationViewSet(viewsets.ViewSet):
def stats(self, request):
"""Get overall user moderation statistics."""
total_actions = ModerationAction.objects.count()
active_actions = ModerationAction.objects.filter(
is_active=True, expires_at__gt=timezone.now()
).count()
expired_actions = ModerationAction.objects.filter(
expires_at__lte=timezone.now()
).count()
active_actions = ModerationAction.objects.filter(is_active=True, expires_at__gt=timezone.now()).count()
expired_actions = ModerationAction.objects.filter(expires_at__lte=timezone.now()).count()
stats_data = {
"total_actions": total_actions,
@@ -1404,6 +1352,7 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
Includes claim/unclaim endpoints with concurrency protection using
database row locking (select_for_update) to prevent race conditions.
"""
queryset = EditSubmission.objects.all()
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
search_fields = ["reason", "changes"]
@@ -1425,7 +1374,7 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
# User filter
user_id = self.request.query_params.get("user")
if user_id:
queryset = queryset.filter(user_id=user_id)
queryset = queryset.filter(user_id=user_id)
return queryset
@@ -1452,15 +1401,12 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
# Lock the row for update - other transactions will fail immediately
submission = EditSubmission.objects.select_for_update(nowait=True).get(pk=pk)
except EditSubmission.DoesNotExist:
return Response(
{"error": "Submission not found"},
status=status.HTTP_404_NOT_FOUND
)
return Response({"error": "Submission not found"}, status=status.HTTP_404_NOT_FOUND)
except DatabaseError:
# Row is already locked by another transaction
return Response(
{"error": "Submission is being claimed by another moderator. Please try again."},
status=status.HTTP_409_CONFLICT
status=status.HTTP_409_CONFLICT,
)
# Check if already claimed
@@ -1471,14 +1417,14 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
"claimed_by": submission.claimed_by.username if submission.claimed_by else None,
"claimed_at": submission.claimed_at.isoformat() if submission.claimed_at else None,
},
status=status.HTTP_409_CONFLICT
status=status.HTTP_409_CONFLICT,
)
# Check if in valid state for claiming
if submission.status != "PENDING":
return Response(
{"error": f"Cannot claim submission in {submission.status} state"},
status=status.HTTP_400_BAD_REQUEST
status=status.HTTP_400_BAD_REQUEST,
)
try:
@@ -1512,15 +1458,11 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
# Only the claiming user or an admin can unclaim
if submission.claimed_by != request.user and not request.user.is_staff:
return Response(
{"error": "Only the claiming moderator or an admin can unclaim"},
status=status.HTTP_403_FORBIDDEN
{"error": "Only the claiming moderator or an admin can unclaim"}, status=status.HTTP_403_FORBIDDEN
)
if submission.status != "CLAIMED":
return Response(
{"error": "Submission is not claimed"},
status=status.HTTP_400_BAD_REQUEST
)
return Response({"error": "Submission is not claimed"}, status=status.HTTP_400_BAD_REQUEST)
try:
submission.unclaim(user=request.user)
@@ -1557,8 +1499,8 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
reason = request.data.get("reason", "")
try:
submission.reject(moderator=user, reason=reason)
return Response(self.get_serializer(submission).data)
submission.reject(moderator=user, reason=reason)
return Response(self.get_serializer(submission).data)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@@ -1569,8 +1511,8 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
reason = request.data.get("reason", "")
try:
submission.escalate(moderator=user, reason=reason)
return Response(self.get_serializer(submission).data)
submission.escalate(moderator=user, reason=reason)
return Response(self.get_serializer(submission).data)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@@ -1582,6 +1524,7 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
Includes claim/unclaim endpoints with concurrency protection using
database row locking (select_for_update) to prevent race conditions.
"""
queryset = PhotoSubmission.objects.all()
serializer_class = PhotoSubmissionSerializer
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
@@ -1599,7 +1542,7 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
# User filter
user_id = self.request.query_params.get("user")
if user_id:
queryset = queryset.filter(user_id=user_id)
queryset = queryset.filter(user_id=user_id)
return queryset
@@ -1617,14 +1560,11 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
try:
submission = PhotoSubmission.objects.select_for_update(nowait=True).get(pk=pk)
except PhotoSubmission.DoesNotExist:
return Response(
{"error": "Submission not found"},
status=status.HTTP_404_NOT_FOUND
)
return Response({"error": "Submission not found"}, status=status.HTTP_404_NOT_FOUND)
except DatabaseError:
return Response(
{"error": "Submission is being claimed by another moderator. Please try again."},
status=status.HTTP_409_CONFLICT
status=status.HTTP_409_CONFLICT,
)
if submission.status == "CLAIMED":
@@ -1634,13 +1574,13 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
"claimed_by": submission.claimed_by.username if submission.claimed_by else None,
"claimed_at": submission.claimed_at.isoformat() if submission.claimed_at else None,
},
status=status.HTTP_409_CONFLICT
status=status.HTTP_409_CONFLICT,
)
if submission.status != "PENDING":
return Response(
{"error": f"Cannot claim submission in {submission.status} state"},
status=status.HTTP_400_BAD_REQUEST
status=status.HTTP_400_BAD_REQUEST,
)
try:
@@ -1669,15 +1609,11 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
if submission.claimed_by != request.user and not request.user.is_staff:
return Response(
{"error": "Only the claiming moderator or an admin can unclaim"},
status=status.HTTP_403_FORBIDDEN
{"error": "Only the claiming moderator or an admin can unclaim"}, status=status.HTTP_403_FORBIDDEN
)
if submission.status != "CLAIMED":
return Response(
{"error": "Submission is not claimed"},
status=status.HTTP_400_BAD_REQUEST
)
return Response({"error": "Submission is not claimed"}, status=status.HTTP_400_BAD_REQUEST)
try:
submission.unclaim(user=request.user)
@@ -1731,4 +1667,3 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
return Response(self.get_serializer(submission).data)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)