Geocoding service/provider framework

This commit is contained in:
Sean Morley
2026-05-31 20:43:27 -04:00
parent 69315a2b98
commit e3b0e0dc1d
45 changed files with 3294 additions and 2148 deletions

View File

@@ -1,881 +1,90 @@
import requests
import time
import socket
import re
import unicodedata
from urllib.parse import quote
from worldtravel.models import Region, City, VisitedRegion, VisitedCity
"""Legacy geocoding compatibility shim.
The active implementation lives in providers, services, and shared utils.
This module only preserves old import paths during migration.
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from django.conf import settings
# -----------------
# SEARCHING
from adventures.providers.geocoding.google import reverse_geocode as google_reverse_geocode
from adventures.providers.geocoding.osm import reverse_geocode as osm_reverse_geocode
from adventures.providers.places.google import search_places as google_search_places
from adventures.providers.places.osm import search_places as osm_search_places
from adventures.services.geocoding.reverse import reverse_geocode as reverse_geocode_service
from adventures.services.places.details import get_place_details as get_place_details_service
from adventures.services.places.search import search_places as search_places_service
from adventures.utils.geocoding_utils import (
_extract_google_location_name,
_extract_osm_location_name,
_fetch_google_nearby_place_name,
_parse_google_address_components,
extractIsoCode,
)
@dataclass
class ProviderSearchResult:
provider_used: Optional[str]
providers_attempted: List[str] = field(default_factory=list)
results: List[Dict[str, Any]] = field(default_factory=list)
error: Optional[str] = None
def search_google(query):
try:
api_key = settings.GOOGLE_MAPS_API_KEY
if not api_key:
return {"error": "Geocoding service unavailable. Please check configuration."}
# Updated to use the new Places API (New) endpoint
url = "https://places.googleapis.com/v1/places:searchText"
headers = {
'Content-Type': 'application/json',
'X-Goog-Api-Key': api_key,
'X-Goog-FieldMask': (
'places.id,places.displayName.text,places.formattedAddress,places.location,'
'places.types,places.rating,places.userRatingCount,places.websiteUri,'
'places.nationalPhoneNumber,places.internationalPhoneNumber,'
'places.editorialSummary.text,places.googleMapsUri,places.photos.name'
)
}
payload = {
"textQuery": query,
"maxResultCount": 20 # Adjust as needed
}
response = requests.post(url, json=payload, headers=headers, timeout=(2, 5))
response.raise_for_status()
data = response.json()
# Check if we have places in the response
places = data.get("places", [])
if not places:
return {"error": "No locations found for the given query."}
results = []
for place in places:
location = place.get("location", {})
types = place.get("types", [])
primary_type = types[0] if types else None
category = _extract_google_category(types)
addresstype = _infer_addresstype(primary_type)
importance = None
rating = place.get("rating")
ratings_total = place.get("userRatingCount")
if rating is not None and ratings_total:
importance = round(float(rating) * ratings_total / 100, 2)
photos = []
for photo in place.get('photos', [])[:5]:
photo_name = photo.get('name')
if photo_name:
photos.append(
f"https://places.googleapis.com/v1/{photo_name}/media?key={api_key}&maxHeightPx=800&maxWidthPx=800"
)
# Extract display name from the new API structure
display_name_obj = place.get("displayName", {})
name = display_name_obj.get("text") if display_name_obj else None
results.append({
"lat": location.get("latitude"),
"lon": location.get("longitude"),
"name": name,
"display_name": place.get("formattedAddress"),
"place_id": place.get("id"),
"type": primary_type,
"types": types,
"category": category,
"description": (place.get('editorialSummary') or {}).get('text'),
"website": place.get('websiteUri'),
"phone_number": place.get('internationalPhoneNumber') or place.get('nationalPhoneNumber'),
"google_maps_url": place.get('googleMapsUri'),
"importance": importance,
"rating": rating,
"review_count": ratings_total,
"photos": photos,
"addresstype": addresstype,
"powered_by": "google",
})
if results:
results.sort(key=lambda r: r["importance"] if r["importance"] is not None else 0, reverse=True)
return results
except requests.exceptions.Timeout:
return {"error": "Request timed out while contacting Google Maps. Please try again."}
except requests.exceptions.ConnectionError:
return {"error": "Unable to connect to Google Maps service. Please check your internet connection."}
except requests.exceptions.HTTPError as e:
if response.status_code == 400:
return {"error": "Invalid request to Google Maps. Please check your query."}
elif response.status_code == 401:
return {"error": "Authentication failed with Google Maps. Please check API configuration."}
elif response.status_code == 403:
return {"error": "Access forbidden to Google Maps. Please check API permissions."}
elif response.status_code == 429:
return {"error": "Too many requests to Google Maps. Please try again later."}
else:
return {"error": "Google Maps service error. Please try again later."}
except requests.exceptions.RequestException:
return {"error": "Network error while contacting Google Maps. Please try again."}
except Exception:
return {"error": "An unexpected error occurred during Google search. Please try again."}
def _extract_google_category(types):
# Basic category inference based on common place types
if not types:
return None
if "restaurant" in types:
return "food"
if "lodging" in types:
return "accommodation"
if "park" in types or "natural_feature" in types:
return "nature"
if "museum" in types or "tourist_attraction" in types:
return "attraction"
if "locality" in types or "administrative_area_level_1" in types:
return "region"
return types[0] # fallback to first type
def _infer_addresstype(type_):
# Rough mapping of Google place types to OSM-style addresstypes
mapping = {
"locality": "city",
"sublocality": "neighborhood",
"administrative_area_level_1": "region",
"administrative_area_level_2": "county",
"country": "country",
"premise": "building",
"point_of_interest": "poi",
"route": "road",
"street_address": "address",
}
return mapping.get(type_, None)
api_key = getattr(settings, "GOOGLE_MAPS_API_KEY", None)
payload = google_search_places(query, api_key=api_key or "")
if payload.error:
return {"error": payload.error}
return payload.data or []
def search_osm(query):
try:
url = f"https://nominatim.openstreetmap.org/search?q={query}&format=jsonv2"
headers = {'User-Agent': 'AdventureLog Server'}
response = requests.get(url, headers=headers, timeout=(2, 5))
response.raise_for_status()
data = response.json()
return [{
"lat": item.get("lat"),
"lon": item.get("lon"),
"name": item.get("name"),
"display_name": item.get("display_name"),
"type": item.get("type"),
"category": item.get("category"),
"importance": item.get("importance"),
"addresstype": item.get("addresstype"),
"powered_by": "nominatim",
} for item in data]
except requests.exceptions.Timeout:
return {"error": "Request timed out while contacting OpenStreetMap. Please try again."}
except requests.exceptions.ConnectionError:
return {"error": "Unable to connect to OpenStreetMap service. Please check your internet connection."}
except requests.exceptions.HTTPError as e:
if response.status_code == 400:
return {"error": "Invalid request to OpenStreetMap. Please check your query."}
elif response.status_code == 429:
return {"error": "Too many requests to OpenStreetMap. Please try again later."}
else:
return {"error": "OpenStreetMap service error. Please try again later."}
except requests.exceptions.RequestException:
return {"error": "Network error while contacting OpenStreetMap. Please try again."}
except Exception:
return {"error": "An unexpected error occurred during OpenStreetMap search. Please try again."}
def search(query):
"""
Unified search function that tries Google Maps first, then falls back to OpenStreetMap.
"""
if getattr(settings, 'GOOGLE_MAPS_API_KEY', None):
google_result = search_google(query)
if "error" not in google_result:
return google_result
# If Google fails, fallback to OSM
return search_osm(query)
payload = osm_search_places(query)
if payload.error:
return {"error": payload.error}
return payload.data or []
def _fetch_wikipedia_summary(query, language='en'):
normalized_query = (query or '').strip()
if not normalized_query:
return None
candidates = [normalized_query]
if ',' in normalized_query:
head = normalized_query.split(',')[0].strip()
if head and head not in candidates:
candidates.append(head)
for candidate in candidates:
try:
encoded_query = quote(candidate, safe='')
url = f"https://{language}.wikipedia.org/api/rest_v1/page/summary/{encoded_query}"
response = requests.get(
url,
headers={'User-Agent': 'AdventureLog Server'},
timeout=(2, 5),
)
if response.status_code != 200:
continue
data = response.json()
if data.get('type') == 'disambiguation':
continue
extract = (data.get('extract') or '').strip()
if len(extract) >= 120:
return extract
except requests.exceptions.RequestException:
continue
return None
def _compose_place_description(
editorial_summary,
review_snippets,
):
parts = []
summary = (editorial_summary or '').strip()
if summary:
parts.append(f"### About\n\n{summary}")
cleaned_reviews = []
for snippet in review_snippets:
text = (snippet or '').strip()
if len(text) >= 40:
cleaned_reviews.append(text)
if len(cleaned_reviews) >= 2:
break
if cleaned_reviews:
review_block = '### Visitor Highlights\n\n' + '\n'.join(
f"- {text}" for text in cleaned_reviews
)
parts.append(review_block)
return '\n\n'.join(parts).strip() or None
def search_places(query):
selection = search_places_service(query)
if selection.error:
return {"error": selection.error}
return selection.results
def get_place_details(place_id, fallback_query=None, language='en'):
if not place_id:
return {'error': 'place_id is required'}
return get_place_details_service(place_id, fallback_query=fallback_query, language=language)
details = {
'description': None,
'name': None,
'formatted_address': None,
'types': [],
'rating': None,
'review_count': None,
'website': None,
'phone_number': None,
'google_maps_url': None,
'source': None,
}
api_key = settings.GOOGLE_MAPS_API_KEY
if api_key:
try:
url = f"https://places.googleapis.com/v1/places/{place_id}"
headers = {
'X-Goog-Api-Key': api_key,
'X-Goog-FieldMask': (
'id,displayName.text,formattedAddress,editorialSummary.text,types,'
'rating,userRatingCount,websiteUri,nationalPhoneNumber,'
'internationalPhoneNumber,googleMapsUri,reviews.text.text'
),
}
response = requests.get(url, headers=headers, timeout=(2, 6))
response.raise_for_status()
place = response.json()
details['name'] = (place.get('displayName') or {}).get('text')
details['formatted_address'] = place.get('formattedAddress')
details['types'] = place.get('types') or []
details['rating'] = place.get('rating')
details['review_count'] = place.get('userRatingCount')
details['website'] = place.get('websiteUri')
details['phone_number'] = (
place.get('internationalPhoneNumber') or place.get('nationalPhoneNumber')
)
details['google_maps_url'] = place.get('googleMapsUri')
editorial_summary = (place.get('editorialSummary') or {}).get('text')
reviews = place.get('reviews') or []
review_snippets = [((review.get('text') or {}).get('text')) for review in reviews]
details['description'] = _compose_place_description(
editorial_summary,
review_snippets,
)
if details['description']:
details['source'] = 'google'
except requests.exceptions.RequestException:
pass
# Google summaries are often short; fallback to Wikipedia for richer context.
description_text = (details.get('description') or '').strip()
if len(description_text) < 220:
wikipedia_summary = _fetch_wikipedia_summary(
fallback_query or details.get('name') or '',
language=language,
)
if wikipedia_summary:
if description_text:
details['description'] = f"{description_text}\n\n### Background\n\n{wikipedia_summary}"
details['source'] = 'google+wikipedia'
else:
details['description'] = f"### Background\n\n{wikipedia_summary}"
details['source'] = 'wikipedia'
if not details.get('description'):
return {'error': 'Unable to enrich place description'}
return details
def _clean_location_candidate(value):
if value is None:
return None
cleaned = str(value).strip()
return cleaned or None
def _looks_like_street_address(value):
candidate = _clean_location_candidate(value)
if not candidate:
return False
lowered = candidate.lower()
if not re.search(r"\d", lowered):
return False
if lowered.count(",") >= 2:
return True
if not re.match(r"^\d{1,6}\s+\S+", lowered):
return False
street_tokens = (
"st",
"street",
"rd",
"road",
"ave",
"avenue",
"blvd",
"boulevard",
"dr",
"drive",
"ln",
"lane",
"ct",
"court",
"pl",
"place",
"pkwy",
"parkway",
"hwy",
"highway",
"trl",
"trail",
)
return any(re.search(rf"\b{token}\b", lowered) for token in street_tokens)
def _first_preferred_location_name(candidates, allow_address_fallback=False):
address_fallback = None
for candidate in candidates:
cleaned = _clean_location_candidate(candidate)
if not cleaned:
continue
if not _looks_like_street_address(cleaned):
return cleaned
if address_fallback is None:
address_fallback = cleaned
return address_fallback if allow_address_fallback else None
def _extract_google_component_name(address_components):
preferred_types = (
"premise",
"point_of_interest",
"establishment",
"subpremise",
"natural_feature",
"airport",
"park",
"tourist_attraction",
"shopping_mall",
"university",
"school",
"hospital",
)
for preferred_type in preferred_types:
for component in address_components or []:
types = component.get("types", [])
if preferred_type in types:
return component.get("long_name") or component.get("short_name")
return None
def _score_google_result_types(types):
priority = (
"point_of_interest",
"establishment",
"premise",
"subpremise",
"tourist_attraction",
"park",
"airport",
"shopping_mall",
"university",
"school",
"hospital",
"street_address",
"route",
)
for idx, type_name in enumerate(priority):
if type_name in types:
return len(priority) - idx
return 0
def _fetch_google_nearby_place_name(lat, lon, api_key):
url = "https://places.googleapis.com/v1/places:searchNearby"
headers = {
'Content-Type': 'application/json',
'X-Goog-Api-Key': api_key,
'X-Goog-FieldMask': 'places.displayName.text,places.formattedAddress,places.types',
}
payload = {
"maxResultCount": 6,
"rankPreference": "DISTANCE",
"locationRestriction": {
"circle": {
"center": {
"latitude": float(lat),
"longitude": float(lon),
},
"radius": 45.0,
}
},
}
try:
response = requests.post(url, headers=headers, json=payload, timeout=(2, 5))
response.raise_for_status()
places = (response.json() or {}).get("places", [])
except requests.exceptions.RequestException:
return None
candidates = [((place.get("displayName") or {}).get("text")) for place in places]
return _first_preferred_location_name(candidates, allow_address_fallback=False)
def _extract_google_location_name(results, nearby_place_name=None):
preferred_nearby = _first_preferred_location_name([nearby_place_name], allow_address_fallback=False)
if preferred_nearby:
return preferred_nearby
scored_candidates = []
for result in results or []:
score = _score_google_result_types(result.get("types", []))
if score <= 0:
continue
component_name = _extract_google_component_name(result.get("address_components", []))
name_candidate = _first_preferred_location_name([component_name], allow_address_fallback=False)
if name_candidate:
scored_candidates.append((score, name_candidate))
if scored_candidates:
scored_candidates.sort(key=lambda item: item[0], reverse=True)
return scored_candidates[0][1]
component_candidates = [
_extract_google_component_name(result.get("address_components", []))
for result in (results or [])
]
component_pick = _first_preferred_location_name(component_candidates, allow_address_fallback=False)
if component_pick:
return component_pick
formatted_candidates = [result.get("formatted_address") for result in (results or [])]
return _first_preferred_location_name(formatted_candidates, allow_address_fallback=True)
def _extract_osm_location_name(data):
address = data.get("address", {}) or {}
namedetails = data.get("namedetails", {}) or {}
extratags = data.get("extratags", {}) or {}
candidates = [
data.get("name"),
namedetails.get("name"),
namedetails.get("official_name"),
namedetails.get("short_name"),
namedetails.get("brand"),
namedetails.get("loc_name"),
address.get("amenity"),
address.get("tourism"),
address.get("attraction"),
address.get("building"),
address.get("shop"),
address.get("leisure"),
address.get("historic"),
address.get("man_made"),
address.get("office"),
address.get("aeroway"),
address.get("railway"),
address.get("public_transport"),
address.get("craft"),
address.get("house_name"),
extratags.get("name"),
extratags.get("official_name"),
extratags.get("brand"),
extratags.get("operator"),
]
preferred = _first_preferred_location_name(candidates, allow_address_fallback=False)
if preferred:
return preferred
return _first_preferred_location_name(
[data.get("name"), data.get("display_name")],
allow_address_fallback=True,
)
# -----------------
# REVERSE GEOCODING
# -----------------
def extractIsoCode(user, data):
"""
Extract the ISO code from the response data.
Returns a dictionary containing the region name, country name, and ISO code if found.
"""
iso_code = None
display_name = None
country_code = None
city = None
visited_city = None
location_name = _clean_location_candidate(data.get('location_name') or data.get('name'))
address = data.get('address', {}) or {}
# Capture country code early for ISO selection and name fallback.
country_code = address.get("ISO3166-1")
state_name = address.get("state")
# Prefer the most specific ISO 3166-2 code available before falling back to country-level.
# France gets lvl4 (regions) first for city matching, then lvl6 (departments) as a fallback.
preferred_iso_keys = (
[
"ISO3166-2-lvl10",
"ISO3166-2-lvl9",
"ISO3166-2-lvl8",
"ISO3166-2-lvl4",
"ISO3166-2-lvl6",
"ISO3166-2-lvl7",
"ISO3166-2-lvl5",
"ISO3166-2-lvl3",
"ISO3166-2-lvl2",
"ISO3166-2-lvl1",
"ISO3166-2",
]
if country_code == "FR"
else [
"ISO3166-2-lvl10",
"ISO3166-2-lvl9",
"ISO3166-2-lvl8",
"ISO3166-2-lvl4",
"ISO3166-2-lvl7",
"ISO3166-2-lvl6",
"ISO3166-2-lvl5",
"ISO3166-2-lvl3",
"ISO3166-2-lvl2",
"ISO3166-2-lvl1",
"ISO3166-2",
]
)
iso_candidates = []
for key in preferred_iso_keys:
value = address.get(key)
if value and value not in iso_candidates:
iso_candidates.append(value)
# If no region-level code, fall back to country code only as a last resort.
if not iso_candidates and "ISO3166-1" in address:
iso_candidates.append(address.get("ISO3166-1"))
iso_code = iso_candidates[0] if iso_candidates else None
region_candidates = []
for candidate in iso_candidates:
if len(str(candidate)) <= 2:
continue
match = Region.objects.filter(id=candidate).first()
if match and match not in region_candidates:
region_candidates.append(match)
region = region_candidates[0] if region_candidates else None
# Fallback: attempt to resolve region by name and country code when no ISO match.
if not region and state_name:
region_queryset = Region.objects.filter(name__iexact=state_name)
if country_code:
region_queryset = region_queryset.filter(country__country_code=country_code)
region = region_queryset.first()
if region:
iso_code = region.id
if not country_code:
country_code = region.country.country_code
if region not in region_candidates:
region_candidates.insert(0, region)
if not region:
return {"error": "No region found"}
if not country_code:
country_code = region.country.country_code
region_visited = False
city_visited = False
# ordered preference for best-effort locality matching
locality_keys = [
'suburb',
'neighbourhood',
'neighborhood', # alternate spelling
'city',
'city_district',
'town',
'village',
'hamlet',
'locality',
'municipality',
'county',
]
def _normalize_name(value):
normalized = unicodedata.normalize("NFKD", value)
ascii_only = normalized.encode("ascii", "ignore").decode("ascii")
return re.sub(r"[^a-z0-9]", "", ascii_only.lower())
def match_locality(key_name, target_region):
value = address.get(key_name)
if not value:
return None
qs = City.objects.filter(region=target_region)
# Use exact matches first to avoid broad county/name collisions (e.g. Troms vs Tromsø).
exact_match = qs.filter(name__iexact=value).first()
if exact_match:
return exact_match
normalized_value = _normalize_name(value)
for candidate in qs.values_list('id', 'name'):
candidate_id, candidate_name = candidate
if _normalize_name(candidate_name) == normalized_value:
return qs.filter(id=candidate_id).first()
# Allow partial matching for most locality fields but keep county strict.
if key_name == 'county':
return None
return qs.filter(name__icontains=value).first()
chosen_region = region
for candidate_region in region_candidates or [region]:
for key_name in locality_keys:
city = match_locality(key_name, candidate_region)
if city:
chosen_region = candidate_region
iso_code = chosen_region.id
break
if city:
break
region = chosen_region
iso_code = region.id
visited_region = VisitedRegion.objects.filter(region=region, user=user).first()
region_visited = bool(visited_region)
if city:
display_name = f"{city.name}, {region.name}, {country_code or region.country.country_code}"
visited_city = VisitedCity.objects.filter(city=city, user=user).first()
city_visited = bool(visited_city)
else:
display_name = f"{region.name}, {country_code or region.country.country_code}"
return {
"region_id": iso_code,
"region": region.name,
"country": region.country.name,
"country_id": region.country.country_code,
"region_visited": region_visited,
"display_name": display_name,
"city": city.name if city else None,
"city_id": city.id if city else None,
"city_visited": city_visited,
'location_name': location_name,
}
def is_host_resolvable(hostname: str) -> bool:
try:
socket.gethostbyname(hostname)
return True
except socket.error:
return False
def reverse_geocode(lat, lon, user):
if getattr(settings, 'GOOGLE_MAPS_API_KEY', None):
google_result = reverse_geocode_google(lat, lon, user)
if "error" not in google_result:
return google_result
# If Google fails, fallback to OSM
return reverse_geocode_osm(lat, lon, user)
return reverse_geocode_osm(lat, lon, user)
selection = reverse_geocode_service(lat, lon, user)
return selection.data
def reverse_geocode_osm(lat, lon, user):
url = (
"https://nominatim.openstreetmap.org/reverse"
f"?format=jsonv2&addressdetails=1&namedetails=1&extratags=1&zoom=18&lat={lat}&lon={lon}"
)
headers = {'User-Agent': 'AdventureLog Server'}
connect_timeout = 1
read_timeout = 5
if not is_host_resolvable("nominatim.openstreetmap.org"):
return {"error": "Unable to resolve OpenStreetMap service. Please check your internet connection."}
try:
response = requests.get(url, headers=headers, timeout=(connect_timeout, read_timeout))
response.raise_for_status()
data = response.json()
data["location_name"] = _extract_osm_location_name(data)
return extractIsoCode(user, data)
except requests.exceptions.Timeout:
return {"error": "Request timed out while contacting OpenStreetMap. Please try again."}
except requests.exceptions.ConnectionError:
return {"error": "Unable to connect to OpenStreetMap service. Please check your internet connection."}
except requests.exceptions.HTTPError as e:
if response.status_code == 400:
return {"error": "Invalid request to OpenStreetMap. Please check coordinates."}
elif response.status_code == 429:
return {"error": "Too many requests to OpenStreetMap. Please try again later."}
else:
return {"error": "OpenStreetMap service error. Please try again later."}
except requests.exceptions.RequestException:
return {"error": "Network error while contacting OpenStreetMap. Please try again."}
except Exception:
return {"error": "An unexpected error occurred during OpenStreetMap geocoding. Please try again."}
def reverse_geocode_google(lat, lon, user):
api_key = settings.GOOGLE_MAPS_API_KEY
# Updated to use the new Geocoding API endpoint (this one is still supported)
# The Geocoding API is separate from Places API and still uses the old format
url = "https://maps.googleapis.com/maps/api/geocode/json"
params = {"latlng": f"{lat},{lon}", "key": api_key}
api_key = getattr(settings, "GOOGLE_MAPS_API_KEY", None)
if not api_key:
return {"error": "Geocoding service unavailable. Please check configuration."}
try:
response = requests.get(url, params=params, timeout=(2, 5))
response.raise_for_status()
data = response.json()
payload = google_reverse_geocode(lat, lon, api_key)
if payload.error:
return {"error": payload.error}
return payload.data or {}
status = data.get("status")
if status != "OK":
if status == "ZERO_RESULTS":
return {"error": "No location found for the given coordinates."}
elif status == "OVER_QUERY_LIMIT":
return {"error": "Query limit exceeded for Google Maps. Please try again later."}
elif status == "REQUEST_DENIED":
return {"error": "Request denied by Google Maps. Please check API configuration."}
elif status == "INVALID_REQUEST":
return {"error": "Invalid request to Google Maps. Please check coordinates."}
else:
return {"error": "Geocoding failed. Please try again."}
results = data.get("results", [])
if not results:
return {"error": "No location found for the given coordinates."}
def reverse_geocode_osm(lat, lon, user):
payload = osm_reverse_geocode(lat, lon)
if payload.error:
return {"error": payload.error}
return payload.data or {}
nearby_place_name = _fetch_google_nearby_place_name(lat, lon, api_key)
location_name = _extract_google_location_name(results, nearby_place_name=nearby_place_name)
# Convert Google schema to Nominatim-style for extractIsoCode
first_result = results[0]
address_result = next(
(result for result in results if "plus_code" not in result.get("types", [])),
first_result,
)
result_data = {
"name": first_result.get("formatted_address"),
"location_name": location_name,
"address": _parse_google_address_components(address_result.get("address_components", [])),
}
return extractIsoCode(user, result_data)
except requests.exceptions.Timeout:
return {"error": "Request timed out while contacting Google Maps. Please try again."}
except requests.exceptions.ConnectionError:
return {"error": "Unable to connect to Google Maps service. Please check your internet connection."}
except requests.exceptions.HTTPError as e:
if response.status_code == 400:
return {"error": "Invalid request to Google Maps. Please check coordinates."}
elif response.status_code == 401:
return {"error": "Authentication failed with Google Maps. Please check API configuration."}
elif response.status_code == 403:
return {"error": "Access forbidden to Google Maps. Please check API permissions."}
elif response.status_code == 429:
return {"error": "Too many requests to Google Maps. Please try again later."}
else:
return {"error": "Google Maps service error. Please try again later."}
except requests.exceptions.RequestException:
return {"error": "Network error while contacting Google Maps. Please try again."}
except Exception:
return {"error": "An unexpected error occurred during Google geocoding. Please try again."}
def _parse_google_address_components(components):
parsed = {}
country_code = None
state_code = None
for comp in components:
types = comp.get("types", [])
long_name = comp.get("long_name")
short_name = comp.get("short_name")
if "country" in types:
parsed["country"] = long_name
country_code = short_name
parsed["ISO3166-1"] = short_name
if "administrative_area_level_1" in types:
parsed["state"] = long_name
state_code = short_name
if "administrative_area_level_2" in types:
parsed["county"] = long_name
if "administrative_area_level_3" in types:
parsed["municipality"] = long_name
if "locality" in types:
parsed["city"] = long_name
if "postal_town" in types:
parsed.setdefault("city", long_name)
if "sublocality" in types or any(t.startswith("sublocality_level_") for t in types):
parsed["suburb"] = long_name
if "neighborhood" in types:
parsed["neighbourhood"] = long_name
if "route" in types:
parsed["road"] = long_name
if "street_address" in types:
parsed["address"] = long_name
# Build composite ISO 3166-2 code like US-ME (matches Region.id in DB)
if country_code and state_code:
parsed["ISO3166-2-lvl1"] = f"{country_code}-{state_code}"
return parsed
def search(query):
"""Legacy helper returning a list or error dict."""
selection = search_places_service(query)
if selection.error:
return {"error": selection.error}
return selection.results

View File

@@ -26,9 +26,10 @@ def background_geocode_and_assign(location_id: str):
if not (location.latitude and location.longitude):
return
from adventures.geocoding import reverse_geocode # or wherever you defined it
from adventures.services.geocoding.reverse import reverse_geocode as reverse_geocode_service
is_visited = location.is_visited_status()
result = reverse_geocode(location.latitude, location.longitude, location.user)
selection = reverse_geocode_service(location.latitude, location.longitude, location.user)
result = selection.data
if 'region_id' in result:
region = Region.objects.filter(id=result['region_id']).first()

View File

@@ -0,0 +1 @@
"""External provider integrations."""

View File

@@ -0,0 +1,10 @@
from dataclasses import dataclass
from typing import Generic, Optional, TypeVar
T = TypeVar("T")
@dataclass
class ProviderResult(Generic[T]):
data: Optional[T] = None
error: Optional[str] = None

View File

@@ -0,0 +1 @@
"""Geocoding provider integrations."""

View File

@@ -0,0 +1,29 @@
from typing import Dict
import requests
from adventures.providers.base import ProviderResult
GEOCODE_URL = "https://maps.googleapis.com/maps/api/geocode/json"
def reverse_geocode(lat: float, lon: float, api_key: str) -> ProviderResult[Dict]:
if not api_key:
return ProviderResult(error="Google Maps API key not configured")
params = {"latlng": f"{lat},{lon}", "key": api_key}
try:
response = requests.get(GEOCODE_URL, params=params, timeout=(2, 5))
response.raise_for_status()
except requests.exceptions.Timeout:
return ProviderResult(error="Request timed out while contacting Google Maps")
except requests.exceptions.RequestException:
return ProviderResult(error="Google Maps service error")
data = response.json() or {}
status = data.get("status")
if status and status != "OK":
return ProviderResult(error=f"Google Maps geocoding error: {status}")
return ProviderResult(data=data)

View File

@@ -0,0 +1,42 @@
import socket
from typing import Dict
import requests
from adventures.providers.base import ProviderResult
REVERSE_URL = "https://nominatim.openstreetmap.org/reverse"
def _is_host_resolvable(hostname: str) -> bool:
try:
socket.gethostbyname(hostname)
return True
except socket.error:
return False
def reverse_geocode(lat: float, lon: float, user_agent: str = "AdventureLog Server") -> ProviderResult[Dict]:
if not _is_host_resolvable("nominatim.openstreetmap.org"):
return ProviderResult(error="Unable to resolve OpenStreetMap service")
params = {
"format": "jsonv2",
"addressdetails": 1,
"namedetails": 1,
"extratags": 1,
"zoom": 18,
"lat": lat,
"lon": lon,
}
headers = {"User-Agent": user_agent}
try:
response = requests.get(REVERSE_URL, headers=headers, params=params, timeout=(1, 5))
response.raise_for_status()
except requests.exceptions.Timeout:
return ProviderResult(error="Request timed out while contacting OpenStreetMap")
except requests.exceptions.RequestException:
return ProviderResult(error="OpenStreetMap service error")
return ProviderResult(data=response.json() or {})

View File

@@ -0,0 +1 @@
"""Place provider integrations."""

View File

@@ -0,0 +1,104 @@
from typing import Dict, List
import requests
from adventures.providers.base import ProviderResult
SEARCH_URL = "https://places.googleapis.com/v1/places:searchText"
DETAILS_URL = "https://places.googleapis.com/v1/places/{place_id}"
NEARBY_URL = "https://places.googleapis.com/v1/places:searchNearby"
SEARCH_FIELD_MASK = (
"places.id,places.displayName.text,places.formattedAddress,places.location,"
"places.types,places.rating,places.userRatingCount,places.websiteUri,"
"places.nationalPhoneNumber,places.internationalPhoneNumber,"
"places.editorialSummary.text,places.googleMapsUri,places.photos.name"
)
DETAILS_FIELD_MASK = (
"id,displayName.text,formattedAddress,editorialSummary.text,types,"
"rating,userRatingCount,websiteUri,nationalPhoneNumber,"
"internationalPhoneNumber,googleMapsUri,reviews.text.text"
)
NEARBY_FIELD_MASK = "places.displayName.text,places.formattedAddress,places.types"
def search_places(query: str, api_key: str, max_results: int = 20) -> ProviderResult[List[Dict]]:
if not api_key:
return ProviderResult(error="Google Maps API key not configured")
headers = {
"Content-Type": "application/json",
"X-Goog-Api-Key": api_key,
"X-Goog-FieldMask": SEARCH_FIELD_MASK,
}
payload = {
"textQuery": query,
"maxResultCount": max_results,
}
try:
response = requests.post(SEARCH_URL, json=payload, headers=headers, timeout=(2, 5))
response.raise_for_status()
except requests.exceptions.Timeout:
return ProviderResult(error="Request timed out while contacting Google Maps")
except requests.exceptions.RequestException:
return ProviderResult(error="Google Maps service error")
data = response.json() or {}
return ProviderResult(data=data.get("places", []))
def fetch_place_details(place_id: str, api_key: str) -> ProviderResult[Dict]:
if not api_key:
return ProviderResult(error="Google Maps API key not configured")
headers = {
"X-Goog-Api-Key": api_key,
"X-Goog-FieldMask": DETAILS_FIELD_MASK,
}
try:
response = requests.get(
DETAILS_URL.format(place_id=place_id),
headers=headers,
timeout=(2, 6),
)
response.raise_for_status()
except requests.exceptions.Timeout:
return ProviderResult(error="Request timed out while contacting Google Maps")
except requests.exceptions.RequestException:
return ProviderResult(error="Google Maps service error")
return ProviderResult(data=response.json() or {})
def search_nearby(lat: float, lon: float, api_key: str, radius: float = 45.0) -> ProviderResult[List[Dict]]:
if not api_key:
return ProviderResult(error="Google Maps API key not configured")
headers = {
"Content-Type": "application/json",
"X-Goog-Api-Key": api_key,
"X-Goog-FieldMask": NEARBY_FIELD_MASK,
}
payload = {
"maxResultCount": 6,
"rankPreference": "DISTANCE",
"locationRestriction": {
"circle": {
"center": {"latitude": float(lat), "longitude": float(lon)},
"radius": float(radius),
}
},
}
try:
response = requests.post(NEARBY_URL, headers=headers, json=payload, timeout=(2, 5))
response.raise_for_status()
except requests.exceptions.RequestException:
return ProviderResult(error="Google Maps service error")
data = response.json() or {}
return ProviderResult(data=data.get("places", []))

View File

@@ -0,0 +1,26 @@
from typing import Dict, List
import requests
from adventures.providers.base import ProviderResult
SEARCH_URL = "https://nominatim.openstreetmap.org/search"
def search_places(query: str, limit: int = 20, user_agent: str = "AdventureLog Server") -> ProviderResult[List[Dict]]:
headers = {"User-Agent": user_agent}
params = {
"q": query,
"format": "jsonv2",
"limit": limit,
}
try:
response = requests.get(SEARCH_URL, headers=headers, params=params, timeout=(2, 5))
response.raise_for_status()
except requests.exceptions.Timeout:
return ProviderResult(error="Request timed out while contacting OpenStreetMap")
except requests.exceptions.RequestException:
return ProviderResult(error="OpenStreetMap service error")
return ProviderResult(data=response.json() or [])

View File

@@ -0,0 +1,42 @@
from typing import Optional
from urllib.parse import quote
import requests
from adventures.providers.base import ProviderResult
def fetch_summary(query: str, language: str = "en") -> ProviderResult[Optional[str]]:
normalized_query = (query or "").strip()
if not normalized_query:
return ProviderResult(error="Missing query")
candidates = [normalized_query]
if "," in normalized_query:
head = normalized_query.split(",")[0].strip()
if head and head not in candidates:
candidates.append(head)
for candidate in candidates:
try:
encoded_query = quote(candidate, safe="")
url = f"https://{language}.wikipedia.org/api/rest_v1/page/summary/{encoded_query}"
response = requests.get(
url,
headers={"User-Agent": "AdventureLog Server"},
timeout=(2, 5),
)
if response.status_code != 200:
continue
data = response.json() or {}
if data.get("type") == "disambiguation":
continue
extract = (data.get("extract") or "").strip()
if len(extract) >= 120:
return ProviderResult(data=extract)
except requests.exceptions.RequestException:
continue
return ProviderResult(data=None)

View File

@@ -0,0 +1 @@
"""Recommendation provider integrations."""

View File

@@ -0,0 +1,118 @@
from typing import Dict, List
import requests
from adventures.providers.base import ProviderResult
NEARBY_URL = "https://places.googleapis.com/v1/places:searchNearby"
FIELD_MASK = (
"places.id,"
"places.displayName,"
"places.formattedAddress,"
"places.shortFormattedAddress,"
"places.location,"
"places.types,"
"places.rating,"
"places.userRatingCount,"
"places.businessStatus,"
"places.priceLevel,"
"places.websiteUri,"
"places.googleMapsUri,"
"places.nationalPhoneNumber,"
"places.internationalPhoneNumber,"
"places.editorialSummary,"
"places.photos,"
"places.currentOpeningHours,"
"places.regularOpeningHours"
)
def search_nearby(
lat: float,
lon: float,
radius: float,
api_key: str,
included_types: List[str],
max_results: int = 20,
) -> ProviderResult[List[Dict]]:
if not api_key:
return ProviderResult(error="Google Maps API key not configured")
headers = {
"Content-Type": "application/json",
"X-Goog-Api-Key": api_key,
"X-Goog-FieldMask": FIELD_MASK,
}
payload = {
"includedTypes": included_types,
"maxResultCount": max_results,
"rankPreference": "DISTANCE",
"locationRestriction": {
"circle": {
"center": {"latitude": float(lat), "longitude": float(lon)},
"radius": float(radius),
}
},
}
try:
response = requests.post(NEARBY_URL, json=payload, headers=headers, timeout=15)
response.raise_for_status()
except requests.exceptions.Timeout:
return ProviderResult(error="Google Places API timeout")
except requests.exceptions.RequestException as e:
# If v1 API returns 400 Bad Request, attempt legacy Places NearbySearch as a fallback
try:
status = getattr(e.response, 'status_code', None)
except Exception:
status = None
if status == 400:
# Legacy NearbySearch
try:
legacy_url = "https://maps.googleapis.com/maps/api/place/nearbysearch/json"
params = {
'key': api_key,
'location': f"{lat},{lon}",
'radius': int(min(radius, 50000)),
# Use the first included type as the 'type' or use keyword
}
if included_types:
params['keyword'] = ','.join(included_types[:5])
legacy_resp = requests.get(legacy_url, params=params, timeout=10)
legacy_resp.raise_for_status()
legacy_data = legacy_resp.json() or {}
places = []
for item in legacy_data.get('results', [])[:max_results]:
loc = item.get('geometry', {}).get('location', {})
photos = item.get('photos') or []
places.append({
'id': item.get('place_id'),
'displayName': item.get('name'),
'formattedAddress': item.get('vicinity') or item.get('formatted_address'),
'location': {'latitude': loc.get('lat'), 'longitude': loc.get('lng')},
'types': item.get('types'),
'rating': item.get('rating'),
'userRatingCount': item.get('user_ratings_total') or item.get('userRatingCount'),
'businessStatus': None,
'priceLevel': item.get('price_level'),
'websiteUri': None,
'googleMapsUri': None,
'nationalPhoneNumber': None,
'internationalPhoneNumber': None,
'editorialSummary': None,
'photos': [{'photo_reference': p.get('photo_reference')} for p in photos],
'currentOpeningHours': {'openNow': item.get('opening_hours', {}).get('open_now')} if item.get('opening_hours') else None,
'regularOpeningHours': None,
})
return ProviderResult(data=places)
except requests.exceptions.RequestException:
return ProviderResult(error="Google Places API error")
return ProviderResult(error="Google Places API error")
data = response.json() or {}
return ProviderResult(data=data.get("places", []))

View File

@@ -0,0 +1,53 @@
from typing import Dict, List
import logging
import requests
from adventures.providers.base import ProviderResult
logger = logging.getLogger(__name__)
# Primary and fallback Overpass endpoints
OVERPASS_URLS: List[str] = [
"https://overpass-api.de/api/interpreter",
"https://lz4.overpass-api.de/api/interpreter",
"https://overpass.kumi.systems/api/interpreter",
]
def execute_overpass_query(query: str, timeout: int = 30) -> ProviderResult[Dict]:
last_exception = None
for url in OVERPASS_URLS:
try:
headers = {
"User-Agent": "AdventureLog/1.0 (Overpass request)",
"Accept": "application/json",
}
# Send the query as a form field named 'data' which Overpass accepts
response = requests.post(url, data={"data": query}, timeout=timeout, headers=headers)
# If the server returns a 5xx or 4xx, log body for debugging
if response.status_code != 200:
logger.warning("Overpass server %s returned status %s: %s", url, response.status_code, response.text[:1024])
response.raise_for_status()
try:
data = response.json() or {}
except ValueError:
logger.exception("Invalid JSON from Overpass server %s", url)
return ProviderResult(error="Invalid OpenStreetMap response")
return ProviderResult(data=data)
except requests.exceptions.Timeout as e:
logger.warning("Overpass timeout from %s: %s", url, str(e))
last_exception = e
continue
except requests.exceptions.RequestException as e:
logger.warning("Overpass request failed for %s: %s", url, str(e))
last_exception = e
continue
# If we get here, all endpoints failed
if last_exception:
logger.exception("All Overpass endpoints failed: %s", str(last_exception))
return ProviderResult(error="OpenStreetMap query error")
return ProviderResult(error="OpenStreetMap query error")

View File

@@ -0,0 +1 @@
"""Service layer for AdventureLog business logic."""

View File

@@ -0,0 +1 @@
"""Geocoding services."""

View File

@@ -0,0 +1,107 @@
from dataclasses import dataclass
from typing import Any, Dict, Optional
from django.conf import settings
from adventures.providers.geocoding.google import reverse_geocode as google_reverse
from adventures.providers.geocoding.osm import reverse_geocode as osm_reverse
from adventures.utils.geocoding_utils import (
_extract_google_location_name,
_extract_osm_location_name,
_fetch_google_nearby_place_name,
_parse_google_address_components,
extractIsoCode,
)
@dataclass
class ReverseGeocodeResult:
data: Dict[str, Any]
provider_used: Optional[str]
def _normalize_google_geocode_payload(payload: Dict[str, Any], lat: float, lon: float, api_key: str) -> Dict[str, Any]:
results = (payload or {}).get("results", []) or []
if not results:
return {"error": "No location found for the given coordinates."}
nearby_place_name = _fetch_google_nearby_place_name(lat, lon, api_key)
location_name = _extract_google_location_name(results, nearby_place_name=nearby_place_name)
first_result = results[0]
address_result = next(
(result for result in results if "plus_code" not in result.get("types", [])),
first_result,
)
return {
"name": first_result.get("formatted_address"),
"location_name": location_name,
"address": _parse_google_address_components(address_result.get("address_components", [])),
}
def _reverse_geocode_quality(result: Dict[str, Any]) -> int:
if not result or result.get("error"):
return 0
score = 0
if result.get("region_id"):
score += 3
if result.get("city_id"):
score += 2
if result.get("country_id"):
score += 1
if result.get("location_name"):
score += 1
if result.get("display_name"):
score += 1
return score
def reverse_geocode(lat: float, lon: float, user) -> ReverseGeocodeResult:
google_result = None
api_key = getattr(settings, "GOOGLE_MAPS_API_KEY", None)
if api_key:
google_payload = google_reverse(lat, lon, api_key)
if not google_payload.error:
normalized_google = _normalize_google_geocode_payload(
google_payload.data, lat=lat, lon=lon, api_key=api_key
)
if normalized_google.get("error"):
google_result = normalized_google
else:
google_result = extractIsoCode(user, normalized_google)
if isinstance(google_result, dict) and "error" not in google_result:
google_result["provider_used"] = "google"
google_result["provider"] = "google"
if google_result and not google_result.get("error"):
if _reverse_geocode_quality(google_result) >= 5:
return ReverseGeocodeResult(data=google_result, provider_used="google")
osm_payload = osm_reverse(lat, lon)
osm_result = None
if not osm_payload.error:
osm_data = osm_payload.data
osm_data["location_name"] = _extract_osm_location_name(osm_data)
osm_result = extractIsoCode(user, osm_data)
if isinstance(osm_result, dict) and "error" not in osm_result:
osm_result["provider_used"] = "osm"
osm_result["provider"] = "osm"
if osm_result and not osm_result.get("error"):
if google_result and not google_result.get("error"):
google_score = _reverse_geocode_quality(google_result)
osm_score = _reverse_geocode_quality(osm_result)
best = google_result if google_score >= osm_score else osm_result
used = "google" if best is google_result else "osm"
return ReverseGeocodeResult(data=best, provider_used=used)
return ReverseGeocodeResult(data=osm_result, provider_used="osm")
if google_result:
return ReverseGeocodeResult(data=google_result, provider_used="google")
return ReverseGeocodeResult(data=osm_result or {"error": "Unable to reverse geocode location."}, provider_used=None)

View File

@@ -0,0 +1,188 @@
import logging
import socket
import ipaddress
import mimetypes
import uuid
from urllib.parse import urljoin, urlparse
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import List
import requests
from django.core.files.base import ContentFile
from django.contrib.contenttypes.models import ContentType
from users.media_utils import enforce_media_storage_limit, get_uploaded_file_size
from adventures.models import ContentImage
logger = logging.getLogger(__name__)
def _public_import_error_message(exc):
if isinstance(exc, ValueError):
return "Invalid image URL"
if isinstance(exc, requests.exceptions.Timeout):
return "Download timeout"
if isinstance(exc, requests.exceptions.RequestException):
return "Failed to fetch image from the remote server"
return "Image import failed"
def _is_safe_url(image_url):
parsed = urlparse(image_url)
if parsed.scheme not in ("http", "https"):
return False, "Invalid URL scheme. Only http and https are allowed."
port = parsed.port
if port is not None and port not in (80, 443):
return False, "Non-standard ports are not allowed."
hostname = parsed.hostname
if not hostname:
return False, "Invalid URL: missing hostname."
try:
addr_infos = socket.getaddrinfo(hostname, None)
except socket.gaierror:
return False, "Could not resolve hostname."
if not addr_infos:
return False, "Could not resolve hostname."
for addr_info in addr_infos:
try:
ip = ipaddress.ip_address(addr_info[4][0])
except ValueError:
return False, "Invalid IP address resolved from hostname."
if (ip.is_private or ip.is_loopback or ip.is_reserved
or ip.is_link_local or ip.is_multicast):
return False, "Access to internal networks is not allowed."
return True, parsed
def download_remote_image(image_url: str, max_redirects: int = 3, timeout: int = 10):
safe, result = _is_safe_url(image_url)
if not safe:
raise ValueError(result)
headers = {"User-Agent": "AdventureLog/1.0 (Image Import)"}
current_url = image_url
response = None
for _ in range(max_redirects + 1):
response = requests.get(
current_url,
timeout=timeout,
headers=headers,
stream=True,
allow_redirects=False,
)
if not response.is_redirect:
break
redirect_url = response.headers.get("Location", "")
if not redirect_url:
raise ValueError("Redirect with missing Location header")
redirect_url = urljoin(current_url, redirect_url)
safe, result = _is_safe_url(redirect_url)
if not safe:
raise ValueError(f"Redirect blocked: {result}")
current_url = redirect_url
else:
raise ValueError("Too many redirects")
if response is None:
raise ValueError("Failed to fetch image")
response.raise_for_status()
content_type = response.headers.get("Content-Type", "").split(";")[0].strip().lower()
if not content_type.startswith("image/"):
raise ValueError("URL does not point to an image")
content_length = response.headers.get("Content-Length")
if content_length and int(content_length) > 20 * 1024 * 1024:
raise ValueError("Image too large (max 20MB)")
ext = mimetypes.guess_extension(content_type) or ".jpg"
if ext == ".jpe":
ext = ".jpg"
return {
"filename": f"remote_{uuid.uuid4().hex}{ext}",
"content": response.content,
"content_type": content_type,
"source_url": image_url,
}
def import_remote_images_for_object(content_object, urls: List[str], owner=None, max_workers: int = 5):
content_type = ContentType.objects.get_for_model(content_object.__class__)
object_id = str(content_object.id)
image_owner = owner or getattr(content_object, "user", None)
downloaded_results = []
worker_count = max(1, min(max_workers, len(urls)))
with ThreadPoolExecutor(max_workers=worker_count) as executor:
futures = {executor.submit(download_remote_image, url): (i, url) for i, url in enumerate(urls)}
for future in as_completed(futures):
i, url = futures[future]
try:
file_data = future.result()
downloaded_results.append((i, url, file_data, None))
except Exception as exc:
logger.warning("Image import failed for URL %s", url, exc_info=True)
downloaded_results.append((i, url, None, _public_import_error_message(exc)))
downloaded_results.sort(key=lambda item: item[0])
existing_image_count = ContentImage.objects.filter(content_type=content_type, object_id=object_id).count()
set_primary_next = existing_image_count == 0
created_images = []
results = []
failed = []
for _, image_url, file_data, error_message in downloaded_results:
if error_message:
failure = {"url": image_url, "error": error_message}
results.append({**failure, "status": "failed"})
failed.append(failure)
continue
incoming_bytes = len(file_data["content"])
allowed, details = enforce_media_storage_limit(image_owner, incoming_bytes)
if not allowed:
failure = {"url": image_url, "error": "Media storage limit exceeded", "details": details}
results.append({**failure, "status": "failed"})
failed.append(failure)
continue
image_file = ContentFile(file_data["content"], name=file_data["filename"])
image = ContentImage.objects.create(
user=image_owner,
image=image_file,
content_type=content_type,
object_id=object_id,
is_primary=set_primary_next,
)
if set_primary_next:
set_primary_next = False
created_images.append(image)
results.append({"url": image_url, "status": "created", "id": str(image.id)})
return {
"created_images": created_images,
"results": results,
"created_count": len(created_images),
"requested_count": len(urls),
"failed_count": len(failed),
"failed": failed,
}

View File

@@ -0,0 +1 @@
"""Media-related services."""

View File

@@ -0,0 +1,223 @@
import ipaddress
import mimetypes
import socket
import uuid
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import urljoin, urlparse
import requests
from django.contrib.contenttypes.models import ContentType
from django.core.files.base import ContentFile
from adventures.models import ContentImage
from users.media_utils import enforce_media_storage_limit
def _public_import_error_message(exc):
"""Return a safe, user-facing import error without exposing internal details."""
if isinstance(exc, ValueError):
return "Invalid image URL"
if isinstance(exc, requests.exceptions.Timeout):
return "Download timeout"
if isinstance(exc, requests.exceptions.RequestException):
return "Failed to fetch image from the remote server"
return "Image import failed"
def _is_safe_url(image_url):
"""
Validate a URL for safe proxy use.
Returns (True, parsed) on success or (False, error_message) on failure.
Checks:
- Scheme is http or https
- No non-standard ports (only 80 and 443 allowed)
- All resolved IPs are public (no private/loopback/reserved/link-local/multicast)
"""
parsed = urlparse(image_url)
if parsed.scheme not in ("http", "https"):
return False, "Invalid URL scheme. Only http and https are allowed."
port = parsed.port
if port is not None and port not in (80, 443):
return False, "Non-standard ports are not allowed."
hostname = parsed.hostname
if not hostname:
return False, "Invalid URL: missing hostname."
try:
addr_infos = socket.getaddrinfo(hostname, None)
except socket.gaierror:
return False, "Could not resolve hostname."
if not addr_infos:
return False, "Could not resolve hostname."
for addr_info in addr_infos:
try:
ip = ipaddress.ip_address(addr_info[4][0])
except ValueError:
return False, "Invalid IP address resolved from hostname."
if (
ip.is_private
or ip.is_loopback
or ip.is_reserved
or ip.is_link_local
or ip.is_multicast
):
return False, "Access to internal networks is not allowed."
return True, parsed
def download_remote_image(image_url):
safe, result = _is_safe_url(image_url)
if not safe:
raise ValueError(result)
headers = {"User-Agent": "AdventureLog/1.0 (Image Import)"}
max_redirects = 3
current_url = image_url
response = None
for _ in range(max_redirects + 1):
response = requests.get(
current_url,
timeout=10,
headers=headers,
stream=True,
allow_redirects=False,
)
if not response.is_redirect:
break
redirect_url = response.headers.get("Location", "")
if not redirect_url:
raise ValueError("Redirect with missing Location header")
# Handle relative redirects safely.
redirect_url = urljoin(current_url, redirect_url)
safe, result = _is_safe_url(redirect_url)
if not safe:
raise ValueError(f"Redirect blocked: {result}")
current_url = redirect_url
else:
raise ValueError("Too many redirects")
if response is None:
raise ValueError("Failed to fetch image")
response.raise_for_status()
content_type = response.headers.get("Content-Type", "").split(";")[0].strip().lower()
if not content_type.startswith("image/"):
raise ValueError("URL does not point to an image")
content_length = response.headers.get("Content-Length")
if content_length and int(content_length) > 20 * 1024 * 1024:
raise ValueError("Image too large (max 20MB)")
ext = mimetypes.guess_extension(content_type) or ".jpg"
if ext == ".jpe":
ext = ".jpg"
return {
"filename": f"remote_{uuid.uuid4().hex}{ext}",
"content": response.content,
"content_type": content_type,
"source_url": image_url,
}
def import_remote_images_for_object(content_object, urls, owner=None, max_workers=5):
"""Download remote URLs and attach them as ContentImage records for a content object."""
content_type = ContentType.objects.get_for_model(content_object.__class__)
object_id = str(content_object.id)
image_owner = owner or getattr(content_object, "user", None)
downloaded_results = []
worker_count = max(1, min(max_workers, len(urls)))
with ThreadPoolExecutor(max_workers=worker_count) as executor:
futures = {
executor.submit(download_remote_image, image_url): (index, image_url)
for index, image_url in enumerate(urls)
}
for future in as_completed(futures):
index, image_url = futures[future]
try:
file_data = future.result()
downloaded_results.append((index, image_url, file_data, None))
except Exception as exc:
downloaded_results.append((index, image_url, None, _public_import_error_message(exc)))
downloaded_results.sort(key=lambda item: item[0])
existing_image_count = ContentImage.objects.filter(
content_type=content_type,
object_id=object_id,
).count()
set_primary_next = existing_image_count == 0
created_images = []
results = []
failed = []
for _, image_url, file_data, error_message in downloaded_results:
if error_message:
failure = {
"url": image_url,
"error": error_message,
}
results.append({
**failure,
"status": "failed",
})
failed.append(failure)
continue
incoming_bytes = len(file_data["content"])
allowed, details = enforce_media_storage_limit(image_owner, incoming_bytes)
if not allowed:
failure = {
"url": image_url,
"error": "Media storage limit exceeded",
"details": details,
}
results.append({
**failure,
"status": "failed",
})
failed.append(failure)
continue
image_file = ContentFile(file_data["content"], name=file_data["filename"])
image = ContentImage.objects.create(
user=image_owner,
image=image_file,
content_type=content_type,
object_id=object_id,
is_primary=set_primary_next,
)
if set_primary_next:
set_primary_next = False
created_images.append(image)
results.append({
"url": image_url,
"status": "success",
"image_id": image.id,
})
return {
"created": created_images,
"created_count": len(created_images),
"failed": failed,
"failed_count": len(failed),
"results": results,
}

View File

@@ -0,0 +1 @@
"""Place-related services."""

View File

@@ -0,0 +1,127 @@
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from django.conf import settings
from adventures.providers.places.google import fetch_place_details
from adventures.providers.places.wikipedia import fetch_summary
@dataclass
class PlaceDetails:
description: Optional[str]
name: Optional[str]
formatted_address: Optional[str]
types: List[str]
rating: Optional[float]
review_count: Optional[int]
website: Optional[str]
phone_number: Optional[str]
google_maps_url: Optional[str]
source: Optional[str]
provider_used: Optional[str]
def to_dict(self) -> Dict[str, Any]:
return {
"description": self.description,
"name": self.name,
"formatted_address": self.formatted_address,
"types": self.types,
"rating": self.rating,
"review_count": self.review_count,
"website": self.website,
"phone_number": self.phone_number,
"google_maps_url": self.google_maps_url,
"source": self.source,
"provider_used": self.provider_used,
}
def _compose_place_description(editorial_summary: Optional[str], review_snippets: List[str]) -> Optional[str]:
parts: List[str] = []
summary = (editorial_summary or "").strip()
if summary:
parts.append(f"### About\n\n{summary}")
cleaned_reviews = []
for snippet in review_snippets:
text = (snippet or "").strip()
if len(text) >= 40:
cleaned_reviews.append(text)
if len(cleaned_reviews) >= 2:
break
if cleaned_reviews:
review_block = "### Visitor Highlights\n\n" + "\n".join(f"- {text}" for text in cleaned_reviews)
parts.append(review_block)
return "\n\n".join(parts).strip() or None
def get_place_details(
place_id: str,
fallback_query: Optional[str] = None,
language: str = "en",
) -> Dict[str, Any]:
if not place_id:
return {"error": "place_id is required"}
details = PlaceDetails(
description=None,
name=None,
formatted_address=None,
types=[],
rating=None,
review_count=None,
website=None,
phone_number=None,
google_maps_url=None,
source=None,
provider_used=None,
)
api_key = getattr(settings, "GOOGLE_MAPS_API_KEY", None)
if api_key:
google_payload = fetch_place_details(place_id, api_key)
if not google_payload.error:
place = google_payload.data or {}
details.name = (place.get("displayName") or {}).get("text")
details.formatted_address = place.get("formattedAddress")
details.types = place.get("types") or []
details.rating = place.get("rating")
details.review_count = place.get("userRatingCount")
details.website = place.get("websiteUri")
details.phone_number = (
place.get("internationalPhoneNumber") or place.get("nationalPhoneNumber")
)
details.google_maps_url = place.get("googleMapsUri")
editorial_summary = (place.get("editorialSummary") or {}).get("text")
reviews = place.get("reviews") or []
review_snippets = [((review.get("text") or {}).get("text")) for review in reviews]
details.description = _compose_place_description(editorial_summary, review_snippets)
if details.description:
details.source = "google"
details.provider_used = "google"
description_text = (details.description or "").strip()
if len(description_text) < 220:
wiki_payload = fetch_summary(fallback_query or details.name or "", language=language)
wikipedia_summary = wiki_payload.data if not wiki_payload.error else None
if wikipedia_summary:
if description_text:
details.description = (
f"{description_text}\n\n### Background\n\n{wikipedia_summary}"
)
details.source = "google+wikipedia"
details.provider_used = "google+wikipedia"
else:
details.description = f"### Background\n\n{wikipedia_summary}"
details.source = "wikipedia"
details.provider_used = "wikipedia"
if not details.description:
return {"error": "Unable to enrich place description"}
return details.to_dict()

View File

@@ -0,0 +1,360 @@
import math
import re
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from django.conf import settings
from adventures.providers.places.google import search_places as google_search
from adventures.providers.places.osm import search_places as osm_search
@dataclass
class PlaceSearchResult:
lat: Optional[float]
lon: Optional[float]
name: Optional[str]
display_name: Optional[str]
place_id: Optional[str]
type: Optional[str]
types: List[str]
category: Optional[str]
description: Optional[str]
website: Optional[str]
phone_number: Optional[str]
google_maps_url: Optional[str]
importance: Optional[float]
rating: Optional[float]
review_count: Optional[int]
photos: List[str]
addresstype: Optional[str]
provider: Optional[str]
powered_by: Optional[str]
def to_dict(self) -> Dict[str, Any]:
return {
"lat": self.lat,
"lon": self.lon,
"name": self.name,
"display_name": self.display_name,
"place_id": self.place_id,
"type": self.type,
"types": self.types,
"category": self.category,
"description": self.description,
"website": self.website,
"phone_number": self.phone_number,
"google_maps_url": self.google_maps_url,
"importance": self.importance,
"rating": self.rating,
"review_count": self.review_count,
"photos": self.photos,
"addresstype": self.addresstype,
"provider": self.provider,
"powered_by": self.powered_by,
}
@dataclass
class ProviderSearchResult:
provider_used: Optional[str]
providers_attempted: List[str] = field(default_factory=list)
results: List[Dict[str, Any]] = field(default_factory=list)
error: Optional[str] = None
def _safe_float(value: Any) -> Optional[float]:
try:
return float(value)
except (TypeError, ValueError):
return None
def _safe_int(value: Any) -> Optional[int]:
try:
return int(value)
except (TypeError, ValueError):
return None
def _extract_google_category(types: List[str]) -> Optional[str]:
if not types:
return None
if "restaurant" in types:
return "food"
if "lodging" in types:
return "accommodation"
if "park" in types or "natural_feature" in types:
return "nature"
if "museum" in types or "tourist_attraction" in types:
return "attraction"
if "locality" in types or "administrative_area_level_1" in types:
return "region"
return types[0]
def _infer_addresstype(type_name: Optional[str]) -> Optional[str]:
mapping = {
"locality": "city",
"sublocality": "neighborhood",
"administrative_area_level_1": "region",
"administrative_area_level_2": "county",
"country": "country",
"premise": "building",
"point_of_interest": "poi",
"route": "road",
"street_address": "address",
}
return mapping.get(type_name, None)
def _normalize_google_search_results(places: List[Dict[str, Any]], api_key: str) -> List[Dict[str, Any]]:
normalized: List[Dict[str, Any]] = []
for place in places or []:
location = place.get("location", {}) or {}
types = place.get("types", []) or []
primary_type = types[0] if types else None
category = _extract_google_category(types)
addresstype = _infer_addresstype(primary_type)
rating = _safe_float(place.get("rating"))
review_count = _safe_int(place.get("userRatingCount"))
importance = None
if rating is not None and review_count:
importance = round(float(rating) * float(review_count) / 100, 2)
photos = []
for photo in place.get("photos", [])[:5]:
photo_name = photo.get("name")
if photo_name:
photos.append(
f"https://places.googleapis.com/v1/{photo_name}/media?key={api_key}&maxHeightPx=800&maxWidthPx=800"
)
display_name_obj = place.get("displayName", {}) or {}
name = display_name_obj.get("text") if isinstance(display_name_obj, dict) else display_name_obj
normalized.append(
PlaceSearchResult(
lat=_safe_float(location.get("latitude")),
lon=_safe_float(location.get("longitude")),
name=name,
display_name=place.get("formattedAddress"),
place_id=place.get("id"),
type=primary_type,
types=types,
category=category,
description=(place.get("editorialSummary") or {}).get("text"),
website=place.get("websiteUri"),
phone_number=place.get("internationalPhoneNumber")
or place.get("nationalPhoneNumber"),
google_maps_url=place.get("googleMapsUri"),
importance=importance,
rating=rating,
review_count=review_count,
photos=photos,
addresstype=addresstype,
provider="google",
powered_by="google",
).to_dict()
)
if normalized:
normalized.sort(
key=lambda r: r.get("importance") if r.get("importance") is not None else 0,
reverse=True,
)
return normalized
def _normalize_osm_search_results(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
normalized: List[Dict[str, Any]] = []
for item in results or []:
normalized.append(
PlaceSearchResult(
lat=_safe_float(item.get("lat")),
lon=_safe_float(item.get("lon")),
name=item.get("name"),
display_name=item.get("display_name"),
place_id=None,
type=item.get("type"),
types=[],
category=item.get("category"),
description=None,
website=None,
phone_number=None,
google_maps_url=None,
importance=_safe_float(item.get("importance")),
rating=None,
review_count=None,
photos=[],
addresstype=item.get("addresstype"),
provider="osm",
powered_by="nominatim",
).to_dict()
)
return normalized
def _query_looks_like_coordinates(query: str) -> bool:
if not query:
return False
return bool(re.match(r"^\s*-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?\s*$", query))
def _score_search_result(result: Dict[str, Any], query: str) -> float:
score = 0.0
importance = result.get("importance")
if importance is not None:
try:
score += math.log1p(abs(float(importance)))
except (TypeError, ValueError):
pass
rating = result.get("rating")
if rating is not None:
try:
score += float(rating) / 5.0
except (TypeError, ValueError):
pass
review_count = result.get("review_count")
if review_count is not None:
try:
score += math.log1p(float(review_count)) / 3.0
except (TypeError, ValueError):
pass
if result.get("place_id"):
score += 0.5
query_normalized = (query or "").strip().lower()
if query_normalized:
haystack = f"{result.get('name', '')} {result.get('display_name', '')}".lower()
if query_normalized in haystack:
score += 1.5
return score
def _score_search_results(results: List[Dict[str, Any]], query: str) -> float:
if not results:
return 0.0
scores = sorted((_score_search_result(item, query) for item in results), reverse=True)
top_scores = scores[:5]
if not top_scores:
return 0.0
return sum(top_scores) / len(top_scores)
def _search_result_key(result: Dict[str, Any]) -> str:
name = (result.get("name") or result.get("display_name") or "").strip().lower()
lat = result.get("lat")
lon = result.get("lon")
lat_value = round(float(lat), 5) if lat is not None else ""
lon_value = round(float(lon), 5) if lon is not None else ""
return f"{name}:{lat_value}:{lon_value}"
def _merge_search_results(
primary: List[Dict[str, Any]],
secondary: List[Dict[str, Any]],
limit: int = 20,
) -> List[Dict[str, Any]]:
merged: List[Dict[str, Any]] = []
seen = set()
for item in (primary or []) + (secondary or []):
key = _search_result_key(item)
if key in seen:
continue
seen.add(key)
merged.append(item)
if len(merged) >= limit:
break
return merged
def _should_compare_osm(query: str, google_results: List[Dict[str, Any]]) -> bool:
if _query_looks_like_coordinates(query):
return True
if not google_results:
return True
if len(google_results) < 3:
return True
return _score_search_results(google_results, query) < 1.5
def search_places(query: str, max_results: int = 20) -> ProviderSearchResult:
normalized_query = str(query or "").strip()
if not normalized_query:
return ProviderSearchResult(
provider_used=None,
results=[],
error="Query parameter is required",
)
providers_attempted: List[str] = []
google_results: Optional[List[Dict[str, Any]]] = None
osm_results: Optional[List[Dict[str, Any]]] = None
google_error: Optional[str] = None
osm_error: Optional[str] = None
api_key = getattr(settings, "GOOGLE_MAPS_API_KEY", None)
if api_key:
providers_attempted.append("google")
google_payload = google_search(normalized_query, api_key=api_key, max_results=max_results)
if google_payload.error:
google_error = google_payload.error
else:
google_results = _normalize_google_search_results(google_payload.data, api_key)
if google_results is None or _should_compare_osm(normalized_query, google_results):
providers_attempted.append("osm")
osm_payload = osm_search(normalized_query, limit=max_results)
if osm_payload.error:
osm_error = osm_payload.error
else:
osm_results = _normalize_osm_search_results(osm_payload.data)
provider_used: Optional[str] = None
results: List[Dict[str, Any]] = []
if google_results and osm_results:
google_score = _score_search_results(google_results, normalized_query)
osm_score = _score_search_results(osm_results, normalized_query)
score_gap = abs(google_score - osm_score)
score_floor = max(google_score, osm_score, 0.1)
if score_gap / score_floor <= 0.15:
provider_used = "mixed"
results = _merge_search_results(google_results, osm_results, limit=max_results)
elif google_score >= osm_score:
provider_used = "google"
results = google_results
else:
provider_used = "osm"
results = osm_results
elif google_results:
provider_used = "google"
results = google_results
elif osm_results:
provider_used = "osm"
results = osm_results
else:
error_message = google_error or osm_error or "No locations found for the given query."
return ProviderSearchResult(
provider_used=None,
providers_attempted=providers_attempted,
results=[],
error=error_message,
)
return ProviderSearchResult(
provider_used=provider_used,
providers_attempted=providers_attempted,
results=results,
)

View File

@@ -0,0 +1,309 @@
import datetime
from urllib.parse import urlparse
from django.core.exceptions import PermissionDenied as DjangoPermissionDenied
from django.db import models
from django.utils.dateparse import parse_date, parse_datetime
from rest_framework import status
from rest_framework.exceptions import PermissionDenied as DRFPermissionDenied
from rest_framework.response import Response
from django.contrib.contenttypes.models import ContentType
from adventures.models import Collection, CollectionItineraryItem, Visit
def coerce_coordinate(value, min_value, max_value):
try:
number = round(float(value), 6)
except (TypeError, ValueError):
return None
if number < min_value or number > max_value:
return None
return number
def coerce_float(value):
try:
return float(value)
except (TypeError, ValueError):
return None
def coerce_int(value):
try:
return int(value)
except (TypeError, ValueError):
return None
def coerce_bool(value, default=False):
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"true", "1", "yes", "on"}:
return True
if normalized in {"false", "0", "no", "off"}:
return False
return default
def clean_url(value):
if not isinstance(value, str):
return None
normalized = value.strip()
if not normalized:
return None
parsed = urlparse(normalized)
if parsed.scheme in {"http", "https"} and parsed.netloc:
return normalized
return None
def sanitize_tags(raw_tags, max_tags=8):
if not isinstance(raw_tags, list):
return []
tags = []
for item in raw_tags:
if not isinstance(item, str):
continue
value = item.strip()
if not value or value in tags:
continue
tags.append(value)
if len(tags) >= max_tags:
break
return tags
def sanitize_photo_urls(raw_urls, max_urls=5):
if not isinstance(raw_urls, list):
return []
cleaned = []
for value in raw_urls:
url = clean_url(value)
if not url or url in cleaned:
continue
cleaned.append(url)
if len(cleaned) >= max_urls:
break
return cleaned
def build_quick_add_description(base_description, detailed_description):
description = str(detailed_description or "").strip() or str(base_description or "").strip()
return description or None
def resolve_quick_add_collection(collection_id, validate_permissions, permission_error_message):
if not collection_id:
return None
try:
collection = Collection.objects.get(id=collection_id)
except Collection.DoesNotExist:
return Response(
{"error": "Collection not found."},
status=status.HTTP_404_NOT_FOUND,
)
try:
validate_permissions([collection])
except (DjangoPermissionDenied, DRFPermissionDenied):
return Response(
{"error": permission_error_message},
status=status.HTTP_403_FORBIDDEN,
)
return collection
def preferred_link(payload, details):
website = clean_url(details.get("website")) or clean_url(payload.get("website"))
maps_url = clean_url(details.get("google_maps_url")) or clean_url(payload.get("google_maps_url"))
return clean_url(payload.get("link")) or website or maps_url
def infer_lodging_type(primary_type, place_types):
valid_types = {
"hotel",
"hostel",
"resort",
"bnb",
"campground",
"cabin",
"apartment",
"house",
"villa",
"motel",
"other",
}
if isinstance(primary_type, str):
normalized = primary_type.strip().lower()
if normalized in valid_types:
return normalized
normalized_types = [
str(type_name).strip().lower()
for type_name in (place_types or [])
if str(type_name).strip()
]
mapping = {
"hotel": "hotel",
"resort_hotel": "resort",
"motel": "motel",
"hostel": "hostel",
"bed_and_breakfast": "bnb",
"guest_house": "bnb",
"campground": "campground",
"rv_park": "campground",
"camping_cabin": "cabin",
"apartment_building": "apartment",
"lodging": "hotel",
"villa": "villa",
}
for type_name in normalized_types:
if type_name in mapping:
return mapping[type_name]
for type_name in normalized_types:
if type_name in valid_types:
return type_name
return "other"
def parse_itinerary_date(value):
if not value:
return None
raw_value = str(value).strip()
if not raw_value:
return None
parsed_date = parse_date(raw_value)
if parsed_date:
return parsed_date
parsed_datetime = parse_datetime(raw_value)
if parsed_datetime:
return parsed_datetime.date()
return None
def validate_itinerary_date(collection, date_value):
if not collection or not date_value:
return None
if collection.start_date and date_value < collection.start_date:
return Response(
{"error": "Itinerary item date is before the collection start_date"},
status=status.HTTP_400_BAD_REQUEST,
)
if collection.end_date and date_value > collection.end_date:
return Response(
{"error": "Itinerary item date is after the collection end_date"},
status=status.HTTP_400_BAD_REQUEST,
)
return None
def apply_quick_add_itinerary_date(content_object, date_value):
if not content_object or not date_value:
return
model_name = content_object._meta.model_name
if model_name == "location":
start_dt = datetime.datetime.combine(date_value, datetime.time.min)
end_dt = datetime.datetime.combine(date_value, datetime.time.max)
exact_match = Visit.objects.filter(
location=content_object, start_date=start_dt, end_date=end_dt
).first()
if exact_match:
return
overlap_q = models.Q(start_date__lte=end_dt) & models.Q(end_date__gte=start_dt)
existing = Visit.objects.filter(location=content_object).filter(overlap_q).first()
if existing:
existing.start_date = start_dt
existing.end_date = end_dt
existing.save(update_fields=["start_date", "end_date"])
return
Visit.objects.create(
location=content_object,
start_date=start_dt,
end_date=end_dt,
notes="Created from quick add",
)
return
if model_name == "lodging":
if content_object.check_in and content_object.check_out:
return
check_in = datetime.datetime.combine(date_value, datetime.time.min)
check_out = check_in + datetime.timedelta(days=1)
content_object.check_in = check_in
content_object.check_out = check_out
content_object.save(update_fields=["check_in", "check_out"])
def create_quick_add_itinerary_item(collection, content_object, date_value):
if not collection or not content_object or not date_value:
return None
existing_error = validate_itinerary_date(collection, date_value)
if isinstance(existing_error, Response):
return existing_error
content_type = ContentType.objects.get_for_model(content_object.__class__)
existing_item = CollectionItineraryItem.objects.filter(
collection=collection,
content_type=content_type,
object_id=content_object.id,
date=date_value,
is_global=False,
).first()
if existing_item:
return existing_item
max_order = (
CollectionItineraryItem.objects.filter(
collection=collection, date=date_value, is_global=False
).aggregate(max_order=models.Max("order"))["max_order"]
or -1
)
apply_quick_add_itinerary_date(content_object, date_value)
return CollectionItineraryItem.objects.create(
collection=collection,
content_type=content_type,
object_id=content_object.id,
date=date_value,
is_global=False,
order=max_order + 1,
)

View File

@@ -0,0 +1 @@
"""Recommendation services."""

View File

@@ -0,0 +1,307 @@
from typing import Any, Dict, List, Optional
import logging
from geopy.distance import geodesic
from django.conf import settings
from adventures.providers.recommendations import google as google_reco_provider
from adventures.providers.recommendations import osm as osm_reco_provider
logger = logging.getLogger(__name__)
def calculate_quality_score(place_data: Dict[str, Any]) -> float:
import math
score = 0.0
rating = place_data.get("rating")
if rating is not None and rating > 0:
score += (rating / 5.0) * 50
review_count = place_data.get("review_count")
if review_count is not None and review_count > 0:
score += min(30, math.log10(review_count) * 10)
distance_km = place_data.get("distance_km")
if distance_km is not None:
if distance_km < 1:
score += 20
elif distance_km < 5:
score += 15
elif distance_km < 10:
score += 10
elif distance_km < 20:
score += 5
if place_data.get("is_verified") or place_data.get("business_status") == "OPERATIONAL":
score += 10
photos = place_data.get("photos")
if photos and len(photos) > 0:
score += 5
opening_hours = place_data.get("opening_hours")
if opening_hours and len(opening_hours) > 0:
score += 5
return round(score, 2)
def _parse_google_places(places: List[Dict[str, Any]], origin: Optional[tuple]) -> List[Dict[str, Any]]:
locations: List[Dict[str, Any]] = []
api_key = getattr(settings, "GOOGLE_MAPS_API_KEY", None)
for place in places or []:
location = place.get("location", {}) or {}
types = place.get("types", []) or []
display_name = place.get("displayName") or place.get("display_name")
name = display_name.get("text") if isinstance(display_name, dict) else display_name
lat = location.get("latitude")
lon = location.get("longitude")
if lat is None or lon is None or not name:
continue
rating = place.get("rating")
review_count = place.get("userRatingCount") or 0
# Filtering thresholds
if rating and rating < 3.0:
continue
if review_count and review_count < 5:
continue
distance_km = None
if origin:
try:
distance_km = round(geodesic(origin, (float(lat), float(lon))).km, 2)
except Exception:
distance_km = None
formatted_address = place.get("formattedAddress") or place.get("shortFormattedAddress")
business_status = place.get("businessStatus")
opening_hours = place.get("regularOpeningHours")
current_opening = place.get("currentOpeningHours") or {}
is_open_now = current_opening.get("openNow")
photos = place.get("photos", []) or []
photo_urls = []
if photos and api_key:
for photo in photos[:5]:
# v1 API returns photo entries with a 'name' attribute. Legacy API returns 'photo_reference'.
photo_name = photo.get("name")
if photo_name:
photo_urls.append(f"https://places.googleapis.com/v1/{photo_name}/media?key={api_key}&maxHeightPx=800&maxWidthPx=800")
continue
photo_ref = photo.get('photo_reference')
if photo_ref:
# Fallback to legacy photo endpoint
photo_urls.append(f"https://maps.googleapis.com/maps/api/place/photo?maxwidth=800&photoreference={photo_ref}&key={api_key}")
place_data = {
"id": f"google:{place.get('id')}",
"external_id": place.get("id"),
"source": "google",
"name": name,
"description": (place.get("editorialSummary") or {}).get("text") if isinstance(place.get("editorialSummary"), dict) else None,
"latitude": lat,
"longitude": lon,
"address": formatted_address,
"distance_km": distance_km,
"rating": rating,
"review_count": review_count,
"price_level": place.get("priceLevel"),
"types": types,
"primary_type": types[0] if types else None,
"business_status": business_status,
"is_open_now": is_open_now,
"opening_hours": opening_hours.get("weekdayDescriptions") if opening_hours else None,
"phone_number": place.get("nationalPhoneNumber") or place.get("internationalPhoneNumber"),
"website": place.get("websiteUri"),
"google_maps_url": place.get("googleMapsUri"),
"photos": photo_urls,
"is_verified": business_status == "OPERATIONAL",
}
place_data["quality_score"] = calculate_quality_score(place_data)
locations.append(place_data)
return locations
def _parse_overpass(data: Dict[str, Any], origin: Optional[tuple]) -> List[Dict[str, Any]]:
nodes = data.get("elements", []) or []
locations: List[Dict[str, Any]] = []
for node in nodes:
if node.get("type") not in ["node", "way", "relation"]:
continue
tags = node.get("tags", {}) or {}
lat = node.get("lat") or node.get("center", {}).get("lat")
lon = node.get("lon") or node.get("center", {}).get("lon")
name = tags.get("name") or tags.get("official_name") or tags.get("alt_name")
if not name or lat is None or lon is None:
continue
distance_km = None
if origin:
try:
distance_km = round(geodesic(origin, (float(lat), float(lon))).km, 2)
except Exception:
distance_km = None
address_parts = [
tags.get('addr:housenumber'),
tags.get('addr:street'),
tags.get('addr:suburb') or tags.get('addr:neighbourhood'),
tags.get('addr:city'),
tags.get('addr:state'),
tags.get('addr:postcode'),
tags.get('addr:country')
]
formatted_address = ", ".join([p for p in address_parts if p]) or None
types = [tags.get(k) for k in ['tourism', 'leisure', 'amenity', 'natural', 'historic', 'attraction', 'shop', 'sport'] if tags.get(k)]
primary_type = types[0] if types else None
if not primary_type:
continue
if tags.get('disused') or tags.get('construction'):
continue
place_data = {
"id": f"osm:{node.get('type')}:{node.get('id')}",
"external_id": str(node.get('id')),
"source": "osm",
"name": name,
"description": tags.get('description') or tags.get('note'),
"latitude": lat,
"longitude": lon,
"address": formatted_address,
"distance_km": distance_km,
"rating": None,
"review_count": None,
"price_level": None,
"types": types,
"primary_type": primary_type,
"business_status": None,
"is_open_now": None,
"opening_hours": [tags.get('opening_hours')] if tags.get('opening_hours') else None,
"phone_number": tags.get('phone') or tags.get('contact:phone'),
"website": tags.get('website') or tags.get('contact:website') or tags.get('url'),
"google_maps_url": None,
"photos": [tags.get('image')] if tags.get('image') else [],
"is_verified": bool(tags.get('wikipedia') or tags.get('wikidata')),
"osm_type": node.get('type'),
"wikipedia": tags.get('wikipedia'),
}
place_data['quality_score'] = calculate_quality_score(place_data)
locations.append(place_data)
return locations
def _deduplicate_results(google_results: List[Dict[str, Any]], osm_results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
from difflib import SequenceMatcher
def is_similar(name1: str, name2: str, threshold: float = 0.85) -> bool:
return SequenceMatcher(None, (name1 or "").lower(), (name2 or "").lower()).ratio() > threshold
def is_nearby(loc1: Dict[str, Any], loc2: Dict[str, Any], max_distance_m: int = 50) -> bool:
try:
dist = geodesic((loc1['latitude'], loc1['longitude']), (loc2['latitude'], loc2['longitude'])).meters
return dist < max_distance_m
except Exception:
return False
dedup = list(google_results or [])
for osm_loc in osm_results or []:
duplicate = False
for google_loc in google_results or []:
if is_similar(osm_loc.get('name', ''), google_loc.get('name', '')) and is_nearby(osm_loc, google_loc):
duplicate = True
break
if not duplicate:
dedup.append(osm_loc)
return dedup
def get_recommendations(lat: float, lon: float, radius: float, category: str, sources: str = 'both') -> Dict[str, Any]:
api_key = getattr(settings, 'GOOGLE_MAPS_API_KEY', None)
origin = (float(lat), float(lon))
google_results: List[Dict[str, Any]] = []
osm_results: List[Dict[str, Any]] = []
osm_error = None
included_types_map = {
'lodging': ['lodging', 'hotel', 'hostel', 'resort_hotel', 'extended_stay_hotel'],
'food': ['restaurant', 'cafe', 'bar', 'bakery', 'meal_takeaway', 'meal_delivery'],
# Remove types unsupported by Google Places v1 (e.g. 'natural_feature')
'tourism': ['tourist_attraction', 'museum', 'art_gallery', 'aquarium', 'zoo', 'amusement_park', 'park'],
}
included_types = included_types_map.get(category, ['tourist_attraction'])
if api_key and sources in ['google', 'both']:
# Google Places v1 enforces maxResultCount between 1 and 20
provider_payload = google_reco_provider.search_nearby(lat, lon, radius, api_key, included_types, max_results=20)
if provider_payload.error:
logger.warning(f"Google recommendations provider error: {provider_payload.error}")
else:
google_results = _parse_google_places(provider_payload.data or [], origin)
if sources in ['osm', 'both'] or (sources == 'google' and not google_results):
if category == 'tourism':
query = f"""
[out:json][timeout:25];
(
nwr["tourism"~"attraction|viewpoint|museum|gallery|zoo|aquarium"](around:{min(radius,5000)},{lat},{lon});
nwr["leisure"~"park|garden|nature_reserve"](around:{min(radius,5000)},{lat},{lon});
);
out center tags 50;
"""
elif category == 'lodging':
query = f"""
[out:json][timeout:25];
nwr["tourism"~"hotel|motel|guest_house|hostel"](around:{min(radius,5000)},{lat},{lon});
out center tags 50;
"""
else:
query = f"""
[out:json][timeout:25];
nwr["amenity"~"restaurant|cafe|bar|pub|fast_food|bakery"](around:{min(radius,5000)},{lat},{lon});
out center tags 50;
"""
osm_payload = osm_reco_provider.execute_overpass_query(query)
if osm_payload.error:
osm_error = osm_payload.error
logger.warning(f"OSM recommendations provider error: {osm_error}")
else:
osm_results = _parse_overpass(osm_payload.data or {}, origin)
if sources == 'both' and google_results and osm_results:
all_results = _deduplicate_results(google_results, osm_results)
else:
all_results = (google_results or []) + (osm_results or [])
all_results.sort(key=lambda x: x.get('quality_score', 0), reverse=True)
limited = all_results[:50]
response = {
'count': len(limited),
'results': limited,
'sources_used': {
'google': len(google_results),
'osm': len(osm_results),
'total_before_dedup': len(google_results) + len(osm_results)
}
}
if osm_error and len(osm_results) == 0:
response['warnings'] = [osm_error]
return response

View File

@@ -0,0 +1,39 @@
import logging
from typing import List, Dict
import requests
logger = logging.getLogger(__name__)
def get_sun_times_for_visits(latitude: float, longitude: float, visits: List[Dict]) -> List[Dict]:
"""Return sunrise/sunset times for visits given lat/lon and visit date entries.
visits: list of dicts with keys 'start_date' and 'id'
"""
sun_times = []
for visit in visits:
date = visit.get('start_date')
if not (date and latitude and longitude):
continue
api_url = (
f'https://api.sunrisesunset.io/json?'
f'lat={latitude}&lng={longitude}&date={date}'
)
try:
response = requests.get(api_url, timeout=10)
if response.status_code == 200:
data = response.json()
results = data.get('results', {})
if results.get('sunrise') and results.get('sunset'):
sun_times.append({
'date': date,
'visit_id': visit.get('id'),
'sunrise': results.get('sunrise'),
'sunset': results.get('sunset'),
})
except requests.RequestException:
logger.debug('Sun times API request failed for date %s', date)
continue
return sun_times

View File

@@ -0,0 +1,108 @@
import logging
import re
from typing import Dict, List, Optional
from difflib import SequenceMatcher
from adventures.services.wikipedia.search import WikipediaClient, is_valid_image_url
logger = logging.getLogger(__name__)
class WikipediaDescriptionService:
LANGUAGE_PATTERN = re.compile(r"^[a-z0-9-]{2,12}$", re.IGNORECASE)
MAX_CANDIDATES = 10
MIN_DESCRIPTION_LENGTH = 50
def __init__(self, client: Optional[WikipediaClient] = None, max_candidates: int = None):
self.client = client or WikipediaClient()
if max_candidates:
self.MAX_CANDIDATES = max_candidates
def get_language(self, candidate: Optional[str]) -> str:
if not candidate:
candidate = self.client.DEFAULT_LANGUAGE
normalized = candidate.replace('_', '-').lower()
if self.LANGUAGE_PATTERN.match(normalized):
return normalized
return 'en'
def is_disambiguation_page(self, page_data: Dict) -> bool:
categories = page_data.get('categories', [])
for cat in categories:
cat_title = cat.get('title', '').lower()
if 'disambiguation' in cat_title or 'disambig' in cat_title:
return True
title = page_data.get('title', '').lower()
if '(disambiguation)' in title:
return True
return False
def is_list_or_index_page(self, page_data: Dict) -> bool:
title = page_data.get('title', '').lower()
list_patterns = ['list of', 'index of', 'timeline of', 'glossary of', 'outline of']
return any(p in title for p in list_patterns)
def get_description(self, term: str, lang: str) -> Optional[Dict]:
if not term:
return None
candidates = self.client.get_candidate_pages(term, lang, max_candidates=self.MAX_CANDIDATES)
for candidate in candidates:
try:
page_data = self.client.fetch_page(lang=lang, candidate=candidate, props='extracts|categories', extra_params={'exintro': 1, 'explaintext': 1})
except Exception:
logger.exception('Error fetching page %s', candidate)
continue
if not page_data or page_data.get('missing'):
continue
if self.is_disambiguation_page(page_data):
continue
extract = (page_data.get('extract') or '').strip()
if len(extract) < self.MIN_DESCRIPTION_LENGTH:
continue
if self.is_list_or_index_page(page_data):
continue
page_data['lang'] = lang
return page_data
return None
def get_images(self, term: str, lang: str, limit: int = 8) -> List[Dict]:
if not term:
return []
candidates = self.client.get_candidate_pages(term, lang, max_candidates=self.MAX_CANDIDATES)
found_images: List[Dict] = []
for candidate in candidates:
if len(found_images) >= limit:
break
try:
page_data = self.client.fetch_page(lang=lang, candidate=candidate, props='pageimages|categories', extra_params={'piprop': 'original|thumbnail', 'pithumbsize': 640})
except Exception:
logger.exception('Error fetching page for images %s', candidate)
continue
if not page_data or page_data.get('missing'):
continue
if self.is_disambiguation_page(page_data):
continue
if self.is_list_or_index_page(page_data):
continue
original = page_data.get('original')
if original and is_valid_image_url(original.get('source')):
found_images.append({
'source': original.get('source'),
'width': original.get('width'),
'height': original.get('height'),
'title': page_data.get('title'),
'type': 'original'
})
continue
thumbnail = page_data.get('thumbnail')
if thumbnail and is_valid_image_url(thumbnail.get('source')):
found_images.append({
'source': thumbnail.get('source'),
'width': thumbnail.get('width'),
'height': thumbnail.get('height'),
'title': page_data.get('title'),
'type': 'thumbnail'
})
return found_images

View File

@@ -0,0 +1,148 @@
import logging
import urllib.parse
from difflib import SequenceMatcher
from typing import List, Dict, Optional
import requests
from django.conf import settings
logger = logging.getLogger(__name__)
class WikipediaClient:
BASE_HEADERS = {
'User-Agent': f'AdventureLog/{getattr(settings, "ADVENTURELOG_RELEASE_VERSION", "unknown")}'
}
DEFAULT_LANGUAGE = 'en'
LANGUAGE_PATTERN = r'^[a-z0-9-]{2,12}$'
ACCEPTED_IMAGE_FORMATS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
def __init__(self):
pass
def build_api_url(self, lang: str) -> str:
subdomain = lang.split('-', 1)[0]
return f'https://{subdomain}.wikipedia.org/w/api.php'
def get_headers(self, lang: str) -> Dict[str, str]:
headers = dict(self.BASE_HEADERS)
headers['Accept-Language'] = lang
headers['Accept'] = 'application/json'
return headers
def get_candidate_pages(self, term: str, lang: str, max_candidates: int = 10) -> List[Dict[str, Optional[int]]]:
if not term:
return []
url = self.build_api_url(lang)
params = {
'origin': '*',
'action': 'query',
'format': 'json',
'list': 'search',
'srsearch': term,
'srlimit': max_candidates,
'srwhat': 'text',
'utf8': 1,
}
resp = requests.get(url, headers=self.get_headers(lang), params=params, timeout=10)
resp.raise_for_status()
data = resp.json()
search_results = data.get('query', {}).get('search', [])
if not search_results:
return [{'title': term, 'pageid': None}]
normalized = term.lower()
ranked_results = []
for result in search_results:
title = (result.get('title') or '').strip()
if not title:
continue
title_lower = title.lower()
similarity = SequenceMatcher(None, normalized, title_lower).ratio()
exact_match = int(title_lower == normalized)
starts_with = int(title_lower.startswith(normalized))
is_disambig = int('disambiguation' in title_lower or '(disambig' in title_lower)
is_list = int(any(p in title_lower for p in ['list of', 'index of', 'timeline of']))
score = result.get('score') or 0
ranked_results.append({
'title': title,
'pageid': result.get('pageid'),
'exact': exact_match,
'starts_with': starts_with,
'similarity': similarity,
'score': score,
'is_disambig': is_disambig,
'is_list': is_list,
})
ranked_results.sort(
key=lambda e: (
e['exact'],
e['starts_with'],
-e['is_disambig'],
-e['is_list'],
e['similarity'],
e['score'],
),
reverse=True,
)
candidates = []
seen = set()
for entry in ranked_results:
key = entry['title'].lower()
if key in seen:
continue
seen.add(key)
candidates.append({'title': entry['title'], 'pageid': entry['pageid']})
if len(candidates) >= max_candidates:
break
if term.lower() not in seen:
candidates.append({'title': term, 'pageid': None})
return candidates
def fetch_page(self, lang: str, candidate: Dict[str, Optional[int]], props: str, extra_params: Optional[Dict] = None) -> Optional[Dict]:
if not candidate or not candidate.get('title'):
return None
params = {
'origin': '*',
'action': 'query',
'format': 'json',
'prop': props,
}
page_id = candidate.get('pageid')
if page_id:
params['pageids'] = page_id
else:
params['titles'] = candidate['title']
if extra_params:
params.update(extra_params)
resp = requests.get(self.build_api_url(lang), headers=self.get_headers(lang), params=params, timeout=10)
resp.raise_for_status()
data = resp.json()
pages = data.get('query', {}).get('pages', {})
if not pages:
return None
if page_id is not None:
page_data = pages.get(str(page_id))
if page_data:
page_data.setdefault('title', candidate['title'])
return page_data
page_data = next(iter(pages.values()))
if page_data:
page_data.setdefault('title', candidate['title'])
return page_data
def is_valid_image_url(url: Optional[str]) -> bool:
if not url:
return False
url_lower = url.lower()
if '.svg' in url_lower:
return False
return any(url_lower.endswith(fmt) or fmt in url_lower for fmt in WikipediaClient.ACCEPTED_IMAGE_FORMATS)

View File

View File

@@ -0,0 +1,108 @@
from unittest.mock import patch
from django.test import SimpleTestCase, override_settings
from adventures.providers.base import ProviderResult
from adventures.services.places.search import search_places
class ProviderSelectionTests(SimpleTestCase):
@override_settings(GOOGLE_MAPS_API_KEY="test-key")
@patch("adventures.services.places.search.osm_search")
@patch("adventures.services.places.search.google_search")
def test_search_places_prefers_google_when_scores_higher(self, mock_google, mock_osm):
mock_google.return_value = ProviderResult(
data=[
{
"id": "g-1",
"displayName": {"text": "Central Park"},
"formattedAddress": "Central Park, NY",
"location": {"latitude": 40.785091, "longitude": -73.968285},
"types": ["park"],
"rating": 4.7,
"userRatingCount": 1200,
}
]
)
mock_osm.return_value = ProviderResult(
data=[
{
"name": "Central Park",
"display_name": "Central Park, Manhattan",
"lat": "40.785091",
"lon": "-73.968285",
"importance": 0.4,
"type": "park",
"category": "leisure",
"addresstype": "park",
}
]
)
selection = search_places("40.78,-73.96")
self.assertIsNone(selection.error)
self.assertEqual(selection.provider_used, "google")
self.assertTrue(selection.results)
@override_settings(GOOGLE_MAPS_API_KEY="test-key")
@patch("adventures.services.places.search.osm_search")
@patch("adventures.services.places.search.google_search")
def test_search_places_falls_back_to_osm_when_google_errors(self, mock_google, mock_osm):
mock_google.return_value = ProviderResult(error="Google unavailable")
mock_osm.return_value = ProviderResult(
data=[
{
"name": "Lisbon",
"display_name": "Lisbon, Portugal",
"lat": "38.7223",
"lon": "-9.1393",
"importance": 0.6,
"type": "city",
"category": "place",
"addresstype": "city",
}
]
)
selection = search_places("Lisbon")
self.assertIsNone(selection.error)
self.assertEqual(selection.provider_used, "osm")
self.assertTrue(selection.results)
@override_settings(GOOGLE_MAPS_API_KEY="test-key")
@patch("adventures.services.places.search.osm_search")
@patch("adventures.services.places.search.google_search")
def test_search_places_returns_mixed_when_scores_close(self, mock_google, mock_osm):
mock_google.return_value = ProviderResult(
data=[
{
"id": "g-2",
"displayName": {"text": "Sydney"},
"formattedAddress": "Sydney, Australia",
"location": {"latitude": -33.8688, "longitude": 151.2093},
"types": ["locality"],
}
]
)
mock_osm.return_value = ProviderResult(
data=[
{
"name": "Sydney",
"display_name": "Sydney, NSW, Australia",
"lat": "-33.8688",
"lon": "151.2093",
"importance": 0.648,
"type": "city",
"category": "place",
"addresstype": "city",
}
]
)
selection = search_places("-33.86,151.20")
self.assertIsNone(selection.error)
self.assertEqual(selection.provider_used, "mixed")
self.assertTrue(selection.results)

View File

@@ -1,6 +1,7 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from adventures.views import *
from adventures.views.places_api_view import PlacesAPI
router = DefaultRouter()
router.register(r'locations', LocationViewSet, basename='locations')
@@ -13,6 +14,7 @@ router.register(r'notes', NoteViewSet, basename='notes')
router.register(r'checklists', ChecklistViewSet, basename='checklists')
router.register(r'images', ContentImageViewSet, basename='images')
router.register(r'reverse-geocode', ReverseGeocodeViewSet, basename='reverse-geocode')
router.register(r'places', PlacesAPI, basename='places')
router.register(r'categories', CategoryViewSet, basename='categories')
router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-calendar')
router.register(r'search', GlobalSearchView, basename='search')

View File

@@ -0,0 +1,427 @@
import re
import socket
import unicodedata
from typing import Any, Dict, Optional
import requests
from django.conf import settings
from worldtravel.models import City, Region, VisitedCity, VisitedRegion
def _clean_location_candidate(value):
if value is None:
return None
cleaned = str(value).strip()
return cleaned or None
def _looks_like_street_address(value):
candidate = _clean_location_candidate(value)
if not candidate:
return False
lowered = candidate.lower()
if not re.search(r"\d", lowered):
return False
if lowered.count(",") >= 2:
return True
if not re.match(r"^\d{1,6}\s+\S+", lowered):
return False
street_tokens = (
"st",
"street",
"rd",
"road",
"ave",
"avenue",
"blvd",
"boulevard",
"dr",
"drive",
"ln",
"lane",
"ct",
"court",
"pl",
"place",
"pkwy",
"parkway",
"hwy",
"highway",
"trl",
"trail",
)
return any(re.search(rf"\b{token}\b", lowered) for token in street_tokens)
def _first_preferred_location_name(candidates, allow_address_fallback=False):
address_fallback = None
for candidate in candidates:
cleaned = _clean_location_candidate(candidate)
if not cleaned:
continue
if not _looks_like_street_address(cleaned):
return cleaned
if address_fallback is None:
address_fallback = cleaned
return address_fallback if allow_address_fallback else None
def _extract_google_component_name(address_components):
preferred_types = (
"premise",
"point_of_interest",
"establishment",
"subpremise",
"natural_feature",
"airport",
"park",
"tourist_attraction",
"shopping_mall",
"university",
"school",
"hospital",
)
for preferred_type in preferred_types:
for component in address_components or []:
types = component.get("types", [])
if preferred_type in types:
return component.get("long_name") or component.get("short_name")
return None
def _score_google_result_types(types):
priority = (
"point_of_interest",
"establishment",
"premise",
"subpremise",
"tourist_attraction",
"park",
"airport",
"shopping_mall",
"university",
"school",
"hospital",
"street_address",
"route",
)
for idx, type_name in enumerate(priority):
if type_name in types:
return len(priority) - idx
return 0
def _extract_osm_location_name(data):
address = data.get("address", {}) or {}
namedetails = data.get("namedetails", {}) or {}
extratags = data.get("extratags", {}) or {}
candidates = [
data.get("name"),
namedetails.get("name"),
namedetails.get("official_name"),
namedetails.get("short_name"),
namedetails.get("brand"),
namedetails.get("loc_name"),
address.get("amenity"),
address.get("tourism"),
address.get("attraction"),
address.get("building"),
address.get("shop"),
address.get("leisure"),
address.get("historic"),
address.get("man_made"),
address.get("office"),
address.get("aeroway"),
address.get("railway"),
address.get("public_transport"),
address.get("craft"),
address.get("house_name"),
extratags.get("name"),
extratags.get("official_name"),
extratags.get("brand"),
extratags.get("operator"),
]
preferred = _first_preferred_location_name(candidates, allow_address_fallback=False)
if preferred:
return preferred
return _first_preferred_location_name(
[data.get("name"), data.get("display_name")],
allow_address_fallback=True,
)
def _parse_google_address_components(components):
parsed = {}
country_code = None
state_code = None
for comp in components:
types = comp.get("types", [])
long_name = comp.get("long_name")
short_name = comp.get("short_name")
if "country" in types:
parsed["country"] = long_name
country_code = short_name
parsed["ISO3166-1"] = short_name
if "administrative_area_level_1" in types:
parsed["state"] = long_name
state_code = short_name
if "administrative_area_level_2" in types:
parsed["county"] = long_name
if "administrative_area_level_3" in types:
parsed["municipality"] = long_name
if "locality" in types:
parsed["city"] = long_name
if "postal_town" in types:
parsed.setdefault("city", long_name)
if "sublocality" in types or any(t.startswith("sublocality_level_") for t in types):
parsed["suburb"] = long_name
if "neighborhood" in types:
parsed["neighbourhood"] = long_name
if "route" in types:
parsed["road"] = long_name
if "street_address" in types:
parsed["address"] = long_name
if country_code and state_code:
parsed["ISO3166-2-lvl1"] = f"{country_code}-{state_code}"
return parsed
def _fetch_google_nearby_place_name(lat, lon, api_key):
url = "https://places.googleapis.com/v1/places:searchNearby"
headers = {
"Content-Type": "application/json",
"X-Goog-Api-Key": api_key,
"X-Goog-FieldMask": "places.displayName.text,places.formattedAddress,places.types",
}
payload = {
"maxResultCount": 6,
"rankPreference": "DISTANCE",
"locationRestriction": {
"circle": {
"center": {"latitude": float(lat), "longitude": float(lon)},
"radius": 45.0,
}
},
}
try:
response = requests.post(url, headers=headers, json=payload, timeout=(2, 5))
response.raise_for_status()
places = (response.json() or {}).get("places", [])
except requests.exceptions.RequestException:
return None
candidates = [((place.get("displayName") or {}).get("text")) for place in places]
return _first_preferred_location_name(candidates, allow_address_fallback=False)
def _extract_google_location_name(results, nearby_place_name=None):
preferred_nearby = _first_preferred_location_name([nearby_place_name], allow_address_fallback=False)
if preferred_nearby:
return preferred_nearby
scored_candidates = []
for result in results or []:
score = _score_google_result_types(result.get("types", []))
if score <= 0:
continue
component_name = _extract_google_component_name(result.get("address_components", []))
name_candidate = _first_preferred_location_name([component_name], allow_address_fallback=False)
if name_candidate:
scored_candidates.append((score, name_candidate))
if scored_candidates:
scored_candidates.sort(key=lambda item: item[0], reverse=True)
return scored_candidates[0][1]
component_candidates = [
_extract_google_component_name(result.get("address_components", []))
for result in (results or [])
]
component_pick = _first_preferred_location_name(component_candidates, allow_address_fallback=False)
if component_pick:
return component_pick
formatted_candidates = [result.get("formatted_address") for result in (results or [])]
return _first_preferred_location_name(formatted_candidates, allow_address_fallback=True)
def is_host_resolvable(hostname: str) -> bool:
try:
socket.gethostbyname(hostname)
return True
except socket.error:
return False
def extractIsoCode(user, data):
iso_code = None
display_name = None
country_code = None
city = None
visited_city = None
location_name = _clean_location_candidate(data.get("location_name") or data.get("name"))
address = data.get("address", {}) or {}
country_code = address.get("ISO3166-1")
state_name = address.get("state")
preferred_iso_keys = (
[
"ISO3166-2-lvl10",
"ISO3166-2-lvl9",
"ISO3166-2-lvl8",
"ISO3166-2-lvl4",
"ISO3166-2-lvl6",
"ISO3166-2-lvl7",
"ISO3166-2-lvl5",
"ISO3166-2-lvl3",
"ISO3166-2-lvl2",
"ISO3166-2-lvl1",
"ISO3166-2",
]
if country_code == "FR"
else [
"ISO3166-2-lvl10",
"ISO3166-2-lvl9",
"ISO3166-2-lvl8",
"ISO3166-2-lvl4",
"ISO3166-2-lvl7",
"ISO3166-2-lvl6",
"ISO3166-2-lvl5",
"ISO3166-2-lvl3",
"ISO3166-2-lvl2",
"ISO3166-2-lvl1",
"ISO3166-2",
]
)
iso_candidates = []
for key in preferred_iso_keys:
value = address.get(key)
if value and value not in iso_candidates:
iso_candidates.append(value)
if not iso_candidates and "ISO3166-1" in address:
iso_candidates.append(address.get("ISO3166-1"))
iso_code = iso_candidates[0] if iso_candidates else None
region_candidates = []
for candidate in iso_candidates:
if len(str(candidate)) <= 2:
continue
match = Region.objects.filter(id=candidate).first()
if match and match not in region_candidates:
region_candidates.append(match)
region = region_candidates[0] if region_candidates else None
if not region and state_name:
region_queryset = Region.objects.filter(name__iexact=state_name)
if country_code:
region_queryset = region_queryset.filter(country__country_code=country_code)
region = region_queryset.first()
if region:
iso_code = region.id
if not country_code:
country_code = region.country.country_code
if region not in region_candidates:
region_candidates.insert(0, region)
if not region:
return {"error": "No region found"}
if not country_code:
country_code = region.country.country_code
region_visited = False
city_visited = False
locality_keys = [
"suburb",
"neighbourhood",
"neighborhood",
"city",
"city_district",
"town",
"village",
"hamlet",
"locality",
"municipality",
"county",
]
def _normalize_name(value):
normalized = unicodedata.normalize("NFKD", value)
ascii_only = normalized.encode("ascii", "ignore").decode("ascii")
return re.sub(r"[^a-z0-9]", "", ascii_only.lower())
def match_locality(key_name, target_region):
value = address.get(key_name)
if not value:
return None
qs = City.objects.filter(region=target_region)
exact_match = qs.filter(name__iexact=value).first()
if exact_match:
return exact_match
normalized_value = _normalize_name(value)
for candidate in qs.values_list("id", "name"):
candidate_id, candidate_name = candidate
if _normalize_name(candidate_name) == normalized_value:
return qs.filter(id=candidate_id).first()
if key_name == "county":
return None
return qs.filter(name__icontains=value).first()
chosen_region = region
for candidate_region in region_candidates or [region]:
for key_name in locality_keys:
city = match_locality(key_name, candidate_region)
if city:
chosen_region = candidate_region
iso_code = chosen_region.id
break
if city:
break
region = chosen_region
iso_code = region.id
visited_region = VisitedRegion.objects.filter(region=region, user=user).first()
region_visited = bool(visited_region)
if city:
display_name = f"{city.name}, {region.name}, {country_code or region.country.country_code}"
visited_city = VisitedCity.objects.filter(city=city, user=user).first()
city_visited = bool(visited_city)
else:
display_name = f"{region.name}, {country_code or region.country.country_code}"
return {
"region_id": iso_code,
"region": region.name,
"country": region.country.name,
"country_id": region.country.country_code,
"region_visited": region_visited,
"display_name": display_name,
"city": city.name if city else None,
"city_id": city.id if city else None,
"city_visited": city_visited,
"location_name": location_name,
}

View File

@@ -1,77 +1,39 @@
import logging
import re
import urllib.parse
from difflib import SequenceMatcher
import requests
from django.conf import settings
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from adventures.throttling import ExternalWikipediaThrottle
from adventures.services.wikipedia.description_service import WikipediaDescriptionService
logger = logging.getLogger(__name__)
class GenerateDescription(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
# User-Agent header required by Wikipedia API, Accept-Language patched in per request
BASE_HEADERS = {
'User-Agent': f'AdventureLog/{getattr(settings, "ADVENTURELOG_RELEASE_VERSION", "unknown")}'
}
DEFAULT_LANGUAGE = "en"
LANGUAGE_PATTERN = re.compile(r"^[a-z0-9-]{2,12}$", re.IGNORECASE)
MAX_CANDIDATES = 10 # Increased to find better matches
# Accepted image formats (no SVG)
ACCEPTED_IMAGE_FORMATS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
MIN_DESCRIPTION_LENGTH = 50 # Minimum characters for a valid description
@action(detail=False, methods=['get'], throttle_classes=[ExternalWikipediaThrottle])
def desc(self, request):
name = self.request.query_params.get('name', '')
name = request.query_params.get('name', '')
if not name:
return Response({"error": "Name parameter is required"}, status=400)
name = urllib.parse.unquote(name).strip()
if not name:
return Response({"error": "Name parameter is required"}, status=400)
lang = self.get_language(request)
service = WikipediaDescriptionService()
lang = service.get_language(request.query_params.get('lang'))
try:
candidates = self.get_candidate_pages(name, lang)
for candidate in candidates:
page_data = self.fetch_page(
lang=lang,
candidate=candidate,
props='extracts|categories',
extra_params={'exintro': 1, 'explaintext': 1}
)
if not page_data or page_data.get('missing'):
continue
# Check if this is a disambiguation page
if self.is_disambiguation_page(page_data):
continue
extract = (page_data.get('extract') or '').strip()
# Filter out pages with very short descriptions
if len(extract) < self.MIN_DESCRIPTION_LENGTH:
continue
# Filter out list/index pages
if self.is_list_or_index_page(page_data):
continue
page_data['lang'] = lang
return Response(page_data)
page = service.get_description(name, lang)
if page:
return Response(page)
return Response({"error": "No description found"}, status=404)
except requests.exceptions.RequestException:
logger.exception("Failed to fetch data from Wikipedia")
return Response({"error": "Failed to fetch data from Wikipedia."}, status=500)
@@ -80,295 +42,24 @@ class GenerateDescription(viewsets.ViewSet):
@action(detail=False, methods=['get'], throttle_classes=[ExternalWikipediaThrottle])
def img(self, request):
name = self.request.query_params.get('name', '')
name = request.query_params.get('name', '')
if not name:
return Response({"error": "Name parameter is required"}, status=400)
name = urllib.parse.unquote(name).strip()
if not name:
return Response({"error": "Name parameter is required"}, status=400)
lang = self.get_language(request)
service = WikipediaDescriptionService()
lang = service.get_language(request.query_params.get('lang'))
try:
candidates = self.get_candidate_pages(name, lang)
found_images = []
for candidate in candidates:
# Stop after finding 5 valid images
if len(found_images) >= 8:
break
page_data = self.fetch_page(
lang=lang,
candidate=candidate,
props='pageimages|categories',
extra_params={'piprop': 'original|thumbnail', 'pithumbsize': 640}
)
if not page_data or page_data.get('missing'):
continue
# Skip disambiguation pages
if self.is_disambiguation_page(page_data):
continue
# Skip list/index pages
if self.is_list_or_index_page(page_data):
continue
# Try original image first
original_image = page_data.get('original')
if original_image and self.is_valid_image(original_image.get('source')):
found_images.append({
'source': original_image.get('source'),
'width': original_image.get('width'),
'height': original_image.get('height'),
'title': page_data.get('title'),
'type': 'original'
})
continue
# Fall back to thumbnail
thumbnail_image = page_data.get('thumbnail')
if thumbnail_image and self.is_valid_image(thumbnail_image.get('source')):
found_images.append({
'source': thumbnail_image.get('source'),
'width': thumbnail_image.get('width'),
'height': thumbnail_image.get('height'),
'title': page_data.get('title'),
'type': 'thumbnail'
})
if found_images:
return Response({"images": found_images})
images = service.get_images(name, lang)
if images:
return Response({"images": images})
return Response({"error": "No image found"}, status=404)
except requests.exceptions.RequestException:
logger.exception("Failed to fetch data from Wikipedia")
return Response({"error": "Failed to fetch data from Wikipedia."}, status=500)
except ValueError:
return Response({"error": "Invalid response from Wikipedia API"}, status=500)
def is_valid_image(self, image_url):
"""Check if image URL is valid and not an SVG"""
if not image_url:
return False
url_lower = image_url.lower()
# Reject SVG images
if '.svg' in url_lower:
return False
# Accept only specific image formats
return any(url_lower.endswith(fmt) or fmt in url_lower for fmt in self.ACCEPTED_IMAGE_FORMATS)
def is_disambiguation_page(self, page_data):
"""Check if page is a disambiguation page"""
categories = page_data.get('categories', [])
for cat in categories:
cat_title = cat.get('title', '').lower()
if 'disambiguation' in cat_title or 'disambig' in cat_title:
return True
# Check title for disambiguation indicators
title = page_data.get('title', '').lower()
if '(disambiguation)' in title:
return True
return False
def is_list_or_index_page(self, page_data):
"""Check if page is a list or index page"""
title = page_data.get('title', '').lower()
# Common patterns for list/index pages
list_patterns = [
'list of',
'index of',
'timeline of',
'glossary of',
'outline of'
]
return any(pattern in title for pattern in list_patterns)
def get_candidate_pages(self, term, lang):
"""Get and rank candidate pages from Wikipedia search"""
if not term:
return []
url = self.build_api_url(lang)
params = {
'origin': '*',
'action': 'query',
'format': 'json',
'list': 'search',
'srsearch': term,
'srlimit': self.MAX_CANDIDATES,
'srwhat': 'text',
'utf8': 1,
}
response = requests.get(url, headers=self.get_headers(lang), params=params, timeout=10)
response.raise_for_status()
try:
data = response.json()
except ValueError:
logger.warning("Invalid response while searching Wikipedia for '%s'", term)
return [{'title': term, 'pageid': None}]
search_results = data.get('query', {}).get('search', [])
if not search_results:
return [{'title': term, 'pageid': None}]
normalized = term.lower()
ranked_results = []
for result in search_results:
title = (result.get('title') or '').strip()
if not title:
continue
title_lower = title.lower()
# Calculate multiple similarity metrics
similarity = SequenceMatcher(None, normalized, title_lower).ratio()
# Boost score for exact matches
exact_match = int(title_lower == normalized)
# Boost score for titles that start with the search term
starts_with = int(title_lower.startswith(normalized))
# Penalize disambiguation pages
is_disambig = int('disambiguation' in title_lower or '(disambig' in title_lower)
# Penalize list/index pages
is_list = int(any(p in title_lower for p in ['list of', 'index of', 'timeline of']))
score = result.get('score') or 0
ranked_results.append({
'title': title,
'pageid': result.get('pageid'),
'exact': exact_match,
'starts_with': starts_with,
'similarity': similarity,
'score': score,
'is_disambig': is_disambig,
'is_list': is_list
})
if not ranked_results:
return [{'title': term, 'pageid': None}]
# Sort by: exact match > starts with > not disambiguation > not list > similarity > search score
ranked_results.sort(
key=lambda e: (
e['exact'],
e['starts_with'],
-e['is_disambig'],
-e['is_list'],
e['similarity'],
e['score']
),
reverse=True
)
candidates = []
seen_titles = set()
for entry in ranked_results:
title_key = entry['title'].lower()
if title_key in seen_titles:
continue
seen_titles.add(title_key)
candidates.append({'title': entry['title'], 'pageid': entry['pageid']})
if len(candidates) >= self.MAX_CANDIDATES:
break
# Add original term as fallback if not already included
if normalized not in seen_titles:
candidates.append({'title': term, 'pageid': None})
return candidates
def fetch_page(self, *, lang, candidate, props, extra_params=None):
"""Fetch page data from Wikipedia API"""
if not candidate or not candidate.get('title'):
return None
params = {
'origin': '*',
'action': 'query',
'format': 'json',
'prop': props,
}
page_id = candidate.get('pageid')
if page_id:
params['pageids'] = page_id
else:
params['titles'] = candidate['title']
if extra_params:
params.update(extra_params)
response = requests.get(
self.build_api_url(lang),
headers=self.get_headers(lang),
params=params,
timeout=10
)
response.raise_for_status()
try:
data = response.json()
except ValueError:
logger.warning("Invalid response while fetching Wikipedia page '%s'", candidate['title'])
return None
pages = data.get('query', {}).get('pages', {})
if not pages:
return None
if page_id is not None:
page_data = pages.get(str(page_id))
if page_data:
page_data.setdefault('title', candidate['title'])
return page_data
page_data = next(iter(pages.values()))
if page_data:
page_data.setdefault('title', candidate['title'])
return page_data
def get_language(self, request):
"""Extract and validate language parameter"""
candidate = request.query_params.get('lang')
if not candidate:
candidate = self.DEFAULT_LANGUAGE
if not candidate:
candidate = 'en'
normalized = candidate.replace('_', '-').lower()
if self.LANGUAGE_PATTERN.match(normalized):
return normalized
return 'en'
def get_headers(self, lang):
"""Build headers for Wikipedia API request"""
headers = dict(self.BASE_HEADERS)
headers['Accept-Language'] = lang
headers['Accept'] = 'application/json'
return headers
def build_api_url(self, lang):
"""Build Wikipedia API URL for given language"""
subdomain = lang.split('-', 1)[0]
return f'https://{subdomain}.wikipedia.org/w/api.php'
return Response({"error": "Invalid response from Wikipedia API"}, status=500)

View File

@@ -4,239 +4,25 @@ from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from adventures.throttling import ImageImportThrottle, ImageProxyThrottle
from django.http import HttpResponse
from concurrent.futures import ThreadPoolExecutor, as_completed
import ipaddress
import mimetypes
import socket
from urllib.parse import urljoin
from urllib.parse import urlparse
from django.db.models import Q
from django.core.files.base import ContentFile
from django.contrib.contenttypes.models import ContentType
from adventures.models import Location, Transportation, Note, Lodging, Visit, ContentImage
from adventures.serializers import ContentImageSerializer
from integrations.models import ImmichIntegration
from adventures.permissions import IsOwnerOrSharedWithFullAccess # Your existing permission class
import requests
from adventures.permissions import IsOwnerOrSharedWithFullAccess
from adventures.permissions import ContentImagePermission
import logging
import uuid
import requests
from users.media_utils import enforce_media_storage_limit, get_uploaded_file_size
from adventures.services.images.fetch import (
download_remote_image,
import_remote_images_for_object,
)
logger = logging.getLogger(__name__)
def _public_import_error_message(exc):
"""Return a safe, user-facing import error without exposing internal details."""
if isinstance(exc, ValueError):
return "Invalid image URL"
if isinstance(exc, requests.exceptions.Timeout):
return "Download timeout"
if isinstance(exc, requests.exceptions.RequestException):
return "Failed to fetch image from the remote server"
return "Image import failed"
def _is_safe_url(image_url):
"""
Validate a URL for safe proxy use.
Returns (True, parsed) on success or (False, error_message) on failure.
Checks:
- Scheme is http or https
- No non-standard ports (only 80 and 443 allowed)
- All resolved IPs are public (no private/loopback/reserved/link-local/multicast)
"""
parsed = urlparse(image_url)
if parsed.scheme not in ('http', 'https'):
return False, "Invalid URL scheme. Only http and https are allowed."
port = parsed.port
if port is not None and port not in (80, 443):
return False, "Non-standard ports are not allowed."
hostname = parsed.hostname
if not hostname:
return False, "Invalid URL: missing hostname."
try:
addr_infos = socket.getaddrinfo(hostname, None)
except socket.gaierror:
return False, "Could not resolve hostname."
if not addr_infos:
return False, "Could not resolve hostname."
for addr_info in addr_infos:
try:
ip = ipaddress.ip_address(addr_info[4][0])
except ValueError:
return False, "Invalid IP address resolved from hostname."
if (ip.is_private or ip.is_loopback or ip.is_reserved
or ip.is_link_local or ip.is_multicast):
return False, "Access to internal networks is not allowed."
return True, parsed
def download_remote_image(image_url):
safe, result = _is_safe_url(image_url)
if not safe:
raise ValueError(result)
headers = {'User-Agent': 'AdventureLog/1.0 (Image Import)'}
max_redirects = 3
current_url = image_url
response = None
for _ in range(max_redirects + 1):
response = requests.get(
current_url,
timeout=10,
headers=headers,
stream=True,
allow_redirects=False,
)
if not response.is_redirect:
break
redirect_url = response.headers.get('Location', '')
if not redirect_url:
raise ValueError('Redirect with missing Location header')
# Handle relative redirects safely.
redirect_url = urljoin(current_url, redirect_url)
safe, result = _is_safe_url(redirect_url)
if not safe:
raise ValueError(f'Redirect blocked: {result}')
current_url = redirect_url
else:
raise ValueError('Too many redirects')
if response is None:
raise ValueError('Failed to fetch image')
response.raise_for_status()
content_type = response.headers.get('Content-Type', '').split(';')[0].strip().lower()
if not content_type.startswith('image/'):
raise ValueError('URL does not point to an image')
content_length = response.headers.get('Content-Length')
if content_length and int(content_length) > 20 * 1024 * 1024:
raise ValueError('Image too large (max 20MB)')
ext = mimetypes.guess_extension(content_type) or '.jpg'
if ext == '.jpe':
ext = '.jpg'
return {
'filename': f"remote_{uuid.uuid4().hex}{ext}",
'content': response.content,
'content_type': content_type,
'source_url': image_url,
}
def import_remote_images_for_object(content_object, urls, owner=None, max_workers=5):
"""Download remote URLs and attach them as ContentImage records for a content object."""
content_type = ContentType.objects.get_for_model(content_object.__class__)
object_id = str(content_object.id)
image_owner = owner or getattr(content_object, 'user', None)
downloaded_results = []
worker_count = max(1, min(max_workers, len(urls)))
with ThreadPoolExecutor(max_workers=worker_count) as executor:
futures = {
executor.submit(download_remote_image, image_url): (index, image_url)
for index, image_url in enumerate(urls)
}
for future in as_completed(futures):
index, image_url = futures[future]
try:
file_data = future.result()
downloaded_results.append((index, image_url, file_data, None))
except Exception as exc:
logger.warning(
"Image import failed for URL %s",
image_url,
exc_info=True,
)
downloaded_results.append((index, image_url, None, _public_import_error_message(exc)))
downloaded_results.sort(key=lambda item: item[0])
existing_image_count = ContentImage.objects.filter(
content_type=content_type,
object_id=object_id,
).count()
set_primary_next = existing_image_count == 0
created_images = []
results = []
failed = []
for _, image_url, file_data, error_message in downloaded_results:
if error_message:
failure = {
'url': image_url,
'error': error_message,
}
results.append({
**failure,
'status': 'failed',
})
failed.append(failure)
continue
incoming_bytes = len(file_data['content'])
allowed, details = enforce_media_storage_limit(image_owner, incoming_bytes)
if not allowed:
failure = {
'url': image_url,
'error': 'Media storage limit exceeded',
'details': details,
}
results.append({
**failure,
'status': 'failed',
})
failed.append(failure)
continue
image_file = ContentFile(file_data['content'], name=file_data['filename'])
image = ContentImage.objects.create(
user=image_owner,
image=image_file,
content_type=content_type,
object_id=object_id,
is_primary=set_primary_next,
)
if set_primary_next:
set_primary_next = False
created_images.append(image)
results.append({
'url': image_url,
'status': 'created',
'id': str(image.id),
})
return {
'created_images': created_images,
'results': results,
'created_count': len(created_images),
'requested_count': len(urls),
'failed_count': len(failed),
'failed': failed,
}
class ContentImageViewSet(viewsets.ModelViewSet):
serializer_class = ContentImageSerializer
permission_classes = [ContentImagePermission]

View File

@@ -19,8 +19,8 @@ from adventures.serializers import (
MapPinSerializer,
)
from adventures.utils import pagination
from adventures.geocoding import reverse_geocode
from adventures.throttling import ExternalGeocodeThrottle, ExternalSunTimesThrottle
from adventures.services.geocoding.reverse import reverse_geocode as reverse_geocode_service
from worldtravel.models import City, Country, Region
from .location_image_view import import_remote_images_for_object
from .quick_add_utils import (
@@ -210,12 +210,17 @@ class LocationViewSet(viewsets.ModelViewSet):
return collection
reverse_data = {}
_, details = extract_google_place_details(payload, fallback_query=name)
# Use centralized services for place details and reverse geocoding
details = {}
try:
_, details = extract_google_place_details(payload, fallback_query=name)
except Exception:
details = {}
try:
reverse_result = reverse_geocode(latitude, longitude, request.user)
if isinstance(reverse_result, dict) and 'error' not in reverse_result:
reverse_data = reverse_result
selection = reverse_geocode_service(latitude, longitude, request.user)
if selection and selection.data and not selection.data.get('error'):
reverse_data = selection.data
except Exception:
reverse_data = {}
@@ -768,37 +773,13 @@ class LocationViewSet(viewsets.ModelViewSet):
return False
def _get_sun_times(self, adventure, visits):
"""Get sunrise/sunset times for adventure visits."""
sun_times = []
"""Get sunrise/sunset times for adventure visits via service."""
from adventures.services.sun.times import get_sun_times_for_visits
for visit in visits:
date = visit.get('start_date')
if not (date and adventure.longitude and adventure.latitude):
continue
api_url = (
f'https://api.sunrisesunset.io/json?'
f'lat={adventure.latitude}&lng={adventure.longitude}&date={date}'
)
try:
response = requests.get(api_url)
if response.status_code == 200:
data = response.json()
results = data.get('results', {})
if results.get('sunrise') and results.get('sunset'):
sun_times.append({
"date": date,
"visit_id": visit.get('id'),
"sunrise": results.get('sunrise'),
"sunset": results.get('sunset')
})
except requests.RequestException:
# Skip this visit if API call fails
continue
return sun_times
try:
return get_sun_times_for_visits(adventure.latitude, adventure.longitude, visits)
except Exception:
return []
def paginate_and_respond(self, queryset, request):
"""Paginate queryset and return response."""

View File

@@ -7,8 +7,8 @@ from adventures.models import Lodging
from adventures.serializers import CollectionItineraryItemSerializer, LodgingSerializer
from rest_framework.exceptions import PermissionDenied
from adventures.permissions import IsOwnerOrSharedWithFullAccess
from adventures.geocoding import reverse_geocode
from adventures.throttling import ExternalGeocodeThrottle
from adventures.services.geocoding.reverse import reverse_geocode as reverse_geocode_service
from .location_image_view import import_remote_images_for_object
from .quick_add_utils import (
build_quick_add_description,
@@ -115,9 +115,9 @@ class LodgingViewSet(viewsets.ModelViewSet):
reverse_data = {}
try:
reverse_result = reverse_geocode(latitude, longitude, request.user)
if isinstance(reverse_result, dict) and 'error' not in reverse_result:
reverse_data = reverse_result
selection = reverse_geocode_service(latitude, longitude, request.user)
if selection and selection.data and not selection.data.get('error'):
reverse_data = selection.data
except Exception:
reverse_data = {}

View File

@@ -0,0 +1,74 @@
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from adventures.services.places.search import search_places
from adventures.services.places.details import get_place_details
from adventures.services.geocoding.reverse import reverse_geocode
from adventures.throttling import ExternalGeocodeThrottle
class PlacesAPI(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
@action(detail=False, methods=["get"], throttle_classes=[ExternalGeocodeThrottle])
def search(self, request):
query = request.query_params.get("query", "").strip()
if not query:
return Response({"error": "Query parameter is required"}, status=400)
include_meta = request.query_params.get("include_meta", "").lower() in {"1", "true", "yes"}
try:
selection = search_places(query)
if selection.error:
if include_meta:
return Response({
"provider_used": selection.provider_used,
"providers_attempted": selection.providers_attempted,
"results": [],
"error": selection.error,
})
return Response([])
if include_meta:
return Response({
"provider_used": selection.provider_used,
"providers_attempted": selection.providers_attempted,
"results": selection.results,
})
return Response(selection.results)
except Exception:
return Response({"error": "Internal error"}, status=500)
@action(detail=False, methods=["get"])
def place_details(self, request):
place_id = request.query_params.get("place_id", "").strip()
if not place_id:
return Response({"error": "place_id parameter is required"}, status=400)
name = request.query_params.get("name", "")
language = request.query_params.get("language", "en")
details = get_place_details(place_id, fallback_query=name, language=language)
if isinstance(details, dict) and details.get("error"):
return Response(details, status=502)
return Response(details)
@action(detail=False, methods=["get"], throttle_classes=[ExternalGeocodeThrottle])
def reverse(self, request):
lat = request.query_params.get("lat")
lon = request.query_params.get("lon")
if not lat or not lon:
return Response({"error": "Latitude and longitude are required"}, status=400)
try:
lat = float(lat)
lon = float(lon)
except ValueError:
return Response({"error": "Invalid latitude or longitude"}, status=400)
selection = reverse_geocode(lat, lon, request.user)
data = selection.data if hasattr(selection, "data") else selection
if isinstance(data, dict) and data.get("error"):
return Response({"error": "An internal error occurred while processing the request"}, status=400)
return Response(data)

View File

@@ -10,8 +10,8 @@ from rest_framework.response import Response
from django.contrib.contenttypes.models import ContentType
from adventures.geocoding import get_place_details
from adventures.models import Collection, CollectionItineraryItem, Visit
from adventures.services.places.details import get_place_details
def coerce_coordinate(value, min_value, max_value):

View File

@@ -3,680 +3,64 @@ from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from django.conf import settings
import requests
from geopy.distance import geodesic
import logging
from ..geocoding import search_google, search_osm
from adventures.services.places.search import search_places as search_places_service
from adventures.services.recommendations.search import get_recommendations
from adventures.throttling import ExternalRecommendationsThrottle
logger = logging.getLogger(__name__)
class RecommendationsViewSet(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
HEADERS = {'User-Agent': 'AdventureLog Server'}
# Quality thresholds
MIN_GOOGLE_RATING = 3.0 # Minimum rating to include
MIN_GOOGLE_REVIEWS = 5 # Minimum number of reviews
MAX_RESULTS = 50 # Maximum results to return
def calculate_quality_score(self, place_data):
"""
Calculate a quality score based on multiple factors.
Higher score = better quality recommendation.
"""
import math
score = 0.0
# Rating contribution (0-50 points)
rating = place_data.get('rating')
if rating is not None and rating > 0:
score += (rating / 5.0) * 50
# Review count contribution (0-30 points, logarithmic scale)
review_count = place_data.get('review_count')
if review_count is not None and review_count > 0:
# Logarithmic scale: 10 reviews = ~10 pts, 100 = ~20 pts, 1000 = ~30 pts
score += min(30, math.log10(review_count) * 10)
# Distance penalty (0-20 points, closer is better)
distance_km = place_data.get('distance_km')
if distance_km is not None:
if distance_km < 1:
score += 20
elif distance_km < 5:
score += 15
elif distance_km < 10:
score += 10
elif distance_km < 20:
score += 5
# Verified/business status bonus (0-10 points)
if place_data.get('is_verified') or place_data.get('business_status') == 'OPERATIONAL':
score += 10
# Has photos bonus (0-5 points)
photos = place_data.get('photos')
if photos and len(photos) > 0:
score += 5
# Has opening hours bonus (0-5 points)
opening_hours = place_data.get('opening_hours')
if opening_hours and len(opening_hours) > 0:
score += 5
return round(score, 2)
def parse_google_places(self, places, origin):
"""
Parse Google Places API results into unified format.
Enhanced with quality filtering and comprehensive data extraction.
"""
locations = []
api_key = getattr(settings, 'GOOGLE_MAPS_API_KEY', None)
for place in places:
location = place.get('location', {})
types = place.get('types', [])
# Extract display name
display_name = place.get("displayName", {})
name = display_name.get("text") if isinstance(display_name, dict) else display_name
# Extract coordinates
lat = location.get('latitude')
lon = location.get('longitude')
if not name or not lat or not lon:
continue
# Extract rating information
rating = place.get('rating')
review_count = place.get('userRatingCount', 0)
# Quality filter: Skip low-rated or unreviewed places
if rating and rating < self.MIN_GOOGLE_RATING:
continue
if review_count < self.MIN_GOOGLE_REVIEWS:
continue
# Calculate distance
distance_km = geodesic(origin, (lat, lon)).km
# Extract address information
formatted_address = place.get("formattedAddress") or place.get("shortFormattedAddress")
# Extract business status
business_status = place.get('businessStatus')
is_operational = business_status == 'OPERATIONAL'
# Extract opening hours
opening_hours = place.get('regularOpeningHours', {})
current_opening_hours = place.get('currentOpeningHours', {})
is_open_now = current_opening_hours.get('openNow')
# Extract photos and construct URLs
photos = place.get('photos', [])
photo_urls = []
if photos and api_key:
# Get first 5 photos and construct full URLs
for photo in photos[:5]:
photo_name = photo.get('name', '')
if photo_name:
# Construct Google Places Photo API URL
# Format: https://places.googleapis.com/v1/{name}/media?key={key}&maxHeightPx=800&maxWidthPx=800
photo_url = f"https://places.googleapis.com/v1/{photo_name}/media?key={api_key}&maxHeightPx=800&maxWidthPx=800"
photo_urls.append(photo_url)
# Extract contact information
phone_number = place.get('nationalPhoneNumber') or place.get('internationalPhoneNumber')
website = place.get('websiteUri')
google_maps_uri = place.get('googleMapsUri')
# Extract price level
price_level = place.get('priceLevel')
# Extract editorial summary/description
editorial_summary = place.get('editorialSummary', {})
description = editorial_summary.get('text') if isinstance(editorial_summary, dict) else None
# Filter out unwanted types (generic categories)
filtered_types = [t for t in types if t not in ['point_of_interest', 'establishment']]
# Build unified response
place_data = {
"id": f"google:{place.get('id')}",
"external_id": place.get('id'),
"source": "google",
"name": name,
"description": description,
"latitude": lat,
"longitude": lon,
"address": formatted_address,
"distance_km": round(distance_km, 2),
"rating": rating,
"review_count": review_count,
"price_level": price_level,
"types": filtered_types,
"primary_type": filtered_types[0] if filtered_types else None,
"business_status": business_status,
"is_open_now": is_open_now,
"opening_hours": opening_hours.get('weekdayDescriptions', []) if opening_hours else None,
"phone_number": phone_number,
"website": website,
"google_maps_url": google_maps_uri,
"photos": photo_urls,
"is_verified": is_operational,
}
# Calculate quality score
place_data['quality_score'] = self.calculate_quality_score(place_data)
locations.append(place_data)
return locations
def parse_overpass_response(self, data, request, origin):
"""
Parse Overpass API (OSM) results into unified format.
Enhanced with quality filtering and comprehensive data extraction.
"""
nodes = data.get('elements', [])
locations = []
for node in nodes:
if node.get('type') not in ['node', 'way', 'relation']:
continue
tags = node.get('tags', {})
# Get coordinates (for ways/relations, use center)
lat = node.get('lat') or node.get('center', {}).get('lat')
lon = node.get('lon') or node.get('center', {}).get('lon')
# Extract name (with fallbacks)
name = tags.get('name') or tags.get('official_name') or tags.get('alt_name')
if not name or lat is None or lon is None:
continue
# Calculate distance
distance_km = round(geodesic(origin, (lat, lon)).km, 2) if origin else None
# Extract address information
address_parts = [
tags.get('addr:housenumber'),
tags.get('addr:street'),
tags.get('addr:suburb') or tags.get('addr:neighbourhood'),
tags.get('addr:city'),
tags.get('addr:state'),
tags.get('addr:postcode'),
tags.get('addr:country')
]
formatted_address = ", ".join(filter(None, address_parts)) or None
# Extract contact information
phone = tags.get('phone') or tags.get('contact:phone')
website = tags.get('website') or tags.get('contact:website') or tags.get('url')
# Extract opening hours
opening_hours = tags.get('opening_hours')
# Extract rating/stars (if available)
stars = tags.get('stars')
# Determine category/type hierarchy
category_keys = ['tourism', 'leisure', 'amenity', 'natural', 'historic', 'attraction', 'shop', 'sport']
types = [tags.get(key) for key in category_keys if key in tags]
primary_type = types[0] if types else None
# Extract description and additional info
description = tags.get('description') or tags.get('note')
wikipedia = tags.get('wikipedia') or tags.get('wikidata')
# Extract image if available
image = tags.get('image') or tags.get('wikimedia_commons')
# Quality filters for OSM data
# Skip if it's just a generic POI without specific category
if not primary_type:
continue
# Skip construction or disused places
if tags.get('disused') or tags.get('construction'):
continue
# Build unified response
place_data = {
"id": f"osm:{node.get('type')}:{node.get('id')}",
"external_id": str(node.get('id')),
"source": "osm",
"name": name,
"description": description,
"latitude": lat,
"longitude": lon,
"address": formatted_address,
"distance_km": distance_km,
"rating": None, # OSM doesn't have ratings
"review_count": None,
"price_level": None,
"types": types,
"primary_type": primary_type,
"business_status": None,
"is_open_now": None,
"opening_hours": [opening_hours] if opening_hours else None,
"phone_number": phone,
"website": website,
"google_maps_url": None,
"photos": [image] if image else [],
"is_verified": bool(wikipedia), # Has Wikipedia = more verified
"osm_type": node.get('type'),
"wikipedia": wikipedia,
"stars": stars,
}
# Calculate quality score (will be lower without ratings)
place_data['quality_score'] = self.calculate_quality_score(place_data)
locations.append(place_data)
return locations
def query_overpass(self, lat, lon, radius, category, request):
"""
Query Overpass API (OpenStreetMap) for nearby places.
Enhanced with better queries and error handling.
"""
# Limit radius for OSM to prevent timeouts (max 5km for OSM due to server limits)
osm_radius = min(radius, 5000)
# Build optimized query - use simpler queries and limit results
# Reduced timeout and simplified queries to prevent 504 errors
if category == 'tourism':
query = f"""
[out:json][timeout:25];
(
nwr["tourism"~"attraction|viewpoint|museum|gallery|zoo|aquarium"](around:{osm_radius},{lat},{lon});
nwr["historic"~"monument|castle|memorial"](around:{osm_radius},{lat},{lon});
nwr["leisure"~"park|garden|nature_reserve"](around:{osm_radius},{lat},{lon});
);
out center tags 50;
"""
elif category == 'lodging':
query = f"""
[out:json][timeout:25];
nwr["tourism"~"hotel|motel|guest_house|hostel"](around:{osm_radius},{lat},{lon});
out center tags 50;
"""
elif category == 'food':
query = f"""
[out:json][timeout:25];
nwr["amenity"~"restaurant|cafe|bar|pub"](around:{osm_radius},{lat},{lon});
out center tags 50;
"""
else:
logger.error(f"Invalid category requested: {category}")
return {"error": "Invalid category.", "results": []}
try:
response = requests.post(
self.OVERPASS_URL,
data=query,
headers=self.HEADERS,
timeout=30
)
response.raise_for_status()
data = response.json()
except requests.exceptions.Timeout:
logger.warning(f"Overpass API timeout for {category} at ({lat}, {lon}) with radius {osm_radius}m")
return {"error": f"OpenStreetMap query timed out. The service is overloaded. Radius limited to {int(osm_radius)}m.", "results": []}
except requests.exceptions.HTTPError as e:
if e.response.status_code == 504:
logger.warning(f"Overpass API 504 Gateway Timeout for {category}")
return {"error": "OpenStreetMap server is overloaded. Try again later or use Google source.", "results": []}
logger.warning(f"Overpass API HTTP error: {e}")
return {"error": f"OpenStreetMap error: please try again later.", "results": []}
except requests.exceptions.RequestException as e:
logger.warning(f"Overpass API error: {e}")
return {"error": f"OpenStreetMap temporarily unavailable: please try again later.", "results": []}
except ValueError as e:
logger.error(f"Invalid JSON response from Overpass: {e}")
return {"error": "Invalid response from OpenStreetMap.", "results": []}
origin = (float(lat), float(lon))
locations = self.parse_overpass_response(data, request, origin)
logger.info(f"Overpass returned {len(locations)} results")
return {"error": None, "results": locations}
def query_google_nearby(self, lat, lon, radius, category, request):
"""
Query Google Places API (New) for nearby places.
Enhanced with comprehensive field masks and better error handling.
"""
api_key = settings.GOOGLE_MAPS_API_KEY
url = "https://places.googleapis.com/v1/places:searchNearby"
# Comprehensive field mask to get all useful information
headers = {
'Content-Type': 'application/json',
'X-Goog-Api-Key': api_key,
'X-Goog-FieldMask': (
'places.id,'
'places.displayName,'
'places.formattedAddress,'
'places.shortFormattedAddress,'
'places.location,'
'places.types,'
'places.rating,'
'places.userRatingCount,'
'places.businessStatus,'
'places.priceLevel,'
'places.websiteUri,'
'places.googleMapsUri,'
'places.nationalPhoneNumber,'
'places.internationalPhoneNumber,'
'places.editorialSummary,'
'places.photos,'
'places.currentOpeningHours,'
'places.regularOpeningHours'
)
}
# Map categories to place types - use multiple types for better coverage
type_mapping = {
'lodging': ['lodging', 'hotel', 'hostel', 'resort_hotel', 'extended_stay_hotel'],
'food': ['restaurant', 'cafe', 'bar', 'bakery', 'meal_takeaway', 'meal_delivery'],
'tourism': ['tourist_attraction', 'museum', 'art_gallery', 'aquarium', 'zoo', 'amusement_park', 'park', 'natural_feature'],
}
payload = {
"includedTypes": type_mapping.get(category, ['tourist_attraction']),
"maxResultCount": 20,
"rankPreference": "DISTANCE", # Sort by distance first
"locationRestriction": {
"circle": {
"center": {
"latitude": float(lat),
"longitude": float(lon)
},
"radius": float(radius)
}
}
}
try:
response = requests.post(url, json=payload, headers=headers, timeout=15)
response.raise_for_status()
data = response.json()
places = data.get('places', [])
origin = (float(lat), float(lon))
locations = self.parse_google_places(places, origin)
logger.info(f"Google Places returned {len(locations)} quality results for category '{category}'")
return Response(self._prepare_final_results(locations))
except requests.exceptions.Timeout:
logger.warning("Google Places API timeout, falling back to OSM")
return self.query_overpass(lat, lon, radius, category, request)
except requests.exceptions.RequestException as e:
logger.warning(f"Google Places API error: {e}, falling back to OSM")
return self.query_overpass(lat, lon, radius, category, request)
except Exception as e:
logger.error(f"Unexpected error with Google Places API: {e}")
return self.query_overpass(lat, lon, radius, category, request)
def _prepare_final_results(self, locations):
"""
Prepare final results: sort by quality score and limit results.
"""
# Sort by quality score (highest first)
locations.sort(key=lambda x: x.get('quality_score', 0), reverse=True)
# Limit to MAX_RESULTS
locations = locations[:self.MAX_RESULTS]
return locations
def _deduplicate_results(self, google_results, osm_results):
"""
Deduplicate results from both sources based on name and proximity.
Prioritize Google results when duplicates are found.
"""
from difflib import SequenceMatcher
def is_similar(name1, name2, threshold=0.85):
"""Check if two names are similar using fuzzy matching."""
return SequenceMatcher(None, name1.lower(), name2.lower()).ratio() > threshold
def is_nearby(loc1, loc2, max_distance_m=50):
"""Check if two locations are within max_distance_m meters."""
dist = geodesic(
(loc1['latitude'], loc1['longitude']),
(loc2['latitude'], loc2['longitude'])
).meters
return dist < max_distance_m
# Start with all Google results (higher quality)
deduplicated = list(google_results)
# Add OSM results that don't match Google results
for osm_loc in osm_results:
is_duplicate = False
for google_loc in google_results:
if (is_similar(osm_loc['name'], google_loc['name']) and
is_nearby(osm_loc, google_loc)):
is_duplicate = True
break
if not is_duplicate:
deduplicated.append(osm_loc)
return deduplicated
@action(detail=False, methods=['get'], throttle_classes=[ExternalRecommendationsThrottle])
def query(self, request):
"""
Query both Google Places and OSM for recommendations.
Returns unified, high-quality results sorted by quality score.
Query Parameters:
- lat (required): Latitude
- lon (required): Longitude
- radius (optional): Search radius in meters (default: 5000, max: 50000)
- category (required): Category - 'tourism', 'food', or 'lodging'
- sources (optional): Comma-separated sources - 'google', 'osm', or 'both' (default: 'both')
"""
lat = request.query_params.get('lat')
lon = request.query_params.get('lon')
# Allow a free-text `location` parameter which will be geocoded
location_param = request.query_params.get('location')
radius = request.query_params.get('radius', '5000')
category = request.query_params.get('category')
sources = request.query_params.get('sources', 'both').lower()
# If lat/lon not supplied, try geocoding the free-text location param
# If lat/lon not provided, attempt provider-based free-text geocoding
if (not lat or not lon) and location_param:
geocode_results = None
# Try Google first if API key configured
if getattr(settings, 'GOOGLE_MAPS_API_KEY', None):
try:
geocode_results = search_google(location_param)
except Exception:
logger.warning("Google geocoding failed; falling back to OSM")
geocode_results = None
# Fallback to OSM Nominatim
if not geocode_results:
try:
geocode_results = search_osm(location_param)
except Exception:
logger.warning("OSM geocoding failed")
geocode_results = None
# Validate geocode results
if isinstance(geocode_results, dict) and geocode_results.get('error'):
# Log internal geocoding error but avoid exposing sensitive details
logger.warning("Geocoding helper returned an internal error")
return Response({"error": "Geocoding failed. Please try a different location or contact support."}, status=400)
if not geocode_results:
return Response({"error": "Could not geocode provided location."}, status=400)
# geocode_results expected to be a list of results; pick the best (first)
best = None
if isinstance(geocode_results, list) and len(geocode_results) > 0:
best = geocode_results[0]
elif isinstance(geocode_results, dict):
# Some helpers might return a dict when only one result found
best = geocode_results
if not best:
return Response({"error": "No geocoding results found."}, status=400)
try:
lat = float(best.get('lat') or best.get('latitude'))
lon = float(best.get('lon') or best.get('longitude'))
selection = search_places_service(location_param)
if selection and not selection.error and selection.results:
best = selection.results[0]
lat = best.get('lat') or best.get('latitude')
lon = best.get('lon') or best.get('longitude')
location_param = best.get('display_name') or best.get('name') or location_param
else:
return Response({"error": "Could not geocode provided location."}, status=400)
except Exception:
return Response({"error": "Geocoding result missing coordinates."}, status=400)
logger.warning("Provider-based geocoding failed")
return Response({"error": "Geocoding failed. Please try a different location."}, status=400)
# Replace location_param with display name when available for logging/debug
location_param = best.get('display_name') or best.get('name') or location_param
# Validation: require lat and lon at this point
if not lat or not lon:
return Response({
"error": "Latitude and longitude parameters are required (or provide a 'location' parameter to geocode)."
}, status=400)
return Response({"error": "Latitude and longitude parameters are required (or provide a 'location' parameter to geocode)."}, status=400)
try:
lat = float(lat)
lon = float(lon)
radius = min(float(radius), 50000) # Max 50km radius
radius = min(float(radius), 50000)
except ValueError:
return Response({
"error": "Invalid latitude, longitude, or radius value."
}, status=400)
return Response({"error": "Invalid latitude, longitude, or radius value."}, status=400)
valid_categories = ['lodging', 'food', 'tourism']
if category not in valid_categories:
return Response({
"error": f"Invalid category. Valid categories: {', '.join(valid_categories)}"
}, status=400)
return Response({"error": f"Invalid category. Valid categories: {', '.join(valid_categories)}"}, status=400)
valid_sources = ['google', 'osm', 'both']
if sources not in valid_sources:
return Response({
"error": f"Invalid sources. Valid options: {', '.join(valid_sources)}"
}, status=400)
return Response({"error": f"Invalid sources. Valid options: {', '.join(valid_sources)}"}, status=400)
api_key = getattr(settings, 'GOOGLE_MAPS_API_KEY', None)
google_results = []
osm_results = []
# Query Google Places if available and requested
if api_key and sources in ['google', 'both']:
try:
url = "https://places.googleapis.com/v1/places:searchNearby"
headers = {
'Content-Type': 'application/json',
'X-Goog-Api-Key': api_key,
'X-Goog-FieldMask': (
'places.id,places.displayName,places.formattedAddress,'
'places.shortFormattedAddress,places.location,places.types,'
'places.rating,places.userRatingCount,places.businessStatus,'
'places.priceLevel,places.websiteUri,places.googleMapsUri,'
'places.nationalPhoneNumber,places.internationalPhoneNumber,'
'places.editorialSummary,places.photos,'
'places.currentOpeningHours,places.regularOpeningHours'
)
}
type_mapping = {
'lodging': ['lodging', 'hotel', 'hostel', 'resort_hotel'],
'food': ['restaurant', 'cafe', 'bar', 'bakery'],
'tourism': ['tourist_attraction', 'museum', 'art_gallery', 'aquarium', 'zoo', 'park'],
}
payload = {
"includedTypes": type_mapping.get(category, ['tourist_attraction']),
"maxResultCount": 20,
"rankPreference": "DISTANCE",
"locationRestriction": {
"circle": {
"center": {"latitude": lat, "longitude": lon},
"radius": radius
}
}
}
response = requests.post(url, json=payload, headers=headers, timeout=15)
response.raise_for_status()
data = response.json()
places = data.get('places', [])
origin = (lat, lon)
google_results = self.parse_google_places(places, origin)
logger.info(f"Google Places: {len(google_results)} quality results")
except Exception as e:
logger.warning(f"Google Places failed: {e}")
# Query OSM if requested or as fallback
osm_error = None
if sources in ['osm', 'both'] or (sources == 'google' and not google_results):
osm_response = self.query_overpass(lat, lon, radius, category, request)
osm_results = osm_response.get('results', [])
osm_error = osm_response.get('error')
if osm_error:
logger.warning(f"OSM query had issues: {osm_error}")
# Combine and deduplicate if using both sources
if sources == 'both' and google_results and osm_results:
all_results = self._deduplicate_results(google_results, osm_results)
else:
all_results = google_results + osm_results
# Prepare final results
final_results = self._prepare_final_results(all_results)
logger.info(f"Returning {len(final_results)} total recommendations")
# Build response with metadata
response_data = {
"count": len(final_results),
"results": final_results,
"sources_used": {
"google": len(google_results),
"osm": len(osm_results),
"total_before_dedup": len(google_results) + len(osm_results)
}
}
# Add warnings if there were errors but we still have some results
warnings = []
if osm_error and len(osm_results) == 0:
warnings.append(osm_error)
if warnings:
response_data["warnings"] = warnings
# If no results at all and user requested only OSM, return error status
if len(final_results) == 0 and sources == 'osm' and osm_error:
# Log internal error notice for investigation but do not expose details to clients
logger.debug("OSM query error (internal)")
return Response({
"error": "OpenStreetMap service temporarily unavailable. Please try again later.",
"count": 0,
"results": [],
"sources_used": response_data["sources_used"]
}, status=503)
return Response(response_data)
result = get_recommendations(lat=lat, lon=lon, radius=radius, category=category, sources=sources)
# If OSM-only requested and provider returned a warning, surface a 503 when empty
if sources == 'osm' and result.get('count', 0) == 0 and result.get('warnings'):
return Response({"error": "OpenStreetMap service temporarily unavailable. Please try again later.", "count": 0, "results": [], "sources_used": result.get('sources_used')}, status=503)
return Response(result)

View File

@@ -5,10 +5,10 @@ from rest_framework.response import Response
from worldtravel.models import Region, City, VisitedRegion, VisitedCity
from adventures.models import Location
from adventures.serializers import LocationSerializer
from adventures.geocoding import reverse_geocode
from adventures.services.geocoding.reverse import reverse_geocode as reverse_geocode_service
from adventures.services.places.details import get_place_details
from adventures.services.places.search import search_places
from adventures.throttling import ExternalGeocodeThrottle
from django.conf import settings
from adventures.geocoding import search_google, search_osm, get_place_details
class ReverseGeocodeViewSet(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
@@ -24,7 +24,8 @@ class ReverseGeocodeViewSet(viewsets.ViewSet):
lon = float(lon)
except ValueError:
return Response({"error": "Invalid latitude or longitude"}, status=400)
data = reverse_geocode(lat, lon, self.request.user)
selection = reverse_geocode_service(lat, lon, self.request.user)
data = selection.data
if 'error' in data:
return Response({"error": "An internal error occurred while processing the request"}, status=400)
return Response(data)
@@ -35,12 +36,27 @@ class ReverseGeocodeViewSet(viewsets.ViewSet):
if not query:
return Response({"error": "Query parameter is required"}, status=400)
include_meta = request.query_params.get('include_meta', '').lower() in {'1', 'true', 'yes'}
try:
if getattr(settings, 'GOOGLE_MAPS_API_KEY', None):
results = search_google(query)
else:
results = search_osm(query)
return Response(results)
selection = search_places(query)
if selection.error:
if include_meta:
return Response({
"provider_used": selection.provider_used,
"providers_attempted": selection.providers_attempted,
"results": [],
"error": selection.error,
})
return Response([])
if include_meta:
return Response({
"provider_used": selection.provider_used,
"providers_attempted": selection.providers_attempted,
"results": selection.results,
})
return Response(selection.results)
except Exception:
return Response({"error": "An internal error occurred while processing the request"}, status=500)

View File

@@ -251,7 +251,7 @@
params.append('radius', radiusValue.toString());
params.append('category', selectedCategory);
const response = await fetch(`/api/recommendations/query?${params.toString()}`);
const response = await fetch(`/api/recommendations/query/?${params.toString()}`);
if (!response.ok) {
const errorData = await response.json();
@@ -264,7 +264,11 @@
throw new Error(data.error);
}
results = data.results || [];
// Normalize results -> ensure google_maps_uri exists when backend returns google_maps_url
results = (data.results || []).map((r) => ({
...r,
google_maps_uri: r.google_maps_uri || r.google_maps_url || null
}));
// Update map if we have results
if (results.length > 0) {

View File

@@ -20,6 +20,8 @@
location: string;
type?: string;
category?: string;
provider?: string;
powered_by?: string;
};
type LocationMeta = {
@@ -28,6 +30,7 @@
country?: { name: string; country_code: string; visited: boolean };
display_name?: string;
location_name?: string;
provider?: string;
};
const dispatch = createEventDispatcher();
@@ -71,11 +74,25 @@
let initialApplied = false;
let initialTransportationApplied = false;
let isInitializing = false;
let searchProvider: string | null = null;
let startSearchProvider: string | null = null;
let endSearchProvider: string | null = null;
function isFiniteCoordinatePair(lat: unknown, lng: unknown): boolean {
return Number.isFinite(Number(lat)) && Number.isFinite(Number(lng));
}
function formatProviderLabel(provider?: string | null): string | null {
const normalized = (provider || '').trim().toLowerCase();
if (!normalized) return null;
if (normalized === 'google') return 'Google Maps';
if (normalized === 'osm' || normalized === 'nominatim') return 'OpenStreetMap';
if (normalized === 'mixed') return 'Google Maps + OpenStreetMap';
if (normalized === 'google+wikipedia') return 'Google Maps + Wikipedia';
if (normalized === 'wikipedia') return 'Wikipedia';
return provider || null;
}
// Track any provided codes (airport / station / etc)
let startCode: string | null = null;
let endCode: string | null = null;
@@ -197,6 +214,7 @@
async function searchLocations(query: string) {
if (!query.trim() || query.length < 3) {
searchResults = [];
searchProvider = null;
return;
}
@@ -204,11 +222,13 @@
try {
const searchTerm = airportMode ? `${query} Airport` : query;
const response = await fetch(
`/api/reverse-geocode/search/?query=${encodeURIComponent(searchTerm)}`
`/api/places/search/?query=${encodeURIComponent(searchTerm)}&include_meta=1`
);
const results = await response.json();
const payload = await response.json();
const rawResults = Array.isArray(payload) ? payload : payload?.results || [];
searchProvider = Array.isArray(payload) ? null : payload?.provider_used || null;
searchResults = results.map((result: any) => ({
searchResults = rawResults.map((result: any) => ({
id: result.name + result.lat + result.lon,
name: result.name,
lat: parseFloat(result.lat),
@@ -217,11 +237,13 @@
category: result.category,
location: result.display_name,
importance: result.importance,
powered_by: result.powered_by
powered_by: result.powered_by,
provider: result.provider || result.powered_by
}));
} catch (error) {
console.error('Search error:', error);
searchResults = [];
searchProvider = null;
} finally {
isSearching = false;
}
@@ -230,6 +252,7 @@
async function searchStartLocation(query: string) {
if (!query.trim() || query.length < 3) {
startSearchResults = [];
startSearchProvider = null;
return;
}
@@ -237,11 +260,13 @@
try {
const searchTerm = airportMode ? `${query} Airport` : query;
const response = await fetch(
`/api/reverse-geocode/search/?query=${encodeURIComponent(searchTerm)}`
`/api/places/search/?query=${encodeURIComponent(searchTerm)}&include_meta=1`
);
const results = await response.json();
const payload = await response.json();
const rawResults = Array.isArray(payload) ? payload : payload?.results || [];
startSearchProvider = Array.isArray(payload) ? null : payload?.provider_used || null;
startSearchResults = results.map((result: any) => ({
startSearchResults = rawResults.map((result: any) => ({
id: result.name + result.lat + result.lon,
name: result.name,
lat: parseFloat(result.lat),
@@ -250,11 +275,13 @@
category: result.category,
location: result.display_name,
importance: result.importance,
powered_by: result.powered_by
powered_by: result.powered_by,
provider: result.provider || result.powered_by
}));
} catch (error) {
console.error('Search error:', error);
startSearchResults = [];
startSearchProvider = null;
} finally {
isSearchingStart = false;
}
@@ -263,18 +290,20 @@
async function searchEndLocation(query: string) {
if (!query.trim() || query.length < 3) {
endSearchResults = [];
endSearchProvider = null;
return;
}
isSearchingEnd = true;
try {
const searchTerm = airportMode ? `${query} Airport` : query;
const response = await fetch(
`/api/reverse-geocode/search/?query=${encodeURIComponent(searchTerm)}`
`/api/places/search/?query=${encodeURIComponent(searchTerm)}&include_meta=1`
);
const results = await response.json();
const payload = await response.json();
const rawResults = Array.isArray(payload) ? payload : payload?.results || [];
endSearchProvider = Array.isArray(payload) ? null : payload?.provider_used || null;
endSearchResults = results.map((result: any) => ({
endSearchResults = rawResults.map((result: any) => ({
id: result.name + result.lat + result.lon,
name: result.name,
lat: parseFloat(result.lat),
@@ -283,11 +312,13 @@
category: result.category,
location: result.display_name,
importance: result.importance,
powered_by: result.powered_by
powered_by: result.powered_by,
provider: result.provider || result.powered_by
}));
} catch (error) {
console.error('Search error:', error);
endSearchResults = [];
endSearchProvider = null;
} finally {
isSearchingEnd = false;
}
@@ -484,8 +515,10 @@
isReverseGeocoding = true;
try {
const response = await fetch(`/api/reverse-geocode/search/?query=${lat},${lng}`);
const results = await response.json();
const response = await fetch(`/api/places/search/?query=${lat},${lng}&include_meta=1`);
const payload = await response.json();
const results = Array.isArray(payload) ? payload : payload?.results || [];
searchProvider = Array.isArray(payload) ? null : payload?.provider_used || null;
if (results && results.length > 0) {
const result = results[0];
@@ -495,7 +528,9 @@
lng: lng,
location: result.display_name,
type: result.type,
category: result.category
category: result.category,
provider: result.provider || result.powered_by,
powered_by: result.powered_by
};
searchQuery = result.display_name || result.name;
displayName = result.display_name || result.name;
@@ -538,12 +573,11 @@
target: 'single' | 'start' | 'end' = 'single'
) {
try {
const response = await fetch(
`/api/reverse-geocode/reverse_geocode/?lat=${lat}&lon=${lng}&format=json`
);
const response = await fetch(`/api/places/reverse/?lat=${lat}&lon=${lng}&format=json`);
if (response.ok) {
const data = await response.json();
const providerUsed = data.provider_used || data.provider || null;
const metaData = {
city: data.city
? {
@@ -567,7 +601,8 @@
}
: undefined,
display_name: data.display_name,
location_name: data.location_name
location_name: data.location_name,
provider: providerUsed
};
if (target === 'start') {
@@ -583,11 +618,9 @@
const isCoordinatePlaceholder = selectedLocation.name.startsWith('Location at ');
selectedLocation = {
...selectedLocation,
name:
resolvedLocationName ||
(isCoordinatePlaceholder && resolvedDisplayName
? resolvedDisplayName
: selectedLocation.name),
name: isCoordinatePlaceholder
? resolvedLocationName || resolvedDisplayName || selectedLocation.name
: selectedLocation.name,
location: resolvedDisplayName || selectedLocation.location
};
emitUpdate(selectedLocation);
@@ -649,6 +682,8 @@
endSearchQuery = '';
startSearchResults = [];
endSearchResults = [];
startSearchProvider = null;
endSearchProvider = null;
} else {
selectedLocation = null;
selectedMarker = null;
@@ -656,6 +691,7 @@
searchQuery = '';
searchResults = [];
displayName = '';
searchProvider = null;
}
mapCenter = [-74.5, 40];
mapZoom = 2;
@@ -763,6 +799,11 @@
</div>
{:else if startSearchResults.length > 0}
<div class="space-y-2">
{#if startSearchProvider}
<div class="text-xs text-base-content/60">
Source: {formatProviderLabel(startSearchProvider)}
</div>
{/if}
<div class="max-h-48 overflow-y-auto space-y-1">
{#each startSearchResults as result}
<button
@@ -777,6 +818,11 @@
{#if result.category}
<div class="text-xs text-success/70 capitalize">{result.category}</div>
{/if}
{#if result.provider || result.powered_by}
<div class="text-xs text-base-content/50">
Source: {formatProviderLabel(result.provider || result.powered_by)}
</div>
{/if}
</div>
</div>
</button>
@@ -829,6 +875,11 @@
</div>
{:else if endSearchResults.length > 0}
<div class="space-y-2">
{#if endSearchProvider}
<div class="text-xs text-base-content/60">
Source: {formatProviderLabel(endSearchProvider)}
</div>
{/if}
<div class="max-h-48 overflow-y-auto space-y-1">
{#each endSearchResults as result}
<button
@@ -843,6 +894,11 @@
{#if result.category}
<div class="text-xs text-error/70 capitalize">{result.category}</div>
{/if}
{#if result.provider || result.powered_by}
<div class="text-xs text-base-content/50">
Source: {formatProviderLabel(result.provider || result.powered_by)}
</div>
{/if}
</div>
</div>
</button>
@@ -943,6 +999,11 @@
<div class="label">
<span class="label-text text-sm font-medium">{$t('adventures.search_results')}</span>
</div>
{#if searchProvider}
<div class="text-xs text-base-content/60">
Source: {formatProviderLabel(searchProvider)}
</div>
{/if}
<div class="max-h-48 overflow-y-auto space-y-1">
{#each searchResults as result}
<button
@@ -957,6 +1018,11 @@
{#if result.category}
<div class="text-xs text-primary/70 capitalize">{result.category}</div>
{/if}
{#if result.provider || result.powered_by}
<div class="text-xs text-base-content/50">
Source: {formatProviderLabel(result.provider || result.powered_by)}
</div>
{/if}
</div>
</div>
</button>
@@ -1006,6 +1072,15 @@
<p class="text-xs text-base-content/60 mt-1">
{selectedMarker.lat.toFixed(6)}, {selectedMarker.lng.toFixed(6)}
</p>
{#if locationData?.provider || selectedLocation.provider || selectedLocation.powered_by}
<p class="text-xs text-base-content/60 mt-1">
Source: {formatProviderLabel(
locationData?.provider ||
selectedLocation.provider ||
selectedLocation.powered_by
)}
</p>
{/if}
{#if locationData?.city || locationData?.region || locationData?.country}
<div class="flex flex-wrap gap-2 mt-3">

View File

@@ -35,6 +35,7 @@
place_id?: string | null;
google_maps_url?: string | null;
powered_by?: string;
provider?: string;
};
type LocationData = {
@@ -43,6 +44,7 @@
country?: { name: string; country_code: string; visited: boolean };
display_name?: string;
location_name?: string;
provider?: string;
};
const dispatch = createEventDispatcher();
@@ -75,6 +77,18 @@
let locationData: LocationData | null = null;
let selectedQuickAddCategory: Category | null = null;
const placeDetailsCache = new Map<string, any>();
let searchProvider: string | null = null;
function formatProviderLabel(provider?: string | null): string | null {
const normalized = (provider || '').trim().toLowerCase();
if (!normalized) return null;
if (normalized === 'google') return 'Google Maps';
if (normalized === 'osm' || normalized === 'nominatim') return 'OpenStreetMap';
if (normalized === 'mixed') return 'Google Maps + OpenStreetMap';
if (normalized === 'google+wikipedia') return 'Google Maps + Wikipedia';
if (normalized === 'wikipedia') return 'Wikipedia';
return provider || null;
}
function toPlaceResult(result: any): SelectedPlace {
return {
@@ -94,7 +108,8 @@
phone_number: result.phone_number || null,
place_id: result.place_id || null,
google_maps_url: result.google_maps_url || null,
powered_by: result.powered_by
powered_by: result.powered_by,
provider: result.provider || result.powered_by
};
}
@@ -141,9 +156,7 @@
}
try {
const response = await fetch(
`/api/reverse-geocode/search/?query=${encodeURIComponent(query)}`
);
const response = await fetch(`/api/places/search/?query=${encodeURIComponent(query)}`);
if (!response.ok) {
return;
}
@@ -184,7 +197,7 @@
}
const response = await fetch(
`/api/reverse-geocode/place_details/?place_id=${encodeURIComponent(placeId)}&name=${encodeURIComponent(name || '')}`
`/api/places/place_details/?place_id=${encodeURIComponent(placeId)}&name=${encodeURIComponent(name || '')}`
);
if (!response.ok) {
throw new Error('Unable to fetch place details');
@@ -238,19 +251,23 @@
async function searchLocations(query: string) {
if (!query.trim() || query.length < 3) {
searchResults = [];
searchProvider = null;
return;
}
isSearching = true;
try {
const response = await fetch(
`/api/reverse-geocode/search/?query=${encodeURIComponent(query)}`
`/api/places/search/?query=${encodeURIComponent(query)}&include_meta=1`
);
const results = await response.json();
searchResults = Array.isArray(results) ? results.map(toPlaceResult) : [];
const payload = await response.json();
const rawResults = Array.isArray(payload) ? payload : payload?.results || [];
searchProvider = Array.isArray(payload) ? null : payload?.provider_used || null;
searchResults = Array.isArray(rawResults) ? rawResults.map(toPlaceResult) : [];
} catch (error) {
console.error('Search error:', error);
searchResults = [];
searchProvider = null;
} finally {
isSearching = false;
}
@@ -285,8 +302,10 @@
isReverseGeocoding = true;
try {
const response = await fetch(`/api/reverse-geocode/search/?query=${lat},${lng}`);
const results = await response.json();
const response = await fetch(`/api/places/search/?query=${lat},${lng}&include_meta=1`);
const payload = await response.json();
const results = Array.isArray(payload) ? payload : payload?.results || [];
searchProvider = Array.isArray(payload) ? null : payload?.provider_used || null;
if (Array.isArray(results) && results.length > 0) {
selectedLocation = {
@@ -329,12 +348,11 @@
async function performDetailedReverseGeocode(lat: number, lng: number) {
try {
const response = await fetch(
`/api/reverse-geocode/reverse_geocode/?lat=${lat}&lon=${lng}&format=json`
);
const response = await fetch(`/api/places/reverse/?lat=${lat}&lon=${lng}&format=json`);
if (response.ok) {
const data = await response.json();
const providerUsed = data.provider_used || data.provider || null;
locationData = {
city: data.city
? {
@@ -358,7 +376,8 @@
}
: undefined,
display_name: data.display_name,
location_name: data.location_name
location_name: data.location_name,
provider: providerUsed
};
if (selectedLocation) {
@@ -369,11 +388,9 @@
selectedLocation = {
...selectedLocation,
name:
resolvedLocationName ||
(isCoordinatePlaceholder && resolvedDisplayName
? resolvedDisplayName
: selectedLocation.name),
name: isCoordinatePlaceholder
? resolvedLocationName || resolvedDisplayName || selectedLocation.name
: selectedLocation.name,
location: resolvedDisplayName || `${lat.toFixed(4)}, ${lng.toFixed(4)}`
};
searchQuery = selectedLocation.name;
@@ -427,6 +444,7 @@
locationData = null;
searchQuery = '';
searchResults = [];
searchProvider = null;
quickAddedLocation = null;
selectedQuickAddCategory = null;
mapCenter = [-74.5, 40];
@@ -654,6 +672,11 @@
<label class="label" for="quickstart-search-results">
<span class="label-text text-sm font-medium">{$t('adventures.search_results')}</span>
</label>
{#if searchProvider}
<div class="text-xs text-base-content/60">
Source: {formatProviderLabel(searchProvider)}
</div>
{/if}
<div id="quickstart-search-results" class="max-h-52 overflow-y-auto space-y-1">
{#each searchResults as result}
<button
@@ -674,6 +697,11 @@
{/if}
</div>
{/if}
{#if result.provider || result.powered_by}
<div class="text-xs text-base-content/50">
Source: {formatProviderLabel(result.provider || result.powered_by)}
</div>
{/if}
</div>
</div>
</button>
@@ -776,6 +804,13 @@
{/if}
</div>
{/if}
{#if locationData?.provider || selectedLocation.provider || selectedLocation.powered_by}
<p class="text-xs text-base-content/60 mt-1">
Source: {formatProviderLabel(
locationData?.provider || selectedLocation.provider || selectedLocation.powered_by
)}
</p>
{/if}
{#if isEnrichingDescription}
<div class="text-xs text-base-content/60 mt-2 inline-flex items-center gap-1">
<span class="loading loading-spinner loading-xs"></span>

View File

@@ -216,6 +216,8 @@ export type GeocodeSearchResult = {
addresstype?: string;
name?: string;
display_name?: string;
provider?: string;
powered_by?: string;
};
export type Transportation = {
@@ -302,6 +304,8 @@ export type ReverseGeocode = {
city: string;
city_id: string;
location_name: string;
provider_used?: string;
provider?: string;
};
export type Category = {