Coverage for gws-app/gws/plugin/qgis/provider.py: 0%
245 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-17 01:37 +0200
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-17 01:37 +0200
1"""QGIS provider."""
3from typing import Optional, cast
5import gws
6import gws.base.database
7import gws.base.ows.client
8import gws.config.util
9import gws.plugin.postgres.provider
10import gws.lib.metadata
11import gws.lib.net
12import gws.lib.mime
13import gws.lib.osx
14import gws.gis.crs
15import gws.gis.bounds
16import gws.gis.source
17import gws.gis.extent
18import gws.lib.net
20from . import caps, project
23class Config(gws.Config):
24 path: Optional[gws.FilePath]
25 """Qgis project file"""
26 dbUid: Optional[str]
27 """Qgis project database"""
28 schema: Optional[str]
29 """Qgis project schema"""
30 projectName: Optional[str]
31 """Qgis project name"""
32 defaultLegendOptions: Optional[dict]
33 """Default options for qgis legends. (added in 8.1)"""
34 directRender: Optional[list[str]]
35 """QGIS data providers that should be rendered directly"""
36 directSearch: Optional[list[str]]
37 """QGIS data providers that should be searched directly"""
38 forceCrs: Optional[gws.CrsName]
39 """use this CRS for requests"""
40 sqlFilters: Optional[dict]
41 """per-layer sql filters"""
42 extentBuffer: Optional[int]
43 """Extent buffer for automatically computed bounds. (added in 8.1)"""
44 useCanvasExtent: Optional[bool]
45 """Use canvas extent as project extent. (added in 8.1)"""
48class Object(gws.OwsProvider):
49 store: project.Store
50 printTemplates: list[caps.PrintTemplate]
52 directRender: set[str]
53 directSearch: set[str]
55 defaultLegendOptions: dict
57 caps: caps.Caps
59 def configure(self):
60 self.configure_store()
61 # if self.store.path:
62 # self.root.app.monitor.add_file(self.store.path)
64 self.url = 'http://{}:{}'.format(
65 self.root.app.cfg('server.qgis.host'),
66 self.root.app.cfg('server.qgis.port'))
68 self.caps = self.qgis_project().caps()
70 self.metadata = self.caps.metadata
71 self.printTemplates = self.caps.printTemplates
72 self.sourceLayers = self.caps.sourceLayers
73 self.version = self.caps.version
75 self.forceCrs = gws.gis.crs.get(self.cfg('forceCrs')) or self.caps.projectCrs
76 self.alwaysXY = False
78 self.bounds = self._project_bounds()
79 self.wgsExtent = gws.gis.bounds.wgs_extent(self.bounds)
81 self.directRender = self._direct_formats('directRender', {'wms', 'wmts', 'xyz'})
82 self.directSearch = self._direct_formats('directSearch', {'wms', 'wfs', 'postgres'})
84 self.defaultLegendOptions = self.cfg('defaultLegendOptions', default={})
86 def _project_bounds(self):
87 # explicit WMS extent?
88 if self.caps.projectBounds:
89 return gws.gis.bounds.transform(self.caps.projectBounds, self.forceCrs)
91 # canvas extent?
92 if self.cfg('useCanvasExtent') and self.caps.projectCanvasBounds:
93 return gws.gis.bounds.transform(self.caps.projectCanvasBounds, self.forceCrs)
95 # combined data extents + buffer
96 b = gws.gis.source.combined_bounds(self.sourceLayers, self.forceCrs)
97 if b:
98 return gws.gis.bounds.buffer(b, self.cfg('extentBuffer') or 0)
100 return self.forceCrs.bounds
102 def _direct_formats(self, opt, allowed):
103 p = self.cfg(opt)
104 if not p:
105 return set()
107 res = set()
109 for s in p:
110 s = s.lower()
111 if s not in allowed:
112 raise gws.ConfigurationError(f'{opt} not supported for {s!r}')
113 res.add(s)
115 return res
117 def configure_store(self):
118 p = self.cfg('path')
119 if p:
120 pp = gws.lib.osx.parse_path(p)
121 self.store = project.Store(
122 type=project.StoreType.file,
123 projectName=pp['name'],
124 path=p,
125 )
126 return
127 p = self.cfg('projectName')
128 if p:
129 self.store = project.Store(
130 type=project.StoreType.postgres,
131 projectName=p,
132 dbUid=self.cfg('dbUid'),
133 schema=self.cfg('schema') or 'public',
134 )
135 return
136 # @TODO gpkg, etc
137 raise gws.ConfigurationError('cannot load qgis project ("path" or "projectName" must be specified)')
139 ##
141 def qgis_project(self) -> project.Object:
142 return project.from_store(self.root, self.store)
144 def server_project_path(self):
145 if self.store.type == project.StoreType.file:
146 return self.store.path
147 if self.store.type == project.StoreType.postgres:
148 prov = self.root.app.databaseMgr.find_provider(ext_type='postgres', uid=self.store.dbUid)
149 return gws.lib.net.add_params(prov.url, schema=self.store.schema, project=self.store.projectName)
151 def server_params(self, params: dict) -> dict:
152 defaults = dict(
153 MAP=self.server_project_path(),
154 SERVICE=gws.OwsProtocol.WMS,
155 VERSION='1.3.0',
156 )
157 return gws.u.merge(defaults, gws.u.to_upper_dict(params))
159 def call_server(self, params: dict, max_age=0) -> gws.lib.net.HTTPResponse:
160 params = self.server_params(params)
161 res = gws.lib.net.http_request(self.url, params=params, max_age=max_age, timeout=1000)
162 res.raise_if_failed()
163 return res
165 ##
167 def get_map(self, layer: gws.Layer, bounds: gws.Bounds, width: float, height: float, params: dict) -> bytes:
168 bbox = bounds.extent
169 if bounds.crs.isYX and not self.alwaysXY:
170 bbox = gws.gis.extent.swap_xy(bbox)
172 defaults = dict(
173 REQUEST=gws.OwsVerb.GetMap,
174 BBOX=bbox,
175 WIDTH=gws.u.to_rounded_int(width),
176 HEIGHT=gws.u.to_rounded_int(height),
177 CRS=bounds.crs.epsg,
178 FORMAT=gws.lib.mime.PNG,
179 TRANSPARENT='true',
180 STYLES='',
181 )
183 params = gws.u.merge(defaults, params)
185 res = self.call_server(params)
186 if res.content_type.startswith('image/'):
187 return res.content
188 raise gws.Error(res.text)
190 def get_features(self, search, source_layers):
191 shape = search.shape
192 if not shape or shape.type != gws.GeometryType.point:
193 return []
195 request_crs = self.forceCrs
196 if not request_crs:
197 request_crs = gws.gis.crs.best_match(
198 shape.crs,
199 gws.gis.source.combined_crs_list(source_layers))
201 box_size_m = 500
202 box_size_deg = 1
203 box_size_px = 500
205 size = None
207 if shape.crs.uom == gws.Uom.m:
208 size = box_size_px * search.resolution
209 if shape.crs.uom == gws.Uom.deg:
210 # @TODO use search.resolution here as well
211 size = box_size_deg
212 if not size:
213 gws.log.debug('cannot request crs {crs!r}, unsupported unit')
214 return []
216 bbox = (
217 shape.x - (size / 2),
218 shape.y - (size / 2),
219 shape.x + (size / 2),
220 shape.y + (size / 2),
221 )
223 bbox = gws.gis.extent.transform(bbox, shape.crs, request_crs)
225 layer_names = [sl.name for sl in source_layers]
227 params = {
228 'BBOX': bbox,
229 'CRS': request_crs.to_string(gws.CrsFormat.epsg),
230 'WIDTH': box_size_px,
231 'HEIGHT': box_size_px,
232 'I': box_size_px >> 1,
233 'J': box_size_px >> 1,
234 'LAYERS': layer_names,
235 'QUERY_LAYERS': layer_names,
236 'STYLES': [''] * len(layer_names),
237 'FEATURE_COUNT': search.limit or 100,
238 'INFO_FORMAT': 'text/xml',
239 'REQUEST': gws.OwsVerb.GetFeatureInfo,
240 'WITH_GEOMETRY': 'true',
241 }
243 if search.extraParams:
244 params = gws.u.merge(params, gws.u.to_upper_dict(search.extraParams))
246 res = self.call_server(params)
248 fdata = gws.base.ows.client.featureinfo.parse(res.text, default_crs=request_crs, always_xy=self.alwaysXY)
250 if fdata is None:
251 gws.log.debug(f'get_features: NOT_PARSED params={params!r}')
252 return []
254 gws.log.debug(f'get_features: FOUND={len(fdata)} params={params!r}')
256 for fd in fdata:
257 if fd.shape:
258 fd.shape = fd.shape.transformed_to(shape.crs)
260 return fdata
262 ##
264 def leaf_config(self, source_layers):
265 simple_cfg = {
266 'type': 'qgisflat',
267 '_defaultProvider': self,
268 '_defaultSourceLayers': source_layers,
269 }
271 if len(source_layers) > 1 or source_layers[0].isGroup:
272 return simple_cfg
274 ds = source_layers[0].dataSource
275 if not ds or not ds.get('provider'):
276 return simple_cfg
278 render = self._leaf_render_config(ds)
279 search = self._leaf_search_config(ds)
281 cfg = {}
282 cfg.update(render or {})
283 cfg.update(search or {})
285 if not cfg.get('type'):
286 cfg.update(simple_cfg)
288 return cfg
290 def _leaf_render_config(self, ds):
291 prov = ds.get('provider')
292 if prov not in self.directRender:
293 return
295 url = self._leaf_service_url(ds.get('url'), ds.get('params'))
296 if not url:
297 return
299 if prov == 'wms':
300 layers = ds.get('layers')
301 if not layers:
302 return
303 return {
304 'type': 'wmsflat',
305 'sourceLayers': {'names': layers},
306 'display': 'tile',
307 'provider': {'url': url},
308 }
310 if prov == 'wmts':
311 layers = ds.get('layers')
312 if not layers:
313 return
314 cfg = {
315 'type': 'wmts',
316 'sourceLayers': {'names': layers},
317 'display': 'tile',
318 'provider': {'url': url},
319 }
320 p = ds.get('styles')
321 if p:
322 cfg['style'] = p[0]
323 return cfg
325 if prov == 'xyz':
326 return {
327 'type': 'tile',
328 'provider': {'url': url},
329 }
331 def _leaf_search_config(self, ds):
332 prov = ds.get('provider')
333 if prov not in self.directSearch:
334 return
336 if prov == 'wms':
337 url = self._leaf_service_url(ds.get('url'), ds.get('params'))
338 layers = ds.get('layers')
339 if not url or not layers:
340 return
341 finder = {
342 'type': 'wms',
343 'provider': {'url': url},
344 'sourceLayers': {'names': layers},
345 }
346 return {'finders': [finder]}
348 if prov == 'wfs':
349 url = self._leaf_service_url(ds.get('url'), ds.get('params'))
350 if not url:
351 return
352 finder = {
353 'type': 'wfs',
354 'provider': {'url': url},
355 }
356 p = ds.get('typename')
357 if p:
358 finder['sourceLayers'] = {'names': [p]}
359 p = ds.get('srsname')
360 if p:
361 finder['forceCrs'] = p
362 p = ds.get('ignoreaxisorientation')
363 if p == '1':
364 finder['alwaysXY'] = True
365 p = ds.get('invertaxisorientation')
366 if p == '1':
367 # NB assuming this might be only '1' for lat-lon projections
368 finder['alwaysXY'] = True
370 return {'finders': [finder]}
372 if prov == 'postgres':
373 table_name = ds.get('table')
375 # 'table' can also be a select statement, in which case it might be enclosed in parens
376 if not table_name or table_name.startswith('(') or table_name.upper().startswith('SELECT '):
377 return
379 db = self.postgres_provider_from_datasource(ds)
381 model = {
382 'type': 'postgres',
383 'tableName': table_name,
384 'sqlFilter': ds.get('sql'),
385 '_defaultDb': db
386 }
387 finder = {
388 'type': 'postgres',
389 'tableName': table_name,
390 'sqlFilter': ds.get('sql'),
391 '_defaultDb': db
392 }
393 return {'models': [model], 'finders': [finder]}
395 def postgres_provider_from_datasource(self, ds: dict) -> gws.plugin.postgres.provider.Object:
396 cfg = gws.Config(
397 host=ds.get('host'),
398 port=ds.get('port'),
399 database=ds.get('dbname'),
400 username=ds.get('user'),
401 password=ds.get('password'),
402 serviceName=ds.get('service'),
403 options=ds.get('options'),
404 )
405 url = gws.plugin.postgres.provider.connection_url(cfg)
406 mgr = self.root.app.databaseMgr
408 for p in mgr.providers:
409 if p.extType == 'postgres' and p.url == url:
410 return cast(gws.plugin.postgres.provider.Object, p)
412 gws.log.debug(f'creating an ad-hoc postgres provider for qgis {url=}')
413 p = mgr.create_provider(cfg, type='postgres')
414 return cast(gws.plugin.postgres.provider.Object, p)
416 _std_ows_params = {
417 'bbox',
418 'bgcolor',
419 'crs',
420 'exceptions',
421 'format',
422 'height',
423 'layers',
424 'request',
425 'service',
426 'sld',
427 'sld_body',
428 'srs',
429 'styles',
430 'time',
431 'transparent',
432 'version',
433 'width',
434 }
436 def _leaf_service_url(self, url, params):
437 if not url:
438 return
439 if not params:
440 return url
442 # a wms url can be like "server?service=WMS....&bbox=.... &some-non-std-param=...
443 # we need to keep non-std params for caps requests
445 p = {k: v for k, v in params.items() if k.lower() not in self._std_ows_params}
446 return gws.lib.net.add_params(url, p)