mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 09:11:13 -05:00
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.
This commit is contained in:
587
django/apps/moderation/services.py
Normal file
587
django/apps/moderation/services.py
Normal file
@@ -0,0 +1,587 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user