mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-24 05:51:11 -05:00
Refactor test utilities and enhance ASGI settings
- Cleaned up and standardized assertions in ApiTestMixin for API response validation. - Updated ASGI settings to use os.environ for setting the DJANGO_SETTINGS_MODULE. - Removed unused imports and improved formatting in settings.py. - Refactored URL patterns in urls.py for better readability and organization. - Enhanced view functions in views.py for consistency and clarity. - Added .flake8 configuration for linting and style enforcement. - Introduced type stubs for django-environ to improve type checking with Pylance.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
from .roadtrip import RoadTripService
|
||||
from .park_management import ParkService, LocationService
|
||||
|
||||
__all__ = ['RoadTripService', 'ParkService', 'LocationService']
|
||||
__all__ = ["RoadTripService", "ParkService", "LocationService"]
|
||||
|
||||
@@ -6,7 +6,6 @@ Following Django styleguide pattern for business logic encapsulation.
|
||||
from typing import Optional, Dict, Any, TYPE_CHECKING
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
@@ -32,11 +31,11 @@ class ParkService:
|
||||
size_acres: Optional[float] = None,
|
||||
website: str = "",
|
||||
location_data: Optional[Dict[str, Any]] = None,
|
||||
created_by: Optional['AbstractUser'] = None
|
||||
created_by: Optional["AbstractUser"] = None,
|
||||
) -> Park:
|
||||
"""
|
||||
Create a new park with validation and location handling.
|
||||
|
||||
|
||||
Args:
|
||||
name: Park name
|
||||
description: Park description
|
||||
@@ -50,10 +49,10 @@ class ParkService:
|
||||
website: Park website URL
|
||||
location_data: Dictionary containing location information
|
||||
created_by: User creating the park
|
||||
|
||||
|
||||
Returns:
|
||||
Created Park instance
|
||||
|
||||
|
||||
Raises:
|
||||
ValidationError: If park data is invalid
|
||||
"""
|
||||
@@ -67,16 +66,18 @@ class ParkService:
|
||||
closing_date=closing_date,
|
||||
operating_season=operating_season,
|
||||
size_acres=size_acres,
|
||||
website=website
|
||||
website=website,
|
||||
)
|
||||
|
||||
# Set foreign key relationships if provided
|
||||
if operator_id:
|
||||
from parks.models import Company
|
||||
|
||||
park.operator = Company.objects.get(id=operator_id)
|
||||
|
||||
if property_owner_id:
|
||||
from parks.models import Company
|
||||
|
||||
park.property_owner = Company.objects.get(id=property_owner_id)
|
||||
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
@@ -85,10 +86,7 @@ class ParkService:
|
||||
|
||||
# Handle location if provided
|
||||
if location_data:
|
||||
LocationService.create_park_location(
|
||||
park=park,
|
||||
**location_data
|
||||
)
|
||||
LocationService.create_park_location(park=park, **location_data)
|
||||
|
||||
return park
|
||||
|
||||
@@ -97,19 +95,19 @@ class ParkService:
|
||||
*,
|
||||
park_id: int,
|
||||
updates: Dict[str, Any],
|
||||
updated_by: Optional['AbstractUser'] = None
|
||||
updated_by: Optional["AbstractUser"] = None,
|
||||
) -> Park:
|
||||
"""
|
||||
Update an existing park with validation.
|
||||
|
||||
|
||||
Args:
|
||||
park_id: ID of park to update
|
||||
updates: Dictionary of field updates
|
||||
updated_by: User performing the update
|
||||
|
||||
|
||||
Returns:
|
||||
Updated Park instance
|
||||
|
||||
|
||||
Raises:
|
||||
Park.DoesNotExist: If park doesn't exist
|
||||
ValidationError: If update data is invalid
|
||||
@@ -129,23 +127,25 @@ class ParkService:
|
||||
return park
|
||||
|
||||
@staticmethod
|
||||
def delete_park(*, park_id: int, deleted_by: Optional['AbstractUser'] = None) -> bool:
|
||||
def delete_park(
|
||||
*, park_id: int, deleted_by: Optional["AbstractUser"] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Soft delete a park by setting status to DEMOLISHED.
|
||||
|
||||
|
||||
Args:
|
||||
park_id: ID of park to delete
|
||||
deleted_by: User performing the deletion
|
||||
|
||||
|
||||
Returns:
|
||||
True if successfully deleted
|
||||
|
||||
|
||||
Raises:
|
||||
Park.DoesNotExist: If park doesn't exist
|
||||
"""
|
||||
with transaction.atomic():
|
||||
park = Park.objects.select_for_update().get(id=park_id)
|
||||
park.status = 'DEMOLISHED'
|
||||
park.status = "DEMOLISHED"
|
||||
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
park.full_clean()
|
||||
@@ -159,31 +159,27 @@ class ParkService:
|
||||
park_id: int,
|
||||
name: str,
|
||||
description: str = "",
|
||||
created_by: Optional['AbstractUser'] = None
|
||||
created_by: Optional["AbstractUser"] = None,
|
||||
) -> ParkArea:
|
||||
"""
|
||||
Create a new area within a park.
|
||||
|
||||
|
||||
Args:
|
||||
park_id: ID of the parent park
|
||||
name: Area name
|
||||
description: Area description
|
||||
created_by: User creating the area
|
||||
|
||||
|
||||
Returns:
|
||||
Created ParkArea instance
|
||||
|
||||
|
||||
Raises:
|
||||
Park.DoesNotExist: If park doesn't exist
|
||||
ValidationError: If area data is invalid
|
||||
"""
|
||||
park = Park.objects.get(id=park_id)
|
||||
|
||||
area = ParkArea(
|
||||
park=park,
|
||||
name=name,
|
||||
description=description
|
||||
)
|
||||
area = ParkArea(park=park, name=name, description=description)
|
||||
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
area.full_clean()
|
||||
@@ -195,10 +191,10 @@ class ParkService:
|
||||
def update_park_statistics(*, park_id: int) -> Park:
|
||||
"""
|
||||
Recalculate and update park statistics (ride counts, ratings).
|
||||
|
||||
|
||||
Args:
|
||||
park_id: ID of park to update statistics for
|
||||
|
||||
|
||||
Returns:
|
||||
Updated Park instance with fresh statistics
|
||||
"""
|
||||
@@ -211,19 +207,18 @@ class ParkService:
|
||||
|
||||
# Calculate ride counts
|
||||
ride_stats = Ride.objects.filter(park=park).aggregate(
|
||||
total_rides=Count('id'),
|
||||
coaster_count=Count('id', filter=Q(category__in=['RC', 'WC']))
|
||||
total_rides=Count("id"),
|
||||
coaster_count=Count("id", filter=Q(category__in=["RC", "WC"])),
|
||||
)
|
||||
|
||||
# Calculate average rating
|
||||
avg_rating = ParkReview.objects.filter(
|
||||
park=park,
|
||||
is_published=True
|
||||
).aggregate(avg_rating=Avg('rating'))['avg_rating']
|
||||
park=park, is_published=True
|
||||
).aggregate(avg_rating=Avg("rating"))["avg_rating"]
|
||||
|
||||
# Update park fields
|
||||
park.ride_count = ride_stats['total_rides'] or 0
|
||||
park.coaster_count = ride_stats['coaster_count'] or 0
|
||||
park.ride_count = ride_stats["total_rides"] or 0
|
||||
park.coaster_count = ride_stats["coaster_count"] or 0
|
||||
park.average_rating = avg_rating
|
||||
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
@@ -246,11 +241,11 @@ class LocationService:
|
||||
city: str = "",
|
||||
state: str = "",
|
||||
country: str = "",
|
||||
postal_code: str = ""
|
||||
postal_code: str = "",
|
||||
) -> Location:
|
||||
"""
|
||||
Create a location for a park.
|
||||
|
||||
|
||||
Args:
|
||||
park: Park instance
|
||||
latitude: Latitude coordinate
|
||||
@@ -260,24 +255,24 @@ class LocationService:
|
||||
state: State/region name
|
||||
country: Country name
|
||||
postal_code: Postal/ZIP code
|
||||
|
||||
|
||||
Returns:
|
||||
Created Location instance
|
||||
|
||||
|
||||
Raises:
|
||||
ValidationError: If location data is invalid
|
||||
"""
|
||||
location = Location(
|
||||
content_object=park,
|
||||
name=park.name,
|
||||
location_type='park',
|
||||
location_type="park",
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
street_address=street_address,
|
||||
city=city,
|
||||
state=state,
|
||||
country=country,
|
||||
postal_code=postal_code
|
||||
postal_code=postal_code,
|
||||
)
|
||||
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
@@ -288,20 +283,18 @@ class LocationService:
|
||||
|
||||
@staticmethod
|
||||
def update_park_location(
|
||||
*,
|
||||
park_id: int,
|
||||
location_updates: Dict[str, Any]
|
||||
*, park_id: int, location_updates: Dict[str, Any]
|
||||
) -> Location:
|
||||
"""
|
||||
Update location information for a park.
|
||||
|
||||
|
||||
Args:
|
||||
park_id: ID of the park
|
||||
location_updates: Dictionary of location field updates
|
||||
|
||||
|
||||
Returns:
|
||||
Updated Location instance
|
||||
|
||||
|
||||
Raises:
|
||||
Location.DoesNotExist: If location doesn't exist
|
||||
ValidationError: If location data is invalid
|
||||
@@ -314,8 +307,7 @@ class LocationService:
|
||||
except Location.DoesNotExist:
|
||||
# Create location if it doesn't exist
|
||||
return LocationService.create_park_location(
|
||||
park=park,
|
||||
**location_updates
|
||||
park=park, **location_updates
|
||||
)
|
||||
|
||||
# Apply updates
|
||||
|
||||
@@ -13,7 +13,7 @@ import time
|
||||
import math
|
||||
import logging
|
||||
import requests
|
||||
from typing import Dict, List, Tuple, Optional, Any, Union
|
||||
from typing import Dict, List, Tuple, Optional, Any
|
||||
from dataclasses import dataclass
|
||||
from itertools import permutations
|
||||
|
||||
@@ -21,7 +21,6 @@ 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.db.models import Q
|
||||
from parks.models import Park
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -30,6 +29,7 @@ logger = logging.getLogger(__name__)
|
||||
@dataclass
|
||||
class Coordinates:
|
||||
"""Represents latitude and longitude coordinates."""
|
||||
|
||||
latitude: float
|
||||
longitude: float
|
||||
|
||||
@@ -45,6 +45,7 @@ class Coordinates:
|
||||
@dataclass
|
||||
class RouteInfo:
|
||||
"""Information about a calculated route."""
|
||||
|
||||
distance_km: float
|
||||
duration_minutes: int
|
||||
geometry: Optional[str] = None # Encoded polyline
|
||||
@@ -72,12 +73,13 @@ class RouteInfo:
|
||||
@dataclass
|
||||
class TripLeg:
|
||||
"""Represents one leg of a multi-park trip."""
|
||||
from_park: 'Park'
|
||||
to_park: 'Park'
|
||||
|
||||
from_park: "Park"
|
||||
to_park: "Park"
|
||||
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 []
|
||||
@@ -86,7 +88,8 @@ class TripLeg:
|
||||
@dataclass
|
||||
class RoadTrip:
|
||||
"""Complete road trip with multiple parks."""
|
||||
parks: List['Park']
|
||||
|
||||
parks: List["Park"]
|
||||
legs: List[TripLeg]
|
||||
total_distance_km: float
|
||||
total_duration_minutes: int
|
||||
@@ -131,7 +134,6 @@ class RateLimiter:
|
||||
|
||||
class OSMAPIException(Exception):
|
||||
"""Exception for OSM API related errors."""
|
||||
pass
|
||||
|
||||
|
||||
class RoadTripService:
|
||||
@@ -144,27 +146,29 @@ class RoadTripService:
|
||||
self.osrm_base_url = "http://router.project-osrm.org/route/v1/driving"
|
||||
|
||||
# Configuration from Django settings
|
||||
self.cache_timeout = getattr(
|
||||
settings, 'ROADTRIP_CACHE_TIMEOUT', 3600 * 24)
|
||||
self.cache_timeout = getattr(settings, "ROADTRIP_CACHE_TIMEOUT", 3600 * 24)
|
||||
self.route_cache_timeout = getattr(
|
||||
settings, 'ROADTRIP_ROUTE_CACHE_TIMEOUT', 3600 * 6)
|
||||
settings, "ROADTRIP_ROUTE_CACHE_TIMEOUT", 3600 * 6
|
||||
)
|
||||
self.user_agent = getattr(
|
||||
settings, 'ROADTRIP_USER_AGENT', 'ThrillWiki Road Trip Planner')
|
||||
self.request_timeout = getattr(
|
||||
settings, 'ROADTRIP_REQUEST_TIMEOUT', 10)
|
||||
self.max_retries = getattr(settings, 'ROADTRIP_MAX_RETRIES', 3)
|
||||
self.backoff_factor = getattr(settings, 'ROADTRIP_BACKOFF_FACTOR', 2)
|
||||
settings, "ROADTRIP_USER_AGENT", "ThrillWiki Road Trip Planner"
|
||||
)
|
||||
self.request_timeout = getattr(settings, "ROADTRIP_REQUEST_TIMEOUT", 10)
|
||||
self.max_retries = getattr(settings, "ROADTRIP_MAX_RETRIES", 3)
|
||||
self.backoff_factor = getattr(settings, "ROADTRIP_BACKOFF_FACTOR", 2)
|
||||
|
||||
# Rate limiter
|
||||
max_rps = getattr(settings, 'ROADTRIP_MAX_REQUESTS_PER_SECOND', 1)
|
||||
max_rps = getattr(settings, "ROADTRIP_MAX_REQUESTS_PER_SECOND", 1)
|
||||
self.rate_limiter = RateLimiter(max_rps)
|
||||
|
||||
# Request session with proper headers
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'User-Agent': self.user_agent,
|
||||
'Accept': 'application/json',
|
||||
})
|
||||
self.session.headers.update(
|
||||
{
|
||||
"User-Agent": self.user_agent,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
def _make_request(self, url: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -175,9 +179,7 @@ class RoadTripService:
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
response = self.session.get(
|
||||
url,
|
||||
params=params,
|
||||
timeout=self.request_timeout
|
||||
url, params=params, timeout=self.request_timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
@@ -186,11 +188,13 @@ class RoadTripService:
|
||||
logger.warning(f"Request attempt {attempt + 1} failed: {e}")
|
||||
|
||||
if attempt < self.max_retries - 1:
|
||||
wait_time = self.backoff_factor ** attempt
|
||||
wait_time = self.backoff_factor**attempt
|
||||
time.sleep(wait_time)
|
||||
else:
|
||||
raise OSMAPIException(
|
||||
f"Failed to make request after {self.max_retries} attempts: {e}")
|
||||
f"Failed to make request after {
|
||||
self.max_retries} attempts: {e}"
|
||||
)
|
||||
|
||||
def geocode_address(self, address: str) -> Optional[Coordinates]:
|
||||
"""
|
||||
@@ -213,10 +217,10 @@ class RoadTripService:
|
||||
|
||||
try:
|
||||
params = {
|
||||
'q': address.strip(),
|
||||
'format': 'json',
|
||||
'limit': 1,
|
||||
'addressdetails': 1,
|
||||
"q": address.strip(),
|
||||
"format": "json",
|
||||
"limit": 1,
|
||||
"addressdetails": 1,
|
||||
}
|
||||
|
||||
url = f"{self.nominatim_base_url}/search"
|
||||
@@ -225,18 +229,25 @@ class RoadTripService:
|
||||
if response and len(response) > 0:
|
||||
result = response[0]
|
||||
coords = Coordinates(
|
||||
latitude=float(result['lat']),
|
||||
longitude=float(result['lon'])
|
||||
latitude=float(result["lat"]),
|
||||
longitude=float(result["lon"]),
|
||||
)
|
||||
|
||||
# Cache the result
|
||||
cache.set(cache_key, {
|
||||
'latitude': coords.latitude,
|
||||
'longitude': coords.longitude
|
||||
}, self.cache_timeout)
|
||||
cache.set(
|
||||
cache_key,
|
||||
{
|
||||
"latitude": coords.latitude,
|
||||
"longitude": coords.longitude,
|
||||
},
|
||||
self.cache_timeout,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Geocoded '{address}' to {coords.latitude}, {coords.longitude}")
|
||||
f"Geocoded '{address}' to {
|
||||
coords.latitude}, {
|
||||
coords.longitude}"
|
||||
)
|
||||
return coords
|
||||
else:
|
||||
logger.warning(f"No geocoding results for address: {address}")
|
||||
@@ -246,7 +257,9 @@ class RoadTripService:
|
||||
logger.error(f"Geocoding failed for '{address}': {e}")
|
||||
return None
|
||||
|
||||
def calculate_route(self, start_coords: Coordinates, end_coords: Coordinates) -> Optional[RouteInfo]:
|
||||
def calculate_route(
|
||||
self, start_coords: Coordinates, end_coords: Coordinates
|
||||
) -> Optional[RouteInfo]:
|
||||
"""
|
||||
Calculate route between two coordinate points using OSRM.
|
||||
|
||||
@@ -261,52 +274,68 @@ class RoadTripService:
|
||||
return None
|
||||
|
||||
# Check cache first
|
||||
cache_key = f"roadtrip:route:{start_coords.latitude},{start_coords.longitude}:{end_coords.latitude},{end_coords.longitude}"
|
||||
cache_key = f"roadtrip:route:{
|
||||
start_coords.latitude},{
|
||||
start_coords.longitude}:{
|
||||
end_coords.latitude},{
|
||||
end_coords.longitude}"
|
||||
cached_result = cache.get(cache_key)
|
||||
if cached_result:
|
||||
return RouteInfo(**cached_result)
|
||||
|
||||
try:
|
||||
# Format coordinates for OSRM (lon,lat format)
|
||||
coords_string = f"{start_coords.longitude},{start_coords.latitude};{end_coords.longitude},{end_coords.latitude}"
|
||||
coords_string = f"{
|
||||
start_coords.longitude},{
|
||||
start_coords.latitude};{
|
||||
end_coords.longitude},{
|
||||
end_coords.latitude}"
|
||||
url = f"{self.osrm_base_url}/{coords_string}"
|
||||
|
||||
params = {
|
||||
'overview': 'full',
|
||||
'geometries': 'polyline',
|
||||
'steps': 'false',
|
||||
"overview": "full",
|
||||
"geometries": "polyline",
|
||||
"steps": "false",
|
||||
}
|
||||
|
||||
response = self._make_request(url, params)
|
||||
|
||||
if response.get('code') == 'Ok' and response.get('routes'):
|
||||
route_data = response['routes'][0]
|
||||
if response.get("code") == "Ok" and response.get("routes"):
|
||||
route_data = response["routes"][0]
|
||||
|
||||
# Distance is in meters, convert to km
|
||||
distance_km = route_data['distance'] / 1000.0
|
||||
distance_km = route_data["distance"] / 1000.0
|
||||
# Duration is in seconds, convert to minutes
|
||||
duration_minutes = int(route_data['duration'] / 60)
|
||||
duration_minutes = int(route_data["duration"] / 60)
|
||||
|
||||
route_info = RouteInfo(
|
||||
distance_km=distance_km,
|
||||
duration_minutes=duration_minutes,
|
||||
geometry=route_data.get('geometry')
|
||||
geometry=route_data.get("geometry"),
|
||||
)
|
||||
|
||||
# Cache the result
|
||||
cache.set(cache_key, {
|
||||
'distance_km': route_info.distance_km,
|
||||
'duration_minutes': route_info.duration_minutes,
|
||||
'geometry': route_info.geometry
|
||||
}, self.route_cache_timeout)
|
||||
cache.set(
|
||||
cache_key,
|
||||
{
|
||||
"distance_km": route_info.distance_km,
|
||||
"duration_minutes": route_info.duration_minutes,
|
||||
"geometry": route_info.geometry,
|
||||
},
|
||||
self.route_cache_timeout,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Route calculated: {route_info.formatted_distance}, {route_info.formatted_duration}")
|
||||
f"Route calculated: {
|
||||
route_info.formatted_distance}, {
|
||||
route_info.formatted_duration}"
|
||||
)
|
||||
return route_info
|
||||
else:
|
||||
# Fallback to straight-line distance calculation
|
||||
logger.warning(
|
||||
f"OSRM routing failed, falling back to straight-line distance")
|
||||
f"OSRM routing failed, falling back to straight-line distance"
|
||||
)
|
||||
return self._calculate_straight_line_route(start_coords, end_coords)
|
||||
|
||||
except Exception as e:
|
||||
@@ -314,37 +343,46 @@ class RoadTripService:
|
||||
# Fallback to straight-line distance
|
||||
return self._calculate_straight_line_route(start_coords, end_coords)
|
||||
|
||||
def _calculate_straight_line_route(self, start_coords: Coordinates, end_coords: Coordinates) -> RouteInfo:
|
||||
def _calculate_straight_line_route(
|
||||
self, start_coords: Coordinates, end_coords: Coordinates
|
||||
) -> RouteInfo:
|
||||
"""
|
||||
Calculate straight-line distance as fallback when routing fails.
|
||||
"""
|
||||
# Haversine formula for great-circle distance
|
||||
lat1, lon1 = math.radians(start_coords.latitude), math.radians(
|
||||
start_coords.longitude)
|
||||
lat2, lon2 = math.radians(
|
||||
end_coords.latitude), math.radians(end_coords.longitude)
|
||||
start_coords.longitude
|
||||
)
|
||||
lat2, lon2 = math.radians(end_coords.latitude), math.radians(
|
||||
end_coords.longitude
|
||||
)
|
||||
|
||||
dlat = lat2 - lat1
|
||||
dlon = lon2 - lon1
|
||||
|
||||
a = math.sin(dlat/2)**2 + math.cos(lat1) * \
|
||||
math.cos(lat2) * math.sin(dlon/2)**2
|
||||
a = (
|
||||
math.sin(dlat / 2) ** 2
|
||||
+ math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
|
||||
)
|
||||
c = 2 * math.asin(math.sqrt(a))
|
||||
|
||||
# Earth's radius in kilometers
|
||||
earth_radius_km = 6371.0
|
||||
distance_km = earth_radius_km * c
|
||||
|
||||
# Estimate driving time (assume average 80 km/h with 25% extra for roads)
|
||||
# Estimate driving time (assume average 80 km/h with 25% extra for
|
||||
# roads)
|
||||
estimated_duration_minutes = int((distance_km * 1.25 / 80.0) * 60)
|
||||
|
||||
return RouteInfo(
|
||||
distance_km=distance_km,
|
||||
duration_minutes=estimated_duration_minutes,
|
||||
geometry=None
|
||||
geometry=None,
|
||||
)
|
||||
|
||||
def find_parks_along_route(self, start_park: 'Park', end_park: 'Park', max_detour_km: float = 50) -> List['Park']:
|
||||
def find_parks_along_route(
|
||||
self, start_park: "Park", end_park: "Park", max_detour_km: float = 50
|
||||
) -> List["Park"]:
|
||||
"""
|
||||
Find parks along a route within specified detour distance.
|
||||
|
||||
@@ -358,7 +396,7 @@ class RoadTripService:
|
||||
"""
|
||||
from parks.models import Park
|
||||
|
||||
if not hasattr(start_park, 'location') or not hasattr(end_park, 'location'):
|
||||
if not hasattr(start_park, "location") or not hasattr(end_park, "location"):
|
||||
return []
|
||||
|
||||
if not start_park.location or not end_park.location:
|
||||
@@ -370,18 +408,22 @@ class RoadTripService:
|
||||
if not start_coords or not end_coords:
|
||||
return []
|
||||
|
||||
start_point = Point(
|
||||
start_coords[1], start_coords[0], srid=4326) # lon, lat
|
||||
end_point = Point(end_coords[1], end_coords[0], srid=4326)
|
||||
start_point = Point(start_coords[1], start_coords[0], srid=4326) # lon, lat
|
||||
# end_point is not used in this method - we use coordinates directly
|
||||
|
||||
# Find all parks within a reasonable distance from both start and end
|
||||
max_search_distance = Distance(km=max_detour_km * 2)
|
||||
|
||||
candidate_parks = Park.objects.filter(
|
||||
location__point__distance_lte=(start_point, max_search_distance)
|
||||
).exclude(
|
||||
id__in=[start_park.id, end_park.id]
|
||||
).select_related('location')
|
||||
candidate_parks = (
|
||||
Park.objects.filter(
|
||||
location__point__distance_lte=(
|
||||
start_point,
|
||||
max_search_distance,
|
||||
)
|
||||
)
|
||||
.exclude(id__in=[start_park.id, end_park.id])
|
||||
.select_related("location")
|
||||
)
|
||||
|
||||
parks_along_route = []
|
||||
|
||||
@@ -397,7 +439,7 @@ class RoadTripService:
|
||||
detour_distance = self._calculate_detour_distance(
|
||||
Coordinates(*start_coords),
|
||||
Coordinates(*end_coords),
|
||||
Coordinates(*park_coords)
|
||||
Coordinates(*park_coords),
|
||||
)
|
||||
|
||||
if detour_distance and detour_distance <= max_detour_km:
|
||||
@@ -405,7 +447,9 @@ class RoadTripService:
|
||||
|
||||
return parks_along_route
|
||||
|
||||
def _calculate_detour_distance(self, start: Coordinates, end: Coordinates, waypoint: Coordinates) -> Optional[float]:
|
||||
def _calculate_detour_distance(
|
||||
self, start: Coordinates, end: Coordinates, waypoint: Coordinates
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Calculate the detour distance when visiting a waypoint.
|
||||
"""
|
||||
@@ -422,15 +466,16 @@ class RoadTripService:
|
||||
if not route_to_waypoint or not route_from_waypoint:
|
||||
return None
|
||||
|
||||
detour_distance = (route_to_waypoint.distance_km +
|
||||
route_from_waypoint.distance_km) - direct_route.distance_km
|
||||
detour_distance = (
|
||||
route_to_waypoint.distance_km + route_from_waypoint.distance_km
|
||||
) - direct_route.distance_km
|
||||
return max(0, detour_distance) # Don't return negative detours
|
||||
|
||||
except Exception as e:
|
||||
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"]) -> Optional[RoadTrip]:
|
||||
"""
|
||||
Create optimized multi-park road trip using simple nearest neighbor heuristic.
|
||||
|
||||
@@ -449,12 +494,12 @@ 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"]) -> Optional[RoadTrip]:
|
||||
"""
|
||||
Find optimal route by testing all permutations (for small lists).
|
||||
"""
|
||||
best_trip = None
|
||||
best_distance = float('inf')
|
||||
best_distance = float("inf")
|
||||
|
||||
# Try all possible orders (excluding the first park as starting point)
|
||||
for perm in permutations(park_list[1:]):
|
||||
@@ -467,7 +512,9 @@ class RoadTripService:
|
||||
|
||||
return best_trip
|
||||
|
||||
def _optimize_trip_nearest_neighbor(self, park_list: List['Park']) -> Optional[RoadTrip]:
|
||||
def _optimize_trip_nearest_neighbor(
|
||||
self, park_list: List["Park"]
|
||||
) -> Optional[RoadTrip]:
|
||||
"""
|
||||
Optimize trip using nearest neighbor heuristic (for larger lists).
|
||||
"""
|
||||
@@ -482,7 +529,7 @@ class RoadTripService:
|
||||
while remaining_parks:
|
||||
# Find nearest unvisited park
|
||||
nearest_park = None
|
||||
min_distance = float('inf')
|
||||
min_distance = float("inf")
|
||||
|
||||
current_coords = current_park.coordinates
|
||||
if not current_coords:
|
||||
@@ -494,8 +541,7 @@ class RoadTripService:
|
||||
continue
|
||||
|
||||
route = self.calculate_route(
|
||||
Coordinates(*current_coords),
|
||||
Coordinates(*park_coords)
|
||||
Coordinates(*current_coords), Coordinates(*park_coords)
|
||||
)
|
||||
|
||||
if route and route.distance_km < min_distance:
|
||||
@@ -511,7 +557,9 @@ class RoadTripService:
|
||||
|
||||
return self._create_trip_from_order(ordered_parks)
|
||||
|
||||
def _create_trip_from_order(self, ordered_parks: List['Park']) -> Optional[RoadTrip]:
|
||||
def _create_trip_from_order(
|
||||
self, ordered_parks: List["Park"]
|
||||
) -> Optional[RoadTrip]:
|
||||
"""
|
||||
Create a RoadTrip object from an ordered list of parks.
|
||||
"""
|
||||
@@ -533,16 +581,11 @@ class RoadTripService:
|
||||
continue
|
||||
|
||||
route = self.calculate_route(
|
||||
Coordinates(*from_coords),
|
||||
Coordinates(*to_coords)
|
||||
Coordinates(*from_coords), Coordinates(*to_coords)
|
||||
)
|
||||
|
||||
if route:
|
||||
legs.append(TripLeg(
|
||||
from_park=from_park,
|
||||
to_park=to_park,
|
||||
route=route
|
||||
))
|
||||
legs.append(TripLeg(from_park=from_park, to_park=to_park, route=route))
|
||||
total_distance += route.distance_km
|
||||
total_duration += route.duration_minutes
|
||||
|
||||
@@ -553,10 +596,12 @@ class RoadTripService:
|
||||
parks=ordered_parks,
|
||||
legs=legs,
|
||||
total_distance_km=total_distance,
|
||||
total_duration_minutes=total_duration
|
||||
total_duration_minutes=total_duration,
|
||||
)
|
||||
|
||||
def get_park_distances(self, center_park: 'Park', radius_km: float = 100) -> List[Dict[str, Any]]:
|
||||
def get_park_distances(
|
||||
self, center_park: "Park", radius_km: float = 100
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all parks within radius of a center park with distances.
|
||||
|
||||
@@ -569,22 +614,23 @@ class RoadTripService:
|
||||
"""
|
||||
from parks.models import Park
|
||||
|
||||
if not hasattr(center_park, 'location') or not center_park.location:
|
||||
if not hasattr(center_park, "location") or not center_park.location:
|
||||
return []
|
||||
|
||||
center_coords = center_park.coordinates
|
||||
if not center_coords:
|
||||
return []
|
||||
|
||||
center_point = Point(
|
||||
center_coords[1], center_coords[0], srid=4326) # lon, lat
|
||||
center_point = Point(center_coords[1], center_coords[0], srid=4326) # lon, lat
|
||||
search_distance = Distance(km=radius_km)
|
||||
|
||||
nearby_parks = Park.objects.filter(
|
||||
location__point__distance_lte=(center_point, search_distance)
|
||||
).exclude(
|
||||
id=center_park.id
|
||||
).select_related('location')
|
||||
nearby_parks = (
|
||||
Park.objects.filter(
|
||||
location__point__distance_lte=(center_point, search_distance)
|
||||
)
|
||||
.exclude(id=center_park.id)
|
||||
.select_related("location")
|
||||
)
|
||||
|
||||
results = []
|
||||
|
||||
@@ -594,25 +640,26 @@ class RoadTripService:
|
||||
continue
|
||||
|
||||
route = self.calculate_route(
|
||||
Coordinates(*center_coords),
|
||||
Coordinates(*park_coords)
|
||||
Coordinates(*center_coords), Coordinates(*park_coords)
|
||||
)
|
||||
|
||||
if route:
|
||||
results.append({
|
||||
'park': park,
|
||||
'distance_km': route.distance_km,
|
||||
'duration_minutes': route.duration_minutes,
|
||||
'formatted_distance': route.formatted_distance,
|
||||
'formatted_duration': route.formatted_duration,
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"park": park,
|
||||
"distance_km": route.distance_km,
|
||||
"duration_minutes": route.duration_minutes,
|
||||
"formatted_distance": route.formatted_distance,
|
||||
"formatted_duration": route.formatted_duration,
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by distance
|
||||
results.sort(key=lambda x: x['distance_km'])
|
||||
results.sort(key=lambda x: x["distance_km"])
|
||||
|
||||
return results
|
||||
|
||||
def geocode_park_if_needed(self, park: 'Park') -> bool:
|
||||
def geocode_park_if_needed(self, park: "Park") -> bool:
|
||||
"""
|
||||
Geocode park location if coordinates are missing.
|
||||
|
||||
@@ -622,7 +669,7 @@ class RoadTripService:
|
||||
Returns:
|
||||
True if geocoding succeeded or wasn't needed, False otherwise
|
||||
"""
|
||||
if not hasattr(park, 'location') or not park.location:
|
||||
if not hasattr(park, "location") or not park.location:
|
||||
return False
|
||||
|
||||
location = park.location
|
||||
@@ -637,7 +684,7 @@ class RoadTripService:
|
||||
location.street_address,
|
||||
location.city,
|
||||
location.state,
|
||||
location.country
|
||||
location.country,
|
||||
]
|
||||
address = ", ".join(part for part in address_parts if part)
|
||||
|
||||
@@ -649,7 +696,11 @@ class RoadTripService:
|
||||
location.set_coordinates(coords.latitude, coords.longitude)
|
||||
location.save()
|
||||
logger.info(
|
||||
f"Geocoded park '{park.name}' to {coords.latitude}, {coords.longitude}")
|
||||
f"Geocoded park '{
|
||||
park.name}' to {
|
||||
coords.latitude}, {
|
||||
coords.longitude}"
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user