mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 04:25:17 -05:00
feat: add passkey authentication and enhance user preferences - Add passkey login security event type with fingerprint icon - Include request and site context in email confirmation for backend - Add user_id exact match filter to prevent incorrect user lookups - Enable PATCH method for updating user preferences via API - Add moderation_preferences support to user settings - Optimize ticket queries with select_related and prefetch_related This commit introduces passkey authentication tracking, improves user profile filtering accuracy, and extends the preferences API to support updates. Query optimizations reduce database hits for ticket listings.
647 lines
24 KiB
Python
647 lines
24 KiB
Python
"""
|
|
Services for moderation functionality.
|
|
Following Django styleguide pattern for business logic encapsulation.
|
|
"""
|
|
|
|
from typing import Any
|
|
|
|
from django.contrib.contenttypes.models import ContentType
|
|
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 != "CLAIMED":
|
|
raise ValueError(f"Submission {submission_id} must be claimed before approval (current status: {submission.status})")
|
|
|
|
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 != "CLAIMED":
|
|
raise ValueError(f"Submission {submission_id} must be claimed before rejection (current status: {submission.status})")
|
|
|
|
# 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 not in ("PENDING", "CLAIMED"):
|
|
raise ValueError(f"Submission {submission_id} is not pending or claimed for 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 - must claim first then approve
|
|
try:
|
|
submission.claim(user=submitter)
|
|
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 using unified EditSubmission with PHOTO type
|
|
submission = EditSubmission(
|
|
content_type=ContentType.objects.get_for_model(content_object),
|
|
object_id=content_object.pk,
|
|
submission_type="PHOTO",
|
|
changes={}, # Photos don't have field changes
|
|
reason="Photo submission",
|
|
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
|