mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08: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:
496
django/api/v1/endpoints/moderation.py
Normal file
496
django/api/v1/endpoints/moderation.py
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user