Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/bones/address.py: 99%
63 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-27 10:50 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-27 10:50 +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 state = StringBone(descr="State")
35 country = SelectCountryBone(descr="Country")
36 coordinates = SpatialBone(
37 descr="Coordinates",
38 boundsLat=(-90, 90),
39 boundsLng=(-180, 180),
40 gridDimensions=(10, 10),
41 )
44class AddressBone(RecordBone):
45 type = RecordBone.type + ".address"
47 def __init__(
48 self,
49 *,
50 using: t.Type[RelSkel] = AddressRelSkel,
51 format: str = "$(street_name) $(street_number), $(zip_code) $(city)",
52 **kwargs,
53 ):
54 super().__init__(using=using, format=format, **kwargs)
56 def after_from_client(self, skel, name, errors):
57 value = skel[name]
58 # Coordinates sent by the client (e.g. a pin set on a map) are more precise than geocoding
59 if value is not None and self.using.coordinates.isEmpty(value["coordinates"]):
60 coords = self.geocode(value)
61 if coords: 61 ↛ exitline 61 didn't return from function 'after_from_client' because the condition on line 61 was always true
62 value["coordinates"] = coords
64 @staticmethod
65 def _cache_key(params: str) -> db.Key:
66 digest = hashlib.sha256(params.encode()).hexdigest()
67 return db.Key(CACHE_KIND, digest)
69 @staticmethod
70 def geocode(skel: RelSkel) -> tuple[float, float] | None:
71 street = f"{skel['street_name'] or ''} {skel['street_number'] or ''}".strip()
72 params = {
73 "street": street,
74 "postalcode": skel["zip_code"] or "",
75 "city": skel["city"] or "",
76 "country": skel["country"] or "",
77 "format": "json",
78 "limit": 1,
79 }
81 # Only when set, so cache keys of addresses without a state stay the same; `using` may lack the field
82 if state := skel.get("state"):
83 params["state"] = state
85 cache_key = AddressBone._cache_key(urllib.parse.urlencode(params))
86 try:
87 cached = db.get(cache_key)
88 if cached is not None:
89 return cached["lat"], cached["lng"]
90 response = requests.get(
91 "https://nominatim.openstreetmap.org/search",
92 params=params,
93 headers={"User-Agent": "viur-addressbone/1.0 (viur.dev)"},
94 timeout=5,
95 )
96 if response.status_code != 200:
97 logging.error(
98 f"AddressBone: Nominatim returned {response.status_code=}"
99 )
100 return None
101 data = response.json()
102 if not data:
103 return None
104 lat, lng = float(data[0]["lat"]), float(data[0]["lon"])
105 entity = db.Entity(cache_key)
106 entity["lat"] = lat
107 entity["lng"] = lng
108 db.put(entity)
109 return lat, lng
110 except Exception as e:
111 logging.error(f"AddressBone: Nominatim geocoding failed with {e=}")
112 return None