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

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

19 

20from . import caps, project 

21 

22 

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

46 

47 

48class Object(gws.OwsProvider): 

49 store: project.Store 

50 printTemplates: list[caps.PrintTemplate] 

51 

52 directRender: set[str] 

53 directSearch: set[str] 

54 

55 defaultLegendOptions: dict 

56 

57 caps: caps.Caps 

58 

59 def configure(self): 

60 self.configure_store() 

61 # if self.store.path: 

62 # self.root.app.monitor.add_file(self.store.path) 

63 

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

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

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

67 

68 self.caps = self.qgis_project().caps() 

69 

70 self.metadata = self.caps.metadata 

71 self.printTemplates = self.caps.printTemplates 

72 self.sourceLayers = self.caps.sourceLayers 

73 self.version = self.caps.version 

74 

75 self.forceCrs = gws.gis.crs.get(self.cfg('forceCrs')) or self.caps.projectCrs 

76 self.alwaysXY = False 

77 

78 self.bounds = self._project_bounds() 

79 self.wgsExtent = gws.gis.bounds.wgs_extent(self.bounds) 

80 

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

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

83 

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

85 

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) 

90 

91 # canvas extent? 

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

93 return gws.gis.bounds.transform(self.caps.projectCanvasBounds, self.forceCrs) 

94 

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) 

99 

100 return self.forceCrs.bounds 

101 

102 def _direct_formats(self, opt, allowed): 

103 p = self.cfg(opt) 

104 if not p: 

105 return set() 

106 

107 res = set() 

108 

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) 

114 

115 return res 

116 

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

138 

139 ## 

140 

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

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

143 

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) 

150 

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

158 

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 

164 

165 ## 

166 

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) 

171 

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 ) 

182 

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

184 

185 res = self.call_server(params) 

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

187 return res.content 

188 raise gws.Error(res.text) 

189 

190 def get_features(self, search, source_layers): 

191 shape = search.shape 

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

193 return [] 

194 

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

200 

201 box_size_m = 500 

202 box_size_deg = 1 

203 box_size_px = 500 

204 

205 size = None 

206 

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

215 

216 bbox = ( 

217 shape.x - (size / 2), 

218 shape.y - (size / 2), 

219 shape.x + (size / 2), 

220 shape.y + (size / 2), 

221 ) 

222 

223 bbox = gws.gis.extent.transform(bbox, shape.crs, request_crs) 

224 

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

226 

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 } 

242 

243 if search.extraParams: 

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

245 

246 res = self.call_server(params) 

247 

248 fdata = gws.base.ows.client.featureinfo.parse(res.text, default_crs=request_crs, always_xy=self.alwaysXY) 

249 

250 if fdata is None: 

251 gws.log.debug(f'get_features: NOT_PARSED params={params!r}') 

252 return [] 

253 

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

255 

256 for fd in fdata: 

257 if fd.shape: 

258 fd.shape = fd.shape.transformed_to(shape.crs) 

259 

260 return fdata 

261 

262 ## 

263 

264 def leaf_config(self, source_layers): 

265 simple_cfg = { 

266 'type': 'qgisflat', 

267 '_defaultProvider': self, 

268 '_defaultSourceLayers': source_layers, 

269 } 

270 

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

272 return simple_cfg 

273 

274 ds = source_layers[0].dataSource 

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

276 return simple_cfg 

277 

278 render = self._leaf_render_config(ds) 

279 search = self._leaf_search_config(ds) 

280 

281 cfg = {} 

282 cfg.update(render or {}) 

283 cfg.update(search or {}) 

284 

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

286 cfg.update(simple_cfg) 

287 

288 return cfg 

289 

290 def _leaf_render_config(self, ds): 

291 prov = ds.get('provider') 

292 if prov not in self.directRender: 

293 return 

294 

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

296 if not url: 

297 return 

298 

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 } 

309 

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 

324 

325 if prov == 'xyz': 

326 return { 

327 'type': 'tile', 

328 'provider': {'url': url}, 

329 } 

330 

331 def _leaf_search_config(self, ds): 

332 prov = ds.get('provider') 

333 if prov not in self.directSearch: 

334 return 

335 

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

347 

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 

369 

370 return {'finders': [finder]} 

371 

372 if prov == 'postgres': 

373 table_name = ds.get('table') 

374 

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 

378 

379 db = self.postgres_provider_from_datasource(ds) 

380 

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

394 

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 

407 

408 for p in mgr.providers: 

409 if p.extType == 'postgres' and p.url == url: 

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

411 

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) 

415 

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 } 

435 

436 def _leaf_service_url(self, url, params): 

437 if not url: 

438 return 

439 if not params: 

440 return url 

441 

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 

444 

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)