""" 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 from django.core.cache import cache from django.db import models from django.db.models import Q, Min, Max 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 """ # 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 """ # 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 '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: 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')) 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}")