Coverage for gws-app/gws/plugin/qgis/provider.py: 44%
263 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 12:46 +0200
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 12:46 +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.base.metadata
11import gws.lib.net
12import gws.lib.mime
13import gws.lib.osx
14import gws.lib.crs
15import gws.lib.bounds
16import gws.gis.source
17import gws.lib.extent
18import gws.lib.net
20from . import caps as caps_module, project
23class Config(gws.Config):
24 """QGIS provider configuration."""
26 path: Optional[gws.FilePath]
27 """Qgis project file."""
28 dbUid: Optional[str]
29 """Qgis project database."""
30 schema: Optional[str]
31 """Qgis project schema."""
32 projectName: Optional[str]
33 """Qgis project name."""
34 defaultLegendOptions: Optional[dict]
35 """Default options for qgis legends.."""
36 directRender: Optional[list[str]]
37 """Qgis data providers that should be rendered directly."""
38 directSearch: Optional[list[str]]
39 """Qgis data providers that should be searched directly."""
40 forceCrs: Optional[gws.CrsName]
41 """Use this CRS for requests."""
42 extentBuffer: Optional[int]
43 """Extent buffer for automatically computed bounds.."""
44 useCanvasExtent: Optional[bool]
45 """Use canvas extent as project extent.."""
46 withWatch: Optional[bool]
47 """Enable monitoring of the project for changes."""
48 watchFrequency: Optional[gws.Duration]
49 """Frequency for checking project changes."""
52class Object(gws.OwsProvider):
53 store: project.Store
54 printTemplates: list[caps_module.PrintTemplate]
56 directRender: set[str]
57 directSearch: set[str]
59 defaultLegendOptions: dict
61 caps: caps_module.Caps
62 sourceHash: str
64 def configure(self):
65 self.configure_store()
67 self.url = 'http://{}:{}'.format(
68 self.root.app.cfg('server.qgis.host'),
69 self.root.app.cfg('server.qgis.port'))
71 self.caps = self.qgis_project().caps()
73 self.metadata = self.caps.metadata
74 self.printTemplates = self.caps.printTemplates
75 self.sourceLayers = self.caps.sourceLayers
76 self.version = self.caps.version
78 self.forceCrs = gws.lib.crs.get(self.cfg('forceCrs')) or self.caps.projectCrs
79 self.alwaysXY = False
81 self.bounds = self._project_bounds()
82 self.wgsExtent = gws.lib.bounds.wgs_extent(self.bounds)
84 self.directRender = self._direct_formats('directRender', {'wms', 'wmts', 'xyz'})
85 self.directSearch = self._direct_formats('directSearch', {'wms', 'wfs', 'postgres'})
87 self.defaultLegendOptions = self.cfg('defaultLegendOptions', default={})
89 self.sourceHash = ''
90 if self.cfg('withWatch'):
91 self.sourceHash = self.qgis_project().sourceHash
92 self.root.app.monitor.register_periodic_task(self, frequency=self.cfg('watchFrequency', default=0))
93 gws.log.info(f'QGIS: monitoring: enabled: {self.server_project_path()!r}')
95 def periodic_task(self):
96 h = self.qgis_project().sourceHash
97 if h != self.sourceHash:
98 gws.log.info(f'QGIS: monitoring: CHANGED: {self.server_project_path()!r}')
99 self.sourceHash = h
100 self.root.app.monitor.schedule_reload(with_reconfigure=True)
102 def _project_bounds(self):
103 # explicit WMS extent?
104 if self.caps.projectBounds:
105 return gws.lib.bounds.transform(self.caps.projectBounds, self.forceCrs)
107 # canvas extent?
108 if self.cfg('useCanvasExtent') and self.caps.projectCanvasBounds:
109 return gws.lib.bounds.transform(self.caps.projectCanvasBounds, self.forceCrs)
111 # combined data extents + buffer
112 b = gws.gis.source.combined_bounds(self.sourceLayers, self.forceCrs)
113 if b:
114 return gws.lib.bounds.buffer(b, self.cfg('extentBuffer') or 0)
116 return self.forceCrs.bounds
118 def _direct_formats(self, opt, allowed):
119 p = self.cfg(opt)
120 if not p:
121 return set()
123 res = set()
125 for s in p:
126 s = s.lower()
127 if s not in allowed:
128 raise gws.ConfigurationError(f'{opt} not supported for {s!r}')
129 res.add(s)
131 return res
133 def configure_store(self):
134 p = self.cfg('path')
135 if p:
136 pp = gws.lib.osx.parse_path(p)
137 self.store = project.Store(
138 type=project.StoreType.file,
139 projectName=pp.stem,
140 path=p,
141 )
142 return
143 p = self.cfg('projectName')
144 if p:
145 self.store = project.Store(
146 type=project.StoreType.postgres,
147 projectName=p,
148 dbUid=self.cfg('dbUid'),
149 schema=self.cfg('schema') or 'public',
150 )
151 return
152 # @TODO gpkg, etc
153 raise gws.ConfigurationError('cannot load qgis project ("path" or "projectName" must be specified)')
155 ##
157 def qgis_project(self) -> project.Object:
158 return project.from_store(self.root, self.store)
160 def server_project_path(self):
161 if self.store.type == project.StoreType.file:
162 return self.store.path
163 if self.store.type == project.StoreType.postgres:
164 prov = self.root.app.databaseMgr.find_provider(ext_type='postgres', uid=self.store.dbUid)
165 p = {'schema': self.store.schema, 'project': self.store.projectName}
166 if self.sourceHash:
167 # NB the hash is to invalidate QGIS Server internal cache
168 p['h'] = self.sourceHash
169 return gws.lib.net.add_params(prov.url(), p)
171 def server_params(self, params: dict) -> dict:
172 defaults = dict(
173 MAP=self.server_project_path(),
174 SERVICE=gws.OwsProtocol.WMS,
175 VERSION='1.3.0',
176 )
177 return gws.u.merge(defaults, gws.u.to_upper_dict(params))
179 def call_server(self, params: dict, max_age=0) -> gws.lib.net.HTTPResponse:
180 params = self.server_params(params)
181 res = gws.lib.net.http_request(self.url, params=params, max_age=max_age, timeout=1000)
182 res.raise_if_failed()
183 return res
185 ##
187 def get_map(self, layer: gws.Layer, bounds: gws.Bounds, width: float, height: float, params: dict) -> bytes:
188 bbox = bounds.extent
189 if bounds.crs.isYX and not self.alwaysXY:
190 bbox = gws.lib.extent.swap_xy(bbox)
192 defaults = dict(
193 REQUEST=gws.OwsVerb.GetMap,
194 BBOX=bbox,
195 WIDTH=gws.u.to_rounded_int(width),
196 HEIGHT=gws.u.to_rounded_int(height),
197 CRS=bounds.crs.epsg,
198 FORMAT=gws.lib.mime.PNG,
199 TRANSPARENT='true',
200 STYLES='',
201 )
203 params = gws.u.merge(defaults, params)
205 res = self.call_server(params)
206 if res.content_type.startswith('image/'):
207 return res.content
208 raise gws.Error(res.text)
210 def get_features(self, search, source_layers):
211 shape = search.shape
212 if not shape or shape.type != gws.GeometryType.point:
213 return []
215 request_crs = self.forceCrs
216 if not request_crs:
217 request_crs = gws.lib.crs.best_match(
218 shape.crs,
219 gws.gis.source.combined_crs_list(source_layers))
221 box_size_m = 500
222 box_size_deg = 1
223 box_size_px = 500
225 size = None
227 if shape.crs.uom == gws.Uom.m:
228 size = box_size_px * search.resolution
229 if shape.crs.uom == gws.Uom.deg:
230 # @TODO use search.resolution here as well
231 size = box_size_deg
232 if not size:
233 gws.log.debug('cannot request crs {crs!r}, unsupported unit')
234 return []
236 bbox = (
237 shape.x - (size / 2),
238 shape.y - (size / 2),
239 shape.x + (size / 2),
240 shape.y + (size / 2),
241 )
243 bbox = gws.lib.extent.transform(bbox, shape.crs, request_crs)
245 layer_names = [sl.name for sl in source_layers]
247 params = {
248 'BBOX': bbox,
249 'CRS': request_crs.to_string(gws.CrsFormat.epsg),
250 'WIDTH': box_size_px,
251 'HEIGHT': box_size_px,
252 'I': box_size_px >> 1,
253 'J': box_size_px >> 1,
254 'LAYERS': layer_names,
255 'QUERY_LAYERS': layer_names,
256 'STYLES': [''] * len(layer_names),
257 'FEATURE_COUNT': search.limit or 100,
258 'INFO_FORMAT': 'text/xml',
259 'REQUEST': gws.OwsVerb.GetFeatureInfo,
260 'WITH_GEOMETRY': 'true',
261 }
263 if search.extraParams:
264 params = gws.u.merge(params, gws.u.to_upper_dict(search.extraParams))
266 res = self.call_server(params)
268 try:
269 records = gws.base.ows.client.featureinfo.parse(res.text, default_crs=request_crs, always_xy=self.alwaysXY)
270 except gws.Error as exc:
271 gws.log.error(f'get_features: parse error: {exc!r}')
272 return []
274 gws.log.debug(f'get_features: FOUND={len(records)} params={params!r}')
276 for rec in records:
277 if rec.shape:
278 rec.shape = rec.shape.transformed_to(shape.crs)
280 return records
282 ##
284 def leaf_config(self, source_layers):
285 simple_cfg = {
286 'type': 'qgisflat',
287 '_defaultProvider': self,
288 '_defaultSourceLayers': source_layers,
289 }
291 if len(source_layers) > 1 or source_layers[0].isGroup:
292 return simple_cfg
294 ds = source_layers[0].dataSource
295 if not ds or not ds.get('provider'):
296 return simple_cfg
298 render = self._leaf_render_config(ds)
299 search = self._leaf_search_config(ds)
301 cfg = {}
302 cfg.update(render or {})
303 cfg.update(search or {})
305 if not cfg.get('type'):
306 cfg.update(simple_cfg)
308 return cfg
310 def _leaf_render_config(self, ds):
311 prov = ds.get('provider')
312 if prov not in self.directRender:
313 return
315 url = self._leaf_service_url(ds.get('url'), ds.get('params'))
316 if not url:
317 return
319 if prov == 'wms':
320 layers = ds.get('layers')
321 if not layers:
322 return
323 return {
324 'type': 'wmsflat',
325 'sourceLayers': {'names': layers},
326 'display': 'tile',
327 'provider': {'url': url},
328 }
330 if prov == 'wmts':
331 layers = ds.get('layers')
332 if not layers:
333 return
334 cfg = {
335 'type': 'wmts',
336 'sourceLayers': {'names': layers},
337 'display': 'tile',
338 'provider': {'url': url},
339 }
340 p = ds.get('styles')
341 if p:
342 cfg['style'] = p[0]
343 return cfg
345 if prov == 'xyz':
346 return {
347 'type': 'tile',
348 'provider': {'url': url},
349 }
351 def _leaf_search_config(self, ds):
352 prov = ds.get('provider')
353 if prov not in self.directSearch:
354 return
356 if prov == 'wms':
357 url = self._leaf_service_url(ds.get('url'), ds.get('params'))
358 layers = ds.get('layers')
359 if not url or not layers:
360 return
361 finder = {
362 'type': 'wms',
363 'provider': {'url': url},
364 'sourceLayers': {'names': layers},
365 }
366 return {'finders': [finder]}
368 if prov == 'wfs':
369 url = self._leaf_service_url(ds.get('url'), ds.get('params'))
370 if not url:
371 return
372 finder = {
373 'type': 'wfs',
374 'provider': {'url': url},
375 }
376 p = ds.get('typename')
377 if p:
378 finder['sourceLayers'] = {'names': [p]}
379 p = ds.get('srsname')
380 if p:
381 finder['forceCrs'] = p
382 p = ds.get('ignoreaxisorientation')
383 if p == '1':
384 finder['alwaysXY'] = True
385 p = ds.get('invertaxisorientation')
386 if p == '1':
387 # NB assuming this might be only '1' for lat-lon projections
388 finder['alwaysXY'] = True
390 return {'finders': [finder]}
392 if prov == 'postgres':
393 table_name = ds.get('table')
395 # 'table' can also be a select statement, in which case it might be enclosed in parens
396 if not table_name or table_name.startswith('(') or table_name.upper().startswith('SELECT '):
397 return
399 db = self.postgres_provider_from_datasource(ds)
401 model = {
402 'type': 'postgres',
403 'tableName': table_name,
404 'sqlFilter': ds.get('sql'),
405 '_defaultDb': db
406 }
407 finder = {
408 'type': 'postgres',
409 'tableName': table_name,
410 'sqlFilter': ds.get('sql'),
411 '_defaultDb': db
412 }
413 return {'models': [model], 'finders': [finder]}
415 def postgres_provider_from_datasource(self, ds: dict) -> gws.plugin.postgres.provider.Object:
416 cfg = gws.Config(
417 host=ds.get('host'),
418 port=ds.get('port'),
419 database=ds.get('dbname'),
420 username=ds.get('user'),
421 password=ds.get('password'),
422 serviceName=ds.get('service'),
423 options=ds.get('options'),
424 )
425 url = gws.plugin.postgres.provider.connection_url(cfg)
426 mgr = self.root.app.databaseMgr
428 for p in mgr.providers:
429 if p.extType == 'postgres' and p.url() == url:
430 return cast(gws.plugin.postgres.provider.Object, p)
432 gws.log.debug(f'creating an ad-hoc postgres provider for qgis {url=}')
433 p = mgr.create_provider(cfg, type='postgres')
434 return cast(gws.plugin.postgres.provider.Object, p)
436 _std_ows_params = {
437 'bbox',
438 'bgcolor',
439 'crs',
440 'exceptions',
441 'format',
442 'height',
443 'layers',
444 'request',
445 'service',
446 'sld',
447 'sld_body',
448 'srs',
449 'styles',
450 'time',
451 'transparent',
452 'version',
453 'width',
454 }
456 def _leaf_service_url(self, url, params):
457 if not url:
458 return
459 if not params:
460 return url
462 # a wms url can be like "server?service=WMS....&bbox=.... &some-non-std-param=...
463 # we need to keep non-std params for caps requests
465 p = {k: v for k, v in params.items() if k.lower() not in self._std_ows_params}
466 return gws.lib.net.add_params(url, p)