Revert "update"

This reverts commit 75cc618c2b.
This commit is contained in:
pacnpal
2025-09-21 20:11:00 -04:00
parent 75cc618c2b
commit 540f40e689
610 changed files with 4812 additions and 1715 deletions

View File

@@ -1,6 +0,0 @@
"""
Parks API module for ThrillWiki API v1.
This module provides API endpoints for park-related functionality including
search suggestions, location services, and roadtrip planning.
"""

View File

@@ -1,306 +0,0 @@
"""
Park Rides API views for ThrillWiki API v1.
This module implements endpoints for accessing rides within specific parks:
- GET /parks/{park_slug}/rides/ - List rides at a park with pagination and filtering
- GET /parks/{park_slug}/rides/{ride_slug}/ - Get specific ride details within park context
"""
from typing import Any
from django.db import models
from django.db.models import Q, Count, Avg
from django.db.models.query import QuerySet
from rest_framework import status, permissions
from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.pagination import PageNumberPagination
from rest_framework.exceptions import NotFound
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
# Import models
try:
from apps.parks.models import Park
from apps.rides.models import Ride
MODELS_AVAILABLE = True
except Exception:
Park = None # type: ignore
Ride = None # type: ignore
MODELS_AVAILABLE = False
# Import serializers
try:
from apps.api.v1.serializers.rides import RideListOutputSerializer, RideDetailOutputSerializer
from apps.api.v1.serializers.parks import ParkDetailOutputSerializer
SERIALIZERS_AVAILABLE = True
except Exception:
SERIALIZERS_AVAILABLE = False
class StandardResultsSetPagination(PageNumberPagination):
page_size = 20
page_size_query_param = "page_size"
max_page_size = 100
class ParkRidesListAPIView(APIView):
"""List rides at a specific park with pagination and filtering."""
permission_classes = [permissions.AllowAny]
@extend_schema(
summary="List rides at a specific park",
description="Get paginated list of rides at a specific park with filtering options",
parameters=[
# Pagination
OpenApiParameter(name="page", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Page number"),
OpenApiParameter(name="page_size", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Number of results per page (max 100)"),
# Filtering
OpenApiParameter(name="category", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Filter by ride category"),
OpenApiParameter(name="status", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Filter by operational status"),
OpenApiParameter(name="search", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Search rides by name"),
# Ordering
OpenApiParameter(name="ordering", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Order results by field"),
],
responses={
200: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Parks", "Rides"],
)
def get(self, request: Request, park_slug: str) -> Response:
"""List rides at a specific park."""
if not MODELS_AVAILABLE:
return Response(
{"detail": "Park and ride models not available."},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
# Get the park
try:
park, is_historical = Park.get_by_slug(park_slug)
except Park.DoesNotExist:
raise NotFound("Park not found")
# Get rides for this park
qs = Ride.objects.filter(park=park).select_related(
"manufacturer", "designer", "ride_model", "park_area"
).prefetch_related("photos")
# Apply filtering
qs = self._apply_filters(qs, request.query_params)
# Apply ordering
ordering = request.query_params.get("ordering", "name")
if ordering:
qs = qs.order_by(ordering)
# Paginate results
paginator = StandardResultsSetPagination()
page = paginator.paginate_queryset(qs, request)
if SERIALIZERS_AVAILABLE:
serializer = RideListOutputSerializer(
page, many=True, context={"request": request, "park": park}
)
return paginator.get_paginated_response(serializer.data)
else:
# Fallback serialization
serializer_data = [
{
"id": ride.id,
"name": ride.name,
"slug": ride.slug,
"category": getattr(ride, "category", ""),
"status": getattr(ride, "status", ""),
"manufacturer": {
"name": ride.manufacturer.name if ride.manufacturer else "",
"slug": getattr(ride.manufacturer, "slug", "") if ride.manufacturer else "",
},
}
for ride in page
]
return paginator.get_paginated_response(serializer_data)
def _apply_filters(self, qs: QuerySet, params: dict) -> QuerySet:
"""Apply filtering to the rides queryset."""
# Category filter
category = params.get("category")
if category:
qs = qs.filter(category=category)
# Status filter
status_filter = params.get("status")
if status_filter:
qs = qs.filter(status=status_filter)
# Search filter
search = params.get("search")
if search:
qs = qs.filter(
Q(name__icontains=search) |
Q(description__icontains=search) |
Q(manufacturer__name__icontains=search)
)
return qs
class ParkRideDetailAPIView(APIView):
"""Get specific ride details within park context."""
permission_classes = [permissions.AllowAny]
@extend_schema(
summary="Get ride details within park context",
description="Get comprehensive details for a specific ride at a specific park",
responses={
200: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Parks", "Rides"],
)
def get(self, request: Request, park_slug: str, ride_slug: str) -> Response:
"""Get ride details within park context."""
if not MODELS_AVAILABLE:
return Response(
{"detail": "Park and ride models not available."},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
# Get the park
try:
park, is_historical = Park.get_by_slug(park_slug)
except Park.DoesNotExist:
raise NotFound("Park not found")
# Get the ride
try:
ride, is_historical = Ride.get_by_slug(ride_slug, park=park)
except Ride.DoesNotExist:
raise NotFound("Ride not found at this park")
# Ensure ride belongs to this park
if ride.park_id != park.id:
raise NotFound("Ride not found at this park")
if SERIALIZERS_AVAILABLE:
serializer = RideDetailOutputSerializer(
ride, context={"request": request, "park": park}
)
return Response(serializer.data)
else:
# Fallback serialization
return Response({
"id": ride.id,
"name": ride.name,
"slug": ride.slug,
"description": getattr(ride, "description", ""),
"category": getattr(ride, "category", ""),
"status": getattr(ride, "status", ""),
"park": {
"id": park.id,
"name": park.name,
"slug": park.slug,
},
"manufacturer": {
"name": ride.manufacturer.name if ride.manufacturer else "",
"slug": getattr(ride.manufacturer, "slug", "") if ride.manufacturer else "",
} if ride.manufacturer else None,
})
class ParkComprehensiveDetailAPIView(APIView):
"""Get comprehensive park details including summary of rides."""
permission_classes = [permissions.AllowAny]
@extend_schema(
summary="Get comprehensive park details with rides summary",
description="Get complete park details including a summary of rides (first 10) and link to full rides list",
responses={
200: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Parks"],
)
def get(self, request: Request, park_slug: str) -> Response:
"""Get comprehensive park details with rides summary."""
if not MODELS_AVAILABLE:
return Response(
{"detail": "Park and ride models not available."},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
# Get the park
try:
park, is_historical = Park.get_by_slug(park_slug)
except Park.DoesNotExist:
raise NotFound("Park not found")
# Get park with full related data
park = Park.objects.select_related(
"operator", "property_owner", "location"
).prefetch_related(
"areas", "rides", "photos"
).get(pk=park.pk)
# Get a sample of rides (first 10) for preview
rides_sample = Ride.objects.filter(park=park).select_related(
"manufacturer", "designer", "ride_model"
)[:10]
if SERIALIZERS_AVAILABLE:
# Get full park details
park_serializer = ParkDetailOutputSerializer(
park, context={"request": request}
)
park_data = park_serializer.data
# Add rides summary
rides_serializer = RideListOutputSerializer(
rides_sample, many=True, context={"request": request, "park": park}
)
# Enhance response with rides data
park_data["rides_summary"] = {
"total_count": park.ride_count or 0,
"sample": rides_serializer.data,
"full_list_url": f"/api/v1/parks/{park_slug}/rides/",
}
return Response(park_data)
else:
# Fallback serialization
return Response({
"id": park.id,
"name": park.name,
"slug": park.slug,
"description": getattr(park, "description", ""),
"location": str(getattr(park, "location", "")),
"operator": getattr(park.operator, "name", "") if hasattr(park, "operator") else "",
"ride_count": getattr(park, "ride_count", 0),
"rides_summary": {
"total_count": getattr(park, "ride_count", 0),
"sample": [
{
"id": ride.id,
"name": ride.name,
"slug": ride.slug,
"category": getattr(ride, "category", ""),
}
for ride in rides_sample
],
"full_list_url": f"/api/v1/parks/{park_slug}/rides/",
},
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,552 +0,0 @@
"""
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,
)

View File

@@ -1,380 +0,0 @@
"""
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,
)

View File

@@ -1,393 +0,0 @@
"""
Park media serializers for ThrillWiki API v1.
This module contains serializers for park-specific media functionality.
Enhanced from rogue implementation to maintain full feature parity.
"""
from rest_framework import serializers
from drf_spectacular.utils import (
extend_schema_field,
extend_schema_serializer,
OpenApiExample,
)
from apps.parks.models import Park, ParkPhoto
@extend_schema_serializer(
examples=[
OpenApiExample(
name="Park Photo with Cloudflare Images",
summary="Complete park photo response",
description="Example response showing all fields including Cloudflare Images URLs and variants",
value={
"id": 456,
"image": "https://imagedelivery.net/account-hash/def456ghi789/public",
"image_url": "https://imagedelivery.net/account-hash/def456ghi789/public",
"image_variants": {
"thumbnail": "https://imagedelivery.net/account-hash/def456ghi789/thumbnail",
"medium": "https://imagedelivery.net/account-hash/def456ghi789/medium",
"large": "https://imagedelivery.net/account-hash/def456ghi789/large",
"public": "https://imagedelivery.net/account-hash/def456ghi789/public",
},
"caption": "Beautiful park entrance",
"alt_text": "Main entrance gate with decorative archway",
"is_primary": True,
"is_approved": True,
"created_at": "2023-01-01T12:00:00Z",
"updated_at": "2023-01-01T12:00:00Z",
"date_taken": "2023-01-01T11:00:00Z",
"uploaded_by_username": "parkfan456",
"file_size": 1536000,
"dimensions": [1600, 900],
"park_slug": "cedar-point",
"park_name": "Cedar Point",
},
)
]
)
class ParkPhotoOutputSerializer(serializers.ModelSerializer):
"""Enhanced output serializer for park photos with Cloudflare Images support."""
uploaded_by_username = serializers.CharField(
source="uploaded_by.username", read_only=True
)
file_size = serializers.SerializerMethodField()
dimensions = serializers.SerializerMethodField()
image_url = serializers.SerializerMethodField()
image_variants = serializers.SerializerMethodField()
@extend_schema_field(
serializers.IntegerField(allow_null=True, help_text="File size in bytes")
)
def get_file_size(self, obj):
"""Get file size in bytes."""
return obj.file_size
@extend_schema_field(
serializers.ListField(
child=serializers.IntegerField(),
min_length=2,
max_length=2,
allow_null=True,
help_text="Image dimensions as [width, height] in pixels",
)
)
def get_dimensions(self, obj):
"""Get image dimensions as [width, height]."""
return obj.dimensions
@extend_schema_field(
serializers.URLField(
help_text="Full URL to the Cloudflare Images asset", allow_null=True
)
)
def get_image_url(self, obj):
"""Get the full Cloudflare Images URL."""
if obj.image:
return obj.image.url
return None
@extend_schema_field(
serializers.DictField(
child=serializers.URLField(),
help_text="Available Cloudflare Images variants with their URLs",
)
)
def get_image_variants(self, obj):
"""Get available image variants from Cloudflare Images."""
if not obj.image:
return {}
# Common variants for park photos
variants = {
"thumbnail": f"{obj.image.url}/thumbnail",
"medium": f"{obj.image.url}/medium",
"large": f"{obj.image.url}/large",
"public": f"{obj.image.url}/public",
}
return variants
park_slug = serializers.CharField(source="park.slug", read_only=True)
park_name = serializers.CharField(source="park.name", read_only=True)
class Meta:
model = ParkPhoto
fields = [
"id",
"image",
"image_url",
"image_variants",
"caption",
"alt_text",
"is_primary",
"is_approved",
"created_at",
"updated_at",
"date_taken",
"uploaded_by_username",
"file_size",
"dimensions",
"park_slug",
"park_name",
]
read_only_fields = [
"id",
"image_url",
"image_variants",
"created_at",
"updated_at",
"uploaded_by_username",
"file_size",
"dimensions",
"park_slug",
"park_name",
]
class ParkPhotoCreateInputSerializer(serializers.ModelSerializer):
"""Input serializer for creating park photos."""
class Meta:
model = ParkPhoto
fields = [
"image",
"caption",
"alt_text",
"is_primary",
]
class ParkPhotoUpdateInputSerializer(serializers.ModelSerializer):
"""Input serializer for updating park photos."""
class Meta:
model = ParkPhoto
fields = [
"caption",
"alt_text",
"is_primary",
]
class ParkPhotoListOutputSerializer(serializers.ModelSerializer):
"""Optimized output serializer for park photo lists."""
uploaded_by_username = serializers.CharField(
source="uploaded_by.username", read_only=True
)
class Meta:
model = ParkPhoto
fields = [
"id",
"image",
"caption",
"is_primary",
"is_approved",
"created_at",
"uploaded_by_username",
]
read_only_fields = fields
class ParkPhotoApprovalInputSerializer(serializers.Serializer):
"""Input serializer for bulk photo approval operations."""
photo_ids = serializers.ListField(
child=serializers.IntegerField(), help_text="List of photo IDs to approve"
)
approve = serializers.BooleanField(
default=True, help_text="Whether to approve (True) or reject (False) the photos"
)
class ParkPhotoStatsOutputSerializer(serializers.Serializer):
"""Output serializer for park photo statistics."""
total_photos = serializers.IntegerField()
approved_photos = serializers.IntegerField()
pending_photos = serializers.IntegerField()
has_primary = serializers.BooleanField()
recent_uploads = serializers.IntegerField()
# Legacy serializers for backwards compatibility
class ParkPhotoSerializer(serializers.ModelSerializer):
"""Legacy serializer for the ParkPhoto model - maintained for compatibility."""
class Meta:
model = ParkPhoto
fields = (
"id",
"image",
"caption",
"alt_text",
"is_primary",
"uploaded_at",
"uploaded_by",
)
class HybridParkSerializer(serializers.ModelSerializer):
"""
Enhanced serializer for hybrid filtering strategy.
Includes all filterable fields for client-side filtering.
"""
# Location fields from related ParkLocation
city = serializers.SerializerMethodField()
state = serializers.SerializerMethodField()
country = serializers.SerializerMethodField()
continent = serializers.SerializerMethodField()
latitude = serializers.SerializerMethodField()
longitude = serializers.SerializerMethodField()
# Company fields
operator_name = serializers.CharField(source="operator.name", read_only=True)
property_owner_name = serializers.CharField(source="property_owner.name", read_only=True, allow_null=True)
# Image URLs for display
banner_image_url = serializers.SerializerMethodField()
card_image_url = serializers.SerializerMethodField()
# Computed fields for filtering
opening_year = serializers.IntegerField(read_only=True)
search_text = serializers.CharField(read_only=True)
@extend_schema_field(serializers.CharField(allow_null=True))
def get_city(self, obj):
"""Get city from related location."""
try:
return obj.location.city if hasattr(obj, 'location') and obj.location else None
except AttributeError:
return None
@extend_schema_field(serializers.CharField(allow_null=True))
def get_state(self, obj):
"""Get state from related location."""
try:
return obj.location.state if hasattr(obj, 'location') and obj.location else None
except AttributeError:
return None
@extend_schema_field(serializers.CharField(allow_null=True))
def get_country(self, obj):
"""Get country from related location."""
try:
return obj.location.country if hasattr(obj, 'location') and obj.location else None
except AttributeError:
return None
@extend_schema_field(serializers.CharField(allow_null=True))
def get_continent(self, obj):
"""Get continent from related location."""
try:
return obj.location.continent if hasattr(obj, 'location') and obj.location else None
except AttributeError:
return None
@extend_schema_field(serializers.FloatField(allow_null=True))
def get_latitude(self, obj):
"""Get latitude from related location."""
try:
if hasattr(obj, 'location') and obj.location and obj.location.coordinates:
return obj.location.coordinates[1] # PostGIS returns [lon, lat]
return None
except (AttributeError, IndexError, TypeError):
return None
@extend_schema_field(serializers.FloatField(allow_null=True))
def get_longitude(self, obj):
"""Get longitude from related location."""
try:
if hasattr(obj, 'location') and obj.location and obj.location.coordinates:
return obj.location.coordinates[0] # PostGIS returns [lon, lat]
return None
except (AttributeError, IndexError, TypeError):
return None
@extend_schema_field(serializers.URLField(allow_null=True))
def get_banner_image_url(self, obj):
"""Get banner image URL."""
if obj.banner_image and obj.banner_image.image:
return obj.banner_image.image.url
return None
@extend_schema_field(serializers.URLField(allow_null=True))
def get_card_image_url(self, obj):
"""Get card image URL."""
if obj.card_image and obj.card_image.image:
return obj.card_image.image.url
return None
class Meta:
model = Park
fields = [
# Basic park info
"id",
"name",
"slug",
"description",
"status",
"park_type",
# Dates and computed fields
"opening_date",
"closing_date",
"opening_year",
"operating_season",
# Location fields
"city",
"state",
"country",
"continent",
"latitude",
"longitude",
# Company relationships
"operator_name",
"property_owner_name",
# Statistics
"size_acres",
"average_rating",
"ride_count",
"coaster_count",
# Images
"banner_image_url",
"card_image_url",
# URLs
"website",
"url",
# Computed fields for filtering
"search_text",
# Metadata
"created_at",
"updated_at",
]
read_only_fields = fields
class ParkSerializer(serializers.ModelSerializer):
"""Serializer for the Park model."""
class Meta:
model = Park
fields = (
"id",
"name",
"slug",
"country",
"continent",
"latitude",
"longitude",
"website",
"status",
)

View File

@@ -1,87 +0,0 @@
"""Comprehensive URL routes for Parks domain (API v1).
This file exposes a maximal set of "full-fat" endpoints implemented in
`apps.api.v1.parks.park_views` and `apps.api.v1.parks.views`. Endpoints are
intentionally expansive to match the rides API functionality and provide
complete feature parity for parks management.
"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .park_views import (
ParkListCreateAPIView,
ParkDetailAPIView,
FilterOptionsAPIView,
CompanySearchAPIView,
ParkSearchSuggestionsAPIView,
ParkImageSettingsAPIView,
)
from .park_rides_views import (
ParkRidesListAPIView,
ParkRideDetailAPIView,
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 = [
# Core list/create endpoints
path("", ParkListCreateAPIView.as_view(), name="park-list-create"),
# Hybrid filtering endpoints
path("hybrid/", HybridParkAPIView.as_view(), name="park-hybrid-list"),
path("hybrid/filter-metadata/", ParkFilterMetadataAPIView.as_view(), name="park-hybrid-filter-metadata"),
# Filter options
path("filter-options/", FilterOptionsAPIView.as_view(), name="park-filter-options"),
# Autocomplete / suggestion endpoints
path(
"search/companies/",
CompanySearchAPIView.as_view(),
name="park-search-companies",
),
path(
"search-suggestions/",
ParkSearchSuggestionsAPIView.as_view(),
name="park-search-suggestions",
),
# Detail and action endpoints - supports both ID and slug
path("<str:pk>/", ParkDetailAPIView.as_view(), name="park-detail"),
# Park rides endpoints
path("<str:park_slug>/rides/", ParkRidesListAPIView.as_view(), name="park-rides-list"),
path("<str:park_slug>/rides/<str:ride_slug>/", ParkRideDetailAPIView.as_view(), name="park-ride-detail"),
# Comprehensive park detail endpoint with rides summary
path("<str:park_slug>/detail/", ParkComprehensiveDetailAPIView.as_view(), name="park-comprehensive-detail"),
# Park image settings endpoint
path(
"<int:pk>/image-settings/",
ParkImageSettingsAPIView.as_view(),
name="park-image-settings",
),
# 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)),
]

View File

@@ -1,824 +0,0 @@
"""
Park API views for ThrillWiki API v1.
This module contains consolidated park photo viewset for the centralized API structure.
Enhanced from rogue implementation to maintain full feature parity.
"""
from .serializers import (
ParkPhotoOutputSerializer,
ParkPhotoCreateInputSerializer,
ParkPhotoUpdateInputSerializer,
ParkPhotoListOutputSerializer,
ParkPhotoApprovalInputSerializer,
ParkPhotoStatsOutputSerializer,
)
from typing import Any, cast
import logging
from django.core.exceptions import PermissionDenied
from drf_spectacular.utils import extend_schema_view, extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from apps.parks.models import ParkPhoto, Park
from apps.parks.services import ParkMediaService
from django.contrib.auth import get_user_model
UserModel = get_user_model()
logger = logging.getLogger(__name__)
@extend_schema_view(
list=extend_schema(
summary="List park photos",
description="Retrieve a paginated list of park photos with filtering capabilities.",
responses={200: ParkPhotoListOutputSerializer(many=True)},
tags=["Park Media"],
),
create=extend_schema(
summary="Upload park photo",
description="Upload a new photo for a park. Requires authentication.",
request=ParkPhotoCreateInputSerializer,
responses={
201: ParkPhotoOutputSerializer,
400: OpenApiTypes.OBJECT,
401: OpenApiTypes.OBJECT,
},
tags=["Park Media"],
),
retrieve=extend_schema(
summary="Get park photo details",
description="Retrieve detailed information about a specific park photo.",
responses={
200: ParkPhotoOutputSerializer,
404: OpenApiTypes.OBJECT,
},
tags=["Park Media"],
),
update=extend_schema(
summary="Update park photo",
description="Update park photo information. Requires authentication and ownership or admin privileges.",
request=ParkPhotoUpdateInputSerializer,
responses={
200: ParkPhotoOutputSerializer,
400: OpenApiTypes.OBJECT,
401: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Park Media"],
),
partial_update=extend_schema(
summary="Partially update park photo",
description="Partially update park photo information. Requires authentication and ownership or admin privileges.",
request=ParkPhotoUpdateInputSerializer,
responses={
200: ParkPhotoOutputSerializer,
400: OpenApiTypes.OBJECT,
401: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Park Media"],
),
destroy=extend_schema(
summary="Delete park photo",
description="Delete a park photo. Requires authentication and ownership or admin privileges.",
responses={
204: None,
401: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Park Media"],
),
)
class ParkPhotoViewSet(ModelViewSet):
"""
Enhanced ViewSet for managing park photos with full feature parity.
Provides CRUD operations for park photos with proper permission checking.
Uses ParkMediaService 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): # type: ignore[override]
"""Get photos for the current park with optimized queries."""
queryset = ParkPhoto.objects.select_related(
"park", "park__operator", "uploaded_by"
)
# If park_pk is provided in URL kwargs, filter by park
park_pk = self.kwargs.get("park_pk")
if park_pk:
queryset = queryset.filter(park_id=park_pk)
return queryset.order_by("-created_at")
def get_serializer_class(self): # type: ignore[override]
"""Return appropriate serializer based on action."""
if self.action == "list":
return ParkPhotoListOutputSerializer
elif self.action == "create":
return ParkPhotoCreateInputSerializer
elif self.action in ["update", "partial_update"]:
return ParkPhotoUpdateInputSerializer
else:
return ParkPhotoOutputSerializer
def perform_create(self, serializer):
"""Create a new park photo using ParkMediaService."""
park_id = self.kwargs.get("park_pk")
if not park_id:
raise ValidationError("Park ID is required")
try:
Park.objects.get(pk=park_id)
except Park.DoesNotExist:
raise ValidationError("Park not found")
try:
# Use the service to create the photo with proper business logic
service = cast(Any, ParkMediaService())
photo = service.create_photo(
park_id=park_id,
uploaded_by=self.request.user,
**serializer.validated_data,
)
# Set the instance for the serializer response
serializer.instance = photo
except Exception as e:
logger.error(f"Error creating park photo: {e}")
raise ValidationError(f"Failed to create photo: {str(e)}")
def perform_update(self, serializer):
"""Update park photo with permission checking."""
instance = self.get_object()
# Check permissions - allow owner or staff
if not (
self.request.user == instance.uploaded_by
or cast(Any, self.request.user).is_staff
):
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:
ParkMediaService().set_primary_photo(
park_id=instance.park_id, photo_id=instance.id
)
# 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)}")
def perform_destroy(self, instance):
"""Delete park photo with permission checking."""
# Check permissions - allow owner or staff
if not (
self.request.user == instance.uploaded_by
or cast(Any, self.request.user).is_staff
):
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 park photo from Cloudflare: {instance.image.cloudflare_id}")
except Exception as e:
logger.error(
f"Failed to delete park photo from Cloudflare: {str(e)}")
# Continue with database deletion even if Cloudflare deletion fails
ParkMediaService().delete_photo(
instance.id, deleted_by=cast(UserModel, self.request.user)
)
except Exception as e:
logger.error(f"Error deleting park 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 park",
responses={
200: OpenApiTypes.OBJECT,
400: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Park Media"],
)
@action(detail=True, methods=["post"])
def set_primary(self, request, **kwargs):
"""Set this photo as the primary photo for the park."""
photo = self.get_object()
# Check permissions - allow owner or staff
if not (request.user == photo.uploaded_by or cast(Any, request.user).is_staff):
raise PermissionDenied(
"You can only modify your own photos or be an admin."
)
try:
ParkMediaService().set_primary_photo(
park_id=photo.park_id, photo_id=photo.id
)
# 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,
)
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 park photos (admin only)",
request=ParkPhotoApprovalInputSerializer,
responses={
200: OpenApiTypes.OBJECT,
400: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
},
tags=["Park Media"],
)
@action(detail=False, methods=["post"], permission_classes=[IsAuthenticated])
def bulk_approve(self, request, **kwargs):
"""Bulk approve or reject multiple photos (admin only)."""
if not cast(Any, request.user).is_staff:
raise PermissionDenied("Only administrators can approve photos.")
serializer = ParkPhotoApprovalInputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
validated_data = cast(dict, getattr(serializer, "validated_data", {}))
photo_ids = validated_data.get("photo_ids")
approve = validated_data.get("approve")
park_id = self.kwargs.get("park_pk")
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 park (if park_pk provided)
photos_queryset = ParkPhoto.objects.filter(id__in=photo_ids)
if park_id:
photos_queryset = photos_queryset.filter(park_id=park_id)
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 park photo statistics",
description="Get photo statistics for the park",
responses={
200: ParkPhotoStatsOutputSerializer,
404: OpenApiTypes.OBJECT,
500: OpenApiTypes.OBJECT,
},
tags=["Park Media"],
)
@action(detail=False, methods=["get"])
def stats(self, request, **kwargs):
"""Get photo statistics for the park."""
park_pk = self.kwargs.get("park_pk")
park = None
if park_pk:
try:
park = Park.objects.get(pk=park_pk)
except Park.DoesNotExist:
return Response(
{"error": "Park not found."},
status=status.HTTP_404_NOT_FOUND,
)
try:
if park is not None:
stats = ParkMediaService().get_photo_stats(park=park)
else:
stats = ParkMediaService().get_photo_stats(park=cast(Park, None))
serializer = ParkPhotoStatsOutputSerializer(stats)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Error getting park photo stats: {e}")
return Response(
{"error": f"Failed to get photo statistics: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
# Legacy compatibility action using the legacy set_primary logic
@extend_schema(
summary="Set photo as primary (legacy)",
description="Legacy set primary action for backwards compatibility",
responses={
200: OpenApiTypes.OBJECT,
400: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
},
tags=["Park Media"],
)
@action(detail=True, methods=["post"])
def set_primary_legacy(self, request, id=None):
"""Legacy set primary action for backwards compatibility."""
photo = self.get_object()
if not (
request.user == photo.uploaded_by
or request.user.has_perm("parks.change_parkphoto")
):
return Response(
{"error": "You do not have permission to edit photos for this park."},
status=status.HTTP_403_FORBIDDEN,
)
try:
ParkMediaService().set_primary_photo(
park_id=photo.park_id, photo_id=photo.id
)
return Response({"message": "Photo set as primary successfully."})
except Exception as e:
logger.error(f"Error in set_primary_photo: {str(e)}", exc_info=True)
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@extend_schema(
summary="Save Cloudflare image as park photo",
description="Save a Cloudflare image as a park photo after direct upload to Cloudflare",
request=OpenApiTypes.OBJECT,
responses={
201: ParkPhotoOutputSerializer,
400: OpenApiTypes.OBJECT,
401: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Park Media"],
)
@action(detail=False, methods=["post"])
def save_image(self, request, **kwargs):
"""Save a Cloudflare image as a park photo after direct upload to Cloudflare."""
park_pk = self.kwargs.get("park_pk")
if not park_pk:
return Response(
{"error": "Park ID is required"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
park = Park.objects.get(pk=park_pk)
except Park.DoesNotExist:
return Response(
{"error": "Park not found"},
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
from django.utils import timezone
# 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 park photo with the CloudflareImage reference
photo = ParkPhoto.objects.create(
park=park,
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:
ParkMediaService().set_primary_photo(
park_id=park.id, photo_id=photo.id
)
except Exception as e:
logger.error(f"Error setting primary photo: {e}")
# Don't fail the entire operation, just log the error
serializer = ParkPhotoOutputSerializer(photo, context={"request": request})
return Response(serializer.data, status=status.HTTP_201_CREATED)
except Exception as e:
logger.error(f"Error saving park photo: {e}")
return Response(
{"error": f"Failed to save photo: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
)
from rest_framework.views import APIView
from rest_framework.permissions import AllowAny
from .serializers import HybridParkSerializer
from apps.parks.services.hybrid_loader import smart_park_loader
@extend_schema_view(
get=extend_schema(
summary="Get parks with hybrid filtering",
description="Retrieve parks with intelligent hybrid filtering strategy. Automatically chooses between client-side and server-side filtering based on data size.",
parameters=[
OpenApiParameter("status", OpenApiTypes.STR, description="Filter by park status (comma-separated for multiple)"),
OpenApiParameter("park_type", OpenApiTypes.STR, description="Filter by park type (comma-separated for multiple)"),
OpenApiParameter("country", OpenApiTypes.STR, description="Filter by country (comma-separated for multiple)"),
OpenApiParameter("state", OpenApiTypes.STR, description="Filter by state (comma-separated for multiple)"),
OpenApiParameter("opening_year_min", OpenApiTypes.INT, description="Minimum opening year"),
OpenApiParameter("opening_year_max", OpenApiTypes.INT, description="Maximum opening year"),
OpenApiParameter("size_min", OpenApiTypes.NUMBER, description="Minimum park size in acres"),
OpenApiParameter("size_max", OpenApiTypes.NUMBER, description="Maximum park size in acres"),
OpenApiParameter("rating_min", OpenApiTypes.NUMBER, description="Minimum average rating"),
OpenApiParameter("rating_max", OpenApiTypes.NUMBER, description="Maximum average rating"),
OpenApiParameter("ride_count_min", OpenApiTypes.INT, description="Minimum ride count"),
OpenApiParameter("ride_count_max", OpenApiTypes.INT, description="Maximum ride count"),
OpenApiParameter("coaster_count_min", OpenApiTypes.INT, description="Minimum coaster count"),
OpenApiParameter("coaster_count_max", OpenApiTypes.INT, description="Maximum coaster count"),
OpenApiParameter("operator", OpenApiTypes.STR, description="Filter by operator slug (comma-separated for multiple)"),
OpenApiParameter("search", OpenApiTypes.STR, description="Search query for park names, descriptions, locations, and operators"),
OpenApiParameter("offset", OpenApiTypes.INT, description="Offset for progressive loading (server-side pagination)"),
],
responses={
200: {
"description": "Parks data with hybrid filtering metadata",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"parks": {
"type": "array",
"items": {"$ref": "#/components/schemas/HybridParkSerializer"}
},
"total_count": {"type": "integer"},
"strategy": {
"type": "string",
"enum": ["client_side", "server_side"],
"description": "Filtering strategy used"
},
"has_more": {
"type": "boolean",
"description": "Whether more data is available for progressive loading"
},
"next_offset": {
"type": "integer",
"nullable": True,
"description": "Next offset for progressive loading"
},
"filter_metadata": {
"type": "object",
"description": "Available filter options and ranges"
}
}
}
}
}
}
},
tags=["Parks"],
)
)
class HybridParkAPIView(APIView):
"""
Hybrid Park API View with intelligent filtering strategy.
Automatically chooses between client-side and server-side filtering
based on data size and complexity. Provides progressive loading
for large datasets and complete data for smaller sets.
"""
permission_classes = [AllowAny]
def get(self, request):
"""Get parks with hybrid filtering strategy."""
try:
# Extract filters from query parameters
filters = self._extract_filters(request.query_params)
# Check if this is a progressive load request
offset = request.query_params.get('offset')
if offset is not None:
try:
offset = int(offset)
# Get progressive load data
data = smart_park_loader.get_progressive_load(offset, filters)
except ValueError:
return Response(
{"error": "Invalid offset parameter"},
status=status.HTTP_400_BAD_REQUEST
)
else:
# Get initial load data
data = smart_park_loader.get_initial_load(filters)
# Serialize the parks data
serializer = HybridParkSerializer(data['parks'], many=True)
# Prepare response
response_data = {
'parks': serializer.data,
'total_count': data['total_count'],
'strategy': data.get('strategy', 'server_side'),
'has_more': data.get('has_more', False),
'next_offset': data.get('next_offset'),
}
# Include filter metadata for initial loads
if 'filter_metadata' in data:
response_data['filter_metadata'] = data['filter_metadata']
return Response(response_data, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Error in HybridParkAPIView: {e}")
return Response(
{"error": "Internal server error"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
def _extract_filters(self, query_params):
"""Extract and parse filters from query parameters."""
filters = {}
# Handle comma-separated list parameters
list_params = ['status', 'park_type', 'country', 'state', 'operator']
for param in list_params:
value = query_params.get(param)
if value:
filters[param] = [v.strip() for v in value.split(',') if v.strip()]
# Handle integer parameters
int_params = [
'opening_year_min', 'opening_year_max',
'ride_count_min', 'ride_count_max',
'coaster_count_min', 'coaster_count_max'
]
for param in int_params:
value = query_params.get(param)
if value:
try:
filters[param] = int(value)
except ValueError:
pass # Skip invalid integer values
# Handle float parameters
float_params = ['size_min', 'size_max', 'rating_min', 'rating_max']
for param in float_params:
value = query_params.get(param)
if value:
try:
filters[param] = float(value)
except ValueError:
pass # Skip invalid float values
# Handle search parameter
search = query_params.get('search')
if search:
filters['search'] = search.strip()
return filters
@extend_schema_view(
get=extend_schema(
summary="Get park filter metadata",
description="Get available filter options and ranges for parks filtering.",
parameters=[
OpenApiParameter("scoped", OpenApiTypes.BOOL, description="Whether to scope metadata to current filters"),
],
responses={
200: {
"description": "Filter metadata",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"categorical": {
"type": "object",
"properties": {
"countries": {"type": "array", "items": {"type": "string"}},
"states": {"type": "array", "items": {"type": "string"}},
"park_types": {"type": "array", "items": {"type": "string"}},
"statuses": {"type": "array", "items": {"type": "string"}},
"operators": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"slug": {"type": "string"}
}
}
}
}
},
"ranges": {
"type": "object",
"properties": {
"opening_year": {
"type": "object",
"properties": {
"min": {"type": "integer", "nullable": True},
"max": {"type": "integer", "nullable": True}
}
},
"size_acres": {
"type": "object",
"properties": {
"min": {"type": "number", "nullable": True},
"max": {"type": "number", "nullable": True}
}
},
"average_rating": {
"type": "object",
"properties": {
"min": {"type": "number", "nullable": True},
"max": {"type": "number", "nullable": True}
}
},
"ride_count": {
"type": "object",
"properties": {
"min": {"type": "integer", "nullable": True},
"max": {"type": "integer", "nullable": True}
}
},
"coaster_count": {
"type": "object",
"properties": {
"min": {"type": "integer", "nullable": True},
"max": {"type": "integer", "nullable": True}
}
}
}
},
"total_count": {"type": "integer"}
}
}
}
}
}
},
tags=["Parks"],
)
)
class ParkFilterMetadataAPIView(APIView):
"""
API view for getting park filter metadata.
Provides information about available filter options and ranges
to help build dynamic filter interfaces.
"""
permission_classes = [AllowAny]
def get(self, request):
"""Get park filter metadata."""
try:
# Check if metadata should be scoped to current filters
scoped = request.query_params.get('scoped', '').lower() == 'true'
filters = None
if scoped:
filters = self._extract_filters(request.query_params)
# Get filter metadata
metadata = smart_park_loader.get_filter_metadata(filters)
return Response(metadata, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Error in ParkFilterMetadataAPIView: {e}")
return Response(
{"error": "Internal server error"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
def _extract_filters(self, query_params):
"""Extract and parse filters from query parameters."""
# Reuse the same filter extraction logic
view = HybridParkAPIView()
return view._extract_filters(query_params)