mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 05:45:17 -05:00
feat: Implement initial schema and add various API, service, and management command enhancements across the application.
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}"))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user