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:
pacnpal
2025-11-08 15:34:04 -05:00
parent 9c46ef8b03
commit d6ff4cc3a3
335 changed files with 61926 additions and 73 deletions

View File

@@ -0,0 +1,496 @@
"""
Moderation API endpoints.
Provides REST API for content submission and moderation workflow.
"""
from typing import List, Optional
from uuid import UUID
from ninja import Router
from django.shortcuts import get_object_or_404
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError, PermissionDenied
from apps.moderation.models import ContentSubmission, SubmissionItem
from apps.moderation.services import ModerationService
from api.v1.schemas import (
ContentSubmissionCreate,
ContentSubmissionOut,
ContentSubmissionDetail,
SubmissionListOut,
StartReviewRequest,
ApproveRequest,
ApproveSelectiveRequest,
RejectRequest,
RejectSelectiveRequest,
ApprovalResponse,
SelectiveApprovalResponse,
SelectiveRejectionResponse,
ErrorResponse,
)
router = Router(tags=['Moderation'])
# ============================================================================
# Helper Functions
# ============================================================================
def _submission_to_dict(submission: ContentSubmission) -> dict:
"""Convert submission model to dict for schema."""
return {
'id': submission.id,
'status': submission.status,
'submission_type': submission.submission_type,
'title': submission.title,
'description': submission.description or '',
'entity_type': submission.entity_type.model,
'entity_id': submission.entity_id,
'user_id': submission.user.id,
'user_email': submission.user.email,
'locked_by_id': submission.locked_by.id if submission.locked_by else None,
'locked_by_email': submission.locked_by.email if submission.locked_by else None,
'locked_at': submission.locked_at,
'reviewed_by_id': submission.reviewed_by.id if submission.reviewed_by else None,
'reviewed_by_email': submission.reviewed_by.email if submission.reviewed_by else None,
'reviewed_at': submission.reviewed_at,
'rejection_reason': submission.rejection_reason or '',
'source': submission.source,
'metadata': submission.metadata,
'items_count': submission.get_items_count(),
'approved_items_count': submission.get_approved_items_count(),
'rejected_items_count': submission.get_rejected_items_count(),
'created': submission.created,
'modified': submission.modified,
}
def _item_to_dict(item: SubmissionItem) -> dict:
"""Convert submission item model to dict for schema."""
return {
'id': item.id,
'submission_id': item.submission.id,
'field_name': item.field_name,
'field_label': item.field_label or item.field_name,
'old_value': item.old_value,
'new_value': item.new_value,
'change_type': item.change_type,
'is_required': item.is_required,
'order': item.order,
'status': item.status,
'reviewed_by_id': item.reviewed_by.id if item.reviewed_by else None,
'reviewed_by_email': item.reviewed_by.email if item.reviewed_by else None,
'reviewed_at': item.reviewed_at,
'rejection_reason': item.rejection_reason or '',
'old_value_display': item.old_value_display,
'new_value_display': item.new_value_display,
'created': item.created,
'modified': item.modified,
}
def _get_entity(entity_type: str, entity_id: UUID):
"""Get entity instance from type string and ID."""
# Map entity type strings to models
type_map = {
'park': 'entities.Park',
'ride': 'entities.Ride',
'company': 'entities.Company',
'ridemodel': 'entities.RideModel',
}
app_label, model = type_map.get(entity_type.lower(), '').split('.')
content_type = ContentType.objects.get(app_label=app_label, model=model.lower())
model_class = content_type.model_class()
return get_object_or_404(model_class, id=entity_id)
# ============================================================================
# Submission Endpoints
# ============================================================================
@router.post('/submissions', response={201: ContentSubmissionOut, 400: ErrorResponse, 401: ErrorResponse})
def create_submission(request, data: ContentSubmissionCreate):
"""
Create a new content submission.
Creates a submission with multiple items representing field changes.
If auto_submit is True, the submission is immediately moved to pending state.
"""
# TODO: Require authentication
# For now, use a test user or get from request
from apps.users.models import User
user = User.objects.first() # TEMP: Get first user for testing
if not user:
return 401, {'detail': 'Authentication required'}
try:
# Get entity
entity = _get_entity(data.entity_type, data.entity_id)
# Prepare items data
items_data = [
{
'field_name': item.field_name,
'field_label': item.field_label,
'old_value': item.old_value,
'new_value': item.new_value,
'change_type': item.change_type,
'is_required': item.is_required,
'order': item.order,
}
for item in data.items
]
# Create submission
submission = ModerationService.create_submission(
user=user,
entity=entity,
submission_type=data.submission_type,
title=data.title,
description=data.description or '',
items_data=items_data,
metadata=data.metadata,
auto_submit=data.auto_submit,
source='api'
)
return 201, _submission_to_dict(submission)
except Exception as e:
return 400, {'detail': str(e)}
@router.get('/submissions', response=SubmissionListOut)
def list_submissions(
request,
status: Optional[str] = None,
page: int = 1,
page_size: int = 50
):
"""
List content submissions with optional filtering.
Query Parameters:
- status: Filter by status (draft, pending, reviewing, approved, rejected)
- page: Page number (default: 1)
- page_size: Items per page (default: 50, max: 100)
"""
# Validate page_size
page_size = min(page_size, 100)
offset = (page - 1) * page_size
# Get submissions
submissions = ModerationService.get_queue(
status=status,
limit=page_size,
offset=offset
)
# Get total count
total_queryset = ContentSubmission.objects.all()
if status:
total_queryset = total_queryset.filter(status=status)
total = total_queryset.count()
# Calculate total pages
total_pages = (total + page_size - 1) // page_size
# Convert to dicts
items = [_submission_to_dict(sub) for sub in submissions]
return {
'items': items,
'total': total,
'page': page,
'page_size': page_size,
'total_pages': total_pages,
}
@router.get('/submissions/{submission_id}', response={200: ContentSubmissionDetail, 404: ErrorResponse})
def get_submission(request, submission_id: UUID):
"""
Get detailed submission information with all items.
"""
try:
submission = ModerationService.get_submission_details(submission_id)
# Convert to dict with items
data = _submission_to_dict(submission)
data['items'] = [_item_to_dict(item) for item in submission.items.all()]
return 200, data
except ContentSubmission.DoesNotExist:
return 404, {'detail': 'Submission not found'}
@router.delete('/submissions/{submission_id}', response={204: None, 403: ErrorResponse, 404: ErrorResponse})
def delete_submission(request, submission_id: UUID):
"""
Delete a submission (only if draft/pending and owned by user).
"""
# TODO: Get current user from request
from apps.users.models import User
user = User.objects.first() # TEMP
try:
ModerationService.delete_submission(submission_id, user)
return 204, None
except ContentSubmission.DoesNotExist:
return 404, {'detail': 'Submission not found'}
except PermissionDenied as e:
return 403, {'detail': str(e)}
except ValidationError as e:
return 400, {'detail': str(e)}
# ============================================================================
# Review Endpoints
# ============================================================================
@router.post(
'/submissions/{submission_id}/start-review',
response={200: ContentSubmissionOut, 400: ErrorResponse, 403: ErrorResponse, 404: ErrorResponse}
)
def start_review(request, submission_id: UUID, data: StartReviewRequest):
"""
Start reviewing a submission (lock it for 15 minutes).
Only moderators can start reviews.
"""
# TODO: Get current user (moderator) from request
from apps.users.models import User
user = User.objects.first() # TEMP
try:
submission = ModerationService.start_review(submission_id, user)
return 200, _submission_to_dict(submission)
except ContentSubmission.DoesNotExist:
return 404, {'detail': 'Submission not found'}
except PermissionDenied as e:
return 403, {'detail': str(e)}
except ValidationError as e:
return 400, {'detail': str(e)}
@router.post(
'/submissions/{submission_id}/approve',
response={200: ApprovalResponse, 400: ErrorResponse, 403: ErrorResponse, 404: ErrorResponse}
)
def approve_submission(request, submission_id: UUID, data: ApproveRequest):
"""
Approve an entire submission and apply all changes.
Uses atomic transactions - all changes are applied or none are.
Only moderators can approve submissions.
"""
# TODO: Get current user (moderator) from request
from apps.users.models import User
user = User.objects.first() # TEMP
try:
submission = ModerationService.approve_submission(submission_id, user)
return 200, {
'success': True,
'message': 'Submission approved successfully',
'submission': _submission_to_dict(submission)
}
except ContentSubmission.DoesNotExist:
return 404, {'detail': 'Submission not found'}
except PermissionDenied as e:
return 403, {'detail': str(e)}
except ValidationError as e:
return 400, {'detail': str(e)}
@router.post(
'/submissions/{submission_id}/approve-selective',
response={200: SelectiveApprovalResponse, 400: ErrorResponse, 403: ErrorResponse, 404: ErrorResponse}
)
def approve_selective(request, submission_id: UUID, data: ApproveSelectiveRequest):
"""
Approve only specific items in a submission.
Allows moderators to approve some changes while leaving others pending or rejected.
Uses atomic transactions for data integrity.
"""
# TODO: Get current user (moderator) from request
from apps.users.models import User
user = User.objects.first() # TEMP
try:
result = ModerationService.approve_selective(
submission_id,
user,
[str(item_id) for item_id in data.item_ids]
)
return 200, {
'success': True,
'message': f"Approved {result['approved']} of {result['total']} items",
**result
}
except ContentSubmission.DoesNotExist:
return 404, {'detail': 'Submission not found'}
except PermissionDenied as e:
return 403, {'detail': str(e)}
except ValidationError as e:
return 400, {'detail': str(e)}
@router.post(
'/submissions/{submission_id}/reject',
response={200: ApprovalResponse, 400: ErrorResponse, 403: ErrorResponse, 404: ErrorResponse}
)
def reject_submission(request, submission_id: UUID, data: RejectRequest):
"""
Reject an entire submission.
All pending items are rejected with the provided reason.
Only moderators can reject submissions.
"""
# TODO: Get current user (moderator) from request
from apps.users.models import User
user = User.objects.first() # TEMP
try:
submission = ModerationService.reject_submission(submission_id, user, data.reason)
return 200, {
'success': True,
'message': 'Submission rejected',
'submission': _submission_to_dict(submission)
}
except ContentSubmission.DoesNotExist:
return 404, {'detail': 'Submission not found'}
except PermissionDenied as e:
return 403, {'detail': str(e)}
except ValidationError as e:
return 400, {'detail': str(e)}
@router.post(
'/submissions/{submission_id}/reject-selective',
response={200: SelectiveRejectionResponse, 400: ErrorResponse, 403: ErrorResponse, 404: ErrorResponse}
)
def reject_selective(request, submission_id: UUID, data: RejectSelectiveRequest):
"""
Reject only specific items in a submission.
Allows moderators to reject some changes while leaving others pending or approved.
"""
# TODO: Get current user (moderator) from request
from apps.users.models import User
user = User.objects.first() # TEMP
try:
result = ModerationService.reject_selective(
submission_id,
user,
[str(item_id) for item_id in data.item_ids],
data.reason or ''
)
return 200, {
'success': True,
'message': f"Rejected {result['rejected']} of {result['total']} items",
**result
}
except ContentSubmission.DoesNotExist:
return 404, {'detail': 'Submission not found'}
except PermissionDenied as e:
return 403, {'detail': str(e)}
except ValidationError as e:
return 400, {'detail': str(e)}
@router.post(
'/submissions/{submission_id}/unlock',
response={200: ContentSubmissionOut, 404: ErrorResponse}
)
def unlock_submission(request, submission_id: UUID):
"""
Manually unlock a submission.
Removes the review lock. Can be used by moderators or automatically by cleanup tasks.
"""
try:
submission = ModerationService.unlock_submission(submission_id)
return 200, _submission_to_dict(submission)
except ContentSubmission.DoesNotExist:
return 404, {'detail': 'Submission not found'}
# ============================================================================
# Queue Endpoints
# ============================================================================
@router.get('/queue/pending', response=SubmissionListOut)
def get_pending_queue(request, page: int = 1, page_size: int = 50):
"""
Get pending submissions queue.
Returns all submissions awaiting review.
"""
return list_submissions(request, status='pending', page=page, page_size=page_size)
@router.get('/queue/reviewing', response=SubmissionListOut)
def get_reviewing_queue(request, page: int = 1, page_size: int = 50):
"""
Get submissions currently under review.
Returns all submissions being reviewed by moderators.
"""
return list_submissions(request, status='reviewing', page=page, page_size=page_size)
@router.get('/queue/my-submissions', response=SubmissionListOut)
def get_my_submissions(request, page: int = 1, page_size: int = 50):
"""
Get current user's submissions.
Returns all submissions created by the authenticated user.
"""
# TODO: Get current user from request
from apps.users.models import User
user = User.objects.first() # TEMP
# Validate page_size
page_size = min(page_size, 100)
offset = (page - 1) * page_size
# Get user's submissions
submissions = ModerationService.get_queue(
user=user,
limit=page_size,
offset=offset
)
# Get total count
total = ContentSubmission.objects.filter(user=user).count()
# Calculate total pages
total_pages = (total + page_size - 1) // page_size
# Convert to dicts
items = [_submission_to_dict(sub) for sub in submissions]
return {
'items': items,
'total': total,
'page': page,
'page_size': page_size,
'total_pages': total_pages,
}