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