mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-22 21:31:09 -05:00
Refactor parks and rides views for improved organization and readability
- Updated imports in parks/views.py to use ParkReview as Review for clarity. - Enhanced road trip views in parks/views_roadtrip.py by removing unnecessary parameters and improving context handling. - Streamlined error handling and response messages in CreateTripView and FindParksAlongRouteView. - Improved code formatting and consistency across various methods in parks/views_roadtrip.py. - Refactored rides/models.py to import Company from models for better clarity. - Updated rides/views.py to import RideSearchForm from services for better organization. - Added a comprehensive Django best practices analysis document to memory-bank/documentation.
This commit is contained in:
@@ -6,13 +6,14 @@ from django.core.exceptions import ValidationError
|
||||
from decimal import Decimal, ROUND_DOWN, InvalidOperation
|
||||
from typing import Tuple, Optional, Any, TYPE_CHECKING
|
||||
import pghistory
|
||||
|
||||
from .companies import Company
|
||||
from media.models import Photo
|
||||
from core.history import TrackedModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from rides.models import Ride
|
||||
from . import ParkArea
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class Park(TrackedModel):
|
||||
@@ -71,7 +72,8 @@ class Park(TrackedModel):
|
||||
)
|
||||
photos = GenericRelation(Photo, related_query_name="park")
|
||||
areas: models.Manager['ParkArea'] # Type hint for reverse relation
|
||||
rides: models.Manager['Ride'] # Type hint for reverse relation from rides app
|
||||
# Type hint for reverse relation from rides app
|
||||
rides: models.Manager['Ride']
|
||||
|
||||
# Metadata
|
||||
created_at = models.DateTimeField(auto_now_add=True, null=True)
|
||||
@@ -86,7 +88,7 @@ class Park(TrackedModel):
|
||||
def save(self, *args: Any, **kwargs: Any) -> None:
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from core.history import HistoricalSlug
|
||||
|
||||
|
||||
# Get old instance if it exists
|
||||
if self.pk:
|
||||
try:
|
||||
@@ -99,14 +101,14 @@ class Park(TrackedModel):
|
||||
else:
|
||||
old_name = None
|
||||
old_slug = None
|
||||
|
||||
|
||||
# Generate new slug if name has changed or slug is missing
|
||||
if not self.slug or (old_name and old_name != self.name):
|
||||
self.slug = slugify(self.name)
|
||||
|
||||
|
||||
# Save the model
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
# If slug has changed, save historical record
|
||||
if old_slug and old_slug != self.slug:
|
||||
HistoricalSlug.objects.create(
|
||||
@@ -118,9 +120,11 @@ class Park(TrackedModel):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.operator and 'OPERATOR' not in self.operator.roles:
|
||||
raise ValidationError({'operator': 'Company must have the OPERATOR role.'})
|
||||
raise ValidationError(
|
||||
{'operator': 'Company must have the OPERATOR role.'})
|
||||
if self.property_owner and 'PROPERTY_OWNER' not in self.property_owner.roles:
|
||||
raise ValidationError({'property_owner': 'Company must have the PROPERTY_OWNER role.'})
|
||||
raise ValidationError(
|
||||
{'property_owner': 'Company must have the PROPERTY_OWNER role.'})
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return reverse("parks:park_detail", kwargs={"slug": self.slug})
|
||||
@@ -156,54 +160,55 @@ class Park(TrackedModel):
|
||||
"""Get park by current or historical slug"""
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from core.history import HistoricalSlug
|
||||
|
||||
|
||||
print(f"\nLooking up slug: {slug}")
|
||||
|
||||
|
||||
try:
|
||||
park = cls.objects.get(slug=slug)
|
||||
print(f"Found current park with slug: {slug}")
|
||||
return park, False
|
||||
except cls.DoesNotExist:
|
||||
print(f"No current park found with slug: {slug}")
|
||||
|
||||
|
||||
# Try historical slugs in HistoricalSlug model
|
||||
content_type = ContentType.objects.get_for_model(cls)
|
||||
print(f"Searching HistoricalSlug with content_type: {content_type}")
|
||||
print(
|
||||
f"Searching HistoricalSlug with content_type: {content_type}")
|
||||
historical = HistoricalSlug.objects.filter(
|
||||
content_type=content_type,
|
||||
slug=slug
|
||||
).order_by('-created_at').first()
|
||||
|
||||
|
||||
if historical:
|
||||
print(f"Found historical slug record for object_id: {historical.object_id}")
|
||||
print(
|
||||
f"Found historical slug record for object_id: {historical.object_id}")
|
||||
try:
|
||||
park = cls.objects.get(pk=historical.object_id)
|
||||
print(f"Found park from historical slug: {park.name}")
|
||||
return park, True
|
||||
except cls.DoesNotExist:
|
||||
print(f"Park not found for historical slug record")
|
||||
pass
|
||||
print("Park not found for historical slug record")
|
||||
else:
|
||||
print("No historical slug record found")
|
||||
|
||||
|
||||
# Try pghistory events
|
||||
print(f"Searching pghistory events")
|
||||
print("Searching pghistory events")
|
||||
event_model = getattr(cls, 'event_model', None)
|
||||
if event_model:
|
||||
historical_event = event_model.objects.filter(
|
||||
slug=slug
|
||||
).order_by('-pgh_created_at').first()
|
||||
|
||||
|
||||
if historical_event:
|
||||
print(f"Found pghistory event for pgh_obj_id: {historical_event.pgh_obj_id}")
|
||||
print(
|
||||
f"Found pghistory event for pgh_obj_id: {historical_event.pgh_obj_id}")
|
||||
try:
|
||||
park = cls.objects.get(pk=historical_event.pgh_obj_id)
|
||||
print(f"Found park from pghistory: {park.name}")
|
||||
return park, True
|
||||
except cls.DoesNotExist:
|
||||
print(f"Park not found for pghistory event")
|
||||
pass
|
||||
print("Park not found for pghistory event")
|
||||
else:
|
||||
print("No pghistory event found")
|
||||
|
||||
raise cls.DoesNotExist("No park found with this slug")
|
||||
|
||||
raise cls.DoesNotExist("No park found with this slug")
|
||||
|
||||
@@ -22,6 +22,7 @@ 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__)
|
||||
|
||||
@@ -31,11 +32,11 @@ class Coordinates:
|
||||
"""Represents latitude and longitude coordinates."""
|
||||
latitude: float
|
||||
longitude: float
|
||||
|
||||
|
||||
def to_tuple(self) -> Tuple[float, float]:
|
||||
"""Return as (lat, lon) tuple."""
|
||||
return (self.latitude, self.longitude)
|
||||
|
||||
|
||||
def to_point(self) -> Point:
|
||||
"""Convert to Django Point object."""
|
||||
return Point(self.longitude, self.latitude, srid=4326)
|
||||
@@ -47,14 +48,14 @@ class RouteInfo:
|
||||
distance_km: float
|
||||
duration_minutes: int
|
||||
geometry: Optional[str] = None # Encoded polyline
|
||||
|
||||
|
||||
@property
|
||||
def formatted_distance(self) -> str:
|
||||
"""Return formatted distance string."""
|
||||
if self.distance_km < 1:
|
||||
return f"{self.distance_km * 1000:.0f}m"
|
||||
return f"{self.distance_km:.1f}km"
|
||||
|
||||
|
||||
@property
|
||||
def formatted_duration(self) -> str:
|
||||
"""Return formatted duration string."""
|
||||
@@ -74,7 +75,7 @@ class TripLeg:
|
||||
from_park: 'Park'
|
||||
to_park: 'Park'
|
||||
route: RouteInfo
|
||||
|
||||
|
||||
@property
|
||||
def parks_along_route(self) -> List['Park']:
|
||||
"""Get parks along this route segment."""
|
||||
@@ -89,12 +90,12 @@ class RoadTrip:
|
||||
legs: List[TripLeg]
|
||||
total_distance_km: float
|
||||
total_duration_minutes: int
|
||||
|
||||
|
||||
@property
|
||||
def formatted_total_distance(self) -> str:
|
||||
"""Return formatted total distance."""
|
||||
return f"{self.total_distance_km:.1f}km"
|
||||
|
||||
|
||||
@property
|
||||
def formatted_total_duration(self) -> str:
|
||||
"""Return formatted total duration."""
|
||||
@@ -110,21 +111,21 @@ class RoadTrip:
|
||||
|
||||
class RateLimiter:
|
||||
"""Simple rate limiter for API requests."""
|
||||
|
||||
|
||||
def __init__(self, max_requests_per_second: float = 1.0):
|
||||
self.max_requests_per_second = max_requests_per_second
|
||||
self.min_interval = 1.0 / max_requests_per_second
|
||||
self.last_request_time = 0.0
|
||||
|
||||
|
||||
def wait_if_needed(self):
|
||||
"""Wait if necessary to respect rate limits."""
|
||||
current_time = time.time()
|
||||
time_since_last = current_time - self.last_request_time
|
||||
|
||||
|
||||
if time_since_last < self.min_interval:
|
||||
wait_time = self.min_interval - time_since_last
|
||||
time.sleep(wait_time)
|
||||
|
||||
|
||||
self.last_request_time = time.time()
|
||||
|
||||
|
||||
@@ -137,74 +138,79 @@ class RoadTripService:
|
||||
"""
|
||||
Service for planning road trips between theme parks using OpenStreetMap APIs.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
self.nominatim_base_url = "https://nominatim.openstreetmap.org"
|
||||
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.route_cache_timeout = getattr(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.cache_timeout = getattr(
|
||||
settings, 'ROADTRIP_CACHE_TIMEOUT', 3600 * 24)
|
||||
self.route_cache_timeout = getattr(
|
||||
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)
|
||||
|
||||
|
||||
# Rate limiter
|
||||
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',
|
||||
})
|
||||
|
||||
|
||||
def _make_request(self, url: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Make HTTP request with rate limiting, retries, and error handling.
|
||||
"""
|
||||
self.rate_limiter.wait_if_needed()
|
||||
|
||||
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
response = self.session.get(
|
||||
url,
|
||||
params=params,
|
||||
url,
|
||||
params=params,
|
||||
timeout=self.request_timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"Request attempt {attempt + 1} failed: {e}")
|
||||
|
||||
|
||||
if attempt < self.max_retries - 1:
|
||||
wait_time = self.backoff_factor ** attempt
|
||||
time.sleep(wait_time)
|
||||
else:
|
||||
raise OSMAPIException(f"Failed to make request after {self.max_retries} attempts: {e}")
|
||||
|
||||
raise OSMAPIException(
|
||||
f"Failed to make request after {self.max_retries} attempts: {e}")
|
||||
|
||||
def geocode_address(self, address: str) -> Optional[Coordinates]:
|
||||
"""
|
||||
Convert address to coordinates using Nominatim geocoding service.
|
||||
|
||||
|
||||
Args:
|
||||
address: Address string to geocode
|
||||
|
||||
|
||||
Returns:
|
||||
Coordinates object or None if geocoding fails
|
||||
"""
|
||||
if not address or not address.strip():
|
||||
return None
|
||||
|
||||
|
||||
# Check cache first
|
||||
cache_key = f"roadtrip:geocode:{hash(address.lower().strip())}"
|
||||
cached_result = cache.get(cache_key)
|
||||
if cached_result:
|
||||
return Coordinates(**cached_result)
|
||||
|
||||
|
||||
try:
|
||||
params = {
|
||||
'q': address.strip(),
|
||||
@@ -212,186 +218,193 @@ class RoadTripService:
|
||||
'limit': 1,
|
||||
'addressdetails': 1,
|
||||
}
|
||||
|
||||
|
||||
url = f"{self.nominatim_base_url}/search"
|
||||
response = self._make_request(url, params)
|
||||
|
||||
|
||||
if response and len(response) > 0:
|
||||
result = response[0]
|
||||
coords = Coordinates(
|
||||
latitude=float(result['lat']),
|
||||
longitude=float(result['lon'])
|
||||
)
|
||||
|
||||
|
||||
# Cache the result
|
||||
cache.set(cache_key, {
|
||||
'latitude': coords.latitude,
|
||||
'longitude': coords.longitude
|
||||
}, self.cache_timeout)
|
||||
|
||||
logger.info(f"Geocoded '{address}' to {coords.latitude}, {coords.longitude}")
|
||||
|
||||
logger.info(
|
||||
f"Geocoded '{address}' to {coords.latitude}, {coords.longitude}")
|
||||
return coords
|
||||
else:
|
||||
logger.warning(f"No geocoding results for address: {address}")
|
||||
return None
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Geocoding failed for '{address}': {e}")
|
||||
return None
|
||||
|
||||
|
||||
def calculate_route(self, start_coords: Coordinates, end_coords: Coordinates) -> Optional[RouteInfo]:
|
||||
"""
|
||||
Calculate route between two coordinate points using OSRM.
|
||||
|
||||
|
||||
Args:
|
||||
start_coords: Starting coordinates
|
||||
end_coords: Ending coordinates
|
||||
|
||||
|
||||
Returns:
|
||||
RouteInfo object or None if routing fails
|
||||
"""
|
||||
if not start_coords or not end_coords:
|
||||
return None
|
||||
|
||||
|
||||
# Check cache first
|
||||
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}"
|
||||
url = f"{self.osrm_base_url}/{coords_string}"
|
||||
|
||||
|
||||
params = {
|
||||
'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]
|
||||
|
||||
|
||||
# Distance is in meters, convert to km
|
||||
distance_km = route_data['distance'] / 1000.0
|
||||
# Duration is in seconds, convert to minutes
|
||||
duration_minutes = int(route_data['duration'] / 60)
|
||||
|
||||
|
||||
route_info = RouteInfo(
|
||||
distance_km=distance_km,
|
||||
duration_minutes=duration_minutes,
|
||||
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)
|
||||
|
||||
logger.info(f"Route calculated: {route_info.formatted_distance}, {route_info.formatted_duration}")
|
||||
|
||||
logger.info(
|
||||
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")
|
||||
logger.warning(
|
||||
f"OSRM routing failed, falling back to straight-line distance")
|
||||
return self._calculate_straight_line_route(start_coords, end_coords)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Route calculation failed: {e}")
|
||||
# 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:
|
||||
"""
|
||||
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)
|
||||
|
||||
lat1, lon1 = math.radians(start_coords.latitude), math.radians(
|
||||
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)
|
||||
estimated_duration_minutes = int((distance_km * 1.25 / 80.0) * 60)
|
||||
|
||||
|
||||
return RouteInfo(
|
||||
distance_km=distance_km,
|
||||
duration_minutes=estimated_duration_minutes,
|
||||
geometry=None
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
|
||||
Args:
|
||||
start_park: Starting park
|
||||
end_park: Ending park
|
||||
max_detour_km: Maximum detour distance in kilometers
|
||||
|
||||
|
||||
Returns:
|
||||
List of parks along the route
|
||||
"""
|
||||
from parks.models import Park
|
||||
|
||||
|
||||
if not hasattr(start_park, 'location') or not hasattr(end_park, 'location'):
|
||||
return []
|
||||
|
||||
|
||||
if not start_park.location or not end_park.location:
|
||||
return []
|
||||
|
||||
|
||||
start_coords = start_park.coordinates
|
||||
end_coords = end_park.coordinates
|
||||
|
||||
|
||||
if not start_coords or not end_coords:
|
||||
return []
|
||||
|
||||
start_point = Point(start_coords[1], start_coords[0], srid=4326) # lon, lat
|
||||
|
||||
start_point = Point(
|
||||
start_coords[1], start_coords[0], srid=4326) # lon, lat
|
||||
end_point = Point(end_coords[1], end_coords[0], srid=4326)
|
||||
|
||||
|
||||
# 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')
|
||||
|
||||
|
||||
parks_along_route = []
|
||||
|
||||
|
||||
for park in candidate_parks:
|
||||
if not park.location or not park.location.point:
|
||||
continue
|
||||
|
||||
|
||||
park_coords = park.coordinates
|
||||
if not park_coords:
|
||||
continue
|
||||
|
||||
|
||||
# Calculate detour distance
|
||||
detour_distance = self._calculate_detour_distance(
|
||||
Coordinates(*start_coords),
|
||||
Coordinates(*end_coords),
|
||||
Coordinates(*park_coords)
|
||||
)
|
||||
|
||||
|
||||
if detour_distance and detour_distance <= max_detour_km:
|
||||
parks_along_route.append(park)
|
||||
|
||||
|
||||
return parks_along_route
|
||||
|
||||
|
||||
def _calculate_detour_distance(self, start: Coordinates, end: Coordinates, waypoint: Coordinates) -> Optional[float]:
|
||||
"""
|
||||
Calculate the detour distance when visiting a waypoint.
|
||||
@@ -401,128 +414,129 @@ class RoadTripService:
|
||||
direct_route = self.calculate_route(start, end)
|
||||
if not direct_route:
|
||||
return None
|
||||
|
||||
|
||||
# Route via waypoint
|
||||
route_to_waypoint = self.calculate_route(start, waypoint)
|
||||
route_from_waypoint = self.calculate_route(waypoint, end)
|
||||
|
||||
|
||||
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]:
|
||||
"""
|
||||
Create optimized multi-park road trip using simple nearest neighbor heuristic.
|
||||
|
||||
|
||||
Args:
|
||||
park_list: List of parks to visit
|
||||
|
||||
|
||||
Returns:
|
||||
RoadTrip object with optimized route
|
||||
"""
|
||||
if len(park_list) < 2:
|
||||
return None
|
||||
|
||||
|
||||
# For small numbers of parks, try all permutations
|
||||
if len(park_list) <= 6:
|
||||
return self._optimize_trip_exhaustive(park_list)
|
||||
else:
|
||||
return self._optimize_trip_nearest_neighbor(park_list)
|
||||
|
||||
|
||||
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')
|
||||
|
||||
|
||||
# Try all possible orders (excluding the first park as starting point)
|
||||
for perm in permutations(park_list[1:]):
|
||||
ordered_parks = [park_list[0]] + list(perm)
|
||||
trip = self._create_trip_from_order(ordered_parks)
|
||||
|
||||
|
||||
if trip and trip.total_distance_km < best_distance:
|
||||
best_distance = trip.total_distance_km
|
||||
best_trip = trip
|
||||
|
||||
|
||||
return best_trip
|
||||
|
||||
|
||||
def _optimize_trip_nearest_neighbor(self, park_list: List['Park']) -> Optional[RoadTrip]:
|
||||
"""
|
||||
Optimize trip using nearest neighbor heuristic (for larger lists).
|
||||
"""
|
||||
if not park_list:
|
||||
return None
|
||||
|
||||
|
||||
# Start with the first park
|
||||
current_park = park_list[0]
|
||||
ordered_parks = [current_park]
|
||||
remaining_parks = park_list[1:]
|
||||
|
||||
|
||||
while remaining_parks:
|
||||
# Find nearest unvisited park
|
||||
nearest_park = None
|
||||
min_distance = float('inf')
|
||||
|
||||
|
||||
current_coords = current_park.coordinates
|
||||
if not current_coords:
|
||||
break
|
||||
|
||||
|
||||
for park in remaining_parks:
|
||||
park_coords = park.coordinates
|
||||
if not park_coords:
|
||||
continue
|
||||
|
||||
|
||||
route = self.calculate_route(
|
||||
Coordinates(*current_coords),
|
||||
Coordinates(*park_coords)
|
||||
)
|
||||
|
||||
|
||||
if route and route.distance_km < min_distance:
|
||||
min_distance = route.distance_km
|
||||
nearest_park = park
|
||||
|
||||
|
||||
if nearest_park:
|
||||
ordered_parks.append(nearest_park)
|
||||
remaining_parks.remove(nearest_park)
|
||||
current_park = nearest_park
|
||||
else:
|
||||
break
|
||||
|
||||
|
||||
return self._create_trip_from_order(ordered_parks)
|
||||
|
||||
|
||||
def _create_trip_from_order(self, ordered_parks: List['Park']) -> Optional[RoadTrip]:
|
||||
"""
|
||||
Create a RoadTrip object from an ordered list of parks.
|
||||
"""
|
||||
if len(ordered_parks) < 2:
|
||||
return None
|
||||
|
||||
|
||||
legs = []
|
||||
total_distance = 0
|
||||
total_duration = 0
|
||||
|
||||
|
||||
for i in range(len(ordered_parks) - 1):
|
||||
from_park = ordered_parks[i]
|
||||
to_park = ordered_parks[i + 1]
|
||||
|
||||
|
||||
from_coords = from_park.coordinates
|
||||
to_coords = to_park.coordinates
|
||||
|
||||
|
||||
if not from_coords or not to_coords:
|
||||
continue
|
||||
|
||||
|
||||
route = self.calculate_route(
|
||||
Coordinates(*from_coords),
|
||||
Coordinates(*to_coords)
|
||||
)
|
||||
|
||||
|
||||
if route:
|
||||
legs.append(TripLeg(
|
||||
from_park=from_park,
|
||||
@@ -531,58 +545,59 @@ class RoadTripService:
|
||||
))
|
||||
total_distance += route.distance_km
|
||||
total_duration += route.duration_minutes
|
||||
|
||||
|
||||
if not legs:
|
||||
return None
|
||||
|
||||
|
||||
return RoadTrip(
|
||||
parks=ordered_parks,
|
||||
legs=legs,
|
||||
total_distance_km=total_distance,
|
||||
total_duration_minutes=total_duration
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
|
||||
Args:
|
||||
center_park: Center park for search
|
||||
radius_km: Search radius in kilometers
|
||||
|
||||
|
||||
Returns:
|
||||
List of dictionaries with park and distance information
|
||||
"""
|
||||
from parks.models import Park
|
||||
|
||||
|
||||
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')
|
||||
|
||||
|
||||
results = []
|
||||
|
||||
|
||||
for park in nearby_parks:
|
||||
park_coords = park.coordinates
|
||||
if not park_coords:
|
||||
continue
|
||||
|
||||
|
||||
route = self.calculate_route(
|
||||
Coordinates(*center_coords),
|
||||
Coordinates(*park_coords)
|
||||
)
|
||||
|
||||
|
||||
if route:
|
||||
results.append({
|
||||
'park': park,
|
||||
@@ -591,31 +606,31 @@ class RoadTripService:
|
||||
'formatted_distance': route.formatted_distance,
|
||||
'formatted_duration': route.formatted_duration,
|
||||
})
|
||||
|
||||
|
||||
# Sort by distance
|
||||
results.sort(key=lambda x: x['distance_km'])
|
||||
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def geocode_park_if_needed(self, park: 'Park') -> bool:
|
||||
"""
|
||||
Geocode park location if coordinates are missing.
|
||||
|
||||
|
||||
Args:
|
||||
park: Park to geocode
|
||||
|
||||
|
||||
Returns:
|
||||
True if geocoding succeeded or wasn't needed, False otherwise
|
||||
"""
|
||||
if not hasattr(park, 'location') or not park.location:
|
||||
return False
|
||||
|
||||
|
||||
location = park.location
|
||||
|
||||
|
||||
# If we already have coordinates, no need to geocode
|
||||
if location.point:
|
||||
return True
|
||||
|
||||
|
||||
# Build address string for geocoding
|
||||
address_parts = [
|
||||
park.name,
|
||||
@@ -625,15 +640,16 @@ class RoadTripService:
|
||||
location.country
|
||||
]
|
||||
address = ", ".join(part for part in address_parts if part)
|
||||
|
||||
|
||||
if not address:
|
||||
return False
|
||||
|
||||
|
||||
coords = self.geocode_address(address)
|
||||
if coords:
|
||||
location.set_coordinates(coords.latitude, coords.longitude)
|
||||
location.save()
|
||||
logger.info(f"Geocoded park '{park.name}' to {coords.latitude}, {coords.longitude}")
|
||||
logger.info(
|
||||
f"Geocoded park '{park.name}' to {coords.latitude}, {coords.longitude}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
@@ -7,7 +7,7 @@ from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, History
|
||||
from core.views import SlugRedirectMixin
|
||||
from .filters import ParkFilter
|
||||
from .forms import ParkForm
|
||||
from .models import Park, ParkArea
|
||||
from .models import Park, ParkArea, ParkReview as Review
|
||||
from django.http import HttpResponseRedirect, HttpResponse, HttpRequest, JsonResponse
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.contrib import messages
|
||||
|
||||
@@ -20,16 +20,18 @@ from .models import Park
|
||||
from .services.roadtrip import RoadTripService
|
||||
from core.services.map_service import unified_map_service
|
||||
from core.services.data_structures import LocationType, MapFilters
|
||||
JSON_DECODE_ERROR_MSG = 'Invalid JSON data'
|
||||
PARKS_ALONG_ROUTE_HTML = 'parks/partials/parks_along_route.html'
|
||||
|
||||
|
||||
class RoadTripViewMixin:
|
||||
"""Mixin providing common functionality for road trip views."""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.roadtrip_service = RoadTripService()
|
||||
|
||||
def get_roadtrip_context(self, request: HttpRequest) -> Dict[str, Any]:
|
||||
|
||||
def get_roadtrip_context(self) -> Dict[str, Any]:
|
||||
"""Get common context data for road trip views."""
|
||||
return {
|
||||
'roadtrip_api_urls': {
|
||||
@@ -46,21 +48,21 @@ class RoadTripViewMixin:
|
||||
class RoadTripPlannerView(RoadTripViewMixin, TemplateView):
|
||||
"""
|
||||
Main road trip planning interface.
|
||||
|
||||
|
||||
URL: /roadtrip/
|
||||
"""
|
||||
template_name = 'parks/roadtrip_planner.html'
|
||||
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context.update(self.get_roadtrip_context(self.request))
|
||||
|
||||
|
||||
# Get popular parks for suggestions
|
||||
popular_parks = Park.objects.filter(
|
||||
status='OPERATING',
|
||||
location__isnull=False
|
||||
).select_related('location', 'operator').order_by('-ride_count')[:20]
|
||||
|
||||
|
||||
context.update({
|
||||
'page_title': 'Road Trip Planner',
|
||||
'popular_parks': popular_parks,
|
||||
@@ -68,9 +70,9 @@ class RoadTripPlannerView(RoadTripViewMixin, TemplateView):
|
||||
'enable_route_optimization': True,
|
||||
'show_distance_estimates': True,
|
||||
})
|
||||
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def _get_countries_with_parks(self) -> List[str]:
|
||||
"""Get list of countries that have theme parks."""
|
||||
countries = Park.objects.filter(
|
||||
@@ -83,15 +85,15 @@ class RoadTripPlannerView(RoadTripViewMixin, TemplateView):
|
||||
class CreateTripView(RoadTripViewMixin, View):
|
||||
"""
|
||||
Generate optimized road trip routes.
|
||||
|
||||
|
||||
URL: /roadtrip/create/
|
||||
"""
|
||||
|
||||
|
||||
def post(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Create a new road trip with optimized routing."""
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
|
||||
|
||||
# Parse park IDs
|
||||
park_ids = data.get('park_ids', [])
|
||||
if not park_ids or len(park_ids) < 2:
|
||||
@@ -99,34 +101,34 @@ class CreateTripView(RoadTripViewMixin, View):
|
||||
'status': 'error',
|
||||
'message': 'At least 2 parks are required for a road trip'
|
||||
}, status=400)
|
||||
|
||||
|
||||
if len(park_ids) > 10:
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': 'Maximum 10 parks allowed per trip'
|
||||
}, status=400)
|
||||
|
||||
|
||||
# Get parks
|
||||
parks = list(Park.objects.filter(
|
||||
id__in=park_ids,
|
||||
location__isnull=False
|
||||
).select_related('location', 'operator'))
|
||||
|
||||
|
||||
if len(parks) != len(park_ids):
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': 'Some parks could not be found or do not have location data'
|
||||
}, status=400)
|
||||
|
||||
|
||||
# Create optimized trip
|
||||
trip = self.roadtrip_service.create_multi_park_trip(parks)
|
||||
|
||||
|
||||
if not trip:
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': 'Could not create optimized route for the selected parks'
|
||||
}, status=400)
|
||||
|
||||
|
||||
# Convert trip to dict for JSON response
|
||||
trip_data = {
|
||||
'parks': [self._park_to_dict(park) for park in trip.parks],
|
||||
@@ -136,24 +138,24 @@ class CreateTripView(RoadTripViewMixin, View):
|
||||
'formatted_total_distance': trip.formatted_total_distance,
|
||||
'formatted_total_duration': trip.formatted_total_duration,
|
||||
}
|
||||
|
||||
|
||||
return JsonResponse({
|
||||
'status': 'success',
|
||||
'data': trip_data,
|
||||
'trip_url': reverse('parks:roadtrip_detail', kwargs={'trip_id': 'temp'})
|
||||
})
|
||||
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': 'Invalid JSON data'
|
||||
'message': JSON_DECODE_ERROR_MSG
|
||||
}, status=400)
|
||||
except Exception as e:
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': f'Failed to create trip: {str(e)}'
|
||||
}, status=500)
|
||||
|
||||
|
||||
def _park_to_dict(self, park: Park) -> Dict[str, Any]:
|
||||
"""Convert park instance to dictionary."""
|
||||
return {
|
||||
@@ -166,7 +168,7 @@ class CreateTripView(RoadTripViewMixin, View):
|
||||
'ride_count': getattr(park, 'ride_count', 0),
|
||||
'url': reverse('parks:park_detail', kwargs={'slug': park.slug}),
|
||||
}
|
||||
|
||||
|
||||
def _leg_to_dict(self, leg) -> Dict[str, Any]:
|
||||
"""Convert trip leg to dictionary."""
|
||||
return {
|
||||
@@ -183,49 +185,50 @@ class CreateTripView(RoadTripViewMixin, View):
|
||||
class TripDetailView(RoadTripViewMixin, TemplateView):
|
||||
"""
|
||||
Show trip details and map.
|
||||
|
||||
|
||||
URL: /roadtrip/<trip_id>/
|
||||
"""
|
||||
template_name = 'parks/trip_detail.html'
|
||||
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context.update(self.get_roadtrip_context(self.request))
|
||||
|
||||
|
||||
# For now, this is a placeholder since we don't persist trips
|
||||
# In a full implementation, you would retrieve the trip from database
|
||||
trip_id = kwargs.get('trip_id')
|
||||
|
||||
|
||||
context.update({
|
||||
'page_title': f'Road Trip #{trip_id}',
|
||||
'trip_id': trip_id,
|
||||
'message': 'Trip details would be loaded here. Currently trips are not persisted.',
|
||||
})
|
||||
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class FindParksAlongRouteView(RoadTripViewMixin, View):
|
||||
"""
|
||||
HTMX endpoint for route-based park discovery.
|
||||
|
||||
|
||||
URL: /roadtrip/htmx/parks-along-route/
|
||||
"""
|
||||
|
||||
|
||||
def post(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Find parks along a route between two points."""
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
|
||||
|
||||
start_park_id = data.get('start_park_id')
|
||||
end_park_id = data.get('end_park_id')
|
||||
max_detour_km = min(100, max(10, float(data.get('max_detour_km', 50))))
|
||||
|
||||
max_detour_km = min(
|
||||
100, max(10, float(data.get('max_detour_km', 50))))
|
||||
|
||||
if not start_park_id or not end_park_id:
|
||||
return render(request, 'parks/partials/parks_along_route.html', {
|
||||
return render(request, PARKS_ALONG_ROUTE_HTML, {
|
||||
'error': 'Start and end parks are required'
|
||||
})
|
||||
|
||||
|
||||
# Get start and end parks
|
||||
try:
|
||||
start_park = Park.objects.select_related('location').get(
|
||||
@@ -235,29 +238,29 @@ class FindParksAlongRouteView(RoadTripViewMixin, View):
|
||||
id=end_park_id, location__isnull=False
|
||||
)
|
||||
except Park.DoesNotExist:
|
||||
return render(request, 'parks/partials/parks_along_route.html', {
|
||||
return render(request, PARKS_ALONG_ROUTE_HTML, {
|
||||
'error': 'One or both parks could not be found'
|
||||
})
|
||||
|
||||
|
||||
# Find parks along route
|
||||
parks_along_route = self.roadtrip_service.find_parks_along_route(
|
||||
start_park, end_park, max_detour_km
|
||||
)
|
||||
|
||||
return render(request, 'parks/partials/parks_along_route.html', {
|
||||
|
||||
return render(request, PARKS_ALONG_ROUTE_HTML, {
|
||||
'parks': parks_along_route,
|
||||
'start_park': start_park,
|
||||
'end_park': end_park,
|
||||
'max_detour_km': max_detour_km,
|
||||
'count': len(parks_along_route)
|
||||
})
|
||||
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return render(request, 'parks/partials/parks_along_route.html', {
|
||||
'error': 'Invalid request data'
|
||||
return render(request, PARKS_ALONG_ROUTE_HTML, {
|
||||
'error': JSON_DECODE_ERROR_MSG
|
||||
})
|
||||
except Exception as e:
|
||||
return render(request, 'parks/partials/parks_along_route.html', {
|
||||
return render(request, PARKS_ALONG_ROUTE_HTML, {
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
@@ -265,50 +268,50 @@ class FindParksAlongRouteView(RoadTripViewMixin, View):
|
||||
class GeocodeAddressView(RoadTripViewMixin, View):
|
||||
"""
|
||||
HTMX endpoint for geocoding addresses.
|
||||
|
||||
|
||||
URL: /roadtrip/htmx/geocode/
|
||||
"""
|
||||
|
||||
|
||||
def post(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Geocode an address and find nearby parks."""
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
address = data.get('address', '').strip()
|
||||
|
||||
|
||||
if not address:
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': 'Address is required'
|
||||
}, status=400)
|
||||
|
||||
|
||||
# Geocode the address
|
||||
coordinates = self.roadtrip_service.geocode_address(address)
|
||||
|
||||
|
||||
if not coordinates:
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': 'Could not geocode the provided address'
|
||||
}, status=400)
|
||||
|
||||
|
||||
# Find nearby parks
|
||||
radius_km = min(200, max(10, float(data.get('radius_km', 100))))
|
||||
|
||||
|
||||
# Use map service to find parks near coordinates
|
||||
from core.services.data_structures import GeoBounds
|
||||
|
||||
|
||||
# Create a bounding box around the coordinates
|
||||
lat_delta = radius_km / 111.0 # Rough conversion: 1 degree ≈ 111km
|
||||
lng_delta = radius_km / (111.0 * abs(coordinates.latitude / 90.0))
|
||||
|
||||
|
||||
bounds = GeoBounds(
|
||||
north=coordinates.latitude + lat_delta,
|
||||
south=coordinates.latitude - lat_delta,
|
||||
east=coordinates.longitude + lng_delta,
|
||||
west=coordinates.longitude - lng_delta
|
||||
)
|
||||
|
||||
|
||||
filters = MapFilters(location_types={LocationType.PARK})
|
||||
|
||||
|
||||
map_response = unified_map_service.get_locations_by_bounds(
|
||||
north=bounds.north,
|
||||
south=bounds.south,
|
||||
@@ -316,7 +319,7 @@ class GeocodeAddressView(RoadTripViewMixin, View):
|
||||
west=bounds.west,
|
||||
location_types={LocationType.PARK}
|
||||
)
|
||||
|
||||
|
||||
return JsonResponse({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
@@ -329,11 +332,11 @@ class GeocodeAddressView(RoadTripViewMixin, View):
|
||||
'radius_km': radius_km
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': 'Invalid JSON data'
|
||||
'message': JSON_DECODE_ERROR_MSG
|
||||
}, status=400)
|
||||
except Exception as e:
|
||||
return JsonResponse({
|
||||
@@ -345,24 +348,24 @@ class GeocodeAddressView(RoadTripViewMixin, View):
|
||||
class ParkDistanceCalculatorView(RoadTripViewMixin, View):
|
||||
"""
|
||||
HTMX endpoint for calculating distances between parks.
|
||||
|
||||
|
||||
URL: /roadtrip/htmx/distance/
|
||||
"""
|
||||
|
||||
|
||||
def post(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Calculate distance and duration between two parks."""
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
|
||||
|
||||
park1_id = data.get('park1_id')
|
||||
park2_id = data.get('park2_id')
|
||||
|
||||
|
||||
if not park1_id or not park2_id:
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': 'Both park IDs are required'
|
||||
}, status=400)
|
||||
|
||||
|
||||
# Get parks
|
||||
try:
|
||||
park1 = Park.objects.select_related('location').get(
|
||||
@@ -376,30 +379,30 @@ class ParkDistanceCalculatorView(RoadTripViewMixin, View):
|
||||
'status': 'error',
|
||||
'message': 'One or both parks could not be found'
|
||||
}, status=400)
|
||||
|
||||
|
||||
# Calculate route
|
||||
coords1 = park1.coordinates
|
||||
coords2 = park2.coordinates
|
||||
|
||||
|
||||
if not coords1 or not coords2:
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': 'One or both parks do not have coordinate data'
|
||||
}, status=400)
|
||||
|
||||
from ..services.roadtrip import Coordinates
|
||||
|
||||
|
||||
from services.roadtrip import Coordinates
|
||||
|
||||
route = self.roadtrip_service.calculate_route(
|
||||
Coordinates(*coords1),
|
||||
Coordinates(*coords2)
|
||||
)
|
||||
|
||||
|
||||
if not route:
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': 'Could not calculate route between parks'
|
||||
}, status=400)
|
||||
|
||||
|
||||
return JsonResponse({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
@@ -417,14 +420,14 @@ class ParkDistanceCalculatorView(RoadTripViewMixin, View):
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': 'Invalid JSON data'
|
||||
'message': JSON_DECODE_ERROR_MSG
|
||||
}, status=400)
|
||||
except Exception as e:
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}, status=500)
|
||||
}, status=500)
|
||||
|
||||
Reference in New Issue
Block a user