Files
thrilltrack-explorer/django/apps/moderation/services.py
pacnpal d6ff4cc3a3 Add email templates for user notifications and account management
- Created a base email template (base.html) for consistent styling across all emails.
- Added moderation approval email template (moderation_approved.html) to notify users of approved submissions.
- Added moderation rejection email template (moderation_rejected.html) to inform users of required changes for their submissions.
- Created password reset email template (password_reset.html) for users requesting to reset their passwords.
- Developed a welcome email template (welcome.html) to greet new users and provide account details and tips for using ThrillWiki.
2025-11-08 15:34:04 -05:00

588 lines
19 KiB
Python

"""
Moderation services for ThrillWiki.
This module provides business logic for the content moderation workflow:
- Creating submissions
- Starting reviews with locks
- Approving submissions with atomic transactions
- Selective approval of individual items
- Rejecting submissions
- Unlocking expired submissions
"""
import logging
from datetime import timedelta
from django.db import transaction
from django.utils import timezone
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError, PermissionDenied
from apps.moderation.models import ContentSubmission, SubmissionItem, ModerationLock
logger = logging.getLogger(__name__)
class ModerationService:
"""
Service class for moderation operations.
All public methods use atomic transactions to ensure data integrity.
"""
@staticmethod
@transaction.atomic
def create_submission(
user,
entity,
submission_type,
title,
description='',
items_data=None,
metadata=None,
auto_submit=True,
**kwargs
):
"""
Create a new content submission with items.
Args:
user: User creating the submission
entity: Entity being modified (Park, Ride, Company, etc.)
submission_type: 'create', 'update', or 'delete'
title: Brief description of changes
description: Detailed description (optional)
items_data: List of dicts with item details:
[
{
'field_name': 'name',
'field_label': 'Park Name',
'old_value': 'Old Name',
'new_value': 'New Name',
'change_type': 'modify',
'is_required': False,
'order': 0
},
...
]
metadata: Additional metadata dict
auto_submit: Whether to automatically submit (move to pending state)
**kwargs: Additional submission fields (source, ip_address, user_agent)
Returns:
ContentSubmission instance
Raises:
ValidationError: If validation fails
"""
# Get ContentType for entity
entity_type = ContentType.objects.get_for_model(entity)
# Create submission
submission = ContentSubmission.objects.create(
user=user,
entity_type=entity_type,
entity_id=entity.id,
submission_type=submission_type,
title=title,
description=description,
metadata=metadata or {},
source=kwargs.get('source', 'web'),
ip_address=kwargs.get('ip_address'),
user_agent=kwargs.get('user_agent', '')
)
# Create submission items
if items_data:
for item_data in items_data:
SubmissionItem.objects.create(
submission=submission,
field_name=item_data['field_name'],
field_label=item_data.get('field_label', item_data['field_name']),
old_value=item_data.get('old_value'),
new_value=item_data.get('new_value'),
change_type=item_data.get('change_type', 'modify'),
is_required=item_data.get('is_required', False),
order=item_data.get('order', 0)
)
# Auto-submit if requested
if auto_submit:
submission.submit()
submission.save()
return submission
@staticmethod
@transaction.atomic
def start_review(submission_id, reviewer):
"""
Start reviewing a submission (lock it).
Args:
submission_id: UUID of submission
reviewer: User starting the review
Returns:
ContentSubmission instance
Raises:
ValidationError: If submission cannot be reviewed
PermissionDenied: If user lacks permission
"""
submission = ContentSubmission.objects.select_for_update().get(id=submission_id)
# Check if user has permission to review
if not ModerationService._can_moderate(reviewer):
raise PermissionDenied("User does not have moderation permission")
# Check if submission is in correct state
if submission.status != ContentSubmission.STATE_PENDING:
raise ValidationError(f"Submission must be pending to start review (current: {submission.status})")
# Check if already locked by another user
if submission.locked_by and submission.locked_by != reviewer:
if submission.is_locked():
raise ValidationError(f"Submission is locked by {submission.locked_by.email}")
# Start review (FSM transition)
submission.start_review(reviewer)
submission.save()
# Create lock record
expires_at = timezone.now() + timedelta(minutes=15)
ModerationLock.objects.update_or_create(
submission=submission,
defaults={
'locked_by': reviewer,
'expires_at': expires_at,
'is_active': True,
'released_at': None
}
)
return submission
@staticmethod
@transaction.atomic
def approve_submission(submission_id, reviewer):
"""
Approve an entire submission and apply all changes.
This method uses atomic transactions to ensure all-or-nothing behavior.
If any part fails, the entire operation is rolled back.
Args:
submission_id: UUID of submission
reviewer: User approving the submission
Returns:
ContentSubmission instance
Raises:
ValidationError: If submission cannot be approved
PermissionDenied: If user lacks permission
"""
submission = ContentSubmission.objects.select_for_update().get(id=submission_id)
# Check permission
if not ModerationService._can_moderate(reviewer):
raise PermissionDenied("User does not have moderation permission")
# Check if submission can be reviewed
if not submission.can_review(reviewer):
raise ValidationError("Submission cannot be reviewed at this time")
# Apply all changes
entity = submission.entity
if not entity:
raise ValidationError("Entity no longer exists")
# Get all pending items
items = submission.items.filter(status='pending')
for item in items:
# Apply change to entity
if item.change_type in ['add', 'modify']:
setattr(entity, item.field_name, item.new_value)
elif item.change_type == 'remove':
setattr(entity, item.field_name, None)
# Mark item as approved
item.approve(reviewer)
# Save entity (this will trigger versioning through lifecycle hooks)
entity.save()
# Approve submission (FSM transition)
submission.approve(reviewer)
submission.save()
# Release lock
try:
lock = ModerationLock.objects.get(submission=submission, is_active=True)
lock.release()
except ModerationLock.DoesNotExist:
pass
# Send notification email asynchronously
try:
from apps.moderation.tasks import send_moderation_notification
send_moderation_notification.delay(str(submission.id), 'approved')
except Exception as e:
# Don't fail the approval if email fails to queue
logger.warning(f"Failed to queue approval notification: {str(e)}")
return submission
@staticmethod
@transaction.atomic
def approve_selective(submission_id, reviewer, item_ids):
"""
Approve only specific items in a submission (selective approval).
This allows moderators to approve some changes while rejecting others.
Uses atomic transactions for data integrity.
Args:
submission_id: UUID of submission
reviewer: User approving the items
item_ids: List of item UUIDs to approve
Returns:
dict with counts: {'approved': N, 'total': M}
Raises:
ValidationError: If submission cannot be reviewed
PermissionDenied: If user lacks permission
"""
submission = ContentSubmission.objects.select_for_update().get(id=submission_id)
# Check permission
if not ModerationService._can_moderate(reviewer):
raise PermissionDenied("User does not have moderation permission")
# Check if submission can be reviewed
if not submission.can_review(reviewer):
raise ValidationError("Submission cannot be reviewed at this time")
# Get entity
entity = submission.entity
if not entity:
raise ValidationError("Entity no longer exists")
# Get items to approve
items_to_approve = submission.items.filter(
id__in=item_ids,
status='pending'
)
approved_count = 0
for item in items_to_approve:
# Apply change to entity
if item.change_type in ['add', 'modify']:
setattr(entity, item.field_name, item.new_value)
elif item.change_type == 'remove':
setattr(entity, item.field_name, None)
# Mark item as approved
item.approve(reviewer)
approved_count += 1
# Save entity if any changes were made
if approved_count > 0:
entity.save()
# Check if all items are now reviewed
pending_count = submission.items.filter(status='pending').count()
if pending_count == 0:
# All items reviewed - mark submission as approved
submission.approve(reviewer)
submission.save()
# Release lock
try:
lock = ModerationLock.objects.get(submission=submission, is_active=True)
lock.release()
except ModerationLock.DoesNotExist:
pass
return {
'approved': approved_count,
'total': submission.items.count(),
'pending': pending_count,
'submission_approved': pending_count == 0
}
@staticmethod
@transaction.atomic
def reject_submission(submission_id, reviewer, reason):
"""
Reject an entire submission.
Args:
submission_id: UUID of submission
reviewer: User rejecting the submission
reason: Reason for rejection
Returns:
ContentSubmission instance
Raises:
ValidationError: If submission cannot be rejected
PermissionDenied: If user lacks permission
"""
submission = ContentSubmission.objects.select_for_update().get(id=submission_id)
# Check permission
if not ModerationService._can_moderate(reviewer):
raise PermissionDenied("User does not have moderation permission")
# Check if submission can be reviewed
if not submission.can_review(reviewer):
raise ValidationError("Submission cannot be reviewed at this time")
# Reject all pending items
items = submission.items.filter(status='pending')
for item in items:
item.reject(reviewer, reason)
# Reject submission (FSM transition)
submission.reject(reviewer, reason)
submission.save()
# Release lock
try:
lock = ModerationLock.objects.get(submission=submission, is_active=True)
lock.release()
except ModerationLock.DoesNotExist:
pass
# Send notification email asynchronously
try:
from apps.moderation.tasks import send_moderation_notification
send_moderation_notification.delay(str(submission.id), 'rejected')
except Exception as e:
# Don't fail the rejection if email fails to queue
logger.warning(f"Failed to queue rejection notification: {str(e)}")
return submission
@staticmethod
@transaction.atomic
def reject_selective(submission_id, reviewer, item_ids, reason=''):
"""
Reject specific items in a submission.
Args:
submission_id: UUID of submission
reviewer: User rejecting the items
item_ids: List of item UUIDs to reject
reason: Reason for rejection (optional)
Returns:
dict with counts: {'rejected': N, 'total': M}
Raises:
ValidationError: If submission cannot be reviewed
PermissionDenied: If user lacks permission
"""
submission = ContentSubmission.objects.select_for_update().get(id=submission_id)
# Check permission
if not ModerationService._can_moderate(reviewer):
raise PermissionDenied("User does not have moderation permission")
# Check if submission can be reviewed
if not submission.can_review(reviewer):
raise ValidationError("Submission cannot be reviewed at this time")
# Get items to reject
items_to_reject = submission.items.filter(
id__in=item_ids,
status='pending'
)
rejected_count = 0
for item in items_to_reject:
item.reject(reviewer, reason)
rejected_count += 1
# Check if all items are now reviewed
pending_count = submission.items.filter(status='pending').count()
if pending_count == 0:
# All items reviewed
approved_count = submission.items.filter(status='approved').count()
if approved_count > 0:
# Some items approved - mark submission as approved
submission.approve(reviewer)
submission.save()
else:
# All items rejected - mark submission as rejected
submission.reject(reviewer, "All items rejected")
submission.save()
# Release lock
try:
lock = ModerationLock.objects.get(submission=submission, is_active=True)
lock.release()
except ModerationLock.DoesNotExist:
pass
return {
'rejected': rejected_count,
'total': submission.items.count(),
'pending': pending_count,
'submission_complete': pending_count == 0
}
@staticmethod
@transaction.atomic
def unlock_submission(submission_id):
"""
Manually unlock a submission.
Args:
submission_id: UUID of submission
Returns:
ContentSubmission instance
"""
submission = ContentSubmission.objects.select_for_update().get(id=submission_id)
if submission.status == ContentSubmission.STATE_REVIEWING:
submission.unlock()
submission.save()
# Release lock record
try:
lock = ModerationLock.objects.get(submission=submission, is_active=True)
lock.release()
except ModerationLock.DoesNotExist:
pass
return submission
@staticmethod
def cleanup_expired_locks():
"""
Cleanup expired locks and unlock submissions.
This should be called periodically (e.g., every 5 minutes via Celery).
Returns:
int: Number of locks cleaned up
"""
return ModerationLock.cleanup_expired()
@staticmethod
def get_queue(status=None, user=None, limit=50, offset=0):
"""
Get moderation queue with filters.
Args:
status: Filter by status (optional)
user: Filter by submitter (optional)
limit: Maximum results
offset: Pagination offset
Returns:
QuerySet of ContentSubmission objects
"""
queryset = ContentSubmission.objects.select_related(
'user',
'entity_type',
'locked_by',
'reviewed_by'
).prefetch_related('items')
if status:
queryset = queryset.filter(status=status)
if user:
queryset = queryset.filter(user=user)
return queryset[offset:offset + limit]
@staticmethod
def get_submission_details(submission_id):
"""
Get full submission details with all items.
Args:
submission_id: UUID of submission
Returns:
ContentSubmission instance with prefetched items
"""
return ContentSubmission.objects.select_related(
'user',
'entity_type',
'locked_by',
'reviewed_by'
).prefetch_related(
'items',
'items__reviewed_by'
).get(id=submission_id)
@staticmethod
def _can_moderate(user):
"""
Check if user has moderation permission.
Args:
user: User to check
Returns:
bool: True if user can moderate
"""
if not user or not user.is_authenticated:
return False
# Check if user is superuser
if user.is_superuser:
return True
# Check if user has moderator or admin role
try:
return user.role.is_moderator
except:
return False
@staticmethod
@transaction.atomic
def delete_submission(submission_id, user):
"""
Delete a submission (only if draft or by owner).
Args:
submission_id: UUID of submission
user: User attempting to delete
Returns:
bool: True if deleted
Raises:
PermissionDenied: If user cannot delete
ValidationError: If submission cannot be deleted
"""
submission = ContentSubmission.objects.select_for_update().get(id=submission_id)
# Check permission
is_owner = submission.user == user
is_moderator = ModerationService._can_moderate(user)
if not (is_owner or is_moderator):
raise PermissionDenied("Only the owner or a moderator can delete this submission")
# Check state
if submission.status not in [ContentSubmission.STATE_DRAFT, ContentSubmission.STATE_PENDING]:
if not is_moderator:
raise ValidationError("Only moderators can delete submissions under review")
# Delete submission (cascades to items and lock)
submission.delete()
return True