from __future__ import annotations import hashlib import re from typing import Any from app.config import CACHE_TTL_GEOCODE, NOMINATIM_BASE_URL, NOMINATIM_USER_AGENT from app.http_client import get_client from app.database import get_cached_json, set_cached_json _POSTAL_ONLY = re.compile(r"^\d{4,5}$") _POSTAL_CITY = re.compile(r"^(\d{4,5})\s+(.+)$") def _query_hash(query: str) -> str: return hashlib.sha256(query.strip().lower().encode("utf-8")).hexdigest() def _parse_nominatim_item(item: dict[str, Any]) -> dict[str, Any]: address = item.get("address") or {} label = item.get("display_name", "") place = ( address.get("city") or address.get("town") or address.get("village") or address.get("municipality") or address.get("county") or label.split(",")[0] ) return { "label": label, "place": place, "lat": float(item["lat"]), "lon": float(item["lon"]), "type": item.get("type"), "postcode": address.get("postcode"), "county": address.get("county"), "state": address.get("state"), } def _country_codes_for_postal(postal: str) -> str: if len(postal) == 5: return "de" return "at,ch" def _build_search_params(query: str, limit: int) -> dict[str, Any]: base = { "format": "jsonv2", "addressdetails": 1, "limit": limit, } city_match = _POSTAL_CITY.match(query) if city_match: postal, city = city_match.groups() return { **base, "postalcode": postal, "city": city.strip(), "countrycodes": _country_codes_for_postal(postal), } if _POSTAL_ONLY.match(query): return { **base, "postalcode": query, "countrycodes": _country_codes_for_postal(query), } return { **base, "q": query, "countrycodes": "de,at,ch", } async def _nominatim_get(path: str, params: dict[str, Any]) -> Any: headers = {"User-Agent": NOMINATIM_USER_AGENT, "Accept-Language": "de"} client = get_client() response = await client.get(f"{NOMINATIM_BASE_URL}{path}", params=params, headers=headers, timeout=12.0) response.raise_for_status() return response.json() async def search_places(query: str, limit: int = 8) -> list[dict[str, Any]]: query = query.strip() if len(query) < 2: return [] if query.isdigit() and len(query) < 4: return [] qhash = _query_hash(f"search:{query}:{limit}") cached = await get_cached_json("geocode_cache", {"query_hash": qhash}, CACHE_TTL_GEOCODE) if cached is not None: return cached raw = await _nominatim_get("/search", _build_search_params(query, limit)) results = [_parse_nominatim_item(item) for item in raw] await set_cached_json( "geocode_cache", {"query_hash": qhash, "query_text": query}, results, ) return results async def reverse_geocode(lat: float, lon: float) -> dict[str, Any]: qhash = _query_hash(f"reverse:{lat:.4f}:{lon:.4f}") cached = await get_cached_json("geocode_cache", {"query_hash": qhash}, CACHE_TTL_GEOCODE) if cached is not None: return cached raw = await _nominatim_get( "/reverse", {"lat": lat, "lon": lon, "format": "jsonv2", "addressdetails": 1, "zoom": 10}, ) address = raw.get("address") or {} payload = { "label": raw.get("display_name"), "place": address.get("city") or address.get("town") or address.get("village") or address.get("municipality") or address.get("county"), "county": address.get("county"), "state": address.get("state"), "postcode": address.get("postcode"), "admin_names": [ value for value in [ address.get("city"), address.get("town"), address.get("village"), address.get("municipality"), address.get("county"), address.get("state"), ] if value ], } await set_cached_json( "geocode_cache", {"query_hash": qhash, "query_text": f"{lat},{lon}"}, payload, ) return payload