Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/bones/address.py: 87%
60 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-25 15:02 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-25 15:02 +0000
1import hashlib
2import logging
3import typing as t
4import urllib.parse
6import requests
8from .record import RecordBone
9from .string import StringBone
10from .selectcountry import SelectCountryBone
11from .spatial import SpatialBone
12from ..skeleton.relskel import RelSkel
13from .. import db, i18n
14from ..data.zip_patterns import ZIP_CODE_PATTERNS
16CACHE_KIND = "viur-addressbone-geocache"
19class AddressRelSkel(RelSkel):
20 street_name = StringBone(descr="Street", required=True)
21 street_number = StringBone(descr="House number")
22 address_addition = StringBone(descr="Address addition")
23 zip_code = StringBone(
24 descr="ZIP / Postal code",
25 params={
26 "pattern": ZIP_CODE_PATTERNS,
27 "pattern-error": i18n.translate(
28 "viur.core.bones.address.zip_code.invalid",
29 defaultText="Invalid ZIP code",
30 ),
31 },
32 )
33 city = StringBone(descr="City", required=True)
34 country = SelectCountryBone(descr="Country")
35 coordinates = SpatialBone(
36 descr="Coordinates",
37 boundsLat=(-90, 90),
38 boundsLng=(-180, 180),
39 gridDimensions=(10, 10),
40 )
43class AddressBone(RecordBone):
44 type = RecordBone.type + ".address"
46 def __init__(
47 self,
48 *,
49 using: t.Type[RelSkel] = AddressRelSkel,
50 format: str = "$(street_name) $(street_number), $(zip_code) $(city)",
51 **kwargs,
52 ):
53 super().__init__(using=using, format=format, **kwargs)
55 def after_from_client(self, skel, name, errors):
56 value = skel[name]
57 if value is not None:
58 coords = self.geocode(value)
59 if coords:
60 value["coordinates"] = coords
62 @staticmethod
63 def _cache_key(params: str) -> db.Key:
64 digest = hashlib.sha256(params.encode()).hexdigest()
65 return db.Key(CACHE_KIND, digest)
67 @staticmethod
68 def geocode(skel: RelSkel) -> tuple[float, float] | None:
69 street = f"{skel['street_name'] or ''} {skel['street_number'] or ''}".strip()
70 params = {
71 "street": street,
72 "postalcode": skel["zip_code"] or "",
73 "city": skel["city"] or "",
74 "country": skel["country"] or "",
75 "format": "json",
76 "limit": 1,
77 }
78 cache_key = AddressBone._cache_key(urllib.parse.urlencode(params))
79 try:
80 cached = db.get(cache_key)
81 if cached is not None:
82 return cached["lat"], cached["lng"]
83 response = requests.get(
84 "https://nominatim.openstreetmap.org/search",
85 params=params,
86 headers={"User-Agent": "viur-addressbone/1.0 (viur.dev)"},
87 timeout=5,
88 )
89 if response.status_code != 200:
90 logging.error(
91 f"AddressBone: Nominatim returned {response.status_code=}"
92 )
93 return None
94 data = response.json()
95 if not data:
96 return None
97 lat, lng = float(data[0]["lat"]), float(data[0]["lon"])
98 entity = db.Entity(cache_key)
99 entity["lat"] = lat
100 entity["lng"] = lng
101 db.put(entity)
102 return lat, lng
103 except Exception as e:
104 logging.error(f"AddressBone: Nominatim geocoding failed with {e=}")
105 return None