Files
thrillwiki_django_no_react/apps/rides/services/location_service.py
2025-09-21 20:19:12 -04:00

360 lines
12 KiB
Python

"""
Rides-specific location services with OpenStreetMap integration.
Handles location management for individual rides within parks.
"""
import requests
from typing import List, Dict, Any, Optional
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: Optional[Dict[str, List[float]]] = None,
) -> Optional[List[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] list 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