Implement hybrid filtering strategy for parks and rides

- Added comprehensive documentation for hybrid filtering implementation, including architecture, API endpoints, performance characteristics, and usage examples.
- Developed a hybrid pagination and client-side filtering recommendation, detailing server-side responsibilities and client-side logic.
- Created a test script for hybrid filtering endpoints, covering various test cases including basic filtering, search functionality, pagination, and edge cases.
This commit is contained in:
pacnpal
2025-09-14 21:07:17 -04:00
parent 0fd6dc2560
commit 35f8d0ef8f
42 changed files with 8490 additions and 224 deletions

View File

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

View File

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

View File

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

View File

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

View File

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