feat: Implement initial schema and add various API, service, and management command enhancements across the application.

This commit is contained in:
pacnpal
2026-01-01 15:13:01 -05:00
parent c95f99ca10
commit b243b17af7
413 changed files with 11164 additions and 17433 deletions

View File

@@ -24,6 +24,7 @@ from apps.api.v1.serializers.companies import (
try:
from apps.rides.models.company import Company
MODELS_AVAILABLE = True
except ImportError:
Company = None
@@ -65,9 +66,7 @@ class CompanyListCreateAPIView(APIView):
# Search filter
search = request.query_params.get("search", "")
if search:
qs = qs.filter(
Q(name__icontains=search) | Q(description__icontains=search)
)
qs = qs.filter(Q(name__icontains=search) | Q(description__icontains=search))
# Role filter
role = request.query_params.get("role", "")
@@ -120,7 +119,7 @@ class CompanyDetailAPIView(APIView):
try:
return Company.objects.get(pk=pk)
except Company.DoesNotExist:
raise NotFound("Company not found")
raise NotFound("Company not found") from None
@extend_schema(
summary="Retrieve a company",

View File

@@ -93,18 +93,10 @@ class RideModelListCreateAPIView(APIView):
type=OpenApiTypes.STR,
required=True,
),
OpenApiParameter(
name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
),
OpenApiParameter(
name="page_size", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
),
OpenApiParameter(
name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
),
OpenApiParameter(
name="category", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
),
OpenApiParameter(name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT),
OpenApiParameter(name="page_size", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT),
OpenApiParameter(name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR),
OpenApiParameter(name="category", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR),
OpenApiParameter(
name="target_market",
location=OpenApiParameter.QUERY,
@@ -134,7 +126,7 @@ class RideModelListCreateAPIView(APIView):
try:
manufacturer = Company.objects.get(slug=manufacturer_slug)
except Company.DoesNotExist:
raise NotFound("Manufacturer not found")
raise NotFound("Manufacturer not found") from None
qs = (
RideModel.objects.filter(manufacturer=manufacturer)
@@ -176,13 +168,9 @@ class RideModelListCreateAPIView(APIView):
# Year filters
if filters.get("first_installation_year_min"):
qs = qs.filter(
first_installation_year__gte=filters["first_installation_year_min"]
)
qs = qs.filter(first_installation_year__gte=filters["first_installation_year_min"])
if filters.get("first_installation_year_max"):
qs = qs.filter(
first_installation_year__lte=filters["first_installation_year_max"]
)
qs = qs.filter(first_installation_year__lte=filters["first_installation_year_max"])
# Installation count filter
if filters.get("min_installations"):
@@ -190,23 +178,15 @@ class RideModelListCreateAPIView(APIView):
# Height filters
if filters.get("min_height_ft"):
qs = qs.filter(
typical_height_range_max_ft__gte=filters["min_height_ft"]
)
qs = qs.filter(typical_height_range_max_ft__gte=filters["min_height_ft"])
if filters.get("max_height_ft"):
qs = qs.filter(
typical_height_range_min_ft__lte=filters["max_height_ft"]
)
qs = qs.filter(typical_height_range_min_ft__lte=filters["max_height_ft"])
# Speed filters
if filters.get("min_speed_mph"):
qs = qs.filter(
typical_speed_range_max_mph__gte=filters["min_speed_mph"]
)
qs = qs.filter(typical_speed_range_max_mph__gte=filters["min_speed_mph"])
if filters.get("max_speed_mph"):
qs = qs.filter(
typical_speed_range_min_mph__lte=filters["max_speed_mph"]
)
qs = qs.filter(typical_speed_range_min_mph__lte=filters["max_speed_mph"])
# Ordering
ordering = filters.get("ordering", "manufacturer__name,name")
@@ -216,9 +196,7 @@ class RideModelListCreateAPIView(APIView):
paginator = StandardResultsSetPagination()
page = paginator.paginate_queryset(qs, request)
serializer = RideModelListOutputSerializer(
page, many=True, context={"request": request}
)
serializer = RideModelListOutputSerializer(page, many=True, context={"request": request})
return paginator.get_paginated_response(serializer.data)
@extend_schema(
@@ -240,9 +218,7 @@ class RideModelListCreateAPIView(APIView):
"""Create a new ride model for a specific manufacturer."""
if not MODELS_AVAILABLE:
return Response(
{
"detail": "Ride model creation is not available because domain models are not imported."
},
{"detail": "Ride model creation is not available because domain models are not imported."},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
@@ -250,7 +226,7 @@ class RideModelListCreateAPIView(APIView):
try:
manufacturer = Company.objects.get(slug=manufacturer_slug)
except Company.DoesNotExist:
raise NotFound("Manufacturer not found")
raise NotFound("Manufacturer not found") from None
serializer_in = RideModelCreateInputSerializer(data=request.data)
serializer_in.is_valid(raise_exception=True)
@@ -279,18 +255,14 @@ class RideModelListCreateAPIView(APIView):
target_market=validated.get("target_market", ""),
)
out_serializer = RideModelDetailOutputSerializer(
ride_model, context={"request": request}
)
out_serializer = RideModelDetailOutputSerializer(ride_model, context={"request": request})
return Response(out_serializer.data, status=status.HTTP_201_CREATED)
class RideModelDetailAPIView(APIView):
permission_classes = [permissions.AllowAny]
def _get_ride_model_or_404(
self, manufacturer_slug: str, ride_model_slug: str
) -> Any:
def _get_ride_model_or_404(self, manufacturer_slug: str, ride_model_slug: str) -> Any:
if not MODELS_AVAILABLE:
raise NotFound("Ride model models not available")
try:
@@ -300,7 +272,7 @@ class RideModelDetailAPIView(APIView):
.get(manufacturer__slug=manufacturer_slug, slug=ride_model_slug)
)
except RideModel.DoesNotExist:
raise NotFound("Ride model not found")
raise NotFound("Ride model not found") from None
@extend_schema(
summary="Retrieve a ride model",
@@ -322,13 +294,9 @@ class RideModelDetailAPIView(APIView):
responses={200: RideModelDetailOutputSerializer()},
tags=["Ride Models"],
)
def get(
self, request: Request, manufacturer_slug: str, ride_model_slug: str
) -> Response:
def get(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response:
ride_model = self._get_ride_model_or_404(manufacturer_slug, ride_model_slug)
serializer = RideModelDetailOutputSerializer(
ride_model, context={"request": request}
)
serializer = RideModelDetailOutputSerializer(ride_model, context={"request": request})
return Response(serializer.data)
@extend_schema(
@@ -352,9 +320,7 @@ class RideModelDetailAPIView(APIView):
responses={200: RideModelDetailOutputSerializer()},
tags=["Ride Models"],
)
def patch(
self, request: Request, manufacturer_slug: str, ride_model_slug: str
) -> Response:
def patch(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response:
ride_model = self._get_ride_model_or_404(manufacturer_slug, ride_model_slug)
serializer_in = RideModelUpdateInputSerializer(data=request.data, partial=True)
serializer_in.is_valid(raise_exception=True)
@@ -366,20 +332,16 @@ class RideModelDetailAPIView(APIView):
manufacturer = Company.objects.get(id=value)
ride_model.manufacturer = manufacturer
except Company.DoesNotExist:
raise ValidationError({"manufacturer_id": "Manufacturer not found"})
raise ValidationError({"manufacturer_id": "Manufacturer not found"}) from None
else:
setattr(ride_model, field, value)
ride_model.save()
serializer = RideModelDetailOutputSerializer(
ride_model, context={"request": request}
)
serializer = RideModelDetailOutputSerializer(ride_model, context={"request": request})
return Response(serializer.data)
def put(
self, request: Request, manufacturer_slug: str, ride_model_slug: str
) -> Response:
def put(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response:
# Full replace - reuse patch behavior for simplicity
return self.patch(request, manufacturer_slug, ride_model_slug)
@@ -403,9 +365,7 @@ class RideModelDetailAPIView(APIView):
responses={204: None},
tags=["Ride Models"],
)
def delete(
self, request: Request, manufacturer_slug: str, ride_model_slug: str
) -> Response:
def delete(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response:
ride_model = self._get_ride_model_or_404(manufacturer_slug, ride_model_slug)
ride_model.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -449,9 +409,7 @@ class RideModelSearchAPIView(APIView):
)
qs = RideModel.objects.filter(
Q(name__icontains=q)
| Q(description__icontains=q)
| Q(manufacturer__name__icontains=q)
Q(name__icontains=q) | Q(description__icontains=q) | Q(manufacturer__name__icontains=q)
).select_related("manufacturer")[:20]
results = [
@@ -491,8 +449,8 @@ class RideModelFilterOptionsAPIView(APIView):
# Use Rich Choice Objects for fallback options
try:
# Get rich choice objects from registry
categories = get_choices('categories', 'rides')
target_markets = get_choices('target_markets', 'rides')
categories = get_choices("categories", "rides")
target_markets = get_choices("target_markets", "rides")
# Convert Rich Choice Objects to frontend format with metadata
categories_data = [
@@ -500,10 +458,10 @@ class RideModelFilterOptionsAPIView(APIView):
"value": choice.value,
"label": choice.label,
"description": choice.description,
"color": choice.metadata.get('color'),
"icon": choice.metadata.get('icon'),
"css_class": choice.metadata.get('css_class'),
"sort_order": choice.metadata.get('sort_order', 0)
"color": choice.metadata.get("color"),
"icon": choice.metadata.get("icon"),
"css_class": choice.metadata.get("css_class"),
"sort_order": choice.metadata.get("sort_order", 0),
}
for choice in categories
]
@@ -513,10 +471,10 @@ class RideModelFilterOptionsAPIView(APIView):
"value": choice.value,
"label": choice.label,
"description": choice.description,
"color": choice.metadata.get('color'),
"icon": choice.metadata.get('icon'),
"css_class": choice.metadata.get('css_class'),
"sort_order": choice.metadata.get('sort_order', 0)
"color": choice.metadata.get("color"),
"icon": choice.metadata.get("icon"),
"css_class": choice.metadata.get("css_class"),
"sort_order": choice.metadata.get("sort_order", 0),
}
for choice in target_markets
]
@@ -524,25 +482,173 @@ class RideModelFilterOptionsAPIView(APIView):
except Exception:
# Ultimate fallback with basic structure
categories_data = [
{"value": "RC", "label": "Roller Coaster", "description": "High-speed thrill rides with tracks", "color": "red", "icon": "roller-coaster", "css_class": "bg-red-100 text-red-800", "sort_order": 1},
{"value": "DR", "label": "Dark Ride", "description": "Indoor themed experiences", "color": "purple", "icon": "dark-ride", "css_class": "bg-purple-100 text-purple-800", "sort_order": 2},
{"value": "FR", "label": "Flat Ride", "description": "Spinning and rotating attractions", "color": "blue", "icon": "flat-ride", "css_class": "bg-blue-100 text-blue-800", "sort_order": 3},
{"value": "WR", "label": "Water Ride", "description": "Water-based attractions and slides", "color": "cyan", "icon": "water-ride", "css_class": "bg-cyan-100 text-cyan-800", "sort_order": 4},
{"value": "TR", "label": "Transport", "description": "Transportation systems within parks", "color": "green", "icon": "transport", "css_class": "bg-green-100 text-green-800", "sort_order": 5},
{"value": "OT", "label": "Other", "description": "Miscellaneous attractions", "color": "gray", "icon": "other", "css_class": "bg-gray-100 text-gray-800", "sort_order": 6},
{
"value": "RC",
"label": "Roller Coaster",
"description": "High-speed thrill rides with tracks",
"color": "red",
"icon": "roller-coaster",
"css_class": "bg-red-100 text-red-800",
"sort_order": 1,
},
{
"value": "DR",
"label": "Dark Ride",
"description": "Indoor themed experiences",
"color": "purple",
"icon": "dark-ride",
"css_class": "bg-purple-100 text-purple-800",
"sort_order": 2,
},
{
"value": "FR",
"label": "Flat Ride",
"description": "Spinning and rotating attractions",
"color": "blue",
"icon": "flat-ride",
"css_class": "bg-blue-100 text-blue-800",
"sort_order": 3,
},
{
"value": "WR",
"label": "Water Ride",
"description": "Water-based attractions and slides",
"color": "cyan",
"icon": "water-ride",
"css_class": "bg-cyan-100 text-cyan-800",
"sort_order": 4,
},
{
"value": "TR",
"label": "Transport",
"description": "Transportation systems within parks",
"color": "green",
"icon": "transport",
"css_class": "bg-green-100 text-green-800",
"sort_order": 5,
},
{
"value": "OT",
"label": "Other",
"description": "Miscellaneous attractions",
"color": "gray",
"icon": "other",
"css_class": "bg-gray-100 text-gray-800",
"sort_order": 6,
},
]
target_markets_data = [
{"value": "FAMILY", "label": "Family", "description": "Suitable for all family members", "color": "green", "icon": "family", "css_class": "bg-green-100 text-green-800", "sort_order": 1},
{"value": "THRILL", "label": "Thrill", "description": "High-intensity thrill experience", "color": "orange", "icon": "thrill", "css_class": "bg-orange-100 text-orange-800", "sort_order": 2},
{"value": "EXTREME", "label": "Extreme", "description": "Maximum intensity experience", "color": "red", "icon": "extreme", "css_class": "bg-red-100 text-red-800", "sort_order": 3},
{"value": "KIDDIE", "label": "Kiddie", "description": "Designed for young children", "color": "pink", "icon": "kiddie", "css_class": "bg-pink-100 text-pink-800", "sort_order": 4},
{"value": "ALL_AGES", "label": "All Ages", "description": "Enjoyable for all age groups", "color": "blue", "icon": "all-ages", "css_class": "bg-blue-100 text-blue-800", "sort_order": 5},
{
"value": "FAMILY",
"label": "Family",
"description": "Suitable for all family members",
"color": "green",
"icon": "family",
"css_class": "bg-green-100 text-green-800",
"sort_order": 1,
},
{
"value": "THRILL",
"label": "Thrill",
"description": "High-intensity thrill experience",
"color": "orange",
"icon": "thrill",
"css_class": "bg-orange-100 text-orange-800",
"sort_order": 2,
},
{
"value": "EXTREME",
"label": "Extreme",
"description": "Maximum intensity experience",
"color": "red",
"icon": "extreme",
"css_class": "bg-red-100 text-red-800",
"sort_order": 3,
},
{
"value": "KIDDIE",
"label": "Kiddie",
"description": "Designed for young children",
"color": "pink",
"icon": "kiddie",
"css_class": "bg-pink-100 text-pink-800",
"sort_order": 4,
},
{
"value": "ALL_AGES",
"label": "All Ages",
"description": "Enjoyable for all age groups",
"color": "blue",
"icon": "all-ages",
"css_class": "bg-blue-100 text-blue-800",
"sort_order": 5,
},
]
return Response({
return Response(
{
"categories": categories_data,
"target_markets": target_markets_data,
"manufacturers": [{"id": 1, "name": "Bolliger & Mabillard", "slug": "bolliger-mabillard"}],
"ordering_options": [
{"value": "name", "label": "Name A-Z"},
{"value": "-name", "label": "Name Z-A"},
{"value": "manufacturer__name", "label": "Manufacturer A-Z"},
{"value": "-manufacturer__name", "label": "Manufacturer Z-A"},
{"value": "first_installation_year", "label": "Oldest First"},
{"value": "-first_installation_year", "label": "Newest First"},
{"value": "total_installations", "label": "Fewest Installations"},
{"value": "-total_installations", "label": "Most Installations"},
],
}
)
# Get static choice definitions from Rich Choice Objects (primary source)
# Get dynamic data from database queries
# Get rich choice objects from registry
categories = get_choices("categories", "rides")
target_markets = get_choices("target_markets", "rides")
# Convert Rich Choice Objects to frontend format with metadata
categories_data = [
{
"value": choice.value,
"label": choice.label,
"description": choice.description,
"color": choice.metadata.get("color"),
"icon": choice.metadata.get("icon"),
"css_class": choice.metadata.get("css_class"),
"sort_order": choice.metadata.get("sort_order", 0),
}
for choice in categories
]
target_markets_data = [
{
"value": choice.value,
"label": choice.label,
"description": choice.description,
"color": choice.metadata.get("color"),
"icon": choice.metadata.get("icon"),
"css_class": choice.metadata.get("css_class"),
"sort_order": choice.metadata.get("sort_order", 0),
}
for choice in target_markets
]
# Get actual data from database
manufacturers = (
Company.objects.filter(roles__contains=["MANUFACTURER"], ride_models__isnull=False)
.distinct()
.values("id", "name", "slug")
)
return Response(
{
"categories": categories_data,
"target_markets": target_markets_data,
"manufacturers": [{"id": 1, "name": "Bolliger & Mabillard", "slug": "bolliger-mabillard"}],
"manufacturers": list(manufacturers),
"ordering_options": [
{"value": "name", "label": "Name A-Z"},
{"value": "-name", "label": "Name Z-A"},
@@ -553,68 +659,9 @@ class RideModelFilterOptionsAPIView(APIView):
{"value": "total_installations", "label": "Fewest Installations"},
{"value": "-total_installations", "label": "Most Installations"},
],
})
# Get static choice definitions from Rich Choice Objects (primary source)
# Get dynamic data from database queries
# Get rich choice objects from registry
categories = get_choices('categories', 'rides')
target_markets = get_choices('target_markets', 'rides')
# Convert Rich Choice Objects to frontend format with metadata
categories_data = [
{
"value": choice.value,
"label": choice.label,
"description": choice.description,
"color": choice.metadata.get('color'),
"icon": choice.metadata.get('icon'),
"css_class": choice.metadata.get('css_class'),
"sort_order": choice.metadata.get('sort_order', 0)
}
for choice in categories
]
target_markets_data = [
{
"value": choice.value,
"label": choice.label,
"description": choice.description,
"color": choice.metadata.get('color'),
"icon": choice.metadata.get('icon'),
"css_class": choice.metadata.get('css_class'),
"sort_order": choice.metadata.get('sort_order', 0)
}
for choice in target_markets
]
# Get actual data from database
manufacturers = (
Company.objects.filter(
roles__contains=["MANUFACTURER"], ride_models__isnull=False
)
.distinct()
.values("id", "name", "slug")
)
return Response({
"categories": categories_data,
"target_markets": target_markets_data,
"manufacturers": list(manufacturers),
"ordering_options": [
{"value": "name", "label": "Name A-Z"},
{"value": "-name", "label": "Name Z-A"},
{"value": "manufacturer__name", "label": "Manufacturer A-Z"},
{"value": "-manufacturer__name", "label": "Manufacturer Z-A"},
{"value": "first_installation_year", "label": "Oldest First"},
{"value": "-first_installation_year", "label": "Newest First"},
{"value": "total_installations", "label": "Fewest Installations"},
{"value": "-total_installations", "label": "Most Installations"},
],
})
# === RIDE MODEL STATISTICS ===
@@ -646,37 +693,23 @@ class RideModelStatsAPIView(APIView):
# Calculate statistics
total_models = RideModel.objects.count()
total_installations = (
RideModel.objects.aggregate(total=Count("rides"))["total"] or 0
)
total_installations = RideModel.objects.aggregate(total=Count("rides"))["total"] or 0
active_manufacturers = (
Company.objects.filter(
roles__contains=["MANUFACTURER"], ride_models__isnull=False
)
.distinct()
.count()
Company.objects.filter(roles__contains=["MANUFACTURER"], ride_models__isnull=False).distinct().count()
)
discontinued_models = RideModel.objects.filter(is_discontinued=True).count()
# Category breakdown
by_category = {}
category_counts = (
RideModel.objects.exclude(category="")
.values("category")
.annotate(count=Count("id"))
)
category_counts = RideModel.objects.exclude(category="").values("category").annotate(count=Count("id"))
for item in category_counts:
by_category[item["category"]] = item["count"]
# Target market breakdown
by_target_market = {}
market_counts = (
RideModel.objects.exclude(target_market="")
.values("target_market")
.annotate(count=Count("id"))
)
market_counts = RideModel.objects.exclude(target_market="").values("target_market").annotate(count=Count("id"))
for item in market_counts:
by_target_market[item["target_market"]] = item["count"]
@@ -693,9 +726,7 @@ class RideModelStatsAPIView(APIView):
# Recent models (last 30 days)
thirty_days_ago = timezone.now() - timedelta(days=30)
recent_models = RideModel.objects.filter(
created_at__gte=thirty_days_ago
).count()
recent_models = RideModel.objects.filter(created_at__gte=thirty_days_ago).count()
return Response(
{
@@ -730,7 +761,7 @@ class RideModelVariantListCreateAPIView(APIView):
try:
ride_model = RideModel.objects.get(pk=ride_model_pk)
except RideModel.DoesNotExist:
raise NotFound("Ride model not found")
raise NotFound("Ride model not found") from None
variants = RideModelVariant.objects.filter(ride_model=ride_model)
serializer = RideModelVariantOutputSerializer(variants, many=True)
@@ -753,7 +784,7 @@ class RideModelVariantListCreateAPIView(APIView):
try:
ride_model = RideModel.objects.get(pk=ride_model_pk)
except RideModel.DoesNotExist:
raise NotFound("Ride model not found")
raise NotFound("Ride model not found") from None
# Override ride_model_id in the data
data = request.data.copy()
@@ -787,7 +818,7 @@ class RideModelVariantDetailAPIView(APIView):
try:
return RideModelVariant.objects.get(ride_model_id=ride_model_pk, pk=pk)
except RideModelVariant.DoesNotExist:
raise NotFound("Variant not found")
raise NotFound("Variant not found") from None
@extend_schema(
summary="Get a ride model variant",
@@ -807,9 +838,7 @@ class RideModelVariantDetailAPIView(APIView):
)
def patch(self, request: Request, ride_model_pk: int, pk: int) -> Response:
variant = self._get_variant_or_404(ride_model_pk, pk)
serializer_in = RideModelVariantUpdateInputSerializer(
data=request.data, partial=True
)
serializer_in = RideModelVariantUpdateInputSerializer(data=request.data, partial=True)
serializer_in.is_valid(raise_exception=True)
for field, value in serializer_in.validated_data.items():

View File

@@ -118,9 +118,7 @@ class RidePhotoViewSet(ModelViewSet):
def get_queryset(self): # type: ignore[override]
"""Get photos for the current ride with optimized queries."""
queryset = RidePhoto.objects.select_related(
"ride", "ride__park", "ride__park__operator", "uploaded_by"
)
queryset = RidePhoto.objects.select_related("ride", "ride__park", "ride__park__operator", "uploaded_by")
# If ride_pk is provided in URL kwargs, filter by ride
ride_pk = self.kwargs.get("ride_pk")
@@ -149,7 +147,7 @@ class RidePhotoViewSet(ModelViewSet):
try:
ride = Ride.objects.get(pk=ride_id)
except Ride.DoesNotExist:
raise ValidationError("Ride not found")
raise ValidationError("Ride not found") from None
try:
# Use the service to create the photo with proper business logic
@@ -169,17 +167,14 @@ class RidePhotoViewSet(ModelViewSet):
except Exception as e:
logger.error(f"Error creating ride photo: {e}")
raise ValidationError(f"Failed to create photo: {str(e)}")
raise ValidationError(f"Failed to create photo: {str(e)}") from None
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)
):
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
@@ -191,39 +186,31 @@ class RidePhotoViewSet(ModelViewSet):
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)}")
raise ValidationError(f"Failed to set primary photo: {str(e)}") from None
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."
)
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}")
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)}")
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 # type: ignore
)
RideMediaService.delete_photo(instance, deleted_by=self.request.user) # type: ignore
except Exception as e:
logger.error(f"Error deleting ride photo: {e}")
raise ValidationError(f"Failed to delete photo: {str(e)}")
raise ValidationError(f"Failed to delete photo: {str(e)}") from None
@extend_schema(
summary="Set photo as primary",
@@ -242,13 +229,8 @@ class RidePhotoViewSet(ModelViewSet):
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."
)
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)
@@ -260,21 +242,21 @@ class RidePhotoViewSet(ModelViewSet):
return Response(
{
"message": "Photo set as primary successfully",
"detail": "Photo set as primary successfully",
"photo": serializer.data,
},
status=status.HTTP_200_OK,
)
else:
return Response(
{"error": "Failed to set primary photo"},
{"detail": "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)}"},
{"detail": f"Failed to set primary photo: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -305,7 +287,7 @@ class RidePhotoViewSet(ModelViewSet):
if photo_ids is None or approve is None:
return Response(
{"error": "Missing required fields: photo_ids and/or approve."},
{"detail": "Missing required fields: photo_ids and/or approve."},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -319,7 +301,7 @@ class RidePhotoViewSet(ModelViewSet):
return Response(
{
"message": f"Successfully {'approved' if approve else 'rejected'} {updated_count} photos",
"detail": f"Successfully {'approved' if approve else 'rejected'} {updated_count} photos",
"updated_count": updated_count,
},
status=status.HTTP_200_OK,
@@ -328,7 +310,7 @@ class RidePhotoViewSet(ModelViewSet):
except Exception as e:
logger.error(f"Error in bulk photo approval: {e}")
return Response(
{"error": f"Failed to update photos: {str(e)}"},
{"detail": f"Failed to update photos: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -352,7 +334,7 @@ class RidePhotoViewSet(ModelViewSet):
ride = Ride.objects.get(pk=ride_pk)
except Ride.DoesNotExist:
return Response(
{"error": "Ride not found."},
{"detail": "Ride not found."},
status=status.HTTP_404_NOT_FOUND,
)
@@ -363,16 +345,10 @@ class RidePhotoViewSet(ModelViewSet):
# Global stats across all rides
stats = {
"total_photos": RidePhoto.objects.count(),
"approved_photos": RidePhoto.objects.filter(
is_approved=True
).count(),
"pending_photos": RidePhoto.objects.filter(
is_approved=False
).count(),
"approved_photos": RidePhoto.objects.filter(is_approved=True).count(),
"pending_photos": RidePhoto.objects.filter(is_approved=False).count(),
"has_primary": False, # Not applicable for global stats
"recent_uploads": RidePhoto.objects.order_by("-created_at")[
:5
].count(),
"recent_uploads": RidePhoto.objects.order_by("-created_at")[:5].count(),
"by_type": {},
}
@@ -382,7 +358,7 @@ class RidePhotoViewSet(ModelViewSet):
except Exception as e:
logger.error(f"Error getting ride photo stats: {e}")
return Response(
{"error": f"Failed to get photo statistics: {str(e)}"},
{"detail": f"Failed to get photo statistics: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -401,26 +377,23 @@ class RidePhotoViewSet(ModelViewSet):
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("rides.change_ridephoto")
):
if not (request.user == photo.uploaded_by or request.user.has_perm("rides.change_ridephoto")):
return Response(
{"error": "You do not have permission to edit photos for this ride."},
{"detail": "You do not have permission to edit photos for this ride."},
status=status.HTTP_403_FORBIDDEN,
)
try:
success = RideMediaService.set_primary_photo(ride=photo.ride, photo=photo)
if success:
return Response({"message": "Photo set as primary successfully."})
return Response({"detail": "Photo set as primary successfully."})
else:
return Response(
{"error": "Failed to set primary photo"},
{"detail": "Failed to set primary photo"},
status=status.HTTP_400_BAD_REQUEST,
)
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)
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@extend_schema(
summary="Save Cloudflare image as ride photo",
@@ -440,7 +413,7 @@ class RidePhotoViewSet(ModelViewSet):
ride_pk = self.kwargs.get("ride_pk")
if not ride_pk:
return Response(
{"error": "Ride ID is required"},
{"detail": "Ride ID is required"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -448,14 +421,14 @@ class RidePhotoViewSet(ModelViewSet):
ride = Ride.objects.get(pk=ride_pk)
except Ride.DoesNotExist:
return Response(
{"error": "Ride not found"},
{"detail": "Ride 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"},
{"detail": "cloudflare_image_id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -473,27 +446,25 @@ class RidePhotoViewSet(ModelViewSet):
if not image_data:
return Response(
{"error": "Image not found in Cloudflare"},
{"detail": "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)
cloudflare_image = CloudflareImage.objects.get(cloudflare_id=cloudflare_image_id)
# Update existing record with latest data from Cloudflare
cloudflare_image.status = 'uploaded'
cloudflare_image.status = "uploaded"
cloudflare_image.uploaded_at = timezone.now()
cloudflare_image.metadata = image_data.get('meta', {})
cloudflare_image.metadata = image_data.get("meta", {})
# Extract variants from nested result structure
cloudflare_image.variants = image_data.get(
'result', {}).get('variants', [])
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.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:
@@ -501,24 +472,23 @@ class RidePhotoViewSet(ModelViewSet):
cloudflare_image = CloudflareImage.objects.create(
cloudflare_id=cloudflare_image_id,
user=request.user,
status='uploaded',
upload_url='', # Not needed for uploaded images
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', {}),
metadata=image_data.get("meta", {}),
# Extract variants from nested result structure
variants=image_data.get('result', {}).get('variants', []),
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', ''),
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)
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)}"},
{"detail": f"Failed to fetch image from Cloudflare: {str(api_error)}"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -548,6 +518,6 @@ class RidePhotoViewSet(ModelViewSet):
except Exception as e:
logger.error(f"Error saving ride photo: {e}")
return Response(
{"error": f"Failed to save photo: {str(e)}"},
{"detail": f"Failed to save photo: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -52,18 +52,14 @@ from apps.rides.models import Ride, RidePhoto
class RidePhotoOutputSerializer(serializers.ModelSerializer):
"""Output serializer for ride photos with Cloudflare Images support."""
uploaded_by_username = serializers.CharField(
source="uploaded_by.username", read_only=True
)
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")
)
@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
@@ -81,11 +77,7 @@ class RidePhotoOutputSerializer(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
)
)
@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:
@@ -186,9 +178,7 @@ class RidePhotoUpdateInputSerializer(serializers.ModelSerializer):
class RidePhotoListOutputSerializer(serializers.ModelSerializer):
"""Simplified output serializer for ride photo lists."""
uploaded_by_username = serializers.CharField(
source="uploaded_by.username", read_only=True
)
uploaded_by_username = serializers.CharField(source="uploaded_by.username", read_only=True)
class Meta:
model = RidePhoto
@@ -208,12 +198,8 @@ class RidePhotoListOutputSerializer(serializers.ModelSerializer):
class RidePhotoApprovalInputSerializer(serializers.Serializer):
"""Input serializer for 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"
)
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 RidePhotoStatsOutputSerializer(serializers.Serializer):
@@ -224,9 +210,7 @@ class RidePhotoStatsOutputSerializer(serializers.Serializer):
pending_photos = serializers.IntegerField()
has_primary = serializers.BooleanField()
recent_uploads = serializers.IntegerField()
by_type = serializers.DictField(
child=serializers.IntegerField(), help_text="Photo counts by type"
)
by_type = serializers.DictField(child=serializers.IntegerField(), help_text="Photo counts by type")
class RidePhotoTypeFilterSerializer(serializers.Serializer):
@@ -292,8 +276,12 @@ class HybridRideSerializer(serializers.ModelSerializer):
ride_model_name = serializers.CharField(source="ride_model.name", read_only=True, allow_null=True)
ride_model_slug = serializers.CharField(source="ride_model.slug", read_only=True, allow_null=True)
ride_model_category = serializers.CharField(source="ride_model.category", read_only=True, allow_null=True)
ride_model_manufacturer_name = serializers.CharField(source="ride_model.manufacturer.name", read_only=True, allow_null=True)
ride_model_manufacturer_slug = serializers.CharField(source="ride_model.manufacturer.slug", read_only=True, allow_null=True)
ride_model_manufacturer_name = serializers.CharField(
source="ride_model.manufacturer.name", read_only=True, allow_null=True
)
ride_model_manufacturer_slug = serializers.CharField(
source="ride_model.manufacturer.slug", read_only=True, allow_null=True
)
# Roller coaster stats fields
coaster_height_ft = serializers.SerializerMethodField()
@@ -323,7 +311,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_park_city(self, obj):
"""Get city from park location."""
try:
if obj.park and hasattr(obj.park, 'location') and obj.park.location:
if obj.park and hasattr(obj.park, "location") and obj.park.location:
return obj.park.location.city
return None
except AttributeError:
@@ -333,7 +321,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_park_state(self, obj):
"""Get state from park location."""
try:
if obj.park and hasattr(obj.park, 'location') and obj.park.location:
if obj.park and hasattr(obj.park, "location") and obj.park.location:
return obj.park.location.state
return None
except AttributeError:
@@ -343,7 +331,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_park_country(self, obj):
"""Get country from park location."""
try:
if obj.park and hasattr(obj.park, 'location') and obj.park.location:
if obj.park and hasattr(obj.park, "location") and obj.park.location:
return obj.park.location.country
return None
except AttributeError:
@@ -353,7 +341,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_height_ft(self, obj):
"""Get roller coaster height."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return float(obj.coaster_stats.height_ft) if obj.coaster_stats.height_ft else None
return None
except (AttributeError, TypeError):
@@ -363,7 +351,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_length_ft(self, obj):
"""Get roller coaster length."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return float(obj.coaster_stats.length_ft) if obj.coaster_stats.length_ft else None
return None
except (AttributeError, TypeError):
@@ -373,7 +361,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_speed_mph(self, obj):
"""Get roller coaster speed."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return float(obj.coaster_stats.speed_mph) if obj.coaster_stats.speed_mph else None
return None
except (AttributeError, TypeError):
@@ -383,7 +371,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_inversions(self, obj):
"""Get roller coaster inversions."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return obj.coaster_stats.inversions
return None
except AttributeError:
@@ -393,7 +381,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_ride_time_seconds(self, obj):
"""Get roller coaster ride time."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return obj.coaster_stats.ride_time_seconds
return None
except AttributeError:
@@ -403,7 +391,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_track_type(self, obj):
"""Get roller coaster track type."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return obj.coaster_stats.track_type
return None
except AttributeError:
@@ -413,7 +401,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_track_material(self, obj):
"""Get roller coaster track material."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return obj.coaster_stats.track_material
return None
except AttributeError:
@@ -423,7 +411,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_roller_coaster_type(self, obj):
"""Get roller coaster type."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return obj.coaster_stats.roller_coaster_type
return None
except AttributeError:
@@ -433,7 +421,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_max_drop_height_ft(self, obj):
"""Get roller coaster max drop height."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return float(obj.coaster_stats.max_drop_height_ft) if obj.coaster_stats.max_drop_height_ft else None
return None
except (AttributeError, TypeError):
@@ -443,7 +431,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_propulsion_system(self, obj):
"""Get roller coaster propulsion system."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return obj.coaster_stats.propulsion_system
return None
except AttributeError:
@@ -453,7 +441,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_train_style(self, obj):
"""Get roller coaster train style."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return obj.coaster_stats.train_style
return None
except AttributeError:
@@ -463,7 +451,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_trains_count(self, obj):
"""Get roller coaster trains count."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return obj.coaster_stats.trains_count
return None
except AttributeError:
@@ -473,7 +461,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_cars_per_train(self, obj):
"""Get roller coaster cars per train."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return obj.coaster_stats.cars_per_train
return None
except AttributeError:
@@ -483,7 +471,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_seats_per_car(self, obj):
"""Get roller coaster seats per car."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return obj.coaster_stats.seats_per_car
return None
except AttributeError:
@@ -514,44 +502,37 @@ class HybridRideSerializer(serializers.ModelSerializer):
"category",
"status",
"post_closing_status",
# Dates and computed fields
"opening_date",
"closing_date",
"status_since",
"opening_year",
# Park fields
"park_name",
"park_slug",
"park_city",
"park_state",
"park_country",
# Park area fields
"park_area_name",
"park_area_slug",
# Company fields
"manufacturer_name",
"manufacturer_slug",
"designer_name",
"designer_slug",
# Ride model fields
"ride_model_name",
"ride_model_slug",
"ride_model_category",
"ride_model_manufacturer_name",
"ride_model_manufacturer_slug",
# Ride specifications
"min_height_in",
"max_height_in",
"capacity_per_hour",
"ride_duration_seconds",
"average_rating",
# Roller coaster stats
"coaster_height_ft",
"coaster_length_ft",
@@ -567,18 +548,14 @@ class HybridRideSerializer(serializers.ModelSerializer):
"coaster_trains_count",
"coaster_cars_per_train",
"coaster_seats_per_car",
# Images
"banner_image_url",
"card_image_url",
# URLs
"url",
"park_url",
# Computed fields for filtering
"search_text",
# Metadata
"created_at",
"updated_at",

View File

@@ -35,11 +35,9 @@ app_name = "api_v1_rides"
urlpatterns = [
# Core list/create endpoints
path("", RideListCreateAPIView.as_view(), name="ride-list-create"),
# Hybrid filtering endpoints
path("hybrid/", HybridRideAPIView.as_view(), name="ride-hybrid-filtering"),
path("hybrid/filter-metadata/", RideFilterMetadataAPIView.as_view(), name="ride-hybrid-filter-metadata"),
# Filter options
path("filter-options/", FilterOptionsAPIView.as_view(), name="ride-filter-options"),
# Autocomplete / suggestion endpoints
@@ -61,7 +59,6 @@ urlpatterns = [
# Manufacturer and Designer endpoints
path("manufacturers/", ManufacturerListAPIView.as_view(), name="manufacturer-list"),
path("designers/", DesignerListAPIView.as_view(), name="designer-list"),
# Ride model management endpoints - nested under rides/manufacturers
path(
"manufacturers/<slug:manufacturer_slug>/",

View File

@@ -28,6 +28,7 @@ import logging
from typing import Any
from django.db import models
from django.db.models import Count
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view
from rest_framework import permissions, status
@@ -333,9 +334,7 @@ class RideListCreateAPIView(APIView):
paginator = StandardResultsSetPagination()
page = paginator.paginate_queryset(qs, request)
serializer = RideListOutputSerializer(
page, many=True, context={"request": request}
)
serializer = RideListOutputSerializer(page, many=True, context={"request": request})
return paginator.get_paginated_response(serializer.data)
def _apply_filters(self, qs, params):
@@ -567,9 +566,9 @@ class RideListCreateAPIView(APIView):
if ordering in valid_orderings:
if ordering in ["height_ft", "-height_ft", "speed_mph", "-speed_mph"]:
# For coaster stats ordering, we need to join and order by the stats
ordering_field = ordering.replace(
"height_ft", "coaster_stats__height_ft"
).replace("speed_mph", "coaster_stats__speed_mph")
ordering_field = ordering.replace("height_ft", "coaster_stats__height_ft").replace(
"speed_mph", "coaster_stats__speed_mph"
)
qs = qs.order_by(ordering_field)
else:
qs = qs.order_by(ordering)
@@ -602,7 +601,7 @@ class RideListCreateAPIView(APIView):
try:
park = Park.objects.get(id=validated["park_id"]) # type: ignore
except Park.DoesNotExist: # type: ignore
raise NotFound("Park not found")
raise NotFound("Park not found") from None
ride = Ride.objects.create( # type: ignore
name=validated["name"],
@@ -658,7 +657,7 @@ class RideDetailAPIView(APIView):
try:
return Ride.objects.select_related("park").get(pk=pk) # type: ignore
except Ride.DoesNotExist: # type: ignore
raise NotFound("Ride not found")
raise NotFound("Ride not found") from None
@cache_api_response(timeout=1800, key_prefix="ride_detail")
def get(self, request: Request, pk: int) -> Response:
@@ -672,9 +671,7 @@ class RideDetailAPIView(APIView):
serializer_in.is_valid(raise_exception=True)
if not MODELS_AVAILABLE:
return Response(
{
"detail": "Ride update is not available because domain models are not imported."
},
{"detail": "Ride update is not available because domain models are not imported."},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
@@ -690,7 +687,7 @@ class RideDetailAPIView(APIView):
# Use the move_to_park method for proper handling
park_change_info = ride.move_to_park(new_park)
except Park.DoesNotExist: # type: ignore
raise NotFound("Target park not found")
raise NotFound("Target park not found") from None
# Apply other field updates
for key, value in validated_data.items():
@@ -715,9 +712,7 @@ class RideDetailAPIView(APIView):
def delete(self, request: Request, pk: int) -> Response:
if not MODELS_AVAILABLE:
return Response(
{
"detail": "Ride delete is not available because domain models are not imported."
},
{"detail": "Ride delete is not available because domain models are not imported."},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
ride = self._get_ride_or_404(pk)
@@ -1491,16 +1486,12 @@ class FilterOptionsAPIView(APIView):
# Get manufacturers (companies with MANUFACTURER role)
manufacturers = list(
Company.objects.filter(roles__contains=["MANUFACTURER"])
.values("id", "name", "slug")
.order_by("name")
Company.objects.filter(roles__contains=["MANUFACTURER"]).values("id", "name", "slug").order_by("name")
)
# Get designers (companies with DESIGNER role)
designers = list(
Company.objects.filter(roles__contains=["DESIGNER"])
.values("id", "name", "slug")
.order_by("name")
Company.objects.filter(roles__contains=["DESIGNER"]).values("id", "name", "slug").order_by("name")
)
# Get ride models data from database
@@ -1722,11 +1713,7 @@ class FilterOptionsAPIView(APIView):
# --- Company search (autocomplete) -----------------------------------------
@extend_schema(
summary="Search companies (manufacturers/designers) for autocomplete",
parameters=[
OpenApiParameter(
name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
)
],
parameters=[OpenApiParameter(name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR)],
responses={200: OpenApiTypes.OBJECT},
tags=["Rides"],
)
@@ -1753,20 +1740,14 @@ class CompanySearchAPIView(APIView):
)
qs = Company.objects.filter(name__icontains=q)[:20] # type: ignore
results = [
{"id": c.id, "name": c.name, "slug": getattr(c, "slug", "")} for c in qs
]
results = [{"id": c.id, "name": c.name, "slug": getattr(c, "slug", "")} for c in qs]
return Response(results)
# --- Ride model search (autocomplete) --------------------------------------
@extend_schema(
summary="Search ride models for autocomplete",
parameters=[
OpenApiParameter(
name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
)
],
parameters=[OpenApiParameter(name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR)],
tags=["Rides"],
)
class RideModelSearchAPIView(APIView):
@@ -1795,21 +1776,14 @@ class RideModelSearchAPIView(APIView):
)
qs = RideModel.objects.filter(name__icontains=q)[:20] # type: ignore
results = [
{"id": m.id, "name": m.name, "category": getattr(m, "category", "")}
for m in qs
]
results = [{"id": m.id, "name": m.name, "category": getattr(m, "category", "")} for m in qs]
return Response(results)
# --- Search suggestions -----------------------------------------------------
@extend_schema(
summary="Search suggestions for ride search box",
parameters=[
OpenApiParameter(
name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
)
],
parameters=[OpenApiParameter(name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR)],
tags=["Rides"],
)
class RideSearchSuggestionsAPIView(APIView):
@@ -1827,9 +1801,7 @@ class RideSearchSuggestionsAPIView(APIView):
# Very small suggestion implementation: look in ride names if available
if MODELS_AVAILABLE and Ride is not None:
qs = Ride.objects.filter(name__icontains=q).values_list("name", flat=True)[
:10
] # type: ignore
qs = Ride.objects.filter(name__icontains=q).values_list("name", flat=True)[:10] # type: ignore
return Response([{"suggestion": name} for name in qs])
# Fallback suggestions
@@ -1862,7 +1834,7 @@ class RideImageSettingsAPIView(APIView):
try:
return Ride.objects.get(pk=pk) # type: ignore
except Ride.DoesNotExist: # type: ignore
raise NotFound("Ride not found")
raise NotFound("Ride not found") from None
def patch(self, request: Request, pk: int) -> Response:
"""Set banner and card images for the ride."""
@@ -1878,9 +1850,7 @@ class RideImageSettingsAPIView(APIView):
ride.save()
# Return updated ride data
output_serializer = RideDetailOutputSerializer(
ride, context={"request": request}
)
output_serializer = RideDetailOutputSerializer(ride, context={"request": request})
return Response(output_serializer.data)
@@ -1902,12 +1872,8 @@ class RideImageSettingsAPIView(APIView):
OpenApiTypes.STR,
description="Filter by ride status (comma-separated for multiple)",
),
OpenApiParameter(
"park_slug", OpenApiTypes.STR, description="Filter by park slug"
),
OpenApiParameter(
"park_id", OpenApiTypes.INT, description="Filter by park ID"
),
OpenApiParameter("park_slug", OpenApiTypes.STR, description="Filter by park slug"),
OpenApiParameter("park_id", OpenApiTypes.INT, description="Filter by park ID"),
OpenApiParameter(
"manufacturer",
OpenApiTypes.STR,
@@ -1923,18 +1889,10 @@ class RideImageSettingsAPIView(APIView):
OpenApiTypes.STR,
description="Filter by ride model slug (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(
"rating_min", OpenApiTypes.NUMBER, description="Minimum average rating"
),
OpenApiParameter(
"rating_max", OpenApiTypes.NUMBER, description="Maximum average rating"
),
OpenApiParameter("opening_year_min", OpenApiTypes.INT, description="Minimum opening year"),
OpenApiParameter("opening_year_max", OpenApiTypes.INT, description="Maximum opening year"),
OpenApiParameter("rating_min", OpenApiTypes.NUMBER, description="Minimum average rating"),
OpenApiParameter("rating_max", OpenApiTypes.NUMBER, description="Maximum average rating"),
OpenApiParameter(
"height_requirement_min",
OpenApiTypes.INT,
@@ -1945,12 +1903,8 @@ class RideImageSettingsAPIView(APIView):
OpenApiTypes.INT,
description="Maximum height requirement in inches",
),
OpenApiParameter(
"capacity_min", OpenApiTypes.INT, description="Minimum hourly capacity"
),
OpenApiParameter(
"capacity_max", OpenApiTypes.INT, description="Maximum hourly capacity"
),
OpenApiParameter("capacity_min", OpenApiTypes.INT, description="Minimum hourly capacity"),
OpenApiParameter("capacity_max", OpenApiTypes.INT, description="Maximum hourly capacity"),
OpenApiParameter(
"roller_coaster_type",
OpenApiTypes.STR,
@@ -2022,9 +1976,7 @@ class RideImageSettingsAPIView(APIView):
"properties": {
"rides": {
"type": "array",
"items": {
"$ref": "#/components/schemas/HybridRideSerializer"
},
"items": {"$ref": "#/components/schemas/HybridRideSerializer"},
},
"total_count": {"type": "integer"},
"strategy": {
@@ -2084,7 +2036,7 @@ class HybridRideAPIView(APIView):
data = smart_ride_loader.get_progressive_load(offset, filters)
except ValueError:
return Response(
{"error": "Invalid offset parameter"},
{"detail": "Invalid offset parameter"},
status=status.HTTP_400_BAD_REQUEST,
)
else:
@@ -2109,7 +2061,7 @@ class HybridRideAPIView(APIView):
except Exception as e:
logger.error(f"Error in HybridRideAPIView: {e}")
return Response(
{"error": "Internal server error"},
{"detail": "Internal server error"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -2158,7 +2110,7 @@ class HybridRideAPIView(APIView):
for param in int_params:
value = query_params.get(param)
if value:
try:
try: # noqa: SIM105
filters[param] = int(value)
except ValueError:
pass # Skip invalid integer values
@@ -2175,7 +2127,7 @@ class HybridRideAPIView(APIView):
for param in float_params:
value = query_params.get(param)
if value:
try:
try: # noqa: SIM105
filters[param] = float(value)
except ValueError:
pass # Skip invalid float values
@@ -2408,7 +2360,7 @@ class RideFilterMetadataAPIView(APIView):
except Exception as e:
logger.error(f"Error in RideFilterMetadataAPIView: {e}")
return Response(
{"error": "Internal server error"},
{"detail": "Internal server error"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -2417,18 +2369,18 @@ class RideFilterMetadataAPIView(APIView):
# Reuse the same filter extraction logic
view = HybridRideAPIView()
return view._extract_filters(query_params)
# === MANUFACTURER & DESIGNER LISTS ===
class BaseCompanyListAPIView(APIView):
permission_classes = [permissions.AllowAny]
role = None
def get(self, request: Request) -> Response:
if not MODELS_AVAILABLE:
return Response(
{"detail": "Models not available"},
status=status.HTTP_501_NOT_IMPLEMENTED
)
return Response({"detail": "Models not available"}, status=status.HTTP_501_NOT_IMPLEMENTED)
companies = (
Company.objects.filter(roles__contains=[self.role])
@@ -2448,10 +2400,8 @@ class BaseCompanyListAPIView(APIView):
for c in companies
]
return Response({
"results": data,
"count": len(data)
})
return Response({"results": data, "count": len(data)})
@extend_schema(
summary="List manufacturers",
@@ -2462,6 +2412,7 @@ class BaseCompanyListAPIView(APIView):
class ManufacturerListAPIView(BaseCompanyListAPIView):
role = "MANUFACTURER"
@extend_schema(
summary="List designers",
description="List all companies with DESIGNER role.",