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
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
1"""Edit helper."""
3from typing import Optional, cast
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
21from . import api
24LIST_VIEWS = ['title', 'label']
25"""Feature views rendered for feature lists."""
26DEFAULT_TOLERANCE = 10, gws.Uom.px
27"""Search tolerance for feature searches."""
30@gws.ext.object.helper('edit')
31class Object(gws.Node):
32 """Edit helper.
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 """
40 def get_models(self, req: gws.WebRequester, p: api.GetModelsRequest) -> list[gws.Model]:
41 """Return the editable models of the requested project.
43 Args:
44 req: Web request.
45 p: Request parameters.
47 Returns:
48 Models the user can edit, sorted by title.
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)
57 def get_models_response(self, req: gws.WebRequester, p: gws.Request, models: list[gws.Model]) -> api.GetModelsResponse:
58 """Create the response for ``editGetModels``.
60 Args:
61 req: Web request.
62 p: Request parameters, used for the project uid.
63 models: Models to return.
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 )
72 ##
74 def get_features(self, req: gws.WebRequester, p: api.GetFeaturesRequest) -> list[gws.Feature]:
75 """Search features in the requested models.
77 The search is filtered by the extent, shapes, keyword and feature uids
78 in the request.
80 Args:
81 req: Web request.
82 p: Request parameters.
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.
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)
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
114 fs = []
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))
120 return fs
122 def get_features_response(self, req: gws.WebRequester, p: gws.Request, features: list[gws.Feature]) -> api.GetFeaturesResponse:
123 """Create the response for ``editGetFeatures``.
125 Args:
126 req: Web request.
127 p: Request parameters, used for the project uid.
128 features: Features to return.
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))
136 ##
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.
141 Args:
142 req: Web request.
143 p: Request parameters.
145 Returns:
146 Features found by the field for the request keyword.
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)
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)
157 return field.find_relatable_features(search, mc)
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``.
162 Args:
163 req: Web request.
164 p: Request parameters, used for the project uid.
165 features: Features to return.
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))
173 ##
175 def get_feature(self, req: gws.WebRequester, p: api.GetFeatureRequest) -> Optional[gws.Feature]:
176 """Return a single feature for the edit form.
178 Args:
179 req: Web request.
180 p: Request parameters.
182 Returns:
183 The feature, or ``None`` if it does not exist.
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]
194 def get_feature_response(self, req: gws.WebRequester, p: gws.Request, feature: Optional[gws.Feature]) -> api.GetFeatureResponse:
195 """Create the response for ``editGetFeature``.
197 Args:
198 req: Web request.
199 p: Request parameters, used for the project uid.
200 feature: Feature to return.
202 Returns:
203 The response with the feature props.
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))
213 ##
215 def init_feature(self, req: gws.WebRequester, p: api.InitFeatureRequest) -> gws.Feature:
216 """Create a new feature with initial values, without saving it.
218 The related features in ``createWithFeatures`` are attached to the new feature.
220 Args:
221 req: Web request.
222 p: Request parameters.
224 Returns:
225 The new feature.
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)
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 ]
239 f.model.init_feature(f, mc)
240 return f
242 def init_feature_response(self, req: gws.WebRequester, p: gws.Request, feature: Optional[gws.Feature]) -> api.InitFeatureResponse:
243 """Create the response for ``editInitFeature``.
245 Args:
246 req: Web request.
247 p: Request parameters, used for the project uid.
248 feature: Feature to return.
250 Returns:
251 The response with the feature props.
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))
261 ##
263 def write_feature(self, req: gws.WebRequester, p: api.WriteFeatureRequest) -> Optional[gws.Feature]:
264 """Validate and save a new or existing feature.
266 New features (``isNew``) are created, others are updated. After saving,
267 the feature is read back from the model.
269 Args:
270 req: Web request.
271 p: Request parameters.
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.
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)
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 ]
290 if not f.model.validate_feature(f, mc):
291 return f
293 if is_new:
294 uid = f.model.create_feature(f, mc)
295 else:
296 uid = f.model.update_feature(f, mc)
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
303 return f_created[0]
305 def write_feature_response(self, req: gws.WebRequester, p: api.WriteFeatureRequest, feature: Optional[gws.Feature]) -> api.WriteFeatureResponse:
306 """Create the response for ``editWriteFeature``.
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.
313 Returns:
314 The response with the feature props, or with the validation errors only.
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)
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 )
330 ##
332 def delete_feature(self, req: gws.WebRequester, p: api.DeleteFeatureRequest) -> Optional[gws.Feature]:
333 """Delete a feature.
335 Args:
336 req: Web request.
337 p: Request parameters.
339 Returns:
340 The deleted feature.
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
352 def delete_feature_response(self, req: gws.WebRequester, p: api.DeleteFeatureRequest, feature: Optional[gws.Feature]) -> api.DeleteFeatureResponse:
353 """Create the response for ``editDeleteFeature``.
355 Args:
356 req: Web request.
357 p: Request parameters.
358 feature: Deleted feature.
360 Returns:
361 An empty response.
362 """
363 return api.DeleteFeatureResponse()
365 ##
367 def require_model(self, model_uid, user: gws.User, access: gws.Access) -> gws.Model:
368 """Return an editable model the user can access.
370 Args:
371 model_uid: Model uid.
372 user: User.
373 access: Required access.
375 Returns:
376 The model.
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
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.
391 Args:
392 model: Model.
393 field_name: Field name.
394 user: User.
395 access: Required access.
397 Returns:
398 The field.
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
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.
413 Args:
414 props: Feature props, with ``modelUid`` set.
415 access: Required access to the model.
416 mc: Model context.
418 Returns:
419 The feature.
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
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.
434 Renders the list views (``title``, ``label``) of the features and their
435 related features and transforms them to the project map CRS.
437 Args:
438 features: Features to convert.
439 mc: Model context.
441 Returns:
442 A list of feature props.
443 """
444 template_map = {}
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 )
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)
460 return [f.model.feature_to_props(f, mc) for f in features]
462 def feature_to_props(self, feature: gws.Feature, mc: gws.ModelContext) -> gws.FeatureProps:
463 """Convert a single feature to props for the client.
465 Args:
466 feature: Feature to convert.
467 mc: Model context.
469 Returns:
470 The feature props.
471 """
472 ps = self.feature_list_to_props([feature], mc)
473 return ps[0]
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.
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.
485 Returns:
486 The model context.
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 )