Files
thrilltrack-explorer/django-backend/api/v1/endpoints/moderation.py

551 lines
18 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 apps.users.permissions import jwt_auth, require_auth
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}, auth=jwt_auth)
@require_auth
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.
**Authentication:** Required
"""
user = request.auth
if not user or not user.is_authenticated:
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}, auth=jwt_auth)
@require_auth
def delete_submission(request, submission_id: UUID):
"""
Delete a submission (only if draft/pending and owned by user).
**Authentication:** Required
"""
user = request.auth
if not user or not user.is_authenticated:
return 401, {'detail': 'Authentication required'}
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},
auth=jwt_auth
)
@require_auth
def start_review(request, submission_id: UUID, data: StartReviewRequest):
"""
Start reviewing a submission (lock it for 15 minutes).
Only moderators can start reviews.
**Authentication:** Required (Moderator role)
"""
user = request.auth
if not user or not user.is_authenticated:
return 401, {'detail': 'Authentication required'}
# Check moderator permission
if not hasattr(user, 'role') or not user.role.is_moderator:
return 403, {'detail': 'Moderator permission required'}
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},
auth=jwt_auth
)
@require_auth
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.
**Authentication:** Required (Moderator role)
"""
user = request.auth
if not user or not user.is_authenticated:
return 401, {'detail': 'Authentication required'}
# Check moderator permission
if not hasattr(user, 'role') or not user.role.is_moderator:
return 403, {'detail': 'Moderator permission required'}
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},
auth=jwt_auth
)
@require_auth
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.
**Authentication:** Required (Moderator role)
"""
user = request.auth
if not user or not user.is_authenticated:
return 401, {'detail': 'Authentication required'}
# Check moderator permission
if not hasattr(user, 'role') or not user.role.is_moderator:
return 403, {'detail': 'Moderator permission required'}
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},
auth=jwt_auth
)
@require_auth
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.
**Authentication:** Required (Moderator role)
"""
user = request.auth
if not user or not user.is_authenticated:
return 401, {'detail': 'Authentication required'}
# Check moderator permission
if not hasattr(user, 'role') or not user.role.is_moderator:
return 403, {'detail': 'Moderator permission required'}
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},
auth=jwt_auth
)
@require_auth
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.
**Authentication:** Required (Moderator role)
"""
user = request.auth
if not user or not user.is_authenticated:
return 401, {'detail': 'Authentication required'}
# Check moderator permission
if not hasattr(user, 'role') or not user.role.is_moderator:
return 403, {'detail': 'Moderator permission required'}
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, auth=jwt_auth)
@require_auth
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.
**Authentication:** Required
"""
user = request.auth
if not user or not user.is_authenticated:
return {'items': [], 'total': 0, 'page': page, 'page_size': page_size, 'total_pages': 0}
# 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,
}