""" Parks-specific location services with OpenStreetMap integration. Handles geocoding, reverse geocoding, and location search for parks. """ import requests from typing import List, Dict, Any, Optional from django.core.cache import cache from django.db import transaction import logging from ..models import ParkLocation logger = logging.getLogger(__name__) class ParkLocationService: """ Location service specifically for parks using OpenStreetMap Nominatim API. """ NOMINATIM_BASE_URL = "https://nominatim.openstreetmap.org" USER_AGENT = "ThrillWiki/1.0 (https://thrillwiki.com)" @classmethod def search_locations(cls, query: str, limit: int = 10) -> Dict[str, Any]: """ Search for locations using OpenStreetMap Nominatim API. Optimized for finding theme parks and amusement parks. Args: query: Search query string limit: Maximum number of results (default: 10, max: 25) Returns: Dictionary with search results """ if not query.strip(): return {"count": 0, "results": [], "query": query} # Limit the number of results limit = min(limit, 25) # Check cache first cache_key = f"park_location_search:{query.lower()}:{limit}" cached_result = cache.get(cache_key) if cached_result: return cached_result try: params = { "q": query, "format": "json", "limit": limit, "addressdetails": 1, "extratags": 1, "namedetails": 1, "accept-language": "en", # Prioritize places that might be parks or entertainment venues "featuretype": "settlement,leisure,tourism", } headers = { "User-Agent": cls.USER_AGENT, } response = requests.get( f"{cls.NOMINATIM_BASE_URL}/search", params=params, headers=headers, timeout=10, ) response.raise_for_status() osm_results = response.json() # Transform OSM results to our format results = [] for item in osm_results: result = cls._transform_osm_result(item) if result: results.append(result) result_data = {"count": len(results), "results": results, "query": query} # Cache for 1 hour cache.set(cache_key, result_data, 3600) return result_data except requests.RequestException as e: logger.error(f"Error searching park locations: {str(e)}") return { "count": 0, "results": [], "query": query, "error": "Location search service temporarily unavailable", } @classmethod def reverse_geocode(cls, latitude: float, longitude: float) -> Dict[str, Any]: """ Reverse geocode coordinates to get location information using OSM. Args: latitude: Latitude coordinate longitude: Longitude coordinate Returns: Dictionary with location information """ # Validate coordinates if not (-90 <= latitude <= 90) or not (-180 <= longitude <= 180): return {"error": "Invalid coordinates"} # Check cache first cache_key = f"park_reverse_geocode:{latitude:.6f}:{longitude:.6f}" cached_result = cache.get(cache_key) if cached_result: return cached_result try: params = { "lat": latitude, "lon": longitude, "format": "json", "addressdetails": 1, "extratags": 1, "namedetails": 1, "accept-language": "en", } headers = { "User-Agent": cls.USER_AGENT, } response = requests.get( f"{cls.NOMINATIM_BASE_URL}/reverse", params=params, headers=headers, timeout=10, ) response.raise_for_status() osm_result = response.json() if "error" in osm_result: return {"error": "Location not found"} result = cls._transform_osm_reverse_result(osm_result) # Cache for 24 hours cache.set(cache_key, result, 86400) return result except requests.RequestException as e: logger.error(f"Error reverse geocoding park location: {str(e)}") return {"error": "Reverse geocoding service temporarily unavailable"} @classmethod def geocode_address(cls, address: str) -> Dict[str, Any]: """ Geocode an address to get coordinates using OSM. Args: address: Address string to geocode Returns: Dictionary with coordinates and location information """ if not address.strip(): return {"error": "Address is required"} # Use search_locations for geocoding results = cls.search_locations(address, limit=1) if results["count"] > 0: return results["results"][0] else: return {"error": "Address not found"} @classmethod def create_park_location( cls, *, park, latitude: Optional[float] = None, longitude: Optional[float] = None, street_address: str = "", city: str = "", state: str = "", country: str = "USA", postal_code: str = "", highway_exit: str = "", parking_notes: str = "", seasonal_notes: str = "", osm_id: Optional[int] = None, osm_type: str = "", ) -> ParkLocation: """ Create a location for a park with OSM integration. Args: park: Park instance latitude: Latitude coordinate longitude: Longitude coordinate street_address: Street address city: City name state: State/region name country: Country name (default: USA) postal_code: Postal/ZIP code highway_exit: Highway exit information parking_notes: Parking information seasonal_notes: Seasonal access notes osm_id: OpenStreetMap ID osm_type: OpenStreetMap type (node, way, relation) Returns: Created ParkLocation instance """ with transaction.atomic(): park_location = ParkLocation( park=park, street_address=street_address, city=city, state=state, country=country, postal_code=postal_code, highway_exit=highway_exit, parking_notes=parking_notes, seasonal_notes=seasonal_notes, osm_id=osm_id, osm_type=osm_type, ) # Set coordinates if provided if latitude is not None and longitude is not None: park_location.set_coordinates(latitude, longitude) park_location.full_clean() park_location.save() return park_location @classmethod def update_park_location( cls, park_location: ParkLocation, **updates ) -> ParkLocation: """ Update park location with validation. Args: park_location: ParkLocation instance to update **updates: Fields to update Returns: Updated ParkLocation instance """ with transaction.atomic(): # Handle coordinates separately latitude = updates.pop("latitude", None) longitude = updates.pop("longitude", None) # Update regular fields for field, value in updates.items(): if hasattr(park_location, field): setattr(park_location, field, value) # Update coordinates if provided if latitude is not None and longitude is not None: park_location.set_coordinates(latitude, longitude) park_location.full_clean() park_location.save() return park_location @classmethod def find_nearby_parks( cls, latitude: float, longitude: float, radius_km: float = 50 ) -> List[ParkLocation]: """ Find parks near given coordinates using PostGIS. Args: latitude: Center latitude longitude: Center longitude radius_km: Search radius in kilometers Returns: List of nearby ParkLocation instances """ from django.contrib.gis.geos import Point from django.contrib.gis.measure import Distance center_point = Point(longitude, latitude, srid=4326) return list( ParkLocation.objects.filter( point__distance_lte=(center_point, Distance(km=radius_km)) ) .select_related("park", "park__operator") .order_by("point__distance") ) @classmethod def enrich_location_from_osm(cls, park_location: ParkLocation) -> ParkLocation: """ Enrich park location data using OSM reverse geocoding. Args: park_location: ParkLocation instance to enrich Returns: Updated ParkLocation instance """ if not park_location.point: return park_location # Get detailed location info from OSM osm_data = cls.reverse_geocode(park_location.latitude, park_location.longitude) if "error" not in osm_data: updates = {} # Update missing address components if not park_location.street_address and osm_data.get("street_address"): updates["street_address"] = osm_data["street_address"] if not park_location.city and osm_data.get("city"): updates["city"] = osm_data["city"] if not park_location.state and osm_data.get("state"): updates["state"] = osm_data["state"] if not park_location.country and osm_data.get("country"): updates["country"] = osm_data["country"] if not park_location.postal_code and osm_data.get("postal_code"): updates["postal_code"] = osm_data["postal_code"] # Update OSM metadata if osm_data.get("osm_id"): updates["osm_id"] = osm_data["osm_id"] if osm_data.get("osm_type"): updates["osm_type"] = osm_data["osm_type"] if updates: return cls.update_park_location(park_location, **updates) return park_location @classmethod def _transform_osm_result( cls, osm_item: Dict[str, Any] ) -> Optional[Dict[str, Any]]: """Transform OSM search result to our standard format.""" try: address = osm_item.get("address", {}) # Extract address components street_number = address.get("house_number", "") street_name = address.get("road", "") street_address = f"{street_number} {street_name}".strip() city = ( address.get("city") or address.get("town") or address.get("village") or address.get("municipality") or "" ) state = ( address.get("state") or address.get("province") or address.get("region") or "" ) country = address.get("country", "") postal_code = address.get("postcode", "") # Build formatted address address_parts = [] if street_address: address_parts.append(street_address) if city: address_parts.append(city) if state: address_parts.append(state) if postal_code: address_parts.append(postal_code) if country: address_parts.append(country) formatted_address = ", ".join(address_parts) # Check if this might be a theme park or entertainment venue place_type = osm_item.get("type", "").lower() extratags = osm_item.get("extratags", {}) is_park_related = any( [ "park" in place_type, "theme" in place_type, "amusement" in place_type, "attraction" in place_type, extratags.get("tourism") == "theme_park", extratags.get("leisure") == "amusement_arcade", extratags.get("amenity") == "amusement_arcade", ] ) return { "name": osm_item.get("display_name", ""), "latitude": float(osm_item["lat"]), "longitude": float(osm_item["lon"]), "formatted_address": formatted_address, "street_address": street_address, "city": city, "state": state, "country": country, "postal_code": postal_code, "osm_id": osm_item.get("osm_id"), "osm_type": osm_item.get("osm_type"), "place_type": place_type, "importance": osm_item.get("importance", 0), "is_park_related": is_park_related, } except (KeyError, ValueError, TypeError) as e: logger.warning(f"Error transforming OSM result: {str(e)}") return None @classmethod def _transform_osm_reverse_result( cls, osm_result: Dict[str, Any] ) -> Dict[str, Any]: """Transform OSM reverse geocoding result to our standard format.""" address = osm_result.get("address", {}) # Extract address components street_number = address.get("house_number", "") street_name = address.get("road", "") street_address = f"{street_number} {street_name}".strip() city = ( address.get("city") or address.get("town") or address.get("village") or address.get("municipality") or "" ) state = ( address.get("state") or address.get("province") or address.get("region") or "" ) country = address.get("country", "") postal_code = address.get("postcode", "") # Build formatted address address_parts = [] if street_address: address_parts.append(street_address) if city: address_parts.append(city) if state: address_parts.append(state) if postal_code: address_parts.append(postal_code) if country: address_parts.append(country) formatted_address = ", ".join(address_parts) return { "name": osm_result.get("display_name", ""), "latitude": float(osm_result["lat"]), "longitude": float(osm_result["lon"]), "formatted_address": formatted_address, "street_address": street_address, "city": city, "state": state, "country": country, "postal_code": postal_code, "osm_id": osm_result.get("osm_id"), "osm_type": osm_result.get("osm_type"), "place_type": osm_result.get("type", ""), }