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

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)