diff --git a/backend/server/adventures/geocoding.py b/backend/server/adventures/geocoding.py index baa03730..4924a2f8 100644 --- a/backend/server/adventures/geocoding.py +++ b/backend/server/adventures/geocoding.py @@ -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 diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index 2e4391f8..bf975bf9 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -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() diff --git a/backend/server/adventures/providers/__init__.py b/backend/server/adventures/providers/__init__.py new file mode 100644 index 00000000..015daa77 --- /dev/null +++ b/backend/server/adventures/providers/__init__.py @@ -0,0 +1 @@ +"""External provider integrations.""" diff --git a/backend/server/adventures/providers/base.py b/backend/server/adventures/providers/base.py new file mode 100644 index 00000000..b665b185 --- /dev/null +++ b/backend/server/adventures/providers/base.py @@ -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 diff --git a/backend/server/adventures/providers/geocoding/__init__.py b/backend/server/adventures/providers/geocoding/__init__.py new file mode 100644 index 00000000..cfe80a81 --- /dev/null +++ b/backend/server/adventures/providers/geocoding/__init__.py @@ -0,0 +1 @@ +"""Geocoding provider integrations.""" diff --git a/backend/server/adventures/providers/geocoding/google.py b/backend/server/adventures/providers/geocoding/google.py new file mode 100644 index 00000000..af37511d --- /dev/null +++ b/backend/server/adventures/providers/geocoding/google.py @@ -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) diff --git a/backend/server/adventures/providers/geocoding/osm.py b/backend/server/adventures/providers/geocoding/osm.py new file mode 100644 index 00000000..f6075824 --- /dev/null +++ b/backend/server/adventures/providers/geocoding/osm.py @@ -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 {}) diff --git a/backend/server/adventures/providers/places/__init__.py b/backend/server/adventures/providers/places/__init__.py new file mode 100644 index 00000000..feb4a329 --- /dev/null +++ b/backend/server/adventures/providers/places/__init__.py @@ -0,0 +1 @@ +"""Place provider integrations.""" diff --git a/backend/server/adventures/providers/places/google.py b/backend/server/adventures/providers/places/google.py new file mode 100644 index 00000000..c390854c --- /dev/null +++ b/backend/server/adventures/providers/places/google.py @@ -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", [])) diff --git a/backend/server/adventures/providers/places/osm.py b/backend/server/adventures/providers/places/osm.py new file mode 100644 index 00000000..59aab76c --- /dev/null +++ b/backend/server/adventures/providers/places/osm.py @@ -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 []) diff --git a/backend/server/adventures/providers/places/wikipedia.py b/backend/server/adventures/providers/places/wikipedia.py new file mode 100644 index 00000000..d6a50885 --- /dev/null +++ b/backend/server/adventures/providers/places/wikipedia.py @@ -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) diff --git a/backend/server/adventures/providers/recommendations/__init__.py b/backend/server/adventures/providers/recommendations/__init__.py new file mode 100644 index 00000000..279d7d8f --- /dev/null +++ b/backend/server/adventures/providers/recommendations/__init__.py @@ -0,0 +1 @@ +"""Recommendation provider integrations.""" diff --git a/backend/server/adventures/providers/recommendations/google.py b/backend/server/adventures/providers/recommendations/google.py new file mode 100644 index 00000000..9318ffea --- /dev/null +++ b/backend/server/adventures/providers/recommendations/google.py @@ -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", [])) diff --git a/backend/server/adventures/providers/recommendations/osm.py b/backend/server/adventures/providers/recommendations/osm.py new file mode 100644 index 00000000..8bb689b7 --- /dev/null +++ b/backend/server/adventures/providers/recommendations/osm.py @@ -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") diff --git a/backend/server/adventures/services/__init__.py b/backend/server/adventures/services/__init__.py new file mode 100644 index 00000000..91db2384 --- /dev/null +++ b/backend/server/adventures/services/__init__.py @@ -0,0 +1 @@ +"""Service layer for AdventureLog business logic.""" diff --git a/backend/server/adventures/services/geocoding/__init__.py b/backend/server/adventures/services/geocoding/__init__.py new file mode 100644 index 00000000..81756023 --- /dev/null +++ b/backend/server/adventures/services/geocoding/__init__.py @@ -0,0 +1 @@ +"""Geocoding services.""" diff --git a/backend/server/adventures/services/geocoding/reverse.py b/backend/server/adventures/services/geocoding/reverse.py new file mode 100644 index 00000000..5532d238 --- /dev/null +++ b/backend/server/adventures/services/geocoding/reverse.py @@ -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) diff --git a/backend/server/adventures/services/images/fetch.py b/backend/server/adventures/services/images/fetch.py new file mode 100644 index 00000000..418af25e --- /dev/null +++ b/backend/server/adventures/services/images/fetch.py @@ -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, + } diff --git a/backend/server/adventures/services/media/__init__.py b/backend/server/adventures/services/media/__init__.py new file mode 100644 index 00000000..2aff2939 --- /dev/null +++ b/backend/server/adventures/services/media/__init__.py @@ -0,0 +1 @@ +"""Media-related services.""" diff --git a/backend/server/adventures/services/media/images.py b/backend/server/adventures/services/media/images.py new file mode 100644 index 00000000..8382f2cc --- /dev/null +++ b/backend/server/adventures/services/media/images.py @@ -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, + } diff --git a/backend/server/adventures/services/places/__init__.py b/backend/server/adventures/services/places/__init__.py new file mode 100644 index 00000000..fa84899f --- /dev/null +++ b/backend/server/adventures/services/places/__init__.py @@ -0,0 +1 @@ +"""Place-related services.""" diff --git a/backend/server/adventures/services/places/details.py b/backend/server/adventures/services/places/details.py new file mode 100644 index 00000000..0f3e21cf --- /dev/null +++ b/backend/server/adventures/services/places/details.py @@ -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() diff --git a/backend/server/adventures/services/places/search.py b/backend/server/adventures/services/places/search.py new file mode 100644 index 00000000..68e5c49a --- /dev/null +++ b/backend/server/adventures/services/places/search.py @@ -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, + ) diff --git a/backend/server/adventures/services/places/utils.py b/backend/server/adventures/services/places/utils.py new file mode 100644 index 00000000..2f0f8dbf --- /dev/null +++ b/backend/server/adventures/services/places/utils.py @@ -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, + ) diff --git a/backend/server/adventures/services/recommendations/__init__.py b/backend/server/adventures/services/recommendations/__init__.py new file mode 100644 index 00000000..4ab7689d --- /dev/null +++ b/backend/server/adventures/services/recommendations/__init__.py @@ -0,0 +1 @@ +"""Recommendation services.""" diff --git a/backend/server/adventures/services/recommendations/search.py b/backend/server/adventures/services/recommendations/search.py new file mode 100644 index 00000000..f15c4e63 --- /dev/null +++ b/backend/server/adventures/services/recommendations/search.py @@ -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 diff --git a/backend/server/adventures/services/sun/times.py b/backend/server/adventures/services/sun/times.py new file mode 100644 index 00000000..72c5eead --- /dev/null +++ b/backend/server/adventures/services/sun/times.py @@ -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 diff --git a/backend/server/adventures/services/wikipedia/description_service.py b/backend/server/adventures/services/wikipedia/description_service.py new file mode 100644 index 00000000..d762d061 --- /dev/null +++ b/backend/server/adventures/services/wikipedia/description_service.py @@ -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 diff --git a/backend/server/adventures/services/wikipedia/search.py b/backend/server/adventures/services/wikipedia/search.py new file mode 100644 index 00000000..eb62ada9 --- /dev/null +++ b/backend/server/adventures/services/wikipedia/search.py @@ -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) diff --git a/backend/server/adventures/tests/__init__.py b/backend/server/adventures/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/server/adventures/tests/test_geocoding_provider_selection.py b/backend/server/adventures/tests/test_geocoding_provider_selection.py new file mode 100644 index 00000000..5fdd9854 --- /dev/null +++ b/backend/server/adventures/tests/test_geocoding_provider_selection.py @@ -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) diff --git a/backend/server/adventures/urls.py b/backend/server/adventures/urls.py index 11e7e3d7..6157478d 100644 --- a/backend/server/adventures/urls.py +++ b/backend/server/adventures/urls.py @@ -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') diff --git a/backend/server/adventures/utils/geocoding_utils.py b/backend/server/adventures/utils/geocoding_utils.py new file mode 100644 index 00000000..283b0d04 --- /dev/null +++ b/backend/server/adventures/utils/geocoding_utils.py @@ -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, + } diff --git a/backend/server/adventures/views/generate_description_view.py b/backend/server/adventures/views/generate_description_view.py index 3e5b0f75..25484a95 100644 --- a/backend/server/adventures/views/generate_description_view.py +++ b/backend/server/adventures/views/generate_description_view.py @@ -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' \ No newline at end of file + return Response({"error": "Invalid response from Wikipedia API"}, status=500) \ No newline at end of file diff --git a/backend/server/adventures/views/location_image_view.py b/backend/server/adventures/views/location_image_view.py index 941ca42f..10949183 100644 --- a/backend/server/adventures/views/location_image_view.py +++ b/backend/server/adventures/views/location_image_view.py @@ -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] diff --git a/backend/server/adventures/views/location_view.py b/backend/server/adventures/views/location_view.py index ed10a3ae..c764ff53 100644 --- a/backend/server/adventures/views/location_view.py +++ b/backend/server/adventures/views/location_view.py @@ -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.""" diff --git a/backend/server/adventures/views/lodging_view.py b/backend/server/adventures/views/lodging_view.py index 17867d30..1a80565d 100644 --- a/backend/server/adventures/views/lodging_view.py +++ b/backend/server/adventures/views/lodging_view.py @@ -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 = {} diff --git a/backend/server/adventures/views/places_api_view.py b/backend/server/adventures/views/places_api_view.py new file mode 100644 index 00000000..ba905aa0 --- /dev/null +++ b/backend/server/adventures/views/places_api_view.py @@ -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) diff --git a/backend/server/adventures/views/quick_add_utils.py b/backend/server/adventures/views/quick_add_utils.py index 37478a75..3c2582f2 100644 --- a/backend/server/adventures/views/quick_add_utils.py +++ b/backend/server/adventures/views/quick_add_utils.py @@ -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): diff --git a/backend/server/adventures/views/recommendations_view.py b/backend/server/adventures/views/recommendations_view.py index 1e0c72d3..0ed5fd6a 100644 --- a/backend/server/adventures/views/recommendations_view.py +++ b/backend/server/adventures/views/recommendations_view.py @@ -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) \ No newline at end of file + 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) \ No newline at end of file diff --git a/backend/server/adventures/views/reverse_geocode_view.py b/backend/server/adventures/views/reverse_geocode_view.py index a58386d4..333b9543 100644 --- a/backend/server/adventures/views/reverse_geocode_view.py +++ b/backend/server/adventures/views/reverse_geocode_view.py @@ -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) diff --git a/frontend/src/lib/components/CollectionRecommendationView.svelte b/frontend/src/lib/components/CollectionRecommendationView.svelte index 3bef4089..6a1e5645 100644 --- a/frontend/src/lib/components/CollectionRecommendationView.svelte +++ b/frontend/src/lib/components/CollectionRecommendationView.svelte @@ -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) { diff --git a/frontend/src/lib/components/shared/LocationSearchMap.svelte b/frontend/src/lib/components/shared/LocationSearchMap.svelte index 80530d0f..07e4d252 100644 --- a/frontend/src/lib/components/shared/LocationSearchMap.svelte +++ b/frontend/src/lib/components/shared/LocationSearchMap.svelte @@ -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 @@ {:else if startSearchResults.length > 0}
{selectedMarker.lat.toFixed(6)}, {selectedMarker.lng.toFixed(6)}
+ {#if locationData?.provider || selectedLocation.provider || selectedLocation.powered_by} ++ Source: {formatProviderLabel( + locationData?.provider || + selectedLocation.provider || + selectedLocation.powered_by + )} +
+ {/if} {#if locationData?.city || locationData?.region || locationData?.country}+ Source: {formatProviderLabel( + locationData?.provider || selectedLocation.provider || selectedLocation.powered_by + )} +
+ {/if} {#if isEnrichingDescription}