Files
thrillwiki_django_no_react/backend/apps/moderation/signals.py

491 lines
15 KiB
Python

"""
Signal handlers for moderation-related FSM state transitions.
This module provides signal handlers that execute when moderation
models (EditSubmission, PhotoSubmission, ModerationReport, etc.)
undergo state transitions.
Includes:
- Transition handlers for approval, rejection, escalation
- Real-time broadcasting signal for dashboard updates
- Claim/unclaim tracking for concurrency control
"""
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,
)
logger = logging.getLogger(__name__)
# ============================================================================
# Custom Signals for Real-Time Broadcasting
# ============================================================================
# Signal emitted when a submission status changes - for real-time UI updates
# Arguments:
# - sender: The model class (EditSubmission or PhotoSubmission)
# - submission_id: The ID of the submission
# - submission_type: "edit" or "photo"
# - new_status: The new status value
# - previous_status: The previous status value
# - locked_by: Username of the moderator who claimed it (or None)
# - payload: Full payload dictionary for broadcasting
submission_status_changed = Signal()
def handle_submission_claimed(instance, source, target, user, context=None, **kwargs):
"""
Handle submission claim transitions.
Called when an EditSubmission or PhotoSubmission is claimed by a moderator.
Broadcasts the status change for real-time dashboard updates.
Args:
instance: The submission instance.
source: The source state.
target: The target state.
user: The user who claimed.
context: Optional TransitionContext.
"""
if target != 'CLAIMED':
return
logger.info(
f"Submission {instance.pk} claimed by {user.username if user else 'system'}"
)
# Broadcast for real-time dashboard updates
_broadcast_submission_status_change(instance, source, target, user)
def handle_submission_unclaimed(instance, source, target, user, context=None, **kwargs):
"""
Handle submission unclaim transitions (CLAIMED -> PENDING).
Called when a moderator releases their claim on a submission.
Args:
instance: The submission instance.
source: The source state.
target: The target state.
user: The user who unclaimed.
context: Optional TransitionContext.
"""
if source != 'CLAIMED' or target != 'PENDING':
return
logger.info(
f"Submission {instance.pk} unclaimed by {user.username if user else 'system'}"
)
# Broadcast for real-time dashboard updates
_broadcast_submission_status_change(instance, source, target, user)
def handle_submission_approved(instance, source, target, user, context=None, **kwargs):
"""
Handle submission approval transitions.
Called when an EditSubmission or PhotoSubmission is approved.
Args:
instance: The submission instance.
source: The source state.
target: The target state.
user: The user who approved.
context: Optional TransitionContext.
"""
if target != 'APPROVED':
return
logger.info(
f"Submission {instance.pk} approved by {user if user else 'system'}"
)
# Trigger notification (handled by NotificationCallback)
# Invalidate cache (handled by CacheInvalidationCallback)
# Apply the submission changes if applicable
if hasattr(instance, 'apply_changes'):
try:
instance.apply_changes()
logger.info(f"Applied changes for submission {instance.pk}")
except Exception as e:
logger.exception(
f"Failed to apply changes for submission {instance.pk}: {e}"
)
def handle_submission_rejected(instance, source, target, user, context=None, **kwargs):
"""
Handle submission rejection transitions.
Called when an EditSubmission or PhotoSubmission is rejected.
Args:
instance: The submission instance.
source: The source state.
target: The target state.
user: The user who rejected.
context: Optional TransitionContext.
"""
if target != 'REJECTED':
return
reason = context.extra_data.get('reason', '') if context else ''
logger.info(
f"Submission {instance.pk} rejected by {user if user else 'system'}"
f"{f': {reason}' if reason else ''}"
)
def handle_submission_escalated(instance, source, target, user, context=None, **kwargs):
"""
Handle submission escalation transitions.
Called when an EditSubmission or PhotoSubmission is escalated.
Args:
instance: The submission instance.
source: The source state.
target: The target state.
user: The user who escalated.
context: Optional TransitionContext.
"""
if target != 'ESCALATED':
return
reason = context.extra_data.get('reason', '') if context else ''
logger.info(
f"Submission {instance.pk} escalated by {user if user else 'system'}"
f"{f': {reason}' if reason else ''}"
)
# Create escalation task if task system is available
_create_escalation_task(instance, user, reason)
def handle_report_resolved(instance, source, target, user, context=None, **kwargs):
"""
Handle moderation report resolution.
Called when a ModerationReport is resolved.
Args:
instance: The ModerationReport instance.
source: The source state.
target: The target state.
user: The user who resolved.
context: Optional TransitionContext.
"""
if target != 'RESOLVED':
return
logger.info(
f"ModerationReport {instance.pk} resolved by {user if user else 'system'}"
)
# Update related queue items
_update_related_queue_items(instance, 'COMPLETED')
def handle_queue_completed(instance, source, target, user, context=None, **kwargs):
"""
Handle moderation queue completion.
Called when a ModerationQueue item is completed.
Args:
instance: The ModerationQueue instance.
source: The source state.
target: The target state.
user: The user who completed.
context: Optional TransitionContext.
"""
if target != 'COMPLETED':
return
logger.info(
f"ModerationQueue {instance.pk} completed by {user if user else 'system'}"
)
# Update moderation statistics
_update_moderation_stats(instance, user)
def handle_bulk_operation_status(instance, source, target, user, context=None, **kwargs):
"""
Handle bulk operation status changes.
Called when a BulkOperation transitions between states.
Args:
instance: The BulkOperation instance.
source: The source state.
target: The target state.
user: The user who initiated the change.
context: Optional TransitionContext.
"""
logger.info(
f"BulkOperation {instance.pk} transitioned: {source}{target}"
)
if target == 'COMPLETED':
_finalize_bulk_operation(instance, success=True)
elif target == 'FAILED':
_finalize_bulk_operation(instance, success=False)
# Helper functions
def _create_escalation_task(instance, user, reason):
"""Create an escalation task for admin review."""
try:
from apps.moderation.models import ModerationQueue
# Create a queue item for the escalated submission
ModerationQueue.objects.create(
content_object=instance,
priority='HIGH',
reason=f"Escalated: {reason}" if reason else "Escalated for review",
created_by=user,
)
logger.info(f"Created escalation queue item for submission {instance.pk}")
except ImportError:
logger.debug("ModerationQueue model not available")
except Exception as e:
logger.warning(f"Failed to create escalation task: {e}")
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))
queue_items = ModerationQueue.objects.filter(
content_type=content_type,
object_id=instance.pk,
).exclude(status=status)
updated = queue_items.update(status=status)
if updated:
logger.info(f"Updated {updated} queue items to {status}")
except ImportError:
logger.debug("ModerationQueue model not available")
except Exception as e:
logger.warning(f"Failed to update queue items: {e}")
def _update_moderation_stats(instance, user):
"""Update moderation statistics for a user."""
if not user:
return
try:
# Update user's moderation count if they have a profile
profile = getattr(user, 'profile', None)
if profile and hasattr(profile, 'moderation_count'):
profile.moderation_count += 1
profile.save(update_fields=['moderation_count'])
logger.debug(f"Updated moderation count for {user}")
except Exception as e:
logger.warning(f"Failed to update moderation stats: {e}")
def _finalize_bulk_operation(instance, success):
"""Finalize a bulk operation after completion or failure."""
try:
from django.utils import timezone
instance.completed_at = timezone.now()
instance.save(update_fields=['completed_at'])
if success:
logger.info(
f"BulkOperation {instance.pk} completed successfully: "
f"{getattr(instance, 'success_count', 0)} succeeded, "
f"{getattr(instance, 'failure_count', 0)} failed"
)
else:
logger.warning(
f"BulkOperation {instance.pk} failed: "
f"{getattr(instance, 'error_message', 'Unknown error')}"
)
except Exception as e:
logger.warning(f"Failed to finalize bulk operation: {e}")
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,
"submission_type": "edit" | "photo",
"new_status": "CLAIMED",
"previous_status": "PENDING",
"locked_by": "moderator_username" | None,
"locked_at": "2024-01-01T12:00:00Z" | None,
"changed_by": "username" | None,
}
"""
try:
from .models import EditSubmission, PhotoSubmission
# Determine submission type
submission_type = "edit" if isinstance(instance, EditSubmission) else "photo"
# Build the broadcast payload
payload = {
"submission_id": instance.pk,
"submission_type": submission_type,
"new_status": target,
"previous_status": source,
"locked_by": None,
"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),
submission_id=instance.pk,
submission_type=submission_type,
new_status=target,
previous_status=source,
locked_by=payload["locked_by"],
payload=payload,
)
logger.debug(
f"Broadcast status change: {submission_type}#{instance.pk} "
f"{source} -> {target}"
)
except Exception as e:
logger.warning(f"Failed to broadcast submission status change: {e}")
# Signal handler registration
def register_moderation_signal_handlers():
"""
Register all moderation signal handlers.
This function should be called in the moderation app's AppConfig.ready() method.
"""
from apps.core.state_machine.signals import register_transition_handler
try:
from apps.moderation.models import (
EditSubmission,
PhotoSubmission,
ModerationReport,
ModerationQueue,
BulkOperation,
)
# EditSubmission handlers
register_transition_handler(
EditSubmission, '*', 'APPROVED',
handle_submission_approved, stage='post'
)
register_transition_handler(
EditSubmission, '*', 'REJECTED',
handle_submission_rejected, stage='post'
)
register_transition_handler(
EditSubmission, '*', 'ESCALATED',
handle_submission_escalated, stage='post'
)
# PhotoSubmission handlers
register_transition_handler(
PhotoSubmission, '*', 'APPROVED',
handle_submission_approved, stage='post'
)
register_transition_handler(
PhotoSubmission, '*', 'REJECTED',
handle_submission_rejected, stage='post'
)
register_transition_handler(
PhotoSubmission, '*', 'ESCALATED',
handle_submission_escalated, stage='post'
)
# ModerationReport handlers
register_transition_handler(
ModerationReport, '*', 'RESOLVED',
handle_report_resolved, stage='post'
)
# ModerationQueue handlers
register_transition_handler(
ModerationQueue, '*', 'COMPLETED',
handle_queue_completed, stage='post'
)
# BulkOperation handlers
register_transition_handler(
BulkOperation, '*', '*',
handle_bulk_operation_status, stage='post'
)
# Claim/Unclaim handlers for EditSubmission
register_transition_handler(
EditSubmission, 'PENDING', 'CLAIMED',
handle_submission_claimed, stage='post'
)
register_transition_handler(
EditSubmission, 'CLAIMED', 'PENDING',
handle_submission_unclaimed, stage='post'
)
# Claim/Unclaim handlers for PhotoSubmission
register_transition_handler(
PhotoSubmission, 'PENDING', 'CLAIMED',
handle_submission_claimed, stage='post'
)
register_transition_handler(
PhotoSubmission, 'CLAIMED', 'PENDING',
handle_submission_unclaimed, stage='post'
)
logger.info("Registered moderation signal handlers")
except ImportError as e:
logger.warning(f"Could not register moderation signal handlers: {e}")
__all__ = [
'submission_status_changed',
'register_moderation_signal_handlers',
'handle_submission_approved',
'handle_submission_rejected',
'handle_submission_escalated',
'handle_submission_claimed',
'handle_submission_unclaimed',
'handle_report_resolved',
'handle_queue_completed',
'handle_bulk_operation_status',
]