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,8 +1,8 @@
from .roadtrip import RoadTripService
from .park_management import ParkService
from .location_service import ParkLocationService
from .filter_service import ParkFilterService
from .location_service import ParkLocationService
from .media_service import ParkMediaService
from .park_management import ParkService
from .roadtrip import RoadTripService
__all__ = [
"RoadTripService",

View File

@@ -5,11 +5,13 @@ Provides filtering functionality, aggregations, and caching for park filters.
This service handles complex filter logic and provides useful filter statistics.
"""
from typing import Dict, List, Any, Optional
from django.db.models import QuerySet, Count, Q
from django.core.cache import cache
from typing import Any
from django.conf import settings
from ..models import Park, Company
from django.core.cache import cache
from django.db.models import Count, Q, QuerySet
from ..models import Company, Park
from ..querysets import get_base_park_queryset
@@ -25,8 +27,8 @@ class ParkFilterService:
self.cache_prefix = "park_filter"
def get_filter_counts(
self, base_queryset: Optional[QuerySet] = None
) -> Dict[str, Any]:
self, base_queryset: QuerySet | None = None
) -> dict[str, Any]:
"""
Get counts for various filter options to show users what's available.
@@ -61,7 +63,7 @@ class ParkFilterService:
cache.set(cache_key, filter_counts, self.CACHE_TIMEOUT)
return filter_counts
def _get_park_type_counts(self, queryset: QuerySet) -> Dict[str, int]:
def _get_park_type_counts(self, queryset: QuerySet) -> dict[str, int]:
"""Get counts for different park types based on operator names."""
return {
"disney": queryset.filter(operator__name__icontains="Disney").count(),
@@ -76,7 +78,7 @@ class ParkFilterService:
def _get_top_operators(
self, queryset: QuerySet, limit: int = 10
) -> List[Dict[str, Any]]:
) -> list[dict[str, Any]]:
"""Get the top operators by number of parks."""
return list(
queryset.values("operator__name", "operator__id")
@@ -87,7 +89,7 @@ class ParkFilterService:
def _get_country_counts(
self, queryset: QuerySet, limit: int = 10
) -> List[Dict[str, Any]]:
) -> list[dict[str, Any]]:
"""Get countries with the most parks."""
return list(
queryset.filter(location__country__isnull=False)
@@ -97,7 +99,7 @@ class ParkFilterService:
.order_by("-park_count")[:limit]
)
def get_filter_suggestions(self, query: str) -> Dict[str, List[str]]:
def get_filter_suggestions(self, query: str) -> dict[str, list[str]]:
"""
Get filter suggestions based on a search query.
@@ -151,7 +153,7 @@ class ParkFilterService:
cache.set(cache_key, suggestions, 60) # 1 minute cache
return suggestions
def get_popular_filters(self) -> Dict[str, Any]:
def get_popular_filters(self) -> dict[str, Any]:
"""
Get commonly used filter combinations and popular filter values.
@@ -210,7 +212,7 @@ class ParkFilterService:
for key in cache_keys:
cache.delete(key)
def get_filtered_queryset(self, filters: Dict[str, Any]) -> QuerySet: # noqa: C901
def get_filtered_queryset(self, filters: dict[str, Any]) -> QuerySet: # noqa: C901
"""
Apply filters to get a filtered queryset with optimizations.

View File

@@ -5,10 +5,12 @@ This module provides intelligent data loading capabilities for the hybrid filter
optimizing database queries and implementing progressive loading strategies.
"""
from typing import Dict, Optional, Any
from django.db import models
from django.core.cache import cache
from typing import Any
from django.conf import settings
from django.core.cache import cache
from django.db import models
from apps.parks.models import Park
@@ -17,19 +19,19 @@ class SmartParkLoader:
Intelligent park data loader that optimizes queries based on filtering requirements.
Implements progressive loading and smart caching strategies.
"""
# Cache configuration
CACHE_TIMEOUT = getattr(settings, 'HYBRID_FILTER_CACHE_TIMEOUT', 300) # 5 minutes
CACHE_KEY_PREFIX = 'hybrid_parks'
# Progressive loading thresholds
INITIAL_LOAD_SIZE = 50
PROGRESSIVE_LOAD_SIZE = 25
MAX_CLIENT_SIDE_RECORDS = 200
def __init__(self):
self.base_queryset = self._get_optimized_queryset()
def _get_optimized_queryset(self) -> models.QuerySet:
"""Get optimized base queryset with all necessary prefetches."""
return Park.objects.select_related(
@@ -43,31 +45,31 @@ class SmartParkLoader:
# Only include operating and temporarily closed parks by default
status__in=['OPERATING', 'CLOSED_TEMP']
).order_by('name')
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 park data load with smart filtering decisions.
Args:
filters: Optional filters to apply
Returns:
Dictionary containing parks data and metadata
"""
cache_key = self._generate_cache_key('initial', filters)
cached_result = cache.get(cache_key)
if cached_result:
return cached_result
# Apply filters if provided
queryset = self.base_queryset
if filters:
queryset = self._apply_filters(queryset, filters)
# Get total count for pagination decisions
total_count = queryset.count()
# Determine loading strategy
if total_count <= self.MAX_CLIENT_SIDE_RECORDS:
# Load all data for client-side filtering
@@ -79,7 +81,7 @@ class SmartParkLoader:
parks = list(queryset[:self.INITIAL_LOAD_SIZE])
strategy = 'server_side'
has_more = total_count > self.INITIAL_LOAD_SIZE
result = {
'parks': parks,
'total_count': total_count,
@@ -88,163 +90,163 @@ class SmartParkLoader:
'next_offset': len(parks) if has_more else None,
'filter_metadata': self._get_filter_metadata(queryset),
}
# Cache the result
cache.set(cache_key, result, self.CACHE_TIMEOUT)
return result
def get_progressive_load(
self,
offset: int,
filters: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
self,
offset: int,
filters: dict[str, Any] | None = None
) -> dict[str, Any]:
"""
Get next batch of parks for progressive loading.
Args:
offset: Starting offset for the batch
filters: Optional filters to apply
Returns:
Dictionary containing parks data and metadata
"""
cache_key = self._generate_cache_key(f'progressive_{offset}', filters)
cached_result = cache.get(cache_key)
if cached_result:
return cached_result
# Apply filters if provided
queryset = self.base_queryset
if filters:
queryset = self._apply_filters(queryset, filters)
# Get the batch
end_offset = offset + self.PROGRESSIVE_LOAD_SIZE
parks = list(queryset[offset:end_offset])
# Check if there are more records
total_count = queryset.count()
has_more = end_offset < total_count
result = {
'parks': parks,
'total_count': total_count,
'has_more': has_more,
'next_offset': end_offset if has_more else None,
}
# Cache the result
cache.set(cache_key, result, self.CACHE_TIMEOUT)
return result
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 metadata about available filter options.
Args:
filters: Current filters to scope the metadata
Returns:
Dictionary containing filter metadata
"""
cache_key = self._generate_cache_key('metadata', filters)
cached_result = cache.get(cache_key)
if cached_result:
return cached_result
# Apply filters if provided
queryset = self.base_queryset
if filters:
queryset = self._apply_filters(queryset, filters)
result = self._get_filter_metadata(queryset)
# Cache the result
cache.set(cache_key, result, self.CACHE_TIMEOUT)
return result
def _apply_filters(self, queryset: models.QuerySet, filters: Dict[str, Any]) -> models.QuerySet:
def _apply_filters(self, queryset: models.QuerySet, filters: dict[str, Any]) -> models.QuerySet:
"""Apply filters to the queryset."""
# Status filter
if 'status' in filters and filters['status']:
if isinstance(filters['status'], list):
queryset = queryset.filter(status__in=filters['status'])
else:
queryset = queryset.filter(status=filters['status'])
# Park type filter
if 'park_type' in filters and filters['park_type']:
if isinstance(filters['park_type'], list):
queryset = queryset.filter(park_type__in=filters['park_type'])
else:
queryset = queryset.filter(park_type=filters['park_type'])
# Country filter
if 'country' in filters and filters['country']:
queryset = queryset.filter(location__country__in=filters['country'])
# State filter
if 'state' in filters and filters['state']:
queryset = queryset.filter(location__state__in=filters['state'])
# Opening year range
if 'opening_year_min' in filters and filters['opening_year_min']:
queryset = queryset.filter(opening_year__gte=filters['opening_year_min'])
if 'opening_year_max' in filters and filters['opening_year_max']:
queryset = queryset.filter(opening_year__lte=filters['opening_year_max'])
# Size range
if 'size_min' in filters and filters['size_min']:
queryset = queryset.filter(size_acres__gte=filters['size_min'])
if 'size_max' in filters and filters['size_max']:
queryset = queryset.filter(size_acres__lte=filters['size_max'])
# Rating range
if 'rating_min' in filters and filters['rating_min']:
queryset = queryset.filter(average_rating__gte=filters['rating_min'])
if 'rating_max' in filters and filters['rating_max']:
queryset = queryset.filter(average_rating__lte=filters['rating_max'])
# Ride count range
if 'ride_count_min' in filters and filters['ride_count_min']:
queryset = queryset.filter(ride_count__gte=filters['ride_count_min'])
if 'ride_count_max' in filters and filters['ride_count_max']:
queryset = queryset.filter(ride_count__lte=filters['ride_count_max'])
# Coaster count range
if 'coaster_count_min' in filters and filters['coaster_count_min']:
queryset = queryset.filter(coaster_count__gte=filters['coaster_count_min'])
if 'coaster_count_max' in filters and filters['coaster_count_max']:
queryset = queryset.filter(coaster_count__lte=filters['coaster_count_max'])
# Operator filter
if 'operator' in filters and filters['operator']:
if isinstance(filters['operator'], list):
queryset = queryset.filter(operator__slug__in=filters['operator'])
else:
queryset = queryset.filter(operator__slug=filters['operator'])
# Search query
if 'search' in filters and filters['search']:
search_term = filters['search'].lower()
queryset = queryset.filter(search_text__icontains=search_term)
return queryset
def _get_filter_metadata(self, queryset: models.QuerySet) -> Dict[str, Any]:
def _get_filter_metadata(self, queryset: models.QuerySet) -> dict[str, Any]:
"""Generate filter metadata from the current queryset."""
# Get distinct values for categorical filters with counts
countries_data = list(
queryset.values('location__country')
@@ -252,27 +254,27 @@ class SmartParkLoader:
.annotate(count=models.Count('id'))
.order_by('location__country')
)
states_data = list(
queryset.values('location__state')
.exclude(location__state__isnull=True)
.annotate(count=models.Count('id'))
.order_by('location__state')
)
park_types_data = list(
queryset.values('park_type')
.exclude(park_type__isnull=True)
.annotate(count=models.Count('id'))
.order_by('park_type')
)
statuses_data = list(
queryset.values('status')
.annotate(count=models.Count('id'))
.order_by('status')
)
operators_data = list(
queryset.select_related('operator')
.values('operator__id', 'operator__name', 'operator__slug')
@@ -280,7 +282,7 @@ class SmartParkLoader:
.annotate(count=models.Count('id'))
.order_by('operator__name')
)
# Convert to frontend-expected format with value/label/count
countries = [
{
@@ -290,7 +292,7 @@ class SmartParkLoader:
}
for item in countries_data
]
states = [
{
'value': item['location__state'],
@@ -299,7 +301,7 @@ class SmartParkLoader:
}
for item in states_data
]
park_types = [
{
'value': item['park_type'],
@@ -308,7 +310,7 @@ class SmartParkLoader:
}
for item in park_types_data
]
statuses = [
{
'value': item['status'],
@@ -317,7 +319,7 @@ class SmartParkLoader:
}
for item in statuses_data
]
operators = [
{
'value': item['operator__slug'],
@@ -326,7 +328,7 @@ class SmartParkLoader:
}
for item in operators_data
]
# Get ranges for numerical filters
aggregates = queryset.aggregate(
opening_year_min=models.Min('opening_year'),
@@ -340,7 +342,7 @@ class SmartParkLoader:
coaster_count_min=models.Min('coaster_count'),
coaster_count_max=models.Max('coaster_count'),
)
return {
'categorical': {
'countries': countries,
@@ -383,7 +385,7 @@ class SmartParkLoader:
},
'total_count': queryset.count(),
}
def _get_status_label(self, status: str) -> str:
"""Convert status code to human-readable label."""
status_labels = {
@@ -396,19 +398,19 @@ class SmartParkLoader:
return status_labels[status]
else:
raise ValueError(f"Unknown park status: {status}")
def _generate_cache_key(self, operation: str, filters: Optional[Dict[str, Any]] = None) -> str:
def _generate_cache_key(self, operation: str, filters: dict[str, Any] | None = None) -> str:
"""Generate cache key for the given operation and filters."""
key_parts = [self.CACHE_KEY_PREFIX, operation]
if filters:
# Create a consistent string representation of filters
filter_str = '_'.join(f"{k}:{v}" for k, v in sorted(filters.items()) if v)
key_parts.append(filter_str)
return '_'.join(key_parts)
def invalidate_cache(self, filters: Optional[Dict[str, Any]] = None) -> None:
def invalidate_cache(self, filters: dict[str, Any] | None = None) -> None:
"""Invalidate cached data for the given filters."""
# This is a simplified implementation
# In production, you might want to use cache versioning or tags
@@ -416,11 +418,11 @@ class SmartParkLoader:
self._generate_cache_key('initial', filters),
self._generate_cache_key('metadata', filters),
]
# Also invalidate progressive load caches
for offset in range(0, 1000, self.PROGRESSIVE_LOAD_SIZE):
cache_keys.append(self._generate_cache_key(f'progressive_{offset}', filters))
cache.delete_many(cache_keys)

View File

@@ -3,11 +3,12 @@ Parks-specific location services with OpenStreetMap integration.
Handles geocoding, reverse geocoding, and location search for parks.
"""
import logging
from typing import Any
import requests
from typing import List, Dict, Any, Optional
from django.core.cache import cache
from django.db import transaction
import logging
from ..models import ParkLocation
@@ -23,7 +24,7 @@ class ParkLocationService:
USER_AGENT = "ThrillWiki/1.0 (https://thrillwiki.com)"
@classmethod
def search_locations(cls, query: str, limit: int = 10) -> Dict[str, Any]:
def search_locations(cls, query: str, limit: int = 10) -> dict[str, Any]:
"""
Search for locations using OpenStreetMap Nominatim API.
Optimized for finding theme parks and amusement parks.
@@ -98,7 +99,7 @@ class ParkLocationService:
}
@classmethod
def reverse_geocode(cls, latitude: float, longitude: float) -> Dict[str, Any]:
def reverse_geocode(cls, latitude: float, longitude: float) -> dict[str, Any]:
"""
Reverse geocode coordinates to get location information using OSM.
@@ -159,7 +160,7 @@ class ParkLocationService:
return {"error": "Reverse geocoding service temporarily unavailable"}
@classmethod
def geocode_address(cls, address: str) -> Dict[str, Any]:
def geocode_address(cls, address: str) -> dict[str, Any]:
"""
Geocode an address to get coordinates using OSM.
@@ -185,8 +186,8 @@ class ParkLocationService:
cls,
*,
park,
latitude: Optional[float] = None,
longitude: Optional[float] = None,
latitude: float | None = None,
longitude: float | None = None,
street_address: str = "",
city: str = "",
state: str = "",
@@ -195,7 +196,7 @@ class ParkLocationService:
highway_exit: str = "",
parking_notes: str = "",
seasonal_notes: str = "",
osm_id: Optional[int] = None,
osm_id: int | None = None,
osm_type: str = "",
) -> ParkLocation:
"""
@@ -279,7 +280,7 @@ class ParkLocationService:
@classmethod
def find_nearby_parks(
cls, latitude: float, longitude: float, radius_km: float = 50
) -> List[ParkLocation]:
) -> list[ParkLocation]:
"""
Find parks near given coordinates using PostGIS.
@@ -349,8 +350,8 @@ class ParkLocationService:
@classmethod
def _transform_osm_result(
cls, osm_item: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
cls, osm_item: dict[str, Any]
) -> dict[str, Any] | None:
"""Transform OSM search result to our standard format."""
try:
address = osm_item.get("address", {})
@@ -432,8 +433,8 @@ class ParkLocationService:
@classmethod
def _transform_osm_reverse_result(
cls, osm_result: Dict[str, Any]
) -> Dict[str, Any]:
cls, osm_result: dict[str, Any]
) -> dict[str, Any]:
"""Transform OSM reverse geocoding result to our standard format."""
address = osm_result.get("address", {})

View File

@@ -5,11 +5,14 @@ This module provides media management functionality specific to parks.
"""
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 Park, ParkPhoto
User = get_user_model()
@@ -78,7 +81,7 @@ class ParkMediaService:
@staticmethod
def get_park_photos(
park: Park, approved_only: bool = True, primary_first: bool = True
) -> List[ParkPhoto]:
) -> list[ParkPhoto]:
"""
Get photos for a park.
@@ -103,7 +106,7 @@ class ParkMediaService:
return list(queryset)
@staticmethod
def get_primary_photo(park: Park) -> Optional[ParkPhoto]:
def get_primary_photo(park: Park) -> ParkPhoto | None:
"""
Get the primary photo for a park.
@@ -196,7 +199,7 @@ class ParkMediaService:
return False
@staticmethod
def get_photo_stats(park: Park) -> Dict[str, Any]:
def get_photo_stats(park: Park) -> dict[str, Any]:
"""
Get photo statistics for a park.
@@ -217,7 +220,7 @@ class ParkMediaService:
}
@staticmethod
def bulk_approve_photos(photos: List[ParkPhoto], approved_by: User) -> int:
def bulk_approve_photos(photos: list[ParkPhoto], approved_by: User) -> int:
"""
Bulk approve multiple photos.

View File

@@ -4,10 +4,11 @@ Following Django styleguide pattern for business logic encapsulation.
"""
import logging
from typing import Optional, Dict, Any, List, TYPE_CHECKING
from typing import TYPE_CHECKING, Any, Optional
from django.core.files.uploadedfile import UploadedFile
from django.db import transaction
from django.db.models import Q
from django.core.files.uploadedfile import UploadedFile
if TYPE_CHECKING:
from django.contrib.auth.models import AbstractUser
@@ -28,14 +29,14 @@ class ParkService:
name: str,
description: str = "",
status: str = "OPERATING",
operator_id: Optional[int] = None,
property_owner_id: Optional[int] = None,
opening_date: Optional[str] = None,
closing_date: Optional[str] = None,
operator_id: int | None = None,
property_owner_id: int | None = None,
opening_date: str | None = None,
closing_date: str | None = None,
operating_season: str = "",
size_acres: Optional[float] = None,
size_acres: float | None = None,
website: str = "",
location_data: Optional[Dict[str, Any]] = None,
location_data: dict[str, Any] | None = None,
created_by: Optional["AbstractUser"] = None,
) -> Park:
"""
@@ -99,7 +100,7 @@ class ParkService:
def update_park(
*,
park_id: int,
updates: Dict[str, Any],
updates: dict[str, Any],
updated_by: Optional["AbstractUser"] = None,
) -> Park:
"""
@@ -203,9 +204,10 @@ class ParkService:
Returns:
Updated Park instance with fresh statistics
"""
from apps.rides.models import Ride
from django.db.models import Avg, Count
from apps.parks.models import ParkReview
from django.db.models import Count, Avg
from apps.rides.models import Ride
with transaction.atomic():
park = Park.objects.select_for_update().get(id=park_id)
@@ -235,11 +237,11 @@ class ParkService:
@staticmethod
def create_park_with_moderation(
*,
changes: Dict[str, Any],
changes: dict[str, Any],
submitter: "AbstractUser",
reason: str = "",
source: str = "",
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""
Create a park through the moderation system.
@@ -267,11 +269,11 @@ class ParkService:
def update_park_with_moderation(
*,
park: Park,
changes: Dict[str, Any],
changes: dict[str, Any],
submitter: "AbstractUser",
reason: str = "",
source: str = "",
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""
Update a park through the moderation system.
@@ -300,14 +302,14 @@ class ParkService:
def create_or_update_location(
*,
park: Park,
latitude: Optional[float],
longitude: Optional[float],
latitude: float | None,
longitude: float | None,
street_address: str = "",
city: str = "",
state: str = "",
country: str = "USA",
postal_code: str = "",
) -> Optional[ParkLocation]:
) -> ParkLocation | None:
"""
Create or update a park's location.
@@ -356,9 +358,9 @@ class ParkService:
def upload_photos(
*,
park: Park,
photos: List[UploadedFile],
photos: list[UploadedFile],
uploaded_by: "AbstractUser",
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""
Upload multiple photos for a park.
@@ -370,10 +372,9 @@ class ParkService:
Returns:
Dictionary with uploaded_count and errors list
"""
from django.contrib.contenttypes.models import ContentType
uploaded_count = 0
errors: List[str] = []
errors: list[str] = []
for photo_file in photos:
try:
@@ -396,11 +397,11 @@ class ParkService:
@staticmethod
def handle_park_creation_result(
*,
result: Dict[str, Any],
form_data: Dict[str, Any],
photos: List[UploadedFile],
result: dict[str, Any],
form_data: dict[str, Any],
photos: list[UploadedFile],
user: "AbstractUser",
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""
Handle the result of park creation through moderation.
@@ -413,7 +414,7 @@ class ParkService:
Returns:
Dictionary with status, park (if created), uploaded_count, and errors
"""
response: Dict[str, Any] = {
response: dict[str, Any] = {
"status": result["status"],
"park": None,
"uploaded_count": 0,
@@ -454,12 +455,12 @@ class ParkService:
@staticmethod
def handle_park_update_result(
*,
result: Dict[str, Any],
result: dict[str, Any],
park: Park,
form_data: Dict[str, Any],
photos: List[UploadedFile],
form_data: dict[str, Any],
photos: list[UploadedFile],
user: "AbstractUser",
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""
Handle the result of park update through moderation.
@@ -473,7 +474,7 @@ class ParkService:
Returns:
Dictionary with status, park, uploaded_count, and errors
"""
response: Dict[str, Any] = {
response: dict[str, Any] = {
"status": result["status"],
"park": park,
"uploaded_count": 0,

View File

@@ -9,18 +9,19 @@ This service provides functionality for:
- Proper rate limiting and caching
"""
import time
import math
import logging
import requests
from typing import Dict, List, Optional, Any
import math
import time
from dataclasses import dataclass
from itertools import permutations
from typing import Any
import requests
from django.conf import settings
from django.core.cache import cache
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import Distance
from django.core.cache import cache
from apps.parks.models import Park
logger = logging.getLogger(__name__)
@@ -33,7 +34,7 @@ class Coordinates:
latitude: float
longitude: float
def to_list(self) -> List[float]:
def to_list(self) -> list[float]:
"""Return as [lat, lon] list."""
return [self.latitude, self.longitude]
@@ -48,7 +49,7 @@ class RouteInfo:
distance_km: float
duration_minutes: int
geometry: Optional[str] = None # Encoded polyline
geometry: str | None = None # Encoded polyline
@property
def formatted_distance(self) -> str:
@@ -79,7 +80,7 @@ class TripLeg:
route: RouteInfo
@property
def parks_along_route(self) -> List["Park"]:
def parks_along_route(self) -> list["Park"]:
"""Get parks along this route segment."""
# This would be populated by find_parks_along_route
return []
@@ -89,8 +90,8 @@ class TripLeg:
class RoadTrip:
"""Complete road trip with multiple parks."""
parks: List["Park"]
legs: List[TripLeg]
parks: list["Park"]
legs: list[TripLeg]
total_distance_km: float
total_duration_minutes: int
@@ -170,7 +171,7 @@ class RoadTripService:
}
)
def _make_request(self, url: str, params: Dict[str, Any]) -> Dict[str, Any]:
def _make_request(self, url: str, params: dict[str, Any]) -> dict[str, Any]:
"""
Make HTTP request with rate limiting, retries, and error handling.
"""
@@ -195,7 +196,7 @@ class RoadTripService:
f"Failed to make request after {self.max_retries} attempts: {e}"
)
def geocode_address(self, address: str) -> Optional[Coordinates]:
def geocode_address(self, address: str) -> Coordinates | None:
"""
Convert address to coordinates using Nominatim geocoding service.
@@ -256,7 +257,7 @@ class RoadTripService:
def calculate_route(
self, start_coords: Coordinates, end_coords: Coordinates
) -> Optional[RouteInfo]:
) -> RouteInfo | None:
"""
Calculate route between two coordinate points using OSRM.
@@ -377,7 +378,7 @@ class RoadTripService:
def find_parks_along_route(
self, start_park: "Park", end_park: "Park", max_detour_km: float = 50
) -> List["Park"]:
) -> list["Park"]:
"""
Find parks along a route within specified detour distance.
@@ -444,7 +445,7 @@ class RoadTripService:
def _calculate_detour_distance(
self, start: Coordinates, end: Coordinates, waypoint: Coordinates
) -> Optional[float]:
) -> float | None:
"""
Calculate the detour distance when visiting a waypoint.
"""
@@ -470,7 +471,7 @@ class RoadTripService:
logger.error(f"Failed to calculate detour distance: {e}")
return None
def create_multi_park_trip(self, park_list: List["Park"]) -> Optional[RoadTrip]:
def create_multi_park_trip(self, park_list: list["Park"]) -> RoadTrip | None:
"""
Create optimized multi-park road trip using simple nearest neighbor heuristic.
@@ -489,7 +490,7 @@ class RoadTripService:
else:
return self._optimize_trip_nearest_neighbor(park_list)
def _optimize_trip_exhaustive(self, park_list: List["Park"]) -> Optional[RoadTrip]:
def _optimize_trip_exhaustive(self, park_list: list["Park"]) -> RoadTrip | None:
"""
Find optimal route by testing all permutations (for small lists).
"""
@@ -508,8 +509,8 @@ class RoadTripService:
return best_trip
def _optimize_trip_nearest_neighbor(
self, park_list: List["Park"]
) -> Optional[RoadTrip]:
self, park_list: list["Park"]
) -> RoadTrip | None:
"""
Optimize trip using nearest neighbor heuristic (for larger lists).
"""
@@ -553,8 +554,8 @@ class RoadTripService:
return self._create_trip_from_order(ordered_parks)
def _create_trip_from_order(
self, ordered_parks: List["Park"]
) -> Optional[RoadTrip]:
self, ordered_parks: list["Park"]
) -> RoadTrip | None:
"""
Create a RoadTrip object from an ordered list of parks.
"""
@@ -596,7 +597,7 @@ class RoadTripService:
def get_park_distances(
self, center_park: "Park", radius_km: float = 100
) -> List[Dict[str, Any]]:
) -> list[dict[str, Any]]:
"""
Get all parks within radius of a center park with distances.