feat(rides): populate slugs for existing RideModel records and ensure uniqueness

- Added migration 0011 to populate unique slugs for existing RideModel records based on manufacturer and model names.
- Implemented logic to ensure slug uniqueness during population.
- Added reverse migration to clear slugs if needed.

feat(rides): enforce unique slugs for RideModel

- Created migration 0012 to alter the slug field in RideModel to be unique.
- Updated the slug field to include help text and a maximum length of 255 characters.

docs: integrate Cloudflare Images into rides and parks models

- Updated RidePhoto and ParkPhoto models to use CloudflareImagesField for image storage.
- Enhanced API serializers for rides and parks to support Cloudflare Images, including new fields for image URLs and variants.
- Provided comprehensive OpenAPI schema metadata for new fields.
- Documented database migrations for the integration.
- Detailed configuration settings for Cloudflare Images.
- Updated API response formats to include Cloudflare Images URLs and variants.
- Added examples for uploading photos via API and outlined testing procedures.
This commit is contained in:
pacnpal
2025-08-28 15:12:39 -04:00
parent 715e284b3e
commit 67db0aa46e
34 changed files with 6002 additions and 894 deletions

View File

@@ -16,7 +16,8 @@ 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 rest_framework.exceptions import NotFound, ValidationError
from rest_framework.decorators import action
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
@@ -48,6 +49,7 @@ try:
ParkDetailOutputSerializer,
ParkCreateInputSerializer,
ParkUpdateInputSerializer,
ParkImageSettingsInputSerializer,
)
SERIALIZERS_AVAILABLE = True
@@ -414,3 +416,51 @@ class ParkSearchSuggestionsAPIView(APIView):
{"suggestion": f"{q} Amusement Park"},
]
return Response(fallback)
# --- Park image settings ---------------------------------------------------
@extend_schema(
summary="Set park banner and card images",
description="Set banner_image and card_image for a park from existing park photos",
request=("ParkImageSettingsInputSerializer" if SERIALIZERS_AVAILABLE else OpenApiTypes.OBJECT),
responses={
200: ("ParkDetailOutputSerializer" if SERIALIZERS_AVAILABLE else OpenApiTypes.OBJECT),
400: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Parks"],
)
class ParkImageSettingsAPIView(APIView):
permission_classes = [permissions.AllowAny]
def _get_park_or_404(self, pk: int) -> Any:
if not MODELS_AVAILABLE:
raise NotFound("Park models not available")
try:
return Park.objects.get(pk=pk) # type: ignore
except Park.DoesNotExist: # type: ignore
raise NotFound("Park not found")
def patch(self, request: Request, pk: int) -> Response:
"""Set banner and card images for the park."""
if not SERIALIZERS_AVAILABLE:
return Response(
{"detail": "Park image settings serializers not available."},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
park = self._get_park_or_404(pk)
serializer = ParkImageSettingsInputSerializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
# Update the park with the validated data
for field, value in serializer.validated_data.items():
setattr(park, field, value)
park.save()
# Return updated park data
output_serializer = ParkDetailOutputSerializer(
park, context={"request": request})
return Response(output_serializer.data)

View File

@@ -6,12 +6,44 @@ Enhanced from rogue implementation to maintain full feature parity.
"""
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field
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 rich field structure."""
"""Enhanced output serializer for park photos with Cloudflare Images support."""
uploaded_by_username = serializers.CharField(
source="uploaded_by.username", read_only=True
@@ -19,6 +51,8 @@ class ParkPhotoOutputSerializer(serializers.ModelSerializer):
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")
@@ -40,6 +74,38 @@ class ParkPhotoOutputSerializer(serializers.ModelSerializer):
"""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)
@@ -48,6 +114,8 @@ class ParkPhotoOutputSerializer(serializers.ModelSerializer):
fields = [
"id",
"image",
"image_url",
"image_variants",
"caption",
"alt_text",
"is_primary",
@@ -63,6 +131,8 @@ class ParkPhotoOutputSerializer(serializers.ModelSerializer):
]
read_only_fields = [
"id",
"image_url",
"image_variants",
"created_at",
"updated_at",
"uploaded_by_username",

View File

@@ -15,12 +15,13 @@ from .park_views import (
FilterOptionsAPIView,
CompanySearchAPIView,
ParkSearchSuggestionsAPIView,
ParkImageSettingsAPIView,
)
from .views import ParkPhotoViewSet
# Create router for nested photo endpoints
router = DefaultRouter()
router.register(r"photos", ParkPhotoViewSet, basename="park-photo")
router.register(r"", ParkPhotoViewSet, basename="park-photo")
app_name = "api_v1_parks"
@@ -42,6 +43,8 @@ urlpatterns = [
),
# Detail and action endpoints
path("<int:pk>/", ParkDetailAPIView.as_view(), name="park-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)),
]

View File

@@ -141,6 +141,12 @@ class ParkPhotoViewSet(ModelViewSet):
park_id = self.kwargs.get("park_pk")
if not park_id:
raise ValidationError("Park ID is required")
try:
park = 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())