mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 11:31:09 -05:00
feat: Refactor rides app with unique constraints, mixins, and enhanced documentation
- Added migration to convert unique_together constraints to UniqueConstraint for RideModel. - Introduced RideFormMixin for handling entity suggestions in ride forms. - Created comprehensive code standards documentation outlining formatting, docstring requirements, complexity guidelines, and testing requirements. - Established error handling guidelines with a structured exception hierarchy and best practices for API and view error handling. - Documented view pattern guidelines, emphasizing the use of CBVs, FBVs, and ViewSets with examples. - Implemented a benchmarking script for query performance analysis and optimization. - Developed security documentation detailing measures, configurations, and a security checklist. - Compiled a database optimization guide covering indexing strategies, query optimization patterns, and computed fields.
This commit is contained in:
@@ -1302,11 +1302,15 @@ def get_user_statistics(request):
|
||||
user = request.user
|
||||
|
||||
# Calculate user statistics
|
||||
# TODO(THRILLWIKI-104): Implement full user statistics tracking
|
||||
from apps.parks.models import ParkReview
|
||||
from apps.rides.models import RideReview
|
||||
|
||||
data = {
|
||||
"parks_visited": 0, # TODO: Implement based on reviews/check-ins
|
||||
"rides_ridden": 0, # TODO: Implement based on reviews/check-ins
|
||||
"reviews_written": 0, # TODO: Count user's reviews
|
||||
"photos_uploaded": 0, # TODO: Count user's photos
|
||||
"parks_visited": ParkReview.objects.filter(user=user).values("park").distinct().count(),
|
||||
"rides_ridden": RideReview.objects.filter(user=user).values("ride").distinct().count(),
|
||||
"reviews_written": ParkReview.objects.filter(user=user).count() + RideReview.objects.filter(user=user).count(),
|
||||
"photos_uploaded": 0, # TODO(THRILLWIKI-105): Implement photo counting
|
||||
"top_lists_created": TopList.objects.filter(user=user).count(),
|
||||
"member_since": user.date_joined,
|
||||
"last_activity": user.last_login,
|
||||
|
||||
@@ -7,8 +7,6 @@ from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.utils.decorators import method_decorator
|
||||
from typing import Optional, List
|
||||
from drf_spectacular.utils import extend_schema
|
||||
|
||||
@@ -260,12 +258,14 @@ class EntityNotFoundView(APIView):
|
||||
)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class QuickEntitySuggestionView(APIView):
|
||||
"""
|
||||
Lightweight endpoint for quick entity suggestions (e.g., autocomplete).
|
||||
|
||||
Migrated from apps.core.views.entity_search.QuickEntitySuggestionView
|
||||
|
||||
Security Note: This endpoint only accepts GET requests, which are inherently
|
||||
safe from CSRF attacks. No CSRF exemption is needed.
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@@ -12,7 +12,7 @@ from django.contrib.gis.geos import Polygon
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.permissions import AllowAny, IsAdminUser
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema,
|
||||
extend_schema_view,
|
||||
@@ -306,7 +306,7 @@ class MapLocationsAPIView(APIView):
|
||||
return {
|
||||
"status": "success",
|
||||
"locations": locations,
|
||||
"clusters": [], # TODO: Implement clustering
|
||||
"clusters": [], # TODO(THRILLWIKI-106): Implement map clustering algorithm
|
||||
"bounds": self._calculate_bounds(locations),
|
||||
"total_count": len(locations),
|
||||
"clustered": params["cluster"],
|
||||
@@ -471,7 +471,7 @@ class MapLocationDetailAPIView(APIView):
|
||||
obj.opening_date.isoformat() if obj.opening_date else None
|
||||
),
|
||||
},
|
||||
"nearby_locations": [], # TODO: Implement nearby locations
|
||||
"nearby_locations": [], # TODO(THRILLWIKI-107): Implement nearby locations for parks
|
||||
}
|
||||
else: # ride
|
||||
data = {
|
||||
@@ -538,7 +538,7 @@ class MapLocationDetailAPIView(APIView):
|
||||
obj.manufacturer.name if obj.manufacturer else None
|
||||
),
|
||||
},
|
||||
"nearby_locations": [], # TODO: Implement nearby locations
|
||||
"nearby_locations": [], # TODO(THRILLWIKI-107): Implement nearby locations for rides
|
||||
}
|
||||
|
||||
return Response(
|
||||
@@ -669,7 +669,7 @@ class MapSearchAPIView(APIView):
|
||||
else ""
|
||||
),
|
||||
},
|
||||
"relevance_score": 1.0, # TODO: Implement relevance scoring
|
||||
"relevance_score": 1.0, # TODO(THRILLWIKI-108): Implement relevance scoring for search
|
||||
}
|
||||
)
|
||||
|
||||
@@ -722,7 +722,7 @@ class MapSearchAPIView(APIView):
|
||||
else ""
|
||||
),
|
||||
},
|
||||
"relevance_score": 1.0, # TODO: Implement relevance scoring
|
||||
"relevance_score": 1.0, # TODO(THRILLWIKI-108): Implement relevance scoring for search
|
||||
}
|
||||
)
|
||||
|
||||
@@ -965,8 +965,8 @@ class MapStatsAPIView(APIView):
|
||||
"total_locations": total_locations,
|
||||
"parks_with_location": parks_with_location,
|
||||
"rides_with_location": rides_with_location,
|
||||
"cache_hits": 0, # TODO: Implement cache statistics
|
||||
"cache_misses": 0, # TODO: Implement cache statistics
|
||||
"cache_hits": 0, # TODO(THRILLWIKI-109): Implement cache statistics tracking
|
||||
"cache_misses": 0, # TODO(THRILLWIKI-109): Implement cache statistics tracking
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -996,7 +996,7 @@ class MapStatsAPIView(APIView):
|
||||
class MapCacheAPIView(APIView):
|
||||
"""API endpoint for cache management (admin only)."""
|
||||
|
||||
permission_classes = [AllowAny] # TODO: Add admin permission check
|
||||
permission_classes = [IsAdminUser] # Admin only
|
||||
|
||||
def delete(self, request: HttpRequest) -> Response:
|
||||
"""Clear all map cache (admin only)."""
|
||||
|
||||
@@ -5,34 +5,44 @@ This module contains consolidated park photo viewset for the centralized API str
|
||||
Enhanced from rogue implementation to maintain full feature parity.
|
||||
"""
|
||||
|
||||
from .serializers import (
|
||||
ParkPhotoOutputSerializer,
|
||||
ParkPhotoCreateInputSerializer,
|
||||
ParkPhotoUpdateInputSerializer,
|
||||
ParkPhotoListOutputSerializer,
|
||||
ParkPhotoApprovalInputSerializer,
|
||||
ParkPhotoStatsOutputSerializer,
|
||||
)
|
||||
from typing import Any, cast
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from drf_spectacular.utils import extend_schema_view, extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from apps.parks.models import ParkPhoto, Park
|
||||
from apps.core.exceptions import (
|
||||
NotFoundError,
|
||||
PermissionDeniedError,
|
||||
ServiceError,
|
||||
ValidationException,
|
||||
)
|
||||
from apps.core.utils.error_handling import ErrorHandler
|
||||
from apps.parks.models import Park, ParkPhoto
|
||||
from apps.parks.services import ParkMediaService
|
||||
from django.contrib.auth import get_user_model
|
||||
from apps.parks.services.hybrid_loader import smart_park_loader
|
||||
|
||||
UserModel = get_user_model()
|
||||
from .serializers import (
|
||||
HybridParkSerializer,
|
||||
ParkPhotoApprovalInputSerializer,
|
||||
ParkPhotoCreateInputSerializer,
|
||||
ParkPhotoListOutputSerializer,
|
||||
ParkPhotoOutputSerializer,
|
||||
ParkPhotoStatsOutputSerializer,
|
||||
ParkPhotoUpdateInputSerializer,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
UserModel = get_user_model()
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
@@ -113,7 +123,7 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
|
||||
def get_permissions(self):
|
||||
"""Set permissions based on action."""
|
||||
if self.action in ['list', 'retrieve', 'stats']:
|
||||
if self.action in ["list", "retrieve", "stats"]:
|
||||
permission_classes = [AllowAny]
|
||||
else:
|
||||
permission_classes = [IsAuthenticated]
|
||||
@@ -166,8 +176,11 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
# Set the instance for the serializer response
|
||||
serializer.instance = photo
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating park photo: {e}")
|
||||
except (ValidationException, ValidationError) as e:
|
||||
logger.warning(f"Validation error creating park photo: {e}")
|
||||
raise ValidationError(str(e))
|
||||
except ServiceError as e:
|
||||
logger.error(f"Service error creating park photo: {e}")
|
||||
raise ValidationError(f"Failed to create photo: {str(e)}")
|
||||
|
||||
def perform_update(self, serializer):
|
||||
@@ -190,8 +203,11 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
# Remove is_primary from validated_data since service handles it
|
||||
if "is_primary" in serializer.validated_data:
|
||||
del serializer.validated_data["is_primary"]
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting primary photo: {e}")
|
||||
except (ValidationException, ValidationError) as e:
|
||||
logger.warning(f"Validation error setting primary photo: {e}")
|
||||
raise ValidationError(str(e))
|
||||
except ServiceError as e:
|
||||
logger.error(f"Service error setting primary photo: {e}")
|
||||
raise ValidationError(f"Failed to set primary photo: {str(e)}")
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
@@ -205,25 +221,30 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
"You can only delete your own photos or be an admin."
|
||||
)
|
||||
|
||||
try:
|
||||
# Delete from Cloudflare first if image exists
|
||||
if instance.image:
|
||||
try:
|
||||
from django_cloudflareimages_toolkit.services import CloudflareImagesService
|
||||
service = CloudflareImagesService()
|
||||
service.delete_image(instance.image)
|
||||
logger.info(
|
||||
f"Successfully deleted park photo from Cloudflare: {instance.image.cloudflare_id}")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to delete park photo from Cloudflare: {str(e)}")
|
||||
# Continue with database deletion even if Cloudflare deletion fails
|
||||
# Delete from Cloudflare first if image exists
|
||||
if instance.image:
|
||||
try:
|
||||
from django_cloudflareimages_toolkit.services import (
|
||||
CloudflareImagesService,
|
||||
)
|
||||
|
||||
service = CloudflareImagesService()
|
||||
service.delete_image(instance.image)
|
||||
logger.info(
|
||||
f"Successfully deleted park photo from Cloudflare: {instance.image.cloudflare_id}"
|
||||
)
|
||||
except ImportError:
|
||||
logger.warning("CloudflareImagesService not available")
|
||||
except ServiceError as e:
|
||||
logger.error(f"Service error deleting from Cloudflare: {str(e)}")
|
||||
# Continue with database deletion even if Cloudflare deletion fails
|
||||
|
||||
try:
|
||||
ParkMediaService().delete_photo(
|
||||
instance.id, deleted_by=cast(UserModel, self.request.user)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting park photo: {e}")
|
||||
except ServiceError as e:
|
||||
logger.error(f"Service error deleting park photo: {e}")
|
||||
raise ValidationError(f"Failed to delete photo: {str(e)}")
|
||||
|
||||
@extend_schema(
|
||||
@@ -265,11 +286,18 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting primary photo: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to set primary photo: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
except (ValidationException, ValidationError) as e:
|
||||
logger.warning(f"Validation error setting primary photo: {e}")
|
||||
return ErrorHandler.handle_api_error(
|
||||
e,
|
||||
user_message="Failed to set primary photo",
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except ServiceError as e:
|
||||
return ErrorHandler.handle_api_error(
|
||||
e,
|
||||
user_message="Failed to set primary photo",
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
@@ -319,11 +347,18 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in bulk photo approval: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to update photos: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
except (ValidationException, ValidationError) as e:
|
||||
logger.warning(f"Validation error in bulk photo approval: {e}")
|
||||
return ErrorHandler.handle_api_error(
|
||||
e,
|
||||
user_message="Failed to update photos",
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except ServiceError as e:
|
||||
return ErrorHandler.handle_api_error(
|
||||
e,
|
||||
user_message="Failed to update photos",
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
@@ -345,9 +380,10 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
try:
|
||||
park = Park.objects.get(pk=park_pk)
|
||||
except Park.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Park not found."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
return ErrorHandler.handle_api_error(
|
||||
NotFoundError(f"Park with id {park_pk} not found"),
|
||||
user_message="Park not found",
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -359,11 +395,11 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting park photo stats: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to get photo statistics: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
except ServiceError as e:
|
||||
return ErrorHandler.handle_api_error(
|
||||
e,
|
||||
user_message="Failed to get photo statistics",
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
# Legacy compatibility action using the legacy set_primary logic
|
||||
@@ -394,9 +430,19 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
park_id=photo.park_id, photo_id=photo.id
|
||||
)
|
||||
return Response({"message": "Photo set as primary successfully."})
|
||||
except Exception as e:
|
||||
logger.error(f"Error in set_primary_photo: {str(e)}", exc_info=True)
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except (ValidationException, ValidationError) as e:
|
||||
logger.warning(f"Validation error in set_primary_photo: {str(e)}")
|
||||
return ErrorHandler.handle_api_error(
|
||||
e,
|
||||
user_message="Failed to set primary photo",
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except ServiceError as e:
|
||||
return ErrorHandler.handle_api_error(
|
||||
e,
|
||||
user_message="Failed to set primary photo",
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="Save Cloudflare image as park photo",
|
||||
@@ -442,60 +488,55 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
from django.utils import timezone
|
||||
|
||||
# Always fetch the latest image data from Cloudflare API
|
||||
# Get image details from Cloudflare API
|
||||
service = CloudflareImagesService()
|
||||
image_data = service.get_image(cloudflare_image_id)
|
||||
|
||||
if not image_data:
|
||||
return ErrorHandler.handle_api_error(
|
||||
NotFoundError("Image not found in Cloudflare"),
|
||||
user_message="Image not found in Cloudflare",
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Try to find existing CloudflareImage record by cloudflare_id
|
||||
cloudflare_image = None
|
||||
try:
|
||||
# Get image details from Cloudflare API
|
||||
service = CloudflareImagesService()
|
||||
image_data = service.get_image(cloudflare_image_id)
|
||||
cloudflare_image = CloudflareImage.objects.get(
|
||||
cloudflare_id=cloudflare_image_id
|
||||
)
|
||||
|
||||
if not image_data:
|
||||
return Response(
|
||||
{"error": "Image not found in Cloudflare"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# Update existing record with latest data from Cloudflare
|
||||
cloudflare_image.status = "uploaded"
|
||||
cloudflare_image.uploaded_at = timezone.now()
|
||||
cloudflare_image.metadata = image_data.get("meta", {})
|
||||
# Extract variants from nested result structure
|
||||
cloudflare_image.variants = image_data.get("result", {}).get(
|
||||
"variants", []
|
||||
)
|
||||
cloudflare_image.cloudflare_metadata = image_data
|
||||
cloudflare_image.width = image_data.get("width")
|
||||
cloudflare_image.height = image_data.get("height")
|
||||
cloudflare_image.format = image_data.get("format", "")
|
||||
cloudflare_image.save()
|
||||
|
||||
# Try to find existing CloudflareImage record by cloudflare_id
|
||||
cloudflare_image = None
|
||||
try:
|
||||
cloudflare_image = CloudflareImage.objects.get(
|
||||
cloudflare_id=cloudflare_image_id)
|
||||
|
||||
# Update existing record with latest data from Cloudflare
|
||||
cloudflare_image.status = 'uploaded'
|
||||
cloudflare_image.uploaded_at = timezone.now()
|
||||
cloudflare_image.metadata = image_data.get('meta', {})
|
||||
except CloudflareImage.DoesNotExist:
|
||||
# Create new CloudflareImage record from API response
|
||||
cloudflare_image = CloudflareImage.objects.create(
|
||||
cloudflare_id=cloudflare_image_id,
|
||||
user=request.user,
|
||||
status="uploaded",
|
||||
upload_url="", # Not needed for uploaded images
|
||||
expires_at=timezone.now()
|
||||
+ timezone.timedelta(days=365), # Set far future expiry
|
||||
uploaded_at=timezone.now(),
|
||||
metadata=image_data.get("meta", {}),
|
||||
# Extract variants from nested result structure
|
||||
cloudflare_image.variants = image_data.get(
|
||||
'result', {}).get('variants', [])
|
||||
cloudflare_image.cloudflare_metadata = image_data
|
||||
cloudflare_image.width = image_data.get('width')
|
||||
cloudflare_image.height = image_data.get('height')
|
||||
cloudflare_image.format = image_data.get('format', '')
|
||||
cloudflare_image.save()
|
||||
|
||||
except CloudflareImage.DoesNotExist:
|
||||
# Create new CloudflareImage record from API response
|
||||
cloudflare_image = CloudflareImage.objects.create(
|
||||
cloudflare_id=cloudflare_image_id,
|
||||
user=request.user,
|
||||
status='uploaded',
|
||||
upload_url='', # Not needed for uploaded images
|
||||
expires_at=timezone.now() + timezone.timedelta(days=365), # Set far future expiry
|
||||
uploaded_at=timezone.now(),
|
||||
metadata=image_data.get('meta', {}),
|
||||
# Extract variants from nested result structure
|
||||
variants=image_data.get('result', {}).get('variants', []),
|
||||
cloudflare_metadata=image_data,
|
||||
width=image_data.get('width'),
|
||||
height=image_data.get('height'),
|
||||
format=image_data.get('format', ''),
|
||||
)
|
||||
|
||||
except Exception as api_error:
|
||||
logger.error(
|
||||
f"Error fetching image from Cloudflare API: {str(api_error)}", exc_info=True)
|
||||
return Response(
|
||||
{"error": f"Failed to fetch image from Cloudflare: {str(api_error)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
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", ""),
|
||||
)
|
||||
|
||||
# Create the park photo with the CloudflareImage reference
|
||||
@@ -516,25 +557,33 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
ParkMediaService().set_primary_photo(
|
||||
park_id=park.id, photo_id=photo.id
|
||||
)
|
||||
except Exception as e:
|
||||
except ServiceError as e:
|
||||
logger.error(f"Error setting primary photo: {e}")
|
||||
# Don't fail the entire operation, just log the error
|
||||
|
||||
serializer = ParkPhotoOutputSerializer(photo, context={"request": request})
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving park photo: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to save photo: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
except ImportError:
|
||||
logger.error("CloudflareImagesService not available")
|
||||
return ErrorHandler.handle_api_error(
|
||||
ServiceError("Cloudflare Images service not available"),
|
||||
user_message="Image upload service not available",
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
except (ValidationException, ValidationError) as e:
|
||||
logger.warning(f"Validation error saving park photo: {e}")
|
||||
return ErrorHandler.handle_api_error(
|
||||
e,
|
||||
user_message="Failed to save photo",
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except ServiceError as e:
|
||||
return ErrorHandler.handle_api_error(
|
||||
e,
|
||||
user_message="Failed to save photo",
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.permissions import AllowAny
|
||||
from .serializers import HybridParkSerializer
|
||||
from apps.parks.services.hybrid_loader import smart_park_loader
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
@@ -542,23 +591,79 @@ from apps.parks.services.hybrid_loader import smart_park_loader
|
||||
summary="Get parks with hybrid filtering",
|
||||
description="Retrieve parks with intelligent hybrid filtering strategy. Automatically chooses between client-side and server-side filtering based on data size.",
|
||||
parameters=[
|
||||
OpenApiParameter("status", OpenApiTypes.STR, description="Filter by park status (comma-separated for multiple)"),
|
||||
OpenApiParameter("park_type", OpenApiTypes.STR, description="Filter by park type (comma-separated for multiple)"),
|
||||
OpenApiParameter("country", OpenApiTypes.STR, description="Filter by country (comma-separated for multiple)"),
|
||||
OpenApiParameter("state", OpenApiTypes.STR, description="Filter by state (comma-separated for multiple)"),
|
||||
OpenApiParameter("opening_year_min", OpenApiTypes.INT, description="Minimum opening year"),
|
||||
OpenApiParameter("opening_year_max", OpenApiTypes.INT, description="Maximum opening year"),
|
||||
OpenApiParameter("size_min", OpenApiTypes.NUMBER, description="Minimum park size in acres"),
|
||||
OpenApiParameter("size_max", OpenApiTypes.NUMBER, description="Maximum park size in acres"),
|
||||
OpenApiParameter("rating_min", OpenApiTypes.NUMBER, description="Minimum average rating"),
|
||||
OpenApiParameter("rating_max", OpenApiTypes.NUMBER, description="Maximum average rating"),
|
||||
OpenApiParameter("ride_count_min", OpenApiTypes.INT, description="Minimum ride count"),
|
||||
OpenApiParameter("ride_count_max", OpenApiTypes.INT, description="Maximum ride count"),
|
||||
OpenApiParameter("coaster_count_min", OpenApiTypes.INT, description="Minimum coaster count"),
|
||||
OpenApiParameter("coaster_count_max", OpenApiTypes.INT, description="Maximum coaster count"),
|
||||
OpenApiParameter("operator", OpenApiTypes.STR, description="Filter by operator slug (comma-separated for multiple)"),
|
||||
OpenApiParameter("search", OpenApiTypes.STR, description="Search query for park names, descriptions, locations, and operators"),
|
||||
OpenApiParameter("offset", OpenApiTypes.INT, description="Offset for progressive loading (server-side pagination)"),
|
||||
OpenApiParameter(
|
||||
"status",
|
||||
OpenApiTypes.STR,
|
||||
description="Filter by park status (comma-separated for multiple)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
"park_type",
|
||||
OpenApiTypes.STR,
|
||||
description="Filter by park type (comma-separated for multiple)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
"country",
|
||||
OpenApiTypes.STR,
|
||||
description="Filter by country (comma-separated for multiple)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
"state",
|
||||
OpenApiTypes.STR,
|
||||
description="Filter by state (comma-separated for multiple)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
"opening_year_min", OpenApiTypes.INT, description="Minimum opening year"
|
||||
),
|
||||
OpenApiParameter(
|
||||
"opening_year_max", OpenApiTypes.INT, description="Maximum opening year"
|
||||
),
|
||||
OpenApiParameter(
|
||||
"size_min",
|
||||
OpenApiTypes.NUMBER,
|
||||
description="Minimum park size in acres",
|
||||
),
|
||||
OpenApiParameter(
|
||||
"size_max",
|
||||
OpenApiTypes.NUMBER,
|
||||
description="Maximum park size in acres",
|
||||
),
|
||||
OpenApiParameter(
|
||||
"rating_min", OpenApiTypes.NUMBER, description="Minimum average rating"
|
||||
),
|
||||
OpenApiParameter(
|
||||
"rating_max", OpenApiTypes.NUMBER, description="Maximum average rating"
|
||||
),
|
||||
OpenApiParameter(
|
||||
"ride_count_min", OpenApiTypes.INT, description="Minimum ride count"
|
||||
),
|
||||
OpenApiParameter(
|
||||
"ride_count_max", OpenApiTypes.INT, description="Maximum ride count"
|
||||
),
|
||||
OpenApiParameter(
|
||||
"coaster_count_min",
|
||||
OpenApiTypes.INT,
|
||||
description="Minimum coaster count",
|
||||
),
|
||||
OpenApiParameter(
|
||||
"coaster_count_max",
|
||||
OpenApiTypes.INT,
|
||||
description="Maximum coaster count",
|
||||
),
|
||||
OpenApiParameter(
|
||||
"operator",
|
||||
OpenApiTypes.STR,
|
||||
description="Filter by operator slug (comma-separated for multiple)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
"search",
|
||||
OpenApiTypes.STR,
|
||||
description="Search query for park names, descriptions, locations, and operators",
|
||||
),
|
||||
OpenApiParameter(
|
||||
"offset",
|
||||
OpenApiTypes.INT,
|
||||
description="Offset for progressive loading (server-side pagination)",
|
||||
),
|
||||
],
|
||||
responses={
|
||||
200: {
|
||||
@@ -570,31 +675,33 @@ from apps.parks.services.hybrid_loader import smart_park_loader
|
||||
"properties": {
|
||||
"parks": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/HybridParkSerializer"}
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/HybridParkSerializer"
|
||||
},
|
||||
},
|
||||
"total_count": {"type": "integer"},
|
||||
"strategy": {
|
||||
"type": "string",
|
||||
"enum": ["client_side", "server_side"],
|
||||
"description": "Filtering strategy used"
|
||||
"description": "Filtering strategy used",
|
||||
},
|
||||
"has_more": {
|
||||
"type": "boolean",
|
||||
"description": "Whether more data is available for progressive loading"
|
||||
"description": "Whether more data is available for progressive loading",
|
||||
},
|
||||
"next_offset": {
|
||||
"type": "integer",
|
||||
"nullable": True,
|
||||
"description": "Next offset for progressive loading"
|
||||
"description": "Next offset for progressive loading",
|
||||
},
|
||||
"filter_metadata": {
|
||||
"type": "object",
|
||||
"description": "Available filter options and ranges"
|
||||
}
|
||||
}
|
||||
"description": "Available filter options and ranges",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
tags=["Parks"],
|
||||
@@ -603,77 +710,83 @@ from apps.parks.services.hybrid_loader import smart_park_loader
|
||||
class HybridParkAPIView(APIView):
|
||||
"""
|
||||
Hybrid Park API View with intelligent filtering strategy.
|
||||
|
||||
|
||||
Automatically chooses between client-side and server-side filtering
|
||||
based on data size and complexity. Provides progressive loading
|
||||
for large datasets and complete data for smaller sets.
|
||||
"""
|
||||
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
|
||||
def get(self, request):
|
||||
"""Get parks with hybrid filtering strategy."""
|
||||
# Extract filters from query parameters
|
||||
filters = self._extract_filters(request.query_params)
|
||||
|
||||
# Check if this is a progressive load request
|
||||
offset = request.query_params.get("offset")
|
||||
if offset is not None:
|
||||
try:
|
||||
offset = int(offset)
|
||||
except ValueError:
|
||||
return ErrorHandler.handle_api_error(
|
||||
ValidationException("Invalid offset parameter"),
|
||||
user_message="Invalid offset parameter",
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
# Extract filters from query parameters
|
||||
filters = self._extract_filters(request.query_params)
|
||||
|
||||
# Check if this is a progressive load request
|
||||
offset = request.query_params.get('offset')
|
||||
if offset is not None:
|
||||
try:
|
||||
offset = int(offset)
|
||||
# Get progressive load data
|
||||
data = smart_park_loader.get_progressive_load(offset, filters)
|
||||
except ValueError:
|
||||
return Response(
|
||||
{"error": "Invalid offset parameter"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
# Get progressive load data
|
||||
data = smart_park_loader.get_progressive_load(offset, filters)
|
||||
else:
|
||||
# Get initial load data
|
||||
data = smart_park_loader.get_initial_load(filters)
|
||||
|
||||
|
||||
# Serialize the parks data
|
||||
serializer = HybridParkSerializer(data['parks'], many=True)
|
||||
|
||||
serializer = HybridParkSerializer(data["parks"], many=True)
|
||||
|
||||
# Prepare response
|
||||
response_data = {
|
||||
'parks': serializer.data,
|
||||
'total_count': data['total_count'],
|
||||
'strategy': data.get('strategy', 'server_side'),
|
||||
'has_more': data.get('has_more', False),
|
||||
'next_offset': data.get('next_offset'),
|
||||
"parks": serializer.data,
|
||||
"total_count": data["total_count"],
|
||||
"strategy": data.get("strategy", "server_side"),
|
||||
"has_more": data.get("has_more", False),
|
||||
"next_offset": data.get("next_offset"),
|
||||
}
|
||||
|
||||
|
||||
# Include filter metadata for initial loads
|
||||
if 'filter_metadata' in data:
|
||||
response_data['filter_metadata'] = data['filter_metadata']
|
||||
|
||||
if "filter_metadata" in data:
|
||||
response_data["filter_metadata"] = data["filter_metadata"]
|
||||
|
||||
return Response(response_data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in HybridParkAPIView: {e}")
|
||||
return Response(
|
||||
{"error": "Internal server error"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
|
||||
except ServiceError as e:
|
||||
return ErrorHandler.handle_api_error(
|
||||
e,
|
||||
user_message="Failed to load parks",
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
def _extract_filters(self, query_params):
|
||||
"""Extract and parse filters from query parameters."""
|
||||
filters = {}
|
||||
|
||||
|
||||
# Handle comma-separated list parameters
|
||||
list_params = ['status', 'park_type', 'country', 'state', 'operator']
|
||||
list_params = ["status", "park_type", "country", "state", "operator"]
|
||||
for param in list_params:
|
||||
value = query_params.get(param)
|
||||
if value:
|
||||
filters[param] = [v.strip() for v in value.split(',') if v.strip()]
|
||||
|
||||
filters[param] = [v.strip() for v in value.split(",") if v.strip()]
|
||||
|
||||
# Handle integer parameters
|
||||
int_params = [
|
||||
'opening_year_min', 'opening_year_max',
|
||||
'ride_count_min', 'ride_count_max',
|
||||
'coaster_count_min', 'coaster_count_max'
|
||||
"opening_year_min",
|
||||
"opening_year_max",
|
||||
"ride_count_min",
|
||||
"ride_count_max",
|
||||
"coaster_count_min",
|
||||
"coaster_count_max",
|
||||
]
|
||||
for param in int_params:
|
||||
value = query_params.get(param)
|
||||
@@ -682,9 +795,9 @@ class HybridParkAPIView(APIView):
|
||||
filters[param] = int(value)
|
||||
except ValueError:
|
||||
pass # Skip invalid integer values
|
||||
|
||||
|
||||
# Handle float parameters
|
||||
float_params = ['size_min', 'size_max', 'rating_min', 'rating_max']
|
||||
float_params = ["size_min", "size_max", "rating_min", "rating_max"]
|
||||
for param in float_params:
|
||||
value = query_params.get(param)
|
||||
if value:
|
||||
@@ -692,12 +805,12 @@ class HybridParkAPIView(APIView):
|
||||
filters[param] = float(value)
|
||||
except ValueError:
|
||||
pass # Skip invalid float values
|
||||
|
||||
|
||||
# Handle search parameter
|
||||
search = query_params.get('search')
|
||||
search = query_params.get("search")
|
||||
if search:
|
||||
filters['search'] = search.strip()
|
||||
|
||||
filters["search"] = search.strip()
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
@@ -706,7 +819,11 @@ class HybridParkAPIView(APIView):
|
||||
summary="Get park filter metadata",
|
||||
description="Get available filter options and ranges for parks filtering.",
|
||||
parameters=[
|
||||
OpenApiParameter("scoped", OpenApiTypes.BOOL, description="Whether to scope metadata to current filters"),
|
||||
OpenApiParameter(
|
||||
"scoped",
|
||||
OpenApiTypes.BOOL,
|
||||
description="Whether to scope metadata to current filters",
|
||||
),
|
||||
],
|
||||
responses={
|
||||
200: {
|
||||
@@ -719,21 +836,33 @@ class HybridParkAPIView(APIView):
|
||||
"categorical": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"countries": {"type": "array", "items": {"type": "string"}},
|
||||
"states": {"type": "array", "items": {"type": "string"}},
|
||||
"park_types": {"type": "array", "items": {"type": "string"}},
|
||||
"statuses": {"type": "array", "items": {"type": "string"}},
|
||||
"countries": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
"states": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
"park_types": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
"statuses": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
"operators": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"slug": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"slug": {"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"ranges": {
|
||||
"type": "object",
|
||||
@@ -741,45 +870,75 @@ class HybridParkAPIView(APIView):
|
||||
"opening_year": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min": {"type": "integer", "nullable": True},
|
||||
"max": {"type": "integer", "nullable": True}
|
||||
}
|
||||
"min": {
|
||||
"type": "integer",
|
||||
"nullable": True,
|
||||
},
|
||||
"max": {
|
||||
"type": "integer",
|
||||
"nullable": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
"size_acres": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min": {"type": "number", "nullable": True},
|
||||
"max": {"type": "number", "nullable": True}
|
||||
}
|
||||
"min": {
|
||||
"type": "number",
|
||||
"nullable": True,
|
||||
},
|
||||
"max": {
|
||||
"type": "number",
|
||||
"nullable": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
"average_rating": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min": {"type": "number", "nullable": True},
|
||||
"max": {"type": "number", "nullable": True}
|
||||
}
|
||||
"min": {
|
||||
"type": "number",
|
||||
"nullable": True,
|
||||
},
|
||||
"max": {
|
||||
"type": "number",
|
||||
"nullable": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
"ride_count": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min": {"type": "integer", "nullable": True},
|
||||
"max": {"type": "integer", "nullable": True}
|
||||
}
|
||||
"min": {
|
||||
"type": "integer",
|
||||
"nullable": True,
|
||||
},
|
||||
"max": {
|
||||
"type": "integer",
|
||||
"nullable": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
"coaster_count": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min": {"type": "integer", "nullable": True},
|
||||
"max": {"type": "integer", "nullable": True}
|
||||
}
|
||||
}
|
||||
}
|
||||
"min": {
|
||||
"type": "integer",
|
||||
"nullable": True,
|
||||
},
|
||||
"max": {
|
||||
"type": "integer",
|
||||
"nullable": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"total_count": {"type": "integer"}
|
||||
}
|
||||
"total_count": {"type": "integer"},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
tags=["Parks"],
|
||||
@@ -788,35 +947,34 @@ class HybridParkAPIView(APIView):
|
||||
class ParkFilterMetadataAPIView(APIView):
|
||||
"""
|
||||
API view for getting park filter metadata.
|
||||
|
||||
|
||||
Provides information about available filter options and ranges
|
||||
to help build dynamic filter interfaces.
|
||||
"""
|
||||
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
|
||||
def get(self, request):
|
||||
"""Get park filter metadata."""
|
||||
# Check if metadata should be scoped to current filters
|
||||
scoped = request.query_params.get("scoped", "").lower() == "true"
|
||||
filters = None
|
||||
|
||||
if scoped:
|
||||
filters = self._extract_filters(request.query_params)
|
||||
|
||||
try:
|
||||
# Check if metadata should be scoped to current filters
|
||||
scoped = request.query_params.get('scoped', '').lower() == 'true'
|
||||
filters = None
|
||||
|
||||
if scoped:
|
||||
filters = self._extract_filters(request.query_params)
|
||||
|
||||
# Get filter metadata
|
||||
metadata = smart_park_loader.get_filter_metadata(filters)
|
||||
|
||||
return Response(metadata, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in ParkFilterMetadataAPIView: {e}")
|
||||
return Response(
|
||||
{"error": "Internal server error"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
|
||||
except ServiceError as e:
|
||||
return ErrorHandler.handle_api_error(
|
||||
e,
|
||||
user_message="Failed to get filter metadata",
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
def _extract_filters(self, query_params):
|
||||
"""Extract and parse filters from query parameters."""
|
||||
# Reuse the same filter extraction logic
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -365,7 +365,7 @@ class MapLocationDetailSerializer(serializers.Serializer):
|
||||
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
|
||||
def get_nearby_locations(self, obj) -> list:
|
||||
"""Get nearby locations (placeholder for now)."""
|
||||
# TODO: Implement nearby location logic
|
||||
# TODO(THRILLWIKI-107): Implement nearby location logic using spatial queries
|
||||
return []
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user