""" Services for moderation functionality. Following Django styleguide pattern for business logic encapsulation. """ from typing import Optional, Dict, Any, Union from django.db import transaction from django.utils import timezone from django.db.models import QuerySet from apps.accounts.models import User from .models import EditSubmission, PhotoSubmission, ModerationQueue class ModerationService: """Service for handling content moderation workflows.""" @staticmethod def approve_submission( *, submission_id: int, moderator: User, notes: Optional[str] = None ) -> Union[object, None]: """ Approve a content submission and apply changes. Args: submission_id: ID of the submission to approve moderator: User performing the approval notes: Optional notes about the approval Returns: The created/updated object or None if approval failed Raises: EditSubmission.DoesNotExist: If submission doesn't exist ValidationError: If submission data is invalid ValueError: If submission cannot be processed """ with transaction.atomic(): submission = EditSubmission.objects.select_for_update().get( id=submission_id ) if submission.status != "PENDING": raise ValueError(f"Submission {submission_id} is not pending approval") try: # Call the model's approve method which handles the business # logic obj = submission.approve(moderator) # Add moderator notes if provided if notes: if submission.notes: submission.notes += f"\n[Moderator]: {notes}" else: submission.notes = f"[Moderator]: {notes}" submission.save() return obj except Exception as e: # Mark as rejected on any error submission.status = "REJECTED" submission.handled_by = moderator submission.handled_at = timezone.now() submission.notes = f"Approval failed: {str(e)}" submission.save() raise @staticmethod def reject_submission( *, submission_id: int, moderator: User, reason: str ) -> EditSubmission: """ Reject a content submission. Args: submission_id: ID of the submission to reject moderator: User performing the rejection reason: Reason for rejection Returns: Updated submission object Raises: EditSubmission.DoesNotExist: If submission doesn't exist ValueError: If submission cannot be rejected """ with transaction.atomic(): submission = EditSubmission.objects.select_for_update().get( id=submission_id ) if submission.status != "PENDING": raise ValueError(f"Submission {submission_id} is not pending review") submission.status = "REJECTED" submission.handled_by = moderator submission.handled_at = timezone.now() submission.notes = f"Rejected: {reason}" # Call full_clean before saving - CRITICAL STYLEGUIDE FIX submission.full_clean() submission.save() return submission @staticmethod def create_edit_submission( *, content_object: object, changes: Dict[str, Any], submitter: User, submission_type: str = "UPDATE", notes: Optional[str] = None, ) -> EditSubmission: """ Create a new edit submission for moderation. Args: content_object: The object being edited changes: Dictionary of field changes submitter: User submitting the changes submission_type: Type of submission ("CREATE" or "UPDATE") notes: Optional notes about the submission Returns: Created EditSubmission object Raises: ValidationError: If submission data is invalid """ submission = EditSubmission( content_object=content_object, changes=changes, user=submitter, submission_type=submission_type, reason=notes or "", ) # Call full_clean before saving - CRITICAL STYLEGUIDE FIX submission.full_clean() submission.save() return submission @staticmethod def update_submission_changes( *, submission_id: int, moderator_changes: Dict[str, Any], moderator: User, ) -> EditSubmission: """ Update submission with moderator changes before approval. Args: submission_id: ID of the submission to update moderator_changes: Dictionary of moderator modifications moderator: User making the changes Returns: Updated submission object Raises: EditSubmission.DoesNotExist: If submission doesn't exist ValueError: If submission cannot be modified """ with transaction.atomic(): submission = EditSubmission.objects.select_for_update().get( id=submission_id ) if submission.status != "PENDING": raise ValueError(f"Submission {submission_id} is not pending review") submission.moderator_changes = moderator_changes # Add note about moderator changes note = f"[Moderator changes by {moderator.username}]" if submission.notes: submission.notes += f"\n{note}" else: submission.notes = note # Call full_clean before saving - CRITICAL STYLEGUIDE FIX submission.full_clean() submission.save() return submission @staticmethod def get_pending_submissions_for_moderator( *, moderator: User, content_type: Optional[str] = None, limit: Optional[int] = None, ) -> QuerySet: """ Get pending submissions for a moderator to review. Args: moderator: The moderator user content_type: Optional filter by content type limit: Maximum number of submissions to return Returns: QuerySet of pending submissions """ from .selectors import pending_submissions_for_review return pending_submissions_for_review(content_type=content_type, limit=limit) @staticmethod def get_submission_statistics( *, days: int = 30, moderator: Optional[User] = None ) -> Dict[str, Any]: """ Get moderation statistics for a time period. Args: days: Number of days to analyze moderator: Optional filter by specific moderator Returns: Dictionary containing moderation statistics """ from .selectors import moderation_statistics_summary return moderation_statistics_summary(days=days, moderator=moderator) @staticmethod def _is_moderator_or_above(user: User) -> bool: """ Check if user has moderator privileges or above. Args: user: User to check Returns: True if user is MODERATOR, ADMIN, or SUPERUSER """ return user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER'] @staticmethod def create_edit_submission_with_queue( *, content_object: Optional[object], changes: Dict[str, Any], submitter: User, submission_type: str = "EDIT", reason: Optional[str] = None, source: Optional[str] = None, ) -> Dict[str, Any]: """ Create an edit submission with automatic queue routing. For moderators and above: Creates submission and auto-approves For regular users: Creates submission and adds to moderation queue Args: content_object: The object being edited (None for CREATE) changes: Dictionary of field changes submitter: User submitting the changes submission_type: Type of submission ("CREATE" or "EDIT") reason: Reason for the submission source: Source of information Returns: Dictionary with submission info and queue status """ with transaction.atomic(): # Create the submission submission = EditSubmission( content_object=content_object, changes=changes, user=submitter, submission_type=submission_type, reason=reason or "", source=source or "", ) submission.full_clean() submission.save() # Check if user is moderator or above if ModerationService._is_moderator_or_above(submitter): # Auto-approve for moderators try: created_object = submission.approve(submitter) return { 'submission': submission, 'status': 'auto_approved', 'created_object': created_object, 'queue_item': None, 'message': 'Submission auto-approved for moderator' } except Exception as e: return { 'submission': submission, 'status': 'failed', 'created_object': None, 'queue_item': None, 'message': f'Auto-approval failed: {str(e)}' } else: # Create queue item for regular users queue_item = ModerationService._create_queue_item_for_submission( submission=submission, submitter=submitter ) return { 'submission': submission, 'status': 'queued', 'created_object': None, 'queue_item': queue_item, 'message': 'Submission added to moderation queue' } @staticmethod def create_photo_submission_with_queue( *, content_object: object, photo, caption: str = "", date_taken=None, submitter: User, ) -> Dict[str, Any]: """ Create a photo submission with automatic queue routing. For moderators and above: Creates submission and auto-approves For regular users: Creates submission and adds to moderation queue Args: content_object: The object the photo is for photo: The photo file caption: Photo caption date_taken: Date the photo was taken submitter: User submitting the photo Returns: Dictionary with submission info and queue status """ with transaction.atomic(): # Create the photo submission submission = PhotoSubmission( content_object=content_object, photo=photo, caption=caption, date_taken=date_taken, user=submitter, ) submission.full_clean() submission.save() # Check if user is moderator or above if ModerationService._is_moderator_or_above(submitter): # Auto-approve for moderators try: submission.auto_approve() return { 'submission': submission, 'status': 'auto_approved', 'queue_item': None, 'message': 'Photo submission auto-approved for moderator' } except Exception as e: return { 'submission': submission, 'status': 'failed', 'queue_item': None, 'message': f'Auto-approval failed: {str(e)}' } else: # Create queue item for regular users queue_item = ModerationService._create_queue_item_for_photo_submission( submission=submission, submitter=submitter ) return { 'submission': submission, 'status': 'queued', 'queue_item': queue_item, 'message': 'Photo submission added to moderation queue' } @staticmethod def _create_queue_item_for_submission( *, submission: EditSubmission, submitter: User ) -> ModerationQueue: """ Create a moderation queue item for an edit submission. Args: submission: The edit submission submitter: User who made the submission Returns: Created ModerationQueue item """ # Determine content type and entity info content_type = submission.content_type entity_type = content_type.model if content_type else "unknown" entity_id = submission.object_id # Create preview data entity_preview = { 'submission_type': submission.submission_type, 'changes_count': len(submission.changes) if submission.changes else 0, 'reason': submission.reason[:100] if submission.reason else "", } if submission.content_object: entity_preview['object_name'] = str(submission.content_object) # Determine title and description action = "creation" if submission.submission_type == "CREATE" else "edit" title = f"{entity_type.title()} {action} by {submitter.username}" description = f"Review {action} submission for {entity_type}" if submission.reason: description += f". Reason: {submission.reason}" # Create queue item queue_item = ModerationQueue( item_type='CONTENT_REVIEW', title=title, description=description, entity_type=entity_type, entity_id=entity_id, entity_preview=entity_preview, content_type=content_type, flagged_by=submitter, priority='MEDIUM', estimated_review_time=15, # 15 minutes default tags=['edit_submission', submission.submission_type.lower()], ) queue_item.full_clean() queue_item.save() return queue_item @staticmethod def _create_queue_item_for_photo_submission( *, submission: PhotoSubmission, submitter: User ) -> ModerationQueue: """ Create a moderation queue item for a photo submission. Args: submission: The photo submission submitter: User who made the submission Returns: Created ModerationQueue item """ # Determine content type and entity info content_type = submission.content_type entity_type = content_type.model if content_type else "unknown" entity_id = submission.object_id # Create preview data entity_preview = { 'caption': submission.caption, 'date_taken': submission.date_taken.isoformat() if submission.date_taken else None, 'photo_url': submission.photo.url if submission.photo else None, } if submission.content_object: entity_preview['object_name'] = str(submission.content_object) # Create title and description title = f"Photo submission for {entity_type} by {submitter.username}" description = f"Review photo submission for {entity_type}" if submission.caption: description += f". Caption: {submission.caption}" # Create queue item queue_item = ModerationQueue( item_type='CONTENT_REVIEW', title=title, description=description, entity_type=entity_type, entity_id=entity_id, entity_preview=entity_preview, content_type=content_type, flagged_by=submitter, priority='LOW', # Photos typically lower priority estimated_review_time=5, # 5 minutes default for photos tags=['photo_submission'], ) queue_item.full_clean() queue_item.save() return queue_item @staticmethod def process_queue_item( *, queue_item_id: int, moderator: User, action: str, notes: Optional[str] = None ) -> Dict[str, Any]: """ Process a moderation queue item (approve, reject, etc.). Args: queue_item_id: ID of the queue item to process moderator: User processing the item action: Action to take ('approve', 'reject', 'escalate') notes: Optional notes about the action Returns: Dictionary with processing results """ with transaction.atomic(): queue_item = ModerationQueue.objects.select_for_update().get( id=queue_item_id ) if queue_item.status != 'PENDING': raise ValueError(f"Queue item {queue_item_id} is not pending") # Find related submission if 'edit_submission' in queue_item.tags: # Find EditSubmission submissions = EditSubmission.objects.filter( user=queue_item.flagged_by, content_type=queue_item.content_type, object_id=queue_item.entity_id, status='PENDING' ).order_by('-created_at') if not submissions.exists(): raise ValueError( "No pending edit submission found for this queue item") submission = submissions.first() if action == 'approve': try: created_object = submission.approve(moderator) queue_item.status = 'COMPLETED' result = { 'status': 'approved', 'created_object': created_object, 'message': 'Submission approved successfully' } except Exception as e: queue_item.status = 'COMPLETED' result = { 'status': 'failed', 'created_object': None, 'message': f'Approval failed: {str(e)}' } elif action == 'reject': submission.reject(moderator, notes or "Rejected by moderator") queue_item.status = 'COMPLETED' result = { 'status': 'rejected', 'created_object': None, 'message': 'Submission rejected' } elif action == 'escalate': submission.escalate(moderator, notes or "Escalated for review") queue_item.priority = 'HIGH' queue_item.status = 'PENDING' # Keep in queue but escalated result = { 'status': 'escalated', 'created_object': None, 'message': 'Submission escalated' } else: raise ValueError(f"Unknown action: {action}") elif 'photo_submission' in queue_item.tags: # Find PhotoSubmission submissions = PhotoSubmission.objects.filter( user=queue_item.flagged_by, content_type=queue_item.content_type, object_id=queue_item.entity_id, status='PENDING' ).order_by('-created_at') if not submissions.exists(): raise ValueError( "No pending photo submission found for this queue item") submission = submissions.first() if action == 'approve': try: submission.approve(moderator, notes or "") queue_item.status = 'COMPLETED' result = { 'status': 'approved', 'created_object': None, 'message': 'Photo submission approved successfully' } except Exception as e: queue_item.status = 'COMPLETED' result = { 'status': 'failed', 'created_object': None, 'message': f'Photo approval failed: {str(e)}' } elif action == 'reject': submission.reject(moderator, notes or "Rejected by moderator") queue_item.status = 'COMPLETED' result = { 'status': 'rejected', 'created_object': None, 'message': 'Photo submission rejected' } elif action == 'escalate': submission.escalate(moderator, notes or "Escalated for review") queue_item.priority = 'HIGH' queue_item.status = 'PENDING' # Keep in queue but escalated result = { 'status': 'escalated', 'created_object': None, 'message': 'Photo submission escalated' } else: raise ValueError(f"Unknown action: {action}") else: raise ValueError("Unknown queue item type") # Update queue item queue_item.assigned_to = moderator queue_item.assigned_at = timezone.now() if notes: queue_item.description += f"\n\nModerator notes: {notes}" queue_item.full_clean() queue_item.save() result['queue_item'] = queue_item return result