mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 07: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.
497 lines
16 KiB
Python
497 lines
16 KiB
Python
"""
|
|
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,
|
|
}
|