feat: Implement MFA authentication, add ride statistics model, and update various services, APIs, and tests across the application.

This commit is contained in:
pacnpal
2025-12-28 17:32:53 -05:00
parent aa56c46c27
commit c95f99ca10
452 changed files with 7948 additions and 6073 deletions

View File

@@ -1,7 +1,7 @@
from .location_service import RideLocationService
from .media_service import RideMediaService
# Import from the services_core module (was services.py, renamed to avoid package collision)
from ..services_core import RideService
from .location_service import RideLocationService
from .media_service import RideMediaService
__all__ = ["RideLocationService", "RideMediaService", "RideService"]

View File

@@ -17,11 +17,12 @@ Architecture:
- Hybrid: Combine both approaches based on data characteristics
"""
from typing import Dict, List, Any, Optional
import logging
from typing import Any
from django.core.cache import cache
from django.db import models
from django.db.models import Q, Min, Max
import logging
from django.db.models import Max, Min, Q
logger = logging.getLogger(__name__)
@@ -29,34 +30,34 @@ 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]:
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'
@@ -65,63 +66,63 @@ class SmartRideLoader:
- 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]:
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: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
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
@@ -131,31 +132,31 @@ class SmartRideLoader:
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:
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: Optional[Dict[str, Any]],
total_count: int) -> Dict[str, Any]:
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',
@@ -168,11 +169,11 @@ class SmartRideLoader:
).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,
@@ -180,16 +181,16 @@ class SmartRideLoader:
'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]:
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),
@@ -197,11 +198,11 @@ class SmartRideLoader:
'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]]):
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',
@@ -214,115 +215,115 @@ class SmartRideLoader:
).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']:
@@ -331,13 +332,13 @@ class SmartRideLoader:
queryset = queryset.order_by(ordering_field)
else:
queryset = queryset.order_by(ordering)
return queryset
def _serialize_rides(self, rides: List) -> List[Dict[str, Any]]:
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 = {
@@ -360,7 +361,7 @@ class SmartRideLoader:
'created_at': ride.created_at.isoformat(),
'updated_at': ride.updated_at.isoformat(),
}
# Park data
if ride.park:
ride_data['park'] = {
@@ -368,7 +369,7 @@ class SmartRideLoader:
'name': ride.park.name,
'slug': ride.park.slug,
}
# Park location data
if hasattr(ride.park, 'location') and ride.park.location:
ride_data['park']['location'] = {
@@ -376,7 +377,7 @@ class SmartRideLoader:
'state': ride.park.location.state,
'country': ride.park.location.country,
}
# Park area data
if ride.park_area:
ride_data['park_area'] = {
@@ -384,7 +385,7 @@ class SmartRideLoader:
'name': ride.park_area.name,
'slug': ride.park_area.slug,
}
# Company data
if ride.manufacturer:
ride_data['manufacturer'] = {
@@ -392,14 +393,14 @@ class SmartRideLoader:
'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'] = {
@@ -408,14 +409,14 @@ class SmartRideLoader:
'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
@@ -435,70 +436,70 @@ class SmartRideLoader:
'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]:
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 = [
{
@@ -508,7 +509,7 @@ class SmartRideLoader:
}
for item in categories_data
]
statuses = [
{
'value': item['status'],
@@ -517,7 +518,7 @@ class SmartRideLoader:
}
for item in statuses_data
]
roller_coaster_types = [
{
'value': item['roller_coaster_type'],
@@ -526,7 +527,7 @@ class SmartRideLoader:
}
for item in rc_types_data
]
track_materials = [
{
'value': item['track_material'],
@@ -535,7 +536,7 @@ class SmartRideLoader:
}
for item in track_materials_data
]
propulsion_systems = [
{
'value': item['propulsion_system'],
@@ -544,7 +545,7 @@ class SmartRideLoader:
}
for item in propulsion_systems_data
]
# Convert other data to expected format
parks = [
{
@@ -554,7 +555,7 @@ class SmartRideLoader:
}
for item in parks_data
]
park_areas = [
{
'value': str(item['park_area__id']),
@@ -563,7 +564,7 @@ class SmartRideLoader:
}
for item in park_areas_data
]
manufacturers = [
{
'value': str(item['id']),
@@ -572,7 +573,7 @@ class SmartRideLoader:
}
for item in manufacturers_data
]
designers = [
{
'value': str(item['id']),
@@ -581,7 +582,7 @@ class SmartRideLoader:
}
for item in designers_data
]
ride_models = [
{
'value': str(item['id']),
@@ -590,7 +591,7 @@ class SmartRideLoader:
}
for item in ride_models_data
]
# Calculate ranges from actual data
ride_stats = Ride.objects.aggregate(
min_rating=Min('average_rating'),
@@ -604,7 +605,7 @@ class SmartRideLoader:
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'),
@@ -626,7 +627,7 @@ class SmartRideLoader:
min_seats=Min('seats_per_car'),
max_seats=Max('seats_per_car'),
)
return {
'categorical': {
'categories': categories,
@@ -698,7 +699,7 @@ class SmartRideLoader:
},
'total_count': Ride.objects.count(),
}
def _get_category_label(self, category: str) -> str:
"""Convert category code to human-readable label."""
category_labels = {
@@ -713,7 +714,7 @@ class SmartRideLoader:
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 = {
@@ -730,7 +731,7 @@ class SmartRideLoader:
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 = {
@@ -752,7 +753,7 @@ class SmartRideLoader:
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 = {
@@ -764,7 +765,7 @@ class SmartRideLoader:
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 = {

View File

@@ -3,10 +3,11 @@ Rides-specific location services with OpenStreetMap integration.
Handles location management for individual rides within parks.
"""
import requests
from typing import List, Dict, Any, Optional
from django.db import transaction
import logging
from typing import Any
import requests
from django.db import transaction
from ..models import RideLocation
@@ -27,8 +28,8 @@ class RideLocationService:
cls,
*,
ride,
latitude: Optional[float] = None,
longitude: Optional[float] = None,
latitude: float | None = None,
longitude: float | None = None,
park_area: str = "",
notes: str = "",
entrance_notes: str = "",
@@ -101,7 +102,7 @@ class RideLocationService:
return ride_location
@classmethod
def find_rides_in_area(cls, park, park_area: str) -> List[RideLocation]:
def find_rides_in_area(cls, park, park_area: str) -> list[RideLocation]:
"""
Find all rides in a specific park area.
@@ -121,7 +122,7 @@ class RideLocationService:
@classmethod
def find_nearby_rides(
cls, latitude: float, longitude: float, park=None, radius_meters: float = 500
) -> List[RideLocation]:
) -> list[RideLocation]:
"""
Find rides near given coordinates using PostGIS.
Useful for finding rides near a specific location within a park.
@@ -153,7 +154,7 @@ class RideLocationService:
)
@classmethod
def get_ride_navigation_info(cls, ride_location: RideLocation) -> Dict[str, Any]:
def get_ride_navigation_info(cls, ride_location: RideLocation) -> dict[str, Any]:
"""
Get comprehensive navigation information for a ride.
@@ -196,8 +197,8 @@ class RideLocationService:
def estimate_ride_coordinates_from_park(
cls,
ride_location: RideLocation,
area_offset_meters: Optional[Dict[str, List[float]]] = None,
) -> Optional[List[float]]:
area_offset_meters: dict[str, list[float]] | None = None,
) -> list[float] | None:
"""
Estimate ride coordinates based on park location and area.
Useful when exact ride coordinates are not available.
@@ -332,7 +333,7 @@ class RideLocationService:
return updated_count
@classmethod
def generate_park_area_map(cls, park) -> Dict[str, List[str]]:
def generate_park_area_map(cls, park) -> dict[str, list[str]]:
"""
Generate a map of park areas and the rides in each area.

View File

@@ -5,11 +5,14 @@ This module provides media management functionality specific to rides.
"""
import logging
from typing import List, Optional, Dict, Any
from typing import Any
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import UploadedFile
from django.db import transaction
from django.contrib.auth import get_user_model
from apps.core.services.media_service import MediaService
from ..models import Ride, RidePhoto
User = get_user_model()
@@ -83,8 +86,8 @@ class RideMediaService:
ride: Ride,
approved_only: bool = True,
primary_first: bool = True,
photo_type: Optional[str] = None,
) -> List[RidePhoto]:
photo_type: str | None = None,
) -> list[RidePhoto]:
"""
Get photos for a ride.
@@ -113,7 +116,7 @@ class RideMediaService:
return list(queryset)
@staticmethod
def get_primary_photo(ride: Ride) -> Optional[RidePhoto]:
def get_primary_photo(ride: Ride) -> RidePhoto | None:
"""
Get the primary photo for a ride.
@@ -129,7 +132,7 @@ class RideMediaService:
return None
@staticmethod
def get_photos_by_type(ride: Ride, photo_type: str) -> List[RidePhoto]:
def get_photos_by_type(ride: Ride, photo_type: str) -> list[RidePhoto]:
"""
Get photos of a specific type for a ride.
@@ -224,7 +227,7 @@ class RideMediaService:
return False
@staticmethod
def get_photo_stats(ride: Ride) -> Dict[str, Any]:
def get_photo_stats(ride: Ride) -> dict[str, Any]:
"""
Get photo statistics for a ride.
@@ -251,7 +254,7 @@ class RideMediaService:
}
@staticmethod
def bulk_approve_photos(photos: List[RidePhoto], approved_by: User) -> int:
def bulk_approve_photos(photos: list[RidePhoto], approved_by: User) -> int:
"""
Bulk approve multiple photos.
@@ -275,7 +278,7 @@ class RideMediaService:
return approved_count
@staticmethod
def get_construction_timeline(ride: Ride) -> List[RidePhoto]:
def get_construction_timeline(ride: Ride) -> list[RidePhoto]:
"""
Get construction photos ordered chronologically.
@@ -292,7 +295,7 @@ class RideMediaService:
)
@staticmethod
def get_onride_photos(ride: Ride) -> List[RidePhoto]:
def get_onride_photos(ride: Ride) -> list[RidePhoto]:
"""
Get on-ride photos for a ride.

View File

@@ -7,23 +7,21 @@ Rankings are determined by winning percentage in these comparisons.
"""
import logging
from typing import Dict, List, Optional
from decimal import Decimal
from datetime import date
from decimal import Decimal
from django.db import transaction
from django.db.models import Avg, Count, Q
from django.utils import timezone
from apps.rides.models import (
Ride,
RideReview,
RideRanking,
RidePairComparison,
RankingSnapshot,
Ride,
RidePairComparison,
RideRanking,
RideReview,
)
logger = logging.getLogger(__name__)
@@ -43,7 +41,7 @@ class RideRankingService:
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
self.calculation_version = "1.0"
def update_all_rankings(self, category: Optional[str] = None) -> Dict[str, any]:
def update_all_rankings(self, category: str | None = None) -> dict[str, any]:
"""
Main entry point to update all ride rankings.
@@ -105,7 +103,7 @@ class RideRankingService:
self.logger.error(f"Error updating rankings: {e}", exc_info=True)
raise
def _get_eligible_rides(self, category: Optional[str] = None) -> List[Ride]:
def _get_eligible_rides(self, category: str | None = None) -> list[Ride]:
"""
Get rides that are eligible for ranking.
@@ -127,8 +125,8 @@ class RideRankingService:
return list(queryset.distinct())
def _calculate_all_comparisons(
self, rides: List[Ride]
) -> Dict[tuple[int, int], RidePairComparison]:
self, rides: list[Ride]
) -> dict[tuple[int, int], RidePairComparison]:
"""
Calculate pairwise comparisons for all ride pairs.
@@ -156,7 +154,7 @@ class RideRankingService:
def _calculate_pairwise_comparison(
self, ride_a: Ride, ride_b: Ride
) -> Optional[RidePairComparison]:
) -> RidePairComparison | None:
"""
Calculate the pairwise comparison between two rides.
@@ -246,8 +244,8 @@ class RideRankingService:
return comparison
def _calculate_rankings_from_comparisons(
self, rides: List[Ride], comparisons: Dict[tuple[int, int], RidePairComparison]
) -> List[Dict]:
self, rides: list[Ride], comparisons: dict[tuple[int, int], RidePairComparison]
) -> list[dict]:
"""
Calculate final rankings from pairwise comparisons.
@@ -343,9 +341,9 @@ class RideRankingService:
def _apply_tiebreakers(
self,
rankings: List[Dict],
comparisons: Dict[tuple[int, int], RidePairComparison],
) -> List[Dict]:
rankings: list[dict],
comparisons: dict[tuple[int, int], RidePairComparison],
) -> list[dict]:
"""
Apply head-to-head tiebreaker for rides with identical winning percentages.
@@ -379,9 +377,9 @@ class RideRankingService:
def _sort_tied_group(
self,
tied_group: List[Dict],
comparisons: Dict[tuple[int, int], RidePairComparison],
) -> List[Dict]:
tied_group: list[dict],
comparisons: dict[tuple[int, int], RidePairComparison],
) -> list[dict]:
"""
Sort a group of tied rides using head-to-head comparisons.
"""
@@ -426,7 +424,7 @@ class RideRankingService:
return tied_group
def _save_rankings(self, rankings: List[Dict]):
def _save_rankings(self, rankings: list[dict]):
"""Save calculated rankings to the database."""
for ranking_data in rankings:
RideRanking.objects.update_or_create(
@@ -445,7 +443,7 @@ class RideRankingService:
},
)
def _save_ranking_snapshots(self, rankings: List[Dict]):
def _save_ranking_snapshots(self, rankings: list[dict]):
"""Save ranking snapshots for historical tracking."""
today = date.today()
@@ -471,7 +469,7 @@ class RideRankingService:
if deleted_snapshots[0] > 0:
self.logger.info(f"Deleted {deleted_snapshots[0]} old ranking snapshots")
def get_ride_ranking_details(self, ride: Ride) -> Optional[Dict]:
def get_ride_ranking_details(self, ride: Ride) -> dict | None:
"""
Get detailed ranking information for a specific ride.

View File

@@ -9,19 +9,20 @@ This service implements the filtering design specified in:
backend/docs/ride_filtering_design.md
"""
from typing import Any
from django.contrib.postgres.search import (
SearchVector,
SearchQuery,
SearchRank,
SearchVector,
TrigramSimilarity,
)
from django.db import models
from django.db.models import Q, F, Value
from django.db.models import F, Q, Value
from django.db.models.functions import Greatest
from typing import Dict, List, Optional, Any
from apps.rides.models import Ride
from apps.parks.models import Park
from apps.rides.models import Ride
from apps.rides.models.company import Company
@@ -104,11 +105,11 @@ class RideSearchService:
def search_and_filter(
self,
filters: Dict[str, Any],
filters: dict[str, Any],
sort_by: str = "relevance",
page: int = 1,
page_size: int = 20,
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""
Main search and filter method that combines all capabilities.
@@ -219,7 +220,7 @@ class RideSearchService:
return queryset, final_rank
def _apply_basic_info_filters(
self, queryset, filters: Dict[str, Any]
self, queryset, filters: dict[str, Any]
) -> models.QuerySet:
"""Apply basic information filters."""
@@ -267,7 +268,7 @@ class RideSearchService:
return queryset
def _apply_date_filters(self, queryset, filters: Dict[str, Any]) -> models.QuerySet:
def _apply_date_filters(self, queryset, filters: dict[str, Any]) -> models.QuerySet:
"""Apply date range filters."""
# Opening date range
@@ -297,7 +298,7 @@ class RideSearchService:
return queryset
def _apply_height_safety_filters(
self, queryset, filters: Dict[str, Any]
self, queryset, filters: dict[str, Any]
) -> models.QuerySet:
"""Apply height and safety requirement filters."""
@@ -320,7 +321,7 @@ class RideSearchService:
return queryset
def _apply_performance_filters(
self, queryset, filters: Dict[str, Any]
self, queryset, filters: dict[str, Any]
) -> models.QuerySet:
"""Apply performance metric filters."""
@@ -355,7 +356,7 @@ class RideSearchService:
return queryset
def _apply_relationship_filters(
self, queryset, filters: Dict[str, Any]
self, queryset, filters: dict[str, Any]
) -> models.QuerySet:
"""Apply relationship filters (manufacturer, designer, ride model)."""
@@ -398,7 +399,7 @@ class RideSearchService:
return queryset
def _apply_roller_coaster_filters(
self, queryset, filters: Dict[str, Any]
self, queryset, filters: dict[str, Any]
) -> models.QuerySet:
"""Apply roller coaster specific filters."""
queryset = self._apply_numeric_range_filter(
@@ -448,7 +449,7 @@ class RideSearchService:
def _apply_numeric_range_filter(
self,
queryset,
filters: Dict[str, Any],
filters: dict[str, Any],
filter_key: str,
field_name: str,
) -> models.QuerySet:
@@ -466,7 +467,7 @@ class RideSearchService:
return queryset
def _apply_company_filters(
self, queryset, filters: Dict[str, Any]
self, queryset, filters: dict[str, Any]
) -> models.QuerySet:
"""Apply company-related filters."""
@@ -522,8 +523,8 @@ class RideSearchService:
) # Always add name as secondary sort
def _add_search_highlights(
self, results: List[Ride], search_term: str
) -> List[Ride]:
self, results: list[Ride], search_term: str
) -> list[Ride]:
"""Add search highlights to results using SearchHeadline."""
if not search_term or not results:
@@ -536,12 +537,12 @@ class RideSearchService:
# (note: highlights would need to be processed at query time)
for ride in results:
# Store highlighted versions as dynamic attributes (for template use)
setattr(ride, "highlighted_name", ride.name)
setattr(ride, "highlighted_description", ride.description)
ride.highlighted_name = ride.name
ride.highlighted_description = ride.description
return results
def _get_applied_filters_summary(self, filters: Dict[str, Any]) -> Dict[str, Any]:
def _get_applied_filters_summary(self, filters: dict[str, Any]) -> dict[str, Any]:
"""Generate a summary of applied filters for the frontend."""
applied = {}
@@ -602,7 +603,7 @@ class RideSearchService:
def get_search_suggestions(
self, query: str, limit: int = 10
) -> List[Dict[str, Any]]:
) -> list[dict[str, Any]]:
"""
Get search suggestions for autocomplete functionality.
"""
@@ -672,8 +673,8 @@ class RideSearchService:
return suggestions[:limit]
def get_filter_options(
self, filter_type: str, context_filters: Optional[Dict[str, Any]] = None
) -> List[Dict[str, Any]]:
self, filter_type: str, context_filters: dict[str, Any] | None = None
) -> list[dict[str, Any]]:
"""
Get available options for a specific filter type.
Optionally filter options based on current context.
@@ -716,7 +717,7 @@ class RideSearchService:
# Add more filter options as needed
return []
def _apply_all_filters(self, queryset, filters: Dict[str, Any]) -> models.QuerySet:
def _apply_all_filters(self, queryset, filters: dict[str, Any]) -> models.QuerySet:
"""Apply all filters except search ranking."""
queryset = self._apply_basic_info_filters(queryset, filters)

View File

@@ -3,9 +3,9 @@ Services for ride status transitions and management.
Following Django styleguide pattern for business logic encapsulation.
"""
from typing import Optional
from django.db import transaction
from django.contrib.auth.models import AbstractBaseUser
from django.db import transaction
from apps.rides.models import Ride
@@ -14,7 +14,7 @@ class RideStatusService:
"""Service for managing ride status transitions using FSM."""
@staticmethod
def open_ride(*, ride_id: int, user: Optional[AbstractBaseUser] = None) -> Ride:
def open_ride(*, ride_id: int, user: AbstractBaseUser | None = None) -> Ride:
"""
Open a ride for operation.
@@ -35,7 +35,7 @@ class RideStatusService:
@staticmethod
def close_ride_temporarily(
*, ride_id: int, user: Optional[AbstractBaseUser] = None
*, ride_id: int, user: AbstractBaseUser | None = None
) -> Ride:
"""
Temporarily close a ride.
@@ -57,7 +57,7 @@ class RideStatusService:
@staticmethod
def mark_ride_sbno(
*, ride_id: int, user: Optional[AbstractBaseUser] = None
*, ride_id: int, user: AbstractBaseUser | None = None
) -> Ride:
"""
Mark a ride as SBNO (Standing But Not Operating).
@@ -83,7 +83,7 @@ class RideStatusService:
ride_id: int,
closing_date,
post_closing_status: str,
user: Optional[AbstractBaseUser] = None,
user: AbstractBaseUser | None = None,
) -> Ride:
"""
Mark a ride as closing with a specific date and post-closing status.
@@ -112,7 +112,7 @@ class RideStatusService:
@staticmethod
def close_ride_permanently(
*, ride_id: int, user: Optional[AbstractBaseUser] = None
*, ride_id: int, user: AbstractBaseUser | None = None
) -> Ride:
"""
Permanently close a ride.
@@ -133,7 +133,7 @@ class RideStatusService:
return ride
@staticmethod
def demolish_ride(*, ride_id: int, user: Optional[AbstractBaseUser] = None) -> Ride:
def demolish_ride(*, ride_id: int, user: AbstractBaseUser | None = None) -> Ride:
"""
Mark a ride as demolished.
@@ -153,7 +153,7 @@ class RideStatusService:
return ride
@staticmethod
def relocate_ride(*, ride_id: int, user: Optional[AbstractBaseUser] = None) -> Ride:
def relocate_ride(*, ride_id: int, user: AbstractBaseUser | None = None) -> Ride:
"""
Mark a ride as relocated.