Coverage for gws-app/gws/plugin/qfieldcloud/core.py: 100%

65 statements  

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

1"""QField project configuration and object.""" 

2 

3from typing import Optional 

4import gws 

5import gws.plugin.qgis.provider 

6import gws.plugin.model_field.file as file_field 

7 

8from . import api 

9 

10 

11class DeltaApplyError(gws.Error): 

12 """Raised when a delta payload cannot be applied.""" 

13 

14 

15class DeltaStatus(gws.Data): 

16 """Status of a delta, as returned to QField.""" 

17 

18 id: str 

19 """Delta id.""" 

20 deltafile_id: str 

21 """Id of the delta payload.""" 

22 created_by: str 

23 """Login name of the user who sent the delta.""" 

24 created_at: str 

25 """Creation time.""" 

26 updated_at: str 

27 """Last update time.""" 

28 status: api.DeltaStatusType 

29 """Delta status.""" 

30 output: str 

31 """Error message, if the delta could not be applied.""" 

32 content: api.Delta 

33 """Delta as sent by QField.""" 

34 

35 

36class SyncFileConfig(gws.Config): 

37 """File field whose content is sent to QField. (added in 8.5)""" 

38 

39 field: str 

40 """Name of a file field in the project models.""" 

41 useBlob: bool = False 

42 """Send the content as a binary GeoPackage column instead of a file.""" 

43 

44 

45class SyncFile(gws.Data): 

46 """File field whose content is sent to QField.""" 

47 

48 model: gws.Model 

49 """Model the file field belongs to.""" 

50 field: file_field.Object 

51 """File field.""" 

52 useBlob: bool 

53 """Send the content as a binary GeoPackage column, named after the ``contentColumn``, instead of a file under the path in the ``nameColumn``.""" 

54 

55 

56class ProjectConfig(gws.ConfigWithAccess): 

57 """Project offered to QField, packaged from a QGIS project.""" 

58 

59 title: str = '' 

60 """Project title shown in QField.""" 

61 provider: gws.plugin.qgis.provider.Config 

62 """QGIS project the QField package is built from.""" 

63 models: Optional[list[gws.ext.config.model]] 

64 """Data models for editable layers, matched by table name.""" 

65 mapCacheLifeTime: gws.Duration = '0' 

66 """How long rendered offline base maps are reused.""" 

67 thumbnail: Optional[gws.FilePath] 

68 """Thumbnail image shown in the project details. (added in 8.5)""" 

69 syncFiles: Optional[list[SyncFileConfig]] 

70 """File fields whose content is sent to QField. (added in 8.5)""" 

71 

72 

73class QfcProject(gws.Node): 

74 """Project offered to QField. 

75 

76 Holds the QGIS provider of the source project, the models for editable 

77 layers and the base map settings used when the project is packaged. 

78 """ 

79 

80 title: str 

81 """Project title, the uid if no title is configured.""" 

82 qgisProvider: gws.plugin.qgis.provider.Object 

83 """QGIS provider for the source project.""" 

84 models: list[gws.DatabaseModel] 

85 """Configured models for editable layers.""" 

86 mapCacheLifeTime: int 

87 """How long rendered base maps are reused, in seconds. ``0`` disables the cache.""" 

88 thumbnail: str 

89 """Path to the thumbnail image, or an empty string.""" 

90 syncFiles: list[SyncFile] 

91 """File fields whose content is sent to QField.""" 

92 

93 def configure(self): 

94 self.title = self.cfg('title', '') or self.uid 

95 self.qgisProvider = self.create_child(gws.plugin.qgis.provider.Object, self.cfg('provider')) 

96 self.root.app.register_supported_crs(self.qgisProvider.forceCrs) 

97 self.models = self.create_children(gws.ext.object.model, self.cfg('models')) 

98 self.mapCacheLifeTime = self.cfg('mapCacheLifeTime') or 0 

99 self.thumbnail = self.cfg('thumbnail') or '' 

100 self.syncFiles = [self.configure_sync_file(c) for c in self.cfg('syncFiles', default=[])] 

101 

102 def configure_sync_file(self, cfg: SyncFileConfig) -> SyncFile: 

103 """Find the file field for a ``syncFiles`` entry. 

104 

105 The columns are checked in the field config, because file fields set their column names in ``post_configure``. 

106 

107 Args: 

108 cfg: ``syncFiles`` entry. 

109 

110 Returns: 

111 The file field and the blob flag. 

112 

113 Raises: 

114 ``gws.ConfigurationError``: If the name does not match exactly one file field in the project models, 

115 the field has no ``contentColumn``, or has no ``nameColumn`` and ``useBlob`` is not set. 

116 """ 

117 found = [f for m in self.models for f in m.fields if f.name == cfg.field and isinstance(f, file_field.Object)] 

118 if len(found) != 1: 

119 raise gws.ConfigurationError(f'syncFiles: expected one file field {cfg.field!r}, found {len(found)}') 

120 fld = found[0] 

121 if not fld.cfg('contentColumn'): 

122 raise gws.ConfigurationError(f'syncFiles: file field {cfg.field!r} has no contentColumn') 

123 if not fld.cfg('nameColumn') and not cfg.useBlob: 

124 raise gws.ConfigurationError(f'syncFiles: file field {cfg.field!r} has no nameColumn') 

125 return SyncFile( 

126 model=fld.model, 

127 field=fld, 

128 useBlob=bool(cfg.useBlob), 

129 ) 

130 

131 

132def gp_field_name(name: str) -> str: 

133 """GeoPackage column name for a model field name. ``fid`` is renamed to ``fid_gws``, because GDAL uses it internally.""" 

134 if name.lower() == 'fid': 

135 return 'fid_gws' 

136 return name 

137 

138 

139def db_field_name(name: str) -> str: 

140 """Model field name for a GeoPackage column name, the reverse of ``gp_field_name``.""" 

141 if name == 'fid_gws': 

142 return 'fid' 

143 return name