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:
pacnpal
2025-08-15 20:53:00 -04:00
parent da7c7e3381
commit b5bae44cb8
99 changed files with 18697 additions and 4010 deletions

1
core/forms/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .search import LocationSearchForm

105
core/forms/search.py Normal file
View File

@@ -0,0 +1,105 @@
from django import forms
from django.utils.translation import gettext_lazy as _
class LocationSearchForm(forms.Form):
"""
A comprehensive search form that includes text search, location-based
search, and content type filtering for a unified search experience.
"""
# Text search query
q = forms.CharField(
required=False,
label=_("Search Query"),
widget=forms.TextInput(attrs={
'placeholder': _("Search parks, rides, companies..."),
'class': 'w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white'
})
)
# Location-based search
location = forms.CharField(
required=False,
label=_("Near Location"),
widget=forms.TextInput(attrs={
'placeholder': _("City, address, or coordinates..."),
'id': 'location-input',
'class': 'w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white'
})
)
# Hidden fields for coordinates
lat = forms.FloatField(required=False, widget=forms.HiddenInput(attrs={'id': 'lat-input'}))
lng = forms.FloatField(required=False, widget=forms.HiddenInput(attrs={'id': 'lng-input'}))
# Search radius
radius_km = forms.ChoiceField(
required=False,
label=_("Search Radius"),
choices=[
('', _("Any distance")),
('5', _("5 km")),
('10', _("10 km")),
('25', _("25 km")),
('50', _("50 km")),
('100', _("100 km")),
('200', _("200 km")),
],
widget=forms.Select(attrs={
'class': 'w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white'
})
)
# Content type filters
search_parks = forms.BooleanField(
required=False,
initial=True,
label=_("Search Parks"),
widget=forms.CheckboxInput(attrs={'class': 'rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700'})
)
search_rides = forms.BooleanField(
required=False,
label=_("Search Rides"),
widget=forms.CheckboxInput(attrs={'class': 'rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700'})
)
search_companies = forms.BooleanField(
required=False,
label=_("Search Companies"),
widget=forms.CheckboxInput(attrs={'class': 'rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700'})
)
# Geographic filters
country = forms.CharField(
required=False,
widget=forms.TextInput(attrs={
'placeholder': _("Country"),
'class': 'w-full px-3 py-2 text-sm border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white'
})
)
state = forms.CharField(
required=False,
widget=forms.TextInput(attrs={
'placeholder': _("State/Region"),
'class': 'w-full px-3 py-2 text-sm border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white'
})
)
city = forms.CharField(
required=False,
widget=forms.TextInput(attrs={
'placeholder': _("City"),
'class': 'w-full px-3 py-2 text-sm border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white'
})
)
def clean(self):
cleaned_data = super().clean()
# If lat/lng are provided, ensure location field is populated for display
lat = cleaned_data.get('lat')
lng = cleaned_data.get('lng')
location = cleaned_data.get('location')
if lat and lng and not location:
cleaned_data['location'] = f"{lat}, {lng}"
return cleaned_data

View File

@@ -0,0 +1,393 @@
"""
Location-aware search service for ThrillWiki.
Integrates PostGIS location data with existing search functionality
to provide proximity-based search, location filtering, and geographic
search capabilities.
"""
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import Distance
from django.db.models import Q, Case, When, F, Value, CharField
from django.db.models.functions import Coalesce
from typing import Optional, List, Dict, Any, Tuple, Set
from dataclasses import dataclass
from parks.models import Park
from rides.models import Ride
from parks.models.companies import Company
from parks.models.location import ParkLocation
from rides.models.location import RideLocation
from parks.models.companies import CompanyHeadquarters
@dataclass
class LocationSearchFilters:
"""Filters for location-aware search queries."""
# Text search
search_query: Optional[str] = None
# Location-based filters
location_point: Optional[Point] = None
radius_km: Optional[float] = None
location_types: Optional[Set[str]] = None # 'park', 'ride', 'company'
# Geographic filters
country: Optional[str] = None
state: Optional[str] = None
city: Optional[str] = None
# Content-specific filters
park_status: Optional[List[str]] = None
ride_types: Optional[List[str]] = None
company_roles: Optional[List[str]] = None
# Result options
include_distance: bool = True
max_results: int = 100
@dataclass
class LocationSearchResult:
"""Single search result with location data."""
# Core data
content_type: str # 'park', 'ride', 'company'
object_id: int
name: str
description: Optional[str] = None
url: Optional[str] = None
# Location data
latitude: Optional[float] = None
longitude: Optional[float] = None
address: Optional[str] = None
city: Optional[str] = None
state: Optional[str] = None
country: Optional[str] = None
# Distance data (if proximity search)
distance_km: Optional[float] = None
# Additional metadata
status: Optional[str] = None
tags: Optional[List[str]] = None
rating: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
'content_type': self.content_type,
'object_id': self.object_id,
'name': self.name,
'description': self.description,
'url': self.url,
'location': {
'latitude': self.latitude,
'longitude': self.longitude,
'address': self.address,
'city': self.city,
'state': self.state,
'country': self.country,
},
'distance_km': self.distance_km,
'status': self.status,
'tags': self.tags or [],
'rating': self.rating,
}
class LocationSearchService:
"""Service for performing location-aware searches across ThrillWiki content."""
def search(self, filters: LocationSearchFilters) -> List[LocationSearchResult]:
"""
Perform a comprehensive location-aware search.
Args:
filters: Search filters and options
Returns:
List of search results with location data
"""
results = []
# Search each content type based on filters
if not filters.location_types or 'park' in filters.location_types:
results.extend(self._search_parks(filters))
if not filters.location_types or 'ride' in filters.location_types:
results.extend(self._search_rides(filters))
if not filters.location_types or 'company' in filters.location_types:
results.extend(self._search_companies(filters))
# Sort by distance if proximity search, otherwise by relevance
if filters.location_point and filters.include_distance:
results.sort(key=lambda x: x.distance_km or float('inf'))
else:
results.sort(key=lambda x: x.name.lower())
# Apply max results limit
return results[:filters.max_results]
def _search_parks(self, filters: LocationSearchFilters) -> List[LocationSearchResult]:
"""Search parks with location data."""
queryset = Park.objects.select_related('location', 'operator').all()
# Apply location filters
queryset = self._apply_location_filters(queryset, filters, 'location__point')
# Apply text search
if filters.search_query:
query = Q(name__icontains=filters.search_query) | \
Q(description__icontains=filters.search_query) | \
Q(location__city__icontains=filters.search_query) | \
Q(location__state__icontains=filters.search_query) | \
Q(location__country__icontains=filters.search_query)
queryset = queryset.filter(query)
# Apply park-specific filters
if filters.park_status:
queryset = queryset.filter(status__in=filters.park_status)
# Add distance annotation if proximity search
if filters.location_point and filters.include_distance:
queryset = queryset.annotate(
distance=Distance('location__point', filters.location_point)
).order_by('distance')
# Convert to search results
results = []
for park in queryset:
result = LocationSearchResult(
content_type='park',
object_id=park.id,
name=park.name,
description=park.description,
url=park.get_absolute_url() if hasattr(park, 'get_absolute_url') else None,
status=park.get_status_display(),
rating=float(park.average_rating) if park.average_rating else None,
tags=['park', park.status.lower()]
)
# Add location data
if hasattr(park, 'location') and park.location:
location = park.location
result.latitude = location.latitude
result.longitude = location.longitude
result.address = location.formatted_address
result.city = location.city
result.state = location.state
result.country = location.country
# Add distance if proximity search
if filters.location_point and filters.include_distance and hasattr(park, 'distance'):
result.distance_km = float(park.distance.km)
results.append(result)
return results
def _search_rides(self, filters: LocationSearchFilters) -> List[LocationSearchResult]:
"""Search rides with location data."""
queryset = Ride.objects.select_related('park', 'location').all()
# Apply location filters
queryset = self._apply_location_filters(queryset, filters, 'location__point')
# Apply text search
if filters.search_query:
query = Q(name__icontains=filters.search_query) | \
Q(description__icontains=filters.search_query) | \
Q(park__name__icontains=filters.search_query) | \
Q(location__park_area__icontains=filters.search_query)
queryset = queryset.filter(query)
# Apply ride-specific filters
if filters.ride_types:
queryset = queryset.filter(ride_type__in=filters.ride_types)
# Add distance annotation if proximity search
if filters.location_point and filters.include_distance:
queryset = queryset.annotate(
distance=Distance('location__point', filters.location_point)
).order_by('distance')
# Convert to search results
results = []
for ride in queryset:
result = LocationSearchResult(
content_type='ride',
object_id=ride.id,
name=ride.name,
description=ride.description,
url=ride.get_absolute_url() if hasattr(ride, 'get_absolute_url') else None,
status=ride.status,
tags=['ride', ride.ride_type.lower() if ride.ride_type else 'attraction']
)
# Add location data from ride location or park location
location = None
if hasattr(ride, 'location') and ride.location:
location = ride.location
result.latitude = location.latitude
result.longitude = location.longitude
result.address = f"{ride.park.name} - {location.park_area}" if location.park_area else ride.park.name
# Add distance if proximity search
if filters.location_point and filters.include_distance and hasattr(ride, 'distance'):
result.distance_km = float(ride.distance.km)
# Fall back to park location if no specific ride location
elif ride.park and hasattr(ride.park, 'location') and ride.park.location:
park_location = ride.park.location
result.latitude = park_location.latitude
result.longitude = park_location.longitude
result.address = park_location.formatted_address
result.city = park_location.city
result.state = park_location.state
result.country = park_location.country
results.append(result)
return results
def _search_companies(self, filters: LocationSearchFilters) -> List[LocationSearchResult]:
"""Search companies with headquarters location data."""
queryset = Company.objects.select_related('headquarters').all()
# Apply location filters
queryset = self._apply_location_filters(queryset, filters, 'headquarters__point')
# Apply text search
if filters.search_query:
query = Q(name__icontains=filters.search_query) | \
Q(description__icontains=filters.search_query) | \
Q(headquarters__city__icontains=filters.search_query) | \
Q(headquarters__state_province__icontains=filters.search_query) | \
Q(headquarters__country__icontains=filters.search_query)
queryset = queryset.filter(query)
# Apply company-specific filters
if filters.company_roles:
queryset = queryset.filter(roles__overlap=filters.company_roles)
# Add distance annotation if proximity search
if filters.location_point and filters.include_distance:
queryset = queryset.annotate(
distance=Distance('headquarters__point', filters.location_point)
).order_by('distance')
# Convert to search results
results = []
for company in queryset:
result = LocationSearchResult(
content_type='company',
object_id=company.id,
name=company.name,
description=company.description,
url=company.get_absolute_url() if hasattr(company, 'get_absolute_url') else None,
tags=['company'] + (company.roles or [])
)
# Add location data
if hasattr(company, 'headquarters') and company.headquarters:
hq = company.headquarters
result.latitude = hq.latitude
result.longitude = hq.longitude
result.address = hq.formatted_address
result.city = hq.city
result.state = hq.state_province
result.country = hq.country
# Add distance if proximity search
if filters.location_point and filters.include_distance and hasattr(company, 'distance'):
result.distance_km = float(company.distance.km)
results.append(result)
return results
def _apply_location_filters(self, queryset, filters: LocationSearchFilters, point_field: str):
"""Apply common location filters to a queryset."""
# Proximity filter
if filters.location_point and filters.radius_km:
distance = Distance(km=filters.radius_km)
queryset = queryset.filter(**{
f'{point_field}__distance_lte': (filters.location_point, distance)
})
# Geographic filters - adjust field names based on model
if filters.country:
if 'headquarters' in point_field:
queryset = queryset.filter(headquarters__country__icontains=filters.country)
else:
location_field = point_field.split('__')[0]
queryset = queryset.filter(**{f'{location_field}__country__icontains': filters.country})
if filters.state:
if 'headquarters' in point_field:
queryset = queryset.filter(headquarters__state_province__icontains=filters.state)
else:
location_field = point_field.split('__')[0]
queryset = queryset.filter(**{f'{location_field}__state__icontains': filters.state})
if filters.city:
location_field = point_field.split('__')[0]
queryset = queryset.filter(**{f'{location_field}__city__icontains': filters.city})
return queryset
def suggest_locations(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
"""
Get location suggestions for autocomplete.
Args:
query: Search query string
limit: Maximum number of suggestions
Returns:
List of location suggestions
"""
suggestions = []
if len(query) < 2:
return suggestions
# Get park location suggestions
park_locations = ParkLocation.objects.filter(
Q(park__name__icontains=query) |
Q(city__icontains=query) |
Q(state__icontains=query)
).select_related('park')[:limit//3]
for location in park_locations:
suggestions.append({
'type': 'park',
'name': location.park.name,
'address': location.formatted_address,
'coordinates': location.coordinates,
'url': location.park.get_absolute_url() if hasattr(location.park, 'get_absolute_url') else None
})
# Get city suggestions
cities = ParkLocation.objects.filter(
city__icontains=query
).values('city', 'state', 'country').distinct()[:limit//3]
for city_data in cities:
suggestions.append({
'type': 'city',
'name': f"{city_data['city']}, {city_data['state']}",
'address': f"{city_data['city']}, {city_data['state']}, {city_data['country']}",
'coordinates': None
})
return suggestions[:limit]
# Global instance
location_search_service = LocationSearchService()

33
core/urls/maps.py Normal file
View File

@@ -0,0 +1,33 @@
"""
URL patterns for map views.
Includes both HTML views and HTMX endpoints.
"""
from django.urls import path
from ..views.maps import (
UniversalMapView,
ParkMapView,
NearbyLocationsView,
LocationFilterView,
LocationSearchView,
MapBoundsUpdateView,
LocationDetailModalView,
LocationListView,
)
app_name = 'maps'
urlpatterns = [
# Main map views
path('', UniversalMapView.as_view(), name='universal_map'),
path('parks/', ParkMapView.as_view(), name='park_map'),
path('nearby/', NearbyLocationsView.as_view(), name='nearby_locations'),
path('list/', LocationListView.as_view(), name='location_list'),
# HTMX endpoints for dynamic updates
path('htmx/filter/', LocationFilterView.as_view(), name='htmx_filter'),
path('htmx/search/', LocationSearchView.as_view(), name='htmx_search'),
path('htmx/bounds/', MapBoundsUpdateView.as_view(), name='htmx_bounds_update'),
path('htmx/location/<str:location_type>/<int:location_id>/',
LocationDetailModalView.as_view(), name='htmx_location_detail'),
]

View File

@@ -1,5 +1,10 @@
from django.urls import path
from core.views.search import AdaptiveSearchView, FilterFormView
from core.views.search import (
AdaptiveSearchView,
FilterFormView,
LocationSearchView,
LocationSuggestionsView
)
from rides.views import RideSearchView
app_name = 'search'
@@ -9,4 +14,8 @@ urlpatterns = [
path('parks/filters/', FilterFormView.as_view(), name='filter_form'),
path('rides/', RideSearchView.as_view(), name='ride_search'),
path('rides/results/', RideSearchView.as_view(), name='ride_search_results'),
# Location-aware search
path('location/', LocationSearchView.as_view(), name='location_search'),
path('location/suggestions/', LocationSuggestionsView.as_view(), name='location_suggestions'),
]

View File

@@ -1,33 +1,62 @@
"""
API views for the unified map service.
Enhanced with proper error handling, pagination, and performance optimizations.
"""
import json
import logging
from typing import Dict, Any, Optional, Set
from django.http import JsonResponse, HttpRequest, Http404
from django.views.decorators.http import require_http_methods
from django.views.decorators.cache import cache_page
from django.views.decorators.gzip import gzip_page
from django.utils.decorators import method_decorator
from django.views import View
from django.core.exceptions import ValidationError
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.conf import settings
import time
from ..services.map_service import unified_map_service
from ..services.data_structures import GeoBounds, MapFilters, LocationType
logger = logging.getLogger(__name__)
class MapAPIView(View):
"""Base view for map API endpoints with common functionality."""
# Pagination settings
DEFAULT_PAGE_SIZE = 50
MAX_PAGE_SIZE = 200
def dispatch(self, request, *args, **kwargs):
"""Add CORS headers and handle preflight requests."""
response = super().dispatch(request, *args, **kwargs)
"""Add CORS headers, compression, and handle preflight requests."""
start_time = time.time()
# Add CORS headers for API access
response['Access-Control-Allow-Origin'] = '*'
response['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS'
response['Access-Control-Allow-Headers'] = 'Content-Type, Authorization'
return response
try:
response = super().dispatch(request, *args, **kwargs)
# Add CORS headers for API access
response['Access-Control-Allow-Origin'] = '*'
response['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS'
response['Access-Control-Allow-Headers'] = 'Content-Type, Authorization'
# Add performance headers
response['X-Response-Time'] = f"{(time.time() - start_time) * 1000:.2f}ms"
# Add compression hint for large responses
if hasattr(response, 'content') and len(response.content) > 1024:
response['Content-Encoding'] = 'gzip'
return response
except Exception as e:
logger.error(f"API error in {request.path}: {str(e)}", exc_info=True)
return self._error_response(
"An internal server error occurred",
status=500
)
def options(self, request, *args, **kwargs):
"""Handle preflight CORS requests."""
@@ -42,16 +71,48 @@ class MapAPIView(View):
west = request.GET.get('west')
if all(param is not None for param in [north, south, east, west]):
return GeoBounds(
bounds = GeoBounds(
north=float(north),
south=float(south),
east=float(east),
west=float(west)
)
# Validate bounds
if not (-90 <= bounds.south <= bounds.north <= 90):
raise ValidationError("Invalid latitude bounds")
if not (-180 <= bounds.west <= bounds.east <= 180):
raise ValidationError("Invalid longitude bounds")
return bounds
return None
except (ValueError, TypeError) as e:
raise ValidationError(f"Invalid bounds parameters: {e}")
def _parse_pagination(self, request: HttpRequest) -> Dict[str, int]:
"""Parse pagination parameters from request."""
try:
page = max(1, int(request.GET.get('page', 1)))
page_size = min(
self.MAX_PAGE_SIZE,
max(1, int(request.GET.get('page_size', self.DEFAULT_PAGE_SIZE)))
)
offset = (page - 1) * page_size
return {
'page': page,
'page_size': page_size,
'offset': offset,
'limit': page_size
}
except (ValueError, TypeError):
return {
'page': 1,
'page_size': self.DEFAULT_PAGE_SIZE,
'offset': 0,
'limit': self.DEFAULT_PAGE_SIZE
}
def _parse_filters(self, request: HttpRequest) -> Optional[MapFilters]:
"""Parse filtering parameters from request."""
try:
@@ -61,9 +122,10 @@ class MapAPIView(View):
location_types_param = request.GET.get('types')
if location_types_param:
type_strings = location_types_param.split(',')
valid_types = {lt.value for lt in LocationType}
filters.location_types = {
LocationType(t.strip()) for t in type_strings
if t.strip() in [lt.value for lt in LocationType]
LocationType(t.strip()) for t in type_strings
if t.strip() in valid_types
}
# Park status
@@ -81,18 +143,30 @@ class MapAPIView(View):
if company_roles_param:
filters.company_roles = set(company_roles_param.split(','))
# Search query
filters.search_query = request.GET.get('q') or request.GET.get('search')
# Search query with length validation
search_query = request.GET.get('q') or request.GET.get('search')
if search_query and len(search_query.strip()) >= 2:
filters.search_query = search_query.strip()
# Rating filter
# Rating filter with validation
min_rating_param = request.GET.get('min_rating')
if min_rating_param:
filters.min_rating = float(min_rating_param)
min_rating = float(min_rating_param)
if 0 <= min_rating <= 10:
filters.min_rating = min_rating
# Geographic filters
filters.country = request.GET.get('country')
filters.state = request.GET.get('state')
filters.city = request.GET.get('city')
# Geographic filters with validation
country = request.GET.get('country', '').strip()
if country and len(country) >= 2:
filters.country = country
state = request.GET.get('state', '').strip()
if state and len(state) >= 2:
filters.state = state
city = request.GET.get('city', '').strip()
if city and len(city) >= 2:
filters.city = city
# Coordinates requirement
has_coordinates_param = request.GET.get('has_coordinates')
@@ -117,13 +191,78 @@ class MapAPIView(View):
except (ValueError, TypeError):
return 10 # Default zoom level
def _error_response(self, message: str, status: int = 400) -> JsonResponse:
"""Return standardized error response."""
return JsonResponse({
def _create_paginated_response(self, data: list, total_count: int,
pagination: Dict[str, int], request: HttpRequest) -> Dict[str, Any]:
"""Create paginated response with metadata."""
total_pages = (total_count + pagination['page_size'] - 1) // pagination['page_size']
# Build pagination URLs
base_url = request.build_absolute_uri(request.path)
query_params = request.GET.copy()
next_url = None
if pagination['page'] < total_pages:
query_params['page'] = pagination['page'] + 1
next_url = f"{base_url}?{query_params.urlencode()}"
prev_url = None
if pagination['page'] > 1:
query_params['page'] = pagination['page'] - 1
prev_url = f"{base_url}?{query_params.urlencode()}"
return {
'status': 'success',
'data': data,
'pagination': {
'page': pagination['page'],
'page_size': pagination['page_size'],
'total_pages': total_pages,
'total_count': total_count,
'has_next': pagination['page'] < total_pages,
'has_previous': pagination['page'] > 1,
'next_url': next_url,
'previous_url': prev_url,
}
}
def _error_response(self, message: str, status: int = 400,
error_code: str = None, details: Dict[str, Any] = None) -> JsonResponse:
"""Return standardized error response with enhanced information."""
response_data = {
'status': 'error',
'message': message,
'timestamp': time.time(),
'data': None
}, status=status)
}
if error_code:
response_data['error_code'] = error_code
if details:
response_data['details'] = details
# Add request ID for debugging in production
if hasattr(settings, 'DEBUG') and not settings.DEBUG:
response_data['request_id'] = getattr(self.request, 'id', None)
return JsonResponse(response_data, status=status)
def _success_response(self, data: Any, message: str = None,
metadata: Dict[str, Any] = None) -> JsonResponse:
"""Return standardized success response."""
response_data = {
'status': 'success',
'data': data,
'timestamp': time.time(),
}
if message:
response_data['message'] = message
if metadata:
response_data['metadata'] = metadata
return JsonResponse(response_data)
class MapLocationsView(MapAPIView):
@@ -144,6 +283,7 @@ class MapLocationsView(MapAPIView):
"""
@method_decorator(cache_page(300)) # Cache for 5 minutes
@method_decorator(gzip_page) # Compress large responses
def get(self, request: HttpRequest) -> JsonResponse:
"""Get map locations with optional clustering and filtering."""
try:
@@ -151,6 +291,7 @@ class MapLocationsView(MapAPIView):
bounds = self._parse_bounds(request)
filters = self._parse_filters(request)
zoom_level = self._parse_zoom_level(request)
pagination = self._parse_pagination(request)
# Clustering preference
cluster_param = request.GET.get('cluster', 'true')
@@ -160,6 +301,13 @@ class MapLocationsView(MapAPIView):
use_cache_param = request.GET.get('cache', 'true')
use_cache = use_cache_param.lower() in ['true', '1', 'yes']
# Validate request
if not enable_clustering and not bounds and not filters:
return self._error_response(
"Either bounds, filters, or clustering must be specified for non-clustered requests",
error_code="MISSING_PARAMETERS"
)
# Get map data
response = unified_map_service.get_map_data(
bounds=bounds,
@@ -169,12 +317,42 @@ class MapLocationsView(MapAPIView):
use_cache=use_cache
)
return JsonResponse(response.to_dict())
# Handle pagination for non-clustered results
if not enable_clustering and response.locations:
start_idx = pagination['offset']
end_idx = start_idx + pagination['limit']
paginated_locations = response.locations[start_idx:end_idx]
return JsonResponse(self._create_paginated_response(
[loc.to_dict() for loc in paginated_locations],
len(response.locations),
pagination,
request
))
# For clustered results, return as-is with metadata
response_dict = response.to_dict()
return self._success_response(
response_dict,
metadata={
'clustered': response.clustered,
'cache_hit': response.cache_hit,
'query_time_ms': response.query_time_ms,
'filters_applied': response.filters_applied
}
)
except ValidationError as e:
return self._error_response(str(e), 400)
logger.warning(f"Validation error in MapLocationsView: {str(e)}")
return self._error_response(str(e), 400, error_code="VALIDATION_ERROR")
except Exception as e:
return self._error_response(f"Internal server error: {str(e)}", 500)
logger.error(f"Error in MapLocationsView: {str(e)}", exc_info=True)
return self._error_response(
"Failed to retrieve map locations",
500,
error_code="INTERNAL_ERROR"
)
class MapLocationDetailView(MapAPIView):
@@ -189,22 +367,50 @@ class MapLocationDetailView(MapAPIView):
"""Get detailed information for a specific location."""
try:
# Validate location type
if location_type not in [lt.value for lt in LocationType]:
return self._error_response(f"Invalid location type: {location_type}", 400)
valid_types = [lt.value for lt in LocationType]
if location_type not in valid_types:
return self._error_response(
f"Invalid location type: {location_type}. Valid types: {', '.join(valid_types)}",
400,
error_code="INVALID_LOCATION_TYPE"
)
# Validate location ID
if location_id <= 0:
return self._error_response(
"Location ID must be a positive integer",
400,
error_code="INVALID_LOCATION_ID"
)
# Get location details
location = unified_map_service.get_location_details(location_type, location_id)
if not location:
return self._error_response("Location not found", 404)
return self._error_response(
f"Location not found: {location_type}/{location_id}",
404,
error_code="LOCATION_NOT_FOUND"
)
return JsonResponse({
'status': 'success',
'data': location.to_dict()
})
return self._success_response(
location.to_dict(),
metadata={
'location_type': location_type,
'location_id': location_id
}
)
except ValueError as e:
logger.warning(f"Value error in MapLocationDetailView: {str(e)}")
return self._error_response(str(e), 400, error_code="INVALID_PARAMETER")
except Exception as e:
return self._error_response(f"Internal server error: {str(e)}", 500)
logger.error(f"Error in MapLocationDetailView: {str(e)}", exc_info=True)
return self._error_response(
"Failed to retrieve location details",
500,
error_code="INTERNAL_ERROR"
)
class MapSearchView(MapAPIView):
@@ -219,54 +425,83 @@ class MapSearchView(MapAPIView):
- limit: Maximum results (default 50)
"""
@method_decorator(gzip_page) # Compress responses
def get(self, request: HttpRequest) -> JsonResponse:
"""Search locations by text query."""
"""Search locations by text query with pagination."""
try:
# Get search query
query = request.GET.get('q')
# Get and validate search query
query = request.GET.get('q', '').strip()
if not query:
return self._error_response("Search query 'q' parameter is required", 400)
return self._error_response(
"Search query 'q' parameter is required",
400,
error_code="MISSING_QUERY"
)
# Parse optional parameters
if len(query) < 2:
return self._error_response(
"Search query must be at least 2 characters long",
400,
error_code="QUERY_TOO_SHORT"
)
# Parse parameters
bounds = self._parse_bounds(request)
pagination = self._parse_pagination(request)
# Parse location types
location_types = None
types_param = request.GET.get('types')
if types_param:
try:
valid_types = {lt.value for lt in LocationType}
location_types = {
LocationType(t.strip()) for t in types_param.split(',')
if t.strip() in [lt.value for lt in LocationType]
if t.strip() in valid_types
}
except ValueError:
return self._error_response("Invalid location types", 400)
return self._error_response(
"Invalid location types",
400,
error_code="INVALID_TYPES"
)
# Parse limit
limit = min(100, max(1, int(request.GET.get('limit', '50'))))
# Set reasonable search limit (higher for search than general listings)
search_limit = min(500, pagination['page'] * pagination['page_size'])
# Perform search
locations = unified_map_service.search_locations(
query=query,
bounds=bounds,
location_types=location_types,
limit=limit
limit=search_limit
)
return JsonResponse({
'status': 'success',
'data': {
'locations': [loc.to_dict() for loc in locations],
'query': query,
'count': len(locations),
'limit': limit
}
})
# Apply pagination
start_idx = pagination['offset']
end_idx = start_idx + pagination['limit']
paginated_locations = locations[start_idx:end_idx]
return JsonResponse(self._create_paginated_response(
[loc.to_dict() for loc in paginated_locations],
len(locations),
pagination,
request
))
except ValidationError as e:
logger.warning(f"Validation error in MapSearchView: {str(e)}")
return self._error_response(str(e), 400, error_code="VALIDATION_ERROR")
except ValueError as e:
return self._error_response(str(e), 400)
logger.warning(f"Value error in MapSearchView: {str(e)}")
return self._error_response(str(e), 400, error_code="INVALID_PARAMETER")
except Exception as e:
return self._error_response(f"Internal server error: {str(e)}", 500)
logger.error(f"Error in MapSearchView: {str(e)}", exc_info=True)
return self._error_response(
"Search failed due to internal error",
500,
error_code="SEARCH_FAILED"
)
class MapBoundsView(MapAPIView):

400
core/views/maps.py Normal file
View File

@@ -0,0 +1,400 @@
"""
HTML views for the unified map service.
Provides web interfaces for map functionality with HTMX integration.
"""
import json
from typing import Dict, Any, Optional, Set
from django.shortcuts import render, get_object_or_404
from django.http import JsonResponse, HttpRequest, HttpResponse
from django.views.generic import TemplateView, View
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.paginator import Paginator
from django.core.exceptions import ValidationError
from django.db.models import Q
from ..services.map_service import unified_map_service
from ..services.data_structures import GeoBounds, MapFilters, LocationType
class MapViewMixin:
"""Mixin providing common functionality for map views."""
def get_map_context(self, request: HttpRequest) -> Dict[str, Any]:
"""Get common context data for map views."""
return {
'map_api_urls': {
'locations': '/api/map/locations/',
'search': '/api/map/search/',
'bounds': '/api/map/bounds/',
'location_detail': '/api/map/locations/',
},
'location_types': [lt.value for lt in LocationType],
'default_zoom': 10,
'enable_clustering': True,
'enable_search': True,
}
def parse_location_types(self, request: HttpRequest) -> Optional[Set[LocationType]]:
"""Parse location types from request parameters."""
types_param = request.GET.get('types')
if types_param:
try:
return {
LocationType(t.strip()) for t in types_param.split(',')
if t.strip() in [lt.value for lt in LocationType]
}
except ValueError:
return None
return None
class UniversalMapView(MapViewMixin, TemplateView):
"""
Main universal map view showing all location types.
URL: /maps/
"""
template_name = 'maps/universal_map.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(self.get_map_context(self.request))
# Additional context for universal map
context.update({
'page_title': 'Interactive Map - All Locations',
'map_type': 'universal',
'show_all_types': True,
'initial_location_types': [lt.value for lt in LocationType],
'filters_enabled': True,
})
# Handle initial bounds from query parameters
if all(param in self.request.GET for param in ['north', 'south', 'east', 'west']):
try:
context['initial_bounds'] = {
'north': float(self.request.GET['north']),
'south': float(self.request.GET['south']),
'east': float(self.request.GET['east']),
'west': float(self.request.GET['west']),
}
except (ValueError, TypeError):
pass
return context
class ParkMapView(MapViewMixin, TemplateView):
"""
Map view focused specifically on parks.
URL: /maps/parks/
"""
template_name = 'maps/park_map.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(self.get_map_context(self.request))
# Park-specific context
context.update({
'page_title': 'Theme Parks Map',
'map_type': 'parks',
'show_all_types': False,
'initial_location_types': [LocationType.PARK.value],
'filters_enabled': True,
'park_specific_filters': True,
})
return context
class NearbyLocationsView(MapViewMixin, TemplateView):
"""
View for showing locations near a specific point.
URL: /maps/nearby/
"""
template_name = 'maps/nearby_locations.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(self.get_map_context(self.request))
# Parse coordinates from query parameters
lat = self.request.GET.get('lat')
lng = self.request.GET.get('lng')
radius = self.request.GET.get('radius', '50') # Default 50km radius
if lat and lng:
try:
center_lat = float(lat)
center_lng = float(lng)
search_radius = min(200, max(1, float(radius))) # Clamp between 1-200km
context.update({
'page_title': f'Locations Near {center_lat:.4f}, {center_lng:.4f}',
'map_type': 'nearby',
'center_coordinates': {'lat': center_lat, 'lng': center_lng},
'search_radius': search_radius,
'show_radius_circle': True,
})
except (ValueError, TypeError):
context['error'] = 'Invalid coordinates provided'
else:
context.update({
'page_title': 'Nearby Locations',
'map_type': 'nearby',
'prompt_for_location': True,
})
return context
class LocationFilterView(MapViewMixin, View):
"""
HTMX endpoint for updating map when filters change.
URL: /maps/htmx/filter/
"""
def get(self, request: HttpRequest) -> HttpResponse:
"""Return filtered location data for HTMX updates."""
try:
# Parse filter parameters
location_types = self.parse_location_types(request)
search_query = request.GET.get('q', '').strip()
country = request.GET.get('country', '').strip()
state = request.GET.get('state', '').strip()
# Create filters
filters = None
if any([location_types, search_query, country, state]):
filters = MapFilters(
location_types=location_types,
search_query=search_query or None,
country=country or None,
state=state or None,
has_coordinates=True
)
# Get filtered locations
map_response = unified_map_service.get_map_data(
filters=filters,
zoom_level=int(request.GET.get('zoom', '10')),
cluster=request.GET.get('cluster', 'true').lower() == 'true'
)
# Return JSON response for HTMX
return JsonResponse({
'status': 'success',
'data': map_response.to_dict(),
'filters_applied': map_response.filters_applied
})
except Exception as e:
return JsonResponse({
'status': 'error',
'message': str(e)
}, status=400)
class LocationSearchView(MapViewMixin, View):
"""
HTMX endpoint for real-time location search.
URL: /maps/htmx/search/
"""
def get(self, request: HttpRequest) -> HttpResponse:
"""Return search results for HTMX updates."""
query = request.GET.get('q', '').strip()
if not query or len(query) < 3:
return render(request, 'maps/partials/search_results.html', {
'results': [],
'query': query,
'message': 'Enter at least 3 characters to search'
})
try:
# Parse optional location types
location_types = self.parse_location_types(request)
limit = min(20, max(5, int(request.GET.get('limit', '10'))))
# Perform search
results = unified_map_service.search_locations(
query=query,
location_types=location_types,
limit=limit
)
return render(request, 'maps/partials/search_results.html', {
'results': results,
'query': query,
'count': len(results)
})
except Exception as e:
return render(request, 'maps/partials/search_results.html', {
'results': [],
'query': query,
'error': str(e)
})
class MapBoundsUpdateView(MapViewMixin, View):
"""
HTMX endpoint for updating locations when map bounds change.
URL: /maps/htmx/bounds/
"""
def post(self, request: HttpRequest) -> HttpResponse:
"""Update map data when bounds change."""
try:
data = json.loads(request.body)
# Parse bounds
bounds = GeoBounds(
north=float(data['north']),
south=float(data['south']),
east=float(data['east']),
west=float(data['west'])
)
# Parse additional parameters
zoom_level = int(data.get('zoom', 10))
location_types = None
if 'types' in data:
location_types = {
LocationType(t) for t in data['types']
if t in [lt.value for lt in LocationType]
}
# Create filters if needed
filters = None
if location_types:
filters = MapFilters(location_types=location_types)
# Get updated map data
map_response = unified_map_service.get_locations_by_bounds(
north=bounds.north,
south=bounds.south,
east=bounds.east,
west=bounds.west,
location_types=location_types,
zoom_level=zoom_level
)
return JsonResponse({
'status': 'success',
'data': map_response.to_dict()
})
except (json.JSONDecodeError, ValueError, KeyError) as e:
return JsonResponse({
'status': 'error',
'message': f'Invalid request data: {str(e)}'
}, status=400)
except Exception as e:
return JsonResponse({
'status': 'error',
'message': str(e)
}, status=500)
class LocationDetailModalView(MapViewMixin, View):
"""
HTMX endpoint for showing location details in modal.
URL: /maps/htmx/location/<type>/<id>/
"""
def get(self, request: HttpRequest, location_type: str, location_id: int) -> HttpResponse:
"""Return location detail modal content."""
try:
# Validate location type
if location_type not in [lt.value for lt in LocationType]:
return render(request, 'maps/partials/location_modal.html', {
'error': f'Invalid location type: {location_type}'
})
# Get location details
location = unified_map_service.get_location_details(location_type, location_id)
if not location:
return render(request, 'maps/partials/location_modal.html', {
'error': 'Location not found'
})
return render(request, 'maps/partials/location_modal.html', {
'location': location,
'location_type': location_type
})
except Exception as e:
return render(request, 'maps/partials/location_modal.html', {
'error': str(e)
})
class LocationListView(MapViewMixin, TemplateView):
"""
View for listing locations with pagination (non-map view).
URL: /maps/list/
"""
template_name = 'maps/location_list.html'
paginate_by = 20
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Parse filters
location_types = self.parse_location_types(self.request)
search_query = self.request.GET.get('q', '').strip()
country = self.request.GET.get('country', '').strip()
state = self.request.GET.get('state', '').strip()
# Create filters
filters = None
if any([location_types, search_query, country, state]):
filters = MapFilters(
location_types=location_types,
search_query=search_query or None,
country=country or None,
state=state or None,
has_coordinates=True
)
# Get locations without clustering
map_response = unified_map_service.get_map_data(
filters=filters,
cluster=False,
use_cache=True
)
# Paginate results
paginator = Paginator(map_response.locations, self.paginate_by)
page_number = self.request.GET.get('page')
page_obj = paginator.get_page(page_number)
context.update({
'page_title': 'All Locations',
'locations': page_obj,
'total_count': map_response.total_count,
'applied_filters': filters,
'location_types': [lt.value for lt in LocationType],
'current_filters': {
'types': self.request.GET.getlist('types'),
'q': search_query,
'country': country,
'state': state,
}
})
return context

View File

@@ -1,6 +1,11 @@
from django.views.generic import TemplateView
from django.http import JsonResponse
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import Distance
from parks.models import Park
from parks.filters import ParkFilter
from core.services.location_search import location_search_service, LocationSearchFilters
from core.forms.search import LocationSearchForm
class AdaptiveSearchView(TemplateView):
template_name = "core/search/results.html"
@@ -9,7 +14,7 @@ class AdaptiveSearchView(TemplateView):
"""
Get the base queryset, optimized with select_related and prefetch_related
"""
return Park.objects.select_related('owner').prefetch_related(
return Park.objects.select_related('operator', 'property_owner').prefetch_related(
'location',
'photos'
).all()
@@ -27,10 +32,17 @@ class AdaptiveSearchView(TemplateView):
context = super().get_context_data(**kwargs)
filterset = self.get_filterset()
# Check if location-based search is being used
location_search = self.request.GET.get('location_search', '').strip()
near_location = self.request.GET.get('near_location', '').strip()
# Add location search context
context.update({
'results': filterset.qs,
'filters': filterset,
'applied_filters': bool(self.request.GET), # Check if any filters are applied
'is_location_search': bool(location_search or near_location),
'location_search_query': location_search or near_location,
})
return context
@@ -46,3 +58,107 @@ class FilterFormView(TemplateView):
filterset = ParkFilter(self.request.GET, queryset=Park.objects.all())
context['filters'] = filterset
return context
class LocationSearchView(TemplateView):
"""
Enhanced search view with comprehensive location search capabilities.
"""
template_name = "core/search/location_results.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Build search filters from request parameters
filters = self._build_search_filters()
# Perform search
results = location_search_service.search(filters)
# Group results by type for better presentation
grouped_results = {
'parks': [r for r in results if r.content_type == 'park'],
'rides': [r for r in results if r.content_type == 'ride'],
'companies': [r for r in results if r.content_type == 'company'],
}
context.update({
'results': results,
'grouped_results': grouped_results,
'total_results': len(results),
'search_filters': filters,
'has_location_filter': bool(filters.location_point),
'search_form': LocationSearchForm(self.request.GET),
})
return context
def _build_search_filters(self) -> LocationSearchFilters:
"""Build LocationSearchFilters from request parameters."""
form = LocationSearchForm(self.request.GET)
form.is_valid() # Populate cleaned_data
# Parse location coordinates if provided
location_point = None
lat = form.cleaned_data.get('lat')
lng = form.cleaned_data.get('lng')
if lat and lng:
try:
location_point = Point(float(lng), float(lat), srid=4326)
except (ValueError, TypeError):
location_point = None
# Parse location types
location_types = set()
if form.cleaned_data.get('search_parks'):
location_types.add('park')
if form.cleaned_data.get('search_rides'):
location_types.add('ride')
if form.cleaned_data.get('search_companies'):
location_types.add('company')
# If no specific types selected, search all
if not location_types:
location_types = {'park', 'ride', 'company'}
# Parse radius
radius_km = None
radius_str = form.cleaned_data.get('radius_km', '').strip()
if radius_str:
try:
radius_km = float(radius_str)
radius_km = max(1, min(500, radius_km)) # Clamp between 1-500km
except (ValueError, TypeError):
radius_km = None
return LocationSearchFilters(
search_query=form.cleaned_data.get('q', '').strip() or None,
location_point=location_point,
radius_km=radius_km,
location_types=location_types if location_types else None,
country=form.cleaned_data.get('country', '').strip() or None,
state=form.cleaned_data.get('state', '').strip() or None,
city=form.cleaned_data.get('city', '').strip() or None,
park_status=self.request.GET.getlist('park_status') or None,
include_distance=True,
max_results=int(self.request.GET.get('limit', 100))
)
class LocationSuggestionsView(TemplateView):
"""
AJAX endpoint for location search suggestions.
"""
def get(self, request, *args, **kwargs):
query = request.GET.get('q', '').strip()
limit = int(request.GET.get('limit', 10))
if len(query) < 2:
return JsonResponse({'suggestions': []})
try:
suggestions = location_search_service.suggest_locations(query, limit)
return JsonResponse({'suggestions': suggestions})
except Exception as e:
return JsonResponse({'error': str(e)}, status=500)