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
« 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."""
3from typing import Optional, cast
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
14from . import core
17class ProjectProps(gws.Data):
18 """Custom project properties as defined by QField."""
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."""
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}``."""
77class LayerProps(gws.Data):
78 """Custom layer properties as defined by QField."""
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."""
118class LayerAction(gws.Enum):
119 """What the packager does with a layer."""
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."""
129class ModelEntry(gws.Data):
130 """A model for an offline table, with its GeoPackage layer name."""
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."""
140class LayerEntry(gws.Data):
141 """QField-related information about a QGIS layer."""
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."""
167class Caps(gws.Data):
168 """QField related capabilities extracted from the QGIS project and GWS config."""
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."""
190class Parser:
191 """Reads QField-related capabilities from a QGIS project.
193 ``parse`` reads the project and layer properties. ``create_models`` and
194 ``assign_path_props`` complete the layer entries and are called separately.
195 """
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."""
204 def __init__(self, qfc_project: core.QfcProject):
205 """Create a parser.
207 Args:
208 qfc_project: QField project to parse.
209 """
210 self.qfcProject = qfc_project
212 def parse(self) -> Caps:
213 """Parse the QGIS project.
215 Reads the project properties, the area of interest, the directories to copy, the base map layers and the layer entries.
217 Returns:
218 Capabilities, also stored in ``self.caps``.
219 """
220 qp = self.qfcProject.qgisProvider.qgis_project()
221 self.qgisCaps = qp.caps()
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 )
235 if self.qfcProject.qgisProvider.store.type == gws.plugin.qgis.project.StoreType.file:
236 self.caps.qgisPath = self.qfcProject.qgisProvider.store.path
238 self.parse_area_of_interest()
239 self.parse_copy_dirs()
240 self.parse_base_map()
242 self.iter_layers()
244 return self.caps
246 ##
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
258 def parse_copy_dirs(self):
259 """Set the directories to copy into the package.
261 Relative paths are resolved against the QGIS project file. Nested directories are dropped.
262 """
263 raw_dirs = []
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)
271 # attachmentDirs and dataDirs are lists
272 raw_dirs.extend(self.caps.projectProps.attachmentDirs or [])
273 raw_dirs.extend(self.caps.projectProps.dataDirs or [])
275 abs_dirs = []
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)
285 unnest_dirs = []
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)
292 self.caps.copyDirs = unnest_dirs
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
299 bt = self.caps.projectProps.baseMapType
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
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
314 if bt == 'singleLayer':
315 uid = self.caps.projectProps.baseMapLayer
316 if uid:
317 self.caps.baseMapLayerIds = [uid]
319 ##
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
328 def layer_entry(self, sl: gws.SourceLayer) -> Optional[LayerEntry]:
329 """Create a layer entry for a source layer.
331 Args:
332 sl: Source layer.
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
343 return le
345 def layer_entry_2(self, sl: gws.SourceLayer) -> Optional[LayerEntry]:
346 """Determine the action for a source layer.
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``.
351 Args:
352 sl: Source layer.
354 Returns:
355 Layer entry without the id and source layer, or ``None`` for other layers.
356 """
357 props = self.extract_layer_props(sl)
359 if sl.sourceId in self.caps.baseMapLayerIds:
360 return LayerEntry(action=LayerAction.baseMap, props=props)
362 # 'offline', 'no_action' or 'remove'
363 # for "cable", use "action" instead of "cloud_action"
364 act = props.cloud_action
366 if act == 'remove':
367 return LayerEntry(action=LayerAction.remove, props=props)
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)
377 def postgres_layer_entry(self, sl, props: LayerProps) -> LayerEntry:
378 """Create a layer entry for an offline Postgres layer.
380 Layers without a plain table name (e.g. SQL queries) get the ``remove`` action.
382 Args:
383 sl: Source layer.
384 props: QFieldSync layer properties.
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)
394 return LayerEntry(
395 action=LayerAction.edit,
396 readOnly=sl.isReadOnly is True,
397 sqlFilter=sl.dataSource.get('sql', ''),
398 props=props,
399 )
401 ##
403 def extract_project_props(self) -> ProjectProps:
404 """Read the QFieldSync project properties.
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', {}))
414 t = ProjectProps()
415 _dict_to_data(d, t)
416 return t
418 def extract_layer_props(self, sl: gws.SourceLayer) -> LayerProps:
419 """Read the QFieldSync properties of a layer.
421 Args:
422 sl: Source layer.
424 Returns:
425 Layer properties.
426 """
427 d = {}
429 for k, v in sl.properties.items():
430 if k.startswith('QFieldSync/'):
431 d[k.split('/').pop()] = v
433 t = LayerProps()
434 _dict_to_data(d, t)
435 return t
437 ##
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'
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'
456 def create_models(self):
457 """Create model entries for all ``edit`` layers."""
458 self.caps.modelMap = {}
460 for le in self.caps.layerMap.values():
461 self.create_model_entry_for_layer(le)
463 def create_model_entry_for_layer(self, le: LayerEntry):
464 """Assign a model entry to an ``edit`` layer.
466 If no model is found, the layer gets the ``remove`` action.
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
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.
483 A configured model with the same table name is used if present.
484 Otherwise a generic Postgres model is created for the table.
486 Args:
487 sl: Source layer.
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
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]
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
509 gp_name = self.gp_name_for_model(*db.split_table_name(table_name))
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))
526 return self.caps.modelMap[gp_name]
528 def gp_name_for_model(self, schema, name):
529 """Return the GeoPackage name for a table.
531 Args:
532 schema: Schema name.
533 name: Table name.
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
546##
549def is_locked(props: LayerProps, name: str) -> bool:
550 """Check if an operation is locked in the QGIS project.
552 A lock controlled by an expression is evaluated by QField only and is not considered locked.
554 Args:
555 props: QFieldSync layer properties.
556 name: QField lock name: ``feature_addition``, ``attribute_editing``, ``geometry_editing`` or ``feature_deletion``.
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
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