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:
pacnpal
2025-08-20 19:51:59 -04:00
parent 69c07d1381
commit 66ed4347a9
230 changed files with 15094 additions and 11578 deletions

View File

@@ -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