From 516c8473774e278fb62216d4123d25f27dabd68c Mon Sep 17 00:00:00 2001 From: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Tue, 16 Sep 2025 20:26:24 -0400 Subject: [PATCH] feat: Add ride photo and review APIs with CRUD operations for parks --- .../apps/api/v1/parks/ride_photos_views.py | 552 ++++++++++++++++++ .../apps/api/v1/parks/ride_reviews_views.py | 380 ++++++++++++ backend/apps/api/v1/parks/urls.py | 15 + .../apps/api/v1/serializers/ride_reviews.py | 219 +++++++ 4 files changed, 1166 insertions(+) create mode 100644 backend/apps/api/v1/parks/ride_photos_views.py create mode 100644 backend/apps/api/v1/parks/ride_reviews_views.py create mode 100644 backend/apps/api/v1/serializers/ride_reviews.py diff --git a/backend/apps/api/v1/parks/ride_photos_views.py b/backend/apps/api/v1/parks/ride_photos_views.py new file mode 100644 index 00000000..9b919030 --- /dev/null +++ b/backend/apps/api/v1/parks/ride_photos_views.py @@ -0,0 +1,552 @@ +""" +Ride photo API views for ThrillWiki API v1 (nested under parks). + +This module contains ride photo ViewSet following the parks pattern for domain consistency. +Provides CRUD operations for ride photos nested under parks/{park_slug}/rides/{ride_slug}/photos/ +""" + +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + pass + +from django.core.exceptions import PermissionDenied +from django.utils import timezone +from drf_spectacular.utils import extend_schema_view, extend_schema +from drf_spectacular.types import OpenApiTypes +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.exceptions import ValidationError, NotFound +from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet + +from apps.rides.models.media import RidePhoto +from apps.rides.models import Ride +from apps.parks.models import Park +from apps.rides.services.media_service import RideMediaService +from apps.api.v1.rides.serializers import ( + RidePhotoOutputSerializer, + RidePhotoCreateInputSerializer, + RidePhotoUpdateInputSerializer, + RidePhotoListOutputSerializer, + RidePhotoApprovalInputSerializer, + RidePhotoStatsOutputSerializer, +) + +logger = logging.getLogger(__name__) + + +@extend_schema_view( + list=extend_schema( + summary="List ride photos", + description="Retrieve a paginated list of ride photos with filtering capabilities.", + responses={200: RidePhotoListOutputSerializer(many=True)}, + tags=["Ride Photos"], + ), + create=extend_schema( + summary="Upload ride photo", + description="Upload a new photo for a ride. Requires authentication.", + request=RidePhotoCreateInputSerializer, + responses={ + 201: RidePhotoOutputSerializer, + 400: OpenApiTypes.OBJECT, + 401: OpenApiTypes.OBJECT, + }, + tags=["Ride Photos"], + ), + retrieve=extend_schema( + summary="Get ride photo details", + description="Retrieve detailed information about a specific ride photo.", + responses={ + 200: RidePhotoOutputSerializer, + 404: OpenApiTypes.OBJECT, + }, + tags=["Ride Photos"], + ), + update=extend_schema( + summary="Update ride photo", + description="Update ride photo information. Requires authentication and ownership or admin privileges.", + request=RidePhotoUpdateInputSerializer, + responses={ + 200: RidePhotoOutputSerializer, + 400: OpenApiTypes.OBJECT, + 401: OpenApiTypes.OBJECT, + 403: OpenApiTypes.OBJECT, + 404: OpenApiTypes.OBJECT, + }, + tags=["Ride Photos"], + ), + partial_update=extend_schema( + summary="Partially update ride photo", + description="Partially update ride photo information. Requires authentication and ownership or admin privileges.", + request=RidePhotoUpdateInputSerializer, + responses={ + 200: RidePhotoOutputSerializer, + 400: OpenApiTypes.OBJECT, + 401: OpenApiTypes.OBJECT, + 403: OpenApiTypes.OBJECT, + 404: OpenApiTypes.OBJECT, + }, + tags=["Ride Photos"], + ), + destroy=extend_schema( + summary="Delete ride photo", + description="Delete a ride photo. Requires authentication and ownership or admin privileges.", + responses={ + 204: None, + 401: OpenApiTypes.OBJECT, + 403: OpenApiTypes.OBJECT, + 404: OpenApiTypes.OBJECT, + }, + tags=["Ride Photos"], + ), +) +class RidePhotoViewSet(ModelViewSet): + """ + ViewSet for managing ride photos with full CRUD operations (nested under parks). + + Provides CRUD operations for ride photos with proper permission checking. + Uses RideMediaService for business logic operations. + Includes advanced features like bulk approval and statistics. + """ + + lookup_field = "id" + + def get_permissions(self): + """Set permissions based on action.""" + if self.action in ['list', 'retrieve', 'stats']: + permission_classes = [AllowAny] + else: + permission_classes = [IsAuthenticated] + return [permission() for permission in permission_classes] + + def get_queryset(self): + """Get photos for the current ride with optimized queries.""" + queryset = RidePhoto.objects.select_related( + "ride", "ride__park", "ride__park__operator", "uploaded_by" + ) + + # Filter by park and ride from URL kwargs + park_slug = self.kwargs.get("park_slug") + ride_slug = self.kwargs.get("ride_slug") + + if park_slug and ride_slug: + try: + park, _ = Park.get_by_slug(park_slug) + ride, _ = Ride.get_by_slug(ride_slug, park=park) + queryset = queryset.filter(ride=ride) + except (Park.DoesNotExist, Ride.DoesNotExist): + # Return empty queryset if park or ride not found + return queryset.none() + + return queryset.order_by("-created_at") + + def get_serializer_class(self): + """Return appropriate serializer based on action.""" + if self.action == "list": + return RidePhotoListOutputSerializer + elif self.action == "create": + return RidePhotoCreateInputSerializer + elif self.action in ["update", "partial_update"]: + return RidePhotoUpdateInputSerializer + else: + return RidePhotoOutputSerializer + + def perform_create(self, serializer): + """Create a new ride photo using RideMediaService.""" + park_slug = self.kwargs.get("park_slug") + ride_slug = self.kwargs.get("ride_slug") + + if not park_slug or not ride_slug: + raise ValidationError("Park and ride slugs are required") + + try: + park, _ = Park.get_by_slug(park_slug) + ride, _ = Ride.get_by_slug(ride_slug, park=park) + except Park.DoesNotExist: + raise NotFound("Park not found") + except Ride.DoesNotExist: + raise NotFound("Ride not found at this park") + + try: + # Use the service to create the photo with proper business logic + photo = RideMediaService.upload_photo( + ride=ride, + image_file=serializer.validated_data["image"], + user=self.request.user, + caption=serializer.validated_data.get("caption", ""), + alt_text=serializer.validated_data.get("alt_text", ""), + photo_type=serializer.validated_data.get("photo_type", "exterior"), + is_primary=serializer.validated_data.get("is_primary", False), + auto_approve=False, # Default to requiring approval + ) + + # Set the instance for the serializer response + serializer.instance = photo + + logger.info(f"Created ride photo {photo.id} for ride {ride.name} by user {self.request.user.username}") + + except Exception as e: + logger.error(f"Error creating ride photo: {e}") + raise ValidationError(f"Failed to create photo: {str(e)}") + + def perform_update(self, serializer): + """Update ride photo with permission checking.""" + instance = self.get_object() + + # Check permissions - allow owner or staff + if not ( + self.request.user == instance.uploaded_by + or getattr(self.request.user, "is_staff", False) + ): + raise PermissionDenied("You can only edit your own photos or be an admin.") + + # Handle primary photo logic using service + if serializer.validated_data.get("is_primary", False): + try: + RideMediaService.set_primary_photo(ride=instance.ride, photo=instance) + # Remove is_primary from validated_data since service handles it + if "is_primary" in serializer.validated_data: + del serializer.validated_data["is_primary"] + except Exception as e: + logger.error(f"Error setting primary photo: {e}") + raise ValidationError(f"Failed to set primary photo: {str(e)}") + + try: + serializer.save() + logger.info(f"Updated ride photo {instance.id} by user {self.request.user.username}") + except Exception as e: + logger.error(f"Error updating ride photo: {e}") + raise ValidationError(f"Failed to update photo: {str(e)}") + + def perform_destroy(self, instance): + """Delete ride photo with permission checking.""" + # Check permissions - allow owner or staff + if not ( + self.request.user == instance.uploaded_by + or getattr(self.request.user, "is_staff", False) + ): + raise PermissionDenied( + "You can only delete your own photos or be an admin." + ) + + try: + # Delete from Cloudflare first if image exists + if instance.image: + try: + from django_cloudflareimages_toolkit.services import CloudflareImagesService + service = CloudflareImagesService() + service.delete_image(instance.image) + logger.info( + f"Successfully deleted ride photo from Cloudflare: {instance.image.cloudflare_id}") + except Exception as e: + logger.error( + f"Failed to delete ride photo from Cloudflare: {str(e)}") + # Continue with database deletion even if Cloudflare deletion fails + + RideMediaService.delete_photo( + instance, deleted_by=self.request.user + ) + + logger.info(f"Deleted ride photo {instance.id} by user {self.request.user.username}") + except Exception as e: + logger.error(f"Error deleting ride photo: {e}") + raise ValidationError(f"Failed to delete photo: {str(e)}") + + @extend_schema( + summary="Set photo as primary", + description="Set this photo as the primary photo for the ride", + responses={ + 200: OpenApiTypes.OBJECT, + 400: OpenApiTypes.OBJECT, + 403: OpenApiTypes.OBJECT, + 404: OpenApiTypes.OBJECT, + }, + tags=["Ride Photos"], + ) + @action(detail=True, methods=["post"]) + def set_primary(self, request, **kwargs): + """Set this photo as the primary photo for the ride.""" + photo = self.get_object() + + # Check permissions - allow owner or staff + if not ( + request.user == photo.uploaded_by + or getattr(request.user, "is_staff", False) + ): + raise PermissionDenied( + "You can only modify your own photos or be an admin." + ) + + try: + success = RideMediaService.set_primary_photo(ride=photo.ride, photo=photo) + + if success: + # Refresh the photo instance + photo.refresh_from_db() + serializer = self.get_serializer(photo) + + return Response( + { + "message": "Photo set as primary successfully", + "photo": serializer.data, + }, + status=status.HTTP_200_OK, + ) + else: + return Response( + {"error": "Failed to set primary photo"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + except Exception as e: + logger.error(f"Error setting primary photo: {e}") + return Response( + {"error": f"Failed to set primary photo: {str(e)}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + @extend_schema( + summary="Bulk approve/reject photos", + description="Bulk approve or reject multiple ride photos (admin only)", + request=RidePhotoApprovalInputSerializer, + responses={ + 200: OpenApiTypes.OBJECT, + 400: OpenApiTypes.OBJECT, + 403: OpenApiTypes.OBJECT, + }, + tags=["Ride Photos"], + ) + @action(detail=False, methods=["post"], permission_classes=[IsAuthenticated]) + def bulk_approve(self, request, **kwargs): + """Bulk approve or reject multiple photos (admin only).""" + if not getattr(request.user, "is_staff", False): + raise PermissionDenied("Only administrators can approve photos.") + + serializer = RidePhotoApprovalInputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + validated_data = getattr(serializer, "validated_data", {}) + photo_ids = validated_data.get("photo_ids") + approve = validated_data.get("approve") + + park_slug = self.kwargs.get("park_slug") + ride_slug = self.kwargs.get("ride_slug") + + if photo_ids is None or approve is None: + return Response( + {"error": "Missing required fields: photo_ids and/or approve."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + # Filter photos to only those belonging to this ride + photos_queryset = RidePhoto.objects.filter(id__in=photo_ids) + if park_slug and ride_slug: + park, _ = Park.get_by_slug(park_slug) + ride, _ = Ride.get_by_slug(ride_slug, park=park) + photos_queryset = photos_queryset.filter(ride=ride) + + updated_count = photos_queryset.update(is_approved=approve) + + return Response( + { + "message": f"Successfully {'approved' if approve else 'rejected'} {updated_count} photos", + "updated_count": updated_count, + }, + status=status.HTTP_200_OK, + ) + + except Exception as e: + logger.error(f"Error in bulk photo approval: {e}") + return Response( + {"error": f"Failed to update photos: {str(e)}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + @extend_schema( + summary="Get ride photo statistics", + description="Get photo statistics for the ride", + responses={ + 200: RidePhotoStatsOutputSerializer, + 404: OpenApiTypes.OBJECT, + 500: OpenApiTypes.OBJECT, + }, + tags=["Ride Photos"], + ) + @action(detail=False, methods=["get"]) + def stats(self, request, **kwargs): + """Get photo statistics for the ride.""" + park_slug = self.kwargs.get("park_slug") + ride_slug = self.kwargs.get("ride_slug") + + if not park_slug or not ride_slug: + return Response( + {"error": "Park and ride slugs are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + park, _ = Park.get_by_slug(park_slug) + ride, _ = Ride.get_by_slug(ride_slug, park=park) + except Park.DoesNotExist: + return Response( + {"error": "Park not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + except Ride.DoesNotExist: + return Response( + {"error": "Ride not found at this park"}, + status=status.HTTP_404_NOT_FOUND, + ) + + try: + stats = RideMediaService.get_photo_stats(ride) + serializer = RidePhotoStatsOutputSerializer(stats) + return Response(serializer.data, status=status.HTTP_200_OK) + + except Exception as e: + logger.error(f"Error getting ride photo stats: {e}") + return Response( + {"error": f"Failed to get photo statistics: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + @extend_schema( + summary="Save Cloudflare image as ride photo", + description="Save a Cloudflare image as a ride photo after direct upload to Cloudflare", + request=OpenApiTypes.OBJECT, + responses={ + 201: RidePhotoOutputSerializer, + 400: OpenApiTypes.OBJECT, + 401: OpenApiTypes.OBJECT, + 404: OpenApiTypes.OBJECT, + }, + tags=["Ride Photos"], + ) + @action(detail=False, methods=["post"]) + def save_image(self, request, **kwargs): + """Save a Cloudflare image as a ride photo after direct upload to Cloudflare.""" + park_slug = self.kwargs.get("park_slug") + ride_slug = self.kwargs.get("ride_slug") + + if not park_slug or not ride_slug: + return Response( + {"error": "Park and ride slugs are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + park, _ = Park.get_by_slug(park_slug) + ride, _ = Ride.get_by_slug(ride_slug, park=park) + except Park.DoesNotExist: + return Response( + {"error": "Park not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + except Ride.DoesNotExist: + return Response( + {"error": "Ride not found at this park"}, + status=status.HTTP_404_NOT_FOUND, + ) + + cloudflare_image_id = request.data.get("cloudflare_image_id") + if not cloudflare_image_id: + return Response( + {"error": "cloudflare_image_id is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + # Import CloudflareImage model and service + from django_cloudflareimages_toolkit.models import CloudflareImage + from django_cloudflareimages_toolkit.services import CloudflareImagesService + + # Always fetch the latest image data from Cloudflare API + try: + # Get image details from Cloudflare API + service = CloudflareImagesService() + image_data = service.get_image(cloudflare_image_id) + + if not image_data: + return Response( + {"error": "Image not found in Cloudflare"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Try to find existing CloudflareImage record by cloudflare_id + cloudflare_image = None + try: + cloudflare_image = CloudflareImage.objects.get( + cloudflare_id=cloudflare_image_id) + + # Update existing record with latest data from Cloudflare + cloudflare_image.status = 'uploaded' + cloudflare_image.uploaded_at = timezone.now() + cloudflare_image.metadata = image_data.get('meta', {}) + # Extract variants from nested result structure + cloudflare_image.variants = image_data.get( + 'result', {}).get('variants', []) + cloudflare_image.cloudflare_metadata = image_data + cloudflare_image.width = image_data.get('width') + cloudflare_image.height = image_data.get('height') + cloudflare_image.format = image_data.get('format', '') + cloudflare_image.save() + + except CloudflareImage.DoesNotExist: + # Create new CloudflareImage record from API response + cloudflare_image = CloudflareImage.objects.create( + cloudflare_id=cloudflare_image_id, + user=request.user, + status='uploaded', + upload_url='', # Not needed for uploaded images + expires_at=timezone.now() + timezone.timedelta(days=365), # Set far future expiry + uploaded_at=timezone.now(), + metadata=image_data.get('meta', {}), + # Extract variants from nested result structure + variants=image_data.get('result', {}).get('variants', []), + cloudflare_metadata=image_data, + width=image_data.get('width'), + height=image_data.get('height'), + format=image_data.get('format', ''), + ) + + except Exception as api_error: + logger.error( + f"Error fetching image from Cloudflare API: {str(api_error)}", exc_info=True) + return Response( + {"error": f"Failed to fetch image from Cloudflare: {str(api_error)}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Create the ride photo with the CloudflareImage reference + photo = RidePhoto.objects.create( + ride=ride, + image=cloudflare_image, + uploaded_by=request.user, + caption=request.data.get("caption", ""), + alt_text=request.data.get("alt_text", ""), + photo_type=request.data.get("photo_type", "exterior"), + is_primary=request.data.get("is_primary", False), + is_approved=False, # Default to requiring approval + ) + + # Handle primary photo logic if requested + if request.data.get("is_primary", False): + try: + RideMediaService.set_primary_photo(ride=ride, photo=photo) + except Exception as e: + logger.error(f"Error setting primary photo: {e}") + # Don't fail the entire operation, just log the error + + serializer = RidePhotoOutputSerializer(photo, context={"request": request}) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + except Exception as e: + logger.error(f"Error saving ride photo: {e}") + return Response( + {"error": f"Failed to save photo: {str(e)}"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/backend/apps/api/v1/parks/ride_reviews_views.py b/backend/apps/api/v1/parks/ride_reviews_views.py new file mode 100644 index 00000000..cb73af70 --- /dev/null +++ b/backend/apps/api/v1/parks/ride_reviews_views.py @@ -0,0 +1,380 @@ +""" +Ride review API views for ThrillWiki API v1 (nested under parks). + +This module contains ride review ViewSet following the parks pattern for domain consistency. +Provides CRUD operations for ride reviews nested under parks/{park_slug}/rides/{ride_slug}/reviews/ +""" + +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + pass + +from django.core.exceptions import PermissionDenied +from django.db.models import Avg, Count, Q +from django.utils import timezone +from drf_spectacular.utils import extend_schema_view, extend_schema +from drf_spectacular.types import OpenApiTypes +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.exceptions import ValidationError, NotFound +from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet + +from apps.rides.models.reviews import RideReview +from apps.rides.models import Ride +from apps.parks.models import Park +from apps.api.v1.serializers.ride_reviews import ( + RideReviewOutputSerializer, + RideReviewCreateInputSerializer, + RideReviewUpdateInputSerializer, + RideReviewListOutputSerializer, + RideReviewStatsOutputSerializer, + RideReviewModerationInputSerializer, +) + +logger = logging.getLogger(__name__) + + +@extend_schema_view( + list=extend_schema( + summary="List ride reviews", + description="Retrieve a paginated list of ride reviews with filtering capabilities.", + responses={200: RideReviewListOutputSerializer(many=True)}, + tags=["Ride Reviews"], + ), + create=extend_schema( + summary="Create ride review", + description="Create a new review for a ride. Requires authentication.", + request=RideReviewCreateInputSerializer, + responses={ + 201: RideReviewOutputSerializer, + 400: OpenApiTypes.OBJECT, + 401: OpenApiTypes.OBJECT, + }, + tags=["Ride Reviews"], + ), + retrieve=extend_schema( + summary="Get ride review details", + description="Retrieve detailed information about a specific ride review.", + responses={ + 200: RideReviewOutputSerializer, + 404: OpenApiTypes.OBJECT, + }, + tags=["Ride Reviews"], + ), + update=extend_schema( + summary="Update ride review", + description="Update ride review information. Requires authentication and ownership or admin privileges.", + request=RideReviewUpdateInputSerializer, + responses={ + 200: RideReviewOutputSerializer, + 400: OpenApiTypes.OBJECT, + 401: OpenApiTypes.OBJECT, + 403: OpenApiTypes.OBJECT, + 404: OpenApiTypes.OBJECT, + }, + tags=["Ride Reviews"], + ), + partial_update=extend_schema( + summary="Partially update ride review", + description="Partially update ride review information. Requires authentication and ownership or admin privileges.", + request=RideReviewUpdateInputSerializer, + responses={ + 200: RideReviewOutputSerializer, + 400: OpenApiTypes.OBJECT, + 401: OpenApiTypes.OBJECT, + 403: OpenApiTypes.OBJECT, + 404: OpenApiTypes.OBJECT, + }, + tags=["Ride Reviews"], + ), + destroy=extend_schema( + summary="Delete ride review", + description="Delete a ride review. Requires authentication and ownership or admin privileges.", + responses={ + 204: None, + 401: OpenApiTypes.OBJECT, + 403: OpenApiTypes.OBJECT, + 404: OpenApiTypes.OBJECT, + }, + tags=["Ride Reviews"], + ), +) +class RideReviewViewSet(ModelViewSet): + """ + ViewSet for managing ride reviews with full CRUD operations. + + Provides CRUD operations for ride reviews with proper permission checking. + Includes advanced features like bulk moderation and statistics. + """ + + lookup_field = "id" + + def get_permissions(self): + """Set permissions based on action.""" + if self.action in ['list', 'retrieve', 'stats']: + permission_classes = [AllowAny] + else: + permission_classes = [IsAuthenticated] + return [permission() for permission in permission_classes] + + def get_queryset(self): + """Get reviews for the current ride with optimized queries.""" + queryset = RideReview.objects.select_related( + "ride", "ride__park", "user", "user__profile" + ) + + # Filter by park and ride from URL kwargs + park_slug = self.kwargs.get("park_slug") + ride_slug = self.kwargs.get("ride_slug") + + if park_slug and ride_slug: + try: + park, _ = Park.get_by_slug(park_slug) + ride, _ = Ride.get_by_slug(ride_slug, park=park) + queryset = queryset.filter(ride=ride) + except (Park.DoesNotExist, Ride.DoesNotExist): + # Return empty queryset if park or ride not found + return queryset.none() + + # Filter published reviews for non-staff users + if not (hasattr(self.request, 'user') and + getattr(self.request.user, 'is_staff', False)): + queryset = queryset.filter(is_published=True) + + return queryset.order_by("-created_at") + + def get_serializer_class(self): + """Return appropriate serializer based on action.""" + if self.action == "list": + return RideReviewListOutputSerializer + elif self.action == "create": + return RideReviewCreateInputSerializer + elif self.action in ["update", "partial_update"]: + return RideReviewUpdateInputSerializer + else: + return RideReviewOutputSerializer + + def perform_create(self, serializer): + """Create a new ride review.""" + park_slug = self.kwargs.get("park_slug") + ride_slug = self.kwargs.get("ride_slug") + + if not park_slug or not ride_slug: + raise ValidationError("Park and ride slugs are required") + + try: + park, _ = Park.get_by_slug(park_slug) + ride, _ = Ride.get_by_slug(ride_slug, park=park) + except Park.DoesNotExist: + raise NotFound("Park not found") + except Ride.DoesNotExist: + raise NotFound("Ride not found at this park") + + # Check if user already has a review for this ride + if RideReview.objects.filter(ride=ride, user=self.request.user).exists(): + raise ValidationError("You have already reviewed this ride") + + try: + # Save the review + review = serializer.save( + ride=ride, + user=self.request.user, + is_published=True # Auto-publish for now, can add moderation later + ) + + logger.info(f"Created ride review {review.id} for ride {ride.name} by user {self.request.user.username}") + + except Exception as e: + logger.error(f"Error creating ride review: {e}") + raise ValidationError(f"Failed to create review: {str(e)}") + + def perform_update(self, serializer): + """Update ride review with permission checking.""" + instance = self.get_object() + + # Check permissions - allow owner or staff + if not ( + self.request.user == instance.user + or getattr(self.request.user, "is_staff", False) + ): + raise PermissionDenied("You can only edit your own reviews or be an admin.") + + try: + serializer.save() + logger.info(f"Updated ride review {instance.id} by user {self.request.user.username}") + except Exception as e: + logger.error(f"Error updating ride review: {e}") + raise ValidationError(f"Failed to update review: {str(e)}") + + def perform_destroy(self, instance): + """Delete ride review with permission checking.""" + # Check permissions - allow owner or staff + if not ( + self.request.user == instance.user + or getattr(self.request.user, "is_staff", False) + ): + raise PermissionDenied("You can only delete your own reviews or be an admin.") + + try: + logger.info(f"Deleting ride review {instance.id} by user {self.request.user.username}") + instance.delete() + except Exception as e: + logger.error(f"Error deleting ride review: {e}") + raise ValidationError(f"Failed to delete review: {str(e)}") + + @extend_schema( + summary="Get ride review statistics", + description="Get review statistics for the ride", + responses={ + 200: RideReviewStatsOutputSerializer, + 404: OpenApiTypes.OBJECT, + 500: OpenApiTypes.OBJECT, + }, + tags=["Ride Reviews"], + ) + @action(detail=False, methods=["get"]) + def stats(self, request, **kwargs): + """Get review statistics for the ride.""" + park_slug = self.kwargs.get("park_slug") + ride_slug = self.kwargs.get("ride_slug") + + if not park_slug or not ride_slug: + return Response( + {"error": "Park and ride slugs are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + park, _ = Park.get_by_slug(park_slug) + ride, _ = Ride.get_by_slug(ride_slug, park=park) + except Park.DoesNotExist: + return Response( + {"error": "Park not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + except Ride.DoesNotExist: + return Response( + {"error": "Ride not found at this park"}, + status=status.HTTP_404_NOT_FOUND, + ) + + try: + # Get review statistics + reviews = RideReview.objects.filter(ride=ride, is_published=True) + + total_reviews = reviews.count() + published_reviews = total_reviews # Since we're filtering published + pending_reviews = RideReview.objects.filter(ride=ride, is_published=False).count() + + # Calculate average rating + avg_rating = reviews.aggregate(avg_rating=Avg('rating'))['avg_rating'] + + # Get rating distribution + rating_distribution = {} + for i in range(1, 11): + rating_distribution[str(i)] = reviews.filter(rating=i).count() + + # Get recent reviews count (last 30 days) + from datetime import timedelta + thirty_days_ago = timezone.now() - timedelta(days=30) + recent_reviews = reviews.filter(created_at__gte=thirty_days_ago).count() + + stats = { + "total_reviews": total_reviews, + "published_reviews": published_reviews, + "pending_reviews": pending_reviews, + "average_rating": round(avg_rating, 2) if avg_rating else None, + "rating_distribution": rating_distribution, + "recent_reviews": recent_reviews, + } + + serializer = RideReviewStatsOutputSerializer(stats) + return Response(serializer.data, status=status.HTTP_200_OK) + + except Exception as e: + logger.error(f"Error getting ride review stats: {e}") + return Response( + {"error": f"Failed to get review statistics: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + @extend_schema( + summary="Bulk moderate reviews", + description="Bulk moderate multiple ride reviews (admin only)", + request=RideReviewModerationInputSerializer, + responses={ + 200: OpenApiTypes.OBJECT, + 400: OpenApiTypes.OBJECT, + 403: OpenApiTypes.OBJECT, + }, + tags=["Ride Reviews"], + ) + @action(detail=False, methods=["post"], permission_classes=[IsAuthenticated]) + def moderate(self, request, **kwargs): + """Bulk moderate multiple reviews (admin only).""" + if not getattr(request.user, "is_staff", False): + raise PermissionDenied("Only administrators can moderate reviews.") + + serializer = RideReviewModerationInputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + validated_data = serializer.validated_data + review_ids = validated_data.get("review_ids") + action_type = validated_data.get("action") + moderation_notes = validated_data.get("moderation_notes", "") + + park_slug = self.kwargs.get("park_slug") + ride_slug = self.kwargs.get("ride_slug") + + try: + # Filter reviews to only those belonging to this ride + reviews_queryset = RideReview.objects.filter(id__in=review_ids) + if park_slug and ride_slug: + park, _ = Park.get_by_slug(park_slug) + ride, _ = Ride.get_by_slug(ride_slug, park=park) + reviews_queryset = reviews_queryset.filter(ride=ride) + + if action_type == "publish": + updated_count = reviews_queryset.update( + is_published=True, + moderated_by=request.user, + moderated_at=timezone.now(), + moderation_notes=moderation_notes + ) + message = f"Successfully published {updated_count} reviews" + elif action_type == "unpublish": + updated_count = reviews_queryset.update( + is_published=False, + moderated_by=request.user, + moderated_at=timezone.now(), + moderation_notes=moderation_notes + ) + message = f"Successfully unpublished {updated_count} reviews" + elif action_type == "delete": + updated_count = reviews_queryset.count() + reviews_queryset.delete() + message = f"Successfully deleted {updated_count} reviews" + else: + return Response( + {"error": "Invalid action type"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response( + { + "message": message, + "updated_count": updated_count, + }, + status=status.HTTP_200_OK, + ) + + except Exception as e: + logger.error(f"Error in bulk review moderation: {e}") + return Response( + {"error": f"Failed to moderate reviews: {str(e)}"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/backend/apps/api/v1/parks/urls.py b/backend/apps/api/v1/parks/urls.py index 44dbc87d..e578e132 100644 --- a/backend/apps/api/v1/parks/urls.py +++ b/backend/apps/api/v1/parks/urls.py @@ -23,11 +23,20 @@ from .park_rides_views import ( ParkComprehensiveDetailAPIView, ) from .views import ParkPhotoViewSet, HybridParkAPIView, ParkFilterMetadataAPIView +from .ride_photos_views import RidePhotoViewSet +from .ride_reviews_views import RideReviewViewSet # Create router for nested photo endpoints router = DefaultRouter() router.register(r"", ParkPhotoViewSet, basename="park-photo") +# Create routers for nested ride endpoints +ride_photos_router = DefaultRouter() +ride_photos_router.register(r"", RidePhotoViewSet, basename="ride-photo") + +ride_reviews_router = DefaultRouter() +ride_reviews_router.register(r"", RideReviewViewSet, basename="ride-review") + app_name = "api_v1_parks" urlpatterns = [ @@ -69,4 +78,10 @@ urlpatterns = [ ), # Park photo endpoints - domain-specific photo management path("/photos/", include(router.urls)), + + # Nested ride photo endpoints - photos for specific rides within parks + path("/rides//photos/", include(ride_photos_router.urls)), + + # Nested ride review endpoints - reviews for specific rides within parks + path("/rides//reviews/", include(ride_reviews_router.urls)), ] diff --git a/backend/apps/api/v1/serializers/ride_reviews.py b/backend/apps/api/v1/serializers/ride_reviews.py new file mode 100644 index 00000000..60be865e --- /dev/null +++ b/backend/apps/api/v1/serializers/ride_reviews.py @@ -0,0 +1,219 @@ +""" +Serializers for ride review API endpoints. + +This module contains serializers for ride review CRUD operations with Rich Choice Objects compliance. +""" + +from rest_framework import serializers +from drf_spectacular.utils import extend_schema_field, extend_schema_serializer, OpenApiExample +from apps.rides.models.reviews import RideReview +from apps.accounts.models import User +from apps.core.choices.serializers import RichChoiceSerializer + + +class ReviewUserSerializer(serializers.ModelSerializer): + """Serializer for user information in ride reviews.""" + + avatar_url = serializers.SerializerMethodField() + display_name = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ["id", "username", "display_name", "avatar_url"] + read_only_fields = fields + + @extend_schema_field(serializers.URLField(allow_null=True)) + def get_avatar_url(self, obj): + """Get the user's avatar URL.""" + if hasattr(obj, "profile") and obj.profile: + return obj.profile.get_avatar_url() + return "/static/images/default-avatar.png" + + @extend_schema_field(serializers.CharField()) + def get_display_name(self, obj): + """Get the user's display name.""" + return obj.get_display_name() + + +@extend_schema_serializer( + examples=[ + OpenApiExample( + name="Complete Ride Review", + summary="Full ride review response", + description="Example response showing all fields for a ride review", + value={ + "id": 123, + "title": "Amazing roller coaster experience!", + "content": "This ride was absolutely incredible. The inversions were smooth and the theming was top-notch.", + "rating": 9, + "visit_date": "2023-06-15", + "created_at": "2023-06-16T10:30:00Z", + "updated_at": "2023-06-16T10:30:00Z", + "is_published": True, + "user": { + "id": 456, + "username": "coaster_fan", + "display_name": "Coaster Fan", + "avatar_url": "https://example.com/avatar.jpg" + }, + "ride": { + "id": 789, + "name": "Steel Vengeance", + "slug": "steel-vengeance" + }, + "park": { + "id": 101, + "name": "Cedar Point", + "slug": "cedar-point" + } + } + ) + ] +) +class RideReviewOutputSerializer(serializers.ModelSerializer): + """Output serializer for ride reviews.""" + + user = ReviewUserSerializer(read_only=True) + + # Ride information + ride = serializers.SerializerMethodField() + park = serializers.SerializerMethodField() + + class Meta: + model = RideReview + fields = [ + "id", + "title", + "content", + "rating", + "visit_date", + "created_at", + "updated_at", + "is_published", + "user", + "ride", + "park", + ] + read_only_fields = [ + "id", + "created_at", + "updated_at", + "user", + "ride", + "park", + ] + + @extend_schema_field(serializers.DictField()) + def get_ride(self, obj): + """Get ride information.""" + return { + "id": obj.ride.id, + "name": obj.ride.name, + "slug": obj.ride.slug, + } + + @extend_schema_field(serializers.DictField()) + def get_park(self, obj): + """Get park information.""" + return { + "id": obj.ride.park.id, + "name": obj.ride.park.name, + "slug": obj.ride.park.slug, + } + + +class RideReviewCreateInputSerializer(serializers.ModelSerializer): + """Input serializer for creating ride reviews.""" + + class Meta: + model = RideReview + fields = [ + "title", + "content", + "rating", + "visit_date", + ] + + def validate_rating(self, value): + """Validate rating is between 1 and 10.""" + if not (1 <= value <= 10): + raise serializers.ValidationError("Rating must be between 1 and 10.") + return value + + +class RideReviewUpdateInputSerializer(serializers.ModelSerializer): + """Input serializer for updating ride reviews.""" + + class Meta: + model = RideReview + fields = [ + "title", + "content", + "rating", + "visit_date", + ] + + def validate_rating(self, value): + """Validate rating is between 1 and 10.""" + if not (1 <= value <= 10): + raise serializers.ValidationError("Rating must be between 1 and 10.") + return value + + +class RideReviewListOutputSerializer(serializers.ModelSerializer): + """Simplified output serializer for ride review lists.""" + + user = ReviewUserSerializer(read_only=True) + ride_name = serializers.CharField(source="ride.name", read_only=True) + park_name = serializers.CharField(source="ride.park.name", read_only=True) + + class Meta: + model = RideReview + fields = [ + "id", + "title", + "rating", + "visit_date", + "created_at", + "is_published", + "user", + "ride_name", + "park_name", + ] + read_only_fields = fields + + +class RideReviewStatsOutputSerializer(serializers.Serializer): + """Output serializer for ride review statistics.""" + + total_reviews = serializers.IntegerField() + published_reviews = serializers.IntegerField() + pending_reviews = serializers.IntegerField() + average_rating = serializers.FloatField(allow_null=True) + rating_distribution = serializers.DictField( + child=serializers.IntegerField(), + help_text="Count of reviews by rating (1-10)" + ) + recent_reviews = serializers.IntegerField() + + +class RideReviewModerationInputSerializer(serializers.Serializer): + """Input serializer for review moderation operations.""" + + review_ids = serializers.ListField( + child=serializers.IntegerField(), + help_text="List of review IDs to moderate" + ) + action = serializers.ChoiceField( + choices=[ + ("publish", "Publish"), + ("unpublish", "Unpublish"), + ("delete", "Delete"), + ], + help_text="Moderation action to perform" + ) + moderation_notes = serializers.CharField( + required=False, + allow_blank=True, + help_text="Optional notes about the moderation action" + )