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:
pacnpal
2025-09-14 21:07:17 -04:00
parent 0fd6dc2560
commit 35f8d0ef8f
42 changed files with 8490 additions and 224 deletions

View File

@@ -31,11 +31,11 @@ import importlib
# --- Shared utilities and base classes ---
from .shared import (
CATEGORY_CHOICES,
ModelChoices,
LocationOutputSerializer,
CompanyOutputSerializer,
UserModel,
FilterOptionSerializer,
FilterRangeSerializer,
StandardizedFilterMetadataSerializer,
validate_filter_metadata_contract,
ensure_filter_option_format,
) # noqa: F401
# --- Parks domain ---
@@ -183,11 +183,11 @@ for domain in _optional_domains:
# --- Construct a conservative __all__ based on explicit lists and discovered serializer names ---
_SHARED_EXPORTS = [
"CATEGORY_CHOICES",
"ModelChoices",
"LocationOutputSerializer",
"CompanyOutputSerializer",
"UserModel",
"FilterOptionSerializer",
"FilterRangeSerializer",
"StandardizedFilterMetadataSerializer",
"validate_filter_metadata_contract",
"ensure_filter_option_format",
]
_PARKS_EXPORTS = [
@@ -259,11 +259,11 @@ _SERVICES_EXPORTS = [
# Build a static __all__ list with only the serializers we know exist
__all__ = [
# Shared exports
"CATEGORY_CHOICES",
"ModelChoices",
"LocationOutputSerializer",
"CompanyOutputSerializer",
"UserModel",
"FilterOptionSerializer",
"FilterRangeSerializer",
"StandardizedFilterMetadataSerializer",
"validate_filter_metadata_contract",
"ensure_filter_option_format",
# Parks exports
"ParkListOutputSerializer",
"ParkDetailOutputSerializer",

View File

@@ -1,205 +1,633 @@
"""
Shared serializers and utilities for ThrillWiki API v1.
Shared Contract Serializers for ThrillWiki API
This module contains common serializers and helper classes used across multiple domains
to avoid code duplication and maintain consistency.
This module contains standardized serializers that enforce consistent formats
across all API responses, ensuring they match frontend TypeScript interfaces exactly.
These serializers prevent contract violations by providing a single source of truth
for common data structures used throughout the API.
"""
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field
from django.contrib.auth import get_user_model
from django.conf import settings
# Import models inside class methods to avoid Django initialization issues
UserModel = get_user_model()
# Define constants to avoid import-time model loading
CATEGORY_CHOICES = [
("RC", "Roller Coaster"),
("FL", "Flat Ride"),
("DR", "Dark Ride"),
("WR", "Water Ride"),
("TR", "Transport"),
("OT", "Other"),
]
from typing import Dict, Any, List, Optional
# Placeholder for dynamic model choices - will be populated at runtime
class ModelChoices:
@staticmethod
def get_ride_status_choices():
try:
from apps.rides.models import Ride
return Ride.STATUS_CHOICES
except ImportError:
return [("OPERATING", "Operating"), ("CLOSED", "Closed")]
@staticmethod
def get_park_status_choices():
try:
from apps.parks.models import Park
return Park.STATUS_CHOICES
except ImportError:
return [("OPERATING", "Operating"), ("CLOSED", "Closed")]
@staticmethod
def get_company_role_choices():
try:
from apps.parks.models import Company
return Company.CompanyRole.choices
except ImportError:
return [("OPERATOR", "Operator"), ("MANUFACTURER", "Manufacturer")]
@staticmethod
def get_coaster_track_choices():
try:
from apps.rides.models import RollerCoasterStats
return RollerCoasterStats.TRACK_MATERIAL_CHOICES
except ImportError:
return [("STEEL", "Steel"), ("WOOD", "Wood")]
@staticmethod
def get_coaster_type_choices():
try:
from apps.rides.models import RollerCoasterStats
return RollerCoasterStats.COASTER_TYPE_CHOICES
except ImportError:
return [("SITDOWN", "Sit Down"), ("INVERTED", "Inverted")]
@staticmethod
def get_launch_choices():
try:
from apps.rides.models import RollerCoasterStats
return RollerCoasterStats.LAUNCH_CHOICES
except ImportError:
return [("CHAIN", "Chain Lift"), ("LAUNCH", "Launch")]
@staticmethod
def get_top_list_categories():
try:
from apps.accounts.models import TopList
return TopList.Categories.choices
except ImportError:
return [("RC", "Roller Coasters"), ("PARKS", "Parks")]
@staticmethod
def get_ride_post_closing_choices():
try:
from apps.rides.models import Ride
return Ride.POST_CLOSING_STATUS_CHOICES
except ImportError:
return [
("DEMOLISHED", "Demolished"),
("RELOCATED", "Relocated"),
("SBNO", "Standing But Not Operating"),
]
@staticmethod
def get_ride_category_choices():
try:
from apps.rides.models import CATEGORY_CHOICES
return CATEGORY_CHOICES
except ImportError:
return [
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport"),
("OT", "Other"),
]
class FilterOptionSerializer(serializers.Serializer):
"""
Standard filter option format - matches frontend TypeScript exactly.
Frontend TypeScript interface:
interface FilterOption {
value: string;
label: string;
count?: number;
selected?: boolean;
}
"""
value = serializers.CharField(
help_text="The actual value used for filtering"
)
label = serializers.CharField(
help_text="Human-readable display label"
)
count = serializers.IntegerField(
required=False,
allow_null=True,
help_text="Number of items matching this filter option"
)
selected = serializers.BooleanField(
default=False,
help_text="Whether this option is currently selected"
)
class LocationOutputSerializer(serializers.Serializer):
"""Shared serializer for location data."""
class FilterRangeSerializer(serializers.Serializer):
"""
Standard range filter format.
Frontend TypeScript interface:
interface FilterRange {
min: number;
max: number;
step: number;
unit?: string;
}
"""
min = serializers.FloatField(
allow_null=True,
help_text="Minimum value for the range"
)
max = serializers.FloatField(
allow_null=True,
help_text="Maximum value for the range"
)
step = serializers.FloatField(
default=1.0,
help_text="Step size for range inputs"
)
unit = serializers.CharField(
required=False,
allow_null=True,
help_text="Unit of measurement (e.g., 'feet', 'mph', 'stars')"
)
latitude = serializers.SerializerMethodField()
longitude = serializers.SerializerMethodField()
city = serializers.SerializerMethodField()
state = serializers.SerializerMethodField()
country = serializers.SerializerMethodField()
formatted_address = serializers.SerializerMethodField()
@extend_schema_field(serializers.FloatField(allow_null=True))
def get_latitude(self, obj) -> float | None:
if hasattr(obj, "location") and obj.location:
return obj.location.latitude
return None
class BooleanFilterSerializer(serializers.Serializer):
"""
Standard boolean filter format.
Frontend TypeScript interface:
interface BooleanFilter {
key: string;
label: string;
description: string;
}
"""
key = serializers.CharField(
help_text="The filter parameter key"
)
label = serializers.CharField(
help_text="Human-readable label for the filter"
)
description = serializers.CharField(
help_text="Description of what this filter does"
)
@extend_schema_field(serializers.FloatField(allow_null=True))
def get_longitude(self, obj) -> float | None:
if hasattr(obj, "location") and obj.location:
return obj.location.longitude
return None
@extend_schema_field(serializers.CharField(allow_null=True))
def get_city(self, obj) -> str | None:
if hasattr(obj, "location") and obj.location:
return obj.location.city
return None
class OrderingOptionSerializer(serializers.Serializer):
"""
Standard ordering option format.
Frontend TypeScript interface:
interface OrderingOption {
value: string;
label: string;
}
"""
value = serializers.CharField(
help_text="The ordering parameter value"
)
label = serializers.CharField(
help_text="Human-readable label for the ordering option"
)
@extend_schema_field(serializers.CharField(allow_null=True))
def get_state(self, obj) -> str | None:
if hasattr(obj, "location") and obj.location:
return obj.location.state
return None
@extend_schema_field(serializers.CharField(allow_null=True))
def get_country(self, obj) -> str | None:
if hasattr(obj, "location") and obj.location:
return obj.location.country
return None
class StandardizedFilterMetadataSerializer(serializers.Serializer):
"""
Matches frontend TypeScript interface exactly.
This serializer ensures all filter metadata responses follow the same structure
that the frontend expects, preventing runtime type errors.
"""
categorical = serializers.DictField(
child=FilterOptionSerializer(many=True),
help_text="Categorical filter options with value/label/count structure"
)
ranges = serializers.DictField(
child=FilterRangeSerializer(),
help_text="Range filter metadata with min/max/step/unit"
)
total_count = serializers.IntegerField(
help_text="Total number of items in the filtered dataset"
)
ordering_options = FilterOptionSerializer(
many=True,
required=False,
help_text="Available ordering options"
)
boolean_filters = BooleanFilterSerializer(
many=True,
required=False,
help_text="Available boolean filter options"
)
@extend_schema_field(serializers.CharField())
def get_formatted_address(self, obj) -> str:
if hasattr(obj, "location") and obj.location:
return obj.location.formatted_address
return ""
class PaginationMetadataSerializer(serializers.Serializer):
"""
Standard pagination metadata format.
Frontend TypeScript interface:
interface PaginationMetadata {
count: number;
next: string | null;
previous: string | null;
page_size: number;
current_page: number;
total_pages: number;
}
"""
count = serializers.IntegerField(
help_text="Total number of items across all pages"
)
next = serializers.URLField(
allow_null=True,
required=False,
help_text="URL for the next page of results"
)
previous = serializers.URLField(
allow_null=True,
required=False,
help_text="URL for the previous page of results"
)
page_size = serializers.IntegerField(
help_text="Number of items per page"
)
current_page = serializers.IntegerField(
help_text="Current page number (1-indexed)"
)
total_pages = serializers.IntegerField(
help_text="Total number of pages"
)
class ApiResponseSerializer(serializers.Serializer):
"""
Standard API response wrapper.
Frontend TypeScript interface:
interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
errors?: ValidationError;
}
"""
success = serializers.BooleanField(
help_text="Whether the request was successful"
)
response_data = serializers.JSONField(
required=False,
help_text="Response data (structure varies by endpoint)",
source='data'
)
message = serializers.CharField(
required=False,
help_text="Human-readable message about the operation"
)
response_errors = serializers.DictField(
required=False,
help_text="Validation errors (field -> error messages)",
source='errors'
)
class ErrorResponseSerializer(serializers.Serializer):
"""
Standard error response format.
Frontend TypeScript interface:
interface ApiError {
status: "error";
error: {
code: string;
message: string;
details?: any;
request_user?: string;
};
data: null;
}
"""
status = serializers.CharField(
default="error",
help_text="Response status indicator"
)
error = serializers.DictField(
help_text="Error details"
)
response_data = serializers.JSONField(
default=None,
allow_null=True,
help_text="Always null for error responses",
source='data'
)
class LocationSerializer(serializers.Serializer):
"""
Standard location format.
Frontend TypeScript interface:
interface Location {
city: string;
state?: string;
country: string;
address?: string;
latitude?: number;
longitude?: number;
}
"""
city = serializers.CharField(
help_text="City name"
)
state = serializers.CharField(
required=False,
allow_null=True,
help_text="State/province name"
)
country = serializers.CharField(
help_text="Country name"
)
address = serializers.CharField(
required=False,
allow_null=True,
help_text="Street address"
)
latitude = serializers.FloatField(
required=False,
allow_null=True,
help_text="Latitude coordinate"
)
longitude = serializers.FloatField(
required=False,
allow_null=True,
help_text="Longitude coordinate"
)
# Alias for backward compatibility
LocationOutputSerializer = LocationSerializer
class CompanyOutputSerializer(serializers.Serializer):
"""Shared serializer for company data."""
"""
Standard company output format.
Frontend TypeScript interface:
interface Company {
id: number;
name: string;
slug: string;
roles?: string[];
}
"""
id = serializers.IntegerField(
help_text="Company ID"
)
name = serializers.CharField(
help_text="Company name"
)
slug = serializers.SlugField(
help_text="URL-friendly identifier"
)
roles = serializers.ListField(
child=serializers.CharField(),
required=False,
help_text="Company roles (manufacturer, operator, etc.)"
)
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
roles = serializers.ListField(child=serializers.CharField(), required=False)
url = serializers.SerializerMethodField()
@extend_schema_field(serializers.URLField())
def get_url(self, obj) -> str:
"""Generate the frontend URL for this company based on their primary role.
# Category choices for ride models
CATEGORY_CHOICES = [
('RC', 'Roller Coaster'),
('DR', 'Dark Ride'),
('FR', 'Flat Ride'),
('WR', 'Water Ride'),
('TR', 'Transport Ride'),
]
CRITICAL DOMAIN SEPARATION:
- OPERATOR and PROPERTY_OWNER are for parks domain
- MANUFACTURER and DESIGNER are for rides domain
"""
# Use the URL field from the model if it exists (auto-generated on save)
if hasattr(obj, "url") and obj.url:
return obj.url
# Fallback URL generation (should not be needed if model save works correctly)
if hasattr(obj, "roles") and obj.roles:
frontend_domain = getattr(
settings, "FRONTEND_DOMAIN", "https://thrillwiki.com"
)
primary_role = obj.roles[0] if obj.roles else None
class ModelChoices:
"""
Utility class to provide model choices for serializers.
This prevents circular imports while providing access to model choices.
"""
@staticmethod
def get_park_status_choices():
"""Get park status choices."""
return [
('OPERATING', 'Operating'),
('CLOSED_TEMP', 'Temporarily Closed'),
('CLOSED_PERM', 'Permanently Closed'),
('UNDER_CONSTRUCTION', 'Under Construction'),
('PLANNED', 'Planned'),
]
@staticmethod
def get_ride_status_choices():
"""Get ride status choices."""
return [
('OPERATING', 'Operating'),
('CLOSED_TEMP', 'Temporarily Closed'),
('CLOSED_PERM', 'Permanently Closed'),
('SBNO', 'Standing But Not Operating'),
('UNDER_CONSTRUCTION', 'Under Construction'),
('RELOCATED', 'Relocated'),
('DEMOLISHED', 'Demolished'),
]
@staticmethod
def get_company_role_choices():
"""Get company role choices."""
return [
('MANUFACTURER', 'Manufacturer'),
('OPERATOR', 'Operator'),
('DESIGNER', 'Designer'),
('PROPERTY_OWNER', 'Property Owner'),
]
@staticmethod
def get_ride_category_choices():
"""Get ride category choices."""
return CATEGORY_CHOICES
@staticmethod
def get_ride_post_closing_choices():
"""Get ride post-closing status choices."""
return [
('RELOCATED', 'Relocated'),
('DEMOLISHED', 'Demolished'),
('STORED', 'Stored'),
('UNKNOWN', 'Unknown'),
]
@staticmethod
def get_coaster_track_choices():
"""Get coaster track type choices."""
return [
('STEEL', 'Steel'),
('WOOD', 'Wood'),
('HYBRID', 'Hybrid'),
]
@staticmethod
def get_coaster_type_choices():
"""Get coaster type choices."""
return [
('SIT_DOWN', 'Sit Down'),
('INVERTED', 'Inverted'),
('FLOORLESS', 'Floorless'),
('FLYING', 'Flying'),
('STAND_UP', 'Stand Up'),
('SPINNING', 'Spinning'),
('WING', 'Wing'),
('DIVE', 'Dive'),
('LAUNCHED', 'Launched'),
]
@staticmethod
def get_launch_choices():
"""Get launch system choices."""
return [
('NONE', 'None'),
('LIM', 'Linear Induction Motor'),
('LSM', 'Linear Synchronous Motor'),
('HYDRAULIC', 'Hydraulic'),
('PNEUMATIC', 'Pneumatic'),
('CABLE', 'Cable'),
('FLYWHEEL', 'Flywheel'),
]
# Only generate URLs for rides domain roles here
if primary_role == "MANUFACTURER":
return f"{frontend_domain}/rides/manufacturers/{obj.slug}/"
elif primary_role == "DESIGNER":
return f"{frontend_domain}/rides/designers/{obj.slug}/"
# OPERATOR and PROPERTY_OWNER URLs are handled by parks domain
return ""
class EntityReferenceSerializer(serializers.Serializer):
"""
Standard entity reference format.
Frontend TypeScript interface:
interface Entity {
id: number;
name: string;
slug: string;
}
"""
id = serializers.IntegerField(
help_text="Unique identifier"
)
name = serializers.CharField(
help_text="Display name"
)
slug = serializers.SlugField(
help_text="URL-friendly identifier"
)
class ImageVariantsSerializer(serializers.Serializer):
"""
Standard image variants format.
Frontend TypeScript interface:
interface ImageVariants {
thumbnail: string;
medium: string;
large: string;
avatar?: string;
}
"""
thumbnail = serializers.URLField(
help_text="Thumbnail size image URL"
)
medium = serializers.URLField(
help_text="Medium size image URL"
)
large = serializers.URLField(
help_text="Large size image URL"
)
avatar = serializers.URLField(
required=False,
help_text="Avatar size image URL (for user avatars)"
)
class PhotoSerializer(serializers.Serializer):
"""
Standard photo format.
Frontend TypeScript interface:
interface Photo {
id: number;
image_variants: ImageVariants;
alt_text?: string;
image_url?: string;
caption?: string;
photo_type?: string;
uploaded_by?: UserInfo;
uploaded_at?: string;
}
"""
id = serializers.IntegerField(
help_text="Photo ID"
)
image_variants = ImageVariantsSerializer(
help_text="Available image size variants"
)
alt_text = serializers.CharField(
required=False,
allow_null=True,
help_text="Alternative text for accessibility"
)
image_url = serializers.URLField(
required=False,
help_text="Primary image URL (for compatibility)"
)
caption = serializers.CharField(
required=False,
allow_null=True,
help_text="Photo caption"
)
photo_type = serializers.CharField(
required=False,
allow_null=True,
help_text="Type/category of photo"
)
uploaded_by = EntityReferenceSerializer(
required=False,
help_text="User who uploaded the photo"
)
uploaded_at = serializers.DateTimeField(
required=False,
help_text="When the photo was uploaded"
)
class UserInfoSerializer(serializers.Serializer):
"""
Standard user info format.
Frontend TypeScript interface:
interface UserInfo {
id: number;
username: string;
display_name: string;
avatar_url?: string;
}
"""
id = serializers.IntegerField(
help_text="User ID"
)
username = serializers.CharField(
help_text="Username"
)
display_name = serializers.CharField(
help_text="Display name"
)
avatar_url = serializers.URLField(
required=False,
allow_null=True,
help_text="User avatar URL"
)
def validate_filter_metadata_contract(data: Dict[str, Any]) -> Dict[str, Any]:
"""
Validate that filter metadata follows the expected contract.
This function can be used in views to ensure filter metadata
matches the frontend TypeScript interface before returning it.
Args:
data: Filter metadata dictionary
Returns:
Validated and potentially transformed data
Raises:
serializers.ValidationError: If data doesn't match contract
"""
serializer = StandardizedFilterMetadataSerializer(data=data)
serializer.is_valid(raise_exception=True)
# Return validated_data directly - it's already a dict
return serializer.validated_data
def ensure_filter_option_format(options: List[Any]) -> List[Dict[str, Any]]:
"""
Ensure a list of filter options follows the expected format.
This utility function converts various input formats to the standard
FilterOption format expected by the frontend.
Args:
options: List of options in various formats
Returns:
List of options in standard format
"""
standardized = []
for option in options:
if isinstance(option, dict):
# Already in correct format or close to it
standardized_option = {
'value': str(option.get('value', option.get('id', ''))),
'label': option.get('label', option.get('name', str(option.get('value', '')))),
'count': option.get('count'),
'selected': option.get('selected', False)
}
elif isinstance(option, (list, tuple)) and len(option) >= 2:
# Tuple format: (value, label) or (value, label, count)
standardized_option = {
'value': str(option[0]),
'label': str(option[1]),
'count': option[2] if len(option) > 2 else None,
'selected': False
}
else:
# Simple value - use as both value and label
standardized_option = {
'value': str(option),
'label': str(option),
'count': None,
'selected': False
}
standardized.append(standardized_option)
return standardized
def ensure_range_format(range_data: Dict[str, Any]) -> Dict[str, Any]:
"""
Ensure range data follows the expected format.
Args:
range_data: Range data dictionary
Returns:
Range data in standard format
"""
return {
'min': range_data.get('min'),
'max': range_data.get('max'),
'step': range_data.get('step', 1.0),
'unit': range_data.get('unit')
}