feat: Implement MFA authentication, add ride statistics model, and update various services, APIs, and tests across the application.

This commit is contained in:
pacnpal
2025-12-28 17:32:53 -05:00
parent aa56c46c27
commit c95f99ca10
452 changed files with 7948 additions and 6073 deletions

View File

@@ -13,9 +13,7 @@ Performance targets:
from django.contrib import admin, messages
from django.contrib.admin import AdminSite
from django.db.models import Count
from django.urls import reverse
from django.utils import timezone
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django_fsm_log.models import StateLog

View File

@@ -2,7 +2,6 @@ import logging
from django.apps import AppConfig
logger = logging.getLogger(__name__)
@@ -20,11 +19,12 @@ class ModerationConfig(AppConfig):
def _apply_state_machines(self):
"""Apply FSM to all moderation models."""
from apps.core.state_machine import apply_state_machine
from .models import (
EditSubmission,
ModerationReport,
ModerationQueue,
BulkOperation,
EditSubmission,
ModerationQueue,
ModerationReport,
PhotoSubmission,
)
@@ -62,21 +62,22 @@ class ModerationConfig(AppConfig):
def _register_callbacks(self):
"""Register FSM transition callbacks for moderation models."""
from apps.core.state_machine.registry import register_callback
from apps.core.state_machine.callbacks.notifications import (
SubmissionApprovedNotification,
SubmissionRejectedNotification,
SubmissionEscalatedNotification,
ModerationNotificationCallback,
)
from apps.core.state_machine.callbacks.cache import (
ModerationCacheInvalidation,
)
from apps.core.state_machine.callbacks.notifications import (
ModerationNotificationCallback,
SubmissionApprovedNotification,
SubmissionEscalatedNotification,
SubmissionRejectedNotification,
)
from apps.core.state_machine.registry import register_callback
from .models import (
EditSubmission,
ModerationReport,
ModerationQueue,
BulkOperation,
EditSubmission,
ModerationQueue,
ModerationReport,
PhotoSubmission,
)

View File

@@ -5,7 +5,7 @@ This module defines all choice options for the moderation system using the Rich
All choices include rich metadata for UI styling, business logic, and enhanced functionality.
"""
from apps.core.choices.base import RichChoice, ChoiceCategory
from apps.core.choices.base import ChoiceCategory, RichChoice
from apps.core.choices.registry import register_choices
# ============================================================================

View File

@@ -5,19 +5,21 @@ This module contains Django filter classes for the moderation system,
providing comprehensive filtering capabilities for all moderation models.
"""
from datetime import timedelta
import django_filters
from django.contrib.auth import get_user_model
from django.db.models import Q
from django.utils import timezone
from datetime import timedelta
from apps.core.choices.registry import get_choices
from .models import (
ModerationReport,
ModerationQueue,
ModerationAction,
BulkOperation,
ModerationAction,
ModerationQueue,
ModerationReport,
)
from apps.core.choices.registry import get_choices
User = get_user_model()

View File

@@ -5,13 +5,14 @@ This command provides insights into transition usage, patterns, and statistics
across all models using django-fsm-log.
"""
from django.core.management.base import BaseCommand
from django.db.models import Count, Avg, F
from django.db.models.functions import TruncDate, ExtractHour
from django.utils import timezone
from datetime import timedelta
from django_fsm_log.models import StateLog
from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand
from django.db.models import Count
from django.db.models.functions import ExtractHour, TruncDate
from django.utils import timezone
from django_fsm_log.models import StateLog
class Command(BaseCommand):
@@ -143,7 +144,7 @@ class Command(BaseCommand):
# 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(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}%)")
@@ -181,7 +182,7 @@ class Command(BaseCommand):
# Transition patterns (common 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')
@@ -198,7 +199,7 @@ class Command(BaseCommand):
.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]])
@@ -215,7 +216,7 @@ class Command(BaseCommand):
self.stdout.write(f" {pattern}: {count} occurrences")
self.stdout.write(
self.style.SUCCESS(f'\n=== Analysis Complete ===\n')
self.style.SUCCESS('\n=== Analysis Complete ===\n')
)
# Export options
@@ -228,7 +229,7 @@ class Command(BaseCommand):
"""Export analysis results as JSON."""
import json
from datetime import datetime
data = {
'analysis_date': datetime.now().isoformat(),
'period_days': days,
@@ -240,11 +241,11 @@ class Command(BaseCommand):
)
)
}
filename = f'transition_analysis_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
with open(filename, 'w') as f:
json.dump(data, f, indent=2, default=str)
self.stdout.write(
self.style.SUCCESS(f'Exported to {filename}')
)
@@ -253,16 +254,16 @@ class Command(BaseCommand):
"""Export analysis results as CSV."""
import csv
from datetime import datetime
filename = f'transition_analysis_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv'
with open(filename, 'w', newline='') as f:
writer = csv.writer(f)
writer.writerow([
'ID', 'Timestamp', 'Model', 'Object ID',
'State', 'Transition', 'User'
])
for log in queryset.select_related('content_type', 'by'):
writer.writerow([
log.id,
@@ -273,7 +274,7 @@ class Command(BaseCommand):
log.transition or 'N/A',
log.by.username if log.by else 'System'
])
self.stdout.write(
self.style.SUCCESS(f'Exported to {filename}')
)

View File

@@ -1,11 +1,13 @@
from django.core.management.base import BaseCommand
from datetime import date
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.management.base import BaseCommand
from apps.moderation.models import EditSubmission, PhotoSubmission
from apps.parks.models import Park
from apps.rides.models import Ride
from datetime import date
User = get_user_model()

View File

@@ -1,13 +1,13 @@
"""Management command to validate state machine configurations for moderation models."""
from django.core.management.base import BaseCommand
from django.core.management import CommandError
from django.core.management.base import BaseCommand
from apps.core.state_machine import MetadataValidator
from apps.moderation.models import (
EditSubmission,
ModerationReport,
ModerationQueue,
BulkOperation,
EditSubmission,
ModerationQueue,
ModerationReport,
PhotoSubmission,
)

View File

@@ -1,8 +1,9 @@
# Generated by Django 5.2.5 on 2025-09-15 17:35
import apps.core.choices.fields
from django.db import migrations
import apps.core.choices.fields
class Migration(migrations.Migration):

View File

@@ -2,9 +2,10 @@
# This migration converts status fields from RichChoiceField to RichFSMField
# across all moderation models to enable FSM state management.
import apps.core.state_machine.fields
from django.db import migrations
import apps.core.state_machine.fields
class Migration(migrations.Migration):

View File

@@ -1,10 +1,11 @@
# Generated by Django 5.1.6 on 2025-12-26 14:10
import apps.core.state_machine.fields
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import apps.core.state_machine.fields
class Migration(migrations.Migration):

View File

@@ -1,12 +1,13 @@
# Generated by Django 5.1.6 on 2025-12-26 20:01
import apps.core.state_machine.fields
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
from django.db import migrations, models
import apps.core.state_machine.fields
class Migration(migrations.Migration):

View File

@@ -1,16 +1,18 @@
from typing import Any, Dict, Optional, Type, cast
import json
from typing import Any, cast
from django.contrib.auth import get_user_model
from django.contrib.auth.mixins import UserPassesTestMixin
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.http import (
JsonResponse,
HttpResponseForbidden,
HttpRequest,
HttpResponse,
HttpResponseForbidden,
JsonResponse,
)
from django.views.generic import DetailView
from django.db import models
from django.contrib.auth import get_user_model
import json
from .models import EditSubmission, PhotoSubmission, UserType
User = get_user_model()
@@ -21,12 +23,12 @@ class EditSubmissionMixin(DetailView):
Mixin for handling edit submissions with proper moderation.
"""
model: Optional[Type[models.Model]] = None
model: type[models.Model] | None = None
def handle_edit_submission(
self,
request: HttpRequest,
changes: Dict[str, Any],
changes: dict[str, Any],
reason: str = "",
source: str = "",
submission_type: str = "EDIT",
@@ -148,7 +150,7 @@ class PhotoSubmissionMixin(DetailView):
Mixin for handling photo submissions with proper moderation.
"""
model: Optional[Type[models.Model]] = None
model: type[models.Model] | None = None
def handle_photo_submission(self, request: HttpRequest) -> JsonResponse:
"""Handle a photo submission based on user's role"""
@@ -214,7 +216,7 @@ class PhotoSubmissionMixin(DetailView):
class ModeratorRequiredMixin(UserPassesTestMixin):
"""Require moderator or higher role for access"""
request: Optional[HttpRequest] = None
request: HttpRequest | None = None
def test_func(self) -> bool:
if not self.request:
@@ -235,7 +237,7 @@ class ModeratorRequiredMixin(UserPassesTestMixin):
class AdminRequiredMixin(UserPassesTestMixin):
"""Require admin or superuser role for access"""
request: Optional[HttpRequest] = None
request: HttpRequest | None = None
def test_func(self) -> bool:
if not self.request:
@@ -255,9 +257,9 @@ class AdminRequiredMixin(UserPassesTestMixin):
class InlineEditMixin:
"""Add inline editing context to views"""
request: Optional[HttpRequest] = None
request: HttpRequest | None = None
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context = super().get_context_data(**kwargs) # type: ignore
if self.request and self.request.user.is_authenticated:
context["can_edit"] = True
@@ -285,7 +287,7 @@ class InlineEditMixin:
class HistoryMixin:
"""Add edit history context to views"""
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context = super().get_context_data(**kwargs) # type: ignore
# Only add history context for DetailViews

View File

@@ -16,19 +16,21 @@ Callbacks for notifications, cache invalidation, and related updates
are registered via the callback configuration defined in each model's Meta class.
"""
from typing import Any, Dict, Optional, Union
from django.db import models
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from datetime import timedelta
from typing import Any, Union
import pghistory
from django.conf import settings
from django.utils import timezone
from django.core.exceptions import ObjectDoesNotExist, FieldDoesNotExist
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.auth.models import AnonymousUser
from datetime import timedelta
import pghistory
from apps.core.history import TrackedModel
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist
from django.db import models
from django.utils import timezone
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]
@@ -38,10 +40,10 @@ UserType = Union[AbstractBaseUser, AnonymousUser]
def _get_notification_callbacks():
"""Lazy import of notification callbacks."""
from apps.core.state_machine.callbacks.notifications import (
SubmissionApprovedNotification,
SubmissionRejectedNotification,
SubmissionEscalatedNotification,
ModerationNotificationCallback,
SubmissionApprovedNotification,
SubmissionEscalatedNotification,
SubmissionRejectedNotification,
)
return {
'approved': SubmissionApprovedNotification,
@@ -70,7 +72,7 @@ def _get_cache_callbacks():
@pghistory.track() # Track all changes by default
class EditSubmission(StateMachineMixin, TrackedModel):
"""Edit submission model with FSM-managed status transitions."""
state_field_name = "status"
# Who submitted the edit
@@ -173,7 +175,7 @@ class EditSubmission(StateMachineMixin, TrackedModel):
target = "Unknown"
return f"{action} by {self.user.username} on {target}"
def _resolve_foreign_keys(self, data: Dict[str, Any]) -> Dict[str, Any]:
def _resolve_foreign_keys(self, data: dict[str, Any]) -> dict[str, Any]:
"""Convert foreign key IDs to model instances"""
if not (model_class := self.content_type.model_class()):
raise ValueError("Could not resolve model class")
@@ -197,7 +199,7 @@ class EditSubmission(StateMachineMixin, TrackedModel):
return resolved_data
def _get_final_changes(self) -> Dict[str, Any]:
def _get_final_changes(self) -> dict[str, Any]:
"""Get the final changes to apply (moderator changes if available, otherwise original changes)"""
return self.moderator_changes or self.changes
@@ -213,12 +215,12 @@ class EditSubmission(StateMachineMixin, TrackedModel):
ValidationError: If submission is not in PENDING state
"""
from django.core.exceptions import ValidationError
if self.status != "PENDING":
raise ValidationError(
f"Cannot claim submission: current status is {self.status}, expected PENDING"
)
self.transition_to_claimed(user=user)
self.claimed_by = user
self.claimed_at = timezone.now()
@@ -236,12 +238,12 @@ class EditSubmission(StateMachineMixin, TrackedModel):
ValidationError: If submission is not in CLAIMED state
"""
from django.core.exceptions import ValidationError
if self.status != "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
self.status = "PENDING"
@@ -249,7 +251,7 @@ class EditSubmission(StateMachineMixin, TrackedModel):
self.claimed_at = None
self.save()
def approve(self, moderator: UserType, user=None) -> Optional[models.Model]:
def approve(self, moderator: UserType, user=None) -> models.Model | None:
"""
Approve this submission and apply the changes.
Wrapper method that preserves business logic while using FSM.
@@ -266,16 +268,16 @@ class EditSubmission(StateMachineMixin, TrackedModel):
ValidationError: If the data is invalid
"""
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention)
approver = user or moderator
# 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})"
)
model_class = self.content_type.model_class()
if not model_class:
raise ValueError("Could not resolve model class")
@@ -333,16 +335,16 @@ class EditSubmission(StateMachineMixin, TrackedModel):
user: Alternative parameter for FSM compatibility
"""
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention)
rejecter = user or moderator
# 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})"
)
# Use FSM transition to update status
self.transition_to_rejected(user=rejecter)
self.handled_by = rejecter
@@ -361,16 +363,16 @@ class EditSubmission(StateMachineMixin, TrackedModel):
user: Alternative parameter for FSM compatibility
"""
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention)
escalator = user or moderator
# 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})"
)
# Use FSM transition to update status
self.transition_to_escalated(user=escalator)
self.handled_by = escalator
@@ -401,7 +403,7 @@ class ModerationReport(StateMachineMixin, TrackedModel):
This handles the initial reporting phase where users flag content
or behavior that needs moderator attention.
"""
state_field_name = "status"
# Report details
@@ -493,7 +495,7 @@ class ModerationQueue(StateMachineMixin, TrackedModel):
This represents items in the moderation queue that need attention,
separate from the initial reports.
"""
state_field_name = "status"
# Queue item details
@@ -678,7 +680,7 @@ class BulkOperation(StateMachineMixin, TrackedModel):
This handles large-scale operations like bulk updates,
imports, exports, or mass moderation actions.
"""
state_field_name = "status"
# Operation details
@@ -773,7 +775,7 @@ class BulkOperation(StateMachineMixin, TrackedModel):
@pghistory.track() # Track all changes by default
class PhotoSubmission(StateMachineMixin, TrackedModel):
"""Photo submission model with FSM-managed status transitions."""
state_field_name = "status"
# Who submitted the photo
@@ -869,12 +871,12 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
ValidationError: If submission is not in PENDING state
"""
from django.core.exceptions import ValidationError
if self.status != "PENDING":
raise ValidationError(
f"Cannot claim submission: current status is {self.status}, expected PENDING"
)
self.transition_to_claimed(user=user)
self.claimed_by = user
self.claimed_at = timezone.now()
@@ -892,12 +894,12 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
ValidationError: If submission is not in CLAIMED state
"""
from django.core.exceptions import ValidationError
if self.status != "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
self.status = "PENDING"
@@ -909,25 +911,26 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
"""
Approve the photo submission.
Wrapper method that preserves business logic while using FSM.
Args:
moderator: The user approving the submission
notes: Optional approval notes
user: Alternative parameter for FSM compatibility
"""
from django.core.exceptions import ValidationError
from apps.parks.models.media import ParkPhoto
from apps.rides.models.media import RidePhoto
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention)
approver = user or moderator
# Validate state - must be CLAIMED before approval
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot approve photo submission: must be CLAIMED first (current status: {self.status})"
)
# Determine the correct photo model based on the content type
model_class = self.content_type.model_class()
if model_class.__name__ == "Park":
@@ -945,7 +948,7 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
caption=self.caption,
is_approved=True,
)
# Use FSM transition to update status
self.transition_to_approved(user=approver)
self.handled_by = approver # type: ignore
@@ -957,23 +960,23 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
"""
Reject the photo submission.
Wrapper method that preserves business logic while using FSM.
Args:
moderator: The user rejecting the submission
notes: Rejection reason
user: Alternative parameter for FSM compatibility
"""
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention)
rejecter = user or moderator
# Validate state - must be CLAIMED before rejection
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot reject photo submission: must be CLAIMED first (current status: {self.status})"
)
# Use FSM transition to update status
self.transition_to_rejected(user=rejecter)
self.handled_by = rejecter # type: ignore
@@ -994,23 +997,23 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
"""
Escalate the photo submission to admin.
Wrapper method that preserves business logic while using FSM.
Args:
moderator: The user escalating the submission
notes: Escalation reason
user: Alternative parameter for FSM compatibility
"""
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention)
escalator = user or moderator
# Validate state - must be CLAIMED before escalation
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot escalate photo submission: must be CLAIMED first (current status: {self.status})"
)
# Use FSM transition to update status
self.transition_to_escalated(user=escalator)
self.handled_by = escalator # type: ignore

View File

@@ -9,9 +9,11 @@ the permission to an FSM guard function, enabling alignment between API
permissions and FSM transition checks.
"""
from typing import Callable, Any, Optional
from rest_framework import permissions
from collections.abc import Callable
from typing import Any
from django.contrib.auth import get_user_model
from rest_framework import permissions
User = get_user_model()
@@ -35,7 +37,7 @@ class PermissionGuardAdapter:
def __init__(
self,
permission_class: type,
error_message: Optional[str] = None,
error_message: str | None = None,
):
"""
Initialize the guard adapter.
@@ -46,10 +48,10 @@ class PermissionGuardAdapter:
"""
self.permission_class = permission_class
self._custom_error_message = error_message
self._last_error_code: Optional[str] = None
self._last_error_code: str | None = None
@property
def error_code(self) -> Optional[str]:
def error_code(self) -> str | None:
"""Return the error code from the last failed check."""
return self._last_error_code
@@ -118,7 +120,7 @@ class GuardMixin:
"""
@classmethod
def as_guard(cls, error_message: Optional[str] = None) -> Callable:
def as_guard(cls, error_message: str | None = None) -> Callable:
"""
Convert this permission class to an FSM guard function.
@@ -443,9 +445,7 @@ class CanManageUserRestrictions(GuardMixin, permissions.BasePermission):
# Admins can manage most restrictions
if user_role == "ADMIN":
# Admins cannot create permanent bans
if action_type == "USER_BAN" and request.data.get("duration_hours") is None:
return False
return True
return not (action_type == "USER_BAN" and request.data.get("duration_hours") is None)
# Moderators can only manage basic restrictions
if user_role == "MODERATOR":

View File

@@ -3,18 +3,19 @@ Selectors for moderation-related data retrieval.
Following Django styleguide pattern for separating data access from business logic.
"""
from typing import Optional, Dict, Any
from django.db.models import QuerySet, Count, F, ExpressionWrapper, FloatField
from datetime import timedelta
from typing import Any
from django.contrib.auth.models import User
from django.db.models import Count, ExpressionWrapper, F, FloatField, QuerySet
from django.db.models.functions import Extract
from django.utils import timezone
from datetime import timedelta
from django.contrib.auth.models import User
from .models import EditSubmission
def pending_submissions_for_review(
*, content_type: Optional[str] = None, limit: int = 50
*, content_type: str | None = None, limit: int = 50
) -> QuerySet[EditSubmission]:
"""
Get pending submissions that need moderation review.
@@ -39,7 +40,7 @@ def pending_submissions_for_review(
def submissions_by_user(
*, user_id: int, status: Optional[str] = None
*, user_id: int, status: str | None = None
) -> QuerySet[EditSubmission]:
"""
Get submissions created by a specific user.
@@ -105,7 +106,7 @@ def recent_submissions(*, days: int = 7) -> QuerySet[EditSubmission]:
def submissions_by_content_type(
*, content_type: str, status: Optional[str] = None
*, content_type: str, status: str | None = None
) -> QuerySet[EditSubmission]:
"""
Get submissions for a specific content type.
@@ -127,7 +128,7 @@ def submissions_by_content_type(
return queryset.order_by("-created_at")
def moderation_queue_summary() -> Dict[str, Any]:
def moderation_queue_summary() -> dict[str, Any]:
"""
Get summary statistics for the moderation queue.
@@ -159,8 +160,8 @@ def moderation_queue_summary() -> Dict[str, Any]:
def moderation_statistics_summary(
*, days: int = 30, moderator: Optional[User] = None
) -> Dict[str, Any]:
*, days: int = 30, moderator: User | None = None
) -> dict[str, Any]:
"""
Get comprehensive moderation statistics for a time period.
@@ -175,10 +176,7 @@ def moderation_statistics_summary(
base_queryset = EditSubmission.objects.filter(created_at__gte=cutoff_date)
if moderator:
handled_queryset = base_queryset.filter(handled_by=moderator)
else:
handled_queryset = base_queryset
handled_queryset = base_queryset.filter(handled_by=moderator) if moderator else base_queryset
total_submissions = base_queryset.count()
pending_submissions = base_queryset.filter(status="PENDING").count()
@@ -258,7 +256,7 @@ def top_contributors(*, days: int = 30, limit: int = 10) -> QuerySet[User]:
)
def moderator_workload_summary(*, days: int = 30) -> Dict[str, Any]:
def moderator_workload_summary(*, days: int = 30) -> dict[str, Any]:
"""
Get workload distribution among moderators.

View File

@@ -10,18 +10,19 @@ This module contains DRF serializers for the moderation system, including:
All serializers include comprehensive validation and nested relationships.
"""
from rest_framework import serializers
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from datetime import timedelta
from rest_framework import serializers
from .models import (
ModerationReport,
ModerationQueue,
ModerationAction,
BulkOperation,
EditSubmission,
ModerationAction,
ModerationQueue,
ModerationReport,
PhotoSubmission,
)
@@ -274,7 +275,7 @@ class ModerationReportSerializer(serializers.ModelSerializer):
# Define SLA hours by priority
sla_hours = {"URGENT": 2, "HIGH": 8, "MEDIUM": 24, "LOW": 72}
if obj.priority in sla_hours:
threshold = sla_hours[obj.priority]
else:
@@ -375,11 +376,10 @@ class UpdateModerationReportSerializer(serializers.ModelSerializer):
def validate_status(self, value):
"""Validate status transitions."""
if self.instance and self.instance.status == "RESOLVED":
if value != "RESOLVED":
raise serializers.ValidationError(
"Cannot change status of resolved report"
)
if self.instance and self.instance.status == "RESOLVED" and value != "RESOLVED":
raise serializers.ValidationError(
"Cannot change status of resolved report"
)
return value
def update(self, instance, validated_data):
@@ -935,7 +935,7 @@ class PhotoSubmissionSerializer(serializers.ModelSerializer):
source="content_type.model", read_only=True
)
photo_url = serializers.SerializerMethodField()
# UI Metadata
status_display = serializers.CharField(source="get_status_display", read_only=True)
status_color = serializers.SerializerMethodField()

View File

@@ -3,14 +3,16 @@ Services for moderation functionality.
Following Django styleguide pattern for business logic encapsulation.
"""
from typing import Optional, Dict, Any, Union
from typing import Any
from django.db import transaction
from django.utils import timezone
from django.db.models import QuerySet
from django.utils import timezone
from django_fsm import TransitionNotAllowed
from apps.accounts.models import User
from .models import EditSubmission, PhotoSubmission, ModerationQueue
from .models import EditSubmission, ModerationQueue, PhotoSubmission
class ModerationService:
@@ -18,8 +20,8 @@ class ModerationService:
@staticmethod
def approve_submission(
*, submission_id: int, moderator: User, notes: Optional[str] = None
) -> Union[object, None]:
*, submission_id: int, moderator: User, notes: str | None = None
) -> object | None:
"""
Approve a content submission and apply changes.
@@ -115,10 +117,10 @@ class ModerationService:
def create_edit_submission(
*,
content_object: object,
changes: Dict[str, Any],
changes: dict[str, Any],
submitter: User,
submission_type: str = "UPDATE",
notes: Optional[str] = None,
notes: str | None = None,
) -> EditSubmission:
"""
Create a new edit submission for moderation.
@@ -154,7 +156,7 @@ class ModerationService:
def update_submission_changes(
*,
submission_id: int,
moderator_changes: Dict[str, Any],
moderator_changes: dict[str, Any],
moderator: User,
) -> EditSubmission:
"""
@@ -199,8 +201,8 @@ class ModerationService:
def get_pending_submissions_for_moderator(
*,
moderator: User,
content_type: Optional[str] = None,
limit: Optional[int] = None,
content_type: str | None = None,
limit: int | None = None,
) -> QuerySet:
"""
Get pending submissions for a moderator to review.
@@ -219,8 +221,8 @@ class ModerationService:
@staticmethod
def get_submission_statistics(
*, days: int = 30, moderator: Optional[User] = None
) -> Dict[str, Any]:
*, days: int = 30, moderator: User | None = None
) -> dict[str, Any]:
"""
Get moderation statistics for a time period.
@@ -251,13 +253,13 @@ class ModerationService:
@staticmethod
def create_edit_submission_with_queue(
*,
content_object: Optional[object],
changes: Dict[str, Any],
content_object: object | None,
changes: dict[str, Any],
submitter: User,
submission_type: str = "EDIT",
reason: Optional[str] = None,
source: Optional[str] = None,
) -> Dict[str, Any]:
reason: str | None = None,
source: str | None = None,
) -> dict[str, Any]:
"""
Create an edit submission with automatic queue routing.
@@ -332,7 +334,7 @@ class ModerationService:
caption: str = "",
date_taken=None,
submitter: User,
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""
Create a photo submission with automatic queue routing.
@@ -508,8 +510,8 @@ class ModerationService:
@staticmethod
def process_queue_item(
*, queue_item_id: int, moderator: User, action: str, notes: Optional[str] = None
) -> Dict[str, Any]:
*, queue_item_id: int, moderator: User, action: str, notes: str | None = None
) -> dict[str, Any]:
"""
Process a moderation queue item (approve, reject, etc.).
@@ -675,6 +677,6 @@ class ModerationService:
queue_item.full_clean()
queue_item.save()
result['queue_item'] = queue_item
return result

View File

@@ -13,14 +13,7 @@ Includes:
import logging
from django.conf import settings
from django.dispatch import receiver, Signal
from apps.core.state_machine.signals import (
post_state_transition,
state_transition_failed,
)
from django.dispatch import Signal
logger = logging.getLogger(__name__)
@@ -269,6 +262,7 @@ def _update_related_queue_items(instance, status):
"""Update queue items related to a moderation object."""
try:
from django.contrib.contenttypes.models import ContentType
from apps.moderation.models import ModerationQueue
content_type = ContentType.objects.get_for_model(type(instance))
@@ -328,10 +322,10 @@ def _finalize_bulk_operation(instance, success):
def _broadcast_submission_status_change(instance, source, target, user):
"""
Broadcast submission status change for real-time UI updates.
Emits the submission_status_changed signal with a structured payload
that can be consumed by notification systems (Novu, SSE, WebSocket, etc.).
Payload format:
{
"submission_id": 123,
@@ -344,11 +338,11 @@ def _broadcast_submission_status_change(instance, source, target, user):
}
"""
try:
from .models import EditSubmission, PhotoSubmission
from .models import EditSubmission
# Determine submission type
submission_type = "edit" if isinstance(instance, EditSubmission) else "photo"
# Build the broadcast payload
payload = {
"submission_id": instance.pk,
@@ -359,13 +353,13 @@ def _broadcast_submission_status_change(instance, source, target, user):
"locked_at": None,
"changed_by": user.username if user else None,
}
# Add claim information if available
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:
payload["locked_at"] = instance.claimed_at.isoformat()
# Emit the signal for downstream notification handlers
submission_status_changed.send(
sender=type(instance),
@@ -376,7 +370,7 @@ def _broadcast_submission_status_change(instance, source, target, user):
locked_by=payload["locked_by"],
payload=payload,
)
logger.debug(
f"Broadcast status change: {submission_type}#{instance.pk} "
f"{source} -> {target}"
@@ -397,11 +391,11 @@ def register_moderation_signal_handlers():
try:
from apps.moderation.models import (
EditSubmission,
PhotoSubmission,
ModerationReport,
ModerationQueue,
BulkOperation,
EditSubmission,
ModerationQueue,
ModerationReport,
PhotoSubmission,
)
# EditSubmission handlers

View File

@@ -8,14 +8,11 @@ import json
import logging
import queue
import threading
import time
from typing import Generator
from collections.abc import Generator
from django.http import StreamingHttpResponse, JsonResponse
from django.views import View
from django.contrib.auth.mixins import LoginRequiredMixin
from rest_framework.views import APIView
from django.http import JsonResponse, StreamingHttpResponse
from rest_framework.permissions import IsAuthenticated
from rest_framework.views import APIView
from apps.moderation.permissions import CanViewModerationData
from apps.moderation.signals import submission_status_changed
@@ -27,15 +24,15 @@ logger = logging.getLogger(__name__)
class SSEBroadcaster:
"""
Manages SSE connections and broadcasts events to all clients.
Uses a simple subscriber pattern where each connected client
gets its own queue of events to consume.
"""
def __init__(self):
self._subscribers: list[queue.Queue] = []
self._lock = threading.Lock()
def subscribe(self) -> queue.Queue:
"""Create a new subscriber queue and register it."""
client_queue = queue.Queue()
@@ -43,14 +40,14 @@ class SSEBroadcaster:
self._subscribers.append(client_queue)
logger.debug(f"SSE client subscribed. Total clients: {len(self._subscribers)}")
return client_queue
def unsubscribe(self, client_queue: queue.Queue):
"""Remove a subscriber queue."""
with self._lock:
if client_queue in self._subscribers:
self._subscribers.remove(client_queue)
logger.debug(f"SSE client unsubscribed. Total clients: {len(self._subscribers)}")
def broadcast(self, event_data: dict):
"""Send an event to all connected clients."""
with self._lock:
@@ -68,7 +65,7 @@ sse_broadcaster = SSEBroadcaster()
def handle_submission_status_changed(sender, payload, **kwargs):
"""
Signal handler that broadcasts submission status changes to SSE clients.
Connected to the submission_status_changed signal from signals.py.
"""
sse_broadcaster.broadcast(payload)
@@ -82,14 +79,14 @@ submission_status_changed.connect(handle_submission_status_changed)
class ModerationSSEView(APIView):
"""
Server-Sent Events endpoint for real-time moderation updates.
Provides a streaming response that sends submission status changes
as they occur. Clients should connect to this endpoint and keep
the connection open to receive real-time updates.
Response format (SSE):
data: {"submission_id": 1, "new_status": "CLAIMED", ...}
Usage:
const eventSource = new EventSource('/api/moderation/sse/')
eventSource.onmessage = (event) => {
@@ -97,22 +94,22 @@ class ModerationSSEView(APIView):
// Handle update
}
"""
permission_classes = [IsAuthenticated, CanViewModerationData]
def get(self, request):
"""
Establish SSE connection and stream events.
Sends a heartbeat every 30 seconds to keep the connection alive.
"""
def event_stream() -> Generator[str, None, None]:
def event_stream() -> Generator[str]:
client_queue = sse_broadcaster.subscribe()
try:
# Send initial connection event
yield f"data: {json.dumps({'type': 'connected', 'message': 'SSE connection established'})}\n\n"
while True:
try:
# Wait for event with timeout for heartbeat
@@ -120,13 +117,13 @@ class ModerationSSEView(APIView):
yield f"data: {json.dumps(event)}\n\n"
except queue.Empty:
# Send heartbeat to keep connection alive
yield f": heartbeat\n\n"
yield ": heartbeat\n\n"
except GeneratorExit:
# Client disconnected
sse_broadcaster.unsubscribe(client_queue)
finally:
sse_broadcaster.unsubscribe(client_queue)
response = StreamingHttpResponse(
event_stream(),
content_type='text/event-stream'
@@ -134,17 +131,17 @@ class ModerationSSEView(APIView):
response['Cache-Control'] = 'no-cache'
response['X-Accel-Buffering'] = 'no' # Disable nginx buffering
response['Connection'] = 'keep-alive'
return response
class ModerationSSETestView(APIView):
"""
Test endpoint to manually trigger an SSE event.
This is useful for testing the SSE connection without making
actual state transitions.
POST /api/moderation/sse/test/
{
"submission_id": 1,
@@ -153,9 +150,9 @@ class ModerationSSETestView(APIView):
"previous_status": "PENDING"
}
"""
permission_classes = [IsAuthenticated, CanViewModerationData]
def post(self, request):
"""Broadcast a test event."""
test_payload = {
@@ -168,9 +165,9 @@ class ModerationSSETestView(APIView):
"changed_by": request.user.username,
"test": True,
}
sse_broadcaster.broadcast(test_payload)
return JsonResponse({
"status": "ok",
"message": f"Test event broadcast to {len(sse_broadcaster._subscribers)} clients",

View File

@@ -1,12 +1,13 @@
from typing import Any
from django import template
from django.contrib.contenttypes.models import ContentType
from typing import Optional, Dict, Any, List, Union
register = template.Library()
@register.filter
def get_object_name(value: Optional[int], model_path: str) -> Optional[str]:
def get_object_name(value: int | None, model_path: str) -> str | None:
"""Get object name from ID and model path."""
if not value or not model_path or "." not in model_path:
return None
@@ -27,7 +28,7 @@ def get_object_name(value: Optional[int], model_path: str) -> Optional[str]:
@register.filter
def get_category_display(value: Optional[str]) -> Optional[str]:
def get_category_display(value: str | None) -> str | None:
"""Get display value for ride category."""
if not value:
return None
@@ -44,7 +45,7 @@ def get_category_display(value: Optional[str]) -> Optional[str]:
@register.filter
def get_park_area_name(value: Optional[int], park_id: Optional[int]) -> Optional[str]:
def get_park_area_name(value: int | None, park_id: int | None) -> str | None:
"""Get park area name from ID and park ID."""
if not value or not park_id:
return None
@@ -60,8 +61,8 @@ def get_park_area_name(value: Optional[int], park_id: Optional[int]) -> Optional
@register.filter
def get_item(
dictionary: Optional[Dict[str, Any]], key: Optional[Union[str, int]]
) -> List[Any]:
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 []

View File

@@ -11,34 +11,36 @@ This module contains tests for:
- Mixin functionality tests
"""
from django.test import TestCase, Client
import json
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from django.contrib.contenttypes.models import ContentType
from django.core.files.uploadedfile import SimpleUploadedFile
from django.http import JsonResponse, HttpRequest
from django.http import HttpRequest, JsonResponse
from django.test import Client, RequestFactory, TestCase
from django.utils import timezone
from django_fsm import TransitionNotAllowed
from .models import (
EditSubmission,
PhotoSubmission,
ModerationReport,
ModerationQueue,
BulkOperation,
ModerationAction,
)
from .mixins import (
EditSubmissionMixin,
PhotoSubmissionMixin,
ModeratorRequiredMixin,
AdminRequiredMixin,
InlineEditMixin,
HistoryMixin,
)
from apps.parks.models import Company as Operator
from django.views.generic import DetailView
from django.test import RequestFactory
import json
from django_fsm import TransitionNotAllowed
from apps.parks.models import Company as Operator
from .mixins import (
AdminRequiredMixin,
EditSubmissionMixin,
HistoryMixin,
InlineEditMixin,
ModeratorRequiredMixin,
PhotoSubmissionMixin,
)
from .models import (
BulkOperation,
EditSubmission,
ModerationAction,
ModerationQueue,
ModerationReport,
PhotoSubmission,
)
User = get_user_model()
@@ -421,12 +423,12 @@ class EditSubmissionTransitionTests(TestCase):
"""Test transition from PENDING to APPROVED."""
submission = self._create_submission()
self.assertEqual(submission.status, 'PENDING')
submission.transition_to_approved(user=self.moderator)
submission.handled_by = self.moderator
submission.handled_at = timezone.now()
submission.save()
submission.refresh_from_db()
self.assertEqual(submission.status, 'APPROVED')
self.assertEqual(submission.handled_by, self.moderator)
@@ -436,13 +438,13 @@ class EditSubmissionTransitionTests(TestCase):
"""Test transition from PENDING to REJECTED."""
submission = self._create_submission()
self.assertEqual(submission.status, 'PENDING')
submission.transition_to_rejected(user=self.moderator)
submission.handled_by = self.moderator
submission.handled_at = timezone.now()
submission.notes = 'Rejected: Insufficient evidence'
submission.save()
submission.refresh_from_db()
self.assertEqual(submission.status, 'REJECTED')
self.assertEqual(submission.handled_by, self.moderator)
@@ -452,25 +454,25 @@ class EditSubmissionTransitionTests(TestCase):
"""Test transition from PENDING to ESCALATED."""
submission = self._create_submission()
self.assertEqual(submission.status, 'PENDING')
submission.transition_to_escalated(user=self.moderator)
submission.handled_by = self.moderator
submission.handled_at = timezone.now()
submission.notes = 'Escalated: Needs admin review'
submission.save()
submission.refresh_from_db()
self.assertEqual(submission.status, 'ESCALATED')
def test_escalated_to_approved_transition(self):
"""Test transition from ESCALATED to APPROVED."""
submission = self._create_submission(status='ESCALATED')
submission.transition_to_approved(user=self.admin)
submission.handled_by = self.admin
submission.handled_at = timezone.now()
submission.save()
submission.refresh_from_db()
self.assertEqual(submission.status, 'APPROVED')
self.assertEqual(submission.handled_by, self.admin)
@@ -478,20 +480,20 @@ class EditSubmissionTransitionTests(TestCase):
def test_escalated_to_rejected_transition(self):
"""Test transition from ESCALATED to REJECTED."""
submission = self._create_submission(status='ESCALATED')
submission.transition_to_rejected(user=self.admin)
submission.handled_by = self.admin
submission.handled_at = timezone.now()
submission.notes = 'Rejected by admin'
submission.save()
submission.refresh_from_db()
self.assertEqual(submission.status, 'REJECTED')
def test_invalid_transition_from_approved(self):
"""Test that transitions from APPROVED state fail."""
submission = self._create_submission(status='APPROVED')
# Attempting to transition from APPROVED should raise TransitionNotAllowed
with self.assertRaises(TransitionNotAllowed):
submission.transition_to_rejected(user=self.moderator)
@@ -499,7 +501,7 @@ class EditSubmissionTransitionTests(TestCase):
def test_invalid_transition_from_rejected(self):
"""Test that transitions from REJECTED state fail."""
submission = self._create_submission(status='REJECTED')
# Attempting to transition from REJECTED should raise TransitionNotAllowed
with self.assertRaises(TransitionNotAllowed):
submission.transition_to_approved(user=self.moderator)
@@ -507,9 +509,9 @@ class EditSubmissionTransitionTests(TestCase):
def test_approve_wrapper_method(self):
"""Test the approve() wrapper method."""
submission = self._create_submission()
result = submission.approve(self.moderator)
submission.approve(self.moderator)
submission.refresh_from_db()
self.assertEqual(submission.status, 'APPROVED')
self.assertEqual(submission.handled_by, self.moderator)
@@ -518,9 +520,9 @@ class EditSubmissionTransitionTests(TestCase):
def test_reject_wrapper_method(self):
"""Test the reject() wrapper method."""
submission = self._create_submission()
submission.reject(self.moderator, reason='Not enough evidence')
submission.refresh_from_db()
self.assertEqual(submission.status, 'REJECTED')
self.assertIn('Not enough evidence', submission.notes)
@@ -528,9 +530,9 @@ class EditSubmissionTransitionTests(TestCase):
def test_escalate_wrapper_method(self):
"""Test the escalate() wrapper method."""
submission = self._create_submission()
submission.escalate(self.moderator, reason='Needs admin approval')
submission.refresh_from_db()
self.assertEqual(submission.status, 'ESCALATED')
self.assertIn('Needs admin approval', submission.notes)
@@ -582,11 +584,11 @@ class ModerationReportTransitionTests(TestCase):
"""Test transition from PENDING to UNDER_REVIEW."""
report = self._create_report()
self.assertEqual(report.status, 'PENDING')
report.transition_to_under_review(user=self.moderator)
report.assigned_moderator = self.moderator
report.save()
report.refresh_from_db()
self.assertEqual(report.status, 'UNDER_REVIEW')
self.assertEqual(report.assigned_moderator, self.moderator)
@@ -596,13 +598,13 @@ class ModerationReportTransitionTests(TestCase):
report = self._create_report(status='UNDER_REVIEW')
report.assigned_moderator = self.moderator
report.save()
report.transition_to_resolved(user=self.moderator)
report.resolution_action = 'Content updated'
report.resolution_notes = 'Fixed the incorrect information'
report.resolved_at = timezone.now()
report.save()
report.refresh_from_db()
self.assertEqual(report.status, 'RESOLVED')
self.assertIsNotNone(report.resolved_at)
@@ -612,26 +614,26 @@ class ModerationReportTransitionTests(TestCase):
report = self._create_report(status='UNDER_REVIEW')
report.assigned_moderator = self.moderator
report.save()
report.transition_to_dismissed(user=self.moderator)
report.resolution_notes = 'Report is not valid'
report.resolved_at = timezone.now()
report.save()
report.refresh_from_db()
self.assertEqual(report.status, 'DISMISSED')
def test_invalid_transition_from_resolved(self):
"""Test that transitions from RESOLVED state fail."""
report = self._create_report(status='RESOLVED')
with self.assertRaises(TransitionNotAllowed):
report.transition_to_dismissed(user=self.moderator)
def test_invalid_transition_from_dismissed(self):
"""Test that transitions from DISMISSED state fail."""
report = self._create_report(status='DISMISSED')
with self.assertRaises(TransitionNotAllowed):
report.transition_to_resolved(user=self.moderator)
@@ -668,12 +670,12 @@ class ModerationQueueTransitionTests(TestCase):
"""Test transition from PENDING to IN_PROGRESS."""
item = self._create_queue_item()
self.assertEqual(item.status, 'PENDING')
item.transition_to_in_progress(user=self.moderator)
item.assigned_to = self.moderator
item.assigned_at = timezone.now()
item.save()
item.refresh_from_db()
self.assertEqual(item.status, 'IN_PROGRESS')
self.assertEqual(item.assigned_to, self.moderator)
@@ -683,10 +685,10 @@ class ModerationQueueTransitionTests(TestCase):
item = self._create_queue_item(status='IN_PROGRESS')
item.assigned_to = self.moderator
item.save()
item.transition_to_completed(user=self.moderator)
item.save()
item.refresh_from_db()
self.assertEqual(item.status, 'COMPLETED')
@@ -695,27 +697,27 @@ class ModerationQueueTransitionTests(TestCase):
item = self._create_queue_item(status='IN_PROGRESS')
item.assigned_to = self.moderator
item.save()
item.transition_to_cancelled(user=self.moderator)
item.save()
item.refresh_from_db()
self.assertEqual(item.status, 'CANCELLED')
def test_pending_to_cancelled_transition(self):
"""Test transition from PENDING to CANCELLED."""
item = self._create_queue_item()
item.transition_to_cancelled(user=self.moderator)
item.save()
item.refresh_from_db()
self.assertEqual(item.status, 'CANCELLED')
def test_invalid_transition_from_completed(self):
"""Test that transitions from COMPLETED state fail."""
item = self._create_queue_item(status='COMPLETED')
with self.assertRaises(TransitionNotAllowed):
item.transition_to_in_progress(user=self.moderator)
@@ -753,11 +755,11 @@ class BulkOperationTransitionTests(TestCase):
"""Test transition from PENDING to RUNNING."""
operation = self._create_bulk_operation()
self.assertEqual(operation.status, 'PENDING')
operation.transition_to_running(user=self.admin)
operation.started_at = timezone.now()
operation.save()
operation.refresh_from_db()
self.assertEqual(operation.status, 'RUNNING')
self.assertIsNotNone(operation.started_at)
@@ -767,12 +769,12 @@ class BulkOperationTransitionTests(TestCase):
operation = self._create_bulk_operation(status='RUNNING')
operation.started_at = timezone.now()
operation.save()
operation.transition_to_completed(user=self.admin)
operation.completed_at = timezone.now()
operation.processed_items = 100
operation.save()
operation.refresh_from_db()
self.assertEqual(operation.status, 'COMPLETED')
self.assertIsNotNone(operation.completed_at)
@@ -783,13 +785,13 @@ class BulkOperationTransitionTests(TestCase):
operation = self._create_bulk_operation(status='RUNNING')
operation.started_at = timezone.now()
operation.save()
operation.transition_to_failed(user=self.admin)
operation.completed_at = timezone.now()
operation.results = {'error': 'Database connection failed'}
operation.failed_items = 50
operation.save()
operation.refresh_from_db()
self.assertEqual(operation.status, 'FAILED')
self.assertEqual(operation.failed_items, 50)
@@ -797,10 +799,10 @@ class BulkOperationTransitionTests(TestCase):
def test_pending_to_cancelled_transition(self):
"""Test transition from PENDING to CANCELLED."""
operation = self._create_bulk_operation()
operation.transition_to_cancelled(user=self.admin)
operation.save()
operation.refresh_from_db()
self.assertEqual(operation.status, 'CANCELLED')
@@ -809,24 +811,24 @@ class BulkOperationTransitionTests(TestCase):
operation = self._create_bulk_operation(status='RUNNING')
operation.can_cancel = True
operation.save()
operation.transition_to_cancelled(user=self.admin)
operation.save()
operation.refresh_from_db()
self.assertEqual(operation.status, 'CANCELLED')
def test_invalid_transition_from_completed(self):
"""Test that transitions from COMPLETED state fail."""
operation = self._create_bulk_operation(status='COMPLETED')
with self.assertRaises(TransitionNotAllowed):
operation.transition_to_running(user=self.admin)
def test_invalid_transition_from_failed(self):
"""Test that transitions from FAILED state fail."""
operation = self._create_bulk_operation(status='FAILED')
with self.assertRaises(TransitionNotAllowed):
operation.transition_to_completed(user=self.admin)
@@ -835,12 +837,12 @@ class BulkOperationTransitionTests(TestCase):
operation = self._create_bulk_operation()
operation.total_items = 100
operation.processed_items = 50
self.assertEqual(operation.progress_percentage, 50.0)
operation.processed_items = 0
self.assertEqual(operation.progress_percentage, 0.0)
operation.total_items = 0
self.assertEqual(operation.progress_percentage, 0.0)
@@ -876,7 +878,7 @@ class TransitionLoggingTestCase(TestCase):
def test_transition_creates_log(self):
"""Test that transitions create StateLog entries."""
from django_fsm_log.models import StateLog
# Create a submission
submission = EditSubmission.objects.create(
user=self.user,
@@ -887,18 +889,18 @@ class TransitionLoggingTestCase(TestCase):
status='PENDING',
reason='Test reason'
)
# Perform transition
submission.transition_to_approved(user=self.moderator)
submission.save()
# Check log was created
submission_ct = ContentType.objects.get_for_model(submission)
log = StateLog.objects.filter(
content_type=submission_ct,
object_id=submission.id
).first()
self.assertIsNotNone(log, "StateLog entry should be created")
self.assertEqual(log.state, 'APPROVED')
self.assertEqual(log.by, self.moderator)
@@ -907,7 +909,7 @@ class TransitionLoggingTestCase(TestCase):
def test_multiple_transitions_logged(self):
"""Test that multiple transitions are all logged."""
from django_fsm_log.models import StateLog
submission = EditSubmission.objects.create(
user=self.user,
content_type=self.content_type,
@@ -917,23 +919,23 @@ class TransitionLoggingTestCase(TestCase):
status='PENDING',
reason='Test reason'
)
submission_ct = ContentType.objects.get_for_model(submission)
# First transition
submission.transition_to_escalated(user=self.moderator)
submission.save()
# Second transition
submission.transition_to_approved(user=self.moderator)
submission.save()
# Check multiple logs created
logs = StateLog.objects.filter(
content_type=submission_ct,
object_id=submission.id
).order_by('timestamp')
self.assertEqual(logs.count(), 2, "Should have 2 log entries")
self.assertEqual(logs[0].state, 'ESCALATED')
self.assertEqual(logs[1].state, 'APPROVED')
@@ -941,10 +943,10 @@ class TransitionLoggingTestCase(TestCase):
def test_history_endpoint_returns_logs(self):
"""Test history API endpoint returns transition logs."""
from rest_framework.test import APIClient
api_client = APIClient()
api_client.force_authenticate(user=self.moderator)
submission = EditSubmission.objects.create(
user=self.user,
content_type=self.content_type,
@@ -954,21 +956,21 @@ class TransitionLoggingTestCase(TestCase):
status='PENDING',
reason='Test reason'
)
# Perform transition to create log
submission.transition_to_approved(user=self.moderator)
submission.save()
# Note: This assumes EditSubmission has a history endpoint
# Adjust URL pattern based on actual implementation
response = api_client.get('/api/moderation/reports/all_history/')
self.assertEqual(response.status_code, 200)
def test_system_transitions_without_user(self):
"""Test that system transitions work without a user."""
from django_fsm_log.models import StateLog
submission = EditSubmission.objects.create(
user=self.user,
content_type=self.content_type,
@@ -978,18 +980,18 @@ class TransitionLoggingTestCase(TestCase):
status='PENDING',
reason='Test reason'
)
# Perform transition without user
submission.transition_to_rejected(user=None)
submission.save()
# Check log was created even without user
submission_ct = ContentType.objects.get_for_model(submission)
log = StateLog.objects.filter(
content_type=submission_ct,
object_id=submission.id
).first()
self.assertIsNotNone(log)
self.assertEqual(log.state, 'REJECTED')
self.assertIsNone(log.by, "System transitions should have no user")
@@ -997,7 +999,7 @@ class TransitionLoggingTestCase(TestCase):
def test_transition_log_includes_description(self):
"""Test that transition logs can include descriptions."""
from django_fsm_log.models import StateLog
submission = EditSubmission.objects.create(
user=self.user,
content_type=self.content_type,
@@ -1007,27 +1009,27 @@ class TransitionLoggingTestCase(TestCase):
status='PENDING',
reason='Test reason'
)
# Perform transition
submission.transition_to_approved(user=self.moderator)
submission.save()
# Check log
submission_ct = ContentType.objects.get_for_model(submission)
log = StateLog.objects.filter(
content_type=submission_ct,
object_id=submission.id
).first()
self.assertIsNotNone(log)
# Description field exists and can be used for audit trails
self.assertTrue(hasattr(log, 'description'))
def test_log_ordering_by_timestamp(self):
"""Test that logs are properly ordered by timestamp."""
from django_fsm_log.models import StateLog
import time
submission = EditSubmission.objects.create(
user=self.user,
content_type=self.content_type,
@@ -1037,22 +1039,22 @@ class TransitionLoggingTestCase(TestCase):
status='PENDING',
reason='Test reason'
)
submission_ct = ContentType.objects.get_for_model(submission)
# Create multiple transitions
submission.transition_to_escalated(user=self.moderator)
submission.save()
submission.transition_to_approved(user=self.moderator)
submission.save()
# Get logs ordered by timestamp
logs = list(StateLog.objects.filter(
content_type=submission_ct,
object_id=submission.id
).order_by('timestamp'))
# Verify ordering
self.assertEqual(len(logs), 2)
self.assertTrue(logs[0].timestamp <= logs[1].timestamp)
@@ -1091,7 +1093,7 @@ class ModerationActionTests(TestCase):
moderator=self.moderator,
target_user=self.target_user
)
self.assertIsNotNone(action.expires_at)
# expires_at should be approximately 24 hours from now
time_diff = action.expires_at - timezone.now()
@@ -1106,7 +1108,7 @@ class ModerationActionTests(TestCase):
moderator=self.moderator,
target_user=self.target_user
)
self.assertIsNone(action.expires_at)
def test_action_is_active_by_default(self):
@@ -1168,7 +1170,7 @@ class PhotoSubmissionTransitionTests(TestCase):
def _create_submission(self, status='PENDING'):
"""Helper to create a PhotoSubmission."""
# Create using direct database creation to bypass FK validation
from unittest.mock import patch, Mock
from unittest.mock import Mock, patch
with patch.object(PhotoSubmission, 'photo', Mock()):
submission = PhotoSubmission(

View File

@@ -6,7 +6,6 @@ state log, and history event admin classes including query optimization
and custom moderation actions.
"""
import pytest
from django.contrib.admin.sites import AdminSite
from django.contrib.auth import get_user_model
from django.test import RequestFactory, TestCase
@@ -14,7 +13,6 @@ from django.test import RequestFactory, TestCase
from apps.moderation.admin import (
EditSubmissionAdmin,
HistoryEventAdmin,
ModerationAdminSite,
PhotoSubmissionAdmin,
StateLogAdmin,
moderation_site,

View File

@@ -9,18 +9,18 @@ This module tests end-to-end moderation workflows including:
- Bulk operation workflow
"""
from django.test import TestCase
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from django.utils import timezone
from unittest.mock import patch, Mock
User = get_user_model()
class SubmissionApprovalWorkflowTests(TestCase):
"""Tests for the complete submission approval workflow."""
@classmethod
def setUpTestData(cls):
"""Set up test data for all tests."""
@@ -42,22 +42,22 @@ class SubmissionApprovalWorkflowTests(TestCase):
password='testpass123',
role='ADMIN'
)
def test_edit_submission_approval_workflow(self):
"""
Test complete edit submission approval workflow.
Flow: User submits → Moderator reviews → Moderator approves → Changes applied
"""
from apps.moderation.models import EditSubmission
from apps.parks.models import Company
# Create target object
company = Company.objects.create(
name='Test Company',
description='Original description'
)
# User submits an edit
content_type = ContentType.objects.get_for_model(company)
submission = EditSubmission.objects.create(
@@ -69,31 +69,31 @@ class SubmissionApprovalWorkflowTests(TestCase):
status='PENDING',
reason='Fixing typo'
)
self.assertEqual(submission.status, 'PENDING')
self.assertIsNone(submission.handled_by)
self.assertIsNone(submission.handled_at)
# Moderator approves
submission.transition_to_approved(user=self.moderator)
submission.handled_by = self.moderator
submission.handled_at = timezone.now()
submission.save()
submission.refresh_from_db()
self.assertEqual(submission.status, 'APPROVED')
self.assertEqual(submission.handled_by, self.moderator)
self.assertIsNotNone(submission.handled_at)
def test_photo_submission_approval_workflow(self):
"""
Test complete photo submission approval workflow.
Flow: User submits photo → Moderator reviews → Moderator approves → Photo created
"""
from apps.moderation.models import PhotoSubmission
from apps.parks.models import Park, Company
from apps.parks.models import Company, Park
# Create target park
operator = Company.objects.create(
name='Test Operator',
@@ -106,7 +106,7 @@ class SubmissionApprovalWorkflowTests(TestCase):
status='OPERATING',
timezone='America/New_York'
)
# User submits a photo
content_type = ContentType.objects.get_for_model(park)
submission = PhotoSubmission.objects.create(
@@ -117,22 +117,22 @@ class SubmissionApprovalWorkflowTests(TestCase):
photo_type='GENERAL',
description='Beautiful park entrance'
)
self.assertEqual(submission.status, 'PENDING')
# Moderator approves
submission.transition_to_approved(user=self.moderator)
submission.handled_by = self.moderator
submission.handled_at = timezone.now()
submission.save()
submission.refresh_from_db()
self.assertEqual(submission.status, 'APPROVED')
class SubmissionRejectionWorkflowTests(TestCase):
"""Tests for the submission rejection workflow."""
@classmethod
def setUpTestData(cls):
cls.regular_user = User.objects.create_user(
@@ -147,21 +147,21 @@ class SubmissionRejectionWorkflowTests(TestCase):
password='testpass123',
role='MODERATOR'
)
def test_edit_submission_rejection_with_reason(self):
"""
Test rejection workflow with reason.
Flow: User submits → Moderator rejects with reason → User notified
"""
from apps.moderation.models import EditSubmission
from apps.parks.models import Company
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,
@@ -172,14 +172,14 @@ class SubmissionRejectionWorkflowTests(TestCase):
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.save()
submission.refresh_from_db()
self.assertEqual(submission.status, 'REJECTED')
self.assertIn('spam', submission.notes.lower())
@@ -187,7 +187,7 @@ class SubmissionRejectionWorkflowTests(TestCase):
class SubmissionEscalationWorkflowTests(TestCase):
"""Tests for the submission escalation workflow."""
@classmethod
def setUpTestData(cls):
cls.regular_user = User.objects.create_user(
@@ -208,21 +208,21 @@ class SubmissionEscalationWorkflowTests(TestCase):
password='testpass123',
role='ADMIN'
)
def test_escalation_workflow(self):
"""
Test complete escalation workflow.
Flow: User submits → Moderator escalates → Admin reviews → Admin approves
"""
from apps.moderation.models import EditSubmission
from apps.parks.models import Company
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,
@@ -233,20 +233,20 @@ class SubmissionEscalationWorkflowTests(TestCase):
status='PENDING',
reason='Major name change'
)
# Moderator escalates
submission.transition_to_escalated(user=self.moderator)
submission.notes = 'Escalated: Major change needs admin review'
submission.save()
self.assertEqual(submission.status, 'ESCALATED')
# Admin approves
submission.transition_to_approved(user=self.admin)
submission.handled_by = self.admin
submission.handled_at = timezone.now()
submission.save()
submission.refresh_from_db()
self.assertEqual(submission.status, 'APPROVED')
self.assertEqual(submission.handled_by, self.admin)
@@ -254,7 +254,7 @@ class SubmissionEscalationWorkflowTests(TestCase):
class ReportHandlingWorkflowTests(TestCase):
"""Tests for the moderation report handling workflow."""
@classmethod
def setUpTestData(cls):
cls.reporter = User.objects.create_user(
@@ -269,23 +269,23 @@ class ReportHandlingWorkflowTests(TestCase):
password='testpass123',
role='MODERATOR'
)
def test_report_resolution_workflow(self):
"""
Test complete report resolution workflow.
Flow: User reports → Moderator assigned → Moderator investigates → Resolved
"""
from apps.moderation.models import ModerationReport
from apps.parks.models import Company
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',
@@ -298,44 +298,44 @@ class ReportHandlingWorkflowTests(TestCase):
description='This content is inappropriate',
reported_by=self.reporter
)
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.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.resolved_at = timezone.now()
report.save()
report.refresh_from_db()
self.assertEqual(report.status, 'RESOLVED')
self.assertIsNotNone(report.resolved_at)
def test_report_dismissal_workflow(self):
"""
Test report dismissal workflow for invalid reports.
Flow: User reports → Moderator reviews → Moderator dismisses
"""
from apps.moderation.models import ModerationReport
from apps.parks.models import Company
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',
@@ -347,25 +347,25 @@ class ReportHandlingWorkflowTests(TestCase):
description='I just do not like this',
reported_by=self.reporter
)
# Moderator claims
report.transition_to_under_review(user=self.moderator)
report.assigned_moderator = self.moderator
report.save()
# Moderator dismisses as invalid
report.transition_to_dismissed(user=self.moderator)
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')
class BulkOperationWorkflowTests(TestCase):
"""Tests for bulk operation workflows."""
@classmethod
def setUpTestData(cls):
cls.admin = User.objects.create_user(
@@ -374,15 +374,15 @@ class BulkOperationWorkflowTests(TestCase):
password='testpass123',
role='ADMIN'
)
def test_bulk_operation_success_workflow(self):
"""
Test successful bulk operation workflow.
Flow: Admin creates → Operation runs → Progress tracked → Completed
"""
from apps.moderation.models import BulkOperation
operation = BulkOperation.objects.create(
operation_type='APPROVE_SUBMISSIONS',
status='PENDING',
@@ -391,39 +391,39 @@ class BulkOperationWorkflowTests(TestCase):
created_by=self.admin,
parameters={'submission_ids': list(range(1, 11))}
)
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')
# Simulate progress
for i in range(1, 11):
operation.processed_items = i
operation.save()
# Complete operation
operation.transition_to_completed(user=self.admin)
operation.completed_at = timezone.now()
operation.results = {'approved': 10, 'failed': 0}
operation.save()
operation.refresh_from_db()
self.assertEqual(operation.status, 'COMPLETED')
self.assertEqual(operation.processed_items, 10)
def test_bulk_operation_failure_workflow(self):
"""
Test bulk operation failure workflow.
Flow: Admin creates → Operation runs → Error occurs → Failed
"""
from apps.moderation.models import BulkOperation
operation = BulkOperation.objects.create(
operation_type='DELETE_CONTENT',
status='PENDING',
@@ -432,11 +432,11 @@ class BulkOperationWorkflowTests(TestCase):
created_by=self.admin,
parameters={'content_ids': list(range(1, 6))}
)
operation.transition_to_running(user=self.admin)
operation.started_at = timezone.now()
operation.save()
# Simulate partial progress then failure
operation.processed_items = 2
operation.failed_items = 3
@@ -444,19 +444,19 @@ class BulkOperationWorkflowTests(TestCase):
operation.completed_at = timezone.now()
operation.results = {'error': 'Database connection lost', 'processed': 2}
operation.save()
operation.refresh_from_db()
self.assertEqual(operation.status, 'FAILED')
self.assertEqual(operation.failed_items, 3)
def test_bulk_operation_cancellation_workflow(self):
"""
Test bulk operation cancellation workflow.
Flow: Admin creates → Operation runs → Admin cancels
"""
from apps.moderation.models import BulkOperation
operation = BulkOperation.objects.create(
operation_type='BATCH_UPDATE',
status='PENDING',
@@ -466,20 +466,20 @@ class BulkOperationWorkflowTests(TestCase):
parameters={'update_field': 'status'},
can_cancel=True
)
operation.transition_to_running(user=self.admin)
operation.save()
# Partial progress
operation.processed_items = 30
operation.save()
# Admin cancels
operation.transition_to_cancelled(user=self.admin)
operation.completed_at = timezone.now()
operation.results = {'cancelled_at': 30, 'reason': 'User requested cancellation'}
operation.save()
operation.refresh_from_db()
self.assertEqual(operation.status, 'CANCELLED')
self.assertEqual(operation.processed_items, 30)
@@ -487,7 +487,7 @@ class BulkOperationWorkflowTests(TestCase):
class ModerationQueueWorkflowTests(TestCase):
"""Tests for moderation queue workflows."""
@classmethod
def setUpTestData(cls):
cls.moderator = User.objects.create_user(
@@ -496,15 +496,15 @@ class ModerationQueueWorkflowTests(TestCase):
password='testpass123',
role='MODERATOR'
)
def test_queue_completion_workflow(self):
"""
Test queue item completion workflow.
Flow: Item created → Moderator claims → Work done → Completed
"""
from apps.moderation.models import ModerationQueue
queue_item = ModerationQueue.objects.create(
queue_type='SUBMISSION_REVIEW',
status='PENDING',
@@ -512,21 +512,21 @@ class ModerationQueueWorkflowTests(TestCase):
item_type='edit_submission',
item_id=123
)
self.assertEqual(queue_item.status, 'PENDING')
# Moderator claims
queue_item.transition_to_in_progress(user=self.moderator)
queue_item.assigned_to = self.moderator
queue_item.assigned_at = timezone.now()
queue_item.save()
self.assertEqual(queue_item.status, 'IN_PROGRESS')
# Work completed
queue_item.transition_to_completed(user=self.moderator)
queue_item.completed_at = timezone.now()
queue_item.save()
queue_item.refresh_from_db()
self.assertEqual(queue_item.status, 'COMPLETED')

View File

@@ -6,29 +6,29 @@ All endpoints are nested under /api/moderation/ and provide comprehensive
moderation functionality including reports, queue management, actions, and bulk operations.
"""
from django.urls import path, include
from django.urls import include, path
from django.views.generic import TemplateView
from rest_framework.routers import DefaultRouter
from .views import (
ModerationReportViewSet,
ModerationQueueViewSet,
ModerationActionViewSet,
BulkOperationViewSet,
UserModerationViewSet,
EditSubmissionViewSet,
PhotoSubmissionViewSet,
)
from .sse import ModerationSSEView, ModerationSSETestView
from apps.core.views.views import FSMTransitionView
from .sse import ModerationSSETestView, ModerationSSEView
from .views import (
BulkOperationViewSet,
EditSubmissionViewSet,
ModerationActionViewSet,
ModerationQueueViewSet,
ModerationReportViewSet,
PhotoSubmissionViewSet,
UserModerationViewSet,
)
class ModerationDashboardView(TemplateView):
"""Moderation dashboard view with HTMX integration."""
template_name = "moderation/dashboard.html"
def get_context_data(self, **kwargs):
from .models import EditSubmission, PhotoSubmission
from .selectors import pending_submissions_for_review
context = super().get_context_data(**kwargs)
@@ -41,9 +41,10 @@ class SubmissionListView(TemplateView):
template_name = "moderation/partials/dashboard_content.html"
def get_context_data(self, **kwargs):
from .models import EditSubmission, PhotoSubmission
from itertools import chain
from .models import EditSubmission, PhotoSubmission
context = super().get_context_data(**kwargs)
status = self.request.GET.get("status", "PENDING")

View File

@@ -10,63 +10,62 @@ This module contains DRF viewsets for the moderation system, including:
All views include comprehensive permissions, filtering, and pagination.
"""
from rest_framework import viewsets, status, permissions
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.filters import SearchFilter, OrderingFilter
from django_filters.rest_framework import DjangoFilterBackend
from django.contrib.auth import get_user_model
from django.utils import timezone
from django.db.models import Q, Count
from django.shortcuts import render
from django.core.paginator import Paginator
import logging
from datetime import timedelta
from django_fsm import can_proceed, TransitionNotAllowed
from django.contrib.auth import get_user_model
from django.core.paginator import Paginator
from django.db.models import Count, Q
from django.shortcuts import render
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from django_fsm import TransitionNotAllowed, can_proceed
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.response import Response
from apps.core.logging import log_business_event
from apps.core.state_machine.exceptions import (
TransitionPermissionDenied,
TransitionValidationError,
format_transition_error,
)
from .filters import (
BulkOperationFilter,
ModerationActionFilter,
ModerationQueueFilter,
ModerationReportFilter,
)
from .models import (
ModerationReport,
ModerationQueue,
ModerationAction,
BulkOperation,
EditSubmission,
ModerationAction,
ModerationQueue,
ModerationReport,
PhotoSubmission,
)
from .serializers import (
ModerationReportSerializer,
CreateModerationReportSerializer,
UpdateModerationReportSerializer,
ModerationQueueSerializer,
AssignQueueItemSerializer,
CompleteQueueItemSerializer,
ModerationActionSerializer,
CreateModerationActionSerializer,
BulkOperationSerializer,
CreateBulkOperationSerializer,
UserModerationProfileSerializer,
EditSubmissionSerializer,
EditSubmissionListSerializer,
PhotoSubmissionSerializer,
)
from .filters import (
ModerationReportFilter,
ModerationQueueFilter,
ModerationActionFilter,
BulkOperationFilter,
)
import logging
from apps.core.logging import log_exception, log_business_event
from .permissions import (
IsModeratorOrAdmin,
IsAdminOrSuperuser,
CanViewModerationData,
IsAdminOrSuperuser,
IsModeratorOrAdmin,
)
from .serializers import (
AssignQueueItemSerializer,
BulkOperationSerializer,
CompleteQueueItemSerializer,
CreateBulkOperationSerializer,
CreateModerationActionSerializer,
CreateModerationReportSerializer,
EditSubmissionListSerializer,
EditSubmissionSerializer,
ModerationActionSerializer,
ModerationQueueSerializer,
ModerationReportSerializer,
PhotoSubmissionSerializer,
UpdateModerationReportSerializer,
UserModerationProfileSerializer,
)
User = get_user_model()
@@ -352,8 +351,8 @@ class ModerationReportViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=["get"], permission_classes=[CanViewModerationData])
def history(self, request, pk=None):
"""Get transition history for this report."""
from django_fsm_log.models import StateLog
from django.contrib.contenttypes.models import ContentType
from django_fsm_log.models import StateLog
report = self.get_object()
content_type = ContentType.objects.get_for_model(report)
@@ -387,8 +386,8 @@ class ModerationReportViewSet(viewsets.ModelViewSet):
Supports both HTMX (returns HTML partials) and API (returns JSON) requests.
"""
from django_fsm_log.models import StateLog
from django.contrib.contenttypes.models import ContentType
from django_fsm_log.models import StateLog
queryset = StateLog.objects.select_related("by", "content_type").all()
@@ -829,8 +828,8 @@ class ModerationQueueViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=["get"], permission_classes=[CanViewModerationData])
def history(self, request, pk=None):
"""Get transition history for this queue item."""
from django_fsm_log.models import StateLog
from django.contrib.contenttypes.models import ContentType
from django_fsm_log.models import StateLog
queue_item = self.get_object()
content_type = ContentType.objects.get_for_model(queue_item)
@@ -890,10 +889,7 @@ class ModerationActionViewSet(viewsets.ModelViewSet):
def get_permissions(self):
"""Return appropriate permissions based on action."""
if self.action == "create":
permission_classes = [IsModeratorOrAdmin]
else:
permission_classes = [CanViewModerationData]
permission_classes = [IsModeratorOrAdmin] if self.action == "create" else [CanViewModerationData]
return [permission() for permission in permission_classes]
@@ -1125,8 +1121,8 @@ class BulkOperationViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=["get"])
def history(self, request, pk=None):
"""Get transition history for this bulk operation."""
from django_fsm_log.models import StateLog
from django.contrib.contenttypes.models import ContentType
from django_fsm_log.models import StateLog
operation = self.get_object()
content_type = ContentType.objects.get_for_model(operation)
@@ -1404,7 +1400,7 @@ class UserModerationViewSet(viewsets.ViewSet):
class EditSubmissionViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing edit submissions.
Includes claim/unclaim endpoints with concurrency protection using
database row locking (select_for_update) to prevent race conditions.
"""
@@ -1425,7 +1421,7 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
status = self.request.query_params.get("status")
if status:
queryset = queryset.filter(status=status)
# User filter
user_id = self.request.query_params.get("user")
if user_id:
@@ -1437,20 +1433,20 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
def claim(self, request, pk=None):
"""
Claim a submission for review with concurrency protection.
Uses select_for_update() to acquire a database row lock,
preventing race conditions when multiple moderators try to
claim the same submission simultaneously.
Returns:
200: Submission successfully claimed
404: Submission not found
409: Submission already claimed or being claimed by another moderator
400: Invalid state for claiming
"""
from django.db import transaction, DatabaseError
from django.core.exceptions import ValidationError
from django.db import DatabaseError, transaction
with transaction.atomic():
try:
# Lock the row for update - other transactions will fail immediately
@@ -1466,7 +1462,7 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
{"error": "Submission is being claimed by another moderator. Please try again."},
status=status.HTTP_409_CONFLICT
)
# Check if already claimed
if submission.status == "CLAIMED":
return Response(
@@ -1477,14 +1473,14 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
},
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
)
try:
submission.claim(user=request.user)
log_business_event(
@@ -1506,26 +1502,26 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
def unclaim(self, request, pk=None):
"""
Release claim on a submission.
Only the claiming moderator or an admin can unclaim a submission.
"""
from django.core.exceptions import ValidationError
submission = self.get_object()
# 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
)
if submission.status != "CLAIMED":
return Response(
{"error": "Submission is not claimed"},
status=status.HTTP_400_BAD_REQUEST
)
try:
submission.unclaim(user=request.user)
log_business_event(
@@ -1547,7 +1543,7 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
def approve(self, request, pk=None):
submission = self.get_object()
user = request.user
try:
submission.approve(moderator=user)
return Response(self.get_serializer(submission).data)
@@ -1559,19 +1555,19 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
submission = self.get_object()
user = request.user
reason = request.data.get("reason", "")
try:
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)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def escalate(self, request, pk=None):
submission = self.get_object()
user = request.user
reason = request.data.get("reason", "")
try:
submission.escalate(moderator=user, reason=reason)
return Response(self.get_serializer(submission).data)
@@ -1582,7 +1578,7 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
class PhotoSubmissionViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing photo submissions.
Includes claim/unclaim endpoints with concurrency protection using
database row locking (select_for_update) to prevent race conditions.
"""
@@ -1599,24 +1595,24 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
status = self.request.query_params.get("status")
if status:
queryset = queryset.filter(status=status)
# User filter
user_id = self.request.query_params.get("user")
if user_id:
queryset = queryset.filter(user_id=user_id)
return queryset
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def claim(self, request, pk=None):
"""
Claim a photo submission for review with concurrency protection.
Uses select_for_update() to acquire a database row lock.
"""
from django.db import transaction, DatabaseError
from django.core.exceptions import ValidationError
from django.db import DatabaseError, transaction
with transaction.atomic():
try:
submission = PhotoSubmission.objects.select_for_update(nowait=True).get(pk=pk)
@@ -1630,7 +1626,7 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
{"error": "Submission is being claimed by another moderator. Please try again."},
status=status.HTTP_409_CONFLICT
)
if submission.status == "CLAIMED":
return Response(
{
@@ -1640,13 +1636,13 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
},
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
)
try:
submission.claim(user=request.user)
log_business_event(
@@ -1668,21 +1664,21 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
def unclaim(self, request, pk=None):
"""Release claim on a photo submission."""
from django.core.exceptions import ValidationError
submission = self.get_object()
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
)
if submission.status != "CLAIMED":
return Response(
{"error": "Submission is not claimed"},
status=status.HTTP_400_BAD_REQUEST
)
try:
submission.unclaim(user=request.user)
log_business_event(
@@ -1705,7 +1701,7 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
submission = self.get_object()
user = request.user
notes = request.data.get("notes", "")
try:
submission.approve(moderator=user, notes=notes)
return Response(self.get_serializer(submission).data)
@@ -1717,7 +1713,7 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
submission = self.get_object()
user = request.user
notes = request.data.get("notes", "")
try:
submission.reject(moderator=user, notes=notes)
return Response(self.get_serializer(submission).data)
@@ -1729,7 +1725,7 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
submission = self.get_object()
user = request.user
notes = request.data.get("notes", "")
try:
submission.escalate(moderator=user, notes=notes)
return Response(self.get_serializer(submission).data)