feat: Implement initial schema and add various API, service, and management command enhancements across the application.

This commit is contained in:
pacnpal
2026-01-01 15:13:01 -05:00
parent c95f99ca10
commit b243b17af7
413 changed files with 11164 additions and 17433 deletions

View File

@@ -26,9 +26,7 @@ class ParkFilterService:
def __init__(self):
self.cache_prefix = "park_filter"
def get_filter_counts(
self, base_queryset: QuerySet | None = None
) -> dict[str, Any]:
def get_filter_counts(self, base_queryset: QuerySet | None = None) -> dict[str, Any]:
"""
Get counts for various filter options to show users what's available.
@@ -76,9 +74,7 @@ class ParkFilterService:
).count(),
}
def _get_top_operators(
self, queryset: QuerySet, limit: int = 10
) -> list[dict[str, Any]]:
def _get_top_operators(self, queryset: QuerySet, limit: int = 10) -> list[dict[str, Any]]:
"""Get the top operators by number of parks."""
return list(
queryset.values("operator__name", "operator__id")
@@ -87,9 +83,7 @@ class ParkFilterService:
.order_by("-park_count")[:limit]
)
def _get_country_counts(
self, queryset: QuerySet, limit: int = 10
) -> list[dict[str, Any]]:
def _get_country_counts(self, queryset: QuerySet, limit: int = 10) -> list[dict[str, Any]]:
"""Get countries with the most parks."""
return list(
queryset.filter(location__country__isnull=False)
@@ -123,21 +117,18 @@ class ParkFilterService:
if len(query) >= 2: # Only search for queries of 2+ characters
# Park name suggestions
park_names = Park.objects.filter(name__icontains=query).values_list(
"name", flat=True
)[:5]
park_names = Park.objects.filter(name__icontains=query).values_list("name", flat=True)[:5]
suggestions["parks"] = list(park_names)
# Operator suggestions
operator_names = Company.objects.filter(
roles__contains=["OPERATOR"], name__icontains=query
).values_list("name", flat=True)[:5]
operator_names = Company.objects.filter(roles__contains=["OPERATOR"], name__icontains=query).values_list(
"name", flat=True
)[:5]
suggestions["operators"] = list(operator_names)
# Location suggestions (cities and countries)
locations = Park.objects.filter(
Q(location__city__icontains=query)
| Q(location__country__icontains=query)
Q(location__city__icontains=query) | Q(location__country__icontains=query)
).values_list("location__city", "location__country")[:5]
location_suggestions = []
@@ -264,14 +255,10 @@ class ParkFilterService:
# Apply location filters
if filters.get("country_filter"):
queryset = queryset.filter(
location__country__icontains=filters["country_filter"]
)
queryset = queryset.filter(location__country__icontains=filters["country_filter"])
if filters.get("state_filter"):
queryset = queryset.filter(
location__state__icontains=filters["state_filter"]
)
queryset = queryset.filter(location__state__icontains=filters["state_filter"])
# Apply ordering
if filters.get("ordering"):

View File

@@ -21,8 +21,8 @@ class SmartParkLoader:
"""
# Cache configuration
CACHE_TIMEOUT = getattr(settings, 'HYBRID_FILTER_CACHE_TIMEOUT', 300) # 5 minutes
CACHE_KEY_PREFIX = 'hybrid_parks'
CACHE_TIMEOUT = getattr(settings, "HYBRID_FILTER_CACHE_TIMEOUT", 300) # 5 minutes
CACHE_KEY_PREFIX = "hybrid_parks"
# Progressive loading thresholds
INITIAL_LOAD_SIZE = 50
@@ -34,17 +34,22 @@ class SmartParkLoader:
def _get_optimized_queryset(self) -> models.QuerySet:
"""Get optimized base queryset with all necessary prefetches."""
return Park.objects.select_related(
'operator',
'property_owner',
'banner_image',
'card_image',
).prefetch_related(
'location', # ParkLocation relationship
).filter(
# Only include operating and temporarily closed parks by default
status__in=['OPERATING', 'CLOSED_TEMP']
).order_by('name')
return (
Park.objects.select_related(
"operator",
"property_owner",
"banner_image",
"card_image",
)
.prefetch_related(
"location", # ParkLocation relationship
)
.filter(
# Only include operating and temporarily closed parks by default
status__in=["OPERATING", "CLOSED_TEMP"]
)
.order_by("name")
)
def get_initial_load(self, filters: dict[str, Any] | None = None) -> dict[str, Any]:
"""
@@ -56,7 +61,7 @@ class SmartParkLoader:
Returns:
Dictionary containing parks data and metadata
"""
cache_key = self._generate_cache_key('initial', filters)
cache_key = self._generate_cache_key("initial", filters)
cached_result = cache.get(cache_key)
if cached_result:
@@ -74,21 +79,21 @@ class SmartParkLoader:
if total_count <= self.MAX_CLIENT_SIDE_RECORDS:
# Load all data for client-side filtering
parks = list(queryset.all())
strategy = 'client_side'
strategy = "client_side"
has_more = False
else:
# Load initial batch for server-side pagination
parks = list(queryset[:self.INITIAL_LOAD_SIZE])
strategy = 'server_side'
parks = list(queryset[: self.INITIAL_LOAD_SIZE])
strategy = "server_side"
has_more = total_count > self.INITIAL_LOAD_SIZE
result = {
'parks': parks,
'total_count': total_count,
'strategy': strategy,
'has_more': has_more,
'next_offset': len(parks) if has_more else None,
'filter_metadata': self._get_filter_metadata(queryset),
"parks": parks,
"total_count": total_count,
"strategy": strategy,
"has_more": has_more,
"next_offset": len(parks) if has_more else None,
"filter_metadata": self._get_filter_metadata(queryset),
}
# Cache the result
@@ -96,11 +101,7 @@ class SmartParkLoader:
return result
def get_progressive_load(
self,
offset: int,
filters: dict[str, Any] | None = None
) -> dict[str, Any]:
def get_progressive_load(self, offset: int, filters: dict[str, Any] | None = None) -> dict[str, Any]:
"""
Get next batch of parks for progressive loading.
@@ -111,7 +112,7 @@ class SmartParkLoader:
Returns:
Dictionary containing parks data and metadata
"""
cache_key = self._generate_cache_key(f'progressive_{offset}', filters)
cache_key = self._generate_cache_key(f"progressive_{offset}", filters)
cached_result = cache.get(cache_key)
if cached_result:
@@ -131,10 +132,10 @@ class SmartParkLoader:
has_more = end_offset < total_count
result = {
'parks': parks,
'total_count': total_count,
'has_more': has_more,
'next_offset': end_offset if has_more else None,
"parks": parks,
"total_count": total_count,
"has_more": has_more,
"next_offset": end_offset if has_more else None,
}
# Cache the result
@@ -152,7 +153,7 @@ class SmartParkLoader:
Returns:
Dictionary containing filter metadata
"""
cache_key = self._generate_cache_key('metadata', filters)
cache_key = self._generate_cache_key("metadata", filters)
cached_result = cache.get(cache_key)
if cached_result:
@@ -174,72 +175,72 @@ class SmartParkLoader:
"""Apply filters to the queryset."""
# Status filter
if 'status' in filters and filters['status']:
if isinstance(filters['status'], list):
queryset = queryset.filter(status__in=filters['status'])
if "status" in filters and filters["status"]:
if isinstance(filters["status"], list):
queryset = queryset.filter(status__in=filters["status"])
else:
queryset = queryset.filter(status=filters['status'])
queryset = queryset.filter(status=filters["status"])
# Park type filter
if 'park_type' in filters and filters['park_type']:
if isinstance(filters['park_type'], list):
queryset = queryset.filter(park_type__in=filters['park_type'])
if "park_type" in filters and filters["park_type"]:
if isinstance(filters["park_type"], list):
queryset = queryset.filter(park_type__in=filters["park_type"])
else:
queryset = queryset.filter(park_type=filters['park_type'])
queryset = queryset.filter(park_type=filters["park_type"])
# Country filter
if 'country' in filters and filters['country']:
queryset = queryset.filter(location__country__in=filters['country'])
if "country" in filters and filters["country"]:
queryset = queryset.filter(location__country__in=filters["country"])
# State filter
if 'state' in filters and filters['state']:
queryset = queryset.filter(location__state__in=filters['state'])
if "state" in filters and filters["state"]:
queryset = queryset.filter(location__state__in=filters["state"])
# Opening year range
if 'opening_year_min' in filters and filters['opening_year_min']:
queryset = queryset.filter(opening_year__gte=filters['opening_year_min'])
if "opening_year_min" in filters and filters["opening_year_min"]:
queryset = queryset.filter(opening_year__gte=filters["opening_year_min"])
if 'opening_year_max' in filters and filters['opening_year_max']:
queryset = queryset.filter(opening_year__lte=filters['opening_year_max'])
if "opening_year_max" in filters and filters["opening_year_max"]:
queryset = queryset.filter(opening_year__lte=filters["opening_year_max"])
# Size range
if 'size_min' in filters and filters['size_min']:
queryset = queryset.filter(size_acres__gte=filters['size_min'])
if "size_min" in filters and filters["size_min"]:
queryset = queryset.filter(size_acres__gte=filters["size_min"])
if 'size_max' in filters and filters['size_max']:
queryset = queryset.filter(size_acres__lte=filters['size_max'])
if "size_max" in filters and filters["size_max"]:
queryset = queryset.filter(size_acres__lte=filters["size_max"])
# Rating range
if 'rating_min' in filters and filters['rating_min']:
queryset = queryset.filter(average_rating__gte=filters['rating_min'])
if "rating_min" in filters and filters["rating_min"]:
queryset = queryset.filter(average_rating__gte=filters["rating_min"])
if 'rating_max' in filters and filters['rating_max']:
queryset = queryset.filter(average_rating__lte=filters['rating_max'])
if "rating_max" in filters and filters["rating_max"]:
queryset = queryset.filter(average_rating__lte=filters["rating_max"])
# Ride count range
if 'ride_count_min' in filters and filters['ride_count_min']:
queryset = queryset.filter(ride_count__gte=filters['ride_count_min'])
if "ride_count_min" in filters and filters["ride_count_min"]:
queryset = queryset.filter(ride_count__gte=filters["ride_count_min"])
if 'ride_count_max' in filters and filters['ride_count_max']:
queryset = queryset.filter(ride_count__lte=filters['ride_count_max'])
if "ride_count_max" in filters and filters["ride_count_max"]:
queryset = queryset.filter(ride_count__lte=filters["ride_count_max"])
# Coaster count range
if 'coaster_count_min' in filters and filters['coaster_count_min']:
queryset = queryset.filter(coaster_count__gte=filters['coaster_count_min'])
if "coaster_count_min" in filters and filters["coaster_count_min"]:
queryset = queryset.filter(coaster_count__gte=filters["coaster_count_min"])
if 'coaster_count_max' in filters and filters['coaster_count_max']:
queryset = queryset.filter(coaster_count__lte=filters['coaster_count_max'])
if "coaster_count_max" in filters and filters["coaster_count_max"]:
queryset = queryset.filter(coaster_count__lte=filters["coaster_count_max"])
# Operator filter
if 'operator' in filters and filters['operator']:
if isinstance(filters['operator'], list):
queryset = queryset.filter(operator__slug__in=filters['operator'])
if "operator" in filters and filters["operator"]:
if isinstance(filters["operator"], list):
queryset = queryset.filter(operator__slug__in=filters["operator"])
else:
queryset = queryset.filter(operator__slug=filters['operator'])
queryset = queryset.filter(operator__slug=filters["operator"])
# Search query
if 'search' in filters and filters['search']:
search_term = filters['search'].lower()
if "search" in filters and filters["search"]:
search_term = filters["search"].lower()
queryset = queryset.filter(search_text__icontains=search_term)
return queryset
@@ -249,150 +250,125 @@ class SmartParkLoader:
# Get distinct values for categorical filters with counts
countries_data = list(
queryset.values('location__country')
queryset.values("location__country")
.exclude(location__country__isnull=True)
.annotate(count=models.Count('id'))
.order_by('location__country')
.annotate(count=models.Count("id"))
.order_by("location__country")
)
states_data = list(
queryset.values('location__state')
queryset.values("location__state")
.exclude(location__state__isnull=True)
.annotate(count=models.Count('id'))
.order_by('location__state')
.annotate(count=models.Count("id"))
.order_by("location__state")
)
park_types_data = list(
queryset.values('park_type')
queryset.values("park_type")
.exclude(park_type__isnull=True)
.annotate(count=models.Count('id'))
.order_by('park_type')
.annotate(count=models.Count("id"))
.order_by("park_type")
)
statuses_data = list(
queryset.values('status')
.annotate(count=models.Count('id'))
.order_by('status')
)
statuses_data = list(queryset.values("status").annotate(count=models.Count("id")).order_by("status"))
operators_data = list(
queryset.select_related('operator')
.values('operator__id', 'operator__name', 'operator__slug')
queryset.select_related("operator")
.values("operator__id", "operator__name", "operator__slug")
.exclude(operator__isnull=True)
.annotate(count=models.Count('id'))
.order_by('operator__name')
.annotate(count=models.Count("id"))
.order_by("operator__name")
)
# Convert to frontend-expected format with value/label/count
countries = [
{
'value': item['location__country'],
'label': item['location__country'],
'count': item['count']
}
{"value": item["location__country"], "label": item["location__country"], "count": item["count"]}
for item in countries_data
]
states = [
{
'value': item['location__state'],
'label': item['location__state'],
'count': item['count']
}
{"value": item["location__state"], "label": item["location__state"], "count": item["count"]}
for item in states_data
]
park_types = [
{
'value': item['park_type'],
'label': item['park_type'],
'count': item['count']
}
for item in park_types_data
{"value": item["park_type"], "label": item["park_type"], "count": item["count"]} for item in park_types_data
]
statuses = [
{
'value': item['status'],
'label': self._get_status_label(item['status']),
'count': item['count']
}
{"value": item["status"], "label": self._get_status_label(item["status"]), "count": item["count"]}
for item in statuses_data
]
operators = [
{
'value': item['operator__slug'],
'label': item['operator__name'],
'count': item['count']
}
{"value": item["operator__slug"], "label": item["operator__name"], "count": item["count"]}
for item in operators_data
]
# Get ranges for numerical filters
aggregates = queryset.aggregate(
opening_year_min=models.Min('opening_year'),
opening_year_max=models.Max('opening_year'),
size_min=models.Min('size_acres'),
size_max=models.Max('size_acres'),
rating_min=models.Min('average_rating'),
rating_max=models.Max('average_rating'),
ride_count_min=models.Min('ride_count'),
ride_count_max=models.Max('ride_count'),
coaster_count_min=models.Min('coaster_count'),
coaster_count_max=models.Max('coaster_count'),
opening_year_min=models.Min("opening_year"),
opening_year_max=models.Max("opening_year"),
size_min=models.Min("size_acres"),
size_max=models.Max("size_acres"),
rating_min=models.Min("average_rating"),
rating_max=models.Max("average_rating"),
ride_count_min=models.Min("ride_count"),
ride_count_max=models.Max("ride_count"),
coaster_count_min=models.Min("coaster_count"),
coaster_count_max=models.Max("coaster_count"),
)
return {
'categorical': {
'countries': countries,
'states': states,
'park_types': park_types,
'statuses': statuses,
'operators': operators,
"categorical": {
"countries": countries,
"states": states,
"park_types": park_types,
"statuses": statuses,
"operators": operators,
},
'ranges': {
'opening_year': {
'min': aggregates['opening_year_min'],
'max': aggregates['opening_year_max'],
'step': 1,
'unit': 'year'
"ranges": {
"opening_year": {
"min": aggregates["opening_year_min"],
"max": aggregates["opening_year_max"],
"step": 1,
"unit": "year",
},
'size_acres': {
'min': float(aggregates['size_min']) if aggregates['size_min'] else None,
'max': float(aggregates['size_max']) if aggregates['size_max'] else None,
'step': 1.0,
'unit': 'acres'
"size_acres": {
"min": float(aggregates["size_min"]) if aggregates["size_min"] else None,
"max": float(aggregates["size_max"]) if aggregates["size_max"] else None,
"step": 1.0,
"unit": "acres",
},
'average_rating': {
'min': float(aggregates['rating_min']) if aggregates['rating_min'] else None,
'max': float(aggregates['rating_max']) if aggregates['rating_max'] else None,
'step': 0.1,
'unit': 'stars'
"average_rating": {
"min": float(aggregates["rating_min"]) if aggregates["rating_min"] else None,
"max": float(aggregates["rating_max"]) if aggregates["rating_max"] else None,
"step": 0.1,
"unit": "stars",
},
'ride_count': {
'min': aggregates['ride_count_min'],
'max': aggregates['ride_count_max'],
'step': 1,
'unit': 'rides'
"ride_count": {
"min": aggregates["ride_count_min"],
"max": aggregates["ride_count_max"],
"step": 1,
"unit": "rides",
},
'coaster_count': {
'min': aggregates['coaster_count_min'],
'max': aggregates['coaster_count_max'],
'step': 1,
'unit': 'coasters'
"coaster_count": {
"min": aggregates["coaster_count_min"],
"max": aggregates["coaster_count_max"],
"step": 1,
"unit": "coasters",
},
},
'total_count': queryset.count(),
"total_count": queryset.count(),
}
def _get_status_label(self, status: str) -> str:
"""Convert status code to human-readable label."""
status_labels = {
'OPERATING': 'Operating',
'CLOSED_TEMP': 'Temporarily Closed',
'CLOSED_PERM': 'Permanently Closed',
'UNDER_CONSTRUCTION': 'Under Construction',
"OPERATING": "Operating",
"CLOSED_TEMP": "Temporarily Closed",
"CLOSED_PERM": "Permanently Closed",
"UNDER_CONSTRUCTION": "Under Construction",
}
if status in status_labels:
return status_labels[status]
@@ -405,23 +381,23 @@ class SmartParkLoader:
if filters:
# Create a consistent string representation of filters
filter_str = '_'.join(f"{k}:{v}" for k, v in sorted(filters.items()) if v)
filter_str = "_".join(f"{k}:{v}" for k, v in sorted(filters.items()) if v)
key_parts.append(filter_str)
return '_'.join(key_parts)
return "_".join(key_parts)
def invalidate_cache(self, filters: dict[str, Any] | None = None) -> None:
"""Invalidate cached data for the given filters."""
# This is a simplified implementation
# In production, you might want to use cache versioning or tags
cache_keys = [
self._generate_cache_key('initial', filters),
self._generate_cache_key('metadata', filters),
self._generate_cache_key("initial", filters),
self._generate_cache_key("metadata", filters),
]
# Also invalidate progressive load caches
for offset in range(0, 1000, self.PROGRESSIVE_LOAD_SIZE):
cache_keys.append(self._generate_cache_key(f'progressive_{offset}', filters))
cache_keys.append(self._generate_cache_key(f"progressive_{offset}", filters))
cache.delete_many(cache_keys)

View File

@@ -245,9 +245,7 @@ class ParkLocationService:
return park_location
@classmethod
def update_park_location(
cls, park_location: ParkLocation, **updates
) -> ParkLocation:
def update_park_location(cls, park_location: ParkLocation, **updates) -> ParkLocation:
"""
Update park location with validation.
@@ -278,9 +276,7 @@ class ParkLocationService:
return park_location
@classmethod
def find_nearby_parks(
cls, latitude: float, longitude: float, radius_km: float = 50
) -> list[ParkLocation]:
def find_nearby_parks(cls, latitude: float, longitude: float, radius_km: float = 50) -> list[ParkLocation]:
"""
Find parks near given coordinates using PostGIS.
@@ -298,9 +294,7 @@ class ParkLocationService:
center_point = Point(longitude, latitude, srid=4326)
return list(
ParkLocation.objects.filter(
point__distance_lte=(center_point, Distance(km=radius_km))
)
ParkLocation.objects.filter(point__distance_lte=(center_point, Distance(km=radius_km)))
.select_related("park", "park__operator")
.order_by("point__distance")
)
@@ -349,9 +343,7 @@ class ParkLocationService:
return park_location
@classmethod
def _transform_osm_result(
cls, osm_item: dict[str, Any]
) -> dict[str, Any] | None:
def _transform_osm_result(cls, osm_item: dict[str, Any]) -> dict[str, Any] | None:
"""Transform OSM search result to our standard format."""
try:
address = osm_item.get("address", {})
@@ -369,12 +361,7 @@ class ParkLocationService:
or ""
)
state = (
address.get("state")
or address.get("province")
or address.get("region")
or ""
)
state = address.get("state") or address.get("province") or address.get("region") or ""
country = address.get("country", "")
postal_code = address.get("postcode", "")
@@ -432,9 +419,7 @@ class ParkLocationService:
return None
@classmethod
def _transform_osm_reverse_result(
cls, osm_result: dict[str, Any]
) -> dict[str, Any]:
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", {})
@@ -443,20 +428,9 @@ class ParkLocationService:
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 ""
)
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 ""
)
state = address.get("state") or address.get("province") or address.get("region") or ""
country = address.get("country", "")
postal_code = address.get("postcode", "")

View File

@@ -79,9 +79,7 @@ class ParkMediaService:
return photo
@staticmethod
def get_park_photos(
park: Park, approved_only: bool = True, primary_first: bool = True
) -> list[ParkPhoto]:
def get_park_photos(park: Park, approved_only: bool = True, primary_first: bool = True) -> list[ParkPhoto]:
"""
Get photos for a park.
@@ -190,9 +188,7 @@ class ParkMediaService:
photo.image.delete(save=False)
photo.delete()
logger.info(
f"Photo {photo_id} deleted from park {park_slug} by user {deleted_by.username}"
)
logger.info(f"Photo {photo_id} deleted from park {park_slug} by user {deleted_by.username}")
return True
except Exception as e:
logger.error(f"Failed to delete photo {photo.pk}: {str(e)}")
@@ -238,7 +234,5 @@ class ParkMediaService:
if ParkMediaService.approve_photo(photo, approved_by):
approved_count += 1
logger.info(
f"Bulk approved {approved_count} photos by user {approved_by.username}"
)
logger.info(f"Bulk approved {approved_count} photos by user {approved_by.username}")
return approved_count

View File

@@ -133,9 +133,7 @@ class ParkService:
return park
@staticmethod
def delete_park(
*, park_id: int, deleted_by: Optional["AbstractUser"] = None
) -> bool:
def delete_park(*, park_id: int, deleted_by: Optional["AbstractUser"] = None) -> bool:
"""
Soft delete a park by setting status to DEMOLISHED.
@@ -219,9 +217,9 @@ class ParkService:
)
# Calculate average rating
avg_rating = ParkReview.objects.filter(
park=park, is_published=True
).aggregate(avg_rating=Avg("rating"))["avg_rating"]
avg_rating = ParkReview.objects.filter(park=park, is_published=True).aggregate(avg_rating=Avg("rating"))[
"avg_rating"
]
# Update park fields
park.ride_count = ride_stats["total_rides"] or 0

View File

@@ -148,12 +148,8 @@ class RoadTripService:
# Configuration from Django settings
self.cache_timeout = getattr(settings, "ROADTRIP_CACHE_TIMEOUT", 3600 * 24)
self.route_cache_timeout = getattr(
settings, "ROADTRIP_ROUTE_CACHE_TIMEOUT", 3600 * 6
)
self.user_agent = getattr(
settings, "ROADTRIP_USER_AGENT", "ThrillWiki Road Trip Planner"
)
self.route_cache_timeout = getattr(settings, "ROADTRIP_ROUTE_CACHE_TIMEOUT", 3600 * 6)
self.user_agent = getattr(settings, "ROADTRIP_USER_AGENT", "ThrillWiki Road Trip Planner")
self.request_timeout = getattr(settings, "ROADTRIP_REQUEST_TIMEOUT", 10)
self.max_retries = getattr(settings, "ROADTRIP_MAX_RETRIES", 3)
self.backoff_factor = getattr(settings, "ROADTRIP_BACKOFF_FACTOR", 2)
@@ -179,9 +175,7 @@ class RoadTripService:
for attempt in range(self.max_retries):
try:
response = self.session.get(
url, params=params, timeout=self.request_timeout
)
response = self.session.get(url, params=params, timeout=self.request_timeout)
response.raise_for_status()
return response.json()
@@ -192,9 +186,7 @@ class RoadTripService:
wait_time = self.backoff_factor**attempt
time.sleep(wait_time)
else:
raise OSMAPIException(
f"Failed to make request after {self.max_retries} attempts: {e}"
)
raise OSMAPIException(f"Failed to make request after {self.max_retries} attempts: {e}") from e
def geocode_address(self, address: str) -> Coordinates | None:
"""
@@ -243,9 +235,7 @@ class RoadTripService:
self.cache_timeout,
)
logger.info(
f"Geocoded '{address}' to {coords.latitude}, {coords.longitude}"
)
logger.info(f"Geocoded '{address}' to {coords.latitude}, {coords.longitude}")
return coords
else:
logger.warning(f"No geocoding results for address: {address}")
@@ -255,9 +245,7 @@ class RoadTripService:
logger.error(f"Geocoding failed for '{address}': {e}")
return None
def calculate_route(
self, start_coords: Coordinates, end_coords: Coordinates
) -> RouteInfo | None:
def calculate_route(self, start_coords: Coordinates, end_coords: Coordinates) -> RouteInfo | None:
"""
Calculate route between two coordinate points using OSRM.
@@ -327,9 +315,7 @@ class RoadTripService:
return route_info
else:
# Fallback to straight-line distance calculation
logger.warning(
"OSRM routing failed, falling back to straight-line distance"
)
logger.warning("OSRM routing failed, falling back to straight-line distance")
return self._calculate_straight_line_route(start_coords, end_coords)
except Exception as e:
@@ -337,9 +323,7 @@ class RoadTripService:
# Fallback to straight-line distance
return self._calculate_straight_line_route(start_coords, end_coords)
def _calculate_straight_line_route(
self, start_coords: Coordinates, end_coords: Coordinates
) -> RouteInfo:
def _calculate_straight_line_route(self, start_coords: Coordinates, end_coords: Coordinates) -> RouteInfo:
"""
Calculate straight-line distance as fallback when routing fails.
"""
@@ -356,10 +340,7 @@ class RoadTripService:
dlat = lat2 - lat1
dlon = lon2 - lon1
a = (
math.sin(dlat / 2) ** 2
+ math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
)
a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
c = 2 * math.asin(math.sqrt(a))
# Earth's radius in kilometers
@@ -376,9 +357,7 @@ class RoadTripService:
geometry=None,
)
def find_parks_along_route(
self, start_park: "Park", end_park: "Park", max_detour_km: float = 50
) -> list["Park"]:
def find_parks_along_route(self, start_park: "Park", end_park: "Park", max_detour_km: float = 50) -> list["Park"]:
"""
Find parks along a route within specified detour distance.
@@ -443,9 +422,7 @@ class RoadTripService:
return parks_along_route
def _calculate_detour_distance(
self, start: Coordinates, end: Coordinates, waypoint: Coordinates
) -> float | None:
def _calculate_detour_distance(self, start: Coordinates, end: Coordinates, waypoint: Coordinates) -> float | None:
"""
Calculate the detour distance when visiting a waypoint.
"""
@@ -508,9 +485,7 @@ class RoadTripService:
return best_trip
def _optimize_trip_nearest_neighbor(
self, park_list: list["Park"]
) -> RoadTrip | None:
def _optimize_trip_nearest_neighbor(self, park_list: list["Park"]) -> RoadTrip | None:
"""
Optimize trip using nearest neighbor heuristic (for larger lists).
"""
@@ -536,9 +511,7 @@ class RoadTripService:
if not park_coords:
continue
route = self.calculate_route(
Coordinates(*current_coords), Coordinates(*park_coords)
)
route = self.calculate_route(Coordinates(*current_coords), Coordinates(*park_coords))
if route and route.distance_km < min_distance:
min_distance = route.distance_km
@@ -553,9 +526,7 @@ class RoadTripService:
return self._create_trip_from_order(ordered_parks)
def _create_trip_from_order(
self, ordered_parks: list["Park"]
) -> RoadTrip | None:
def _create_trip_from_order(self, ordered_parks: list["Park"]) -> RoadTrip | None:
"""
Create a RoadTrip object from an ordered list of parks.
"""
@@ -576,9 +547,7 @@ class RoadTripService:
if not from_coords or not to_coords:
continue
route = self.calculate_route(
Coordinates(*from_coords), Coordinates(*to_coords)
)
route = self.calculate_route(Coordinates(*from_coords), Coordinates(*to_coords))
if route:
legs.append(TripLeg(from_park=from_park, to_park=to_park, route=route))
@@ -595,9 +564,7 @@ class RoadTripService:
total_duration_minutes=total_duration,
)
def get_park_distances(
self, center_park: "Park", radius_km: float = 100
) -> list[dict[str, Any]]:
def get_park_distances(self, center_park: "Park", radius_km: float = 100) -> list[dict[str, Any]]:
"""
Get all parks within radius of a center park with distances.
@@ -621,9 +588,7 @@ class RoadTripService:
search_distance = Distance(km=radius_km)
nearby_parks = (
Park.objects.filter(
location__point__distance_lte=(center_point, search_distance)
)
Park.objects.filter(location__point__distance_lte=(center_point, search_distance))
.exclude(id=center_park.id)
.select_related("location")
)
@@ -635,9 +600,7 @@ class RoadTripService:
if not park_coords:
continue
route = self.calculate_route(
Coordinates(*center_coords), Coordinates(*park_coords)
)
route = self.calculate_route(Coordinates(*center_coords), Coordinates(*park_coords))
if route:
results.append(
@@ -691,9 +654,7 @@ class RoadTripService:
if coords:
location.set_coordinates(coords.latitude, coords.longitude)
location.save()
logger.info(
f"Geocoded park '{park.name}' to {coords.latitude}, {coords.longitude}"
)
logger.info(f"Geocoded park '{park.name}' to {coords.latitude}, {coords.longitude}")
return True
return False