""" 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.dispatch import Signal 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 # 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 ( BulkOperation, EditSubmission, ModerationQueue, ModerationReport, PhotoSubmission, ) # 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", ]