""" 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 """ import logging from typing import Any from django.core.cache import cache from django.db import models from django.db.models import Max, Min, Q 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: dict[str, Any] | None = 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 """ # 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: dict[str, Any] | None = 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 """ # 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: dict[str, Any] | None = 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: dict[str, Any] | None = 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: dict[str, Any] | None, 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: dict[str, Any] | None, 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: dict[str, Any] | None): """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 'propulsion_system' in filters and filters['propulsion_system']: q_objects &= Q(coaster_stats__propulsion_system__in=filters['propulsion_system']) # 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, 'propulsion_system': stats.propulsion_system, '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: dict[str, Any] | None = 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')) propulsion_systems_data = list(RollerCoasterStats.objects.values('propulsion_system').annotate( count=models.Count('ride') ).exclude(propulsion_system__isnull=True).order_by('propulsion_system')) # 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 ] propulsion_systems = [ { 'value': item['propulsion_system'], 'label': self._get_propulsion_system_label(item['propulsion_system']), 'count': item['count'] } for item in propulsion_systems_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, 'propulsion_systems': propulsion_systems, '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', } if category in category_labels: return category_labels[category] else: raise ValueError(f"Unknown ride 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', } if status in status_labels: return status_labels[status] else: raise ValueError(f"Unknown ride 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', 'FAMILY': 'Family', } if rc_type in rc_type_labels: return rc_type_labels[rc_type] else: raise ValueError(f"Unknown roller coaster type: {rc_type}") 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)', } if material in material_labels: return material_labels[material] else: raise ValueError(f"Unknown track material: {material}") def _get_propulsion_system_label(self, propulsion_system: str) -> str: """Convert propulsion system to human-readable label.""" propulsion_labels = { 'CHAIN': 'Chain Lift', 'LSM': 'Linear Synchronous Motor', 'LIM': 'Linear Induction Motor', 'HYDRAULIC': 'Hydraulic Launch', 'PNEUMATIC': 'Pneumatic Launch', 'CABLE': 'Cable Lift', 'FLYWHEEL': 'Flywheel Launch', 'GRAVITY': 'Gravity', 'NONE': 'No Propulsion System', } if propulsion_system in propulsion_labels: return propulsion_labels[propulsion_system] else: raise ValueError(f"Unknown propulsion system: {propulsion_system}")