From 68ba3c4b4d92d80fdfa3d5eb66e9d10c374759cc Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Sat, 24 May 2025 14:59:58 -0400 Subject: [PATCH] Add Google Maps API integration for geocoding and reverse geocoding functionality --- backend/server/.env.example | 2 + backend/server/adventures/geocoding.py | 178 ++++++++++++++++-- .../adventures/views/reverse_geocode_view.py | 29 +-- backend/server/main/settings.py | 2 + .../lib/components/LocationDropdown.svelte | 12 +- 5 files changed, 183 insertions(+), 40 deletions(-) diff --git a/backend/server/.env.example b/backend/server/.env.example index 598aeb7..2c93208 100644 --- a/backend/server/.env.example +++ b/backend/server/.env.example @@ -22,6 +22,8 @@ EMAIL_BACKEND='console' # EMAIL_HOST_PASSWORD='password' # DEFAULT_FROM_EMAIL='user@example.com' +# GOOGLE_MAPS_API_KEY='key' + # ------------------- # # For Developers to start a Demo Database diff --git a/backend/server/adventures/geocoding.py b/backend/server/adventures/geocoding.py index 258dc04..937bd69 100644 --- a/backend/server/adventures/geocoding.py +++ b/backend/server/adventures/geocoding.py @@ -2,6 +2,106 @@ import requests import time import socket from worldtravel.models import Region, City, VisitedRegion, VisitedCity +from django.conf import settings + +# ----------------- +# SEARCHING +# ----------------- +def search_google(query): + api_key = settings.GOOGLE_MAPS_API_KEY + url = "https://maps.googleapis.com/maps/api/place/textsearch/json" + params = {'query': query, 'key': api_key} + response = requests.get(url, params=params) + data = response.json() + + results = [] + for r in data.get("results", []): + location = r.get("geometry", {}).get("location", {}) + types = r.get("types", []) + + # First type is often most specific (e.g., 'restaurant', 'locality') + primary_type = types[0] if types else None + category = _extract_google_category(types) + addresstype = _infer_addresstype(primary_type) + + importance = None + if r.get("user_ratings_total") and r.get("rating"): + # Simple importance heuristic based on popularity and quality + importance = round(float(r["rating"]) * r["user_ratings_total"] / 100, 2) + + results.append({ + "lat": location.get("lat"), + "lon": location.get("lng"), + "name": r.get("name"), + "display_name": r.get("formatted_address"), + "type": primary_type, + "category": category, + "importance": importance, + "addresstype": addresstype, + "powered_by": "google", + }) + + # order by importance if available + if results and any("importance" in r for r in results): + results.sort(key=lambda x: x.get("importance", 0), reverse=True) + + return results + + +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) + + +def search_osm(query): + url = f"https://nominatim.openstreetmap.org/search?q={query}&format=jsonv2" + headers = {'User-Agent': 'AdventureLog Server'} + response = requests.get(url, headers=headers) + 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] + +# ----------------- +# REVERSE GEOCODING +# ----------------- def extractIsoCode(user, data): """ @@ -57,39 +157,85 @@ def extractIsoCode(user, data): if region: 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} return {"error": "No region found"} - def is_host_resolvable(hostname: str) -> bool: try: socket.gethostbyname(hostname) return True except socket.error: - return False # silently fail + return False def reverse_geocode(lat, lon, user): + if getattr(settings, 'GOOGLE_MAPS_API_KEY', None): + print("Using Google Maps API for reverse geocoding") + return reverse_geocode_google(lat, lon, user) + return reverse_geocode_osm(lat, lon, user) + +def reverse_geocode_osm(lat, lon, user): url = f"https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat={lat}&lon={lon}" headers = {'User-Agent': 'AdventureLog Server'} - connect_timeout = 1 # instead of 0.3 - read_timeout = 5 # instead of 2 - + connect_timeout = 1 + read_timeout = 5 if not is_host_resolvable("nominatim.openstreetmap.org"): return {"error": "DNS resolution failed"} - start = time.time() try: response = requests.get(url, headers=headers, timeout=(connect_timeout, read_timeout)) response.raise_for_status() data = response.json() - elapsed = time.time() - start return extractIsoCode(user, data) + except Exception: + return {"error": "An internal error occurred while processing the request"} - except requests.exceptions.ConnectionError as e: +def reverse_geocode_google(lat, lon, user): + api_key = settings.GOOGLE_MAPS_API_KEY + url = "https://maps.googleapis.com/maps/api/geocode/json" + params = {"latlng": f"{lat},{lon}", "key": api_key} + + try: + response = requests.get(url, params=params) + response.raise_for_status() + data = response.json() + + if data.get("status") != "OK": + return {"error": "Geocoding failed"} + + # Convert Google schema to Nominatim-style for extractIsoCode + first_result = data.get("results", [])[0] + result_data = { + "name": first_result.get("formatted_address"), + "address": _parse_google_address_components(first_result.get("address_components", [])) + } + return extractIsoCode(user, result_data) + except Exception: return {"error": "An internal error occurred while processing the request"} - except requests.exceptions.Timeout as e: - return {"error": "An internal error occurred while processing the request"} - except requests.exceptions.HTTPError as e: - return {"error": "An internal error occurred while processing the request"} - except requests.exceptions.JSONDecodeError as e: - return {"error": "An internal error occurred while processing the request"} - except Exception as e: - return {"error": "An internal error occurred while processing the request"} \ No newline at end of file + +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 "locality" in types: + parsed["city"] = long_name + if "sublocality" in types: + parsed["town"] = long_name + + # Build composite ISO 3166-2 code like US-ME + if country_code and state_code: + parsed["ISO3166-2-lvl1"] = f"{country_code}-{state_code}" + + return parsed diff --git a/backend/server/adventures/views/reverse_geocode_view.py b/backend/server/adventures/views/reverse_geocode_view.py index bdc03dd..0e1eb90 100644 --- a/backend/server/adventures/views/reverse_geocode_view.py +++ b/backend/server/adventures/views/reverse_geocode_view.py @@ -8,6 +8,8 @@ from adventures.serializers import AdventureSerializer import requests from adventures.geocoding import reverse_geocode from adventures.geocoding import extractIsoCode +from django.conf import settings +from adventures.geocoding import search_google, search_osm class ReverseGeocodeViewSet(viewsets.ViewSet): permission_classes = [IsAuthenticated] @@ -33,26 +35,15 @@ class ReverseGeocodeViewSet(viewsets.ViewSet): query = request.query_params.get('query', '') if not query: return Response({"error": "Query parameter is required"}, status=400) - url = f"https://nominatim.openstreetmap.org/search?q={query}&format=jsonv2" - headers = {'User-Agent': 'AdventureLog Server'} - response = requests.get(url, headers=headers) + try: - data = response.json() - parsed_results = [] - for item in data: - parsed_results.append({ - "lat": item.get("lat"), - "lon": item.get("lon"), - "category": item.get("category"), - "type": item.get("type"), - "importance": item.get("importance"), - "addresstype": item.get("addresstype"), - "name": item.get("name"), - "display_name": item.get("display_name"), - }) - except requests.exceptions.JSONDecodeError: - return Response({"error": "Invalid response from geocoding service"}, status=400) - return Response(parsed_results) + if getattr(settings, 'GOOGLE_MAPS_API_KEY', None): + results = search_google(query) + else: + results = search_osm(query) + return Response(results) + except Exception as e: + return Response({"error": str(e)}, status=500) @action(detail=False, methods=['post']) def mark_visited_region(self, request): diff --git a/backend/server/main/settings.py b/backend/server/main/settings.py index bf51406..e7d3ace 100644 --- a/backend/server/main/settings.py +++ b/backend/server/main/settings.py @@ -314,3 +314,5 @@ LOGGING = { # https://github.com/dr5hn/countries-states-cities-database/tags COUNTRY_REGION_JSON_VERSION = 'v2.6' + +GOOGLE_MAPS_API_KEY = getenv('GOOGLE_MAPS_API_KEY', '') \ No newline at end of file diff --git a/frontend/src/lib/components/LocationDropdown.svelte b/frontend/src/lib/components/LocationDropdown.svelte index d9b470a..cc38486 100644 --- a/frontend/src/lib/components/LocationDropdown.svelte +++ b/frontend/src/lib/components/LocationDropdown.svelte @@ -272,16 +272,18 @@ markers = [ { lngLat: { lng: Number(place.lon), lat: Number(place.lat) }, - location: place.display_name, - name: place.name, - activity_type: place.type + location: place.display_name ?? '', + name: place.name ?? '', + activity_type: place.type ?? '' } ]; - item.name = place.name; + item.name = place.name ?? ''; }} > - {place.display_name} + {place.name} +
+ {place.display_name} {/each}