""" 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', ]