mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 15:51:08 -05:00
643 lines
22 KiB
Python
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
|