""" Rides-specific location services with OpenStreetMap integration. Handles location management for individual rides within parks. """ import requests from typing import List, Dict, Any, Optional, Tuple from django.db import transaction import logging from ..models import RideLocation logger = logging.getLogger(__name__) class RideLocationService: """ Location service specifically for rides using OpenStreetMap integration. Focuses on precise positioning within parks and navigation assistance. """ NOMINATIM_BASE_URL = "https://nominatim.openstreetmap.org" USER_AGENT = "ThrillWiki/1.0 (https://thrillwiki.com)" @classmethod def create_ride_location( cls, *, ride, latitude: Optional[float] = None, longitude: Optional[float] = None, park_area: str = "", notes: str = "", entrance_notes: str = "", accessibility_notes: str = "", ) -> RideLocation: """ Create a location for a ride within a park. Args: ride: Ride instance latitude: Latitude coordinate (optional for rides) longitude: Longitude coordinate (optional for rides) park_area: Themed area within the park notes: General location notes entrance_notes: Entrance and navigation notes accessibility_notes: Accessibility information Returns: Created RideLocation instance """ with transaction.atomic(): ride_location = RideLocation( ride=ride, park_area=park_area, notes=notes, entrance_notes=entrance_notes, accessibility_notes=accessibility_notes, ) # Set coordinates if provided if latitude is not None and longitude is not None: ride_location.set_coordinates(latitude, longitude) ride_location.full_clean() ride_location.save() return ride_location @classmethod def update_ride_location( cls, ride_location: RideLocation, **updates ) -> RideLocation: """ Update ride location with validation. Args: ride_location: RideLocation instance to update **updates: Fields to update Returns: Updated RideLocation 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(ride_location, field): setattr(ride_location, field, value) # Update coordinates if provided if latitude is not None and longitude is not None: ride_location.set_coordinates(latitude, longitude) ride_location.full_clean() ride_location.save() return ride_location @classmethod def find_rides_in_area(cls, park, park_area: str) -> List[RideLocation]: """ Find all rides in a specific park area. Args: park: Park instance park_area: Name of the park area/land Returns: List of RideLocation instances in the area """ return list( RideLocation.objects.filter(ride__park=park, park_area__icontains=park_area) .select_related("ride") .order_by("ride__name") ) @classmethod def find_nearby_rides( cls, latitude: float, longitude: float, park=None, radius_meters: float = 500 ) -> List[RideLocation]: """ Find rides near given coordinates using PostGIS. Useful for finding rides near a specific location within a park. Args: latitude: Center latitude longitude: Center longitude park: Optional park to limit search to radius_meters: Search radius in meters (default: 500m) Returns: List of nearby RideLocation instances """ from django.contrib.gis.geos import Point from django.contrib.gis.measure import Distance center_point = Point(longitude, latitude, srid=4326) queryset = RideLocation.objects.filter( point__distance_lte=(center_point, Distance(m=radius_meters)), point__isnull=False, ) if park: queryset = queryset.filter(ride__park=park) return list( queryset.select_related("ride", "ride__park").order_by("point__distance") ) @classmethod def get_ride_navigation_info(cls, ride_location: RideLocation) -> Dict[str, Any]: """ Get comprehensive navigation information for a ride. Args: ride_location: RideLocation instance Returns: Dictionary with navigation information """ info = { "ride_name": ride_location.ride.name, "park_name": ride_location.ride.park.name, "park_area": ride_location.park_area, "has_coordinates": ride_location.has_coordinates, "entrance_notes": ride_location.entrance_notes, "accessibility_notes": ride_location.accessibility_notes, "general_notes": ride_location.notes, } # Add coordinate information if available if ride_location.has_coordinates: info.update( { "latitude": ride_location.latitude, "longitude": ride_location.longitude, "coordinates": ride_location.coordinates, } ) # Calculate distance to park entrance if park has location park_location = getattr(ride_location.ride.park, "location", None) if park_location and park_location.point: distance_km = ride_location.distance_to_park_location() if distance_km is not None: info["distance_from_park_entrance_km"] = round(distance_km, 2) return info @classmethod def estimate_ride_coordinates_from_park( cls, ride_location: RideLocation, area_offset_meters: Dict[str, Tuple[float, float]] = None, ) -> Optional[Tuple[float, float]]: """ Estimate ride coordinates based on park location and area. Useful when exact ride coordinates are not available. Args: ride_location: RideLocation instance area_offset_meters: Dictionary mapping area names to (north_offset, east_offset) in meters Returns: Estimated (latitude, longitude) tuple or None """ park_location = getattr(ride_location.ride.park, "location", None) if not park_location or not park_location.point: return None # Default area offsets (rough estimates for common themed areas) default_offsets = { "main street": (0, 0), # Usually at entrance "fantasyland": (200, 100), # Often north-east "tomorrowland": (100, 200), # Often east "frontierland": (-100, -200), # Often south-west "adventureland": (-200, 100), # Often south-east "new orleans square": (-150, -100), "critter country": (-200, -200), "galaxy's edge": (300, 300), # Often on periphery "cars land": (200, -200), "pixar pier": (0, 300), # Often waterfront } offsets = area_offset_meters or default_offsets # Find matching area offset area_lower = ride_location.park_area.lower() offset = None for area_name, area_offset in offsets.items(): if area_name in area_lower: offset = area_offset break if not offset: # Default small random offset if no specific area match import random offset = (random.randint(-100, 100), random.randint(-100, 100)) # Convert meter offsets to coordinate offsets # Rough conversion: 1 degree latitude ≈ 111,000 meters # 1 degree longitude varies by latitude, but we'll use a rough approximation lat_offset = offset[0] / 111000 # North offset in degrees lon_offset = offset[1] / ( 111000 * abs(park_location.latitude) * 0.01 ) # East offset estimated_lat = park_location.latitude + lat_offset estimated_lon = park_location.longitude + lon_offset return (estimated_lat, estimated_lon) @classmethod def bulk_update_ride_areas_from_osm(cls, park) -> int: """ Bulk update ride locations for a park using OSM data. Attempts to find more precise locations for rides within the park. Args: park: Park instance Returns: Number of ride locations updated """ updated_count = 0 park_location = getattr(park, "location", None) if not park_location or not park_location.point: return updated_count # Get all rides in the park that don't have precise coordinates ride_locations = RideLocation.objects.filter( ride__park=park, point__isnull=True ).select_related("ride") for ride_location in ride_locations: # Try to search for the specific ride within the park area search_query = f"{ride_location.ride.name} {park.name}" try: # Search for the ride specifically params = { "q": search_query, "format": "json", "limit": 5, "addressdetails": 1, "bounded": 1, # Restrict to viewbox # Create a bounding box around the park (roughly 2km radius) "viewbox": f"{park_location.longitude - 0.02},{park_location.latitude + 0.02},{park_location.longitude + 0.02},{park_location.latitude - 0.02}", } headers = {"User-Agent": cls.USER_AGENT} response = requests.get( f"{cls.NOMINATIM_BASE_URL}/search", params=params, headers=headers, timeout=5, ) if response.status_code == 200: results = response.json() # Look for results that might be the ride for result in results: display_name = result.get("display_name", "").lower() if ( ride_location.ride.name.lower() in display_name and park.name.lower() in display_name ): # Update the ride location ride_location.set_coordinates( float(result["lat"]), float(result["lon"]) ) ride_location.save() updated_count += 1 break except Exception as e: logger.warning( f"Error updating ride location for {ride_location.ride.name}: {str(e)}" ) continue return updated_count @classmethod def generate_park_area_map(cls, park) -> Dict[str, List[str]]: """ Generate a map of park areas and the rides in each area. Args: park: Park instance Returns: Dictionary mapping area names to lists of ride names """ area_map = {} ride_locations = ( RideLocation.objects.filter(ride__park=park) .select_related("ride") .order_by("park_area", "ride__name") ) for ride_location in ride_locations: area = ride_location.park_area or "Unknown Area" if area not in area_map: area_map[area] = [] area_map[area].append(ride_location.ride.name) return area_map