mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-29 23:27:03 -05:00
feat: Implement MFA authentication, add ride statistics model, and update various services, APIs, and tests across the application.
This commit is contained in:
@@ -2,14 +2,16 @@
|
||||
Park history API views.
|
||||
"""
|
||||
|
||||
from rest_framework import viewsets, mixins
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.api.v1.serializers.history import ParkHistoryOutputSerializer, RideHistoryOutputSerializer
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.models import Ride
|
||||
from apps.api.v1.serializers.history import ParkHistoryOutputSerializer, RideHistoryOutputSerializer
|
||||
|
||||
|
||||
class ParkHistoryViewSet(viewsets.GenericViewSet):
|
||||
"""
|
||||
@@ -18,7 +20,7 @@ class ParkHistoryViewSet(viewsets.GenericViewSet):
|
||||
permission_classes = [AllowAny]
|
||||
lookup_field = "slug"
|
||||
lookup_url_kwarg = "park_slug"
|
||||
|
||||
|
||||
@extend_schema(
|
||||
summary="Get park history",
|
||||
description="Retrieve history events for a park.",
|
||||
@@ -27,24 +29,24 @@ class ParkHistoryViewSet(viewsets.GenericViewSet):
|
||||
)
|
||||
def list(self, request, park_slug=None):
|
||||
park = get_object_or_404(Park, slug=park_slug)
|
||||
|
||||
|
||||
events = []
|
||||
if hasattr(park, "events"):
|
||||
events = park.events.all().order_by("-pgh_created_at")
|
||||
|
||||
|
||||
summary = {
|
||||
"total_events": len(events),
|
||||
"first_recorded": events.last().pgh_created_at if len(events) else None,
|
||||
"last_modified": events.first().pgh_created_at if len(events) else None,
|
||||
}
|
||||
|
||||
|
||||
data = {
|
||||
"park": park,
|
||||
"current_state": park,
|
||||
"summary": summary,
|
||||
"events": events
|
||||
}
|
||||
|
||||
|
||||
serializer = ParkHistoryOutputSerializer(data)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@@ -6,27 +6,26 @@ Provides CRUD operations for park reviews nested under parks/{slug}/reviews/
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import Avg
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.utils import extend_schema_view, extend_schema
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
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.exceptions import NotFound, ValidationError
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from apps.parks.models import Park, ParkReview
|
||||
from apps.api.v1.serializers.park_reviews import (
|
||||
ParkReviewOutputSerializer,
|
||||
ParkReviewCreateInputSerializer,
|
||||
ParkReviewUpdateInputSerializer,
|
||||
ParkReviewListOutputSerializer,
|
||||
ParkReviewOutputSerializer,
|
||||
ParkReviewStatsOutputSerializer,
|
||||
ParkReviewModerationInputSerializer,
|
||||
ParkReviewUpdateInputSerializer,
|
||||
)
|
||||
from apps.parks.models import Park, ParkReview
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -66,10 +65,7 @@ class ParkReviewViewSet(ModelViewSet):
|
||||
|
||||
def get_permissions(self):
|
||||
"""Set permissions based on action."""
|
||||
if self.action in ['list', 'retrieve', 'stats']:
|
||||
permission_classes = [AllowAny]
|
||||
else:
|
||||
permission_classes = [IsAuthenticated]
|
||||
permission_classes = [AllowAny] if self.action in ['list', 'retrieve', 'stats'] else [IsAuthenticated]
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -143,7 +139,7 @@ class ParkReviewViewSet(ModelViewSet):
|
||||
reviews = ParkReview.objects.filter(park=park, is_published=True)
|
||||
total_reviews = reviews.count()
|
||||
avg_rating = reviews.aggregate(avg=Avg('rating'))['avg']
|
||||
|
||||
|
||||
rating_distribution = {}
|
||||
for i in range(1, 11):
|
||||
rating_distribution[str(i)] = reviews.filter(rating=i).count()
|
||||
|
||||
@@ -6,19 +6,16 @@ This module implements endpoints for accessing rides within specific parks:
|
||||
- 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 import Q
|
||||
from django.db.models.query import QuerySet
|
||||
|
||||
from rest_framework import status, permissions
|
||||
from rest_framework.views import APIView
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from rest_framework import permissions, status
|
||||
from rest_framework.exceptions import NotFound
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
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
|
||||
from rest_framework.views import APIView
|
||||
|
||||
# Import models
|
||||
try:
|
||||
@@ -32,8 +29,8 @@ except Exception:
|
||||
|
||||
# Import serializers
|
||||
try:
|
||||
from apps.api.v1.serializers.rides import RideListOutputSerializer, RideDetailOutputSerializer
|
||||
from apps.api.v1.serializers.parks import ParkDetailOutputSerializer
|
||||
from apps.api.v1.serializers.rides import RideDetailOutputSerializer, RideListOutputSerializer
|
||||
SERIALIZERS_AVAILABLE = True
|
||||
except Exception:
|
||||
SERIALIZERS_AVAILABLE = False
|
||||
@@ -47,7 +44,7 @@ class StandardResultsSetPagination(PageNumberPagination):
|
||||
|
||||
class ParkRidesListAPIView(APIView):
|
||||
"""List rides at a specific park with pagination and filtering."""
|
||||
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
@@ -59,7 +56,7 @@ class ParkRidesListAPIView(APIView):
|
||||
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"),
|
||||
@@ -67,7 +64,7 @@ class ParkRidesListAPIView(APIView):
|
||||
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"),
|
||||
@@ -158,7 +155,7 @@ class ParkRidesListAPIView(APIView):
|
||||
|
||||
class ParkRideDetailAPIView(APIView):
|
||||
"""Get specific ride details within park context."""
|
||||
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
@@ -222,7 +219,7 @@ class ParkRideDetailAPIView(APIView):
|
||||
|
||||
class ParkComprehensiveDetailAPIView(APIView):
|
||||
"""Get comprehensive park details including summary of rides."""
|
||||
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
@@ -271,7 +268,7 @@ class ParkComprehensiveDetailAPIView(APIView):
|
||||
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,
|
||||
|
||||
@@ -11,23 +11,24 @@ This module implements comprehensive park endpoints with full filtering support:
|
||||
Supports all 24 filtering parameters from frontend API documentation.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
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 django.db import models
|
||||
from django.db.models import Avg, Count, Q
|
||||
from django.db.models.query import QuerySet
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from rest_framework import permissions, status
|
||||
from rest_framework.exceptions import NotFound
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
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
|
||||
from rest_framework.views import APIView
|
||||
|
||||
# Import models
|
||||
try:
|
||||
from apps.parks.models import Park, Company
|
||||
from apps.parks.models import Company, Park
|
||||
MODELS_AVAILABLE = True
|
||||
except Exception:
|
||||
Park = None # type: ignore
|
||||
@@ -45,11 +46,11 @@ except Exception:
|
||||
# Import serializers
|
||||
try:
|
||||
from apps.api.v1.serializers.parks import (
|
||||
ParkListOutputSerializer,
|
||||
ParkDetailOutputSerializer,
|
||||
ParkCreateInputSerializer,
|
||||
ParkUpdateInputSerializer,
|
||||
ParkDetailOutputSerializer,
|
||||
ParkImageSettingsInputSerializer,
|
||||
ParkListOutputSerializer,
|
||||
ParkUpdateInputSerializer,
|
||||
)
|
||||
SERIALIZERS_AVAILABLE = True
|
||||
except Exception:
|
||||
@@ -247,12 +248,12 @@ class ParkListCreateAPIView(APIView):
|
||||
'city': 'location__city__iexact',
|
||||
'continent': 'location__continent__iexact'
|
||||
}
|
||||
|
||||
|
||||
for param_name, filter_field in location_filters.items():
|
||||
value = params.get(param_name)
|
||||
if value:
|
||||
qs = qs.filter(**{filter_field: value})
|
||||
|
||||
|
||||
return qs
|
||||
|
||||
def _apply_park_attribute_filters(self, qs: QuerySet, params: dict) -> QuerySet:
|
||||
@@ -264,7 +265,7 @@ class ParkListCreateAPIView(APIView):
|
||||
status_filter = params.get("status")
|
||||
if status_filter:
|
||||
qs = qs.filter(status=status_filter)
|
||||
|
||||
|
||||
return qs
|
||||
|
||||
def _apply_company_filters(self, qs: QuerySet, params: dict) -> QuerySet:
|
||||
@@ -275,73 +276,59 @@ class ParkListCreateAPIView(APIView):
|
||||
'property_owner_id': 'property_owner_id',
|
||||
'property_owner_slug': 'property_owner__slug'
|
||||
}
|
||||
|
||||
|
||||
for param_name, filter_field in company_filters.items():
|
||||
value = params.get(param_name)
|
||||
if value:
|
||||
qs = qs.filter(**{filter_field: value})
|
||||
|
||||
|
||||
return qs
|
||||
|
||||
def _apply_rating_filters(self, qs: QuerySet, params: dict) -> QuerySet:
|
||||
"""Apply rating-based filtering to the queryset."""
|
||||
min_rating = params.get("min_rating")
|
||||
if min_rating:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
qs = qs.filter(average_rating__gte=float(min_rating))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
max_rating = params.get("max_rating")
|
||||
if max_rating:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
qs = qs.filter(average_rating__lte=float(max_rating))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
|
||||
return qs
|
||||
|
||||
def _apply_ride_count_filters(self, qs: QuerySet, params: dict) -> QuerySet:
|
||||
"""Apply ride count filtering to the queryset."""
|
||||
min_ride_count = params.get("min_ride_count")
|
||||
if min_ride_count:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
qs = qs.filter(ride_count__gte=int(min_ride_count))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
max_ride_count = params.get("max_ride_count")
|
||||
if max_ride_count:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
qs = qs.filter(ride_count__lte=int(max_ride_count))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
|
||||
return qs
|
||||
|
||||
def _apply_opening_year_filters(self, qs: QuerySet, params: dict) -> QuerySet:
|
||||
"""Apply opening year filtering to the queryset."""
|
||||
opening_year = params.get("opening_year")
|
||||
if opening_year:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
qs = qs.filter(opening_date__year=int(opening_year))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
min_opening_year = params.get("min_opening_year")
|
||||
if min_opening_year:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
qs = qs.filter(opening_date__year__gte=int(min_opening_year))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
max_opening_year = params.get("max_opening_year")
|
||||
if max_opening_year:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
qs = qs.filter(opening_date__year__lte=int(max_opening_year))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
|
||||
return qs
|
||||
|
||||
def _apply_roller_coaster_filters(self, qs: QuerySet, params: dict) -> QuerySet:
|
||||
@@ -355,18 +342,14 @@ class ParkListCreateAPIView(APIView):
|
||||
|
||||
min_roller_coaster_count = params.get("min_roller_coaster_count")
|
||||
if min_roller_coaster_count:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
qs = qs.filter(coaster_count__gte=int(min_roller_coaster_count))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
max_roller_coaster_count = params.get("max_roller_coaster_count")
|
||||
if max_roller_coaster_count:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
qs = qs.filter(coaster_count__lte=int(max_roller_coaster_count))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
|
||||
return qs
|
||||
|
||||
@extend_schema(
|
||||
@@ -440,13 +423,13 @@ class ParkDetailAPIView(APIView):
|
||||
def _get_park_or_404(self, identifier: str) -> Any:
|
||||
if not MODELS_AVAILABLE:
|
||||
raise NotFound(
|
||||
(
|
||||
|
||||
"Park detail is not available because domain models "
|
||||
"are not imported. Implement apps.parks.models.Park "
|
||||
"to enable detail endpoints."
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
|
||||
# Try to parse as integer ID first
|
||||
try:
|
||||
pk = int(identifier)
|
||||
@@ -475,36 +458,36 @@ class ParkDetailAPIView(APIView):
|
||||
summary="Get park full details",
|
||||
description="""
|
||||
Retrieve comprehensive park details including:
|
||||
|
||||
|
||||
**Core Information:**
|
||||
- Basic park details (name, slug, description, status)
|
||||
- Opening/closing dates and operating season
|
||||
- Size in acres and website URL
|
||||
- Statistics (average rating, ride count, coaster count)
|
||||
|
||||
|
||||
**Location Data:**
|
||||
- Full address with coordinates
|
||||
- City, state, country information
|
||||
- Formatted address string
|
||||
|
||||
|
||||
**Company Information:**
|
||||
- Operating company details
|
||||
- Property owner information (if different)
|
||||
|
||||
|
||||
**Media:**
|
||||
- All approved photos with Cloudflare variants
|
||||
- Primary photo designation
|
||||
- Banner and card image settings
|
||||
|
||||
|
||||
**Related Content:**
|
||||
- Park areas/themed sections
|
||||
- Associated rides (summary)
|
||||
|
||||
|
||||
**Lookup Methods:**
|
||||
- By ID: `/api/v1/parks/123/`
|
||||
- By current slug: `/api/v1/parks/cedar-point/`
|
||||
- By historical slug: `/api/v1/parks/old-cedar-point-name/`
|
||||
|
||||
|
||||
**No Query Parameters Required** - This endpoint returns full details by default.
|
||||
""",
|
||||
responses={
|
||||
@@ -598,11 +581,11 @@ class FilterOptionsAPIView(APIView):
|
||||
"""Return comprehensive filter options with Rich Choice Objects metadata."""
|
||||
# Import Rich Choice registry
|
||||
from apps.core.choices.registry import get_choices
|
||||
|
||||
|
||||
# Always get static choice definitions from Rich Choice Objects (primary source)
|
||||
park_types = get_choices('types', 'parks')
|
||||
statuses = get_choices('statuses', 'parks')
|
||||
|
||||
|
||||
# Convert Rich Choice Objects to frontend format with metadata
|
||||
park_types_data = [
|
||||
{
|
||||
@@ -616,7 +599,7 @@ class FilterOptionsAPIView(APIView):
|
||||
}
|
||||
for choice in park_types
|
||||
]
|
||||
|
||||
|
||||
statuses_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
@@ -629,12 +612,12 @@ class FilterOptionsAPIView(APIView):
|
||||
}
|
||||
for choice in statuses
|
||||
]
|
||||
|
||||
|
||||
# Get dynamic data from database if models are available
|
||||
if MODELS_AVAILABLE:
|
||||
# Add any dynamic data queries here
|
||||
pass
|
||||
|
||||
|
||||
return Response({
|
||||
"park_types": park_types_data,
|
||||
"statuses": statuses_data,
|
||||
@@ -707,7 +690,7 @@ class FilterOptionsAPIView(APIView):
|
||||
# Get rich choice objects from registry
|
||||
park_types = get_choices('types', 'parks')
|
||||
statuses = get_choices('statuses', 'parks')
|
||||
|
||||
|
||||
# Convert Rich Choice Objects to frontend format with metadata
|
||||
park_types_data = [
|
||||
{
|
||||
@@ -721,7 +704,7 @@ class FilterOptionsAPIView(APIView):
|
||||
}
|
||||
for choice in park_types
|
||||
]
|
||||
|
||||
|
||||
statuses_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
@@ -1118,7 +1101,7 @@ class OperatorListAPIView(APIView):
|
||||
}
|
||||
for op in operators
|
||||
]
|
||||
|
||||
|
||||
return Response({
|
||||
"results": data,
|
||||
"count": len(data)
|
||||
|
||||
@@ -13,27 +13,27 @@ if TYPE_CHECKING:
|
||||
|
||||
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 drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
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.exceptions import NotFound, ValidationError
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
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,
|
||||
RidePhotoCreateInputSerializer,
|
||||
RidePhotoListOutputSerializer,
|
||||
RidePhotoOutputSerializer,
|
||||
RidePhotoStatsOutputSerializer,
|
||||
RidePhotoUpdateInputSerializer,
|
||||
)
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.models import Ride
|
||||
from apps.rides.models.media import RidePhoto
|
||||
from apps.rides.services.media_service import RideMediaService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -116,10 +116,7 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
|
||||
def get_permissions(self):
|
||||
"""Set permissions based on action."""
|
||||
if self.action in ['list', 'retrieve', 'stats']:
|
||||
permission_classes = [AllowAny]
|
||||
else:
|
||||
permission_classes = [IsAuthenticated]
|
||||
permission_classes = [AllowAny] if self.action in ['list', 'retrieve', 'stats'] else [IsAuthenticated]
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -131,7 +128,7 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
# 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)
|
||||
@@ -158,7 +155,7 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
"""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")
|
||||
|
||||
@@ -185,7 +182,7 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
|
||||
# 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:
|
||||
@@ -249,7 +246,7 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
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}")
|
||||
@@ -331,7 +328,7 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
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")
|
||||
|
||||
@@ -381,7 +378,7 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
"""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"},
|
||||
@@ -431,7 +428,7 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
"""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"},
|
||||
|
||||
@@ -12,28 +12,28 @@ if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import Avg, Count, Q
|
||||
from django.db.models import Avg
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.utils import extend_schema_view, extend_schema
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
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.exceptions import NotFound, ValidationError
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
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,
|
||||
RideReviewOutputSerializer,
|
||||
RideReviewStatsOutputSerializer,
|
||||
RideReviewUpdateInputSerializer,
|
||||
)
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.models import Ride
|
||||
from apps.rides.models.reviews import RideReview
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -115,10 +115,7 @@ class RideReviewViewSet(ModelViewSet):
|
||||
|
||||
def get_permissions(self):
|
||||
"""Set permissions based on action."""
|
||||
if self.action in ['list', 'retrieve', 'stats']:
|
||||
permission_classes = [AllowAny]
|
||||
else:
|
||||
permission_classes = [IsAuthenticated]
|
||||
permission_classes = [AllowAny] if self.action in ['list', 'retrieve', 'stats'] else [IsAuthenticated]
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -130,7 +127,7 @@ class RideReviewViewSet(ModelViewSet):
|
||||
# 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)
|
||||
@@ -141,7 +138,7 @@ class RideReviewViewSet(ModelViewSet):
|
||||
return queryset.none()
|
||||
|
||||
# Filter published reviews for non-staff users
|
||||
if not (hasattr(self.request, 'user') and
|
||||
if not (hasattr(self.request, 'user') and
|
||||
getattr(self.request.user, 'is_staff', False)):
|
||||
queryset = queryset.filter(is_published=True)
|
||||
|
||||
@@ -162,7 +159,7 @@ class RideReviewViewSet(ModelViewSet):
|
||||
"""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")
|
||||
|
||||
@@ -185,7 +182,7 @@ class RideReviewViewSet(ModelViewSet):
|
||||
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:
|
||||
@@ -241,7 +238,7 @@ class RideReviewViewSet(ModelViewSet):
|
||||
"""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"},
|
||||
@@ -265,19 +262,19 @@ class RideReviewViewSet(ModelViewSet):
|
||||
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)
|
||||
|
||||
@@ -5,12 +5,13 @@ 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 (
|
||||
OpenApiExample,
|
||||
extend_schema_field,
|
||||
extend_schema_serializer,
|
||||
OpenApiExample,
|
||||
)
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.parks.models import Park, ParkPhoto
|
||||
|
||||
|
||||
@@ -235,7 +236,7 @@ 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()
|
||||
@@ -243,19 +244,19 @@ class HybridParkSerializer(serializers.ModelSerializer):
|
||||
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."""
|
||||
@@ -263,7 +264,7 @@ class HybridParkSerializer(serializers.ModelSerializer):
|
||||
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."""
|
||||
@@ -271,7 +272,7 @@ class HybridParkSerializer(serializers.ModelSerializer):
|
||||
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."""
|
||||
@@ -279,7 +280,7 @@ class HybridParkSerializer(serializers.ModelSerializer):
|
||||
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."""
|
||||
@@ -287,7 +288,7 @@ class HybridParkSerializer(serializers.ModelSerializer):
|
||||
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."""
|
||||
@@ -297,7 +298,7 @@ class HybridParkSerializer(serializers.ModelSerializer):
|
||||
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."""
|
||||
@@ -307,14 +308,14 @@ class HybridParkSerializer(serializers.ModelSerializer):
|
||||
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."""
|
||||
@@ -332,42 +333,42 @@ class HybridParkSerializer(serializers.ModelSerializer):
|
||||
"description",
|
||||
"status",
|
||||
"park_type",
|
||||
|
||||
|
||||
# Dates and computed fields
|
||||
"opening_date",
|
||||
"closing_date",
|
||||
"opening_year",
|
||||
"operating_season",
|
||||
|
||||
|
||||
# Location fields
|
||||
"city",
|
||||
"state",
|
||||
"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",
|
||||
|
||||
@@ -6,28 +6,10 @@ intentionally expansive to match the rides API functionality and provide
|
||||
complete feature parity for parks management.
|
||||
"""
|
||||
|
||||
from django.urls import path, include
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .park_views import (
|
||||
ParkListCreateAPIView,
|
||||
ParkDetailAPIView,
|
||||
FilterOptionsAPIView,
|
||||
CompanySearchAPIView,
|
||||
ParkSearchSuggestionsAPIView,
|
||||
ParkImageSettingsAPIView,
|
||||
OperatorListAPIView,
|
||||
)
|
||||
from .park_rides_views import (
|
||||
ParkRidesListAPIView,
|
||||
ParkRideDetailAPIView,
|
||||
ParkComprehensiveDetailAPIView,
|
||||
)
|
||||
from apps.parks.views import location_search, reverse_geocode
|
||||
from .views import ParkPhotoViewSet, HybridParkAPIView, ParkFilterMetadataAPIView
|
||||
from .ride_photos_views import RidePhotoViewSet
|
||||
from .ride_photos_views import RidePhotoViewSet
|
||||
from .ride_reviews_views import RideReviewViewSet
|
||||
from apps.parks.views_roadtrip import (
|
||||
CreateTripView,
|
||||
FindParksAlongRouteView,
|
||||
@@ -35,6 +17,24 @@ from apps.parks.views_roadtrip import (
|
||||
ParkDistanceCalculatorView,
|
||||
)
|
||||
|
||||
from .park_rides_views import (
|
||||
ParkComprehensiveDetailAPIView,
|
||||
ParkRideDetailAPIView,
|
||||
ParkRidesListAPIView,
|
||||
)
|
||||
from .park_views import (
|
||||
CompanySearchAPIView,
|
||||
FilterOptionsAPIView,
|
||||
OperatorListAPIView,
|
||||
ParkDetailAPIView,
|
||||
ParkImageSettingsAPIView,
|
||||
ParkListCreateAPIView,
|
||||
ParkSearchSuggestionsAPIView,
|
||||
)
|
||||
from .ride_photos_views import RidePhotoViewSet
|
||||
from .ride_reviews_views import RideReviewViewSet
|
||||
from .views import HybridParkAPIView, ParkFilterMetadataAPIView, ParkPhotoViewSet
|
||||
|
||||
# Create router for nested photo endpoints
|
||||
router = DefaultRouter()
|
||||
router.register(r"", ParkPhotoViewSet, basename="park-photo")
|
||||
@@ -42,13 +42,12 @@ 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")
|
||||
from .ride_reviews_views import RideReviewViewSet
|
||||
|
||||
ride_reviews_router = DefaultRouter()
|
||||
ride_reviews_router.register(r"", RideReviewViewSet, basename="ride-review")
|
||||
|
||||
from .park_reviews_views import ParkReviewViewSet
|
||||
from .history_views import ParkHistoryViewSet, RideHistoryViewSet
|
||||
from .park_reviews_views import ParkReviewViewSet
|
||||
|
||||
# Create routers for nested park endpoints
|
||||
reviews_router = DefaultRouter()
|
||||
@@ -60,11 +59,11 @@ 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
|
||||
@@ -80,14 +79,14 @@ urlpatterns = [
|
||||
),
|
||||
# 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/",
|
||||
@@ -96,21 +95,21 @@ urlpatterns = [
|
||||
),
|
||||
# Park photo endpoints - domain-specific photo management
|
||||
path("<str: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)),
|
||||
# Nested ride review endpoints - reviews for specific rides within parks
|
||||
path("<str:park_slug>/rides/<str:ride_slug>/reviews/", include(ride_reviews_router.urls)),
|
||||
|
||||
|
||||
# Ride History
|
||||
path("<str:park_slug>/rides/<str:ride_slug>/history/", RideHistoryViewSet.as_view({'get': 'list'}), name="ride-history"),
|
||||
|
||||
# Park Reviews
|
||||
path("<str:park_slug>/reviews/", include(reviews_router.urls)),
|
||||
|
||||
|
||||
# Park History
|
||||
path("<str:park_slug>/history/", ParkHistoryViewSet.as_view({'get': 'list'}), name="park-history"),
|
||||
|
||||
|
||||
@@ -26,14 +26,13 @@ from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from apps.core.decorators.cache_decorators import cache_api_response
|
||||
from apps.core.exceptions import (
|
||||
NotFoundError,
|
||||
PermissionDeniedError,
|
||||
ServiceError,
|
||||
ValidationException,
|
||||
)
|
||||
from apps.core.utils.error_handling import ErrorHandler
|
||||
from apps.core.decorators.cache_decorators import cache_api_response
|
||||
from apps.parks.models import Park, ParkPhoto
|
||||
from apps.parks.services import ParkMediaService
|
||||
from apps.parks.services.hybrid_loader import smart_park_loader
|
||||
@@ -130,10 +129,7 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
|
||||
def get_permissions(self):
|
||||
"""Set permissions based on action."""
|
||||
if self.action in ["list", "retrieve", "stats"]:
|
||||
permission_classes = [AllowAny]
|
||||
else:
|
||||
permission_classes = [IsAuthenticated]
|
||||
permission_classes = [AllowAny] if self.action in ["list", "retrieve", "stats"] else [IsAuthenticated]
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def get_queryset(self): # type: ignore[override]
|
||||
@@ -171,11 +167,8 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
raise ValidationError("Park ID/Slug is required")
|
||||
|
||||
try:
|
||||
if str(park_id).isdigit():
|
||||
park = Park.objects.get(pk=park_id)
|
||||
else:
|
||||
park = Park.objects.get(slug=park_id)
|
||||
|
||||
park = Park.objects.get(pk=park_id) if str(park_id).isdigit() else Park.objects.get(slug=park_id)
|
||||
|
||||
# Use real park ID
|
||||
park_id = park.id
|
||||
except Park.DoesNotExist:
|
||||
@@ -398,10 +391,7 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
park = None
|
||||
if park_pk:
|
||||
try:
|
||||
if str(park_pk).isdigit():
|
||||
park = Park.objects.get(pk=park_pk)
|
||||
else:
|
||||
park = Park.objects.get(slug=park_pk)
|
||||
park = Park.objects.get(pk=park_pk) if str(park_pk).isdigit() else Park.objects.get(slug=park_pk)
|
||||
except Park.DoesNotExist:
|
||||
return ErrorHandler.handle_api_error(
|
||||
NotFoundError(f"Park with id/slug {park_pk} not found"),
|
||||
@@ -490,10 +480,7 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
)
|
||||
|
||||
try:
|
||||
if str(park_pk).isdigit():
|
||||
park = Park.objects.get(pk=park_pk)
|
||||
else:
|
||||
park = Park.objects.get(slug=park_pk)
|
||||
park = Park.objects.get(pk=park_pk) if str(park_pk).isdigit() else Park.objects.get(slug=park_pk)
|
||||
except Park.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Park not found"},
|
||||
@@ -509,9 +496,9 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
|
||||
try:
|
||||
# Import CloudflareImage model and service
|
||||
from django.utils import timezone
|
||||
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
|
||||
# Get image details from Cloudflare API
|
||||
|
||||
Reference in New Issue
Block a user