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

1"""QGIS provider.""" 

2 

3from typing import Optional, cast 

4 

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 

19 

20from . import caps as caps_module, project 

21 

22 

23class Config(gws.Config): 

24 """QGIS provider configuration.""" 

25 

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.""" 

50 

51 

52class Object(gws.OwsProvider): 

53 store: project.Store 

54 printTemplates: list[caps_module.PrintTemplate] 

55 

56 directRender: set[str] 

57 directSearch: set[str] 

58 

59 defaultLegendOptions: dict 

60 

61 caps: caps_module.Caps 

62 sourceHash: str 

63 

64 def configure(self): 

65 self.configure_store() 

66 

67 self.url = 'http://{}:{}'.format( 

68 self.root.app.cfg('server.qgis.host'), 

69 self.root.app.cfg('server.qgis.port')) 

70 

71 self.caps = self.qgis_project().caps() 

72 

73 self.metadata = self.caps.metadata 

74 self.printTemplates = self.caps.printTemplates 

75 self.sourceLayers = self.caps.sourceLayers 

76 self.version = self.caps.version 

77 

78 self.forceCrs = gws.lib.crs.get(self.cfg('forceCrs')) or self.caps.projectCrs 

79 self.alwaysXY = False 

80 

81 self.bounds = self._project_bounds() 

82 self.wgsExtent = gws.lib.bounds.wgs_extent(self.bounds) 

83 

84 self.directRender = self._direct_formats('directRender', {'wms', 'wmts', 'xyz'}) 

85 self.directSearch = self._direct_formats('directSearch', {'wms', 'wfs', 'postgres'}) 

86 

87 self.defaultLegendOptions = self.cfg('defaultLegendOptions', default={}) 

88 

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}') 

94 

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) 

101 

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) 

106 

107 # canvas extent? 

108 if self.cfg('useCanvasExtent') and self.caps.projectCanvasBounds: 

109 return gws.lib.bounds.transform(self.caps.projectCanvasBounds, self.forceCrs) 

110 

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) 

115 

116 return self.forceCrs.bounds 

117 

118 def _direct_formats(self, opt, allowed): 

119 p = self.cfg(opt) 

120 if not p: 

121 return set() 

122 

123 res = set() 

124 

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) 

130 

131 return res 

132 

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)') 

154 

155 ## 

156 

157 def qgis_project(self) -> project.Object: 

158 return project.from_store(self.root, self.store) 

159 

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) 

170 

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)) 

178 

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 

184 

185 ## 

186 

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) 

191 

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 ) 

202 

203 params = gws.u.merge(defaults, params) 

204 

205 res = self.call_server(params) 

206 if res.content_type.startswith('image/'): 

207 return res.content 

208 raise gws.Error(res.text) 

209 

210 def get_features(self, search, source_layers): 

211 shape = search.shape 

212 if not shape or shape.type != gws.GeometryType.point: 

213 return [] 

214 

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)) 

220 

221 box_size_m = 500 

222 box_size_deg = 1 

223 box_size_px = 500 

224 

225 size = None 

226 

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 [] 

235 

236 bbox = ( 

237 shape.x - (size / 2), 

238 shape.y - (size / 2), 

239 shape.x + (size / 2), 

240 shape.y + (size / 2), 

241 ) 

242 

243 bbox = gws.lib.extent.transform(bbox, shape.crs, request_crs) 

244 

245 layer_names = [sl.name for sl in source_layers] 

246 

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 } 

262 

263 if search.extraParams: 

264 params = gws.u.merge(params, gws.u.to_upper_dict(search.extraParams)) 

265 

266 res = self.call_server(params) 

267 

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 [] 

273 

274 gws.log.debug(f'get_features: FOUND={len(records)} params={params!r}') 

275 

276 for rec in records: 

277 if rec.shape: 

278 rec.shape = rec.shape.transformed_to(shape.crs) 

279 

280 return records 

281 

282 ## 

283 

284 def leaf_config(self, source_layers): 

285 simple_cfg = { 

286 'type': 'qgisflat', 

287 '_defaultProvider': self, 

288 '_defaultSourceLayers': source_layers, 

289 } 

290 

291 if len(source_layers) > 1 or source_layers[0].isGroup: 

292 return simple_cfg 

293 

294 ds = source_layers[0].dataSource 

295 if not ds or not ds.get('provider'): 

296 return simple_cfg 

297 

298 render = self._leaf_render_config(ds) 

299 search = self._leaf_search_config(ds) 

300 

301 cfg = {} 

302 cfg.update(render or {}) 

303 cfg.update(search or {}) 

304 

305 if not cfg.get('type'): 

306 cfg.update(simple_cfg) 

307 

308 return cfg 

309 

310 def _leaf_render_config(self, ds): 

311 prov = ds.get('provider') 

312 if prov not in self.directRender: 

313 return 

314 

315 url = self._leaf_service_url(ds.get('url'), ds.get('params')) 

316 if not url: 

317 return 

318 

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 } 

329 

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 

344 

345 if prov == 'xyz': 

346 return { 

347 'type': 'tile', 

348 'provider': {'url': url}, 

349 } 

350 

351 def _leaf_search_config(self, ds): 

352 prov = ds.get('provider') 

353 if prov not in self.directSearch: 

354 return 

355 

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]} 

367 

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 

389 

390 return {'finders': [finder]} 

391 

392 if prov == 'postgres': 

393 table_name = ds.get('table') 

394 

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 

398 

399 db = self.postgres_provider_from_datasource(ds) 

400 

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]} 

414 

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 

427 

428 for p in mgr.providers: 

429 if p.extType == 'postgres' and p.url() == url: 

430 return cast(gws.plugin.postgres.provider.Object, p) 

431 

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) 

435 

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 } 

455 

456 def _leaf_service_url(self, url, params): 

457 if not url: 

458 return 

459 if not params: 

460 return url 

461 

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 

464 

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)