mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 10:51:09 -05:00
Add Road Trip Planner template with interactive map and trip management features
- Implemented a new HTML template for the Road Trip Planner. - Integrated Leaflet.js for interactive mapping and routing. - Added functionality for searching and selecting parks to include in a trip. - Enabled drag-and-drop reordering of selected parks. - Included trip optimization and route calculation features. - Created a summary display for trip statistics. - Added functionality to save trips and manage saved trips. - Enhanced UI with responsive design and dark mode support.
This commit is contained in:
430
parks/views_roadtrip.py
Normal file
430
parks/views_roadtrip.py
Normal file
@@ -0,0 +1,430 @@
|
||||
"""
|
||||
Road trip planning views for theme parks.
|
||||
Provides interfaces for creating and managing multi-park road trips.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Dict, Any, List, Optional
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django.http import JsonResponse, HttpRequest, HttpResponse, Http404
|
||||
from django.views.generic import TemplateView, View, DetailView
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.contrib import messages
|
||||
from django.urls import reverse
|
||||
from django.db.models import Q
|
||||
|
||||
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
|
||||
|
||||
|
||||
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]:
|
||||
"""Get common context data for road trip views."""
|
||||
return {
|
||||
'roadtrip_api_urls': {
|
||||
'create_trip': '/roadtrip/create/',
|
||||
'find_parks_along_route': '/roadtrip/htmx/parks-along-route/',
|
||||
'geocode': '/roadtrip/htmx/geocode/',
|
||||
},
|
||||
'max_parks_per_trip': 10,
|
||||
'default_detour_km': 50,
|
||||
'enable_osm_integration': True,
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
'countries_with_parks': self._get_countries_with_parks(),
|
||||
'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(
|
||||
status='OPERATING',
|
||||
location__country__isnull=False
|
||||
).values_list('location__country', flat=True).distinct().order_by('location__country')
|
||||
return list(countries)
|
||||
|
||||
|
||||
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:
|
||||
return JsonResponse({
|
||||
'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],
|
||||
'legs': [self._leg_to_dict(leg) for leg in trip.legs],
|
||||
'total_distance_km': trip.total_distance_km,
|
||||
'total_duration_minutes': trip.total_duration_minutes,
|
||||
'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'
|
||||
}, 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 {
|
||||
'id': park.id,
|
||||
'name': park.name,
|
||||
'slug': park.slug,
|
||||
'formatted_location': getattr(park, 'formatted_location', ''),
|
||||
'coordinates': park.coordinates,
|
||||
'operator': park.operator.name if park.operator else None,
|
||||
'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 {
|
||||
'from_park': self._park_to_dict(leg.from_park),
|
||||
'to_park': self._park_to_dict(leg.to_park),
|
||||
'distance_km': leg.route.distance_km,
|
||||
'duration_minutes': leg.route.duration_minutes,
|
||||
'formatted_distance': leg.route.formatted_distance,
|
||||
'formatted_duration': leg.route.formatted_duration,
|
||||
'geometry': leg.route.geometry,
|
||||
}
|
||||
|
||||
|
||||
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))))
|
||||
|
||||
if not start_park_id or not end_park_id:
|
||||
return render(request, 'parks/partials/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(
|
||||
id=start_park_id, location__isnull=False
|
||||
)
|
||||
end_park = Park.objects.select_related('location').get(
|
||||
id=end_park_id, location__isnull=False
|
||||
)
|
||||
except Park.DoesNotExist:
|
||||
return render(request, 'parks/partials/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', {
|
||||
'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'
|
||||
})
|
||||
except Exception as e:
|
||||
return render(request, 'parks/partials/parks_along_route.html', {
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
|
||||
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,
|
||||
east=bounds.east,
|
||||
west=bounds.west,
|
||||
location_types={LocationType.PARK}
|
||||
)
|
||||
|
||||
return JsonResponse({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'coordinates': {
|
||||
'latitude': coordinates.latitude,
|
||||
'longitude': coordinates.longitude
|
||||
},
|
||||
'address': address,
|
||||
'nearby_parks': [loc.to_dict() for loc in map_response.locations[:20]],
|
||||
'radius_km': radius_km
|
||||
}
|
||||
})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': 'Invalid JSON data'
|
||||
}, status=400)
|
||||
except Exception as e:
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}, status=500)
|
||||
|
||||
|
||||
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(
|
||||
id=park1_id, location__isnull=False
|
||||
)
|
||||
park2 = Park.objects.select_related('location').get(
|
||||
id=park2_id, location__isnull=False
|
||||
)
|
||||
except Park.DoesNotExist:
|
||||
return JsonResponse({
|
||||
'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
|
||||
|
||||
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': {
|
||||
'distance_km': route.distance_km,
|
||||
'duration_minutes': route.duration_minutes,
|
||||
'formatted_distance': route.formatted_distance,
|
||||
'formatted_duration': route.formatted_duration,
|
||||
'park1': {
|
||||
'name': park1.name,
|
||||
'formatted_location': getattr(park1, 'formatted_location', '')
|
||||
},
|
||||
'park2': {
|
||||
'name': park2.name,
|
||||
'formatted_location': getattr(park2, 'formatted_location', '')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': 'Invalid JSON data'
|
||||
}, status=400)
|
||||
except Exception as e:
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}, status=500)
|
||||
Reference in New Issue
Block a user