mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2026-07-30 15:28:30 -04:00
Geocoding service/provider framework
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
1
backend/server/adventures/providers/__init__.py
Normal file
1
backend/server/adventures/providers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""External provider integrations."""
|
||||
10
backend/server/adventures/providers/base.py
Normal file
10
backend/server/adventures/providers/base.py
Normal 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
|
||||
@@ -0,0 +1 @@
|
||||
"""Geocoding provider integrations."""
|
||||
29
backend/server/adventures/providers/geocoding/google.py
Normal file
29
backend/server/adventures/providers/geocoding/google.py
Normal 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)
|
||||
42
backend/server/adventures/providers/geocoding/osm.py
Normal file
42
backend/server/adventures/providers/geocoding/osm.py
Normal 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 {})
|
||||
1
backend/server/adventures/providers/places/__init__.py
Normal file
1
backend/server/adventures/providers/places/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Place provider integrations."""
|
||||
104
backend/server/adventures/providers/places/google.py
Normal file
104
backend/server/adventures/providers/places/google.py
Normal 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", []))
|
||||
26
backend/server/adventures/providers/places/osm.py
Normal file
26
backend/server/adventures/providers/places/osm.py
Normal 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 [])
|
||||
42
backend/server/adventures/providers/places/wikipedia.py
Normal file
42
backend/server/adventures/providers/places/wikipedia.py
Normal 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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Recommendation provider integrations."""
|
||||
118
backend/server/adventures/providers/recommendations/google.py
Normal file
118
backend/server/adventures/providers/recommendations/google.py
Normal 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", []))
|
||||
53
backend/server/adventures/providers/recommendations/osm.py
Normal file
53
backend/server/adventures/providers/recommendations/osm.py
Normal 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")
|
||||
1
backend/server/adventures/services/__init__.py
Normal file
1
backend/server/adventures/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Service layer for AdventureLog business logic."""
|
||||
1
backend/server/adventures/services/geocoding/__init__.py
Normal file
1
backend/server/adventures/services/geocoding/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Geocoding services."""
|
||||
107
backend/server/adventures/services/geocoding/reverse.py
Normal file
107
backend/server/adventures/services/geocoding/reverse.py
Normal 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)
|
||||
188
backend/server/adventures/services/images/fetch.py
Normal file
188
backend/server/adventures/services/images/fetch.py
Normal 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,
|
||||
}
|
||||
1
backend/server/adventures/services/media/__init__.py
Normal file
1
backend/server/adventures/services/media/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Media-related services."""
|
||||
223
backend/server/adventures/services/media/images.py
Normal file
223
backend/server/adventures/services/media/images.py
Normal 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,
|
||||
}
|
||||
1
backend/server/adventures/services/places/__init__.py
Normal file
1
backend/server/adventures/services/places/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Place-related services."""
|
||||
127
backend/server/adventures/services/places/details.py
Normal file
127
backend/server/adventures/services/places/details.py
Normal 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()
|
||||
360
backend/server/adventures/services/places/search.py
Normal file
360
backend/server/adventures/services/places/search.py
Normal 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,
|
||||
)
|
||||
309
backend/server/adventures/services/places/utils.py
Normal file
309
backend/server/adventures/services/places/utils.py
Normal 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,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Recommendation services."""
|
||||
307
backend/server/adventures/services/recommendations/search.py
Normal file
307
backend/server/adventures/services/recommendations/search.py
Normal 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
|
||||
39
backend/server/adventures/services/sun/times.py
Normal file
39
backend/server/adventures/services/sun/times.py
Normal 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
|
||||
@@ -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
|
||||
148
backend/server/adventures/services/wikipedia/search.py
Normal file
148
backend/server/adventures/services/wikipedia/search.py
Normal 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)
|
||||
0
backend/server/adventures/tests/__init__.py
Normal file
0
backend/server/adventures/tests/__init__.py
Normal 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)
|
||||
@@ -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')
|
||||
|
||||
427
backend/server/adventures/utils/geocoding_utils.py
Normal file
427
backend/server/adventures/utils/geocoding_utils.py
Normal 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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
|
||||
74
backend/server/adventures/views/places_api_view.py
Normal file
74
backend/server/adventures/views/places_api_view.py
Normal 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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user