Files
thrilltrack-explorer/django/api/v1/endpoints/reviews.py
pacnpal e38a9aaa41 Add ride credits and top lists endpoints for API v1
- Implement CRUD operations for ride credits, allowing users to log rides, track counts, and view statistics.
- Create endpoints for managing user-created ranked lists of parks, rides, or coasters with custom rankings and notes.
- Introduce pagination for both ride credits and top lists.
- Ensure proper authentication and authorization for modifying user-specific data.
- Add serialization methods for ride credits and top lists to return structured data.
- Include error handling and logging for better traceability of operations.
2025-11-08 16:02:11 -05:00

587 lines
19 KiB
Python

"""
Review endpoints for API v1.
Provides CRUD operations for reviews with moderation workflow integration.
Users can review parks and rides, vote on reviews, and moderators can approve/reject.
"""
from typing import List, Optional
from uuid import UUID
from django.shortcuts import get_object_or_404
from django.db.models import Q, Count, Avg
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from ninja import Router, Query
from ninja.pagination import paginate, PageNumberPagination
import logging
from apps.reviews.models import Review, ReviewHelpfulVote
from apps.entities.models import Park, Ride
from apps.users.permissions import jwt_auth, require_auth
from ..schemas import (
ReviewCreateSchema,
ReviewUpdateSchema,
ReviewOut,
ReviewListOut,
ReviewStatsOut,
VoteRequest,
VoteResponse,
ErrorResponse,
UserSchema,
)
router = Router(tags=["Reviews"])
logger = logging.getLogger(__name__)
class ReviewPagination(PageNumberPagination):
"""Custom pagination for reviews."""
page_size = 50
def _get_entity(entity_type: str, entity_id: UUID):
"""Helper to get and validate entity (Park or Ride)."""
if entity_type == 'park':
return get_object_or_404(Park, id=entity_id), ContentType.objects.get_for_model(Park)
elif entity_type == 'ride':
return get_object_or_404(Ride, id=entity_id), ContentType.objects.get_for_model(Ride)
else:
raise ValidationError(f"Invalid entity_type: {entity_type}")
def _serialize_review(review: Review, user=None) -> dict:
"""Serialize review with computed fields."""
data = {
'id': review.id,
'user': UserSchema(
id=review.user.id,
username=review.user.username,
display_name=review.user.display_name,
avatar_url=review.user.avatar_url,
reputation_score=review.user.reputation_score,
),
'entity_type': review.content_type.model,
'entity_id': str(review.object_id),
'entity_name': str(review.content_object) if review.content_object else 'Unknown',
'title': review.title,
'content': review.content,
'rating': review.rating,
'visit_date': review.visit_date,
'wait_time_minutes': review.wait_time_minutes,
'helpful_votes': review.helpful_votes,
'total_votes': review.total_votes,
'helpful_percentage': review.helpful_percentage,
'moderation_status': review.moderation_status,
'moderated_at': review.moderated_at,
'moderated_by_email': review.moderated_by.email if review.moderated_by else None,
'photo_count': review.photos.count(),
'created': review.created,
'modified': review.modified,
'user_vote': None,
}
# Add user's vote if authenticated
if user and user.is_authenticated:
try:
vote = ReviewHelpfulVote.objects.get(review=review, user=user)
data['user_vote'] = vote.is_helpful
except ReviewHelpfulVote.DoesNotExist:
pass
return data
# ============================================================================
# Main Review CRUD Endpoints
# ============================================================================
@router.post("/", response={201: ReviewOut, 400: ErrorResponse, 409: ErrorResponse}, auth=jwt_auth)
@require_auth
def create_review(request, data: ReviewCreateSchema):
"""
Create a new review for a park or ride.
**Authentication:** Required
**Parameters:**
- entity_type: "park" or "ride"
- entity_id: UUID of park or ride
- title: Review title
- content: Review content (min 10 characters)
- rating: 1-5 stars
- visit_date: Optional visit date
- wait_time_minutes: Optional wait time
**Returns:** Created review (pending moderation)
**Note:** Reviews automatically enter moderation workflow.
Users can only create one review per entity.
"""
try:
user = request.auth
# Get and validate entity
entity, content_type = _get_entity(data.entity_type, data.entity_id)
# Check for duplicate review
existing = Review.objects.filter(
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,
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,
)
logger.info(f"Review created: {review.id} by {user.email} for {data.entity_type} {entity.id}")
# Serialize and return
review_data = _serialize_review(review, user)
return 201, review_data
except ValidationError as e:
return 400, {'detail': str(e)}
except Exception as e:
logger.error(f"Error creating review: {e}")
return 400, {'detail': str(e)}
@router.get("/", response={200: List[ReviewOut]})
@paginate(ReviewPagination)
def list_reviews(
request,
entity_type: Optional[str] = Query(None, description="Filter by entity type: park or ride"),
entity_id: Optional[UUID] = Query(None, description="Filter by specific entity ID"),
user_id: Optional[UUID] = Query(None, description="Filter by user ID"),
rating: Optional[int] = Query(None, ge=1, le=5, description="Filter by rating"),
moderation_status: Optional[str] = Query(None, description="Filter by moderation status"),
ordering: Optional[str] = Query("-created", description="Sort by field")
):
"""
List reviews with optional filtering.
**Authentication:** Optional (only approved reviews shown if not authenticated/not moderator)
**Filters:**
- entity_type: park or ride
- entity_id: Specific park/ride
- user_id: Reviews by specific user
- rating: Filter by star rating
- moderation_status: pending/approved/rejected (moderators only)
- ordering: Sort field (default: -created)
**Returns:** Paginated list of reviews
"""
# Base query with optimizations
queryset = Review.objects.select_related(
'user',
'moderated_by',
'content_type'
).prefetch_related('photos')
# Check if user is authenticated and is moderator
user = request.auth if hasattr(request, 'auth') else None
is_moderator = user and hasattr(user, 'role') and user.role.is_moderator if user else False
# Apply moderation filter
if not is_moderator:
queryset = queryset.filter(moderation_status=Review.MODERATION_APPROVED)
# Apply entity type filter
if entity_type:
if entity_type == 'park':
ct = ContentType.objects.get_for_model(Park)
elif entity_type == 'ride':
ct = ContentType.objects.get_for_model(Ride)
else:
queryset = queryset.none()
queryset = queryset.filter(content_type=ct)
# Apply entity ID filter
if entity_id:
queryset = queryset.filter(object_id=entity_id)
# Apply user filter
if user_id:
queryset = queryset.filter(user_id=user_id)
# Apply rating filter
if rating:
queryset = queryset.filter(rating=rating)
# Apply moderation status filter (moderators only)
if moderation_status and is_moderator:
queryset = queryset.filter(moderation_status=moderation_status)
# Apply ordering
valid_order_fields = ['created', 'modified', 'rating', 'helpful_votes', 'visit_date']
order_field = ordering.lstrip('-')
if order_field in valid_order_fields:
queryset = queryset.order_by(ordering)
else:
queryset = queryset.order_by('-created')
# Serialize reviews
reviews = [_serialize_review(review, user) for review in queryset]
return reviews
@router.get("/{review_id}", response={200: ReviewOut, 404: ErrorResponse})
def get_review(request, review_id: int):
"""
Get a specific review by ID.
**Authentication:** Optional
**Parameters:**
- review_id: Review ID
**Returns:** Review details
**Note:** Only approved reviews are accessible to non-moderators.
"""
user = request.auth if hasattr(request, 'auth') else None
is_moderator = user and hasattr(user, 'role') and user.role.is_moderator if user else False
is_owner = user and Review.objects.filter(id=review_id, user=user).exists() if user else False
review = get_object_or_404(
Review.objects.select_related('user', 'moderated_by', 'content_type').prefetch_related('photos'),
id=review_id
)
# Check access
if not review.is_approved and not is_moderator and not is_owner:
return 404, {'detail': 'Review not found'}
review_data = _serialize_review(review, user)
return 200, review_data
@router.put("/{review_id}", response={200: ReviewOut, 403: ErrorResponse, 404: ErrorResponse}, auth=jwt_auth)
@require_auth
def update_review(request, review_id: int, data: ReviewUpdateSchema):
"""
Update your own review.
**Authentication:** Required (must be review owner)
**Parameters:**
- review_id: Review ID
- data: Fields to update
**Returns:** Updated review
**Note:** Updating a review resets it to pending moderation.
"""
user = request.auth
review = get_object_or_404(
Review.objects.select_related('user', 'content_type'),
id=review_id
)
# Check ownership
if review.user != user:
return 403, {'detail': 'You can only update your own reviews'}
# Update fields
update_data = data.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(review, key, value)
# Reset to pending moderation
review.moderation_status = Review.MODERATION_PENDING
review.moderated_at = None
review.moderated_by = None
review.moderation_notes = ''
review.save()
logger.info(f"Review updated: {review.id} by {user.email}")
review_data = _serialize_review(review, user)
return 200, review_data
@router.delete("/{review_id}", response={204: None, 403: ErrorResponse, 404: ErrorResponse}, auth=jwt_auth)
@require_auth
def delete_review(request, review_id: int):
"""
Delete your own review.
**Authentication:** Required (must be review owner)
**Parameters:**
- review_id: Review ID
**Returns:** No content (204)
"""
user = request.auth
review = get_object_or_404(Review, id=review_id)
# Check ownership
if review.user != user:
return 403, {'detail': 'You can only delete your own reviews'}
logger.info(f"Review deleted: {review.id} by {user.email}")
review.delete()
return 204, None
# ============================================================================
# Voting Endpoint
# ============================================================================
@router.post("/{review_id}/vote", response={200: VoteResponse, 400: ErrorResponse, 404: ErrorResponse}, auth=jwt_auth)
@require_auth
def vote_on_review(request, review_id: int, data: VoteRequest):
"""
Vote on a review (helpful or not helpful).
**Authentication:** Required
**Parameters:**
- review_id: Review ID
- is_helpful: True if helpful, False if not helpful
**Returns:** Updated vote counts
**Note:** Users can change their vote but cannot vote on their own reviews.
"""
user = request.auth
review = get_object_or_404(Review, id=review_id)
# Prevent self-voting
if review.user == user:
return 400, {'detail': 'You cannot vote on your own review'}
# Create or update vote
vote, created = ReviewHelpfulVote.objects.update_or_create(
review=review,
user=user,
defaults={'is_helpful': data.is_helpful}
)
# Refresh review to get updated counts
review.refresh_from_db()
return 200, {
'success': True,
'review_id': review.id,
'helpful_votes': review.helpful_votes,
'total_votes': review.total_votes,
'helpful_percentage': review.helpful_percentage,
}
# ============================================================================
# Entity-Specific Review Endpoints
# ============================================================================
@router.get("/parks/{park_id}", response={200: List[ReviewOut]})
@paginate(ReviewPagination)
def get_park_reviews(
request,
park_id: UUID,
rating: Optional[int] = Query(None, ge=1, le=5),
ordering: Optional[str] = Query("-created")
):
"""
Get all reviews for a specific park.
**Parameters:**
- park_id: Park UUID
- rating: Optional rating filter
- ordering: Sort field (default: -created)
**Returns:** Paginated list of park reviews
"""
park = get_object_or_404(Park, id=park_id)
content_type = ContentType.objects.get_for_model(Park)
user = request.auth if hasattr(request, 'auth') else None
is_moderator = user and hasattr(user, 'role') and user.role.is_moderator if user else False
queryset = Review.objects.filter(
content_type=content_type,
object_id=park.id
).select_related('user', 'moderated_by').prefetch_related('photos')
if not is_moderator:
queryset = queryset.filter(moderation_status=Review.MODERATION_APPROVED)
if rating:
queryset = queryset.filter(rating=rating)
valid_order_fields = ['created', 'modified', 'rating', 'helpful_votes', 'visit_date']
order_field = ordering.lstrip('-')
if order_field in valid_order_fields:
queryset = queryset.order_by(ordering)
else:
queryset = queryset.order_by('-created')
reviews = [_serialize_review(review, user) for review in queryset]
return reviews
@router.get("/rides/{ride_id}", response={200: List[ReviewOut]})
@paginate(ReviewPagination)
def get_ride_reviews(
request,
ride_id: UUID,
rating: Optional[int] = Query(None, ge=1, le=5),
ordering: Optional[str] = Query("-created")
):
"""
Get all reviews for a specific ride.
**Parameters:**
- ride_id: Ride UUID
- rating: Optional rating filter
- ordering: Sort field (default: -created)
**Returns:** Paginated list of ride reviews
"""
ride = get_object_or_404(Ride, id=ride_id)
content_type = ContentType.objects.get_for_model(Ride)
user = request.auth if hasattr(request, 'auth') else None
is_moderator = user and hasattr(user, 'role') and user.role.is_moderator if user else False
queryset = Review.objects.filter(
content_type=content_type,
object_id=ride.id
).select_related('user', 'moderated_by').prefetch_related('photos')
if not is_moderator:
queryset = queryset.filter(moderation_status=Review.MODERATION_APPROVED)
if rating:
queryset = queryset.filter(rating=rating)
valid_order_fields = ['created', 'modified', 'rating', 'helpful_votes', 'visit_date']
order_field = ordering.lstrip('-')
if order_field in valid_order_fields:
queryset = queryset.order_by(ordering)
else:
queryset = queryset.order_by('-created')
reviews = [_serialize_review(review, user) for review in queryset]
return reviews
@router.get("/users/{user_id}", response={200: List[ReviewOut]})
@paginate(ReviewPagination)
def get_user_reviews(
request,
user_id: UUID,
entity_type: Optional[str] = Query(None),
ordering: Optional[str] = Query("-created")
):
"""
Get all reviews by a specific user.
**Parameters:**
- user_id: User UUID
- entity_type: Optional filter (park or ride)
- ordering: Sort field (default: -created)
**Returns:** Paginated list of user's reviews
**Note:** Only approved reviews visible unless viewing own reviews or moderator.
"""
user = request.auth if hasattr(request, 'auth') else None
is_owner = user and str(user.id) == str(user_id) if user else False
is_moderator = user and hasattr(user, 'role') and user.role.is_moderator if user else False
queryset = Review.objects.filter(
user_id=user_id
).select_related('user', 'moderated_by', 'content_type').prefetch_related('photos')
# Filter by moderation status
if not is_owner and not is_moderator:
queryset = queryset.filter(moderation_status=Review.MODERATION_APPROVED)
# Apply entity type filter
if entity_type:
if entity_type == 'park':
ct = ContentType.objects.get_for_model(Park)
elif entity_type == 'ride':
ct = ContentType.objects.get_for_model(Ride)
else:
queryset = queryset.none()
queryset = queryset.filter(content_type=ct)
# Apply ordering
valid_order_fields = ['created', 'modified', 'rating', 'helpful_votes', 'visit_date']
order_field = ordering.lstrip('-')
if order_field in valid_order_fields:
queryset = queryset.order_by(ordering)
else:
queryset = queryset.order_by('-created')
reviews = [_serialize_review(review, user) for review in queryset]
return reviews
# ============================================================================
# Statistics Endpoint
# ============================================================================
@router.get("/stats/{entity_type}/{entity_id}", response={200: ReviewStatsOut, 404: ErrorResponse})
def get_review_stats(request, entity_type: str, entity_id: UUID):
"""
Get review statistics for a park or ride.
**Parameters:**
- entity_type: "park" or "ride"
- entity_id: Entity UUID
**Returns:** Statistics including average rating and distribution
"""
try:
entity, content_type = _get_entity(entity_type, entity_id)
except ValidationError as e:
return 404, {'detail': str(e)}
# Get approved reviews only
reviews = Review.objects.filter(
content_type=content_type,
object_id=entity.id,
moderation_status=Review.MODERATION_APPROVED
)
# Calculate stats
stats = reviews.aggregate(
average_rating=Avg('rating'),
total_reviews=Count('id')
)
# Get rating distribution
distribution = {}
for rating in range(1, 6):
distribution[rating] = reviews.filter(rating=rating).count()
return 200, {
'average_rating': stats['average_rating'] or 0.0,
'total_reviews': stats['total_reviews'] or 0,
'rating_distribution': distribution,
}