mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 09: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:
1
core/forms/__init__.py
Normal file
1
core/forms/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .search import LocationSearchForm
|
||||
105
core/forms/search.py
Normal file
105
core/forms/search.py
Normal 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
|
||||
393
core/services/location_search.py
Normal file
393
core/services/location_search.py
Normal 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
33
core/urls/maps.py
Normal 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'),
|
||||
]
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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
400
core/views/maps.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user