Files
2025-09-21 20:19:12 -04:00

643 lines
22 KiB
Python

"""
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