mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 08:11:08 -05:00
Implement hybrid filtering strategy for parks and rides
- Added comprehensive documentation for hybrid filtering implementation, including architecture, API endpoints, performance characteristics, and usage examples. - Developed a hybrid pagination and client-side filtering recommendation, detailing server-side responsibilities and client-side logic. - Created a test script for hybrid filtering endpoints, covering various test cases including basic filtering, search functionality, pagination, and edge cases.
This commit is contained in:
@@ -230,6 +230,151 @@ class ParkPhotoSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
|
||||
|
||||
class HybridParkSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Enhanced serializer for hybrid filtering strategy.
|
||||
Includes all filterable fields for client-side filtering.
|
||||
"""
|
||||
|
||||
# Location fields from related ParkLocation
|
||||
city = serializers.SerializerMethodField()
|
||||
state = serializers.SerializerMethodField()
|
||||
country = serializers.SerializerMethodField()
|
||||
continent = serializers.SerializerMethodField()
|
||||
latitude = serializers.SerializerMethodField()
|
||||
longitude = serializers.SerializerMethodField()
|
||||
|
||||
# Company fields
|
||||
operator_name = serializers.CharField(source="operator.name", read_only=True)
|
||||
property_owner_name = serializers.CharField(source="property_owner.name", read_only=True, allow_null=True)
|
||||
|
||||
# Image URLs for display
|
||||
banner_image_url = serializers.SerializerMethodField()
|
||||
card_image_url = serializers.SerializerMethodField()
|
||||
|
||||
# Computed fields for filtering
|
||||
opening_year = serializers.IntegerField(read_only=True)
|
||||
search_text = serializers.CharField(read_only=True)
|
||||
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_city(self, obj):
|
||||
"""Get city from related location."""
|
||||
try:
|
||||
return obj.location.city if hasattr(obj, 'location') and obj.location else None
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_state(self, obj):
|
||||
"""Get state from related location."""
|
||||
try:
|
||||
return obj.location.state if hasattr(obj, 'location') and obj.location else None
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_country(self, obj):
|
||||
"""Get country from related location."""
|
||||
try:
|
||||
return obj.location.country if hasattr(obj, 'location') and obj.location else None
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_continent(self, obj):
|
||||
"""Get continent from related location."""
|
||||
try:
|
||||
return obj.location.continent if hasattr(obj, 'location') and obj.location else None
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||
def get_latitude(self, obj):
|
||||
"""Get latitude from related location."""
|
||||
try:
|
||||
if hasattr(obj, 'location') and obj.location and obj.location.coordinates:
|
||||
return obj.location.coordinates[1] # PostGIS returns [lon, lat]
|
||||
return None
|
||||
except (AttributeError, IndexError, TypeError):
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||
def get_longitude(self, obj):
|
||||
"""Get longitude from related location."""
|
||||
try:
|
||||
if hasattr(obj, 'location') and obj.location and obj.location.coordinates:
|
||||
return obj.location.coordinates[0] # PostGIS returns [lon, lat]
|
||||
return None
|
||||
except (AttributeError, IndexError, TypeError):
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
||||
def get_banner_image_url(self, obj):
|
||||
"""Get banner image URL."""
|
||||
if obj.banner_image and obj.banner_image.image:
|
||||
return obj.banner_image.image.url
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
||||
def get_card_image_url(self, obj):
|
||||
"""Get card image URL."""
|
||||
if obj.card_image and obj.card_image.image:
|
||||
return obj.card_image.image.url
|
||||
return None
|
||||
|
||||
class Meta:
|
||||
model = Park
|
||||
fields = [
|
||||
# Basic park info
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"description",
|
||||
"status",
|
||||
"park_type",
|
||||
|
||||
# Dates and computed fields
|
||||
"opening_date",
|
||||
"closing_date",
|
||||
"opening_year",
|
||||
"operating_season",
|
||||
|
||||
# Location fields
|
||||
"city",
|
||||
"state",
|
||||
"country",
|
||||
"continent",
|
||||
"latitude",
|
||||
"longitude",
|
||||
|
||||
# Company relationships
|
||||
"operator_name",
|
||||
"property_owner_name",
|
||||
|
||||
# Statistics
|
||||
"size_acres",
|
||||
"average_rating",
|
||||
"ride_count",
|
||||
"coaster_count",
|
||||
|
||||
# Images
|
||||
"banner_image_url",
|
||||
"card_image_url",
|
||||
|
||||
# URLs
|
||||
"website",
|
||||
"url",
|
||||
|
||||
# Computed fields for filtering
|
||||
"search_text",
|
||||
|
||||
# Metadata
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class ParkSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for the Park model."""
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from .park_views import (
|
||||
ParkSearchSuggestionsAPIView,
|
||||
ParkImageSettingsAPIView,
|
||||
)
|
||||
from .views import ParkPhotoViewSet
|
||||
from .views import ParkPhotoViewSet, HybridParkAPIView, ParkFilterMetadataAPIView
|
||||
|
||||
# Create router for nested photo endpoints
|
||||
router = DefaultRouter()
|
||||
@@ -28,6 +28,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
|
||||
|
||||
@@ -17,7 +17,7 @@ from typing import Any, cast
|
||||
import logging
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from drf_spectacular.utils import extend_schema_view, extend_schema
|
||||
from drf_spectacular.utils import extend_schema_view, extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
@@ -522,3 +522,296 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
{"error": f"Failed to save photo: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.permissions import AllowAny
|
||||
from .serializers import HybridParkSerializer
|
||||
from apps.parks.services.hybrid_loader import smart_park_loader
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
summary="Get parks with hybrid filtering",
|
||||
description="Retrieve parks with intelligent hybrid filtering strategy. Automatically chooses between client-side and server-side filtering based on data size.",
|
||||
parameters=[
|
||||
OpenApiParameter("status", OpenApiTypes.STR, description="Filter by park status (comma-separated for multiple)"),
|
||||
OpenApiParameter("park_type", OpenApiTypes.STR, description="Filter by park type (comma-separated for multiple)"),
|
||||
OpenApiParameter("country", OpenApiTypes.STR, description="Filter by country (comma-separated for multiple)"),
|
||||
OpenApiParameter("state", OpenApiTypes.STR, description="Filter by state (comma-separated for multiple)"),
|
||||
OpenApiParameter("opening_year_min", OpenApiTypes.INT, description="Minimum opening year"),
|
||||
OpenApiParameter("opening_year_max", OpenApiTypes.INT, description="Maximum opening year"),
|
||||
OpenApiParameter("size_min", OpenApiTypes.NUMBER, description="Minimum park size in acres"),
|
||||
OpenApiParameter("size_max", OpenApiTypes.NUMBER, description="Maximum park size in acres"),
|
||||
OpenApiParameter("rating_min", OpenApiTypes.NUMBER, description="Minimum average rating"),
|
||||
OpenApiParameter("rating_max", OpenApiTypes.NUMBER, description="Maximum average rating"),
|
||||
OpenApiParameter("ride_count_min", OpenApiTypes.INT, description="Minimum ride count"),
|
||||
OpenApiParameter("ride_count_max", OpenApiTypes.INT, description="Maximum ride count"),
|
||||
OpenApiParameter("coaster_count_min", OpenApiTypes.INT, description="Minimum coaster count"),
|
||||
OpenApiParameter("coaster_count_max", OpenApiTypes.INT, description="Maximum coaster count"),
|
||||
OpenApiParameter("operator", OpenApiTypes.STR, description="Filter by operator slug (comma-separated for multiple)"),
|
||||
OpenApiParameter("search", OpenApiTypes.STR, description="Search query for park names, descriptions, locations, and operators"),
|
||||
OpenApiParameter("offset", OpenApiTypes.INT, description="Offset for progressive loading (server-side pagination)"),
|
||||
],
|
||||
responses={
|
||||
200: {
|
||||
"description": "Parks data with hybrid filtering metadata",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"parks": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/HybridParkSerializer"}
|
||||
},
|
||||
"total_count": {"type": "integer"},
|
||||
"strategy": {
|
||||
"type": "string",
|
||||
"enum": ["client_side", "server_side"],
|
||||
"description": "Filtering strategy used"
|
||||
},
|
||||
"has_more": {
|
||||
"type": "boolean",
|
||||
"description": "Whether more data is available for progressive loading"
|
||||
},
|
||||
"next_offset": {
|
||||
"type": "integer",
|
||||
"nullable": True,
|
||||
"description": "Next offset for progressive loading"
|
||||
},
|
||||
"filter_metadata": {
|
||||
"type": "object",
|
||||
"description": "Available filter options and ranges"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
tags=["Parks"],
|
||||
)
|
||||
)
|
||||
class HybridParkAPIView(APIView):
|
||||
"""
|
||||
Hybrid Park API View with intelligent filtering strategy.
|
||||
|
||||
Automatically chooses between client-side and server-side filtering
|
||||
based on data size and complexity. Provides progressive loading
|
||||
for large datasets and complete data for smaller sets.
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get(self, request):
|
||||
"""Get parks with hybrid filtering strategy."""
|
||||
try:
|
||||
# Extract filters from query parameters
|
||||
filters = self._extract_filters(request.query_params)
|
||||
|
||||
# Check if this is a progressive load request
|
||||
offset = request.query_params.get('offset')
|
||||
if offset is not None:
|
||||
try:
|
||||
offset = int(offset)
|
||||
# Get progressive load data
|
||||
data = smart_park_loader.get_progressive_load(offset, filters)
|
||||
except ValueError:
|
||||
return Response(
|
||||
{"error": "Invalid offset parameter"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
else:
|
||||
# Get initial load data
|
||||
data = smart_park_loader.get_initial_load(filters)
|
||||
|
||||
# Serialize the parks data
|
||||
serializer = HybridParkSerializer(data['parks'], many=True)
|
||||
|
||||
# Prepare response
|
||||
response_data = {
|
||||
'parks': serializer.data,
|
||||
'total_count': data['total_count'],
|
||||
'strategy': data.get('strategy', 'server_side'),
|
||||
'has_more': data.get('has_more', False),
|
||||
'next_offset': data.get('next_offset'),
|
||||
}
|
||||
|
||||
# Include filter metadata for initial loads
|
||||
if 'filter_metadata' in data:
|
||||
response_data['filter_metadata'] = data['filter_metadata']
|
||||
|
||||
return Response(response_data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in HybridParkAPIView: {e}")
|
||||
return Response(
|
||||
{"error": "Internal server error"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
def _extract_filters(self, query_params):
|
||||
"""Extract and parse filters from query parameters."""
|
||||
filters = {}
|
||||
|
||||
# Handle comma-separated list parameters
|
||||
list_params = ['status', 'park_type', 'country', 'state', 'operator']
|
||||
for param in list_params:
|
||||
value = query_params.get(param)
|
||||
if value:
|
||||
filters[param] = [v.strip() for v in value.split(',') if v.strip()]
|
||||
|
||||
# Handle integer parameters
|
||||
int_params = [
|
||||
'opening_year_min', 'opening_year_max',
|
||||
'ride_count_min', 'ride_count_max',
|
||||
'coaster_count_min', 'coaster_count_max'
|
||||
]
|
||||
for param in int_params:
|
||||
value = query_params.get(param)
|
||||
if value:
|
||||
try:
|
||||
filters[param] = int(value)
|
||||
except ValueError:
|
||||
pass # Skip invalid integer values
|
||||
|
||||
# Handle float parameters
|
||||
float_params = ['size_min', 'size_max', 'rating_min', 'rating_max']
|
||||
for param in float_params:
|
||||
value = query_params.get(param)
|
||||
if value:
|
||||
try:
|
||||
filters[param] = float(value)
|
||||
except ValueError:
|
||||
pass # Skip invalid float values
|
||||
|
||||
# Handle search parameter
|
||||
search = query_params.get('search')
|
||||
if search:
|
||||
filters['search'] = search.strip()
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
summary="Get park filter metadata",
|
||||
description="Get available filter options and ranges for parks filtering.",
|
||||
parameters=[
|
||||
OpenApiParameter("scoped", OpenApiTypes.BOOL, description="Whether to scope metadata to current filters"),
|
||||
],
|
||||
responses={
|
||||
200: {
|
||||
"description": "Filter metadata",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"categorical": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"countries": {"type": "array", "items": {"type": "string"}},
|
||||
"states": {"type": "array", "items": {"type": "string"}},
|
||||
"park_types": {"type": "array", "items": {"type": "string"}},
|
||||
"statuses": {"type": "array", "items": {"type": "string"}},
|
||||
"operators": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"slug": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ranges": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"opening_year": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min": {"type": "integer", "nullable": True},
|
||||
"max": {"type": "integer", "nullable": True}
|
||||
}
|
||||
},
|
||||
"size_acres": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min": {"type": "number", "nullable": True},
|
||||
"max": {"type": "number", "nullable": True}
|
||||
}
|
||||
},
|
||||
"average_rating": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min": {"type": "number", "nullable": True},
|
||||
"max": {"type": "number", "nullable": True}
|
||||
}
|
||||
},
|
||||
"ride_count": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min": {"type": "integer", "nullable": True},
|
||||
"max": {"type": "integer", "nullable": True}
|
||||
}
|
||||
},
|
||||
"coaster_count": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min": {"type": "integer", "nullable": True},
|
||||
"max": {"type": "integer", "nullable": True}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"total_count": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
tags=["Parks"],
|
||||
)
|
||||
)
|
||||
class ParkFilterMetadataAPIView(APIView):
|
||||
"""
|
||||
API view for getting park filter metadata.
|
||||
|
||||
Provides information about available filter options and ranges
|
||||
to help build dynamic filter interfaces.
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get(self, request):
|
||||
"""Get park filter metadata."""
|
||||
try:
|
||||
# Check if metadata should be scoped to current filters
|
||||
scoped = request.query_params.get('scoped', '').lower() == 'true'
|
||||
filters = None
|
||||
|
||||
if scoped:
|
||||
filters = self._extract_filters(request.query_params)
|
||||
|
||||
# Get filter metadata
|
||||
metadata = smart_park_loader.get_filter_metadata(filters)
|
||||
|
||||
return Response(metadata, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in ParkFilterMetadataAPIView: {e}")
|
||||
return Response(
|
||||
{"error": "Internal server error"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
def _extract_filters(self, query_params):
|
||||
"""Extract and parse filters from query parameters."""
|
||||
# Reuse the same filter extraction logic
|
||||
view = HybridParkAPIView()
|
||||
return view._extract_filters(query_params)
|
||||
|
||||
Reference in New Issue
Block a user