Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/prototypes/list.py: 0%
221 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-26 11:31 +0000
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-26 11:31 +0000
1import logging
2import typing as t
3from viur.core import current, db, errors, utils
4from viur.core.decorators import *
5from viur.core.cache import flushCache
6from viur.core.skeleton import SkeletonInstance
7from viur.core.bones import BaseBone
8from .skelmodule import SkelModule
11class List(SkelModule):
12 """
13 List module prototype.
15 The list module prototype handles datasets in a flat list. It can be extended to filters and views to provide
16 various use-cases.
18 It is undoubtedly the most frequently used prototype in any ViUR project.
19 """
20 handler = "list"
21 accessRights = ("add", "edit", "view", "delete", "manage")
23 def viewSkel(self, *args, **kwargs) -> SkeletonInstance:
24 """
25 Retrieve a new instance of a :class:`viur.core.skeleton.SkeletonInstance` that is used by the application
26 for viewing an existing entry from the list.
28 The default is a Skeleton instance returned by :func:`~baseSkel`.
30 This SkeletonInstance can be post-processed (just returning a subskel or manually removing single bones) - which
31 is the recommended way to ensure a given user cannot see certain fields. A Jinja-Template may choose not to
32 display certain bones, but if the json or xml render is attached (or the user can use the vi or admin render)
33 he could still see all values. This also prevents the user from filtering by these bones, so no binary search
34 is possible.
36 .. seealso:: :func:`addSkel`, :func:`editSkel`, :func:`~baseSkel`
38 :return: Returns a Skeleton instance for viewing an entry.
39 """
40 return self.baseSkel(**kwargs)
42 def addSkel(self, *args, **kwargs) -> SkeletonInstance:
43 """
44 Retrieve a new instance of a :class:`viur.core.skeleton.Skeleton` that is used by the application
45 for adding an entry to the list.
47 The default is a Skeleton instance returned by :func:`~baseSkel`.
49 Like in :func:`viewSkel`, the skeleton can be post-processed. Bones that are being removed aren't visible
50 and cannot be set, but it's also possible to just set a bone to readOnly (revealing it's value to the user,
51 but preventing any modification. It's possible to pre-set values on that skeleton (and if that bone is
52 readOnly, enforcing these values).
54 .. seealso:: :func:`viewSkel`, :func:`editSkel`, :func:`~baseSkel`
56 :return: Returns a Skeleton instance for adding an entry.
57 """
58 return self.baseSkel(**kwargs)
60 def editSkel(self, *args, **kwargs) -> SkeletonInstance:
61 """
62 Retrieve a new instance of a :class:`viur.core.skeleton.Skeleton` that is used by the application
63 for editing an existing entry from the list.
65 The default is a Skeleton instance returned by :func:`~baseSkel`.
67 Like in :func:`viewSkel`, the skeleton can be post-processed. Bones that are being removed aren't visible
68 and cannot be set, but it's also possible to just set a bone to readOnly (revealing it's value to the user,
69 but preventing any modification.
71 .. seealso:: :func:`viewSkel`, :func:`editSkel`, :func:`~baseSkel`
73 :return: Returns a Skeleton instance for editing an entry.
74 """
75 return self.baseSkel(**kwargs)
77 def cloneSkel(self, *args, **kwargs) -> SkeletonInstance:
78 """
79 Retrieve a new instance of a :class:`viur.core.skeleton.Skeleton` that is used by the application
80 for cloning an existing entry from the list.
82 The default is a SkeletonInstance returned by :func:`~baseSkel`.
84 Like in :func:`viewSkel`, the skeleton can be post-processed. Bones that are being removed aren't visible
85 and cannot be set, but it's also possible to just set a bone to readOnly (revealing it's value to the user,
86 but preventing any modification.
88 .. seealso:: :func:`viewSkel`, :func:`editSkel`, :func:`~baseSkel`
90 :return: Returns a SkeletonInstance for editing an entry.
91 """
93 # On clone, by default, behave as this is a skeleton for adding.
94 return self.addSkel(**kwargs)
96 ## External exposed functions
98 @exposed
99 @force_post
100 @skey
101 def preview(self, *args, **kwargs) -> t.Any:
102 """
103 Renders data for an entry, without reading from the database.
104 This function allows to preview an entry without writing it to the database.
106 Any entity values are provided via *kwargs*.
108 The function uses the viewTemplate of the application.
110 :returns: The rendered representation of the supplied data.
111 """
112 if not self.canPreview():
113 raise errors.Unauthorized()
115 skel = self.viewSkel(allow_client_defined=utils.string.is_prefix(self.render.kind, "json"))
116 skel.fromClient(kwargs)
118 return self.render.view(skel)
120 @exposed
121 def structure(self, action: t.Optional[str] = "view") -> t.Any:
122 """
123 :returns: Returns the structure of our skeleton as used in list/view. Values are the defaultValues set
124 in each bone.
126 :raises: :exc:`viur.core.errors.Unauthorized`, if the current user does not have the required permissions.
127 """
128 # FIXME: In ViUR > 3.7 this could also become dynamic (ActionSkel paradigm).
129 match action:
130 case "view":
131 skel = self.viewSkel(allow_client_defined=utils.string.is_prefix(self.render.kind, "json"))
132 if not self.canView(skel):
133 raise errors.Unauthorized()
135 case "edit":
136 skel = self.editSkel()
137 if not self.canEdit(skel):
138 raise errors.Unauthorized()
140 case "add":
141 if not self.canAdd():
142 raise errors.Unauthorized()
144 skel = self.addSkel()
146 case "clone":
147 skel = self.cloneSkel()
148 if not (self.canAdd() and self.canEdit(skel)):
149 raise errors.Unauthorized()
151 case _:
152 raise errors.NotImplemented(f"The action {action!r} is not implemented.")
154 return self.render.render(f"structure.{action}", skel)
156 @exposed
157 def view(self, key: db.Key | int | str, *args, **kwargs) -> t.Any:
158 """
159 Prepares and renders a single entry for viewing.
161 The entry is fetched by its entity key, which either is provided via *kwargs["key"]*,
162 or as the first parameter in *args*. The function performs several access control checks
163 on the requested entity before it is rendered.
165 .. seealso:: :func:`viewSkel`, :func:`canView`, :func:`onView`
167 :returns: The rendered representation of the requested entity.
169 :raises: :exc:`viur.core.errors.NotAcceptable`, when no *key* is provided.
170 :raises: :exc:`viur.core.errors.NotFound`, when no entry with the given *key* was found.
171 :raises: :exc:`viur.core.errors.Unauthorized`, if the current user does not have the required permissions.
172 """
173 skel = self.viewSkel()
174 if not skel.read(key):
175 raise errors.NotFound()
177 if not self.canView(skel):
178 raise errors.Forbidden()
180 self.onView(skel)
181 return self.render.view(skel)
183 @exposed
184 def list(self, *args, **kwargs) -> t.Any:
185 """
186 Prepares and renders a list of entries.
188 All supplied parameters are interpreted as filters for the elements displayed.
190 Unlike other modules in ViUR, the access control in this function is performed
191 by calling the function :func:`listFilter`, which updates the query-filter to match only
192 elements which the user is allowed to see.
194 .. seealso:: :func:`listFilter`, :func:`viur.core.db.mergeExternalFilter`
196 :returns: The rendered list objects for the matching entries.
198 :raises: :exc:`viur.core.errors.Unauthorized`, if the current user does not have the required permissions.
199 """
200 skel = self.viewSkel(allow_client_defined=utils.string.is_prefix(self.render.kind, "json"))
202 # The general access control is made via self.listFilter()
203 if not (query := self.listFilter(skel.all().mergeExternalFilter(kwargs))):
204 raise errors.Unauthorized()
206 self._apply_default_order(query)
207 return self.render.list(query.fetch())
209 @force_ssl
210 @exposed
211 @skey(allow_empty=True)
212 def edit(self, key: db.Key | int | str, *args, **kwargs) -> t.Any:
213 """
214 Modify an existing entry, and render the entry, eventually with error notes on incorrect data.
215 Data is taken by any other arguments in *kwargs*.
217 The entry is fetched by its entity key, which either is provided via *kwargs["key"]*,
218 or as the first parameter in *args*. The function performs several access control checks
219 on the requested entity before it is modified.
221 .. seealso:: :func:`editSkel`, :func:`onEdit`, :func:`onEdited`, :func:`canEdit`
223 :returns: The rendered, edited object of the entry, eventually with error hints.
225 :raises: :exc:`viur.core.errors.NotAcceptable`, when no *key* is provided.
226 :raises: :exc:`viur.core.errors.NotFound`, when no entry with the given *key* was found.
227 :raises: :exc:`viur.core.errors.Unauthorized`, if the current user does not have the required permissions.
228 :raises: :exc:`viur.core.errors.PreconditionFailed`, if the *skey* could not be verified.
229 """
230 skel = self.editSkel()
231 if not skel.read(key):
232 raise errors.NotFound()
234 if not self.canEdit(skel):
235 raise errors.Unauthorized()
237 if (
238 not kwargs # no data supplied
239 or not current.request.get().isPostRequest # failure if not using POST-method
240 or not skel.fromClient(kwargs, amend=True) # failure on reading into the bones
241 or utils.parse.bool(kwargs.get("bounce")) # review before changing
242 ):
243 # render the skeleton in the version it could as far as it could be read.
244 return self.render.edit(skel)
246 self.onEdit(skel)
247 skel.write() # write it!
248 self.onEdited(skel)
250 return self.render.editSuccess(skel)
252 @force_ssl
253 @exposed
254 @skey(allow_empty=True)
255 def add(self, *args, **kwargs) -> t.Any:
256 """
257 Add a new entry, and render the entry, eventually with error notes on incorrect data.
258 Data is taken by any other arguments in *kwargs*.
260 The function performs several access control checks on the requested entity before it is added.
262 .. seealso:: :func:`addSkel`, :func:`onAdd`, :func:`onAdded`, :func:`canAdd`
264 :returns: The rendered, added object of the entry, eventually with error hints.
266 :raises: :exc:`viur.core.errors.Unauthorized`, if the current user does not have the required permissions.
267 :raises: :exc:`viur.core.errors.PreconditionFailed`, if the *skey* could not be verified.
268 """
269 if not self.canAdd():
270 raise errors.Unauthorized()
272 skel = self.addSkel()
274 if (
275 not kwargs # no data supplied
276 or not current.request.get().isPostRequest # failure if not using POST-method
277 or not skel.fromClient(kwargs) # failure on reading into the bones
278 or utils.parse.bool(kwargs.get("bounce")) # review before adding
279 ):
280 # render the skeleton in the version it could as far as it could be read.
281 return self.render.add(skel)
283 self.onAdd(skel)
284 skel.write()
285 self.onAdded(skel)
287 return self.render.addSuccess(skel)
289 @force_ssl
290 @force_post
291 @exposed
292 @skey
293 def delete(self, key: db.Key | int | str, *args, **kwargs) -> t.Any:
294 """
295 Delete an entry.
297 The function runs several access control checks on the data before it is deleted.
299 .. seealso:: :func:`canDelete`, :func:`editSkel`, :func:`onDeleted`
301 :returns: The rendered, deleted object of the entry.
303 :raises: :exc:`viur.core.errors.NotFound`, when no entry with the given *key* was found.
304 :raises: :exc:`viur.core.errors.Unauthorized`, if the current user does not have the required permissions.
305 :raises: :exc:`viur.core.errors.PreconditionFailed`, if the *skey* could not be verified.
306 """
307 skel = self.editSkel()
308 if not skel.read(key):
309 raise errors.NotFound()
311 if not self.canDelete(skel):
312 raise errors.Unauthorized()
314 self.onDelete(skel)
315 skel.delete()
316 self.onDeleted(skel)
318 return self.render.deleteSuccess(skel)
320 @exposed
321 def index(self, *args, **kwargs) -> t.Any:
322 """
323 Default, SEO-Friendly fallback for view and list.
325 :param args: The first argument - if provided - is interpreted as seoKey.
326 :param kwargs: Used for the fallback list.
327 :return: The rendered entity or list.
328 """
329 if args and args[0]:
330 skel = self.viewSkel(
331 allow_client_defined=utils.string.is_prefix(self.render.kind, "json"),
332 _excludeFromAccessLog=True,
333 )
335 # We probably have a Database or SEO-Key here
336 if skel := skel.all().filter("viur.viurActiveSeoKeys =", str(args[0]).lower()).getSkel():
337 db.currentDbAccessLog.get(set()).add(skel["key"])
338 if not self.canView(skel):
339 raise errors.Forbidden()
340 seoUrl = utils.seoUrlToEntry(self.moduleName, skel)
341 # Check whether this is the current seo-key, otherwise redirect to it
343 if current.request.get().request.path.lower() != seoUrl:
344 raise errors.Redirect(seoUrl, status=301)
345 self.onView(skel)
346 return self.render.view(skel)
347 # This was unsuccessfully, we'll render a list instead
348 if not kwargs:
349 kwargs = self.getDefaultListParams()
350 return self.list(**kwargs)
352 def getDefaultListParams(self):
353 return {}
355 @exposed
356 @force_ssl
357 @skey(allow_empty=True)
358 def clone(self, key: db.Key | str | int, **kwargs):
359 """
360 Clone an existing entry, and render the entry, eventually with error notes on incorrect data.
361 Data is taken by any other arguments in *kwargs*.
363 The function performs several access control checks on the requested entity before it is added.
365 .. seealso:: :func:`canEdit`, :func:`canAdd`, :func:`onClone`, :func:`onCloned`
367 :param key: URL-safe key of the item to be edited.
369 :returns: The cloned object of the entry, eventually with error hints.
371 :raises: :exc:`viur.core.errors.NotAcceptable`, when no valid *skelType* was provided.
372 :raises: :exc:`viur.core.errors.NotFound`, when no *entry* to clone from was found.
373 :raises: :exc:`viur.core.errors.Unauthorized`, if the current user does not have the required permissions.
374 """
376 skel = self.cloneSkel()
377 if not skel.read(key):
378 raise errors.NotFound()
380 # a clone-operation is some kind of edit and add...
381 if not (self.canEdit(skel) and self.canAdd()):
382 raise errors.Unauthorized()
384 # Remember source skel and unset the key for clone operation!
385 src_skel = skel
386 skel = skel.clone(apply_clone_strategy=True)
387 skel["key"] = None
389 # Check all required preconditions for clone
390 if (
391 not kwargs # no data supplied
392 or not current.request.get().isPostRequest # failure if not using POST-method
393 or not skel.fromClient(kwargs) # failure on reading into the bones
394 or utils.parse.bool(kwargs.get("bounce")) # review before changing
395 ):
396 return self.render.edit(skel, action="clone")
398 self.onClone(skel, src_skel=src_skel)
399 assert skel.write()
400 self.onCloned(skel, src_skel=src_skel)
402 return self.render.editSuccess(skel, action="cloneSuccess")
404 ## Default access control functions
406 def listFilter(self, query: db.Query) -> t.Optional[db.Query]:
407 """
408 Access control function on item listing.
410 This function is invoked by the :func:`list` renderer and the related Jinja2 fetching function,
411 and is used to modify the provided filter parameter to match only items that the current user
412 is allowed to see.
414 :param query: Query which should be altered.
416 :returns: The altered filter, or None if access is not granted.
417 """
419 if (user := current.user.get()) and (f"{self.moduleName}-view" in user["access"] or "root" in user["access"]):
420 return query
422 return None
424 def canView(self, skel: SkeletonInstance) -> bool:
425 """
426 Checks if the current user can view the given entry.
427 Should be identical to what's allowed by listFilter.
428 By default, `meth:listFilter` is used to determine what's allowed and whats not; but this
429 method can be overridden for performance improvements (to eliminate that additional database access).
430 :param skel: The entry we check for
431 :return: True if the current session is authorized to view that entry, False otherwise
432 """
433 # We log the key we're querying by hand so we don't have to lock on the entire kind in our query
434 query = self.viewSkel().all(_excludeFromAccessLog=True)
436 if key := skel["key"]:
437 db.currentDbAccessLog.get(set()).add(key)
438 query.mergeExternalFilter({"key": key})
440 query = self.listFilter(query) # Access control
442 if query is None or (key and not query.getEntry()):
443 return False
445 return True
447 def canAdd(self) -> bool:
448 """
449 Access control function for adding permission.
451 Checks if the current user has the permission to add a new entry.
453 The default behavior is:
454 - If no user is logged in, adding is generally refused.
455 - If the user has "root" access, adding is generally allowed.
456 - If the user has the modules "add" permission (module-add) enabled, adding is allowed.
458 It should be overridden for a module-specific behavior.
460 .. seealso:: :func:`add`
462 :returns: True, if adding entries is allowed, False otherwise.
463 """
464 if not (user := current.user.get()):
465 return False
467 # root user is always allowed.
468 if user["access"] and "root" in user["access"]:
469 return True
471 # user with add-permission is allowed.
472 if user and user["access"] and f"{self.moduleName}-add" in user["access"]:
473 return True
475 return False
477 def canPreview(self) -> bool:
478 """
479 Access control function for preview permission.
481 Checks if the current user has the permission to preview an entry.
483 The default behavior is:
484 - If no user is logged in, previewing is generally refused.
485 - If the user has "root" access, previewing is generally allowed.
486 - If the user has the modules "add" or "edit" permission (module-add, module-edit) enabled, \
487 previewing is allowed.
489 It should be overridden for module-specific behavior.
491 .. seealso:: :func:`preview`
493 :returns: True, if previewing entries is allowed, False otherwise.
494 """
495 if not (user := current.user.get()):
496 return False
498 if user["access"] and "root" in user["access"]:
499 return True
501 if (user and user["access"]
502 and (f"{self.moduleName}-add" in user["access"]
503 or f"{self.moduleName}-edit" in user["access"])):
504 return True
506 return False
508 def canEdit(self, skel: SkeletonInstance) -> bool:
509 """
510 Access control function for modification permission.
512 Checks if the current user has the permission to edit an entry.
514 The default behavior is:
515 - If no user is logged in, editing is generally refused.
516 - If the user has "root" access, editing is generally allowed.
517 - If the user has the modules "edit" permission (module-edit) enabled, editing is allowed.
519 It should be overridden for a module-specific behavior.
521 .. seealso:: :func:`edit`
523 :param skel: The Skeleton that should be edited.
525 :returns: True, if editing entries is allowed, False otherwise.
526 """
527 if not (user := current.user.get()):
528 return False
530 if user["access"] and "root" in user["access"]:
531 return True
533 if user and user["access"] and f"{self.moduleName}-edit" in user["access"]:
534 return True
536 return False
538 def canDelete(self, skel: SkeletonInstance) -> bool:
539 """
540 Access control function for delete permission.
542 Checks if the current user has the permission to delete an entry.
544 The default behavior is:
545 - If no user is logged in, deleting is generally refused.
546 - If the user has "root" access, deleting is generally allowed.
547 - If the user has the modules "deleting" permission (module-delete) enabled, \
548 deleting is allowed.
550 It should be overridden for a module-specific behavior.
552 :param skel: The Skeleton that should be deleted.
554 .. seealso:: :func:`delete`
556 :returns: True, if deleting entries is allowed, False otherwise.
557 """
558 if not (user := current.user.get()):
559 return False
561 if user["access"] and "root" in user["access"]:
562 return True
564 if user and user["access"] and f"{self.moduleName}-delete" in user["access"]:
565 return True
567 return False
569 ## Override-able event-hooks
571 def onAdd(self, skel: SkeletonInstance):
572 """
573 Hook function that is called before adding an entry.
575 It can be overridden for a module-specific behavior.
577 :param skel: The Skeleton that is going to be added.
579 .. seealso:: :func:`add`, :func:`onAdded`
580 """
581 pass
583 def onAdded(self, skel: SkeletonInstance):
584 """
585 Hook function that is called after adding an entry.
587 It should be overridden for a module-specific behavior.
588 The default is writing a log entry.
590 :param skel: The Skeleton that has been added.
592 .. seealso:: :func:`add`, , :func:`onAdd`
593 """
594 logging.info(f"""Entry added: {skel["key"]!r}""")
595 flushCache(kind=skel.kindName)
596 if user := current.user.get():
597 logging.info(f"""User: {user["name"]!r} ({user["key"]!r})""")
599 def onEdit(self, skel: SkeletonInstance):
600 """
601 Hook function that is called before editing an entry.
603 It can be overridden for a module-specific behavior.
605 :param skel: The Skeleton that is going to be edited.
607 .. seealso:: :func:`edit`, :func:`onEdited`
608 """
609 pass
611 def onEdited(self, skel: SkeletonInstance):
612 """
613 Hook function that is called after modifying an entry.
615 It should be overridden for a module-specific behavior.
616 The default is writing a log entry.
618 :param skel: The Skeleton that has been modified.
620 .. seealso:: :func:`edit`, :func:`onEdit`
621 """
622 logging.info(f"""Entry changed: {skel["key"]!r}""")
623 flushCache(key=skel["key"])
624 if user := current.user.get():
625 logging.info(f"""User: {user["name"]!r} ({user["key"]!r})""")
627 def onView(self, skel: SkeletonInstance):
628 """
629 Hook function that is called when viewing an entry.
631 It should be overridden for a module-specific behavior.
632 The default is doing nothing.
634 :param skel: The Skeleton that is viewed.
636 .. seealso:: :func:`view`
637 """
638 pass
640 def onDelete(self, skel: SkeletonInstance):
641 """
642 Hook function that is called before deleting an entry.
644 It can be overridden for a module-specific behavior.
646 :param skel: The Skeleton that is going to be deleted.
648 .. seealso:: :func:`delete`, :func:`onDeleted`
649 """
650 pass
652 def onDeleted(self, skel: SkeletonInstance):
653 """
654 Hook function that is called after deleting an entry.
656 It should be overridden for a module-specific behavior.
657 The default is writing a log entry.
659 :param skel: The Skeleton that has been deleted.
661 .. seealso:: :func:`delete`, :func:`onDelete`
662 """
663 logging.info(f"""Entry deleted: {skel["key"]!r}""")
664 flushCache(key=skel["key"])
665 if user := current.user.get():
666 logging.info(f"""User: {user["name"]!r} ({user["key"]!r})""")
668 def onClone(self, skel: SkeletonInstance, src_skel: SkeletonInstance):
669 """
670 Hook function that is called before cloning an entry.
672 It can be overwritten to a module-specific behavior.
674 :param skel: The new SkeletonInstance that is being created.
675 :param src_skel: The source SkeletonInstance `skel` is cloned from.
677 .. seealso:: :func:`clone`, :func:`onCloned`
678 """
679 pass
681 def onCloned(self, skel: SkeletonInstance, src_skel: SkeletonInstance):
682 """
683 Hook function that is called after cloning an entry.
685 It can be overwritten to a module-specific behavior.
687 :param skel: The new SkeletonInstance that was created.
688 :param src_skel: The source SkeletonInstance `skel` was cloned from.
690 .. seealso:: :func:`clone`, :func:`onClone`
691 """
692 logging.info(f"""Entry cloned: {skel["key"]!r}""")
693 flushCache(kind=skel.kindName)
695 if user := current.user.get():
696 logging.info(f"""User: {user["name"]!r} ({user["key"]!r})""")
699List.admin = True
700List.vi = True