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

158 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +0200

1"""Applies changes and file uploads from QField to the database.""" 

2 

3from typing import cast, Optional 

4 

5import gws 

6import gws.lib.shape 

7import gws.plugin.model_field.file as file_field 

8import gws.base.feature 

9import gws.lib.osx 

10 

11from . import core, caps as caps_mod 

12 

13 

14class ChangeType(gws.Enum): 

15 """Type of a change, as in the delta ``method``.""" 

16 

17 patch = 'patch' 

18 """Update an existing feature.""" 

19 create = 'create' 

20 """Create a new feature.""" 

21 delete = 'delete' 

22 """Delete a feature.""" 

23 

24 

25class Change(gws.Data): 

26 """A single change, extracted from a QField delta.""" 

27 

28 uid: str 

29 """Delta uuid.""" 

30 type: ChangeType 

31 """Change type.""" 

32 layerUid: str 

33 """Id of the QGIS layer.""" 

34 newAtts: dict 

35 """New attribute values.""" 

36 oldAtts: dict 

37 """Old attribute values, used to find the primary key of updated and deleted features.""" 

38 wkt: str 

39 """New geometry as WKT, or an empty string.""" 

40 

41 

42class Operation(gws.Data): 

43 """A model operation to be committed.""" 

44 

45 type: gws.ModelOperation 

46 """Operation type.""" 

47 feature: gws.Feature 

48 """Feature to create, update or delete.""" 

49 

50 

51class Args(gws.Data): 

52 """Arguments for the patcher.""" 

53 

54 qfcProject: core.QfcProject 

55 """QField project.""" 

56 caps: caps_mod.Caps 

57 """Capabilities of the QField project.""" 

58 project: Optional[gws.Project] 

59 """GWS project context.""" 

60 user: gws.User 

61 """User who sent the changes.""" 

62 baseDir: str 

63 """Base directory (not used by the patcher).""" 

64 changes: list[Change] 

65 """Changes to apply.""" 

66 filePath: str 

67 """Path of an uploaded file, as sent by QField.""" 

68 fileContent: bytes 

69 """Content of an uploaded file.""" 

70 

71 

72class Object: 

73 """Patcher, applies changes and file uploads from QField. 

74 

75 Changes are converted to model operations, grouped by model, and passed 

76 to the model methods ``create_feature``, ``update_feature`` and 

77 ``delete_feature``. Changes for unknown or non-editable layers, and 

78 updates or deletions of features that do not exist, are skipped with a warning. 

79 """ 

80 

81 root: gws.Root 

82 """Root object.""" 

83 qfcProject: core.QfcProject 

84 """QField project.""" 

85 project: Optional[gws.Project] 

86 """GWS project context.""" 

87 user: gws.User 

88 """User who sent the changes.""" 

89 args: Args 

90 """Patcher arguments.""" 

91 

92 caps: caps_mod.Caps 

93 """Capabilities of the QField project.""" 

94 ops_by_model: dict[str, list[Operation]] 

95 """Operations by GeoPackage name of the model.""" 

96 

97 def apply_changes(self, root: gws.Root, args: Args) -> bool: 

98 """Apply the changes in ``args.changes``. 

99 

100 Args: 

101 root: Root object. 

102 args: Patcher arguments. 

103 

104 Returns: 

105 ``True`` if any operations were committed, ``False`` if there was nothing to apply. 

106 """ 

107 self.root = root 

108 self.prepare(args) 

109 

110 self.ops_by_model = {} 

111 

112 for cc in self.args.changes: 

113 self.prepare_change(cc) 

114 

115 if not self.ops_by_model: 

116 return False 

117 

118 for gpName, ops in self.ops_by_model.items(): 

119 self.commit_operations_for_model(self.caps.modelMap[gpName], ops) 

120 

121 return True 

122 

123 def prepare(self, args: Args): 

124 """Store the arguments in the patcher. 

125 

126 Args: 

127 args: Patcher arguments. 

128 """ 

129 self.args = args 

130 self.qfcProject = self.args.qfcProject 

131 self.project = self.args.project 

132 self.user = self.args.user 

133 self.caps = args.caps 

134 

135 def commit_operations_for_model(self, me: caps_mod.ModelEntry, ops: list[Operation]): 

136 """Commit operations for a model. 

137 

138 Args: 

139 me: Model entry. 

140 ops: Operations to commit. 

141 """ 

142 with me.model.db.connect() as conn: 

143 for op in ops: 

144 gws.log.debug(f'{op.type=} {op.feature.attributes=}') 

145 mc = gws.ModelContext(op=op.type, user=self.user, project=self.project) 

146 if op.type == gws.ModelOperation.create: 

147 me.model.create_feature(op.feature, mc) 

148 continue 

149 if op.type == gws.ModelOperation.update: 

150 me.model.update_feature(op.feature, mc) 

151 continue 

152 if op.type == gws.ModelOperation.delete: 

153 me.model.delete_feature(op.feature, mc) 

154 conn.commit() 

155 

156 def apply_upload(self, root: gws.Root, args: Args) -> bool: 

157 """Apply the file upload in ``args.filePath`` and ``args.fileContent``. 

158 

159 Args: 

160 root: Root object. 

161 args: Patcher arguments. 

162 

163 Returns: 

164 ``True`` if a feature for the file was found and updated. 

165 """ 

166 self.root = root 

167 self.prepare(args) 

168 return self.commit_upload(args.filePath, args.fileContent) 

169 

170 def commit_upload(self, path: str, content: bytes) -> bool: 

171 """Write uploaded file content into the first feature that refers to the file. 

172 

173 Args: 

174 path: File path, as sent by QField. 

175 content: File content. 

176 

177 Returns: 

178 ``True`` if a feature was found and updated. 

179 """ 

180 for me in self.caps.modelMap.values(): 

181 if self.commit_upload_for_model(me, path, content): 

182 return True 

183 gws.log.warning(f'commit_upload: feature not found: {path=}') 

184 return False 

185 

186 def commit_upload_for_model(self, me: caps_mod.ModelEntry, path: str, content: bytes) -> bool: 

187 """Write uploaded file content into a feature of a model. 

188 

189 Looks for a file field with a ``nameColumn`` whose value equals the path, 

190 and writes the content into the field's ``contentColumn``. 

191 

192 Args: 

193 me: Model entry. 

194 path: File path, as sent by QField. 

195 content: File content. 

196 

197 Returns: 

198 ``True`` if a feature was found and updated. 

199 """ 

200 mc = gws.ModelContext(op=gws.ModelOperation.update, user=self.user, project=self.project) 

201 

202 for fld in me.model.fields: 

203 if fld.extType != 'file': 

204 continue 

205 fld = cast(file_field.Object, fld) 

206 if fld.nameColumn is None: 

207 continue 

208 uid = self.find_uid_for_path(me, fld, path, mc) 

209 if not uid: 

210 continue 

211 gws.log.debug(f'commit_upload: found feature: model={me.gpName}: {fld.name=} {uid=} {path=} ') 

212 

213 with me.model.db.connect() as conn: 

214 sql = me.model.table().update().where(me.model.uid_equals(uid)).values({fld.contentColumn: content}) 

215 conn.execute(sql) 

216 conn.commit() 

217 

218 return True 

219 

220 return False 

221 

222 def find_uid_for_path( 

223 self, 

224 me: caps_mod.ModelEntry, 

225 ff: file_field.Object, 

226 path: str, 

227 mc: gws.ModelContext, 

228 ) -> Optional[str]: 

229 """Find the feature whose file name column equals the path. 

230 

231 Args: 

232 me: Model entry. 

233 ff: File field. 

234 path: File path, as sent by QField. 

235 mc: Model context. 

236 

237 Returns: 

238 Primary key of the feature, or ``None`` if not found. 

239 """ 

240 with me.model.db.connect() as conn: 

241 sel = me.model.table().select().with_only_columns(me.model.uid_column()).where(ff.nameColumn == path) 

242 rec = conn.fetch_first(sel) 

243 if rec: 

244 return rec[me.model.uidName] 

245 

246 def prepare_change(self, cc: Change): 

247 """Convert a change to an operation and add it to ``ops_by_model``. 

248 

249 The ``fid_gws`` attribute is renamed back to ``fid`` (see the packager). 

250 For creations, an automatic primary key is removed from the attributes. 

251 

252 Args: 

253 cc: Change. 

254 """ 

255 le = self.caps.layerMap.get(cc.layerUid) 

256 if not le: 

257 gws.log.warning(f'layer not found: {cc.layerUid!r}') 

258 return 

259 if le.action != caps_mod.LayerAction.edit: 

260 gws.log.warning(f'unsupported layer action: {cc.layerUid!r} {le.action!r}') 

261 return 

262 

263 me = le.modelEntry 

264 pk_name = me.model.uidName 

265 

266 atts = dict(cc.newAtts) 

267 

268 # see notes in packager.py about fid/fid_gws 

269 if 'fid_gws' in atts: 

270 atts['fid'] = atts.pop('fid_gws') 

271 

272 if cc.wkt: 

273 geom = me.model.geometryName 

274 if not geom: 

275 gws.log.warning(f'geometry field not found: {me.gpName!r}') 

276 else: 

277 atts[geom] = gws.lib.shape.from_wkt(cc.wkt, me.model.geometryCrs) 

278 

279 ops = self.ops_by_model.setdefault(me.gpName, []) 

280 

281 if cc.type == ChangeType.create: 

282 mc = gws.ModelContext(op=gws.ModelOperation.create, user=self.user, project=self.project) 

283 pk_field = me.model.field(pk_name) 

284 if pk_field and pk_field.isAuto: 

285 atts.pop(pk_name, None) 

286 feat = me.model.feature_from_props(gws.FeatureProps(attributes=atts, isNew=True), mc) 

287 ops.append(Operation(type=gws.ModelOperation.create, feature=feat)) 

288 return 

289 

290 if cc.type == ChangeType.delete: 

291 mc = gws.ModelContext(op=gws.ModelOperation.delete, user=self.user, project=self.project) 

292 pk = cc.oldAtts.get(pk_name, '') 

293 feat = self.get_feature(me, pk) 

294 if not feat: 

295 gws.log.warning(f'delete: not found: {pk=} {me.gpName=} {le.qgisId=}') 

296 return 

297 ops.append(Operation(type=gws.ModelOperation.delete, feature=feat)) 

298 return 

299 

300 if cc.type == ChangeType.patch: 

301 pk = cc.oldAtts.get(pk_name, '') 

302 feat = self.get_feature(me, pk) 

303 if not feat: 

304 gws.log.warning(f'update: not found: {pk=} {me.gpName=} {le.qgisId=}') 

305 return 

306 mc = gws.ModelContext(op=gws.ModelOperation.update, user=self.user, project=self.project) 

307 atts[pk_name] = pk 

308 feat = me.model.feature_from_props(gws.FeatureProps(attributes=atts), mc) 

309 ops.append(Operation(type=gws.ModelOperation.update, feature=feat)) 

310 return 

311 

312 def get_feature(self, me: caps_mod.ModelEntry, pk: str) -> gws.Feature | None: 

313 """Read a feature by its primary key. 

314 

315 Args: 

316 me: Model entry. 

317 pk: Primary key. 

318 

319 Returns: 

320 The feature, or ``None`` if not found. 

321 """ 

322 mc = gws.ModelContext(op=gws.ModelOperation.read, user=self.user, project=self.project) 

323 fs = me.model.get_features([pk], mc) 

324 if fs: 

325 return fs[0]