""" Moderation services for ThrillWiki. This module provides business logic for the content moderation workflow: - Creating submissions - Starting reviews with locks - Approving submissions with atomic transactions - Selective approval of individual items - Rejecting submissions - Unlocking expired submissions """ import logging from datetime import timedelta from django.db import transaction from django.utils import timezone from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError, PermissionDenied from apps.moderation.models import ContentSubmission, SubmissionItem, ModerationLock logger = logging.getLogger(__name__) class ModerationService: """ Service class for moderation operations. All public methods use atomic transactions to ensure data integrity. """ @staticmethod @transaction.atomic def create_submission( user, entity, submission_type, title, description='', items_data=None, metadata=None, auto_submit=True, **kwargs ): """ Create a new content submission with items. Args: user: User creating the submission entity: Entity being modified (Park, Ride, Company, etc.) submission_type: 'create', 'update', or 'delete' title: Brief description of changes description: Detailed description (optional) items_data: List of dicts with item details: [ { 'field_name': 'name', 'field_label': 'Park Name', 'old_value': 'Old Name', 'new_value': 'New Name', 'change_type': 'modify', 'is_required': False, 'order': 0 }, ... ] metadata: Additional metadata dict auto_submit: Whether to automatically submit (move to pending state) **kwargs: Additional submission fields (source, ip_address, user_agent) Returns: ContentSubmission instance Raises: ValidationError: If validation fails """ # Get ContentType for entity entity_type = ContentType.objects.get_for_model(entity) # Create submission submission = ContentSubmission.objects.create( user=user, entity_type=entity_type, entity_id=entity.id, submission_type=submission_type, title=title, description=description, metadata=metadata or {}, source=kwargs.get('source', 'web'), ip_address=kwargs.get('ip_address'), user_agent=kwargs.get('user_agent', '') ) # Create submission items if items_data: for item_data in items_data: SubmissionItem.objects.create( submission=submission, field_name=item_data['field_name'], field_label=item_data.get('field_label', item_data['field_name']), old_value=item_data.get('old_value'), new_value=item_data.get('new_value'), change_type=item_data.get('change_type', 'modify'), is_required=item_data.get('is_required', False), order=item_data.get('order', 0) ) # Auto-submit if requested if auto_submit: submission.submit() submission.save() return submission @staticmethod @transaction.atomic def start_review(submission_id, reviewer): """ Start reviewing a submission (lock it). Args: submission_id: UUID of submission reviewer: User starting the review Returns: ContentSubmission instance Raises: ValidationError: If submission cannot be reviewed PermissionDenied: If user lacks permission """ submission = ContentSubmission.objects.select_for_update().get(id=submission_id) # Check if user has permission to review if not ModerationService._can_moderate(reviewer): raise PermissionDenied("User does not have moderation permission") # Check if submission is in correct state if submission.status != ContentSubmission.STATE_PENDING: raise ValidationError(f"Submission must be pending to start review (current: {submission.status})") # Check if already locked by another user if submission.locked_by and submission.locked_by != reviewer: if submission.is_locked(): raise ValidationError(f"Submission is locked by {submission.locked_by.email}") # Start review (FSM transition) submission.start_review(reviewer) submission.save() # Create lock record expires_at = timezone.now() + timedelta(minutes=15) ModerationLock.objects.update_or_create( submission=submission, defaults={ 'locked_by': reviewer, 'expires_at': expires_at, 'is_active': True, 'released_at': None } ) return submission @staticmethod @transaction.atomic def approve_submission(submission_id, reviewer): """ Approve an entire submission and apply all changes. This method uses atomic transactions to ensure all-or-nothing behavior. If any part fails, the entire operation is rolled back. Handles different submission types polymorphically: - 'review': Delegates to ReviewSubmissionService to create Review record - 'create'/'update'/'delete': Applies changes to entity directly Args: submission_id: UUID of submission reviewer: User approving the submission Returns: ContentSubmission instance Raises: ValidationError: If submission cannot be approved PermissionDenied: If user lacks permission """ submission = ContentSubmission.objects.select_for_update().get(id=submission_id) # Check permission if not ModerationService._can_moderate(reviewer): raise PermissionDenied("User does not have moderation permission") # Check if submission can be reviewed if not submission.can_review(reviewer): raise ValidationError("Submission cannot be reviewed at this time") # Get all pending items items = submission.items.filter(status='pending') # POLYMORPHIC HANDLING BASED ON SUBMISSION TYPE if submission.submission_type == 'review': # Handle review submissions - delegate to ReviewSubmissionService logger.info(f"Approving review submission {submission_id}") from apps.reviews.services import ReviewSubmissionService review = ReviewSubmissionService.apply_review_approval(submission) # Mark all items as approved for item in items: item.approve(reviewer) logger.info(f"Review created: {review.id} from submission {submission_id}") elif submission.submission_type in ['create', 'update', 'delete']: # Handle entity submissions entity = submission.entity if not entity: raise ValidationError("Entity no longer exists") logger.info(f"Approving {submission.submission_type} submission {submission_id}") if submission.submission_type == 'create': # Entity was created in draft state, now apply all fields and make visible for item in items: if item.change_type in ['add', 'modify']: setattr(entity, item.field_name, item.new_value) item.approve(reviewer) entity.save() elif submission.submission_type == 'update': # Apply updates to existing entity for item in items: if item.change_type in ['add', 'modify']: setattr(entity, item.field_name, item.new_value) elif item.change_type == 'remove': setattr(entity, item.field_name, None) item.approve(reviewer) entity.save() elif submission.submission_type == 'delete': # Check deletion type from metadata deletion_type = submission.metadata.get('deletion_type', 'soft') if deletion_type == 'soft': # Soft delete: Apply status change to 'closed' for item in items: if item.field_name == 'status': # Apply status change setattr(entity, 'status', 'closed') item.approve(reviewer) entity.save() logger.info(f"Entity soft-deleted (status=closed): {entity.id}") else: # Hard delete: Remove from database for item in items: item.approve(reviewer) entity.delete() logger.info(f"Entity hard-deleted from database: {entity.id}") logger.info(f"Entity changes applied for submission {submission_id}") else: raise ValidationError(f"Unknown submission type: {submission.submission_type}") # Approve submission (FSM transition) submission.approve(reviewer) submission.save() # Release lock try: lock = ModerationLock.objects.get(submission=submission, is_active=True) lock.release() except ModerationLock.DoesNotExist: pass # Send notification email asynchronously try: from apps.moderation.tasks import send_moderation_notification send_moderation_notification.delay(str(submission.id), 'approved') except Exception as e: # Don't fail the approval if email fails to queue logger.warning(f"Failed to queue approval notification: {str(e)}") return submission @staticmethod @transaction.atomic def approve_selective(submission_id, reviewer, item_ids): """ Approve only specific items in a submission (selective approval). This allows moderators to approve some changes while rejecting others. Uses atomic transactions for data integrity. Args: submission_id: UUID of submission reviewer: User approving the items item_ids: List of item UUIDs to approve Returns: dict with counts: {'approved': N, 'total': M} Raises: ValidationError: If submission cannot be reviewed PermissionDenied: If user lacks permission """ submission = ContentSubmission.objects.select_for_update().get(id=submission_id) # Check permission if not ModerationService._can_moderate(reviewer): raise PermissionDenied("User does not have moderation permission") # Check if submission can be reviewed if not submission.can_review(reviewer): raise ValidationError("Submission cannot be reviewed at this time") # Get entity entity = submission.entity if not entity: raise ValidationError("Entity no longer exists") # Get items to approve items_to_approve = submission.items.filter( id__in=item_ids, status='pending' ) approved_count = 0 for item in items_to_approve: # Apply change to entity if item.change_type in ['add', 'modify']: setattr(entity, item.field_name, item.new_value) elif item.change_type == 'remove': setattr(entity, item.field_name, None) # Mark item as approved item.approve(reviewer) approved_count += 1 # Save entity if any changes were made if approved_count > 0: entity.save() # Check if all items are now reviewed pending_count = submission.items.filter(status='pending').count() if pending_count == 0: # All items reviewed - mark submission as approved submission.approve(reviewer) submission.save() # Release lock try: lock = ModerationLock.objects.get(submission=submission, is_active=True) lock.release() except ModerationLock.DoesNotExist: pass return { 'approved': approved_count, 'total': submission.items.count(), 'pending': pending_count, 'submission_approved': pending_count == 0 } @staticmethod @transaction.atomic def reject_submission(submission_id, reviewer, reason): """ Reject an entire submission. Args: submission_id: UUID of submission reviewer: User rejecting the submission reason: Reason for rejection Returns: ContentSubmission instance Raises: ValidationError: If submission cannot be rejected PermissionDenied: If user lacks permission """ submission = ContentSubmission.objects.select_for_update().get(id=submission_id) # Check permission if not ModerationService._can_moderate(reviewer): raise PermissionDenied("User does not have moderation permission") # Check if submission can be reviewed if not submission.can_review(reviewer): raise ValidationError("Submission cannot be reviewed at this time") # Reject all pending items items = submission.items.filter(status='pending') for item in items: item.reject(reviewer, reason) # Reject submission (FSM transition) submission.reject(reviewer, reason) submission.save() # Release lock try: lock = ModerationLock.objects.get(submission=submission, is_active=True) lock.release() except ModerationLock.DoesNotExist: pass # Send notification email asynchronously try: from apps.moderation.tasks import send_moderation_notification send_moderation_notification.delay(str(submission.id), 'rejected') except Exception as e: # Don't fail the rejection if email fails to queue logger.warning(f"Failed to queue rejection notification: {str(e)}") return submission @staticmethod @transaction.atomic def reject_selective(submission_id, reviewer, item_ids, reason=''): """ Reject specific items in a submission. Args: submission_id: UUID of submission reviewer: User rejecting the items item_ids: List of item UUIDs to reject reason: Reason for rejection (optional) Returns: dict with counts: {'rejected': N, 'total': M} Raises: ValidationError: If submission cannot be reviewed PermissionDenied: If user lacks permission """ submission = ContentSubmission.objects.select_for_update().get(id=submission_id) # Check permission if not ModerationService._can_moderate(reviewer): raise PermissionDenied("User does not have moderation permission") # Check if submission can be reviewed if not submission.can_review(reviewer): raise ValidationError("Submission cannot be reviewed at this time") # Get items to reject items_to_reject = submission.items.filter( id__in=item_ids, status='pending' ) rejected_count = 0 for item in items_to_reject: item.reject(reviewer, reason) rejected_count += 1 # Check if all items are now reviewed pending_count = submission.items.filter(status='pending').count() if pending_count == 0: # All items reviewed approved_count = submission.items.filter(status='approved').count() if approved_count > 0: # Some items approved - mark submission as approved submission.approve(reviewer) submission.save() else: # All items rejected - mark submission as rejected submission.reject(reviewer, "All items rejected") submission.save() # Release lock try: lock = ModerationLock.objects.get(submission=submission, is_active=True) lock.release() except ModerationLock.DoesNotExist: pass return { 'rejected': rejected_count, 'total': submission.items.count(), 'pending': pending_count, 'submission_complete': pending_count == 0 } @staticmethod @transaction.atomic def unlock_submission(submission_id): """ Manually unlock a submission. Args: submission_id: UUID of submission Returns: ContentSubmission instance """ submission = ContentSubmission.objects.select_for_update().get(id=submission_id) if submission.status == ContentSubmission.STATE_REVIEWING: submission.unlock() submission.save() # Release lock record try: lock = ModerationLock.objects.get(submission=submission, is_active=True) lock.release() except ModerationLock.DoesNotExist: pass return submission @staticmethod def cleanup_expired_locks(): """ Cleanup expired locks and unlock submissions. This should be called periodically (e.g., every 5 minutes via Celery). Returns: int: Number of locks cleaned up """ return ModerationLock.cleanup_expired() @staticmethod def get_queue(status=None, user=None, limit=50, offset=0): """ Get moderation queue with filters. Args: status: Filter by status (optional) user: Filter by submitter (optional) limit: Maximum results offset: Pagination offset Returns: QuerySet of ContentSubmission objects """ queryset = ContentSubmission.objects.select_related( 'user', 'entity_type', 'locked_by', 'reviewed_by' ).prefetch_related('items') if status: queryset = queryset.filter(status=status) if user: queryset = queryset.filter(user=user) return queryset[offset:offset + limit] @staticmethod def get_submission_details(submission_id): """ Get full submission details with all items. Args: submission_id: UUID of submission Returns: ContentSubmission instance with prefetched items """ return ContentSubmission.objects.select_related( 'user', 'entity_type', 'locked_by', 'reviewed_by' ).prefetch_related( 'items', 'items__reviewed_by' ).get(id=submission_id) @staticmethod def _can_moderate(user): """ Check if user has moderation permission. Args: user: User to check Returns: bool: True if user can moderate """ if not user or not user.is_authenticated: return False # Check if user is superuser if user.is_superuser: return True # Check if user has moderator or admin role try: return user.role.is_moderator except: return False @staticmethod @transaction.atomic def delete_submission(submission_id, user): """ Delete a submission (only if draft or by owner). Args: submission_id: UUID of submission user: User attempting to delete Returns: bool: True if deleted Raises: PermissionDenied: If user cannot delete ValidationError: If submission cannot be deleted """ submission = ContentSubmission.objects.select_for_update().get(id=submission_id) # Check permission is_owner = submission.user == user is_moderator = ModerationService._can_moderate(user) if not (is_owner or is_moderator): raise PermissionDenied("Only the owner or a moderator can delete this submission") # Check state if submission.status not in [ContentSubmission.STATE_DRAFT, ContentSubmission.STATE_PENDING]: if not is_moderator: raise ValidationError("Only moderators can delete submissions under review") # Delete submission (cascades to items and lock) submission.delete() return True