Coverage for gws-app/gws/plugin/gbd_geoservices/model.py: 0%
104 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
1"""The ``gbd_geoservices`` model."""
3import gws
4import gws.base.feature
5import gws.base.model
6import gws.lib.shape
7import gws.lib.bounds
8import gws.lib.crs
9import gws.gis.source
10import gws.lib.jsonx
11import gws.lib.net
14@gws.ext.config.model('gbd_geoservices')
15class Config(gws.base.model.Config):
16 """Read-only model for features from GBD Geoservices."""
18 apiKey: str
19 """API key for GBD Geoservices."""
22@gws.ext.object.model('gbd_geoservices')
23class Object(gws.base.model.default_model.Object):
24 """GBD Geoservices model.
26 Read-only model that loads features from the GBD Geoservices search
27 service.
28 """
30 apiKey: str
31 """API key for the service."""
33 serviceUrl = 'https://geoservices.gbd-consult.de/search'
35 def configure(self):
36 self.apiKey = self.cfg('apiKey')
37 self.uidName = 'uid'
38 self.geometryName = 'geometry'
39 self.loadingStrategy = gws.FeatureLoadingStrategy.all
41 def props(self, user):
42 return gws.u.merge(
43 super().props(user),
44 canCreate=False,
45 canDelete=False,
46 canWrite=False,
47 )
49 def find_features(self, search, mc, **kwargs):
50 kw = (search.keyword or '').strip()
52 tolerance = 0.0
53 if search.tolerance:
54 n, u = search.tolerance
55 tolerance = n * (search.resolution or 1) if u == 'px' else n
57 area = None
58 if search.shape:
59 area = search.shape.tolerance_polygon(tolerance).transformed_to(gws.lib.crs.WGS84)
61 request = {}
63 if kw:
64 request['mode'] = 'phrase'
65 request['text'] = kw
66 if area:
67 request['area'] = area.to_wkt(output_dimension=2)
68 request['within'] = False
69 elif area:
70 request['mode'] = 'point'
71 if tolerance > 0:
72 request['radius'] = min(tolerance, _MAX_POINT_RADIUS)
73 else:
74 return []
76 request['limit'] = min(search.limit or _MAX_LIMIT, _MAX_LIMIT)
77 if area:
78 c = area.centroid()
79 request['near'] = [c.geom.x, c.geom.y]
81 res = self._request(self.serviceUrl, method='POST', json=request)
82 if not res:
83 return []
85 results = res.get('results') or {}
86 features = results.get('features') or []
88 out = []
90 for f in features:
91 a = self._attributes(f['properties'])
92 if not a:
93 gws.log.warning(f'geoservices: skipping feature without name, address or category: {f.get("id")}')
94 continue
95 shape = gws.lib.shape.from_geojson(f['geometry'], gws.lib.crs.WGS84, always_xy=True)
96 rec = gws.FeatureRecord(uid=f['id'], attributes=a, shape=shape)
97 gws.log.debug(f'geoservices: feature record: {rec=}')
98 out.append(self.feature_from_record(rec, mc))
100 return out
102 def _request(self, url, **kwargs) -> dict:
103 """Send a request to the service and return the decoded JSON, or an empty dict on errors."""
104 res = gws.lib.net.http_request(
105 url,
106 headers={'Authorization': f'Bearer {self.apiKey}'},
107 **kwargs,
108 )
109 if not res.ok:
110 gws.log.error(f'geoservices request error: {res.status_code} {res.text}')
111 return {}
112 gws.log.debug(f'{res.text=}')
113 return gws.lib.jsonx.from_string(res.text)
115 def _attributes(self, props: dict) -> dict | None:
116 """Create feature attributes from a result, or ``None`` if it has no name, address or category."""
117 name = props.get('name') or ''
119 addr1 = _join([props.get('address_street'), props.get('address_housenumber')])
120 addr2 = _join([props.get('address_postcode'), props.get('address_city')])
122 category = props.get('category_name') or ''
123 icon = props.get('icon') or ''
124 places = [p['name'] for p in props.get('places') or []]
125 places_line = ' • '.join(places)
126 place = places[-1] if places else ''
128 a = dict(title='', subtitle='', teaser='', details='', icon=icon)
130 if name:
131 a['title'] = name
132 a['subtitle'] = category
133 a['teaser'] = places_line
134 if addr1:
135 a['details'] = addr1 + '\n' + addr2
136 else:
137 a['details'] = places_line
138 elif addr1:
139 a['title'] = addr1 + '\n' + addr2
140 a['subtitle'] = category
141 elif category:
142 a['title'] = category
143 a['subtitle'] = ''
144 a['teaser'] = place
145 if addr1:
146 a['details'] = addr1 + '\n' + addr2
147 else:
148 a['details'] = places_line
149 else:
150 return None
152 return a
155_MAX_LIMIT = 100
156_MAX_POINT_RADIUS = 10_000
159def _join(parts):
160 """Join the non-empty parts with single spaces."""
161 return ' '.join(' '.join(str(p) for p in parts if p).split())