fix: enforce dd/mm/yyyy, 24h time, and locale-aware location search

- Replace all 'en-US' and undefined locales with 'en-GB' in date
  formatting across 15+ frontend files (dateUtils.ts, cards, routes,
  Luxon calls) to consistently output day-first dates and 24h times
- Set hour12: false in all Intl.DateTimeFormat and toLocaleDateString
  calls that previously used 12h format
- Pass user's svelte-i18n locale as &lang= query param from
  LocationSearchMap and LocationQuickStart to the reverse-geocode API
- Extract lang param in reverse_geocode_view and forward to both
  search_osm and search_google
- Add Accept-Language header to Nominatim requests so searches return
  results in the user's language (e.g. Prague not Praha)
- Add languageCode field to Google Places API payload for same effect
This commit is contained in:
2026-03-06 13:50:27 +00:00
parent df8d1adf15
commit f55b0ea230
27 changed files with 302 additions and 190 deletions

View File

@@ -6,33 +6,37 @@ import unicodedata
from worldtravel.models import Region, City, VisitedRegion, VisitedCity
from django.conf import settings
# -----------------
# SEARCHING
def search_google(query):
def search_google(query, lang="en"):
try:
api_key = settings.GOOGLE_MAPS_API_KEY
if not api_key:
return {"error": "Geocoding service unavailable. Please check configuration."}
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.displayName.text,places.formattedAddress,places.location,places.types,places.rating,places.userRatingCount'
"Content-Type": "application/json",
"X-Goog-Api-Key": api_key,
"X-Goog-FieldMask": "places.displayName.text,places.formattedAddress,places.location,places.types,places.rating,places.userRatingCount",
}
payload = {
"textQuery": query,
"maxResultCount": 20 # Adjust as needed
"maxResultCount": 20, # Adjust as needed
"languageCode": lang,
}
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:
@@ -56,42 +60,62 @@ def search_google(query):
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"),
"type": primary_type,
"category": category,
"importance": importance,
"addresstype": addresstype,
"powered_by": "google",
})
results.append(
{
"lat": location.get("latitude"),
"lon": location.get("longitude"),
"name": name,
"display_name": place.get("formattedAddress"),
"type": primary_type,
"category": category,
"importance": importance,
"addresstype": addresstype,
"powered_by": "google",
}
)
if results:
results.sort(key=lambda r: r["importance"] if r["importance"] is not None else 0, reverse=True)
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."}
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."}
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."}
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."}
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."}
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."}
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."}
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
@@ -126,56 +150,74 @@ def _infer_addresstype(type_):
return mapping.get(type_, None)
def search_osm(query):
def search_osm(query, lang="en"):
try:
url = f"https://nominatim.openstreetmap.org/search?q={query}&format=jsonv2"
headers = {'User-Agent': 'Voyage Server'}
headers = {"User-Agent": "Voyage Server", "Accept-Language": lang}
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]
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."}
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."}
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."}
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."}
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."}
return {
"error": "Network error while contacting OpenStreetMap. Please try again."
}
except Exception:
return {"error": "An unexpected error occurred during OpenStreetMap search. Please try again."}
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):
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)
# -----------------
# REVERSE GEOCODING
# -----------------
def extractIsoCode(user, data):
"""
Extract the ISO code from the response data.
@@ -188,10 +230,10 @@ def extractIsoCode(user, data):
visited_city = None
location_name = None
if 'name' in data.keys():
location_name = data['name']
if "name" in data.keys():
location_name = data["name"]
address = data.get('address', {}) or {}
address = data.get("address", {}) or {}
# Capture country code early for ISO selection and name fallback.
country_code = address.get("ISO3166-1")
@@ -275,17 +317,17 @@ def extractIsoCode(user, data):
# ordered preference for best-effort locality matching
locality_keys = [
'suburb',
'neighbourhood',
'neighborhood', # alternate spelling
'city',
'city_district',
'town',
'village',
'hamlet',
'locality',
'municipality',
'county',
"suburb",
"neighbourhood",
"neighborhood", # alternate spelling
"city",
"city_district",
"town",
"village",
"hamlet",
"locality",
"municipality",
"county",
]
def _normalize_name(value):
@@ -305,13 +347,13 @@ def extractIsoCode(user, data):
return exact_match
normalized_value = _normalize_name(value)
for candidate in qs.values_list('id', 'name'):
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':
if key_name == "county":
return None
return qs.filter(name__icontains=value).first()
@@ -333,7 +375,9 @@ def extractIsoCode(user, data):
region_visited = bool(visited_region)
if city:
display_name = f"{city.name}, {region.name}, {country_code or region.country.country_code}"
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:
@@ -349,9 +393,10 @@ def extractIsoCode(user, data):
"city": city.name if city else None,
"city_id": city.id if city else None,
"city_visited": city_visited,
'location_name': location_name,
"location_name": location_name,
}
def is_host_resolvable(hostname: str) -> bool:
try:
socket.gethostbyname(hostname)
@@ -359,8 +404,9 @@ def is_host_resolvable(hostname: str) -> bool:
except socket.error:
return False
def reverse_geocode(lat, lon, user):
if getattr(settings, 'GOOGLE_MAPS_API_KEY', None):
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
@@ -368,39 +414,59 @@ def reverse_geocode(lat, lon, user):
return reverse_geocode_osm(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': 'Voyage Server'}
url = (
f"https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat={lat}&lon={lon}"
)
headers = {"User-Agent": "Voyage 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."}
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 = requests.get(
url, headers=headers, timeout=(connect_timeout, read_timeout)
)
response.raise_for_status()
data = response.json()
return extractIsoCode(user, data)
except requests.exceptions.Timeout:
return {"error": "Request timed out while contacting OpenStreetMap. Please try again."}
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."}
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."}
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."}
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."}
return {
"error": "Network error while contacting OpenStreetMap. Please try again."
}
except Exception:
return {"error": "An unexpected error occurred during OpenStreetMap geocoding. Please try again."}
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"
@@ -416,11 +482,17 @@ def reverse_geocode_google(lat, lon, user):
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."}
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."}
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."}
return {
"error": "Invalid request to Google Maps. Please check coordinates."
}
else:
return {"error": "Geocoding failed. Please try again."}
@@ -428,28 +500,47 @@ def reverse_geocode_google(lat, lon, user):
first_result = data.get("results", [])[0]
result_data = {
"name": first_result.get("formatted_address"),
"address": _parse_google_address_components(first_result.get("address_components", []))
"address": _parse_google_address_components(
first_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."}
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."}
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."}
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."}
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."}
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."}
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."}
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."}
return {
"error": "An unexpected error occurred during Google geocoding. Please try again."
}
def _parse_google_address_components(components):
parsed = {}
@@ -476,7 +567,9 @@ def _parse_google_address_components(components):
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):
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

View File

@@ -9,41 +9,51 @@ from adventures.geocoding import reverse_geocode
from django.conf import settings
from adventures.geocoding import search_google, search_osm
class ReverseGeocodeViewSet(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
@action(detail=False, methods=['get'])
@action(detail=False, methods=["get"])
def reverse_geocode(self, request):
lat = request.query_params.get('lat', '')
lon = request.query_params.get('lon', '')
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)
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)
data = reverse_geocode(lat, lon, self.request.user)
if 'error' in data:
return Response({"error": "An internal error occurred while processing the request"}, status=400)
if "error" in data:
return Response(
{"error": "An internal error occurred while processing the request"},
status=400,
)
return Response(data)
@action(detail=False, methods=['get'])
@action(detail=False, methods=["get"])
def search(self, request):
query = request.query_params.get('query', '')
query = request.query_params.get("query", "")
lang = request.query_params.get("lang", "en")
if not query:
return Response({"error": "Query parameter is required"}, status=400)
try:
if getattr(settings, 'GOOGLE_MAPS_API_KEY', None):
results = search_google(query)
if getattr(settings, "GOOGLE_MAPS_API_KEY", None):
results = search_google(query, lang=lang)
else:
results = search_osm(query)
results = search_osm(query, lang=lang)
return Response(results)
except Exception:
return Response({"error": "An internal error occurred while processing the request"}, status=500)
return Response(
{"error": "An internal error occurred while processing the request"},
status=500,
)
@action(detail=False, methods=['post'])
@action(detail=False, methods=["post"])
def mark_visited_region(self, request):
"""
Marks regions and cities as visited based on user's visited locations.
@@ -53,37 +63,36 @@ class ReverseGeocodeViewSet(viewsets.ViewSet):
new_regions = {}
new_city_count = 0
new_cities = {}
# Get all visited locations with their region and city data
visited_locations = Location.objects.filter(
user=self.request.user
).select_related('region', 'city')
).select_related("region", "city")
# Track unique regions and cities to create VisitedRegion/VisitedCity entries
regions_to_mark = set()
cities_to_mark = set()
for location in visited_locations:
# Only process locations that are marked as visited
if not location.is_visited_status():
continue
# Collect regions
if location.region:
regions_to_mark.add(location.region.id)
# Collect cities
if location.city:
cities_to_mark.add(location.city.id)
# Get existing visited regions for this user
existing_visited_regions = set(
VisitedRegion.objects.filter(
user=self.request.user,
region_id__in=regions_to_mark
).values_list('region_id', flat=True)
user=self.request.user, region_id__in=regions_to_mark
).values_list("region_id", flat=True)
)
# Create new VisitedRegion entries
new_visited_regions = []
for region_id in regions_to_mark:
@@ -91,7 +100,7 @@ class ReverseGeocodeViewSet(viewsets.ViewSet):
new_visited_regions.append(
VisitedRegion(region_id=region_id, user=self.request.user)
)
if new_visited_regions:
VisitedRegion.objects.bulk_create(new_visited_regions)
new_region_count = len(new_visited_regions)
@@ -100,15 +109,14 @@ class ReverseGeocodeViewSet(viewsets.ViewSet):
id__in=[vr.region_id for vr in new_visited_regions]
)
new_regions = {r.id: r.name for r in regions}
# Get existing visited cities for this user
existing_visited_cities = set(
VisitedCity.objects.filter(
user=self.request.user,
city_id__in=cities_to_mark
).values_list('city_id', flat=True)
user=self.request.user, city_id__in=cities_to_mark
).values_list("city_id", flat=True)
)
# Create new VisitedCity entries
new_visited_cities = []
for city_id in cities_to_mark:
@@ -116,7 +124,7 @@ class ReverseGeocodeViewSet(viewsets.ViewSet):
new_visited_cities.append(
VisitedCity(city_id=city_id, user=self.request.user)
)
if new_visited_cities:
VisitedCity.objects.bulk_create(new_visited_cities)
new_city_count = len(new_visited_cities)
@@ -125,10 +133,12 @@ class ReverseGeocodeViewSet(viewsets.ViewSet):
id__in=[vc.city_id for vc in new_visited_cities]
)
new_cities = {c.id: c.name for c in cities}
return Response({
"new_regions": new_region_count,
"regions": new_regions,
"new_cities": new_city_count,
"cities": new_cities
})
return Response(
{
"new_regions": new_region_count,
"regions": new_regions,
"new_cities": new_city_count,
"cities": new_cities,
}
)