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