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:
pacnpal
2025-11-08 16:49:58 -05:00
parent 618310a87b
commit 9122320e7e
18 changed files with 3170 additions and 171 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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)}

View File

@@ -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
# ============================================================================