mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 09:31:12 -05:00
Add ReviewEvent model and ReviewSubmissionService for review management
- Created a new ReviewEvent model to track review events with fields for content, rating, moderation status, and timestamps. - Added ForeignKey relationships to connect ReviewEvent with ContentSubmission, User, and Review. - Implemented ReviewSubmissionService to handle review submissions, including creation, updates, and moderation workflows. - Introduced atomic transactions to ensure data integrity during review submissions and updates. - Added logging for review submission and moderation actions for better traceability. - Implemented validation to prevent duplicate reviews and ensure only the review owner can update their review.
This commit is contained in:
@@ -11,7 +11,6 @@ from .endpoints.ride_models import router as ride_models_router
|
||||
from .endpoints.parks import router as parks_router
|
||||
from .endpoints.rides import router as rides_router
|
||||
from .endpoints.moderation import router as moderation_router
|
||||
from .endpoints.versioning import router as versioning_router
|
||||
from .endpoints.auth import router as auth_router
|
||||
from .endpoints.photos import router as photos_router
|
||||
from .endpoints.search import router as search_router
|
||||
@@ -101,9 +100,6 @@ api.add_router("/rides", rides_router)
|
||||
# Add moderation router
|
||||
api.add_router("/moderation", moderation_router)
|
||||
|
||||
# Add versioning router
|
||||
api.add_router("", versioning_router) # Versioning endpoints are nested under entity paths
|
||||
|
||||
# Add photos router
|
||||
api.add_router("", photos_router) # Photos endpoints include both /photos and entity-nested routes
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ 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,
|
||||
@@ -109,20 +110,20 @@ def _get_entity(entity_type: str, entity_id: UUID):
|
||||
# Submission Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.post('/submissions', response={201: ContentSubmissionOut, 400: ErrorResponse, 401: ErrorResponse})
|
||||
@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.
|
||||
"""
|
||||
# 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:
|
||||
**Authentication:** Required
|
||||
"""
|
||||
user = request.auth
|
||||
|
||||
if not user or not user.is_authenticated:
|
||||
return 401, {'detail': 'Authentication required'}
|
||||
|
||||
try:
|
||||
@@ -227,14 +228,18 @@ def get_submission(request, submission_id: UUID):
|
||||
return 404, {'detail': 'Submission not found'}
|
||||
|
||||
|
||||
@router.delete('/submissions/{submission_id}', response={204: None, 403: ErrorResponse, 404: ErrorResponse})
|
||||
@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
|
||||
"""
|
||||
# TODO: Get current user from request
|
||||
from apps.users.models import User
|
||||
user = User.objects.first() # TEMP
|
||||
user = request.auth
|
||||
|
||||
if not user or not user.is_authenticated:
|
||||
return 401, {'detail': 'Authentication required'}
|
||||
|
||||
try:
|
||||
ModerationService.delete_submission(submission_id, user)
|
||||
@@ -254,17 +259,26 @@ def delete_submission(request, submission_id: UUID):
|
||||
|
||||
@router.post(
|
||||
'/submissions/{submission_id}/start-review',
|
||||
response={200: ContentSubmissionOut, 400: ErrorResponse, 403: ErrorResponse, 404: ErrorResponse}
|
||||
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)
|
||||
"""
|
||||
# TODO: Get current user (moderator) from request
|
||||
from apps.users.models import User
|
||||
user = User.objects.first() # TEMP
|
||||
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)
|
||||
@@ -280,18 +294,27 @@ def start_review(request, submission_id: UUID, data: StartReviewRequest):
|
||||
|
||||
@router.post(
|
||||
'/submissions/{submission_id}/approve',
|
||||
response={200: ApprovalResponse, 400: ErrorResponse, 403: ErrorResponse, 404: ErrorResponse}
|
||||
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)
|
||||
"""
|
||||
# TODO: Get current user (moderator) from request
|
||||
from apps.users.models import User
|
||||
user = User.objects.first() # TEMP
|
||||
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)
|
||||
@@ -312,18 +335,27 @@ def approve_submission(request, submission_id: UUID, data: ApproveRequest):
|
||||
|
||||
@router.post(
|
||||
'/submissions/{submission_id}/approve-selective',
|
||||
response={200: SelectiveApprovalResponse, 400: ErrorResponse, 403: ErrorResponse, 404: ErrorResponse}
|
||||
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)
|
||||
"""
|
||||
# TODO: Get current user (moderator) from request
|
||||
from apps.users.models import User
|
||||
user = User.objects.first() # TEMP
|
||||
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(
|
||||
@@ -348,18 +380,27 @@ def approve_selective(request, submission_id: UUID, data: ApproveSelectiveReques
|
||||
|
||||
@router.post(
|
||||
'/submissions/{submission_id}/reject',
|
||||
response={200: ApprovalResponse, 400: ErrorResponse, 403: ErrorResponse, 404: ErrorResponse}
|
||||
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)
|
||||
"""
|
||||
# TODO: Get current user (moderator) from request
|
||||
from apps.users.models import User
|
||||
user = User.objects.first() # TEMP
|
||||
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)
|
||||
@@ -380,17 +421,26 @@ def reject_submission(request, submission_id: UUID, data: RejectRequest):
|
||||
|
||||
@router.post(
|
||||
'/submissions/{submission_id}/reject-selective',
|
||||
response={200: SelectiveRejectionResponse, 400: ErrorResponse, 403: ErrorResponse, 404: ErrorResponse}
|
||||
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)
|
||||
"""
|
||||
# TODO: Get current user (moderator) from request
|
||||
from apps.users.models import User
|
||||
user = User.objects.first() # TEMP
|
||||
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(
|
||||
@@ -456,16 +506,20 @@ def get_reviewing_queue(request, page: int = 1, page_size: int = 50):
|
||||
return list_submissions(request, status='reviewing', page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get('/queue/my-submissions', response=SubmissionListOut)
|
||||
@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
|
||||
"""
|
||||
# TODO: Get current user from request
|
||||
from apps.users.models import User
|
||||
user = User.objects.first() # TEMP
|
||||
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)
|
||||
|
||||
@@ -15,6 +15,7 @@ from ninja.pagination import paginate, PageNumberPagination
|
||||
import logging
|
||||
|
||||
from apps.reviews.models import Review, ReviewHelpfulVote
|
||||
from apps.reviews.services import ReviewSubmissionService
|
||||
from apps.entities.models import Park, Ride
|
||||
from apps.users.permissions import jwt_auth, require_auth
|
||||
from ..schemas import (
|
||||
@@ -98,7 +99,7 @@ def _serialize_review(review: Review, user=None) -> dict:
|
||||
@require_auth
|
||||
def create_review(request, data: ReviewCreateSchema):
|
||||
"""
|
||||
Create a new review for a park or ride.
|
||||
Create a new review for a park or ride through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
@@ -111,9 +112,13 @@ def create_review(request, data: ReviewCreateSchema):
|
||||
- visit_date: Optional visit date
|
||||
- wait_time_minutes: Optional wait time
|
||||
|
||||
**Returns:** Created review (pending moderation)
|
||||
**Returns:** Created review or submission confirmation
|
||||
|
||||
**Note:** Reviews automatically enter moderation workflow.
|
||||
**Flow:**
|
||||
- Moderators: Review created immediately (bypass moderation)
|
||||
- Regular users: Submission created, enters moderation queue
|
||||
|
||||
**Note:** All reviews flow through ContentSubmission pipeline.
|
||||
Users can only create one review per entity.
|
||||
"""
|
||||
try:
|
||||
@@ -122,37 +127,31 @@ def create_review(request, data: ReviewCreateSchema):
|
||||
# Get and validate entity
|
||||
entity, content_type = _get_entity(data.entity_type, data.entity_id)
|
||||
|
||||
# Check for duplicate review
|
||||
existing = Review.objects.filter(
|
||||
# Create review through Sacred Pipeline
|
||||
submission, review = ReviewSubmissionService.create_review_submission(
|
||||
user=user,
|
||||
content_type=content_type,
|
||||
object_id=entity.id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return 409, {
|
||||
'detail': f"You have already reviewed this {data.entity_type}. "
|
||||
f"Use PUT /reviews/{existing.id}/ to update your review."
|
||||
}
|
||||
|
||||
# Create review
|
||||
review = Review.objects.create(
|
||||
user=user,
|
||||
content_type=content_type,
|
||||
object_id=entity.id,
|
||||
entity=entity,
|
||||
rating=data.rating,
|
||||
title=data.title,
|
||||
content=data.content,
|
||||
rating=data.rating,
|
||||
visit_date=data.visit_date,
|
||||
wait_time_minutes=data.wait_time_minutes,
|
||||
moderation_status=Review.MODERATION_PENDING,
|
||||
source='api'
|
||||
)
|
||||
|
||||
logger.info(f"Review created: {review.id} by {user.email} for {data.entity_type} {entity.id}")
|
||||
# If moderator bypass happened, Review was created immediately
|
||||
if review:
|
||||
logger.info(f"Review created (moderator): {review.id} by {user.email}")
|
||||
review_data = _serialize_review(review, user)
|
||||
return 201, review_data
|
||||
|
||||
# Serialize and return
|
||||
review_data = _serialize_review(review, user)
|
||||
return 201, review_data
|
||||
# Regular user: submission pending moderation
|
||||
logger.info(f"Review submission created: {submission.id} by {user.email}")
|
||||
return 201, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': 'pending_moderation',
|
||||
'message': 'Review submitted for moderation. You will be notified when it is approved.',
|
||||
}
|
||||
|
||||
except ValidationError as e:
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
@@ -467,58 +467,6 @@ class SubmissionListOut(BaseModel):
|
||||
total_pages: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Versioning Schemas
|
||||
# ============================================================================
|
||||
|
||||
class EntityVersionSchema(TimestampSchema):
|
||||
"""Schema for entity version output."""
|
||||
id: UUID
|
||||
entity_type: str
|
||||
entity_id: UUID
|
||||
entity_name: str
|
||||
version_number: int
|
||||
change_type: str
|
||||
snapshot: dict
|
||||
changed_fields: dict
|
||||
changed_by_id: Optional[UUID] = None
|
||||
changed_by_email: Optional[str] = None
|
||||
submission_id: Optional[UUID] = None
|
||||
comment: Optional[str] = None
|
||||
diff_summary: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class VersionHistoryResponseSchema(BaseModel):
|
||||
"""Response schema for version history."""
|
||||
entity_id: str
|
||||
entity_type: str
|
||||
entity_name: str
|
||||
total_versions: int
|
||||
versions: List[EntityVersionSchema]
|
||||
|
||||
|
||||
class VersionDiffSchema(BaseModel):
|
||||
"""Schema for version diff response."""
|
||||
entity_id: str
|
||||
entity_type: str
|
||||
entity_name: str
|
||||
version_number: int
|
||||
version_date: datetime
|
||||
differences: dict
|
||||
changed_field_count: int
|
||||
|
||||
|
||||
class VersionComparisonSchema(BaseModel):
|
||||
"""Schema for comparing two versions."""
|
||||
version1: EntityVersionSchema
|
||||
version2: EntityVersionSchema
|
||||
differences: dict
|
||||
changed_field_count: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Generic Utility Schemas
|
||||
# ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user