""" 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.reviews.services import ReviewSubmissionService 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, HistoryListResponse, HistoryEventDetailSchema, HistoryComparisonSchema, HistoryDiffCurrentSchema, FieldHistorySchema, HistoryActivitySummarySchema, RollbackRequestSchema, RollbackResponseSchema, ErrorSchema, ) from ..services.history_service import HistoryService 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 through the Sacred Pipeline. **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 or submission confirmation **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: user = request.auth # Get and validate entity entity, content_type = _get_entity(data.entity_type, data.entity_id) # Create review through Sacred Pipeline submission, review = ReviewSubmissionService.create_review_submission( user=user, entity=entity, rating=data.rating, title=data.title, content=data.content, visit_date=data.visit_date, wait_time_minutes=data.wait_time_minutes, source='api' ) # 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 # 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)} 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, } # ============================================================================ # History Endpoints # ============================================================================ @router.get( '/{review_id}/history/', response={200: HistoryListResponse, 404: ErrorSchema}, summary="Get review history", description="Get historical changes for a review" ) def get_review_history( request, review_id: int, page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=100), date_from: Optional[str] = Query(None, description="Filter from date (YYYY-MM-DD)"), date_to: Optional[str] = Query(None, description="Filter to date (YYYY-MM-DD)") ): """Get history for a review.""" from datetime import datetime # Verify review exists review = get_object_or_404(Review, id=review_id) # Parse dates if provided date_from_obj = datetime.fromisoformat(date_from).date() if date_from else None date_to_obj = datetime.fromisoformat(date_to).date() if date_to else None # Get history offset = (page - 1) * page_size events, accessible_count = HistoryService.get_history( 'review', str(review_id), request.user, date_from=date_from_obj, date_to=date_to_obj, limit=page_size, offset=offset ) # Format events formatted_events = [] for event in events: formatted_events.append({ 'id': event['id'], 'timestamp': event['timestamp'], 'operation': event['operation'], 'snapshot': event['snapshot'], 'changed_fields': event.get('changed_fields'), 'change_summary': event.get('change_summary', ''), 'can_rollback': HistoryService.can_rollback(request.user) }) # Calculate pagination total_pages = (accessible_count + page_size - 1) // page_size return { 'entity_id': str(review_id), 'entity_type': 'review', 'entity_name': f"Review by {review.user.username}", 'total_events': accessible_count, 'accessible_events': accessible_count, 'access_limited': HistoryService.is_access_limited(request.user), 'access_reason': HistoryService.get_access_reason(request.user), 'events': formatted_events, 'pagination': { 'page': page, 'page_size': page_size, 'total_pages': total_pages, 'total_items': accessible_count } } @router.get( '/{review_id}/history/{event_id}/', response={200: HistoryEventDetailSchema, 404: ErrorSchema}, summary="Get specific review history event", description="Get detailed information about a specific historical event" ) def get_review_history_event(request, review_id: int, event_id: int): """Get a specific history event for a review.""" review = get_object_or_404(Review, id=review_id) event = HistoryService.get_event('review', event_id, request.user) if not event: return 404, {"error": "Event not found or not accessible"} return { 'id': event['id'], 'timestamp': event['timestamp'], 'operation': event['operation'], 'entity_id': str(review_id), 'entity_type': 'review', 'entity_name': f"Review by {review.user.username}", 'snapshot': event['snapshot'], 'changed_fields': event.get('changed_fields'), 'metadata': event.get('metadata', {}), 'can_rollback': HistoryService.can_rollback(request.user), 'rollback_preview': None } @router.get( '/{review_id}/history/compare/', response={200: HistoryComparisonSchema, 400: ErrorSchema, 404: ErrorSchema}, summary="Compare two review history events", description="Compare two historical events for a review" ) def compare_review_history( request, review_id: int, event1: int = Query(..., description="First event ID"), event2: int = Query(..., description="Second event ID") ): """Compare two historical events for a review.""" review = get_object_or_404(Review, id=review_id) try: comparison = HistoryService.compare_events( 'review', event1, event2, request.user ) if not comparison: return 404, {"error": "One or both events not found"} return { 'entity_id': str(review_id), 'entity_type': 'review', 'entity_name': f"Review by {review.user.username}", 'event1': comparison['event1'], 'event2': comparison['event2'], 'differences': comparison['differences'], 'changed_field_count': comparison['changed_field_count'], 'unchanged_field_count': comparison['unchanged_field_count'], 'time_between': comparison['time_between'] } except ValueError as e: return 400, {"error": str(e)} @router.get( '/{review_id}/history/{event_id}/diff-current/', response={200: HistoryDiffCurrentSchema, 404: ErrorSchema}, summary="Compare historical event with current state", description="Compare a historical event with the current review state" ) def diff_review_history_with_current(request, review_id: int, event_id: int): """Compare historical event with current review state.""" review = get_object_or_404(Review, id=review_id) try: diff = HistoryService.compare_with_current( 'review', event_id, review, request.user ) if not diff: return 404, {"error": "Event not found"} return { 'entity_id': str(review_id), 'entity_type': 'review', 'entity_name': f"Review by {review.user.username}", 'event': diff['event'], 'current_state': diff['current_state'], 'differences': diff['differences'], 'changed_field_count': diff['changed_field_count'], 'time_since': diff['time_since'] } except ValueError as e: return 404, {"error": str(e)} @router.post( '/{review_id}/history/{event_id}/rollback/', response={200: RollbackResponseSchema, 400: ErrorSchema, 403: ErrorSchema}, summary="Rollback review to historical state", description="Rollback review to a historical state (Moderators/Admins only)" ) def rollback_review(request, review_id: int, event_id: int, payload: RollbackRequestSchema): """ Rollback review to a historical state. **Permission:** Moderators, Admins, Superusers only """ # Check authentication if not request.user or not request.user.is_authenticated: return 401, {"error": "Authentication required"} # Check rollback permission if not HistoryService.can_rollback(request.user): return 403, {"error": "Only moderators and administrators can perform rollbacks"} review = get_object_or_404(Review, id=review_id) try: result = HistoryService.rollback_to_event( review, 'review', event_id, request.user, fields=payload.fields, comment=payload.comment, create_backup=payload.create_backup ) return result except (ValueError, PermissionError) as e: return 400, {"error": str(e)} @router.get( '/{review_id}/history/field/{field_name}/', response={200: FieldHistorySchema, 404: ErrorSchema}, summary="Get field-specific history", description="Get history of changes to a specific review field" ) def get_review_field_history(request, review_id: int, field_name: str): """Get history of changes to a specific review field.""" review = get_object_or_404(Review, id=review_id) history = HistoryService.get_field_history( 'review', str(review_id), field_name, request.user ) return { 'entity_id': str(review_id), 'entity_type': 'review', 'entity_name': f"Review by {review.user.username}", 'field': field_name, 'field_type': 'CharField', # Could introspect this **history } @router.get( '/{review_id}/history/summary/', response={200: HistoryActivitySummarySchema, 404: ErrorSchema}, summary="Get review activity summary", description="Get activity summary for a review" ) def get_review_activity_summary(request, review_id: int): """Get activity summary for a review.""" review = get_object_or_404(Review, id=review_id) summary = HistoryService.get_activity_summary( 'review', str(review_id), request.user ) return { 'entity_id': str(review_id), 'entity_type': 'review', 'entity_name': f"Review by {review.user.username}", **summary }