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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +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
20from . import api
23LIST_VIEWS = ['title', 'label']
24"""Feature views rendered for feature lists."""
25DEFAULT_TOLERANCE = 10, gws.Uom.px
26"""Search tolerance for feature searches."""
29@gws.ext.object.helper('edit')
30class Object(gws.Node):
31 """Edit helper.
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 """
39 def get_models(self, req: gws.WebRequester, p: api.GetModelsRequest) -> list[gws.Model]:
40 """Return the editable models of the requested project.
42 Args:
43 req: Web request.
44 p: Request parameters.
46 Returns:
47 Models the user can edit, sorted by title.
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)
56 def get_models_response(self, req: gws.WebRequester, p: gws.Request, models: list[gws.Model]) -> api.GetModelsResponse:
57 """Create the response for ``editGetModels``.
59 Args:
60 req: Web request.
61 p: Request parameters, used for the project uid.
62 models: Models to return.
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 )
71 ##
73 def get_features(self, req: gws.WebRequester, p: api.GetFeaturesRequest) -> list[gws.Feature]:
74 """Search features in the requested models.
76 The search is filtered by the extent, shapes, keyword and feature uids
77 in the request.
79 Args:
80 req: Web request.
81 p: Request parameters.
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.
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)
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
111 fs = []
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))
117 return fs
119 def get_features_response(self, req: gws.WebRequester, p: gws.Request, features: list[gws.Feature]) -> api.GetFeaturesResponse:
120 """Create the response for ``editGetFeatures``.
122 Args:
123 req: Web request.
124 p: Request parameters, used for the project uid.
125 features: Features to return.
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))
133 ##
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.
138 Args:
139 req: Web request.
140 p: Request parameters.
142 Returns:
143 Features found by the field for the request keyword.
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)
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)
154 return field.find_relatable_features(search, mc)
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``.
159 Args:
160 req: Web request.
161 p: Request parameters, used for the project uid.
162 features: Features to return.
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))
170 ##
172 def get_feature(self, req: gws.WebRequester, p: api.GetFeatureRequest) -> Optional[gws.Feature]:
173 """Return a single feature for the edit form.
175 Args:
176 req: Web request.
177 p: Request parameters.
179 Returns:
180 The feature, or ``None`` if it does not exist.
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]
191 def get_feature_response(self, req: gws.WebRequester, p: gws.Request, feature: Optional[gws.Feature]) -> api.GetFeatureResponse:
192 """Create the response for ``editGetFeature``.
194 Args:
195 req: Web request.
196 p: Request parameters, used for the project uid.
197 feature: Feature to return.
199 Returns:
200 The response with the feature props.
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))
210 ##
212 def init_feature(self, req: gws.WebRequester, p: api.InitFeatureRequest) -> gws.Feature:
213 """Create a new feature with initial values, without saving it.
215 The related features in ``createWithFeatures`` are attached to the new feature.
217 Args:
218 req: Web request.
219 p: Request parameters.
221 Returns:
222 The new feature.
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)
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 ]
236 f.model.init_feature(f, mc)
237 return f
239 def init_feature_response(self, req: gws.WebRequester, p: gws.Request, feature: Optional[gws.Feature]) -> api.InitFeatureResponse:
240 """Create the response for ``editInitFeature``.
242 Args:
243 req: Web request.
244 p: Request parameters, used for the project uid.
245 feature: Feature to return.
247 Returns:
248 The response with the feature props.
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))
258 ##
260 def write_feature(self, req: gws.WebRequester, p: api.WriteFeatureRequest) -> Optional[gws.Feature]:
261 """Validate and save a new or existing feature.
263 New features (``isNew``) are created, others are updated. After saving,
264 the feature is read back from the model.
266 Args:
267 req: Web request.
268 p: Request parameters.
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.
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)
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 ]
287 if not f.model.validate_feature(f, mc):
288 return f
290 if is_new:
291 uid = f.model.create_feature(f, mc)
292 else:
293 uid = f.model.update_feature(f, mc)
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
300 return f_created[0]
302 def write_feature_response(self, req: gws.WebRequester, p: api.WriteFeatureRequest, feature: Optional[gws.Feature]) -> api.WriteFeatureResponse:
303 """Create the response for ``editWriteFeature``.
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.
310 Returns:
311 The response with the feature props, or with the validation errors only.
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)
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 )
327 ##
329 def delete_feature(self, req: gws.WebRequester, p: api.DeleteFeatureRequest) -> Optional[gws.Feature]:
330 """Delete a feature.
332 Args:
333 req: Web request.
334 p: Request parameters.
336 Returns:
337 The deleted feature.
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
349 def delete_feature_response(self, req: gws.WebRequester, p: api.DeleteFeatureRequest, feature: Optional[gws.Feature]) -> api.DeleteFeatureResponse:
350 """Create the response for ``editDeleteFeature``.
352 Args:
353 req: Web request.
354 p: Request parameters.
355 feature: Deleted feature.
357 Returns:
358 An empty response.
359 """
360 return api.DeleteFeatureResponse()
362 ##
364 def require_model(self, model_uid, user: gws.User, access: gws.Access) -> gws.Model:
365 """Return an editable model the user can access.
367 Args:
368 model_uid: Model uid.
369 user: User.
370 access: Required access.
372 Returns:
373 The model.
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
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.
388 Args:
389 model: Model.
390 field_name: Field name.
391 user: User.
392 access: Required access.
394 Returns:
395 The field.
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
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.
410 Args:
411 props: Feature props, with ``modelUid`` set.
412 access: Required access to the model.
413 mc: Model context.
415 Returns:
416 The feature.
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
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.
431 Renders the list views (``title``, ``label``) of the features and their
432 related features and transforms them to the project map CRS.
434 Args:
435 features: Features to convert.
436 mc: Model context.
438 Returns:
439 A list of feature props.
440 """
441 template_map = {}
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 )
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)
457 return [f.model.feature_to_props(f, mc) for f in features]
459 def feature_to_props(self, feature: gws.Feature, mc: gws.ModelContext) -> gws.FeatureProps:
460 """Convert a single feature to props for the client.
462 Args:
463 feature: Feature to convert.
464 mc: Model context.
466 Returns:
467 The feature props.
468 """
469 ps = self.feature_list_to_props([feature], mc)
470 return ps[0]
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.
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.
482 Returns:
483 The model context.
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 )