mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 11:51:10 -05:00
Refactor code structure and remove redundant changes
This commit is contained in:
@@ -1,7 +1,4 @@
|
||||
"""
|
||||
Services for the rides app.
|
||||
"""
|
||||
from .location_service import RideLocationService
|
||||
from .media_service import RideMediaService
|
||||
|
||||
from .ranking_service import RideRankingService
|
||||
|
||||
__all__ = ["RideRankingService"]
|
||||
__all__ = ["RideLocationService", "RideMediaService"]
|
||||
|
||||
362
backend/apps/rides/services/location_service.py
Normal file
362
backend/apps/rides/services/location_service.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
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.conf import settings
|
||||
from django.core.cache import cache
|
||||
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
|
||||
305
backend/apps/rides/services/media_service.py
Normal file
305
backend/apps/rides/services/media_service.py
Normal file
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
Ride-specific media service for ThrillWiki.
|
||||
|
||||
This module provides media management functionality specific to rides.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Dict, Any
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.db import transaction
|
||||
from django.contrib.auth import get_user_model
|
||||
from apps.core.services.media_service import MediaService
|
||||
from ..models import Ride, RidePhoto
|
||||
|
||||
User = get_user_model()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RideMediaService:
|
||||
"""Service for managing ride-specific media operations."""
|
||||
|
||||
@staticmethod
|
||||
def upload_photo(
|
||||
ride: Ride,
|
||||
image_file: UploadedFile,
|
||||
user: User,
|
||||
caption: str = "",
|
||||
alt_text: str = "",
|
||||
photo_type: str = "exterior",
|
||||
is_primary: bool = False,
|
||||
auto_approve: bool = False
|
||||
) -> RidePhoto:
|
||||
"""
|
||||
Upload a photo for a ride.
|
||||
|
||||
Args:
|
||||
ride: Ride instance
|
||||
image_file: Uploaded image file
|
||||
user: User uploading the photo
|
||||
caption: Photo caption
|
||||
alt_text: Alt text for accessibility
|
||||
photo_type: Type of photo (exterior, queue, station, etc.)
|
||||
is_primary: Whether this should be the primary photo
|
||||
auto_approve: Whether to auto-approve the photo
|
||||
|
||||
Returns:
|
||||
Created RidePhoto instance
|
||||
|
||||
Raises:
|
||||
ValueError: If image validation fails
|
||||
"""
|
||||
# Validate image file
|
||||
is_valid, error_message = MediaService.validate_image_file(image_file)
|
||||
if not is_valid:
|
||||
raise ValueError(error_message)
|
||||
|
||||
# Process image
|
||||
processed_image = MediaService.process_image(image_file)
|
||||
|
||||
with transaction.atomic():
|
||||
# Create photo instance
|
||||
photo = RidePhoto(
|
||||
ride=ride,
|
||||
image=processed_image,
|
||||
caption=caption or MediaService.generate_default_caption(user.username),
|
||||
alt_text=alt_text,
|
||||
photo_type=photo_type,
|
||||
is_primary=is_primary,
|
||||
is_approved=auto_approve,
|
||||
uploaded_by=user
|
||||
)
|
||||
|
||||
# Extract EXIF date
|
||||
photo.date_taken = MediaService.extract_exif_date(processed_image)
|
||||
|
||||
photo.save()
|
||||
|
||||
logger.info(f"Photo uploaded for ride {ride.slug} by user {user.username}")
|
||||
return photo
|
||||
|
||||
@staticmethod
|
||||
def get_ride_photos(
|
||||
ride: Ride,
|
||||
approved_only: bool = True,
|
||||
primary_first: bool = True,
|
||||
photo_type: Optional[str] = None
|
||||
) -> List[RidePhoto]:
|
||||
"""
|
||||
Get photos for a ride.
|
||||
|
||||
Args:
|
||||
ride: Ride instance
|
||||
approved_only: Whether to only return approved photos
|
||||
primary_first: Whether to order primary photos first
|
||||
photo_type: Filter by photo type (optional)
|
||||
|
||||
Returns:
|
||||
List of RidePhoto instances
|
||||
"""
|
||||
queryset = ride.photos.all()
|
||||
|
||||
if approved_only:
|
||||
queryset = queryset.filter(is_approved=True)
|
||||
|
||||
if photo_type:
|
||||
queryset = queryset.filter(photo_type=photo_type)
|
||||
|
||||
if primary_first:
|
||||
queryset = queryset.order_by('-is_primary', '-created_at')
|
||||
else:
|
||||
queryset = queryset.order_by('-created_at')
|
||||
|
||||
return list(queryset)
|
||||
|
||||
@staticmethod
|
||||
def get_primary_photo(ride: Ride) -> Optional[RidePhoto]:
|
||||
"""
|
||||
Get the primary photo for a ride.
|
||||
|
||||
Args:
|
||||
ride: Ride instance
|
||||
|
||||
Returns:
|
||||
Primary RidePhoto instance or None
|
||||
"""
|
||||
try:
|
||||
return ride.photos.filter(is_primary=True, is_approved=True).first()
|
||||
except RidePhoto.DoesNotExist:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_photos_by_type(ride: Ride, photo_type: str) -> List[RidePhoto]:
|
||||
"""
|
||||
Get photos of a specific type for a ride.
|
||||
|
||||
Args:
|
||||
ride: Ride instance
|
||||
photo_type: Type of photos to retrieve
|
||||
|
||||
Returns:
|
||||
List of RidePhoto instances
|
||||
"""
|
||||
return list(
|
||||
ride.photos.filter(
|
||||
photo_type=photo_type,
|
||||
is_approved=True
|
||||
).order_by('-created_at')
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def set_primary_photo(ride: Ride, photo: RidePhoto) -> bool:
|
||||
"""
|
||||
Set a photo as the primary photo for a ride.
|
||||
|
||||
Args:
|
||||
ride: Ride instance
|
||||
photo: RidePhoto to set as primary
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if photo.ride != ride:
|
||||
return False
|
||||
|
||||
with transaction.atomic():
|
||||
# Unset current primary
|
||||
ride.photos.filter(is_primary=True).update(is_primary=False)
|
||||
|
||||
# Set new primary
|
||||
photo.is_primary = True
|
||||
photo.save()
|
||||
|
||||
logger.info(f"Set photo {photo.pk} as primary for ride {ride.slug}")
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def approve_photo(photo: RidePhoto, approved_by: User) -> bool:
|
||||
"""
|
||||
Approve a ride photo.
|
||||
|
||||
Args:
|
||||
photo: RidePhoto to approve
|
||||
approved_by: User approving the photo
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
photo.is_approved = True
|
||||
photo.save()
|
||||
|
||||
logger.info(f"Photo {photo.pk} approved by user {approved_by.username}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to approve photo {photo.pk}: {str(e)}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def delete_photo(photo: RidePhoto, deleted_by: User) -> bool:
|
||||
"""
|
||||
Delete a ride photo.
|
||||
|
||||
Args:
|
||||
photo: RidePhoto to delete
|
||||
deleted_by: User deleting the photo
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
ride_slug = photo.ride.slug
|
||||
photo_id = photo.pk
|
||||
|
||||
# Delete the file and database record
|
||||
if photo.image:
|
||||
photo.image.delete(save=False)
|
||||
photo.delete()
|
||||
|
||||
logger.info(
|
||||
f"Photo {photo_id} deleted from ride {ride_slug} by user {deleted_by.username}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete photo {photo.pk}: {str(e)}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_photo_stats(ride: Ride) -> Dict[str, Any]:
|
||||
"""
|
||||
Get photo statistics for a ride.
|
||||
|
||||
Args:
|
||||
ride: Ride instance
|
||||
|
||||
Returns:
|
||||
Dictionary with photo statistics
|
||||
"""
|
||||
photos = ride.photos.all()
|
||||
|
||||
# Get counts by photo type
|
||||
type_counts = {}
|
||||
for photo_type, _ in RidePhoto._meta.get_field('photo_type').choices:
|
||||
type_counts[photo_type] = photos.filter(photo_type=photo_type).count()
|
||||
|
||||
return {
|
||||
"total_photos": photos.count(),
|
||||
"approved_photos": photos.filter(is_approved=True).count(),
|
||||
"pending_photos": photos.filter(is_approved=False).count(),
|
||||
"has_primary": photos.filter(is_primary=True).exists(),
|
||||
"recent_uploads": photos.order_by('-created_at')[:5].count(),
|
||||
"by_type": type_counts
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def bulk_approve_photos(photos: List[RidePhoto], approved_by: User) -> int:
|
||||
"""
|
||||
Bulk approve multiple photos.
|
||||
|
||||
Args:
|
||||
photos: List of RidePhoto instances to approve
|
||||
approved_by: User approving the photos
|
||||
|
||||
Returns:
|
||||
Number of photos successfully approved
|
||||
"""
|
||||
approved_count = 0
|
||||
|
||||
with transaction.atomic():
|
||||
for photo in photos:
|
||||
if RideMediaService.approve_photo(photo, approved_by):
|
||||
approved_count += 1
|
||||
|
||||
logger.info(
|
||||
f"Bulk approved {approved_count} photos by user {approved_by.username}")
|
||||
return approved_count
|
||||
|
||||
@staticmethod
|
||||
def get_construction_timeline(ride: Ride) -> List[RidePhoto]:
|
||||
"""
|
||||
Get construction photos ordered chronologically.
|
||||
|
||||
Args:
|
||||
ride: Ride instance
|
||||
|
||||
Returns:
|
||||
List of construction RidePhoto instances ordered by date taken
|
||||
"""
|
||||
return list(
|
||||
ride.photos.filter(
|
||||
photo_type='construction',
|
||||
is_approved=True
|
||||
).order_by('date_taken', 'created_at')
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_onride_photos(ride: Ride) -> List[RidePhoto]:
|
||||
"""
|
||||
Get on-ride photos for a ride.
|
||||
|
||||
Args:
|
||||
ride: Ride instance
|
||||
|
||||
Returns:
|
||||
List of on-ride RidePhoto instances
|
||||
"""
|
||||
return RideMediaService.get_photos_by_type(ride, 'onride')
|
||||
Reference in New Issue
Block a user