mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 18:51:07 -05:00
@@ -1,642 +0,0 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user