Files
pacnpal e4e36c7899 Add migrations for ParkPhoto and RidePhoto models with associated events
- Created ParkPhoto and ParkPhotoEvent models in the parks app, including fields for image, caption, alt text, and relationships to the Park model.
- Implemented triggers for insert and update operations on ParkPhoto to log changes in ParkPhotoEvent.
- Created RidePhoto and RidePhotoEvent models in the rides app, with similar structure and functionality as ParkPhoto.
- Added fields for photo type in RidePhoto and implemented corresponding triggers for logging changes.
- Established necessary indexes and unique constraints for both models to ensure data integrity and optimize queries.
2025-08-26 14:40:46 -04:00

492 lines
16 KiB
Python

"""
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", ""),
}