Coverage for gws-app/gws/plugin/qfieldcloud/caps.py: 99%

276 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-23 20:06 +0200

1"""Parser for QField-related capabilities of a QGIS project.""" 

2 

3from typing import Optional, cast 

4 

5import gws 

6import gws.lib.shape 

7import gws.gis.source 

8import gws.lib.datetimex as dtx 

9import gws.lib.jsonx 

10import gws.lib.osx 

11import gws.lib.crs 

12import gws.plugin.qgis.caps 

13 

14from . import core 

15 

16 

17class ProjectProps(gws.Data): 

18 """Custom project properties as defined by QField.""" 

19 

20 areaOfInterest: str 

21 """Area of interest as WKT.""" 

22 areaOfInterestCrs: str 

23 """CRS of the area of interest.""" 

24 baseMapLayer: str 

25 """Id of the layer used as the base map, if ``baseMapType`` is ``singleLayer``.""" 

26 baseMapTheme: str 

27 """Map theme used as the base map, if ``baseMapType`` is ``mapTheme``.""" 

28 baseMapTileSize: int 

29 """Base map tile size.""" 

30 baseMapTilesMaxZoomLevel: int 

31 """Maximum zoom level of the base map.""" 

32 baseMapTilesMinZoomLevel: int 

33 """Minimum zoom level of the base map.""" 

34 baseMapType: str 

35 """Base map type, ``mapTheme`` or ``singleLayer``.""" 

36 createBaseMap: bool 

37 """Whether a base map is created.""" 

38 digitizingLogsLayer: str 

39 """Id of the digitizing logs layer.""" 

40 forceAutoPush: bool 

41 """Whether changes are pushed automatically.""" 

42 forceAutoPushIntervalMins: int 

43 """Automatic push interval in minutes.""" 

44 forceStamping: bool 

45 """Whether photos are stamped.""" 

46 geofencingBehavior: int 

47 """Geofencing behavior.""" 

48 geofencingIsActive: bool 

49 """Whether geofencing is active.""" 

50 geofencingLayer: str 

51 """Id of the geofencing layer.""" 

52 geofencingShouldPreventDigitizing: bool 

53 """Whether geofencing prevents digitizing.""" 

54 mapThemesActiveLayers: dict 

55 """Active layers of map themes.""" 

56 maximumImageWidthHeight: int 

57 """Maximum width and height of images.""" 

58 offlineCopyOnlyAoi: bool 

59 """Whether only features in the area of interest are packaged.""" 

60 stampingDetailsTemplate: str 

61 """Template for photo stamping.""" 

62 stampingFontStyle: str 

63 """Font style for photo stamping.""" 

64 stampingHorizontalAlignment: int 

65 """Horizontal alignment for photo stamping.""" 

66 stampingImageDecoration: str 

67 """Image decoration for photo stamping.""" 

68 

69 attachmentDirs: list[str] 

70 """Attachment directories.""" 

71 dataDirs: list[str] 

72 """Data directories.""" 

73 dirsToCopy: dict 

74 """Directories to copy, as a dict ``{dirname: bool}``.""" 

75 

76 

77class LayerProps(gws.Data): 

78 """Custom layer properties as defined by QField.""" 

79 

80 action: str 

81 """QFieldSync layer action.""" 

82 attachment_naming: dict 

83 """Naming rules for attachments.""" 

84 cloud_action: str 

85 """Cloud action: ``offline``, ``no_action`` or ``remove``.""" 

86 is_feature_addition_locked: bool 

87 """Whether feature addition is locked.""" 

88 is_feature_addition_locked_expression_active: bool 

89 """Whether the feature addition lock is controlled by an expression.""" 

90 is_attribute_editing_locked: bool 

91 """Whether attribute editing is locked.""" 

92 is_attribute_editing_locked_expression_active: bool 

93 """Whether the attribute editing lock is controlled by an expression.""" 

94 is_geometry_editing_locked: bool 

95 """Whether geometry editing is locked.""" 

96 is_geometry_editing_locked_expression_active: bool 

97 """Whether the geometry editing lock is controlled by an expression.""" 

98 is_feature_deletion_locked: bool 

99 """Whether feature deletion is locked.""" 

100 is_feature_deletion_locked_expression_active: bool 

101 """Whether the feature deletion lock is controlled by an expression.""" 

102 photo_naming: dict 

103 """Naming rules for photos.""" 

104 relationship_maximum_visible: dict 

105 """Maximum number of visible related features.""" 

106 tracking_distance_requirement_minimum_meters: int 

107 """Minimum distance between tracked positions, in meters.""" 

108 tracking_erroneous_distance_safeguard_maximum_meters: int 

109 """Maximum distance of a tracked position before it is considered erroneous, in meters.""" 

110 tracking_measurement_type: int 

111 """Tracking measurement type.""" 

112 tracking_time_requirement_interval_seconds: int 

113 """Minimum time between tracked positions, in seconds.""" 

114 value_map_button_interface_threshold: int 

115 """Threshold for showing value maps as buttons.""" 

116 

117 

118class LayerAction(gws.Enum): 

119 """What the packager does with a layer.""" 

120 

121 remove = 'remove' 

122 """Remove the layer from the project.""" 

123 edit = 'edit' 

124 """Package the layer data for offline editing.""" 

125 baseMap = 'baseMap' 

126 """Render the layer into the base map.""" 

127 

128 

129class ModelEntry(gws.Data): 

130 """A model for an offline table, with its GeoPackage layer name.""" 

131 

132 gpName: str 

133 """Name of the GeoPackage file and layer.""" 

134 tableName: str 

135 """Database table name.""" 

136 model: gws.DatabaseModel 

137 """Model for the table.""" 

138 

139 

140class LayerEntry(gws.Data): 

141 """QField-related information about a QGIS layer.""" 

142 

143 action: LayerAction 

144 """What to do with the layer.""" 

145 qgisId: str 

146 """Layer id in the QGIS project.""" 

147 modelEntry: ModelEntry 

148 """Model entry, for ``edit`` layers.""" 

149 readOnly: bool 

150 """Whether the layer is read-only, in the QGIS project or, for a user, in the packaged project.""" 

151 setProps: dict 

152 """Custom properties to set in the packaged project, ``None`` values remove the property.""" 

153 sqlFilter: str 

154 """SQL filter (subset string) of the layer.""" 

155 dataSourceFileName: str 

156 """Name of the packaged data file.""" 

157 dataSource: str 

158 """Data source string in the packaged QGIS project.""" 

159 dataProvider: str 

160 """Data provider in the packaged QGIS project.""" 

161 sourceLayer: gws.SourceLayer 

162 """Source layer from the QGIS project.""" 

163 props: LayerProps 

164 """QFieldSync layer properties.""" 

165 

166 

167class Caps(gws.Data): 

168 """QField related capabilities extracted from the QGIS project and GWS config.""" 

169 

170 sourceHash: str 

171 """Hash of the QGIS project source, used to invalidate cached caps.""" 

172 qgisPath: str 

173 """Path to the QGIS project file, empty if the project is not stored in a file.""" 

174 layerMap: dict[str, LayerEntry] 

175 """Layer entries by QGIS layer id.""" 

176 modelMap: dict[str, ModelEntry] 

177 """Model entries by GeoPackage name.""" 

178 copyDirs: list[str] 

179 """Absolute paths of directories to copy into the package.""" 

180 baseMapLayerIds: list[str] 

181 """Ids of the layers rendered into the base map.""" 

182 areaOfInterest: Optional[gws.Bounds] 

183 """Area of interest.""" 

184 copyOnlyAreaOfInterest: bool 

185 """Whether only features in the area of interest are packaged.""" 

186 projectProps: ProjectProps 

187 """QFieldSync project properties.""" 

188 

189 

190class Parser: 

191 """Reads QField-related capabilities from a QGIS project. 

192 

193 ``parse`` reads the project and layer properties. ``create_models`` and 

194 ``assign_path_props`` complete the layer entries and are called separately. 

195 """ 

196 

197 qfcProject: core.QfcProject 

198 """QField project.""" 

199 caps: Caps 

200 """Capabilities being built.""" 

201 qgisCaps: gws.plugin.qgis.caps.Caps 

202 """Capabilities of the QGIS project.""" 

203 

204 def __init__(self, qfc_project: core.QfcProject): 

205 """Create a parser. 

206 

207 Args: 

208 qfc_project: QField project to parse. 

209 """ 

210 self.qfcProject = qfc_project 

211 

212 def parse(self) -> Caps: 

213 """Parse the QGIS project. 

214 

215 Reads the project properties, the area of interest, the directories to copy, the base map layers and the layer entries. 

216 

217 Returns: 

218 Capabilities, also stored in ``self.caps``. 

219 """ 

220 qp = self.qfcProject.qgisProvider.qgis_project() 

221 self.qgisCaps = qp.caps() 

222 

223 self.caps = Caps( 

224 qgisPath='', 

225 sourceHash=qp.sourceHash, 

226 layerMap={}, 

227 modelMap={}, 

228 copyDirs=[], 

229 baseMapLayerIds=[], 

230 areaOfInterest=None, 

231 copyOnlyAreaOfInterest=False, 

232 projectProps=self.extract_project_props(), 

233 ) 

234 

235 if self.qfcProject.qgisProvider.store.type == gws.plugin.qgis.project.StoreType.file: 

236 self.caps.qgisPath = self.qfcProject.qgisProvider.store.path 

237 

238 self.parse_area_of_interest() 

239 self.parse_copy_dirs() 

240 self.parse_base_map() 

241 

242 self.iter_layers() 

243 

244 return self.caps 

245 

246 ## 

247 

248 def parse_area_of_interest(self): 

249 """Set the area of interest from the project properties.""" 

250 aoi = self.caps.projectProps.areaOfInterest 

251 if not aoi: 

252 return 

253 crs = self.caps.projectProps.areaOfInterestCrs 

254 shape = gws.lib.shape.from_wkt(aoi, gws.lib.crs.get(crs) or self.qgisCaps.projectCrs) 

255 self.caps.areaOfInterest = shape.bounds() 

256 self.caps.copyOnlyAreaOfInterest = self.caps.projectProps.offlineCopyOnlyAoi is True 

257 

258 def parse_copy_dirs(self): 

259 """Set the directories to copy into the package. 

260 

261 Relative paths are resolved against the QGIS project file. Nested directories are dropped. 

262 """ 

263 raw_dirs = [] 

264 

265 # dirsToCopy is a dict (dirname: bool) 

266 dc = self.caps.projectProps.dirsToCopy or {} 

267 for k, v in dc.items(): 

268 if v: 

269 raw_dirs.append(k) 

270 

271 # attachmentDirs and dataDirs are lists 

272 raw_dirs.extend(self.caps.projectProps.attachmentDirs or []) 

273 raw_dirs.extend(self.caps.projectProps.dataDirs or []) 

274 

275 abs_dirs = [] 

276 

277 for p in raw_dirs: 

278 if not p.startswith('/'): 

279 if not self.caps.qgisPath: 

280 gws.log.warning(f'cannot determine an absolute path for {p!r}') 

281 continue 

282 p = gws.lib.osx.abs_path(p, self.caps.qgisPath) 

283 abs_dirs.append(p) 

284 

285 unnest_dirs = [] 

286 

287 for p in sorted(abs_dirs): 

288 if any(p.startswith(d) for d in unnest_dirs): 

289 continue 

290 unnest_dirs.append(p) 

291 

292 self.caps.copyDirs = unnest_dirs 

293 

294 def parse_base_map(self): 

295 """Set the base map layer ids from the map theme or the single base map layer.""" 

296 if not self.caps.projectProps.createBaseMap: 

297 return 

298 

299 bt = self.caps.projectProps.baseMapType 

300 

301 if bt == 'mapTheme': 

302 theme = self.caps.projectProps.baseMapTheme 

303 if not theme: 

304 gws.log.warning(f'map theme not defined') 

305 return 

306 

307 vp = self.qgisCaps.visibilityPresets.get(theme) 

308 if not vp: 

309 gws.log.warning(f'map theme {theme!r} not found') 

310 return 

311 self.caps.baseMapLayerIds = vp 

312 return 

313 

314 if bt == 'singleLayer': 

315 uid = self.caps.projectProps.baseMapLayer 

316 if uid: 

317 self.caps.baseMapLayerIds = [uid] 

318 

319 ## 

320 

321 def iter_layers(self): 

322 """Create layer entries for all non-group source layers.""" 

323 for sl in gws.gis.source.filter_layers(self.qgisCaps.sourceLayers, is_group=False): 

324 le = self.layer_entry(sl) 

325 if le: 

326 self.caps.layerMap[le.qgisId] = le 

327 

328 def layer_entry(self, sl: gws.SourceLayer) -> Optional[LayerEntry]: 

329 """Create a layer entry for a source layer. 

330 

331 Args: 

332 sl: Source layer. 

333 

334 Returns: 

335 Layer entry, or ``None`` if the layer is left unchanged in the package. 

336 """ 

337 le = self.layer_entry_2(sl) 

338 if not le: 

339 return 

340 le.qgisId = sl.sourceId 

341 le.sourceLayer = sl 

342 

343 return le 

344 

345 def layer_entry_2(self, sl: gws.SourceLayer) -> Optional[LayerEntry]: 

346 """Determine the action for a source layer. 

347 

348 Base map layers get ``baseMap``. Layers with the cloud action ``remove`` get ``remove``. 

349 Postgres layers with the cloud action ``offline`` get ``edit``, offline layers of other providers get ``remove``. 

350 

351 Args: 

352 sl: Source layer. 

353 

354 Returns: 

355 Layer entry without the id and source layer, or ``None`` for other layers. 

356 """ 

357 props = self.extract_layer_props(sl) 

358 

359 if sl.sourceId in self.caps.baseMapLayerIds: 

360 return LayerEntry(action=LayerAction.baseMap, props=props) 

361 

362 # 'offline', 'no_action' or 'remove' 

363 # for "cable", use "action" instead of "cloud_action" 

364 act = props.cloud_action 

365 

366 if act == 'remove': 

367 return LayerEntry(action=LayerAction.remove, props=props) 

368 

369 if act == 'offline': 

370 prov = sl.dataSource.get('provider') 

371 if prov == 'postgres': 

372 return self.postgres_layer_entry(sl, props) 

373 # @TODO support offline for other providers? 

374 gws.log.warning(f'layer {sl.sourceId!r}: offline editing of {prov!r} not supported') 

375 return LayerEntry(action=LayerAction.remove, props=props) 

376 

377 def postgres_layer_entry(self, sl, props: LayerProps) -> LayerEntry: 

378 """Create a layer entry for an offline Postgres layer. 

379 

380 Layers without a plain table name (e.g. SQL queries) get the ``remove`` action. 

381 

382 Args: 

383 sl: Source layer. 

384 props: QFieldSync layer properties. 

385 

386 Returns: 

387 Layer entry. 

388 """ 

389 table_name = sl.dataSource.get('table') 

390 if not table_name or table_name.startswith('(') or table_name.upper().startswith('SELECT '): 

391 gws.log.warning(f'layer {sl.sourceId!r}: no table name') 

392 return LayerEntry(action=LayerAction.remove, props=props) 

393 

394 return LayerEntry( 

395 action=LayerAction.edit, 

396 readOnly=sl.isReadOnly is True, 

397 sqlFilter=sl.dataSource.get('sql', ''), 

398 props=props, 

399 ) 

400 

401 ## 

402 

403 def extract_project_props(self) -> ProjectProps: 

404 """Read the QFieldSync project properties. 

405 

406 Returns: 

407 Project properties. 

408 """ 

409 d = {} 

410 # there are two of them, QFieldSync and libqfieldsync 

411 d.update(self.qgisCaps.properties.get('qfieldsync', {})) 

412 d.update(self.qgisCaps.properties.get('QFieldSync', {})) 

413 

414 t = ProjectProps() 

415 _dict_to_data(d, t) 

416 return t 

417 

418 def extract_layer_props(self, sl: gws.SourceLayer) -> LayerProps: 

419 """Read the QFieldSync properties of a layer. 

420 

421 Args: 

422 sl: Source layer. 

423 

424 Returns: 

425 Layer properties. 

426 """ 

427 d = {} 

428 

429 for k, v in sl.properties.items(): 

430 if k.startswith('QFieldSync/'): 

431 d[k.split('/').pop()] = v 

432 

433 t = LayerProps() 

434 _dict_to_data(d, t) 

435 return t 

436 

437 ## 

438 

439 def assign_path_props(self): 

440 """Set the data file name, data source and provider of ``edit`` and ``baseMap`` layers in the package.""" 

441 for le in self.caps.layerMap.values(): 

442 if le.action == LayerAction.edit: 

443 name = le.modelEntry.gpName 

444 le.dataSourceFileName = f'{name}.gpkg' 

445 le.dataSource = f'./{le.dataSourceFileName}|layername={name}' 

446 if le.sqlFilter: 

447 le.dataSource += f'|subset={le.sqlFilter}' 

448 le.dataProvider = 'ogr' 

449 

450 if le.action == LayerAction.baseMap: 

451 u = gws.u.to_uid(le.qgisId) 

452 le.dataSourceFileName = f'{u}.gpkg' 

453 le.dataSource = f'./{le.dataSourceFileName}' 

454 le.dataProvider = 'gdal' 

455 

456 def create_models(self): 

457 """Create model entries for all ``edit`` layers.""" 

458 self.caps.modelMap = {} 

459 

460 for le in self.caps.layerMap.values(): 

461 self.create_model_entry_for_layer(le) 

462 

463 def create_model_entry_for_layer(self, le: LayerEntry): 

464 """Assign a model entry to an ``edit`` layer. 

465 

466 If no model is found, the layer gets the ``remove`` action. 

467 

468 Args: 

469 le: Layer entry. 

470 """ 

471 if le.action != LayerAction.edit: 

472 return 

473 me = self.model_entry_for_source_layer(le.sourceLayer) 

474 if not me: 

475 gws.log.warning(f'layer {le.qgisId!r}: no model') 

476 le.action = LayerAction.remove 

477 return 

478 le.modelEntry = me 

479 

480 def model_entry_for_source_layer(self, sl: gws.SourceLayer) -> Optional[ModelEntry]: 

481 """Find or create a model entry for the table of a source layer. 

482 

483 A configured model with the same table name is used if present. 

484 Otherwise a generic Postgres model is created for the table. 

485 

486 Args: 

487 sl: Source layer. 

488 

489 Returns: 

490 Model entry, or ``None`` if the layer has no table or the table does not exist. 

491 """ 

492 table_name = sl.dataSource.get('table') 

493 if not table_name: 

494 return 

495 

496 for model in self.qfcProject.models: 

497 full_name = model.db.join_table_name('', model.tableName) 

498 if full_name == model.db.join_table_name('', table_name): 

499 gp_name = self.gp_name_for_model(*model.db.split_table_name(full_name)) 

500 if gp_name not in self.caps.modelMap: 

501 self.caps.modelMap[gp_name] = ModelEntry(gpName=gp_name, tableName=full_name, model=model) 

502 return self.caps.modelMap[gp_name] 

503 

504 db = self.qfcProject.qgisProvider.postgres_provider_from_datasource(sl.dataSource) 

505 if not db.has_table(table_name): 

506 gws.log.warning(f'layer {sl.sourceId!r}: table {table_name!r} not found') 

507 return 

508 

509 gp_name = self.gp_name_for_model(*db.split_table_name(table_name)) 

510 

511 if gp_name not in self.caps.modelMap: 

512 model = self.qfcProject.root.create_shared( 

513 gws.ext.object.model, 

514 gws.Config( 

515 uid=f'qfield_model_{table_name}', 

516 type='postgres', 

517 # NB: permissions are checked in the public export/import functions above 

518 permissions=gws.Config(read=gws.c.PUBLIC, edit=gws.c.PUBLIC), 

519 tableName=table_name, 

520 isEditable=True, 

521 _defaultDb=db, 

522 ), 

523 ) 

524 self.caps.modelMap[gp_name] = ModelEntry(gpName=gp_name, tableName=table_name, model=cast(gws.DatabaseModel, model)) 

525 

526 return self.caps.modelMap[gp_name] 

527 

528 def gp_name_for_model(self, schema, name): 

529 """Return the GeoPackage name for a table. 

530 

531 Args: 

532 schema: Schema name. 

533 name: Table name. 

534 

535 Returns: 

536 Name in the form ``qm_<schema>_<table>``, converted with ``gws.u.to_uid``. 

537 If the conversion changes the names, a short hash of the original names is appended. 

538 """ 

539 raw = f'{schema}_{name}' 

540 uid = gws.u.to_uid(schema) + '_' + gws.u.to_uid(name) 

541 if uid != raw: 

542 uid += '_' + gws.u.sha256([schema, name])[:8] 

543 return 'qm_' + uid 

544 

545 

546## 

547 

548 

549def is_locked(props: LayerProps, name: str) -> bool: 

550 """Check if an operation is locked in the QGIS project. 

551 

552 A lock controlled by an expression is evaluated by QField only and is not considered locked. 

553 

554 Args: 

555 props: QFieldSync layer properties. 

556 name: QField lock name: ``feature_addition``, ``attribute_editing``, ``geometry_editing`` or ``feature_deletion``. 

557 

558 Returns: 

559 ``True`` if the operation is locked. 

560 """ 

561 return props.get(f'is_{name}_locked') is True and props.get(f'is_{name}_locked_expression_active') is not True 

562 

563 

564def _dict_to_data(d: dict, t: gws.Data): 

565 """Copy dict values into a Data object, converting them to the annotated types.""" 

566 for k, typ in t.__class__.__annotations__.items(): 

567 v = d.get(k) 

568 if v is None: 

569 continue 

570 try: 

571 if typ is bool: 

572 # QFieldSync writes flags both as `int` (1/0) and as `bool` (true/false) 

573 v = v is True or str(v).lower() in ('1', 'true') 

574 elif typ is int: 

575 v = int(v) 

576 elif typ is float: 

577 v = float(v) 

578 elif typ is dict: 

579 v = gws.lib.jsonx.from_string(v) 

580 except Exception as exc: 

581 gws.log.warning(f'invalid property value {k!r}={v!r}: {exc}') 

582 continue 

583 setattr(t, k, v) 

584 return t