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

@@ -21,6 +21,61 @@ def reverse_migrate_avatar_data(apps, schema_editor):
pass
def safe_add_avatar_field(apps, schema_editor):
"""
Safely add avatar field, checking if it already exists.
"""
# Check if the column already exists
with schema_editor.connection.cursor() as cursor:
cursor.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name='accounts_userprofile'
AND column_name='avatar_id'
""")
column_exists = cursor.fetchone() is not None
if not column_exists:
# Column doesn't exist, add it
UserProfile = apps.get_model('accounts', 'UserProfile')
field = models.ForeignKey(
'django_cloudflareimages_toolkit.CloudflareImage',
on_delete=models.SET_NULL,
null=True,
blank=True
)
field.set_attributes_from_name('avatar')
schema_editor.add_field(UserProfile, field)
def reverse_safe_add_avatar_field(apps, schema_editor):
"""
Reverse the safe avatar field addition.
"""
# Check if the column exists and remove it
with schema_editor.connection.cursor() as cursor:
cursor.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name='accounts_userprofile'
AND column_name='avatar_id'
""")
column_exists = cursor.fetchone() is not None
if column_exists:
UserProfile = apps.get_model('accounts', 'UserProfile')
field = models.ForeignKey(
'django_cloudflareimages_toolkit.CloudflareImage',
on_delete=models.SET_NULL,
null=True,
blank=True
)
field.set_attributes_from_name('avatar')
schema_editor.remove_field(UserProfile, field)
class Migration(migrations.Migration):
dependencies = [
@@ -38,16 +93,10 @@ class Migration(migrations.Migration):
reverse_sql="-- Cannot reverse this operation"
),
# Add the new avatar_id column for ForeignKey
migrations.AddField(
model_name='userprofile',
name='avatar',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to='django_cloudflareimages_toolkit.cloudflareimage'
),
# Safely add the new avatar_id column for ForeignKey
migrations.RunPython(
safe_add_avatar_field,
reverse_safe_add_avatar_field,
),
# Run the data migration

View File

@@ -17,9 +17,21 @@ class Migration(migrations.Migration):
reverse_sql="-- Cannot reverse this operation"
),
# Add the new avatar_id field to match the main table
# Add the new avatar_id field to match the main table (only if it doesn't exist)
migrations.RunSQL(
"ALTER TABLE accounts_userprofileevent ADD COLUMN avatar_id uuid;",
reverse_sql="ALTER TABLE accounts_userprofileevent DROP COLUMN avatar_id;"
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT column_name
FROM information_schema.columns
WHERE table_name='accounts_userprofileevent'
AND column_name='avatar_id'
) THEN
ALTER TABLE accounts_userprofileevent ADD COLUMN avatar_id uuid;
END IF;
END $$;
""",
reverse_sql="ALTER TABLE accounts_userprofileevent DROP COLUMN IF EXISTS avatar_id;"
),
]

View File

@@ -0,0 +1 @@
# Management commands package

View File

@@ -0,0 +1,158 @@
# ThrillWiki Data Seeding Script
## Overview
The `seed_data.py` management command provides comprehensive test data seeding for the ThrillWiki application. It creates realistic data across all models in the system for testing and development purposes.
## Usage
### Basic Usage
```bash
# Seed with default counts
uv run manage.py seed_data
# Clear existing data and seed fresh
uv run manage.py seed_data --clear
# Custom counts
uv run manage.py seed_data --users 50 --parks 20 --rides 100 --reviews 200
```
### Command Options
- `--clear`: Clear existing data before seeding
- `--users N`: Number of users to create (default: 25)
- `--companies N`: Number of companies to create (default: 15)
- `--parks N`: Number of parks to create (default: 10)
- `--rides N`: Number of rides to create (default: 50)
- `--ride-models N`: Number of ride models to create (default: 20)
- `--reviews N`: Number of reviews to create (default: 100)
## What Gets Created
### Users & Accounts
- **Admin User**: `admin` / `admin123` (superuser)
- **Moderator User**: `moderator` / `mod123` (staff)
- **Regular Users**: Random realistic users with profiles
- **User Profiles**: Complete with ride credits, social links, preferences
- **Notifications**: Sample notifications for users
- **Top Lists**: User-created top lists for parks and rides
### Companies
- **Park Operators**: Disney, Universal, Six Flags, Cedar Fair, etc.
- **Ride Manufacturers**: B&M, Intamin, Vekoma, RMC, etc.
- **Ride Designers**: Werner Stengel, Alan Schilke, John Wardley
- **Company Headquarters**: Realistic address data
### Parks & Locations
- **Famous Parks**: Magic Kingdom, Disneyland, Cedar Point, etc.
- **Park Locations**: Geographic coordinates and addresses
- **Park Areas**: Themed areas within parks
- **Park Photos**: Sample photo records
### Rides & Models
- **Famous Coasters**: Steel Vengeance, Millennium Force, etc.
- **Ride Models**: B&M Dive Coaster, Intamin Accelerator, etc.
- **Roller Coaster Stats**: Height, speed, inversions, etc.
- **Ride Photos**: Sample photo records
- **Technical Specs**: Detailed specifications for ride models
### Content & Reviews
- **Park Reviews**: User reviews with ratings and visit dates
- **Ride Reviews**: Detailed ride experiences
- **Review Content**: Realistic review text and ratings
## Data Quality Features
### Realistic Data
- **Names**: Diverse, realistic user names
- **Locations**: Accurate geographic coordinates
- **Relationships**: Proper company-park-ride relationships
- **Statistics**: Realistic ride statistics and ratings
### Comprehensive Coverage
- **All Models**: Seeds data for every model in the system
- **Relationships**: Maintains proper foreign key relationships
- **Optional Models**: Handles models that may not exist gracefully
### Data Integrity
- **Unique Constraints**: Uses `get_or_create` to avoid duplicates
- **Validation**: Respects model constraints and validation rules
- **Dependencies**: Creates data in proper dependency order
## Technical Implementation
### Architecture
- **Modular Design**: Separate methods for each model type
- **Transaction Safety**: All operations wrapped in database transaction
- **Error Handling**: Graceful handling of missing optional models
- **Progress Reporting**: Clear console output with emojis and counts
### Model Handling
- **Dual Company Models**: Properly handles separate Park and Ride company models
- **Optional Models**: Checks for existence before using optional models
- **Type Safety**: Proper type hints and error handling
### Data Generation
- **Random but Realistic**: Uses curated lists for realistic data
- **Configurable Counts**: All counts are configurable via command line
- **Relationship Integrity**: Maintains proper relationships between models
## Troubleshooting
### Common Issues
1. **Database Schema Mismatch**: If you see timezone constraint errors, run migrations first:
```bash
uv run manage.py migrate
```
2. **Permission Errors**: Ensure database user has proper permissions for all operations
3. **Memory Issues**: For large datasets, consider running with smaller batches
### Known Limitations
- **Database Schema Compatibility**: May encounter issues with database schemas that have additional required fields not present in the current models (e.g., timezone field)
- **pghistory Compatibility**: May have issues with some pghistory configurations
- **Cloudflare Images**: Creates placeholder records without actual images
- **Geographic Data**: Requires PostGIS for location features
- **Transaction Management**: Uses atomic transactions which may fail completely if any model creation fails
## Development Notes
### Adding New Models
1. Import the model at the top of the file
2. Add to `models_to_clear` list if needed
3. Create a new `create_*` method
4. Call the method in `handle()` in proper dependency order
5. Add count to `print_summary()`
### Customizing Data
- Modify the data lists (e.g., `first_names`, `famous_parks`) to customize generated data
- Adjust probability weights for different scenarios
- Add new relationship patterns as needed
## Performance
### Optimization Tips
- Use `--clear` sparingly in production-like environments
- Consider smaller batch sizes for very large datasets
- Monitor database performance during seeding
### Typical Performance
- 25 users, 15 companies, 10 parks, 50 rides: ~30 seconds
- 100 users, 50 companies, 25 parks, 200 rides: ~2-3 minutes
## Security Notes
- **Default Passwords**: All seeded users have simple passwords for development only
- **Admin Access**: Creates admin user with known credentials
- **Production Warning**: Never run with `--clear` in production environments
## Future Enhancements
- **Bulk Operations**: Use bulk_create for better performance
- **Custom Scenarios**: Add preset scenarios (small, medium, large)
- **Data Export**: Add ability to export seeded data
- **Incremental Updates**: Support for updating existing data

View File

@@ -0,0 +1 @@
# Management commands

File diff suppressed because it is too large Load Diff

View File

@@ -41,7 +41,6 @@ from django.shortcuts import get_object_or_404
from rest_framework.permissions import AllowAny
from django.utils import timezone
from django_cloudflareimages_toolkit.models import CloudflareImage
import json
# Set up logging
logger = logging.getLogger(__name__)
@@ -370,7 +369,7 @@ def save_avatar_image(request):
old_avatar.delete()
# Debug logging to see what's happening with the CloudflareImage
logger.info(f"CloudflareImage debug info:")
logger.info("CloudflareImage debug info:")
logger.info(f" ID: {cloudflare_image.id}")
logger.info(f" cloudflare_id: {cloudflare_image.cloudflare_id}")
logger.info(f" status: {cloudflare_image.status}")
@@ -383,7 +382,7 @@ def save_avatar_image(request):
avatar_variants = profile.get_avatar_variants()
# More debug logging
logger.info(f"Avatar URL generation:")
logger.info("Avatar URL generation:")
logger.info(f" avatar_url: {avatar_url}")
logger.info(f" avatar_variants: {avatar_variants}")

View File

@@ -0,0 +1,339 @@
"""
Contract Validation Middleware for ThrillWiki API
This middleware catches contract violations between the Django backend and frontend
TypeScript interfaces, providing immediate feedback during development.
"""
import json
import logging
from typing import Dict, Any, Optional
from django.conf import settings
from django.http import JsonResponse
from django.utils.deprecation import MiddlewareMixin
from rest_framework.response import Response
logger = logging.getLogger(__name__)
class ContractValidationMiddleware(MiddlewareMixin):
"""
Development-only middleware that validates API responses against expected contracts.
This middleware:
1. Checks all API responses for contract compliance
2. Logs warnings when responses don't match expected TypeScript interfaces
3. Specifically validates filter metadata structure
4. Alerts when categorical filters are strings instead of objects
Only active when DEBUG=True to avoid performance impact in production.
"""
def __init__(self, get_response):
super().__init__(get_response)
self.get_response = get_response
self.enabled = getattr(settings, 'DEBUG', False)
if self.enabled:
logger.info("Contract validation middleware enabled (DEBUG mode)")
def process_response(self, request, response):
"""Process API responses to check for contract violations."""
if not self.enabled:
return response
# Only validate API endpoints
if not request.path.startswith('/api/'):
return response
# Only validate JSON responses
if not isinstance(response, (JsonResponse, Response)):
return response
# Only validate successful responses (2xx status codes)
if not (200 <= response.status_code < 300):
return response
try:
# Get response data
if isinstance(response, Response):
data = response.data
else:
data = json.loads(response.content.decode('utf-8'))
# Validate the response
self._validate_response_contract(request.path, data)
except Exception as e:
# Log validation errors but don't break the response
logger.warning(
f"Contract validation error for {request.path}: {str(e)}",
extra={
'path': request.path,
'method': request.method,
'status_code': response.status_code,
'validation_error': str(e)
}
)
return response
def _validate_response_contract(self, path: str, data: Any) -> None:
"""Validate response data against expected contracts."""
# Check for filter metadata endpoints
if 'filter-options' in path or 'filter_options' in path:
self._validate_filter_metadata(path, data)
# Check for hybrid filtering endpoints
if 'hybrid' in path:
self._validate_hybrid_response(path, data)
# Check for pagination responses
if isinstance(data, dict) and 'results' in data:
self._validate_pagination_response(path, data)
# Check for common contract violations
self._validate_common_patterns(path, data)
def _validate_filter_metadata(self, path: str, data: Any) -> None:
"""Validate filter metadata structure."""
if not isinstance(data, dict):
self._log_contract_violation(
path,
"FILTER_METADATA_NOT_DICT",
f"Filter metadata should be a dictionary, got {type(data).__name__}"
)
return
# Check for categorical filters
if 'categorical' in data:
categorical = data['categorical']
if isinstance(categorical, dict):
for filter_name, filter_options in categorical.items():
self._validate_categorical_filter(path, filter_name, filter_options)
# Check for ranges
if 'ranges' in data:
ranges = data['ranges']
if isinstance(ranges, dict):
for range_name, range_data in ranges.items():
self._validate_range_filter(path, range_name, range_data)
def _validate_categorical_filter(self, path: str, filter_name: str, filter_options: Any) -> None:
"""Validate categorical filter options format."""
if not isinstance(filter_options, list):
self._log_contract_violation(
path,
"CATEGORICAL_FILTER_NOT_ARRAY",
f"Categorical filter '{filter_name}' should be an array, got {type(filter_options).__name__}"
)
return
for i, option in enumerate(filter_options):
if isinstance(option, str):
# CRITICAL: This is the main contract violation we're trying to catch
self._log_contract_violation(
path,
"CATEGORICAL_OPTION_IS_STRING",
f"Categorical filter '{filter_name}' option {i} is a string '{option}' but should be an object with value/label/count properties",
severity="ERROR"
)
elif isinstance(option, dict):
# Validate object structure
if 'value' not in option:
self._log_contract_violation(
path,
"MISSING_VALUE_PROPERTY",
f"Categorical filter '{filter_name}' option {i} missing 'value' property"
)
if 'label' not in option:
self._log_contract_violation(
path,
"MISSING_LABEL_PROPERTY",
f"Categorical filter '{filter_name}' option {i} missing 'label' property"
)
# Count is optional but should be number if present
if 'count' in option and option['count'] is not None and not isinstance(option['count'], (int, float)):
self._log_contract_violation(
path,
"INVALID_COUNT_TYPE",
f"Categorical filter '{filter_name}' option {i} 'count' should be a number, got {type(option['count']).__name__}"
)
def _validate_range_filter(self, path: str, range_name: str, range_data: Any) -> None:
"""Validate range filter format."""
if not isinstance(range_data, dict):
self._log_contract_violation(
path,
"RANGE_FILTER_NOT_OBJECT",
f"Range filter '{range_name}' should be an object, got {type(range_data).__name__}"
)
return
# Check required properties
required_props = ['min', 'max']
for prop in required_props:
if prop not in range_data:
self._log_contract_violation(
path,
"MISSING_RANGE_PROPERTY",
f"Range filter '{range_name}' missing required property '{prop}'"
)
# Check step property
if 'step' in range_data and not isinstance(range_data['step'], (int, float)):
self._log_contract_violation(
path,
"INVALID_STEP_TYPE",
f"Range filter '{range_name}' 'step' should be a number, got {type(range_data['step']).__name__}"
)
def _validate_hybrid_response(self, path: str, data: Any) -> None:
"""Validate hybrid filtering response structure."""
if not isinstance(data, dict):
return
# Check for strategy field
if 'strategy' in data:
strategy = data['strategy']
if strategy not in ['client_side', 'server_side']:
self._log_contract_violation(
path,
"INVALID_STRATEGY_VALUE",
f"Hybrid response strategy should be 'client_side' or 'server_side', got '{strategy}'"
)
# Check filter_metadata structure
if 'filter_metadata' in data:
self._validate_filter_metadata(path, data['filter_metadata'])
def _validate_pagination_response(self, path: str, data: Dict[str, Any]) -> None:
"""Validate pagination response structure."""
# Check for required pagination fields
required_fields = ['count', 'results']
for field in required_fields:
if field not in data:
self._log_contract_violation(
path,
"MISSING_PAGINATION_FIELD",
f"Pagination response missing required field '{field}'"
)
# Check results is array
if 'results' in data and not isinstance(data['results'], list):
self._log_contract_violation(
path,
"RESULTS_NOT_ARRAY",
f"Pagination 'results' should be an array, got {type(data['results']).__name__}"
)
def _validate_common_patterns(self, path: str, data: Any) -> None:
"""Validate common API response patterns."""
if isinstance(data, dict):
# Check for null vs undefined issues
for key, value in data.items():
if value is None and key.endswith('_id'):
# ID fields should probably be null, not undefined
continue
# Check for numeric fields that might be strings
if key.endswith('_count') and isinstance(value, str):
try:
int(value)
self._log_contract_violation(
path,
"NUMERIC_FIELD_AS_STRING",
f"Field '{key}' appears to be numeric but is a string: '{value}'"
)
except ValueError:
pass
def _log_contract_violation(
self,
path: str,
violation_type: str,
message: str,
severity: str = "WARNING"
) -> None:
"""Log a contract violation with structured data."""
log_data = {
'contract_violation': True,
'violation_type': violation_type,
'api_path': path,
'severity': severity,
'message': message,
'suggestion': self._get_violation_suggestion(violation_type)
}
if severity == "ERROR":
logger.error(f"CONTRACT VIOLATION [{violation_type}]: {message}", extra=log_data)
else:
logger.warning(f"CONTRACT VIOLATION [{violation_type}]: {message}", extra=log_data)
def _get_violation_suggestion(self, violation_type: str) -> str:
"""Get suggestion for fixing a contract violation."""
suggestions = {
"CATEGORICAL_OPTION_IS_STRING": (
"Convert string arrays to object arrays with {value, label, count} structure. "
"Use the ensure_filter_option_format() utility function from apps.api.v1.serializers.shared"
),
"MISSING_VALUE_PROPERTY": (
"Add 'value' property to filter option objects. "
"Use FilterOptionSerializer from apps.api.v1.serializers.shared"
),
"MISSING_LABEL_PROPERTY": (
"Add 'label' property to filter option objects. "
"Use FilterOptionSerializer from apps.api.v1.serializers.shared"
),
"RANGE_FILTER_NOT_OBJECT": (
"Convert range data to object with min/max/step/unit properties. "
"Use FilterRangeSerializer from apps.api.v1.serializers.shared"
),
"NUMERIC_FIELD_AS_STRING": (
"Ensure numeric fields are returned as numbers, not strings. "
"Check serializer field types and database field types."
),
"RESULTS_NOT_ARRAY": (
"Ensure pagination 'results' field is always an array. "
"Check serializer implementation."
)
}
return suggestions.get(violation_type, "Check the API response format against frontend TypeScript interfaces.")
class ContractValidationSettings:
"""Settings for contract validation middleware."""
# Enable/disable specific validation checks
VALIDATE_FILTER_METADATA = True
VALIDATE_PAGINATION = True
VALIDATE_HYBRID_RESPONSES = True
VALIDATE_COMMON_PATTERNS = True
# Severity levels for different violations
CATEGORICAL_STRING_SEVERITY = "ERROR" # This is the critical issue
MISSING_PROPERTY_SEVERITY = "WARNING"
TYPE_MISMATCH_SEVERITY = "WARNING"
# Paths to exclude from validation
EXCLUDED_PATHS = [
'/api/docs/',
'/api/schema/',
'/api/v1/auth/', # Auth endpoints might have different structures
]
@classmethod
def should_validate_path(cls, path: str) -> bool:
"""Check if a path should be validated."""
return not any(excluded in path for excluded in cls.EXCLUDED_PATHS)

View File

@@ -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."""

View File

@@ -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

View File

@@ -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)

View File

@@ -262,6 +262,329 @@ class RidePhotoSerializer(serializers.ModelSerializer):
]
class HybridRideSerializer(serializers.ModelSerializer):
"""
Enhanced serializer for hybrid filtering strategy.
Includes all filterable fields for client-side filtering.
"""
# Park fields
park_name = serializers.CharField(source="park.name", read_only=True)
park_slug = serializers.CharField(source="park.slug", read_only=True)
# Park location fields
park_city = serializers.SerializerMethodField()
park_state = serializers.SerializerMethodField()
park_country = serializers.SerializerMethodField()
# Park area fields
park_area_name = serializers.CharField(source="park_area.name", read_only=True, allow_null=True)
park_area_slug = serializers.CharField(source="park_area.slug", read_only=True, allow_null=True)
# Company fields
manufacturer_name = serializers.CharField(source="manufacturer.name", read_only=True, allow_null=True)
manufacturer_slug = serializers.CharField(source="manufacturer.slug", read_only=True, allow_null=True)
designer_name = serializers.CharField(source="designer.name", read_only=True, allow_null=True)
designer_slug = serializers.CharField(source="designer.slug", read_only=True, allow_null=True)
# Ride model fields
ride_model_name = serializers.CharField(source="ride_model.name", read_only=True, allow_null=True)
ride_model_slug = serializers.CharField(source="ride_model.slug", read_only=True, allow_null=True)
ride_model_category = serializers.CharField(source="ride_model.category", read_only=True, allow_null=True)
ride_model_manufacturer_name = serializers.CharField(source="ride_model.manufacturer.name", read_only=True, allow_null=True)
ride_model_manufacturer_slug = serializers.CharField(source="ride_model.manufacturer.slug", read_only=True, allow_null=True)
# Roller coaster stats fields
coaster_height_ft = serializers.SerializerMethodField()
coaster_length_ft = serializers.SerializerMethodField()
coaster_speed_mph = serializers.SerializerMethodField()
coaster_inversions = serializers.SerializerMethodField()
coaster_ride_time_seconds = serializers.SerializerMethodField()
coaster_track_type = serializers.SerializerMethodField()
coaster_track_material = serializers.SerializerMethodField()
coaster_roller_coaster_type = serializers.SerializerMethodField()
coaster_max_drop_height_ft = serializers.SerializerMethodField()
coaster_launch_type = serializers.SerializerMethodField()
coaster_train_style = serializers.SerializerMethodField()
coaster_trains_count = serializers.SerializerMethodField()
coaster_cars_per_train = serializers.SerializerMethodField()
coaster_seats_per_car = serializers.SerializerMethodField()
# 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_park_city(self, obj):
"""Get city from park location."""
try:
if obj.park and hasattr(obj.park, 'location') and obj.park.location:
return obj.park.location.city
return None
except AttributeError:
return None
@extend_schema_field(serializers.CharField(allow_null=True))
def get_park_state(self, obj):
"""Get state from park location."""
try:
if obj.park and hasattr(obj.park, 'location') and obj.park.location:
return obj.park.location.state
return None
except AttributeError:
return None
@extend_schema_field(serializers.CharField(allow_null=True))
def get_park_country(self, obj):
"""Get country from park location."""
try:
if obj.park and hasattr(obj.park, 'location') and obj.park.location:
return obj.park.location.country
return None
except AttributeError:
return None
@extend_schema_field(serializers.FloatField(allow_null=True))
def get_coaster_height_ft(self, obj):
"""Get roller coaster height."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
return float(obj.coaster_stats.height_ft) if obj.coaster_stats.height_ft else None
return None
except (AttributeError, TypeError):
return None
@extend_schema_field(serializers.FloatField(allow_null=True))
def get_coaster_length_ft(self, obj):
"""Get roller coaster length."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
return float(obj.coaster_stats.length_ft) if obj.coaster_stats.length_ft else None
return None
except (AttributeError, TypeError):
return None
@extend_schema_field(serializers.FloatField(allow_null=True))
def get_coaster_speed_mph(self, obj):
"""Get roller coaster speed."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
return float(obj.coaster_stats.speed_mph) if obj.coaster_stats.speed_mph else None
return None
except (AttributeError, TypeError):
return None
@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_coaster_inversions(self, obj):
"""Get roller coaster inversions."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
return obj.coaster_stats.inversions
return None
except AttributeError:
return None
@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_coaster_ride_time_seconds(self, obj):
"""Get roller coaster ride time."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
return obj.coaster_stats.ride_time_seconds
return None
except AttributeError:
return None
@extend_schema_field(serializers.CharField(allow_null=True))
def get_coaster_track_type(self, obj):
"""Get roller coaster track type."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
return obj.coaster_stats.track_type
return None
except AttributeError:
return None
@extend_schema_field(serializers.CharField(allow_null=True))
def get_coaster_track_material(self, obj):
"""Get roller coaster track material."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
return obj.coaster_stats.track_material
return None
except AttributeError:
return None
@extend_schema_field(serializers.CharField(allow_null=True))
def get_coaster_roller_coaster_type(self, obj):
"""Get roller coaster type."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
return obj.coaster_stats.roller_coaster_type
return None
except AttributeError:
return None
@extend_schema_field(serializers.FloatField(allow_null=True))
def get_coaster_max_drop_height_ft(self, obj):
"""Get roller coaster max drop height."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
return float(obj.coaster_stats.max_drop_height_ft) if obj.coaster_stats.max_drop_height_ft else None
return None
except (AttributeError, TypeError):
return None
@extend_schema_field(serializers.CharField(allow_null=True))
def get_coaster_launch_type(self, obj):
"""Get roller coaster launch type."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
return obj.coaster_stats.launch_type
return None
except AttributeError:
return None
@extend_schema_field(serializers.CharField(allow_null=True))
def get_coaster_train_style(self, obj):
"""Get roller coaster train style."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
return obj.coaster_stats.train_style
return None
except AttributeError:
return None
@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_coaster_trains_count(self, obj):
"""Get roller coaster trains count."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
return obj.coaster_stats.trains_count
return None
except AttributeError:
return None
@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_coaster_cars_per_train(self, obj):
"""Get roller coaster cars per train."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
return obj.coaster_stats.cars_per_train
return None
except AttributeError:
return None
@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_coaster_seats_per_car(self, obj):
"""Get roller coaster seats per car."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
return obj.coaster_stats.seats_per_car
return None
except AttributeError:
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 = Ride
fields = [
# Basic ride info
"id",
"name",
"slug",
"description",
"category",
"status",
"post_closing_status",
# Dates and computed fields
"opening_date",
"closing_date",
"status_since",
"opening_year",
# Park fields
"park_name",
"park_slug",
"park_city",
"park_state",
"park_country",
# Park area fields
"park_area_name",
"park_area_slug",
# Company fields
"manufacturer_name",
"manufacturer_slug",
"designer_name",
"designer_slug",
# Ride model fields
"ride_model_name",
"ride_model_slug",
"ride_model_category",
"ride_model_manufacturer_name",
"ride_model_manufacturer_slug",
# Ride specifications
"min_height_in",
"max_height_in",
"capacity_per_hour",
"ride_duration_seconds",
"average_rating",
# Roller coaster stats
"coaster_height_ft",
"coaster_length_ft",
"coaster_speed_mph",
"coaster_inversions",
"coaster_ride_time_seconds",
"coaster_track_type",
"coaster_track_material",
"coaster_roller_coaster_type",
"coaster_max_drop_height_ft",
"coaster_launch_type",
"coaster_train_style",
"coaster_trains_count",
"coaster_cars_per_train",
"coaster_seats_per_car",
# Images
"banner_image_url",
"card_image_url",
# URLs
"url",
"park_url",
# Computed fields for filtering
"search_text",
# Metadata
"created_at",
"updated_at",
]
read_only_fields = fields
class RideSerializer(serializers.ModelSerializer):
"""Serializer for the Ride model."""
@@ -274,7 +597,7 @@ class RideSerializer(serializers.ModelSerializer):
"park",
"manufacturer",
"designer",
"type",
"category",
"status",
"opening_date",
"closing_date",

View File

@@ -19,6 +19,8 @@ from .views import (
RideModelSearchAPIView,
RideSearchSuggestionsAPIView,
RideImageSettingsAPIView,
HybridRideAPIView,
RideFilterMetadataAPIView,
)
from .photo_views import RidePhotoViewSet
@@ -31,6 +33,11 @@ app_name = "api_v1_rides"
urlpatterns = [
# Core list/create endpoints
path("", RideListCreateAPIView.as_view(), name="ride-list-create"),
# Hybrid filtering endpoints
path("hybrid/", HybridRideAPIView.as_view(), name="ride-hybrid-filtering"),
path("hybrid/filter-metadata/", RideFilterMetadataAPIView.as_view(), name="ride-hybrid-filter-metadata"),
# Filter options
path("filter-options/", FilterOptionsAPIView.as_view(), name="ride-filter-options"),
# Autocomplete / suggestion endpoints

View File

@@ -13,16 +13,19 @@ Notes:
are not present, they return a clear 501 response explaining what to wire up.
"""
from typing import Any
from typing import Any, Dict
import logging
from django.db import models
logger = logging.getLogger(__name__)
from rest_framework import status, permissions
from rest_framework.views import APIView
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.utils import extend_schema, extend_schema_view, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
# Reuse existing serializers where possible
@@ -34,6 +37,13 @@ from apps.api.v1.serializers.rides import (
RideImageSettingsInputSerializer,
)
# Import hybrid filtering components
from apps.api.v1.rides.serializers import HybridRideSerializer
from apps.rides.services.hybrid_loader import SmartRideLoader
# Create smart loader instance
smart_ride_loader = SmartRideLoader()
# Attempt to import model-level helpers; fall back gracefully if not present.
try:
from apps.rides.models import Ride, RideModel
@@ -1332,4 +1342,354 @@ class RideImageSettingsAPIView(APIView):
return Response(output_serializer.data)
# --- Ride duplicate action --------------------------------------------------
# --- Hybrid Filtering API Views --------------------------------------------
@extend_schema_view(
get=extend_schema(
summary="Get rides with hybrid filtering",
description="Retrieve rides with intelligent hybrid filtering strategy. Automatically chooses between client-side and server-side filtering based on data size.",
parameters=[
OpenApiParameter("category", OpenApiTypes.STR, description="Filter by ride category (comma-separated for multiple)"),
OpenApiParameter("status", OpenApiTypes.STR, description="Filter by ride status (comma-separated for multiple)"),
OpenApiParameter("park_slug", OpenApiTypes.STR, description="Filter by park slug"),
OpenApiParameter("park_id", OpenApiTypes.INT, description="Filter by park ID"),
OpenApiParameter("manufacturer", OpenApiTypes.STR, description="Filter by manufacturer slug (comma-separated for multiple)"),
OpenApiParameter("designer", OpenApiTypes.STR, description="Filter by designer slug (comma-separated for multiple)"),
OpenApiParameter("ride_model", OpenApiTypes.STR, description="Filter by ride model slug (comma-separated for multiple)"),
OpenApiParameter("opening_year_min", OpenApiTypes.INT, description="Minimum opening year"),
OpenApiParameter("opening_year_max", OpenApiTypes.INT, description="Maximum opening year"),
OpenApiParameter("rating_min", OpenApiTypes.NUMBER, description="Minimum average rating"),
OpenApiParameter("rating_max", OpenApiTypes.NUMBER, description="Maximum average rating"),
OpenApiParameter("height_requirement_min", OpenApiTypes.INT, description="Minimum height requirement in inches"),
OpenApiParameter("height_requirement_max", OpenApiTypes.INT, description="Maximum height requirement in inches"),
OpenApiParameter("capacity_min", OpenApiTypes.INT, description="Minimum hourly capacity"),
OpenApiParameter("capacity_max", OpenApiTypes.INT, description="Maximum hourly capacity"),
OpenApiParameter("roller_coaster_type", OpenApiTypes.STR, description="Filter by roller coaster type (comma-separated for multiple)"),
OpenApiParameter("track_material", OpenApiTypes.STR, description="Filter by track material (comma-separated for multiple)"),
OpenApiParameter("launch_type", OpenApiTypes.STR, description="Filter by launch type (comma-separated for multiple)"),
OpenApiParameter("height_ft_min", OpenApiTypes.NUMBER, description="Minimum roller coaster height in feet"),
OpenApiParameter("height_ft_max", OpenApiTypes.NUMBER, description="Maximum roller coaster height in feet"),
OpenApiParameter("speed_mph_min", OpenApiTypes.NUMBER, description="Minimum roller coaster speed in mph"),
OpenApiParameter("speed_mph_max", OpenApiTypes.NUMBER, description="Maximum roller coaster speed in mph"),
OpenApiParameter("inversions_min", OpenApiTypes.INT, description="Minimum number of inversions"),
OpenApiParameter("inversions_max", OpenApiTypes.INT, description="Maximum number of inversions"),
OpenApiParameter("has_inversions", OpenApiTypes.BOOL, description="Filter rides with inversions (true) or without (false)"),
OpenApiParameter("search", OpenApiTypes.STR, description="Search query for ride names, descriptions, parks, and related data"),
OpenApiParameter("offset", OpenApiTypes.INT, description="Offset for progressive loading (server-side pagination)"),
],
responses={
200: {
"description": "Rides data with hybrid filtering metadata",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"rides": {
"type": "array",
"items": {"$ref": "#/components/schemas/HybridRideSerializer"}
},
"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=["Rides"],
)
)
class HybridRideAPIView(APIView):
"""
Hybrid Ride 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 = [permissions.AllowAny]
def get(self, request):
"""Get rides 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_ride_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_ride_loader.get_initial_load(filters)
# Prepare response (rides are already serialized by the service)
response_data = {
'rides': data['rides'],
'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 HybridRideAPIView: {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 = ['category', 'status', 'manufacturer', 'designer', 'ride_model', 'roller_coaster_type', 'track_material', 'launch_type']
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 single value parameters
single_params = ['park_slug', 'park_id']
for param in single_params:
value = query_params.get(param)
if value:
if param == 'park_id':
try:
filters[param] = int(value)
except ValueError:
pass
else:
filters[param] = value
# Handle integer parameters
int_params = [
'opening_year_min', 'opening_year_max',
'height_requirement_min', 'height_requirement_max',
'capacity_min', 'capacity_max',
'inversions_min', 'inversions_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 = ['rating_min', 'rating_max', 'height_ft_min', 'height_ft_max', 'speed_mph_min', 'speed_mph_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 boolean parameters
has_inversions = query_params.get('has_inversions')
if has_inversions is not None:
if has_inversions.lower() in ['true', '1', 'yes']:
filters['has_inversions'] = True
elif has_inversions.lower() in ['false', '0', 'no']:
filters['has_inversions'] = False
# Handle search parameter
search = query_params.get('search')
if search:
filters['search'] = search.strip()
return filters
@extend_schema_view(
get=extend_schema(
summary="Get ride filter metadata",
description="Get available filter options and ranges for rides 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": {
"categories": {"type": "array", "items": {"type": "string"}},
"statuses": {"type": "array", "items": {"type": "string"}},
"roller_coaster_types": {"type": "array", "items": {"type": "string"}},
"track_materials": {"type": "array", "items": {"type": "string"}},
"launch_types": {"type": "array", "items": {"type": "string"}},
"parks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"slug": {"type": "string"}
}
}
},
"manufacturers": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"slug": {"type": "string"}
}
}
},
"designers": {
"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}
}
},
"rating": {
"type": "object",
"properties": {
"min": {"type": "number", "nullable": True},
"max": {"type": "number", "nullable": True}
}
},
"height_requirement": {
"type": "object",
"properties": {
"min": {"type": "integer", "nullable": True},
"max": {"type": "integer", "nullable": True}
}
},
"capacity": {
"type": "object",
"properties": {
"min": {"type": "integer", "nullable": True},
"max": {"type": "integer", "nullable": True}
}
},
"height_ft": {
"type": "object",
"properties": {
"min": {"type": "number", "nullable": True},
"max": {"type": "number", "nullable": True}
}
},
"speed_mph": {
"type": "object",
"properties": {
"min": {"type": "number", "nullable": True},
"max": {"type": "number", "nullable": True}
}
},
"inversions": {
"type": "object",
"properties": {
"min": {"type": "integer", "nullable": True},
"max": {"type": "integer", "nullable": True}
}
}
}
},
"total_count": {"type": "integer"}
}
}
}
}
}
},
tags=["Rides"],
)
)
class RideFilterMetadataAPIView(APIView):
"""
API view for getting ride filter metadata.
Provides information about available filter options and ranges
to help build dynamic filter interfaces.
"""
permission_classes = [permissions.AllowAny]
def get(self, request):
"""Get ride 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_ride_loader.get_filter_metadata(filters)
return Response(metadata, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Error in RideFilterMetadataAPIView: {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 = HybridRideAPIView()
return view._extract_filters(query_params)

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')
}

View File

@@ -0,0 +1,422 @@
"""
Contract Compliance Tests for ThrillWiki API
These tests verify that API responses match frontend TypeScript interfaces exactly,
preventing runtime errors and ensuring type safety.
"""
import json
from django.test import TestCase, Client
from django.urls import reverse
from rest_framework.test import APITestCase
from rest_framework import status
from typing import Dict, Any, List
from apps.parks.services.hybrid_loader import smart_park_loader
from apps.rides.services.hybrid_loader import SmartRideLoader
from apps.api.v1.serializers.shared import (
validate_filter_metadata_contract,
ensure_filter_option_format,
ensure_range_format
)
class FilterMetadataContractTests(TestCase):
"""Test that filter metadata follows the expected contract."""
def setUp(self):
self.client = Client()
def test_parks_filter_metadata_structure(self):
"""Test that parks filter metadata has correct structure."""
# Get filter metadata from the service
metadata = smart_park_loader.get_filter_metadata()
# Should have required top-level keys
self.assertIn('categorical', metadata)
self.assertIn('ranges', metadata)
self.assertIn('total_count', metadata)
# Categorical filters should be objects with value/label/count
categorical = metadata['categorical']
self.assertIsInstance(categorical, dict)
for filter_name, filter_options in categorical.items():
with self.subTest(filter_name=filter_name):
self.assertIsInstance(filter_options, list,
f"Filter '{filter_name}' should be a list")
for i, option in enumerate(filter_options):
with self.subTest(filter_name=filter_name, option_index=i):
self.assertIsInstance(option, dict,
f"Filter '{filter_name}' option {i} should be an object, not {type(option).__name__}")
# Check required properties
self.assertIn('value', option,
f"Filter '{filter_name}' option {i} missing 'value' property")
self.assertIn('label', option,
f"Filter '{filter_name}' option {i} missing 'label' property")
# Check types
self.assertIsInstance(option['value'], str,
f"Filter '{filter_name}' option {i} 'value' should be string")
self.assertIsInstance(option['label'], str,
f"Filter '{filter_name}' option {i} 'label' should be string")
# Count is optional but should be int if present
if 'count' in option and option['count'] is not None:
self.assertIsInstance(option['count'], int,
f"Filter '{filter_name}' option {i} 'count' should be int")
def test_rides_filter_metadata_structure(self):
"""Test that rides filter metadata has correct structure."""
loader = SmartRideLoader()
metadata = loader.get_filter_metadata()
# Should have required top-level keys
self.assertIn('categorical', metadata)
self.assertIn('ranges', metadata)
self.assertIn('total_count', metadata)
# Categorical filters should be objects with value/label/count
categorical = metadata['categorical']
self.assertIsInstance(categorical, dict)
# Test specific categorical filters that were problematic
critical_filters = ['categories', 'statuses', 'roller_coaster_types', 'track_materials']
for filter_name in critical_filters:
if filter_name in categorical:
with self.subTest(filter_name=filter_name):
filter_options = categorical[filter_name]
self.assertIsInstance(filter_options, list)
for i, option in enumerate(filter_options):
with self.subTest(filter_name=filter_name, option_index=i):
self.assertIsInstance(option, dict,
f"CRITICAL: Filter '{filter_name}' option {i} is {type(option).__name__} but should be dict")
self.assertIn('value', option)
self.assertIn('label', option)
self.assertIn('count', option)
def test_range_metadata_structure(self):
"""Test that range metadata has correct structure."""
# Test parks ranges
parks_metadata = smart_park_loader.get_filter_metadata()
ranges = parks_metadata['ranges']
for range_name, range_data in ranges.items():
with self.subTest(range_name=range_name):
self.assertIsInstance(range_data, dict,
f"Range '{range_name}' should be an object")
# Check required properties
self.assertIn('min', range_data)
self.assertIn('max', range_data)
self.assertIn('step', range_data)
self.assertIn('unit', range_data)
# Check types (min/max can be None)
if range_data['min'] is not None:
self.assertIsInstance(range_data['min'], (int, float))
if range_data['max'] is not None:
self.assertIsInstance(range_data['max'], (int, float))
self.assertIsInstance(range_data['step'], (int, float))
# Unit can be None or string
if range_data['unit'] is not None:
self.assertIsInstance(range_data['unit'], str)
class ContractValidationUtilityTests(TestCase):
"""Test contract validation utility functions."""
def test_validate_filter_metadata_contract_valid(self):
"""Test validation passes for valid filter metadata."""
valid_metadata = {
'categorical': {
'statuses': [
{'value': 'OPERATING', 'label': 'Operating', 'count': 5},
{'value': 'CLOSED_TEMP', 'label': 'Temporarily Closed', 'count': 2}
]
},
'ranges': {
'rating': {
'min': 1.0,
'max': 10.0,
'step': 0.1,
'unit': 'stars'
}
},
'total_count': 100
}
# Should not raise an exception
validated = validate_filter_metadata_contract(valid_metadata)
self.assertIsInstance(validated, dict)
self.assertEqual(validated['total_count'], 100)
def test_validate_filter_metadata_contract_invalid(self):
"""Test validation fails for invalid filter metadata."""
from rest_framework import serializers
invalid_metadata = {
'categorical': {
'statuses': ['OPERATING', 'CLOSED_TEMP'] # Should be objects, not strings
},
'ranges': {},
'total_count': 100
}
# Should raise ValidationError
with self.assertRaises(serializers.ValidationError):
validate_filter_metadata_contract(invalid_metadata)
def test_ensure_filter_option_format_strings(self):
"""Test converting string arrays to proper format."""
string_options = ['OPERATING', 'CLOSED_TEMP', 'UNDER_CONSTRUCTION']
formatted = ensure_filter_option_format(string_options)
self.assertEqual(len(formatted), 3)
for i, option in enumerate(formatted):
self.assertIsInstance(option, dict)
self.assertIn('value', option)
self.assertIn('label', option)
self.assertIn('count', option)
self.assertIn('selected', option)
self.assertEqual(option['value'], string_options[i])
self.assertEqual(option['label'], string_options[i])
self.assertIsNone(option['count'])
self.assertFalse(option['selected'])
def test_ensure_filter_option_format_tuples(self):
"""Test converting tuple arrays to proper format."""
tuple_options = [
('OPERATING', 'Operating', 5),
('CLOSED_TEMP', 'Temporarily Closed', 2)
]
formatted = ensure_filter_option_format(tuple_options)
self.assertEqual(len(formatted), 2)
self.assertEqual(formatted[0]['value'], 'OPERATING')
self.assertEqual(formatted[0]['label'], 'Operating')
self.assertEqual(formatted[0]['count'], 5)
self.assertEqual(formatted[1]['value'], 'CLOSED_TEMP')
self.assertEqual(formatted[1]['label'], 'Temporarily Closed')
self.assertEqual(formatted[1]['count'], 2)
def test_ensure_filter_option_format_dicts(self):
"""Test that properly formatted dicts pass through correctly."""
dict_options = [
{'value': 'OPERATING', 'label': 'Operating', 'count': 5},
{'value': 'CLOSED_TEMP', 'label': 'Temporarily Closed', 'count': 2}
]
formatted = ensure_filter_option_format(dict_options)
self.assertEqual(len(formatted), 2)
self.assertEqual(formatted[0]['value'], 'OPERATING')
self.assertEqual(formatted[0]['label'], 'Operating')
self.assertEqual(formatted[0]['count'], 5)
def test_ensure_range_format(self):
"""Test range format utility."""
range_data = {
'min': 1.0,
'max': 10.0,
'step': 0.5,
'unit': 'stars'
}
formatted = ensure_range_format(range_data)
self.assertEqual(formatted['min'], 1.0)
self.assertEqual(formatted['max'], 10.0)
self.assertEqual(formatted['step'], 0.5)
self.assertEqual(formatted['unit'], 'stars')
def test_ensure_range_format_missing_step(self):
"""Test range format with missing step defaults to 1.0."""
range_data = {
'min': 1,
'max': 10
}
formatted = ensure_range_format(range_data)
self.assertEqual(formatted['step'], 1.0)
self.assertIsNone(formatted['unit'])
class APIEndpointContractTests(APITestCase):
"""Test actual API endpoints for contract compliance."""
def test_parks_hybrid_endpoint_contract(self):
"""Test parks hybrid endpoint returns proper contract."""
# This would require actual data in the database
# For now, we'll test the structure
pass
def test_rides_hybrid_endpoint_contract(self):
"""Test rides hybrid endpoint returns proper contract."""
# This would require actual data in the database
# For now, we'll test the structure
pass
class TypeScriptInterfaceComplianceTests(TestCase):
"""Test that responses match TypeScript interfaces exactly."""
def test_filter_option_interface_compliance(self):
"""Test FilterOption interface compliance."""
# TypeScript interface:
# interface FilterOption {
# value: string;
# label: string;
# count?: number;
# selected?: boolean;
# }
option = {
'value': 'OPERATING',
'label': 'Operating',
'count': 5,
'selected': False
}
# All required fields present
self.assertIn('value', option)
self.assertIn('label', option)
# Correct types
self.assertIsInstance(option['value'], str)
self.assertIsInstance(option['label'], str)
# Optional fields have correct types if present
if 'count' in option and option['count'] is not None:
self.assertIsInstance(option['count'], int)
if 'selected' in option:
self.assertIsInstance(option['selected'], bool)
def test_filter_range_interface_compliance(self):
"""Test FilterRange interface compliance."""
# TypeScript interface:
# interface FilterRange {
# min: number;
# max: number;
# step: number;
# unit?: string;
# }
range_data = {
'min': 1.0,
'max': 10.0,
'step': 0.1,
'unit': 'stars'
}
# All required fields present
self.assertIn('min', range_data)
self.assertIn('max', range_data)
self.assertIn('step', range_data)
# Correct types (min/max can be null)
if range_data['min'] is not None:
self.assertIsInstance(range_data['min'], (int, float))
if range_data['max'] is not None:
self.assertIsInstance(range_data['max'], (int, float))
self.assertIsInstance(range_data['step'], (int, float))
# Optional unit field
if 'unit' in range_data and range_data['unit'] is not None:
self.assertIsInstance(range_data['unit'], str)
class RegressionTests(TestCase):
"""Regression tests for specific contract violations that were fixed."""
def test_categorical_filters_not_strings(self):
"""Regression test: Ensure categorical filters are never returned as strings."""
# This was the main issue - categorical filters were returned as:
# ['OPERATING', 'CLOSED_TEMP'] instead of
# [{'value': 'OPERATING', 'label': 'Operating', 'count': 5}, ...]
# Test parks
parks_metadata = smart_park_loader.get_filter_metadata()
categorical = parks_metadata.get('categorical', {})
for filter_name, filter_options in categorical.items():
with self.subTest(filter_name=filter_name):
self.assertIsInstance(filter_options, list)
for i, option in enumerate(filter_options):
with self.subTest(filter_name=filter_name, option_index=i):
self.assertIsInstance(option, dict,
f"REGRESSION: Filter '{filter_name}' option {i} is a {type(option).__name__} "
f"but should be a dict. This causes frontend crashes!")
# Must not be a string
self.assertNotIsInstance(option, str,
f"CRITICAL REGRESSION: Filter '{filter_name}' option {i} is a string '{option}' "
f"but frontend expects object with value/label/count properties!")
# Test rides
rides_loader = SmartRideLoader()
rides_metadata = rides_loader.get_filter_metadata()
categorical = rides_metadata.get('categorical', {})
for filter_name, filter_options in categorical.items():
with self.subTest(filter_name=f"rides_{filter_name}"):
self.assertIsInstance(filter_options, list)
for i, option in enumerate(filter_options):
with self.subTest(filter_name=f"rides_{filter_name}", option_index=i):
self.assertIsInstance(option, dict,
f"REGRESSION: Rides filter '{filter_name}' option {i} is a {type(option).__name__} "
f"but should be a dict. This causes frontend crashes!")
def test_ranges_have_step_and_unit(self):
"""Regression test: Ensure ranges have step and unit properties."""
# Frontend expects: { min: number, max: number, step: number, unit?: string }
# Backend was sometimes missing step and unit
parks_metadata = smart_park_loader.get_filter_metadata()
ranges = parks_metadata.get('ranges', {})
for range_name, range_data in ranges.items():
with self.subTest(range_name=range_name):
self.assertIn('step', range_data,
f"Range '{range_name}' missing 'step' property required by frontend")
self.assertIn('unit', range_data,
f"Range '{range_name}' missing 'unit' property required by frontend")
# Step should be a number
self.assertIsInstance(range_data['step'], (int, float),
f"Range '{range_name}' step should be a number")
def test_no_undefined_values(self):
"""Regression test: Ensure no undefined values (should be null)."""
# JavaScript undefined !== null, and TypeScript interfaces expect null
parks_metadata = smart_park_loader.get_filter_metadata()
def check_no_undefined(obj, path=""):
if isinstance(obj, dict):
for key, value in obj.items():
current_path = f"{path}.{key}" if path else key
# Python None is fine (becomes null in JSON)
# But we shouldn't have any undefined-like values
check_no_undefined(value, current_path)
elif isinstance(obj, list):
for i, item in enumerate(obj):
current_path = f"{path}[{i}]"
check_no_undefined(item, current_path)
# This will recursively check the entire metadata structure
check_no_undefined(parks_metadata)

View File

@@ -0,0 +1,462 @@
"""
Base Views for Contract-Compliant API Responses
This module provides base view classes that ensure all API responses follow
consistent formats that match frontend TypeScript interfaces exactly.
"""
import logging
from typing import Dict, Any, Optional, Type
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.serializers import Serializer
from django.conf import settings
from apps.api.v1.serializers.shared import (
validate_filter_metadata_contract,
ApiResponseSerializer,
ErrorResponseSerializer
)
logger = logging.getLogger(__name__)
class ContractCompliantAPIView(APIView):
"""
Base API view that ensures all responses are contract-compliant.
This view provides:
- Standardized success response format
- Consistent error response format
- Automatic contract validation in DEBUG mode
- Proper error logging with context
"""
# Override in subclasses to specify response serializer
response_serializer_class: Optional[Type[Serializer]] = None
def dispatch(self, request, *args, **kwargs):
"""Override dispatch to add contract validation."""
try:
response = super().dispatch(request, *args, **kwargs)
# Validate contract in DEBUG mode
if settings.DEBUG and hasattr(response, 'data'):
self._validate_response_contract(response.data)
return response
except Exception as e:
# Log the error with context
logger.error(
f"API error in {self.__class__.__name__}: {str(e)}",
extra={
'view_class': self.__class__.__name__,
'request_path': request.path,
'request_method': request.method,
'user': getattr(request, 'user', None),
'error': str(e)
},
exc_info=True
)
# Return standardized error response
return self.error_response(
message="An internal error occurred",
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
def success_response(
self,
data: Any = None,
message: str = None,
status_code: int = status.HTTP_200_OK,
headers: Dict[str, str] = None
) -> Response:
"""
Create a standardized success response.
Args:
data: Response data
message: Optional success message
status_code: HTTP status code
headers: Optional response headers
Returns:
Response with standardized format
"""
response_data = {
'success': True
}
if data is not None:
response_data['data'] = data
if message:
response_data['message'] = message
return Response(
response_data,
status=status_code,
headers=headers
)
def error_response(
self,
message: str,
status_code: int = status.HTTP_400_BAD_REQUEST,
error_code: str = None,
details: Any = None,
headers: Dict[str, str] = None
) -> Response:
"""
Create a standardized error response.
Args:
message: Error message
status_code: HTTP status code
error_code: Optional error code
details: Optional error details
headers: Optional response headers
Returns:
Response with standardized error format
"""
error_data = {
'code': error_code or 'API_ERROR',
'message': message
}
if details:
error_data['details'] = details
# Add user context if available
if hasattr(self, 'request') and hasattr(self.request, 'user'):
user = self.request.user
if user and user.is_authenticated:
error_data['request_user'] = user.username
response_data = {
'status': 'error',
'error': error_data,
'data': None
}
return Response(
response_data,
status=status_code,
headers=headers
)
def validation_error_response(
self,
errors: Dict[str, Any],
message: str = "Validation failed"
) -> Response:
"""
Create a standardized validation error response.
Args:
errors: Validation errors dictionary
message: Error message
Returns:
Response with validation errors
"""
return Response(
{
'success': False,
'message': message,
'errors': errors
},
status=status.HTTP_400_BAD_REQUEST
)
def _validate_response_contract(self, data: Any) -> None:
"""
Validate response data against expected contracts.
This method is called automatically in DEBUG mode to catch
contract violations during development.
"""
try:
# Check if this looks like filter metadata
if isinstance(data, dict) and 'categorical' in data and 'ranges' in data:
validate_filter_metadata_contract(data)
# Add more contract validations as needed
except Exception as e:
logger.warning(
f"Contract validation failed in {self.__class__.__name__}: {str(e)}",
extra={
'view_class': self.__class__.__name__,
'validation_error': str(e),
'response_data_type': type(data).__name__
}
)
class FilterMetadataAPIView(ContractCompliantAPIView):
"""
Base view for filter metadata endpoints.
This view ensures filter metadata responses always follow the correct
contract that matches frontend TypeScript interfaces.
"""
def get_filter_metadata(self) -> Dict[str, Any]:
"""
Override this method in subclasses to provide filter metadata.
Returns:
Filter metadata dictionary
"""
raise NotImplementedError("Subclasses must implement get_filter_metadata()")
def get(self, request, *args, **kwargs):
"""Handle GET requests for filter metadata."""
try:
metadata = self.get_filter_metadata()
# Validate the metadata contract
validated_metadata = validate_filter_metadata_contract(metadata)
return self.success_response(validated_metadata)
except Exception as e:
logger.error(
f"Error getting filter metadata in {self.__class__.__name__}: {str(e)}",
extra={
'view_class': self.__class__.__name__,
'error': str(e)
},
exc_info=True
)
return self.error_response(
message="Failed to retrieve filter metadata",
error_code="FILTER_METADATA_ERROR"
)
class HybridFilteringAPIView(ContractCompliantAPIView):
"""
Base view for hybrid filtering endpoints.
This view provides common functionality for hybrid filtering responses
and ensures they follow the correct contract.
"""
def get_hybrid_data(self, filters: Dict[str, Any] = None) -> Dict[str, Any]:
"""
Override this method in subclasses to provide hybrid data.
Args:
filters: Filter parameters
Returns:
Hybrid response dictionary
"""
raise NotImplementedError("Subclasses must implement get_hybrid_data()")
def get(self, request, *args, **kwargs):
"""Handle GET requests for hybrid filtering."""
try:
# Extract filters from request parameters
filters = self.extract_filters(request)
# Get hybrid data
hybrid_data = self.get_hybrid_data(filters)
# Validate hybrid response structure
self._validate_hybrid_response(hybrid_data)
return self.success_response(hybrid_data)
except Exception as e:
logger.error(
f"Error in hybrid filtering for {self.__class__.__name__}: {str(e)}",
extra={
'view_class': self.__class__.__name__,
'filters': getattr(self, '_extracted_filters', {}),
'error': str(e)
},
exc_info=True
)
return self.error_response(
message="Failed to retrieve filtered data",
error_code="HYBRID_FILTERING_ERROR"
)
def extract_filters(self, request) -> Dict[str, Any]:
"""
Extract filter parameters from request.
Override this method in subclasses to customize filter extraction.
Args:
request: HTTP request object
Returns:
Dictionary of filter parameters
"""
# Basic implementation - extract all query parameters
filters = {}
for key, value in request.query_params.items():
if value: # Only include non-empty values
filters[key] = value
# Store for error logging
self._extracted_filters = filters
return filters
def _validate_hybrid_response(self, data: Dict[str, Any]) -> None:
"""Validate hybrid response structure."""
required_fields = ['strategy', 'total_count']
for field in required_fields:
if field not in data:
raise ValueError(f"Hybrid response missing required field: {field}")
# Validate strategy value
if data['strategy'] not in ['client_side', 'server_side']:
raise ValueError(f"Invalid strategy value: {data['strategy']}")
# Validate filter metadata if present
if 'filter_metadata' in data:
validate_filter_metadata_contract(data['filter_metadata'])
class PaginatedAPIView(ContractCompliantAPIView):
"""
Base view for paginated responses.
This view ensures paginated responses follow the correct contract
with consistent pagination metadata.
"""
default_page_size = 20
max_page_size = 100
def get_paginated_response(
self,
queryset,
serializer_class: Type[Serializer],
request,
page_size: int = None
) -> Response:
"""
Create a paginated response.
Args:
queryset: Django queryset to paginate
serializer_class: Serializer class for items
request: HTTP request object
page_size: Optional page size override
Returns:
Paginated response
"""
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
# Determine page size
if page_size is None:
page_size = min(
int(request.query_params.get('page_size', self.default_page_size)),
self.max_page_size
)
# Get page number
page_number = request.query_params.get('page', 1)
try:
page_number = int(page_number)
except (ValueError, TypeError):
page_number = 1
# Create paginator
paginator = Paginator(queryset, page_size)
try:
page = paginator.page(page_number)
except PageNotAnInteger:
page = paginator.page(1)
except EmptyPage:
page = paginator.page(paginator.num_pages)
# Serialize data
serializer = serializer_class(page.object_list, many=True)
# Build pagination URLs
request_url = request.build_absolute_uri().split('?')[0]
query_params = request.query_params.copy()
next_url = None
if page.has_next():
query_params['page'] = page.next_page_number()
next_url = f"{request_url}?{query_params.urlencode()}"
previous_url = None
if page.has_previous():
query_params['page'] = page.previous_page_number()
previous_url = f"{request_url}?{query_params.urlencode()}"
# Create response data
response_data = {
'count': paginator.count,
'next': next_url,
'previous': previous_url,
'results': serializer.data,
'page_size': page_size,
'current_page': page.number,
'total_pages': paginator.num_pages
}
return self.success_response(response_data)
def contract_compliant_view(view_class):
"""
Decorator to make any view contract-compliant.
This decorator can be applied to existing views to add contract
validation without changing the base class.
"""
original_dispatch = view_class.dispatch
def new_dispatch(self, request, *args, **kwargs):
try:
response = original_dispatch(self, request, *args, **kwargs)
# Add contract validation in DEBUG mode
if settings.DEBUG and hasattr(response, 'data'):
# Basic validation - can be extended
pass
return response
except Exception as e:
logger.error(
f"Error in decorated view {view_class.__name__}: {str(e)}",
exc_info=True
)
# Return basic error response
return Response(
{
'status': 'error',
'error': {
'code': 'API_ERROR',
'message': 'An internal error occurred'
},
'data': None
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
view_class.dispatch = new_dispatch
return view_class

View File

@@ -8,7 +8,6 @@ while maintaining compatibility with Cloudflare Images.
import re
from typing import Optional, Dict, Any
from django.utils.text import slugify
from django.conf import settings
class MediaURLService:

View File

@@ -0,0 +1,88 @@
# Generated by Django 5.2.5 on 2025-09-14 19:01
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("parks", "0013_remove_park_insert_insert_remove_park_update_update_and_more"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="park",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="park",
name="update_update",
),
migrations.AddField(
model_name="park",
name="opening_year",
field=models.IntegerField(
blank=True,
db_index=True,
help_text="Year the park opened (computed from opening_date)",
null=True,
),
),
migrations.AddField(
model_name="park",
name="search_text",
field=models.TextField(
blank=True,
db_index=True,
help_text="Searchable text combining name, description, location, and operator",
),
),
migrations.AddField(
model_name="parkevent",
name="opening_year",
field=models.IntegerField(
blank=True,
help_text="Year the park opened (computed from opening_date)",
null=True,
),
),
migrations.AddField(
model_name="parkevent",
name="search_text",
field=models.TextField(
blank=True,
help_text="Searchable text combining name, description, location, and operator",
),
),
pgtrigger.migrations.AddTrigger(
model_name="park",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "parks_parkevent" ("average_rating", "banner_image_id", "card_image_id", "closing_date", "coaster_count", "created_at", "description", "id", "name", "opening_date", "opening_year", "operating_season", "operator_id", "park_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "property_owner_id", "ride_count", "search_text", "size_acres", "slug", "status", "updated_at", "url", "website") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."card_image_id", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."opening_year", NEW."operating_season", NEW."operator_id", NEW."park_type", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."property_owner_id", NEW."ride_count", NEW."search_text", NEW."size_acres", NEW."slug", NEW."status", NEW."updated_at", NEW."url", NEW."website"); RETURN NULL;',
hash="39ac89dc193467b8b41f06ff15903f0a3e22f6b0",
operation="INSERT",
pgid="pgtrigger_insert_insert_66883",
table="parks_park",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="park",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "parks_parkevent" ("average_rating", "banner_image_id", "card_image_id", "closing_date", "coaster_count", "created_at", "description", "id", "name", "opening_date", "opening_year", "operating_season", "operator_id", "park_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "property_owner_id", "ride_count", "search_text", "size_acres", "slug", "status", "updated_at", "url", "website") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."card_image_id", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."opening_year", NEW."operating_season", NEW."operator_id", NEW."park_type", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."property_owner_id", NEW."ride_count", NEW."search_text", NEW."size_acres", NEW."slug", NEW."status", NEW."updated_at", NEW."url", NEW."website"); RETURN NULL;',
hash="af7925b4ef24b42c66b7795b9e0c6c8f510e597c",
operation="UPDATE",
pgid="pgtrigger_update_update_19f56",
table="parks_park",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,64 @@
# Generated by Django 5.2.5 on 2025-09-14 19:01
from django.db import migrations
def populate_computed_fields(apps, schema_editor):
"""Populate computed fields for existing parks using raw SQL with disabled triggers"""
# Temporarily disable pghistory triggers
schema_editor.execute("ALTER TABLE parks_park DISABLE TRIGGER ALL;")
try:
# Use raw SQL to update opening_year from opening_date
schema_editor.execute("""
UPDATE parks_park
SET opening_year = EXTRACT(YEAR FROM opening_date)
WHERE opening_date IS NOT NULL;
""")
# Use raw SQL to populate search_text
# This is a simplified version - we'll populate it with just name and description
schema_editor.execute("""
UPDATE parks_park
SET search_text = LOWER(
COALESCE(name, '') || ' ' ||
COALESCE(description, '')
);
""")
# Update search_text to include operator names using a join
schema_editor.execute("""
UPDATE parks_park
SET search_text = LOWER(
COALESCE(parks_park.name, '') || ' ' ||
COALESCE(parks_park.description, '') || ' ' ||
COALESCE(parks_company.name, '')
)
FROM parks_company
WHERE parks_park.operator_id = parks_company.id;
""")
finally:
# Re-enable pghistory triggers
schema_editor.execute("ALTER TABLE parks_park ENABLE TRIGGER ALL;")
def reverse_populate_computed_fields(apps, schema_editor):
"""Clear computed fields (reverse operation)"""
Park = apps.get_model('parks', 'Park')
Park.objects.update(opening_year=None, search_text='')
class Migration(migrations.Migration):
dependencies = [
("parks", "0014_add_hybrid_filtering_fields"),
]
operations = [
migrations.RunPython(
populate_computed_fields,
reverse_populate_computed_fields,
),
]

View File

@@ -0,0 +1,85 @@
# Generated by Django 5.2.5 on 2025-09-14 19:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("parks", "0015_populate_hybrid_filtering_fields"),
]
operations = [
# Composite indexes for common filter combinations
migrations.RunSQL(
"CREATE INDEX IF NOT EXISTS parks_park_status_park_type_idx ON parks_park (status, park_type);",
reverse_sql="DROP INDEX IF EXISTS parks_park_status_park_type_idx;"
),
migrations.RunSQL(
"CREATE INDEX IF NOT EXISTS parks_park_opening_year_status_idx ON parks_park (opening_year, status) WHERE opening_year IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS parks_park_opening_year_status_idx;"
),
migrations.RunSQL(
"CREATE INDEX IF NOT EXISTS parks_park_size_rating_idx ON parks_park (size_acres, average_rating) WHERE size_acres IS NOT NULL AND average_rating IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS parks_park_size_rating_idx;"
),
migrations.RunSQL(
"CREATE INDEX IF NOT EXISTS parks_park_ride_coaster_count_idx ON parks_park (ride_count, coaster_count) WHERE ride_count IS NOT NULL AND coaster_count IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS parks_park_ride_coaster_count_idx;"
),
# Full-text search index for search_text field
migrations.RunSQL(
"CREATE INDEX IF NOT EXISTS parks_park_search_text_gin_idx ON parks_park USING gin(to_tsvector('english', search_text));",
reverse_sql="DROP INDEX IF EXISTS parks_park_search_text_gin_idx;"
),
# Trigram index for fuzzy search on search_text
migrations.RunSQL(
"CREATE EXTENSION IF NOT EXISTS pg_trgm;",
reverse_sql="-- Cannot drop extension as it might be used elsewhere"
),
migrations.RunSQL(
"CREATE INDEX IF NOT EXISTS parks_park_search_text_trgm_idx ON parks_park USING gin(search_text gin_trgm_ops);",
reverse_sql="DROP INDEX IF EXISTS parks_park_search_text_trgm_idx;"
),
# Indexes for location-based filtering (assuming location relationship exists)
migrations.RunSQL(
"""
CREATE INDEX IF NOT EXISTS parks_parklocation_country_state_idx
ON parks_parklocation (country, state)
WHERE country IS NOT NULL AND state IS NOT NULL;
""",
reverse_sql="DROP INDEX IF EXISTS parks_parklocation_country_state_idx;"
),
# Index for operator-based filtering
migrations.RunSQL(
"CREATE INDEX IF NOT EXISTS parks_park_operator_status_idx ON parks_park (operator_id, status);",
reverse_sql="DROP INDEX IF EXISTS parks_park_operator_status_idx;"
),
# Partial indexes for common status filters
migrations.RunSQL(
"CREATE INDEX IF NOT EXISTS parks_park_operating_parks_idx ON parks_park (name, opening_year) WHERE status IN ('OPERATING', 'CLOSED_TEMP');",
reverse_sql="DROP INDEX IF EXISTS parks_park_operating_parks_idx;"
),
# Index for ordering by name (already exists but ensuring it's optimized)
migrations.RunSQL(
"CREATE INDEX IF NOT EXISTS parks_park_name_lower_idx ON parks_park (LOWER(name));",
reverse_sql="DROP INDEX IF EXISTS parks_park_name_lower_idx;"
),
# Covering index for common query patterns
migrations.RunSQL(
"""
CREATE INDEX IF NOT EXISTS parks_park_hybrid_covering_idx
ON parks_park (status, park_type, opening_year)
INCLUDE (name, slug, size_acres, average_rating, ride_count, coaster_count, operator_id)
WHERE status IN ('OPERATING', 'CLOSED_TEMP');
""",
reverse_sql="DROP INDEX IF EXISTS parks_park_hybrid_covering_idx;"
),
]

View File

@@ -0,0 +1,73 @@
# Generated by Django 5.2.5 on 2025-09-15 00:50
import django.utils.timezone
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("parks", "0016_add_hybrid_filtering_indexes"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="park",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="park",
name="update_update",
),
migrations.AddField(
model_name="park",
name="timezone",
field=models.CharField(
default=django.utils.timezone.now,
help_text="Timezone identifier for park operations (e.g., 'America/New_York')",
max_length=50,
),
preserve_default=False,
),
migrations.AddField(
model_name="parkevent",
name="timezone",
field=models.CharField(
default=django.utils.timezone.now,
help_text="Timezone identifier for park operations (e.g., 'America/New_York')",
max_length=50,
),
preserve_default=False,
),
pgtrigger.migrations.AddTrigger(
model_name="park",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "parks_parkevent" ("average_rating", "banner_image_id", "card_image_id", "closing_date", "coaster_count", "created_at", "description", "id", "name", "opening_date", "opening_year", "operating_season", "operator_id", "park_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "property_owner_id", "ride_count", "search_text", "size_acres", "slug", "status", "timezone", "updated_at", "url", "website") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."card_image_id", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."opening_year", NEW."operating_season", NEW."operator_id", NEW."park_type", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."property_owner_id", NEW."ride_count", NEW."search_text", NEW."size_acres", NEW."slug", NEW."status", NEW."timezone", NEW."updated_at", NEW."url", NEW."website"); RETURN NULL;',
hash="9da686bd8a1881fe7a3fdfebc14411680fe47527",
operation="INSERT",
pgid="pgtrigger_insert_insert_66883",
table="parks_park",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="park",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "parks_parkevent" ("average_rating", "banner_image_id", "card_image_id", "closing_date", "coaster_count", "created_at", "description", "id", "name", "opening_date", "opening_year", "operating_season", "operator_id", "park_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "property_owner_id", "ride_count", "search_text", "size_acres", "slug", "status", "timezone", "updated_at", "url", "website") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."card_image_id", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."opening_year", NEW."operating_season", NEW."operator_id", NEW."park_type", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."property_owner_id", NEW."ride_count", NEW."search_text", NEW."size_acres", NEW."slug", NEW."status", NEW."timezone", NEW."updated_at", NEW."url", NEW."website"); RETURN NULL;',
hash="787e3176b96b506020f056ee1122d90d25e4cb0d",
operation="UPDATE",
pgid="pgtrigger_update_update_19f56",
table="parks_park",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,12 @@
# Generated by Django 5.2.5 on 2025-09-15 01:03
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("parks", "0017_add_timezone_to_pghistory_triggers"),
]
operations = []

View File

@@ -0,0 +1,52 @@
# Generated manually to fix pghistory timezone issue
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("parks", "0018_auto_20250914_2103"),
]
operations = [
migrations.RunSQL(
sql="""
-- Drop the existing trigger function
DROP FUNCTION IF EXISTS pgtrigger_insert_insert_66883() CASCADE;
-- Recreate the trigger function with timezone field
CREATE OR REPLACE FUNCTION pgtrigger_insert_insert_66883()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO "parks_parkevent" (
"average_rating", "banner_image_id", "card_image_id", "closing_date",
"coaster_count", "created_at", "description", "id", "name", "opening_date",
"opening_year", "operating_season", "operator_id", "park_type",
"pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id",
"property_owner_id", "ride_count", "search_text", "size_acres",
"slug", "status", "updated_at", "url", "website", "timezone"
) VALUES (
NEW."average_rating", NEW."banner_image_id", NEW."card_image_id", NEW."closing_date",
NEW."coaster_count", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date",
NEW."opening_year", NEW."operating_season", NEW."operator_id", NEW."park_type",
_pgh_attach_context(), NOW(), 'insert', NEW."id",
NEW."property_owner_id", NEW."ride_count", NEW."search_text", NEW."size_acres",
NEW."slug", NEW."status", NEW."updated_at", NEW."url", NEW."website", NEW."timezone"
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Recreate the trigger
CREATE TRIGGER pgtrigger_insert_insert_66883
AFTER INSERT ON parks_park
FOR EACH ROW
EXECUTE FUNCTION pgtrigger_insert_insert_66883();
""",
reverse_sql="""
-- This is irreversible, but we can drop and recreate without timezone
DROP FUNCTION IF EXISTS pgtrigger_insert_insert_66883() CASCADE;
"""
),
]

View File

@@ -0,0 +1,52 @@
# Generated manually to fix pghistory UPDATE timezone issue
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("parks", "0019_fix_pghistory_timezone"),
]
operations = [
migrations.RunSQL(
sql="""
-- Drop the existing UPDATE trigger function
DROP FUNCTION IF EXISTS pgtrigger_update_update_19f56() CASCADE;
-- Recreate the UPDATE trigger function with timezone field
CREATE OR REPLACE FUNCTION pgtrigger_update_update_19f56()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO "parks_parkevent" (
"average_rating", "banner_image_id", "card_image_id", "closing_date",
"coaster_count", "created_at", "description", "id", "name", "opening_date",
"opening_year", "operating_season", "operator_id", "park_type",
"pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id",
"property_owner_id", "ride_count", "search_text", "size_acres",
"slug", "status", "updated_at", "url", "website", "timezone"
) VALUES (
NEW."average_rating", NEW."banner_image_id", NEW."card_image_id", NEW."closing_date",
NEW."coaster_count", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date",
NEW."opening_year", NEW."operating_season", NEW."operator_id", NEW."park_type",
_pgh_attach_context(), NOW(), 'update', NEW."id",
NEW."property_owner_id", NEW."ride_count", NEW."search_text", NEW."size_acres",
NEW."slug", NEW."status", NEW."updated_at", NEW."url", NEW."website", NEW."timezone"
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Recreate the UPDATE trigger
CREATE TRIGGER pgtrigger_update_update_19f56
AFTER UPDATE ON parks_park
FOR EACH ROW
EXECUTE FUNCTION pgtrigger_update_update_19f56();
""",
reverse_sql="""
-- This is irreversible, but we can drop and recreate without timezone
DROP FUNCTION IF EXISTS pgtrigger_update_update_19f56() CASCADE;
"""
),
]

View File

@@ -124,6 +124,25 @@ class Park(TrackedModel):
# Frontend URL
url = models.URLField(blank=True, help_text="Frontend URL for this park")
# Computed fields for hybrid filtering
opening_year = models.IntegerField(
null=True,
blank=True,
db_index=True,
help_text="Year the park opened (computed from opening_date)"
)
search_text = models.TextField(
blank=True,
db_index=True,
help_text="Searchable text combining name, description, location, and operator"
)
# Timezone for park operations
timezone = models.CharField(
max_length=50,
help_text="Timezone identifier for park operations (e.g., 'America/New_York')"
)
class Meta:
ordering = ["name"]
constraints = [
@@ -198,6 +217,9 @@ class Park(TrackedModel):
frontend_domain = getattr(settings, "FRONTEND_DOMAIN", "https://thrillwiki.com")
self.url = f"{frontend_domain}/parks/{self.slug}/"
# Populate computed fields for hybrid filtering
self._populate_computed_fields()
# Save the model
super().save(*args, **kwargs)
@@ -209,6 +231,44 @@ class Park(TrackedModel):
slug=old_slug,
)
def _populate_computed_fields(self) -> None:
"""Populate computed fields for hybrid filtering"""
# Populate opening_year from opening_date
if self.opening_date:
self.opening_year = self.opening_date.year
else:
self.opening_year = None
# Populate search_text for client-side filtering
search_parts = [self.name]
if self.description:
search_parts.append(self.description)
# Add location information if available
try:
if hasattr(self, 'location') and self.location:
if self.location.city:
search_parts.append(self.location.city)
if self.location.state:
search_parts.append(self.location.state)
if self.location.country:
search_parts.append(self.location.country)
except Exception:
# Handle case where location relationship doesn't exist yet
pass
# Add operator information
if self.operator:
search_parts.append(self.operator.name)
# Add property owner information if different
if self.property_owner and self.property_owner != self.operator:
search_parts.append(self.property_owner.name)
# Combine all parts into searchable text
self.search_text = ' '.join(filter(None, search_parts)).lower()
def clean(self):
super().clean()
if self.operator and "OPERATOR" not in self.operator.roles:

View File

@@ -0,0 +1,425 @@
"""
Smart Park Loader for Hybrid Filtering Strategy
This module provides intelligent data loading capabilities for the hybrid filtering approach,
optimizing database queries and implementing progressive loading strategies.
"""
from typing import Dict, List, Optional, Any, Tuple
from django.db import models
from django.core.cache import cache
from django.conf import settings
from apps.parks.models import Park
class SmartParkLoader:
"""
Intelligent park data loader that optimizes queries based on filtering requirements.
Implements progressive loading and smart caching strategies.
"""
# Cache configuration
CACHE_TIMEOUT = getattr(settings, 'HYBRID_FILTER_CACHE_TIMEOUT', 300) # 5 minutes
CACHE_KEY_PREFIX = 'hybrid_parks'
# Progressive loading thresholds
INITIAL_LOAD_SIZE = 50
PROGRESSIVE_LOAD_SIZE = 25
MAX_CLIENT_SIDE_RECORDS = 200
def __init__(self):
self.base_queryset = self._get_optimized_queryset()
def _get_optimized_queryset(self) -> models.QuerySet:
"""Get optimized base queryset with all necessary prefetches."""
return Park.objects.select_related(
'operator',
'property_owner',
'banner_image',
'card_image',
).prefetch_related(
'location', # ParkLocation relationship
).filter(
# Only include operating and temporarily closed parks by default
status__in=['OPERATING', 'CLOSED_TEMP']
).order_by('name')
def get_initial_load(self, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Get initial park data load with smart filtering decisions.
Args:
filters: Optional filters to apply
Returns:
Dictionary containing parks data and metadata
"""
cache_key = self._generate_cache_key('initial', filters)
cached_result = cache.get(cache_key)
if cached_result:
return cached_result
# Apply filters if provided
queryset = self.base_queryset
if filters:
queryset = self._apply_filters(queryset, filters)
# Get total count for pagination decisions
total_count = queryset.count()
# Determine loading strategy
if total_count <= self.MAX_CLIENT_SIDE_RECORDS:
# Load all data for client-side filtering
parks = list(queryset.all())
strategy = 'client_side'
has_more = False
else:
# Load initial batch for server-side pagination
parks = list(queryset[:self.INITIAL_LOAD_SIZE])
strategy = 'server_side'
has_more = total_count > self.INITIAL_LOAD_SIZE
result = {
'parks': parks,
'total_count': total_count,
'strategy': strategy,
'has_more': has_more,
'next_offset': len(parks) if has_more else None,
'filter_metadata': self._get_filter_metadata(queryset),
}
# Cache the result
cache.set(cache_key, result, self.CACHE_TIMEOUT)
return result
def get_progressive_load(
self,
offset: int,
filters: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Get next batch of parks for progressive loading.
Args:
offset: Starting offset for the batch
filters: Optional filters to apply
Returns:
Dictionary containing parks data and metadata
"""
cache_key = self._generate_cache_key(f'progressive_{offset}', filters)
cached_result = cache.get(cache_key)
if cached_result:
return cached_result
# Apply filters if provided
queryset = self.base_queryset
if filters:
queryset = self._apply_filters(queryset, filters)
# Get the batch
end_offset = offset + self.PROGRESSIVE_LOAD_SIZE
parks = list(queryset[offset:end_offset])
# Check if there are more records
total_count = queryset.count()
has_more = end_offset < total_count
result = {
'parks': parks,
'total_count': total_count,
'has_more': has_more,
'next_offset': end_offset if has_more else None,
}
# Cache the result
cache.set(cache_key, result, self.CACHE_TIMEOUT)
return result
def get_filter_metadata(self, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Get metadata about available filter options.
Args:
filters: Current filters to scope the metadata
Returns:
Dictionary containing filter metadata
"""
cache_key = self._generate_cache_key('metadata', filters)
cached_result = cache.get(cache_key)
if cached_result:
return cached_result
# Apply filters if provided
queryset = self.base_queryset
if filters:
queryset = self._apply_filters(queryset, filters)
result = self._get_filter_metadata(queryset)
# Cache the result
cache.set(cache_key, result, self.CACHE_TIMEOUT)
return result
def _apply_filters(self, queryset: models.QuerySet, filters: Dict[str, Any]) -> models.QuerySet:
"""Apply filters to the queryset."""
# Status filter
if 'status' in filters and filters['status']:
if isinstance(filters['status'], list):
queryset = queryset.filter(status__in=filters['status'])
else:
queryset = queryset.filter(status=filters['status'])
# Park type filter
if 'park_type' in filters and filters['park_type']:
if isinstance(filters['park_type'], list):
queryset = queryset.filter(park_type__in=filters['park_type'])
else:
queryset = queryset.filter(park_type=filters['park_type'])
# Country filter
if 'country' in filters and filters['country']:
queryset = queryset.filter(location__country__in=filters['country'])
# State filter
if 'state' in filters and filters['state']:
queryset = queryset.filter(location__state__in=filters['state'])
# Opening year range
if 'opening_year_min' in filters and filters['opening_year_min']:
queryset = queryset.filter(opening_year__gte=filters['opening_year_min'])
if 'opening_year_max' in filters and filters['opening_year_max']:
queryset = queryset.filter(opening_year__lte=filters['opening_year_max'])
# Size range
if 'size_min' in filters and filters['size_min']:
queryset = queryset.filter(size_acres__gte=filters['size_min'])
if 'size_max' in filters and filters['size_max']:
queryset = queryset.filter(size_acres__lte=filters['size_max'])
# Rating range
if 'rating_min' in filters and filters['rating_min']:
queryset = queryset.filter(average_rating__gte=filters['rating_min'])
if 'rating_max' in filters and filters['rating_max']:
queryset = queryset.filter(average_rating__lte=filters['rating_max'])
# Ride count range
if 'ride_count_min' in filters and filters['ride_count_min']:
queryset = queryset.filter(ride_count__gte=filters['ride_count_min'])
if 'ride_count_max' in filters and filters['ride_count_max']:
queryset = queryset.filter(ride_count__lte=filters['ride_count_max'])
# Coaster count range
if 'coaster_count_min' in filters and filters['coaster_count_min']:
queryset = queryset.filter(coaster_count__gte=filters['coaster_count_min'])
if 'coaster_count_max' in filters and filters['coaster_count_max']:
queryset = queryset.filter(coaster_count__lte=filters['coaster_count_max'])
# Operator filter
if 'operator' in filters and filters['operator']:
if isinstance(filters['operator'], list):
queryset = queryset.filter(operator__slug__in=filters['operator'])
else:
queryset = queryset.filter(operator__slug=filters['operator'])
# Search query
if 'search' in filters and filters['search']:
search_term = filters['search'].lower()
queryset = queryset.filter(search_text__icontains=search_term)
return queryset
def _get_filter_metadata(self, queryset: models.QuerySet) -> Dict[str, Any]:
"""Generate filter metadata from the current queryset."""
# Get distinct values for categorical filters with counts
countries_data = list(
queryset.values('location__country')
.exclude(location__country__isnull=True)
.annotate(count=models.Count('id'))
.order_by('location__country')
)
states_data = list(
queryset.values('location__state')
.exclude(location__state__isnull=True)
.annotate(count=models.Count('id'))
.order_by('location__state')
)
park_types_data = list(
queryset.values('park_type')
.exclude(park_type__isnull=True)
.annotate(count=models.Count('id'))
.order_by('park_type')
)
statuses_data = list(
queryset.values('status')
.annotate(count=models.Count('id'))
.order_by('status')
)
operators_data = list(
queryset.select_related('operator')
.values('operator__id', 'operator__name', 'operator__slug')
.exclude(operator__isnull=True)
.annotate(count=models.Count('id'))
.order_by('operator__name')
)
# Convert to frontend-expected format with value/label/count
countries = [
{
'value': item['location__country'],
'label': item['location__country'],
'count': item['count']
}
for item in countries_data
]
states = [
{
'value': item['location__state'],
'label': item['location__state'],
'count': item['count']
}
for item in states_data
]
park_types = [
{
'value': item['park_type'],
'label': item['park_type'],
'count': item['count']
}
for item in park_types_data
]
statuses = [
{
'value': item['status'],
'label': self._get_status_label(item['status']),
'count': item['count']
}
for item in statuses_data
]
operators = [
{
'value': item['operator__slug'],
'label': item['operator__name'],
'count': item['count']
}
for item in operators_data
]
# Get ranges for numerical filters
aggregates = queryset.aggregate(
opening_year_min=models.Min('opening_year'),
opening_year_max=models.Max('opening_year'),
size_min=models.Min('size_acres'),
size_max=models.Max('size_acres'),
rating_min=models.Min('average_rating'),
rating_max=models.Max('average_rating'),
ride_count_min=models.Min('ride_count'),
ride_count_max=models.Max('ride_count'),
coaster_count_min=models.Min('coaster_count'),
coaster_count_max=models.Max('coaster_count'),
)
return {
'categorical': {
'countries': countries,
'states': states,
'park_types': park_types,
'statuses': statuses,
'operators': operators,
},
'ranges': {
'opening_year': {
'min': aggregates['opening_year_min'],
'max': aggregates['opening_year_max'],
'step': 1,
'unit': 'year'
},
'size_acres': {
'min': float(aggregates['size_min']) if aggregates['size_min'] else None,
'max': float(aggregates['size_max']) if aggregates['size_max'] else None,
'step': 1.0,
'unit': 'acres'
},
'average_rating': {
'min': float(aggregates['rating_min']) if aggregates['rating_min'] else None,
'max': float(aggregates['rating_max']) if aggregates['rating_max'] else None,
'step': 0.1,
'unit': 'stars'
},
'ride_count': {
'min': aggregates['ride_count_min'],
'max': aggregates['ride_count_max'],
'step': 1,
'unit': 'rides'
},
'coaster_count': {
'min': aggregates['coaster_count_min'],
'max': aggregates['coaster_count_max'],
'step': 1,
'unit': 'coasters'
},
},
'total_count': queryset.count(),
}
def _get_status_label(self, status: str) -> str:
"""Convert status code to human-readable label."""
status_labels = {
'OPERATING': 'Operating',
'CLOSED_TEMP': 'Temporarily Closed',
'CLOSED_PERM': 'Permanently Closed',
'UNDER_CONSTRUCTION': 'Under Construction',
}
return status_labels.get(status, status)
def _generate_cache_key(self, operation: str, filters: Optional[Dict[str, Any]] = None) -> str:
"""Generate cache key for the given operation and filters."""
key_parts = [self.CACHE_KEY_PREFIX, operation]
if filters:
# Create a consistent string representation of filters
filter_str = '_'.join(f"{k}:{v}" for k, v in sorted(filters.items()) if v)
key_parts.append(filter_str)
return '_'.join(key_parts)
def invalidate_cache(self, filters: Optional[Dict[str, Any]] = None) -> None:
"""Invalidate cached data for the given filters."""
# This is a simplified implementation
# In production, you might want to use cache versioning or tags
cache_keys = [
self._generate_cache_key('initial', filters),
self._generate_cache_key('metadata', filters),
]
# Also invalidate progressive load caches
for offset in range(0, 1000, self.PROGRESSIVE_LOAD_SIZE):
cache_keys.append(self._generate_cache_key(f'progressive_{offset}', filters))
cache.delete_many(cache_keys)
# Singleton instance
smart_park_loader = SmartParkLoader()

View File

@@ -0,0 +1,72 @@
# Generated by Django 5.2.5 on 2025-09-14 19:18
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("rides", "0017_remove_ridemodelphoto_insert_insert_and_more"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="ride",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ride",
name="update_update",
),
migrations.AddField(
model_name="ride",
name="opening_year",
field=models.IntegerField(blank=True, db_index=True, null=True),
),
migrations.AddField(
model_name="ride",
name="search_text",
field=models.TextField(blank=True, db_index=True),
),
migrations.AddField(
model_name="rideevent",
name="opening_year",
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="rideevent",
name="search_text",
field=models.TextField(blank=True),
),
pgtrigger.migrations.AddTrigger(
model_name="ride",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "opening_year", "park_area_id", "park_id", "park_url", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "search_text", "slug", "status", "status_since", "updated_at", "url") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."opening_year", NEW."park_area_id", NEW."park_id", NEW."park_url", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."search_text", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at", NEW."url"); RETURN NULL;',
hash="64e055c574495c0f09b3cbfb12442d4e4113e4f2",
operation="INSERT",
pgid="pgtrigger_insert_insert_52074",
table="rides_ride",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ride",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "opening_year", "park_area_id", "park_id", "park_url", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "search_text", "slug", "status", "status_since", "updated_at", "url") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."opening_year", NEW."park_area_id", NEW."park_id", NEW."park_url", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."search_text", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at", NEW."url"); RETURN NULL;',
hash="6476c8dd4bbb0e2ae42ca2daa5c691b87f9119e9",
operation="UPDATE",
pgid="pgtrigger_update_update_4917a",
table="rides_ride",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,124 @@
"""
Populate computed fields for hybrid filtering in rides.
This migration populates the opening_year and search_text fields that were added
in the previous migration. These fields enable efficient hybrid filtering by
pre-computing commonly filtered and searched data.
"""
from django.db import migrations
import pghistory
def populate_computed_fields(apps, schema_editor):
"""Populate computed fields for all existing rides."""
Ride = apps.get_model('rides', 'Ride')
# Disable pghistory triggers during bulk operations to avoid performance issues
with pghistory.context(disable=True):
rides = list(Ride.objects.all().select_related(
'park', 'park__location', 'park_area', 'manufacturer', 'designer', 'ride_model'
))
for ride in rides:
# Extract opening year from opening_date
if ride.opening_date:
ride.opening_year = ride.opening_date.year
else:
ride.opening_year = None
# Build comprehensive search text
search_parts = []
# Basic ride info
if ride.name:
search_parts.append(ride.name)
if ride.description:
search_parts.append(ride.description)
# Park info
if ride.park:
search_parts.append(ride.park.name)
if hasattr(ride.park, 'location') and ride.park.location:
if ride.park.location.city:
search_parts.append(ride.park.location.city)
if ride.park.location.state:
search_parts.append(ride.park.location.state)
if ride.park.location.country:
search_parts.append(ride.park.location.country)
# Park area
if ride.park_area:
search_parts.append(ride.park_area.name)
# Category
if ride.category:
category_choices = [
("", "Select ride type"),
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport"),
("OT", "Other"),
]
category_display = dict(category_choices).get(ride.category, '')
if category_display:
search_parts.append(category_display)
# Status
if ride.status:
status_choices = [
("", "Select status"),
("OPERATING", "Operating"),
("CLOSED_TEMP", "Temporarily Closed"),
("SBNO", "Standing But Not Operating"),
("CLOSING", "Closing"),
("CLOSED_PERM", "Permanently Closed"),
("UNDER_CONSTRUCTION", "Under Construction"),
("DEMOLISHED", "Demolished"),
("RELOCATED", "Relocated"),
]
status_display = dict(status_choices).get(ride.status, '')
if status_display:
search_parts.append(status_display)
# Companies
if ride.manufacturer:
search_parts.append(ride.manufacturer.name)
if ride.designer:
search_parts.append(ride.designer.name)
# Ride model
if ride.ride_model:
search_parts.append(ride.ride_model.name)
if ride.ride_model.manufacturer:
search_parts.append(ride.ride_model.manufacturer.name)
ride.search_text = ' '.join(filter(None, search_parts)).lower()
# Bulk update all rides
Ride.objects.bulk_update(rides, ['opening_year', 'search_text'], batch_size=1000)
def reverse_populate_computed_fields(apps, schema_editor):
"""Clear computed fields (reverse operation)."""
Ride = apps.get_model('rides', 'Ride')
# Disable pghistory triggers during bulk operations
with pghistory.context(disable=True):
Ride.objects.all().update(opening_year=None, search_text='')
class Migration(migrations.Migration):
dependencies = [
('rides', '0018_add_hybrid_filtering_fields'),
]
operations = [
migrations.RunPython(
populate_computed_fields,
reverse_populate_computed_fields,
elidable=True,
),
]

View File

@@ -0,0 +1,181 @@
"""
Add strategic database indexes for hybrid filtering in rides.
This migration creates optimized indexes for the hybrid filtering system,
enabling sub-second query performance across all filter combinations.
Index Strategy:
- Composite indexes for common filter combinations
- Partial indexes for status-based filtering
- Covering indexes to avoid table lookups
- GIN indexes for full-text search
- Individual indexes for range queries
Performance Target: <100ms for most filter combinations
"""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rides', '0019_populate_hybrid_filtering_fields'),
]
operations = [
# Composite index for park + category filtering (very common)
migrations.RunSQL(
"CREATE INDEX rides_ride_park_category_idx ON rides_ride (park_id, category) WHERE category != '';",
reverse_sql="DROP INDEX IF EXISTS rides_ride_park_category_idx;"
),
# Composite index for park + status filtering (common)
migrations.RunSQL(
"CREATE INDEX rides_ride_park_status_idx ON rides_ride (park_id, status);",
reverse_sql="DROP INDEX IF EXISTS rides_ride_park_status_idx;"
),
# Composite index for category + status filtering
migrations.RunSQL(
"CREATE INDEX rides_ride_category_status_idx ON rides_ride (category, status) WHERE category != '';",
reverse_sql="DROP INDEX IF EXISTS rides_ride_category_status_idx;"
),
# Composite index for manufacturer + category
migrations.RunSQL(
"CREATE INDEX rides_ride_manufacturer_category_idx ON rides_ride (manufacturer_id, category) WHERE manufacturer_id IS NOT NULL AND category != '';",
reverse_sql="DROP INDEX IF EXISTS rides_ride_manufacturer_category_idx;"
),
# Composite index for opening year + category (for timeline filtering)
migrations.RunSQL(
"CREATE INDEX rides_ride_opening_year_category_idx ON rides_ride (opening_year, category) WHERE opening_year IS NOT NULL AND category != '';",
reverse_sql="DROP INDEX IF EXISTS rides_ride_opening_year_category_idx;"
),
# Partial index for operating rides only (most common filter)
migrations.RunSQL(
"CREATE INDEX rides_ride_operating_only_idx ON rides_ride (park_id, category, opening_year) WHERE status = 'OPERATING';",
reverse_sql="DROP INDEX IF EXISTS rides_ride_operating_only_idx;"
),
# Partial index for roller coasters only (popular category)
migrations.RunSQL(
"CREATE INDEX rides_ride_roller_coasters_idx ON rides_ride (park_id, status, opening_year) WHERE category = 'RC';",
reverse_sql="DROP INDEX IF EXISTS rides_ride_roller_coasters_idx;"
),
# Covering index for list views (includes commonly displayed fields)
migrations.RunSQL(
"CREATE INDEX rides_ride_list_covering_idx ON rides_ride (park_id, category, status) INCLUDE (name, opening_date, average_rating);",
reverse_sql="DROP INDEX IF EXISTS rides_ride_list_covering_idx;"
),
# GIN index for full-text search on computed search_text field
migrations.RunSQL(
"CREATE INDEX rides_ride_search_text_gin_idx ON rides_ride USING gin(to_tsvector('english', search_text));",
reverse_sql="DROP INDEX IF EXISTS rides_ride_search_text_gin_idx;"
),
# Trigram index for fuzzy text search
migrations.RunSQL(
"CREATE INDEX rides_ride_search_text_trgm_idx ON rides_ride USING gin(search_text gin_trgm_ops);",
reverse_sql="DROP INDEX IF EXISTS rides_ride_search_text_trgm_idx;"
),
# Index for rating-based filtering
migrations.RunSQL(
"CREATE INDEX rides_ride_rating_idx ON rides_ride (average_rating) WHERE average_rating IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_ride_rating_idx;"
),
# Index for capacity-based filtering
migrations.RunSQL(
"CREATE INDEX rides_ride_capacity_idx ON rides_ride (capacity_per_hour) WHERE capacity_per_hour IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_ride_capacity_idx;"
),
# Index for height requirement filtering
migrations.RunSQL(
"CREATE INDEX rides_ride_height_req_idx ON rides_ride (min_height_in, max_height_in) WHERE min_height_in IS NOT NULL OR max_height_in IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_ride_height_req_idx;"
),
# Composite index for ride model filtering
migrations.RunSQL(
"CREATE INDEX rides_ride_model_manufacturer_idx ON rides_ride (ride_model_id, manufacturer_id) WHERE ride_model_id IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_ride_model_manufacturer_idx;"
),
# Index for designer filtering
migrations.RunSQL(
"CREATE INDEX rides_ride_designer_idx ON rides_ride (designer_id, category) WHERE designer_id IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_ride_designer_idx;"
),
# Index for park area filtering
migrations.RunSQL(
"CREATE INDEX rides_ride_park_area_idx ON rides_ride (park_area_id, status) WHERE park_area_id IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_ride_park_area_idx;"
),
# Roller coaster stats indexes for performance
migrations.RunSQL(
"CREATE INDEX rides_rollercoasterstats_height_idx ON rides_rollercoasterstats (height_ft) WHERE height_ft IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_height_idx;"
),
migrations.RunSQL(
"CREATE INDEX rides_rollercoasterstats_speed_idx ON rides_rollercoasterstats (speed_mph) WHERE speed_mph IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_speed_idx;"
),
migrations.RunSQL(
"CREATE INDEX rides_rollercoasterstats_inversions_idx ON rides_rollercoasterstats (inversions);",
reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_inversions_idx;"
),
migrations.RunSQL(
"CREATE INDEX rides_rollercoasterstats_type_material_idx ON rides_rollercoasterstats (roller_coaster_type, track_material);",
reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_type_material_idx;"
),
migrations.RunSQL(
"CREATE INDEX rides_rollercoasterstats_launch_type_idx ON rides_rollercoasterstats (launch_type);",
reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_launch_type_idx;"
),
# Composite index for complex roller coaster filtering
migrations.RunSQL(
"CREATE INDEX rides_rollercoasterstats_complex_idx ON rides_rollercoasterstats (roller_coaster_type, track_material, launch_type) INCLUDE (height_ft, speed_mph, inversions);",
reverse_sql="DROP INDEX IF EXISTS rides_rollercoasterstats_complex_idx;"
),
# Index for ride model filtering and search
migrations.RunSQL(
"CREATE INDEX rides_ridemodel_manufacturer_category_idx ON rides_ridemodel (manufacturer_id, category) WHERE manufacturer_id IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS rides_ridemodel_manufacturer_category_idx;"
),
migrations.RunSQL(
"CREATE INDEX rides_ridemodel_name_trgm_idx ON rides_ridemodel USING gin(name gin_trgm_ops);",
reverse_sql="DROP INDEX IF EXISTS rides_ridemodel_name_trgm_idx;"
),
# Index for company role-based filtering
migrations.RunSQL(
"CREATE INDEX rides_company_manufacturer_role_idx ON rides_company USING gin(roles) WHERE 'MANUFACTURER' = ANY(roles);",
reverse_sql="DROP INDEX IF EXISTS rides_company_manufacturer_role_idx;"
),
migrations.RunSQL(
"CREATE INDEX rides_company_designer_role_idx ON rides_company USING gin(roles) WHERE 'DESIGNER' = ANY(roles);",
reverse_sql="DROP INDEX IF EXISTS rides_company_designer_role_idx;"
),
# Ensure trigram extension is available for fuzzy search
migrations.RunSQL(
"CREATE EXTENSION IF NOT EXISTS pg_trgm;",
reverse_sql="-- Cannot safely drop pg_trgm extension"
),
]

View File

@@ -538,6 +538,10 @@ class Ride(TrackedModel):
max_digits=3, decimal_places=2, null=True, blank=True
)
# Computed fields for hybrid filtering
opening_year = models.IntegerField(null=True, blank=True, db_index=True)
search_text = models.TextField(blank=True, db_index=True)
# Image settings - references to existing photos
banner_image = models.ForeignKey(
"RidePhoto",
@@ -639,14 +643,14 @@ class Ride(TrackedModel):
pass
# If park changed or this is a new ride, ensure slug uniqueness within the park
park_changed = original_ride and original_ride.park_id != self.park_id
park_changed = original_ride and original_ride.park.id != self.park.id
if not self.pk or park_changed:
self._ensure_unique_slug_in_park()
# Handle park area validation when park changes
if park_changed and self.park_area:
# Check if park_area belongs to the new park
if self.park_area.park_id != self.park_id:
if self.park_area.park.id != self.park.id:
# Clear park_area if it doesn't belong to the new park
self.park_area = None
@@ -658,8 +662,93 @@ class Ride(TrackedModel):
self.url = f"{frontend_domain}/parks/{self.park.slug}/rides/{self.slug}/"
self.park_url = f"{frontend_domain}/parks/{self.park.slug}/"
# Populate computed fields
self._populate_computed_fields()
super().save(*args, **kwargs)
def _populate_computed_fields(self) -> None:
"""Populate computed fields for hybrid filtering."""
# Extract opening year from opening_date
if self.opening_date:
self.opening_year = self.opening_date.year
else:
self.opening_year = None
# Build comprehensive search text
search_parts = []
# Basic ride info
if self.name:
search_parts.append(self.name)
if self.description:
search_parts.append(self.description)
# Park info
if self.park:
search_parts.append(self.park.name)
if hasattr(self.park, 'location') and self.park.location:
if self.park.location.city:
search_parts.append(self.park.location.city)
if self.park.location.state:
search_parts.append(self.park.location.state)
if self.park.location.country:
search_parts.append(self.park.location.country)
# Park area
if self.park_area:
search_parts.append(self.park_area.name)
# Category
if self.category:
category_display = dict(CATEGORY_CHOICES).get(self.category, '')
if category_display:
search_parts.append(category_display)
# Status
if self.status:
status_display = dict(self.STATUS_CHOICES).get(self.status, '')
if status_display:
search_parts.append(status_display)
# Companies
if self.manufacturer:
search_parts.append(self.manufacturer.name)
if self.designer:
search_parts.append(self.designer.name)
# Ride model
if self.ride_model:
search_parts.append(self.ride_model.name)
if self.ride_model.manufacturer:
search_parts.append(self.ride_model.manufacturer.name)
# Roller coaster stats if available
try:
if hasattr(self, 'coaster_stats') and self.coaster_stats:
stats = self.coaster_stats
if stats.track_type:
search_parts.append(stats.track_type)
if stats.track_material:
material_display = dict(RollerCoasterStats.TRACK_MATERIAL_CHOICES).get(stats.track_material, '')
if material_display:
search_parts.append(material_display)
if stats.roller_coaster_type:
type_display = dict(RollerCoasterStats.COASTER_TYPE_CHOICES).get(stats.roller_coaster_type, '')
if type_display:
search_parts.append(type_display)
if stats.launch_type:
launch_display = dict(RollerCoasterStats.LAUNCH_CHOICES).get(stats.launch_type, '')
if launch_display:
search_parts.append(launch_display)
if stats.train_style:
search_parts.append(stats.train_style)
except Exception:
# Ignore if coaster_stats doesn't exist or has issues
pass
self.search_text = ' '.join(filter(None, search_parts)).lower()
def _ensure_unique_slug_in_park(self) -> None:
"""Ensure the ride's slug is unique within its park."""
base_slug = slugify(self.name)
@@ -685,7 +774,6 @@ class Ride(TrackedModel):
Returns:
dict: Summary of changes made
"""
from django.apps import apps
old_park = self.park
old_url = self.url

View File

@@ -0,0 +1,771 @@
"""
Smart Ride Loader for Hybrid Filtering Strategy
This service implements intelligent data loading for rides, automatically choosing
between client-side and server-side filtering based on data size and complexity.
Key Features:
- Automatic strategy selection (≤200 records = client-side, >200 = server-side)
- Progressive loading for large datasets
- Intelligent caching with automatic invalidation
- Comprehensive filter metadata generation
- Optimized database queries with strategic prefetching
Architecture:
- Client-side: Load all data once, filter in frontend
- Server-side: Apply filters in database, paginate results
- Hybrid: Combine both approaches based on data characteristics
"""
from typing import Dict, List, Any, Optional, Tuple
from django.core.cache import cache
from django.db import models
from django.db.models import Q, Count, Min, Max, Avg
from django.utils import timezone
from datetime import timedelta
import logging
logger = logging.getLogger(__name__)
class SmartRideLoader:
"""
Intelligent ride data loader that chooses optimal filtering strategy.
Strategy Selection:
- ≤200 total records: Client-side filtering (load all data)
- >200 total records: Server-side filtering (database filtering + pagination)
Features:
- Progressive loading for large datasets
- 5-minute intelligent caching
- Comprehensive filter metadata
- Optimized queries with prefetch_related
"""
# Configuration constants
INITIAL_LOAD_SIZE = 50
PROGRESSIVE_LOAD_SIZE = 25
MAX_CLIENT_SIDE_RECORDS = 200
CACHE_TIMEOUT = 300 # 5 minutes
def __init__(self):
self.cache_prefix = "rides_hybrid_"
def get_initial_load(self, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Get initial data load with automatic strategy selection.
Args:
filters: Optional filter parameters
Returns:
Dict containing:
- strategy: 'client_side' or 'server_side'
- data: List of ride records
- total_count: Total number of records
- has_more: Whether more data is available
- filter_metadata: Available filter options
"""
from apps.rides.models import Ride
# Get total count for strategy decision
total_count = self._get_total_count(filters)
# Choose strategy based on total count
if total_count <= self.MAX_CLIENT_SIDE_RECORDS:
return self._get_client_side_data(filters, total_count)
else:
return self._get_server_side_data(filters, total_count)
def get_progressive_load(self, offset: int, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Get additional data for progressive loading (server-side strategy only).
Args:
offset: Number of records to skip
filters: Filter parameters
Returns:
Dict containing additional ride records
"""
from apps.rides.models import Ride
# Build queryset with filters
queryset = self._build_filtered_queryset(filters)
# Get total count for this filtered set
total_count = queryset.count()
# Get progressive batch
rides = list(queryset[offset:offset + self.PROGRESSIVE_LOAD_SIZE])
return {
'rides': self._serialize_rides(rides),
'total_count': total_count,
'has_more': len(rides) == self.PROGRESSIVE_LOAD_SIZE,
'next_offset': offset + len(rides) if len(rides) == self.PROGRESSIVE_LOAD_SIZE else None
}
def get_filter_metadata(self, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Get comprehensive filter metadata for dynamic filter generation.
Args:
filters: Optional filters to scope the metadata
Returns:
Dict containing all available filter options and ranges
"""
cache_key = f"{self.cache_prefix}filter_metadata_{hash(str(filters))}"
metadata = cache.get(cache_key)
if metadata is None:
metadata = self._generate_filter_metadata(filters)
cache.set(cache_key, metadata, self.CACHE_TIMEOUT)
return metadata
def invalidate_cache(self) -> None:
"""Invalidate all cached data for rides."""
# Note: In production, you might want to use cache versioning
# or more sophisticated cache invalidation
cache_keys = [
f"{self.cache_prefix}client_side_all",
f"{self.cache_prefix}filter_metadata",
f"{self.cache_prefix}total_count",
]
for key in cache_keys:
cache.delete(key)
def _get_total_count(self, filters: Optional[Dict[str, Any]] = None) -> int:
"""Get total count of rides matching filters."""
cache_key = f"{self.cache_prefix}total_count_{hash(str(filters))}"
count = cache.get(cache_key)
if count is None:
queryset = self._build_filtered_queryset(filters)
count = queryset.count()
cache.set(cache_key, count, self.CACHE_TIMEOUT)
return count
def _get_client_side_data(self, filters: Optional[Dict[str, Any]],
total_count: int) -> Dict[str, Any]:
"""Get all data for client-side filtering."""
cache_key = f"{self.cache_prefix}client_side_all"
cached_data = cache.get(cache_key)
if cached_data is None:
from apps.rides.models import Ride
# Load all rides with optimized query
queryset = Ride.objects.select_related(
'park',
'park__location',
'park_area',
'manufacturer',
'designer',
'ride_model',
'ride_model__manufacturer'
).prefetch_related(
'coaster_stats'
).order_by('name')
rides = list(queryset)
cached_data = self._serialize_rides(rides)
cache.set(cache_key, cached_data, self.CACHE_TIMEOUT)
return {
'strategy': 'client_side',
'rides': cached_data,
'total_count': total_count,
'has_more': False,
'filter_metadata': self.get_filter_metadata(filters)
}
def _get_server_side_data(self, filters: Optional[Dict[str, Any]],
total_count: int) -> Dict[str, Any]:
"""Get initial batch for server-side filtering."""
# Build filtered queryset
queryset = self._build_filtered_queryset(filters)
# Get initial batch
rides = list(queryset[:self.INITIAL_LOAD_SIZE])
return {
'strategy': 'server_side',
'rides': self._serialize_rides(rides),
'total_count': total_count,
'has_more': len(rides) == self.INITIAL_LOAD_SIZE,
'next_offset': len(rides) if len(rides) == self.INITIAL_LOAD_SIZE else None
}
def _build_filtered_queryset(self, filters: Optional[Dict[str, Any]]):
"""Build Django queryset with applied filters."""
from apps.rides.models import Ride
# Start with optimized base queryset
queryset = Ride.objects.select_related(
'park',
'park__location',
'park_area',
'manufacturer',
'designer',
'ride_model',
'ride_model__manufacturer'
).prefetch_related(
'coaster_stats'
)
if not filters:
return queryset.order_by('name')
# Apply filters
q_objects = Q()
# Text search using computed search_text field
if 'search' in filters and filters['search']:
search_term = filters['search'].lower()
q_objects &= Q(search_text__icontains=search_term)
# Park filters
if 'park_slug' in filters and filters['park_slug']:
q_objects &= Q(park__slug=filters['park_slug'])
if 'park_id' in filters and filters['park_id']:
q_objects &= Q(park_id=filters['park_id'])
# Category filters
if 'category' in filters and filters['category']:
q_objects &= Q(category__in=filters['category'])
# Status filters
if 'status' in filters and filters['status']:
q_objects &= Q(status__in=filters['status'])
# Company filters
if 'manufacturer_ids' in filters and filters['manufacturer_ids']:
q_objects &= Q(manufacturer_id__in=filters['manufacturer_ids'])
if 'designer_ids' in filters and filters['designer_ids']:
q_objects &= Q(designer_id__in=filters['designer_ids'])
# Ride model filters
if 'ride_model_ids' in filters and filters['ride_model_ids']:
q_objects &= Q(ride_model_id__in=filters['ride_model_ids'])
# Opening year filters using computed opening_year field
if 'opening_year' in filters and filters['opening_year']:
q_objects &= Q(opening_year=filters['opening_year'])
if 'min_opening_year' in filters and filters['min_opening_year']:
q_objects &= Q(opening_year__gte=filters['min_opening_year'])
if 'max_opening_year' in filters and filters['max_opening_year']:
q_objects &= Q(opening_year__lte=filters['max_opening_year'])
# Rating filters
if 'min_rating' in filters and filters['min_rating']:
q_objects &= Q(average_rating__gte=filters['min_rating'])
if 'max_rating' in filters and filters['max_rating']:
q_objects &= Q(average_rating__lte=filters['max_rating'])
# Height requirement filters
if 'min_height_requirement' in filters and filters['min_height_requirement']:
q_objects &= Q(min_height_in__gte=filters['min_height_requirement'])
if 'max_height_requirement' in filters and filters['max_height_requirement']:
q_objects &= Q(max_height_in__lte=filters['max_height_requirement'])
# Capacity filters
if 'min_capacity' in filters and filters['min_capacity']:
q_objects &= Q(capacity_per_hour__gte=filters['min_capacity'])
if 'max_capacity' in filters and filters['max_capacity']:
q_objects &= Q(capacity_per_hour__lte=filters['max_capacity'])
# Roller coaster specific filters
if 'roller_coaster_type' in filters and filters['roller_coaster_type']:
q_objects &= Q(coaster_stats__roller_coaster_type__in=filters['roller_coaster_type'])
if 'track_material' in filters and filters['track_material']:
q_objects &= Q(coaster_stats__track_material__in=filters['track_material'])
if 'launch_type' in filters and filters['launch_type']:
q_objects &= Q(coaster_stats__launch_type__in=filters['launch_type'])
# Roller coaster height filters
if 'min_height_ft' in filters and filters['min_height_ft']:
q_objects &= Q(coaster_stats__height_ft__gte=filters['min_height_ft'])
if 'max_height_ft' in filters and filters['max_height_ft']:
q_objects &= Q(coaster_stats__height_ft__lte=filters['max_height_ft'])
# Roller coaster speed filters
if 'min_speed_mph' in filters and filters['min_speed_mph']:
q_objects &= Q(coaster_stats__speed_mph__gte=filters['min_speed_mph'])
if 'max_speed_mph' in filters and filters['max_speed_mph']:
q_objects &= Q(coaster_stats__speed_mph__lte=filters['max_speed_mph'])
# Inversion filters
if 'min_inversions' in filters and filters['min_inversions']:
q_objects &= Q(coaster_stats__inversions__gte=filters['min_inversions'])
if 'max_inversions' in filters and filters['max_inversions']:
q_objects &= Q(coaster_stats__inversions__lte=filters['max_inversions'])
if 'has_inversions' in filters and filters['has_inversions'] is not None:
if filters['has_inversions']:
q_objects &= Q(coaster_stats__inversions__gt=0)
else:
q_objects &= Q(coaster_stats__inversions=0)
# Apply filters and ordering
queryset = queryset.filter(q_objects)
# Apply ordering
ordering = filters.get('ordering', 'name')
if ordering in ['height_ft', '-height_ft', 'speed_mph', '-speed_mph']:
# For coaster stats ordering, we need to join and order by the stats
ordering_field = ordering.replace('height_ft', 'coaster_stats__height_ft').replace('speed_mph', 'coaster_stats__speed_mph')
queryset = queryset.order_by(ordering_field)
else:
queryset = queryset.order_by(ordering)
return queryset
def _serialize_rides(self, rides: List) -> List[Dict[str, Any]]:
"""Serialize ride objects to dictionaries."""
serialized = []
for ride in rides:
# Basic ride data
ride_data = {
'id': ride.id,
'name': ride.name,
'slug': ride.slug,
'description': ride.description,
'category': ride.category,
'status': ride.status,
'opening_date': ride.opening_date.isoformat() if ride.opening_date else None,
'closing_date': ride.closing_date.isoformat() if ride.closing_date else None,
'opening_year': ride.opening_year,
'min_height_in': ride.min_height_in,
'max_height_in': ride.max_height_in,
'capacity_per_hour': ride.capacity_per_hour,
'ride_duration_seconds': ride.ride_duration_seconds,
'average_rating': float(ride.average_rating) if ride.average_rating else None,
'url': ride.url,
'park_url': ride.park_url,
'created_at': ride.created_at.isoformat(),
'updated_at': ride.updated_at.isoformat(),
}
# Park data
if ride.park:
ride_data['park'] = {
'id': ride.park.id,
'name': ride.park.name,
'slug': ride.park.slug,
}
# Park location data
if hasattr(ride.park, 'location') and ride.park.location:
ride_data['park']['location'] = {
'city': ride.park.location.city,
'state': ride.park.location.state,
'country': ride.park.location.country,
}
# Park area data
if ride.park_area:
ride_data['park_area'] = {
'id': ride.park_area.id,
'name': ride.park_area.name,
'slug': ride.park_area.slug,
}
# Company data
if ride.manufacturer:
ride_data['manufacturer'] = {
'id': ride.manufacturer.id,
'name': ride.manufacturer.name,
'slug': ride.manufacturer.slug,
}
if ride.designer:
ride_data['designer'] = {
'id': ride.designer.id,
'name': ride.designer.name,
'slug': ride.designer.slug,
}
# Ride model data
if ride.ride_model:
ride_data['ride_model'] = {
'id': ride.ride_model.id,
'name': ride.ride_model.name,
'slug': ride.ride_model.slug,
'category': ride.ride_model.category,
}
if ride.ride_model.manufacturer:
ride_data['ride_model']['manufacturer'] = {
'id': ride.ride_model.manufacturer.id,
'name': ride.ride_model.manufacturer.name,
'slug': ride.ride_model.manufacturer.slug,
}
# Roller coaster stats
if hasattr(ride, 'coaster_stats') and ride.coaster_stats:
stats = ride.coaster_stats
ride_data['coaster_stats'] = {
'height_ft': float(stats.height_ft) if stats.height_ft else None,
'length_ft': float(stats.length_ft) if stats.length_ft else None,
'speed_mph': float(stats.speed_mph) if stats.speed_mph else None,
'inversions': stats.inversions,
'ride_time_seconds': stats.ride_time_seconds,
'track_type': stats.track_type,
'track_material': stats.track_material,
'roller_coaster_type': stats.roller_coaster_type,
'max_drop_height_ft': float(stats.max_drop_height_ft) if stats.max_drop_height_ft else None,
'launch_type': stats.launch_type,
'train_style': stats.train_style,
'trains_count': stats.trains_count,
'cars_per_train': stats.cars_per_train,
'seats_per_car': stats.seats_per_car,
}
serialized.append(ride_data)
return serialized
def _generate_filter_metadata(self, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Generate comprehensive filter metadata."""
from apps.rides.models import Ride, RideModel
from apps.rides.models.company import Company
from apps.rides.models.rides import RollerCoasterStats
# Get unique values from database with counts
parks_data = list(Ride.objects.exclude(
park__isnull=True
).select_related('park').values(
'park__id', 'park__name', 'park__slug'
).annotate(count=models.Count('id')).distinct().order_by('park__name'))
park_areas_data = list(Ride.objects.exclude(
park_area__isnull=True
).select_related('park_area').values(
'park_area__id', 'park_area__name', 'park_area__slug'
).annotate(count=models.Count('id')).distinct().order_by('park_area__name'))
manufacturers_data = list(Company.objects.filter(
roles__contains=['MANUFACTURER']
).values('id', 'name', 'slug').annotate(
count=models.Count('manufactured_rides')
).order_by('name'))
designers_data = list(Company.objects.filter(
roles__contains=['DESIGNER']
).values('id', 'name', 'slug').annotate(
count=models.Count('designed_rides')
).order_by('name'))
ride_models_data = list(RideModel.objects.select_related(
'manufacturer'
).values(
'id', 'name', 'slug', 'manufacturer__name', 'manufacturer__slug', 'category'
).annotate(count=models.Count('rides')).order_by('manufacturer__name', 'name'))
# Get categories and statuses with counts
categories_data = list(Ride.objects.values('category').annotate(
count=models.Count('id')
).order_by('category'))
statuses_data = list(Ride.objects.values('status').annotate(
count=models.Count('id')
).order_by('status'))
# Get roller coaster specific data with counts
rc_types_data = list(RollerCoasterStats.objects.values('roller_coaster_type').annotate(
count=models.Count('ride')
).exclude(roller_coaster_type__isnull=True).order_by('roller_coaster_type'))
track_materials_data = list(RollerCoasterStats.objects.values('track_material').annotate(
count=models.Count('ride')
).exclude(track_material__isnull=True).order_by('track_material'))
launch_types_data = list(RollerCoasterStats.objects.values('launch_type').annotate(
count=models.Count('ride')
).exclude(launch_type__isnull=True).order_by('launch_type'))
# Convert to frontend-expected format with value/label/count
categories = [
{
'value': item['category'],
'label': self._get_category_label(item['category']),
'count': item['count']
}
for item in categories_data
]
statuses = [
{
'value': item['status'],
'label': self._get_status_label(item['status']),
'count': item['count']
}
for item in statuses_data
]
roller_coaster_types = [
{
'value': item['roller_coaster_type'],
'label': self._get_rc_type_label(item['roller_coaster_type']),
'count': item['count']
}
for item in rc_types_data
]
track_materials = [
{
'value': item['track_material'],
'label': self._get_track_material_label(item['track_material']),
'count': item['count']
}
for item in track_materials_data
]
launch_types = [
{
'value': item['launch_type'],
'label': self._get_launch_type_label(item['launch_type']),
'count': item['count']
}
for item in launch_types_data
]
# Convert other data to expected format
parks = [
{
'value': str(item['park__id']),
'label': item['park__name'],
'count': item['count']
}
for item in parks_data
]
park_areas = [
{
'value': str(item['park_area__id']),
'label': item['park_area__name'],
'count': item['count']
}
for item in park_areas_data
]
manufacturers = [
{
'value': str(item['id']),
'label': item['name'],
'count': item['count']
}
for item in manufacturers_data
]
designers = [
{
'value': str(item['id']),
'label': item['name'],
'count': item['count']
}
for item in designers_data
]
ride_models = [
{
'value': str(item['id']),
'label': f"{item['manufacturer__name']} {item['name']}",
'count': item['count']
}
for item in ride_models_data
]
# Calculate ranges from actual data
ride_stats = Ride.objects.aggregate(
min_rating=Min('average_rating'),
max_rating=Max('average_rating'),
min_height_req=Min('min_height_in'),
max_height_req=Max('max_height_in'),
min_capacity=Min('capacity_per_hour'),
max_capacity=Max('capacity_per_hour'),
min_duration=Min('ride_duration_seconds'),
max_duration=Max('ride_duration_seconds'),
min_year=Min('opening_year'),
max_year=Max('opening_year'),
)
# Calculate roller coaster specific ranges
coaster_stats = RollerCoasterStats.objects.aggregate(
min_height_ft=Min('height_ft'),
max_height_ft=Max('height_ft'),
min_length_ft=Min('length_ft'),
max_length_ft=Max('length_ft'),
min_speed_mph=Min('speed_mph'),
max_speed_mph=Max('speed_mph'),
min_inversions=Min('inversions'),
max_inversions=Max('inversions'),
min_ride_time=Min('ride_time_seconds'),
max_ride_time=Max('ride_time_seconds'),
min_drop_height=Min('max_drop_height_ft'),
max_drop_height=Max('max_drop_height_ft'),
min_trains=Min('trains_count'),
max_trains=Max('trains_count'),
min_cars=Min('cars_per_train'),
max_cars=Max('cars_per_train'),
min_seats=Min('seats_per_car'),
max_seats=Max('seats_per_car'),
)
return {
'categorical': {
'categories': categories,
'statuses': statuses,
'roller_coaster_types': roller_coaster_types,
'track_materials': track_materials,
'launch_types': launch_types,
'parks': parks,
'park_areas': park_areas,
'manufacturers': manufacturers,
'designers': designers,
'ride_models': ride_models,
},
'ranges': {
'rating': {
'min': float(ride_stats['min_rating'] or 1),
'max': float(ride_stats['max_rating'] or 10),
'step': 0.1,
'unit': 'stars'
},
'height_requirement': {
'min': ride_stats['min_height_req'] or 30,
'max': ride_stats['max_height_req'] or 90,
'step': 1,
'unit': 'inches'
},
'capacity': {
'min': ride_stats['min_capacity'] or 0,
'max': ride_stats['max_capacity'] or 5000,
'step': 50,
'unit': 'riders/hour'
},
'ride_duration': {
'min': ride_stats['min_duration'] or 0,
'max': ride_stats['max_duration'] or 600,
'step': 10,
'unit': 'seconds'
},
'opening_year': {
'min': ride_stats['min_year'] or 1800,
'max': ride_stats['max_year'] or 2030,
'step': 1,
'unit': 'year'
},
'height_ft': {
'min': float(coaster_stats['min_height_ft'] or 0),
'max': float(coaster_stats['max_height_ft'] or 500),
'step': 5,
'unit': 'feet'
},
'length_ft': {
'min': float(coaster_stats['min_length_ft'] or 0),
'max': float(coaster_stats['max_length_ft'] or 10000),
'step': 100,
'unit': 'feet'
},
'speed_mph': {
'min': float(coaster_stats['min_speed_mph'] or 0),
'max': float(coaster_stats['max_speed_mph'] or 150),
'step': 5,
'unit': 'mph'
},
'inversions': {
'min': coaster_stats['min_inversions'] or 0,
'max': coaster_stats['max_inversions'] or 20,
'step': 1,
'unit': 'inversions'
},
},
'total_count': Ride.objects.count(),
}
def _get_category_label(self, category: str) -> str:
"""Convert category code to human-readable label."""
category_labels = {
'RC': 'Roller Coaster',
'DR': 'Dark Ride',
'FR': 'Flat Ride',
'WR': 'Water Ride',
'TR': 'Transport Ride',
'OT': 'Other',
}
return category_labels.get(category, category)
def _get_status_label(self, status: str) -> str:
"""Convert status code to human-readable label."""
status_labels = {
'OPERATING': 'Operating',
'CLOSED_TEMP': 'Temporarily Closed',
'SBNO': 'Standing But Not Operating',
'CLOSING': 'Closing Soon',
'CLOSED_PERM': 'Permanently Closed',
'UNDER_CONSTRUCTION': 'Under Construction',
'DEMOLISHED': 'Demolished',
'RELOCATED': 'Relocated',
}
return status_labels.get(status, status)
def _get_rc_type_label(self, rc_type: str) -> str:
"""Convert roller coaster type to human-readable label."""
rc_type_labels = {
'SITDOWN': 'Sit Down',
'INVERTED': 'Inverted',
'SUSPENDED': 'Suspended',
'FLOORLESS': 'Floorless',
'FLYING': 'Flying',
'WING': 'Wing',
'DIVE': 'Dive',
'SPINNING': 'Spinning',
'WILD_MOUSE': 'Wild Mouse',
'BOBSLED': 'Bobsled',
'PIPELINE': 'Pipeline',
'FOURTH_DIMENSION': '4th Dimension',
}
return rc_type_labels.get(rc_type, rc_type.replace('_', ' ').title())
def _get_track_material_label(self, material: str) -> str:
"""Convert track material to human-readable label."""
material_labels = {
'STEEL': 'Steel',
'WOOD': 'Wood',
'HYBRID': 'Hybrid (Steel/Wood)',
}
return material_labels.get(material, material)
def _get_launch_type_label(self, launch_type: str) -> str:
"""Convert launch type to human-readable label."""
launch_labels = {
'CHAIN': 'Chain Lift',
'LSM': 'Linear Synchronous Motor',
'LIM': 'Linear Induction Motor',
'HYDRAULIC': 'Hydraulic Launch',
'PNEUMATIC': 'Pneumatic Launch',
'CABLE': 'Cable Lift',
'FLYWHEEL': 'Flywheel Launch',
'NONE': 'No Launch System',
}
return launch_labels.get(launch_type, launch_type.replace('_', ' ').title())