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

153 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-23 20:06 +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 

19import gws.lib.uom 

20 

21from . import api 

22 

23 

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

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

26DEFAULT_TOLERANCE = 10, gws.Uom.px 

27"""Search tolerance for feature searches.""" 

28 

29 

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

31class Object(gws.Node): 

32 """Edit helper. 

33 

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

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

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

37 methods separately. 

38 """ 

39 

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

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

42 

43 Args: 

44 req: Web request. 

45 p: Request parameters. 

46 

47 Returns: 

48 Models the user can edit, sorted by title. 

49 

50 Raises: 

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

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

53 """ 

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

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

56 

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

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

59 

60 Args: 

61 req: Web request. 

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

63 models: Models to return. 

64 

65 Returns: 

66 The response with the model props. 

67 """ 

68 return api.GetModelsResponse( 

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

70 ) 

71 

72 ## 

73 

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

75 """Search features in the requested models. 

76 

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

78 in the request. 

79 

80 Args: 

81 req: Web request. 

82 p: Request parameters. 

83 

84 Returns: 

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

86 and the project has no map. 

87 

88 Raises: 

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

90 """ 

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

92 

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

94 if p.extent: 

95 crs = p.crs 

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

97 crs = mc.project.map.bounds.crs 

98 if not crs: 

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

100 return [] 

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

102 if p.shapes: 

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

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

105 if p.tolerance: 

106 search.tolerance = gws.lib.uom.parse(p.tolerance, gws.Uom.px) 

107 if p.resolution: 

108 search.resolution = p.resolution 

109 if p.keyword: 

110 search.keyword = p.keyword 

111 if p.featureUids: 

112 search.uids = p.featureUids 

113 

114 fs = [] 

115 

116 for model_uid in p.modelUids: 

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

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

119 

120 return fs 

121 

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

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

124 

125 Args: 

126 req: Web request. 

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

128 features: Features to return. 

129 

130 Returns: 

131 The response with the feature props. 

132 """ 

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

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

135 

136 ## 

137 

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

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

140 

141 Args: 

142 req: Web request. 

143 p: Request parameters. 

144 

145 Returns: 

146 Features found by the field for the request keyword. 

147 

148 Raises: 

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

150 """ 

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

152 

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

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

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

156 

157 return field.find_relatable_features(search, mc) 

158 

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

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

161 

162 Args: 

163 req: Web request. 

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

165 features: Features to return. 

166 

167 Returns: 

168 The response with the feature props. 

169 """ 

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

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

172 

173 ## 

174 

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

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

177 

178 Args: 

179 req: Web request. 

180 p: Request parameters. 

181 

182 Returns: 

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

184 

185 Raises: 

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

187 """ 

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

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

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

191 if fs: 

192 return fs[0] 

193 

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

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

196 

197 Args: 

198 req: Web request. 

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

200 feature: Feature to return. 

201 

202 Returns: 

203 The response with the feature props. 

204 

205 Raises: 

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

207 """ 

208 if not feature: 

209 raise gws.NotFoundError() 

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

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

212 

213 ## 

214 

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

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

217 

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

219 

220 Args: 

221 req: Web request. 

222 p: Request parameters. 

223 

224 Returns: 

225 The new feature. 

226 

227 Raises: 

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

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

230 """ 

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

232 

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

234 f.createWithFeatures = [ 

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

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

237 ] 

238 

239 f.model.init_feature(f, mc) 

240 return f 

241 

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

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

244 

245 Args: 

246 req: Web request. 

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

248 feature: Feature to return. 

249 

250 Returns: 

251 The response with the feature props. 

252 

253 Raises: 

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

255 """ 

256 if not feature: 

257 raise gws.NotFoundError() 

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

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

260 

261 ## 

262 

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

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

265 

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

267 the feature is read back from the model. 

268 

269 Args: 

270 req: Web request. 

271 p: Request parameters. 

272 

273 Returns: 

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

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

276 

277 Raises: 

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

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

280 """ 

281 is_new = p.feature.isNew 

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

283 

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

285 f.createWithFeatures = [ 

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

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

288 ] 

289 

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

291 return f 

292 

293 if is_new: 

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

295 else: 

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

297 

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

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

300 if not f_created: 

301 return 

302 

303 return f_created[0] 

304 

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

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

307 

308 Args: 

309 req: Web request. 

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

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

312 

313 Returns: 

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

315 

316 Raises: 

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

318 """ 

319 if not feature: 

320 raise gws.NotFoundError() 

321 if feature.errors: 

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

323 

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

325 return api.WriteFeatureResponse( 

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

327 validationErrors=[] 

328 ) 

329 

330 ## 

331 

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

333 """Delete a feature. 

334 

335 Args: 

336 req: Web request. 

337 p: Request parameters. 

338 

339 Returns: 

340 The deleted feature. 

341 

342 Raises: 

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

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

345 """ 

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

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

348 if f: 

349 f.model.delete_feature(f, mc) 

350 return f 

351 

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

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

354 

355 Args: 

356 req: Web request. 

357 p: Request parameters. 

358 feature: Deleted feature. 

359 

360 Returns: 

361 An empty response. 

362 """ 

363 return api.DeleteFeatureResponse() 

364 

365 ## 

366 

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

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

369 

370 Args: 

371 model_uid: Model uid. 

372 user: User. 

373 access: Required access. 

374 

375 Returns: 

376 The model. 

377 

378 Raises: 

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

380 """ 

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

382 if not model: 

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

384 if not model.isEditable: 

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

386 return model 

387 

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

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

390 

391 Args: 

392 model: Model. 

393 field_name: Field name. 

394 user: User. 

395 access: Required access. 

396 

397 Returns: 

398 The field. 

399 

400 Raises: 

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

402 """ 

403 field = model.field(field_name) 

404 if not field: 

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

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

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

408 return field 

409 

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

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

412 

413 Args: 

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

415 access: Required access to the model. 

416 mc: Model context. 

417 

418 Returns: 

419 The feature. 

420 

421 Raises: 

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

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

424 """ 

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

426 feature = model.feature_from_props(props, mc) 

427 if not feature: 

428 raise gws.NotFoundError() 

429 return feature 

430 

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

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

433 

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

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

436 

437 Args: 

438 features: Features to convert. 

439 mc: Model context. 

440 

441 Returns: 

442 A list of feature props. 

443 """ 

444 template_map = {} 

445 

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

447 if f.model.uid not in template_map: 

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

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

450 f'feature.{v}', 

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

452 ) 

453 for v in LIST_VIEWS 

454 ) 

455 

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

457 if mc.project and mc.project.map: 

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

459 

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

461 

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

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

464 

465 Args: 

466 feature: Feature to convert. 

467 mc: Model context. 

468 

469 Returns: 

470 The feature props. 

471 """ 

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

473 return ps[0] 

474 

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

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

477 

478 Args: 

479 req: Web request. 

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

481 op: Model operation. 

482 target: Read target. 

483 max_depth: Maximum depth of related features to process. 

484 

485 Returns: 

486 The model context. 

487 

488 Raises: 

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

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

491 """ 

492 return gws.ModelContext( 

493 op=op, 

494 target=target, 

495 user=req.user, 

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

497 maxDepth=max_depth, 

498 )