mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 11:31:07 -05:00
feat: Add ride photo and review APIs with CRUD operations for parks
This commit is contained in:
552
backend/apps/api/v1/parks/ride_photos_views.py
Normal file
552
backend/apps/api/v1/parks/ride_photos_views.py
Normal file
@@ -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,
|
||||
)
|
||||
380
backend/apps/api/v1/parks/ride_reviews_views.py
Normal file
380
backend/apps/api/v1/parks/ride_reviews_views.py
Normal file
@@ -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,
|
||||
)
|
||||
@@ -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("<int:park_pk>/photos/", include(router.urls)),
|
||||
|
||||
# Nested ride photo endpoints - photos for specific rides within parks
|
||||
path("<str:park_slug>/rides/<str:ride_slug>/photos/", include(ride_photos_router.urls)),
|
||||
|
||||
# Nested ride review endpoints - reviews for specific rides within parks
|
||||
path("<str:park_slug>/rides/<str:ride_slug>/reviews/", include(ride_reviews_router.urls)),
|
||||
]
|
||||
|
||||
219
backend/apps/api/v1/serializers/ride_reviews.py
Normal file
219
backend/apps/api/v1/serializers/ride_reviews.py
Normal file
@@ -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"
|
||||
)
|
||||
Reference in New Issue
Block a user