mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 09:25:18 -05:00
641 lines
23 KiB
Python
641 lines
23 KiB
Python
"""
|
|
Services for moderation functionality.
|
|
Following Django styleguide pattern for business logic encapsulation.
|
|
"""
|
|
|
|
from typing import Any
|
|
|
|
from django.db import transaction
|
|
from django.db.models import QuerySet
|
|
from django.utils import timezone
|
|
from django_fsm import TransitionNotAllowed
|
|
|
|
from apps.accounts.models import User
|
|
|
|
from .models import EditSubmission, ModerationQueue, PhotoSubmission
|
|
|
|
|
|
class ModerationService:
|
|
"""Service for handling content moderation workflows."""
|
|
|
|
@staticmethod
|
|
def approve_submission(*, submission_id: int, moderator: User, notes: str | None = None) -> 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 using FSM transition
|
|
try:
|
|
submission.transition_to_rejected(user=moderator)
|
|
submission.handled_by = moderator
|
|
submission.handled_at = timezone.now()
|
|
submission.notes = f"Approval failed: {str(e)}"
|
|
submission.save()
|
|
except Exception:
|
|
# Fallback if FSM transition fails
|
|
pass
|
|
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")
|
|
|
|
# Use FSM transition method
|
|
submission.transition_to_rejected(user=moderator)
|
|
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: str | None = 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: str | None = None,
|
|
limit: int | None = 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: User | None = 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: object | None,
|
|
changes: dict[str, Any],
|
|
submitter: User,
|
|
submission_type: str = "EDIT",
|
|
reason: str | None = None,
|
|
source: str | None = 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: str | None = 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")
|
|
|
|
# Transition queue item into an active state before processing
|
|
moved_to_in_progress = False
|
|
try:
|
|
queue_item.transition_to_in_progress(user=moderator)
|
|
moved_to_in_progress = True
|
|
except TransitionNotAllowed:
|
|
# If FSM disallows, leave as-is and continue (fallback handled below)
|
|
pass
|
|
except AttributeError:
|
|
# Fallback for environments without the generated transition method
|
|
queue_item.status = "IN_PROGRESS"
|
|
moved_to_in_progress = True
|
|
|
|
if moved_to_in_progress:
|
|
queue_item.full_clean()
|
|
queue_item.save()
|
|
|
|
def _complete_queue_item() -> None:
|
|
"""Transition queue item to completed with FSM-aware fallback."""
|
|
try:
|
|
queue_item.transition_to_completed(user=moderator)
|
|
except TransitionNotAllowed:
|
|
queue_item.status = "COMPLETED"
|
|
except AttributeError:
|
|
queue_item.status = "COMPLETED"
|
|
|
|
# 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)
|
|
# Use FSM transition for queue status
|
|
_complete_queue_item()
|
|
result = {
|
|
"status": "approved",
|
|
"created_object": created_object,
|
|
"message": "Submission approved successfully",
|
|
}
|
|
except Exception as e:
|
|
# Use FSM transition for queue status
|
|
_complete_queue_item()
|
|
result = {"status": "failed", "created_object": None, "message": f"Approval failed: {str(e)}"}
|
|
elif action == "reject":
|
|
submission.reject(moderator, notes or "Rejected by moderator")
|
|
# Use FSM transition for queue status
|
|
_complete_queue_item()
|
|
result = {"status": "rejected", "created_object": None, "message": "Submission rejected"}
|
|
elif action == "escalate":
|
|
submission.escalate(moderator, notes or "Escalated for review")
|
|
queue_item.priority = "HIGH"
|
|
# Keep status as PENDING for escalation
|
|
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 "")
|
|
# Use FSM transition for queue status
|
|
_complete_queue_item()
|
|
result = {
|
|
"status": "approved",
|
|
"created_object": None,
|
|
"message": "Photo submission approved successfully",
|
|
}
|
|
except Exception as e:
|
|
# Use FSM transition for queue status
|
|
_complete_queue_item()
|
|
result = {
|
|
"status": "failed",
|
|
"created_object": None,
|
|
"message": f"Photo approval failed: {str(e)}",
|
|
}
|
|
elif action == "reject":
|
|
submission.reject(moderator, notes or "Rejected by moderator")
|
|
# Use FSM transition for queue status
|
|
_complete_queue_item()
|
|
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"
|
|
# Keep status as PENDING for escalation
|
|
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
|