Coverage for gws-app/gws/base/edit/helper.py: 77%

150 statements  

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

1"""Edit helper.""" 

2 

3from typing import Optional, cast 

4 

5import gws 

6import gws.base.action 

7import gws.base.feature 

8import gws.base.layer 

9import gws.base.legend 

10import gws.base.model 

11import gws.lib.shape 

12import gws.base.template 

13import gws.base.web 

14import gws.lib.crs 

15import gws.gis.render 

16import gws.lib.image 

17import gws.lib.jsonx 

18import gws.lib.mime 

19 

20from . import api 

21 

22 

23LIST_VIEWS = ['title', 'label'] 

24"""Feature views rendered for feature lists.""" 

25DEFAULT_TOLERANCE = 10, gws.Uom.px 

26"""Search tolerance for feature searches.""" 

27 

28 

29@gws.ext.object.helper('edit') 

30class Object(gws.Node): 

31 """Edit helper. 

32 

33 Implements the edit API. For each command there is a method that does 

34 the work and returns models or features, and a method that converts the 

35 result into the API response. Other actions can call or override these 

36 methods separately. 

37 """ 

38 

39 def get_models(self, req: gws.WebRequester, p: api.GetModelsRequest) -> list[gws.Model]: 

40 """Return the editable models of the requested project. 

41 

42 Args: 

43 req: Web request. 

44 p: Request parameters. 

45 

46 Returns: 

47 Models the user can edit, sorted by title. 

48 

49 Raises: 

50 ``gws.NotFoundError``: If the project does not exist. 

51 ``gws.ForbiddenError``: If the user cannot read the project. 

52 """ 

53 project = req.user.require_project(p.projectUid) 

54 return self.root.app.modelMgr.editable_models(project, req.user) 

55 

56 def get_models_response(self, req: gws.WebRequester, p: gws.Request, models: list[gws.Model]) -> api.GetModelsResponse: 

57 """Create the response for ``editGetModels``. 

58 

59 Args: 

60 req: Web request. 

61 p: Request parameters, used for the project uid. 

62 models: Models to return. 

63 

64 Returns: 

65 The response with the model props. 

66 """ 

67 return api.GetModelsResponse( 

68 models=gws.u.compact(gws.props_of(m, req.user) for m in models) 

69 ) 

70 

71 ## 

72 

73 def get_features(self, req: gws.WebRequester, p: api.GetFeaturesRequest) -> list[gws.Feature]: 

74 """Search features in the requested models. 

75 

76 The search is filtered by the extent, shapes, keyword and feature uids 

77 in the request. 

78 

79 Args: 

80 req: Web request. 

81 p: Request parameters. 

82 

83 Returns: 

84 The found features, or an empty list if an extent is given without a CRS 

85 and the project has no map. 

86 

87 Raises: 

88 ``gws.ForbiddenError``: If a model is not accessible or not editable. 

89 """ 

90 mc = self.model_context(req, p, gws.ModelOperation.read, gws.ModelReadTarget.editList) 

91 

92 search = gws.SearchQuery(project=mc.project, tolerance=DEFAULT_TOLERANCE) 

93 if p.extent: 

94 crs = p.crs 

95 if not crs and mc.project and mc.project.map: 

96 crs = mc.project.map.bounds.crs 

97 if not crs: 

98 gws.log.warning('no CRS specified for extent search') 

99 return [] 

100 search.bounds = gws.Bounds(crs=crs, extent=p.extent) 

101 if p.shapes: 

102 shapes = [gws.lib.shape.from_props(s) for s in p.shapes] 

103 search.shape = shapes[0] if len(shapes) == 1 else shapes[0].union(shapes[1:]) 

104 if p.resolution: 

105 search.resolution = p.resolution 

106 if p.keyword: 

107 search.keyword = p.keyword 

108 if p.featureUids: 

109 search.uids = p.featureUids 

110 

111 fs = [] 

112 

113 for model_uid in p.modelUids: 

114 model = self.require_model(model_uid, req.user, gws.Access.read) 

115 fs.extend(model.find_features(search, mc)) 

116 

117 return fs 

118 

119 def get_features_response(self, req: gws.WebRequester, p: gws.Request, features: list[gws.Feature]) -> api.GetFeaturesResponse: 

120 """Create the response for ``editGetFeatures``. 

121 

122 Args: 

123 req: Web request. 

124 p: Request parameters, used for the project uid. 

125 features: Features to return. 

126 

127 Returns: 

128 The response with the feature props. 

129 """ 

130 mc = self.model_context(req, p, gws.ModelOperation.read, gws.ModelReadTarget.editList) 

131 return api.GetFeaturesResponse(features=self.feature_list_to_props(features, mc)) 

132 

133 ## 

134 

135 def get_relatable_features(self, req: gws.WebRequester, p: api.GetRelatableFeaturesRequest) -> list[gws.Feature]: 

136 """Return features that can be linked in a related field. 

137 

138 Args: 

139 req: Web request. 

140 p: Request parameters. 

141 

142 Returns: 

143 Features found by the field for the request keyword. 

144 

145 Raises: 

146 ``gws.ForbiddenError``: If the model or the field is not accessible. 

147 """ 

148 mc = self.model_context(req, p, gws.ModelOperation.read, gws.ModelReadTarget.editList, max_depth=0) 

149 

150 model = self.require_model(p.modelUid, req.user, gws.Access.read) 

151 field = self.require_field(model, p.fieldName, req.user, gws.Access.read) 

152 search = gws.SearchQuery(keyword=p.keyword) 

153 

154 return field.find_relatable_features(search, mc) 

155 

156 def get_relatable_features_response(self, req: gws.WebRequester, p: gws.Request, features: list[gws.Feature]) -> api.GetRelatableFeaturesResponse: 

157 """Create the response for ``editGetRelatableFeatures``. 

158 

159 Args: 

160 req: Web request. 

161 p: Request parameters, used for the project uid. 

162 features: Features to return. 

163 

164 Returns: 

165 The response with the feature props. 

166 """ 

167 mc = self.model_context(req, p, gws.ModelOperation.read, gws.ModelReadTarget.editList) 

168 return api.GetRelatableFeaturesResponse(features=self.feature_list_to_props(features, mc)) 

169 

170 ## 

171 

172 def get_feature(self, req: gws.WebRequester, p: api.GetFeatureRequest) -> Optional[gws.Feature]: 

173 """Return a single feature for the edit form. 

174 

175 Args: 

176 req: Web request. 

177 p: Request parameters. 

178 

179 Returns: 

180 The feature, or ``None`` if it does not exist. 

181 

182 Raises: 

183 ``gws.ForbiddenError``: If the model is not accessible or not editable. 

184 """ 

185 mc = self.model_context(req, p, gws.ModelOperation.read, gws.ModelReadTarget.editForm) 

186 model = self.require_model(p.modelUid, req.user, gws.Access.read) 

187 fs = model.get_features([p.featureUid], mc) 

188 if fs: 

189 return fs[0] 

190 

191 def get_feature_response(self, req: gws.WebRequester, p: gws.Request, feature: Optional[gws.Feature]) -> api.GetFeatureResponse: 

192 """Create the response for ``editGetFeature``. 

193 

194 Args: 

195 req: Web request. 

196 p: Request parameters, used for the project uid. 

197 feature: Feature to return. 

198 

199 Returns: 

200 The response with the feature props. 

201 

202 Raises: 

203 ``gws.NotFoundError``: If ``feature`` is ``None``. 

204 """ 

205 if not feature: 

206 raise gws.NotFoundError() 

207 mc = self.model_context(req, p, gws.ModelOperation.read, gws.ModelReadTarget.editForm) 

208 return api.GetFeatureResponse(feature=self.feature_to_props(feature, mc)) 

209 

210 ## 

211 

212 def init_feature(self, req: gws.WebRequester, p: api.InitFeatureRequest) -> gws.Feature: 

213 """Create a new feature with initial values, without saving it. 

214 

215 The related features in ``createWithFeatures`` are attached to the new feature. 

216 

217 Args: 

218 req: Web request. 

219 p: Request parameters. 

220 

221 Returns: 

222 The new feature. 

223 

224 Raises: 

225 ``gws.ForbiddenError``: If the model is not accessible, not editable or the user cannot create features. 

226 ``gws.NotFoundError``: If a feature cannot be created from the props. 

227 """ 

228 mc = self.model_context(req, p, gws.ModelOperation.create) 

229 

230 f = self.feature_from_props(p.feature, gws.Access.create, mc) 

231 f.createWithFeatures = [ 

232 self.feature_from_props(r, gws.Access.read, mc) 

233 for r in (p.feature.createWithFeatures or []) 

234 ] 

235 

236 f.model.init_feature(f, mc) 

237 return f 

238 

239 def init_feature_response(self, req: gws.WebRequester, p: gws.Request, feature: Optional[gws.Feature]) -> api.InitFeatureResponse: 

240 """Create the response for ``editInitFeature``. 

241 

242 Args: 

243 req: Web request. 

244 p: Request parameters, used for the project uid. 

245 feature: Feature to return. 

246 

247 Returns: 

248 The response with the feature props. 

249 

250 Raises: 

251 ``gws.NotFoundError``: If ``feature`` is ``None``. 

252 """ 

253 if not feature: 

254 raise gws.NotFoundError() 

255 mc = self.model_context(req, p, gws.ModelOperation.create) 

256 return api.InitFeatureResponse(feature=self.feature_to_props(feature, mc)) 

257 

258 ## 

259 

260 def write_feature(self, req: gws.WebRequester, p: api.WriteFeatureRequest) -> Optional[gws.Feature]: 

261 """Validate and save a new or existing feature. 

262 

263 New features (``isNew``) are created, others are updated. After saving, 

264 the feature is read back from the model. 

265 

266 Args: 

267 req: Web request. 

268 p: Request parameters. 

269 

270 Returns: 

271 The saved feature as read back, the unsaved feature with ``errors`` set if the 

272 validation fails, or ``None`` if the saved feature cannot be read back. 

273 

274 Raises: 

275 ``gws.ForbiddenError``: If the model is not accessible, not editable or the user lacks permissions. 

276 ``gws.NotFoundError``: If a feature cannot be created from the props. 

277 """ 

278 is_new = p.feature.isNew 

279 mc = self.model_context(req, p, gws.ModelOperation.create if is_new else gws.ModelOperation.update) 

280 

281 f = self.feature_from_props(p.feature, gws.Access.write, mc) 

282 f.createWithFeatures = [ 

283 self.feature_from_props(r, gws.Access.read, mc) 

284 for r in (p.feature.createWithFeatures or []) 

285 ] 

286 

287 if not f.model.validate_feature(f, mc): 

288 return f 

289 

290 if is_new: 

291 uid = f.model.create_feature(f, mc) 

292 else: 

293 uid = f.model.update_feature(f, mc) 

294 

295 mc = self.model_context(req, p, gws.ModelOperation.read, gws.ModelReadTarget.editForm) 

296 f_created = f.model.get_features([uid], mc) 

297 if not f_created: 

298 return 

299 

300 return f_created[0] 

301 

302 def write_feature_response(self, req: gws.WebRequester, p: api.WriteFeatureRequest, feature: Optional[gws.Feature]) -> api.WriteFeatureResponse: 

303 """Create the response for ``editWriteFeature``. 

304 

305 Args: 

306 req: Web request. 

307 p: Request parameters, used for the project uid. 

308 feature: Saved feature, or a feature with validation errors. 

309 

310 Returns: 

311 The response with the feature props, or with the validation errors only. 

312 

313 Raises: 

314 ``gws.NotFoundError``: If ``feature`` is ``None``. 

315 """ 

316 if not feature: 

317 raise gws.NotFoundError() 

318 if feature.errors: 

319 return api.WriteFeatureResponse(validationErrors=feature.errors) 

320 

321 mc = self.model_context(req, p, gws.ModelOperation.read, gws.ModelReadTarget.editForm) 

322 return api.WriteFeatureResponse( 

323 feature=self.feature_to_props(feature, mc), 

324 validationErrors=[] 

325 ) 

326 

327 ## 

328 

329 def delete_feature(self, req: gws.WebRequester, p: api.DeleteFeatureRequest) -> Optional[gws.Feature]: 

330 """Delete a feature. 

331 

332 Args: 

333 req: Web request. 

334 p: Request parameters. 

335 

336 Returns: 

337 The deleted feature. 

338 

339 Raises: 

340 ``gws.ForbiddenError``: If the model is not accessible, not editable or the user cannot delete features. 

341 ``gws.NotFoundError``: If a feature cannot be created from the props. 

342 """ 

343 mc = self.model_context(req, p, gws.ModelOperation.delete) 

344 f = self.feature_from_props(p.feature, gws.Access.delete, mc) 

345 if f: 

346 f.model.delete_feature(f, mc) 

347 return f 

348 

349 def delete_feature_response(self, req: gws.WebRequester, p: api.DeleteFeatureRequest, feature: Optional[gws.Feature]) -> api.DeleteFeatureResponse: 

350 """Create the response for ``editDeleteFeature``. 

351 

352 Args: 

353 req: Web request. 

354 p: Request parameters. 

355 feature: Deleted feature. 

356 

357 Returns: 

358 An empty response. 

359 """ 

360 return api.DeleteFeatureResponse() 

361 

362 ## 

363 

364 def require_model(self, model_uid, user: gws.User, access: gws.Access) -> gws.Model: 

365 """Return an editable model the user can access. 

366 

367 Args: 

368 model_uid: Model uid. 

369 user: User. 

370 access: Required access. 

371 

372 Returns: 

373 The model. 

374 

375 Raises: 

376 ``gws.ForbiddenError``: If the model does not exist, is not accessible or is not editable. 

377 """ 

378 model = cast(gws.Model, user.acquire(model_uid, gws.ext.object.model, access)) 

379 if not model: 

380 raise gws.ForbiddenError(f'model {model_uid!r} not found or not accessible') 

381 if not model.isEditable: 

382 raise gws.ForbiddenError(f'model {model_uid!r} is not editable') 

383 return model 

384 

385 def require_field(self, model: gws.Model, field_name: str, user: gws.User, access: gws.Access) -> gws.ModelField: 

386 """Return a model field the user can access. 

387 

388 Args: 

389 model: Model. 

390 field_name: Field name. 

391 user: User. 

392 access: Required access. 

393 

394 Returns: 

395 The field. 

396 

397 Raises: 

398 ``gws.ForbiddenError``: If the field does not exist or is not accessible. 

399 """ 

400 field = model.field(field_name) 

401 if not field: 

402 raise gws.ForbiddenError(f'field {field_name!r} not found in model {model.uid!r}') 

403 if not user.can(access, field): 

404 raise gws.ForbiddenError(f'field {field_name!r} not accessible for user {user.uid!r}') 

405 return field 

406 

407 def feature_from_props(self, props: gws.FeatureProps, access: gws.Access, mc: gws.ModelContext) -> gws.Feature: 

408 """Create a feature from props sent by the client. 

409 

410 Args: 

411 props: Feature props, with ``modelUid`` set. 

412 access: Required access to the model. 

413 mc: Model context. 

414 

415 Returns: 

416 The feature. 

417 

418 Raises: 

419 ``gws.ForbiddenError``: If the model is not accessible or not editable. 

420 ``gws.NotFoundError``: If the model returns no feature for the props. 

421 """ 

422 model = self.require_model(props.modelUid, mc.user, access) 

423 feature = model.feature_from_props(props, mc) 

424 if not feature: 

425 raise gws.NotFoundError() 

426 return feature 

427 

428 def feature_list_to_props(self, features: list[gws.Feature], mc: gws.ModelContext) -> list[gws.FeatureProps]: 

429 """Convert features to props for the client. 

430 

431 Renders the list views (``title``, ``label``) of the features and their 

432 related features and transforms them to the project map CRS. 

433 

434 Args: 

435 features: Features to convert. 

436 mc: Model context. 

437 

438 Returns: 

439 A list of feature props. 

440 """ 

441 template_map = {} 

442 

443 for f in gws.base.model.iter_features(features, mc): 

444 if f.model.uid not in template_map: 

445 template_map[f.model.uid] = gws.u.compact( 

446 f.model.root.app.templateMgr.find_template( 

447 f'feature.{v}', 

448 [f.model, f.model.parent, mc.project], user=mc.user 

449 ) 

450 for v in LIST_VIEWS 

451 ) 

452 

453 f.render_views(template_map[f.model.uid], user=mc.user, project=mc.project) 

454 if mc.project and mc.project.map: 

455 f.transform_to(mc.project.map.bounds.crs) 

456 

457 return [f.model.feature_to_props(f, mc) for f in features] 

458 

459 def feature_to_props(self, feature: gws.Feature, mc: gws.ModelContext) -> gws.FeatureProps: 

460 """Convert a single feature to props for the client. 

461 

462 Args: 

463 feature: Feature to convert. 

464 mc: Model context. 

465 

466 Returns: 

467 The feature props. 

468 """ 

469 ps = self.feature_list_to_props([feature], mc) 

470 return ps[0] 

471 

472 def model_context(self, req: gws.WebRequester, p: gws.Request, op, target: Optional[gws.ModelReadTarget] = None, max_depth=1): 

473 """Create a model context for a request. 

474 

475 Args: 

476 req: Web request. 

477 p: Request parameters, used for the project uid. 

478 op: Model operation. 

479 target: Read target. 

480 max_depth: Maximum depth of related features to process. 

481 

482 Returns: 

483 The model context. 

484 

485 Raises: 

486 ``gws.NotFoundError``: If the project does not exist. 

487 ``gws.ForbiddenError``: If the user cannot read the project. 

488 """ 

489 return gws.ModelContext( 

490 op=op, 

491 target=target, 

492 user=req.user, 

493 project=req.user.require_project(p.projectUid), 

494 maxDepth=max_depth, 

495 )