mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-30 03:07:00 -05:00
feat: Implement MFA authentication, add ride statistics model, and update various services, APIs, and tests across the application.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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}')
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user