mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 09:51:13 -05:00
- 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.
588 lines
19 KiB
Python
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
|