Coverage for / home / runner / work / viur-core / viur-core / viur / src / viur / core / bones / base.py: 30%
831 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-10 10:16 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-10 10:16 +0000
1"""
2This module contains the base classes for the bones in ViUR. Bones are the fundamental building blocks of
3ViUR's data structures, representing the fields and their properties in the entities managed by the
4framework. The base classes defined in this module are the foundation upon which specific bone types are
5built, such as string, numeric, and date/time bones.
6"""
8import copy
9import enum
10import hashlib
11import inspect
12import logging
13import typing as t
14from collections.abc import Iterable
15from dataclasses import dataclass, field
16from datetime import timedelta
17from enum import Enum
19from viur.core import current, db, i18n, utils
20from viur.core.config import conf
22if t.TYPE_CHECKING: 22 ↛ 23line 22 didn't jump to line 23 because the condition on line 22 was never true
23 from ..skeleton import Skeleton, SkeletonInstance
25__system_initialized = False
26"""
27Initializes the global variable __system_initialized
28"""
31def setSystemInitialized():
32 """
33 Sets the global __system_initialized variable to True, indicating that the system is
34 initialized and ready for use. This function should be called once all necessary setup
35 tasks have been completed. It also iterates over all skeleton classes and calls their
36 setSystemInitialized() method.
38 Global variables:
39 __system_initialized: A boolean flag indicating if the system is initialized.
40 """
41 global __system_initialized
42 from viur.core.skeleton import iterAllSkelClasses
44 for skelCls in iterAllSkelClasses():
45 skelCls.setSystemInitialized()
47 __system_initialized = True
50def getSystemInitialized():
51 """
52 Retrieves the current state of the system initialization by returning the value of the
53 global variable __system_initialized.
54 """
55 global __system_initialized
56 return __system_initialized
59class ReadFromClientErrorSeverity(Enum):
60 """
61 ReadFromClientErrorSeverity is an enumeration that represents the severity levels of errors
62 that can occur while reading data from the client.
63 """
64 NotSet = 0
65 """No error occurred"""
66 InvalidatesOther = 1
67 # TODO: what is this error about?
68 """The data is valid, for this bone, but in relation to other invalid"""
69 Empty = 2
70 """The data is empty, but the bone requires a value"""
71 Invalid = 3
72 """The data is invalid, but the bone requires a value"""
75@dataclass
76class ReadFromClientError:
77 """
78 The ReadFromClientError class represents an error that occurs while reading data from the client.
79 This class is used to store information about the error, including its severity, an error message,
80 the field path where the error occurred, and a list of invalidated fields.
81 """
82 severity: ReadFromClientErrorSeverity
83 """A ReadFromClientErrorSeverity enumeration value representing the severity of the error."""
84 errorMessage: t.Optional[str] = None
85 """A string containing a human-readable error message describing the issue."""
86 fieldPath: list[str] = field(default_factory=list)
87 """A list of strings representing the path to the field where the error occurred."""
88 invalidatedFields: list[str] = None
89 """A list of strings containing the names of invalidated fields, if any."""
91 def __post_init__(self):
92 if not self.errorMessage:
93 self.errorMessage = {
94 ReadFromClientErrorSeverity.NotSet:
95 i18n.translate("core.bones.error.notset", "Field not submitted"),
96 ReadFromClientErrorSeverity.InvalidatesOther:
97 i18n.translate("core.bones.error.invalidatesother", "Field invalidates another field"),
98 ReadFromClientErrorSeverity.Empty:
99 i18n.translate("core.bones.error.empty", "Field not set"),
100 ReadFromClientErrorSeverity.Invalid:
101 i18n.translate("core.bones.error.invalid", "Invalid value provided"),
102 }[self.severity]
104 def __str__(self):
105 return f"{'.'.join(self.fieldPath)}: {self.errorMessage} [{self.severity.name}]"
108class ReadFromClientException(Exception):
109 """
110 ReadFromClientError as an Exception to raise.
111 """
113 def __init__(self, errors: ReadFromClientError | t.Iterable[ReadFromClientError]):
114 """
115 This is an exception holding ReadFromClientErrors.
117 :param errors: Either one or an iterable of errors.
118 """
119 super().__init__()
121 # Allow to specifiy a single ReadFromClientError
122 if isinstance(errors, ReadFromClientError):
123 errors = (ReadFromClientError, )
125 self.errors = tuple(error for error in errors if isinstance(error, ReadFromClientError))
127 # Disallow ReadFromClientException without any ReadFromClientErrors
128 if not self.errors:
129 raise ValueError("ReadFromClientException requires for at least one ReadFromClientError")
131 # Either show any errors with severity greater ReadFromClientErrorSeverity.NotSet to the Exception notes,
132 # or otherwise all errors (all have ReadFromClientErrorSeverity.NotSet then)
133 notes_errors = tuple(
134 error for error in self.errors if error.severity.value > ReadFromClientErrorSeverity.NotSet.value
135 )
137 self.add_note("\n".join(str(error) for error in notes_errors or self.errors))
140class UniqueLockMethod(Enum):
141 """
142 UniqueLockMethod is an enumeration that represents different locking methods for unique constraints
143 on bones. This is used to specify how the uniqueness of a value or a set of values should be
144 enforced.
145 """
146 SameValue = 1 # Lock this value for just one entry or each value individually if bone is multiple
147 """
148 Lock this value so that there is only one entry, or lock each value individually if the bone
149 is multiple.
150 """
151 SameSet = 2 # Same Set of entries (including duplicates), any order
152 """Lock the same set of entries (including duplicates) regardless of their order."""
153 SameList = 3 # Same Set of entries (including duplicates), in this specific order
154 """Lock the same set of entries (including duplicates) in a specific order."""
157@dataclass
158class UniqueValue: # Mark a bone as unique (it must have a different value for each entry)
159 """
160 The UniqueValue class represents a unique constraint on a bone, ensuring that it must have a
161 different value for each entry. This class is used to store information about the unique
162 constraint, such as the locking method, whether to lock empty values, and an error message to
163 display to the user if the requested value is already taken.
164 """
165 method: UniqueLockMethod # How to handle multiple values (for bones with multiple=True)
166 """
167 A UniqueLockMethod enumeration value specifying how to handle multiple values for bones with
168 multiple=True.
169 """
170 lockEmpty: bool # If False, empty values ("", 0) are not locked - needed if unique but not required
171 """
172 A boolean value indicating if empty values ("", 0) should be locked. If False, empty values are not
173 locked, which is needed if a field is unique but not required.
174 """
175 message: str # Error-Message displayed to the user if the requested value is already taken
176 """
177 A string containing an error message displayed to the user if the requested value is already
178 taken.
179 """
182@dataclass
183class MultipleConstraints:
184 """
185 The MultipleConstraints class is used to define constraints on multiple bones, such as the minimum
186 and maximum number of entries allowed and whether value duplicates are allowed.
187 """
188 min: int = 0
189 """An integer representing the lower bound of how many entries can be submitted (default: 0)."""
190 max: int = 0
191 """An integer representing the upper bound of how many entries can be submitted (default: 0 = unlimited)."""
192 duplicates: bool = False
193 """A boolean indicating if the same value can be used multiple times (default: False)."""
194 sorted: bool | t.Callable = False
195 """A boolean value or a method indicating if the value must be sorted (default: False)."""
196 reversed: bool = False
197 """
198 A boolean value indicating if sorted values shall be sorted in reversed order (default: False).
199 It is only applied when the `sorted`-flag is set accordingly.
200 """
203class ComputeMethod(Enum):
204 Always = 0
205 """Always compute on deserialization"""
206 Lifetime = 1
207 """Update only when given lifetime is outrun; value is only being stored when the skeleton is written"""
208 Once = 2
209 """Compute only once, when it is unset"""
210 OnWrite = 3
211 """Compute before every write of the skeleton"""
214@dataclass
215class ComputeInterval:
216 method: ComputeMethod = ComputeMethod.Always
217 """The compute-method to use for this bone"""
218 lifetime: timedelta = None
219 """Defines a timedelta until which the value stays valid (only used by `ComputeMethod.Lifetime`)"""
222@dataclass
223class Compute:
224 fn: callable
225 """The callable computing the value"""
226 interval: ComputeInterval = field(default_factory=ComputeInterval)
227 """The value caching interval"""
228 raw: bool = True
229 """Defines whether the value returned by fn is used as is, or is passed through `bone.fromClient()`"""
232class CloneStrategy(enum.StrEnum):
233 """Strategy for selecting the value of a cloned skeleton"""
235 SET_NULL = enum.auto()
236 """Sets the cloned bone value to None."""
238 SET_DEFAULT = enum.auto()
239 """Sets the cloned bone value to its defaultValue."""
241 SET_EMPTY = enum.auto()
242 """Sets the cloned bone value to its emptyValue."""
244 COPY_VALUE = enum.auto()
245 """Copies the bone value from the source skeleton."""
247 CUSTOM = enum.auto()
248 """Uses a custom-defined logic for setting the cloned value.
249 Requires :attr:`CloneBehavior.custom_func` to be set.
250 """
253class CloneCustomFunc(t.Protocol):
254 """Type for a custom clone function assigned to :attr:`CloneBehavior.custom_func`"""
256 def __call__(self, skel: "SkeletonInstance", src_skel: "SkeletonInstance", bone_name: str) -> t.Any:
257 """Return the value for the cloned bone"""
258 ...
261@dataclass
262class CloneBehavior:
263 """Strategy configuration for selecting the value of a cloned skeleton"""
265 strategy: CloneStrategy
266 """The strategy used to select a value from a cloned skeleton"""
268 custom_func: CloneCustomFunc = None
269 """custom-defined logic for setting the cloned value
270 Only required when :attr:`strategy` is set to :attr:`CloneStrategy.CUSTOM`.
271 """
273 def __post_init__(self):
274 """Validate this configuration."""
275 if self.strategy == CloneStrategy.CUSTOM and self.custom_func is None: 275 ↛ 276line 275 didn't jump to line 276 because the condition on line 275 was never true
276 raise ValueError("CloneStrategy is CUSTOM, but custom_func is not set")
277 elif self.strategy != CloneStrategy.CUSTOM and self.custom_func is not None: 277 ↛ 278line 277 didn't jump to line 278 because the condition on line 277 was never true
278 raise ValueError("custom_func is set, but CloneStrategy is not CUSTOM")
281class BaseBone(object):
282 """
283 The BaseBone class serves as the base class for all bone types in the ViUR framework.
284 It defines the core functionality and properties that all bones should implement.
286 :param descr: Textual, human-readable description of that bone. Will be translated.
287 :param defaultValue: If set, this bone will be preinitialized with this value
288 :param required: If True, the user must enter a valid value for this bone (the viur.core refuses
289 to save the skeleton otherwise). If a list/tuple of languages (strings) is provided, these
290 language must be entered.
291 :param multiple: If True, multiple values can be given. (ie. n:m relations instead of n:1)
292 :param searchable: If True, this bone will be included in the fulltext search. Can be used
293 without the need of also been indexed.
294 :param type_suffix: Allows to specify an optional suffix for the bone-type, for bone customization
295 :param vfunc: If given, a callable validating the user-supplied value for this bone.
296 This callable must return None if the value is valid, a String containing an meaningful
297 error-message for the user otherwise.
298 :param readOnly: If True, the user is unable to change the value of this bone. If a value for this
299 bone is given along the POST-Request during Add/Edit, this value will be ignored. Its still
300 possible for the developer to modify this value by assigning skel.bone.value.
301 :param visible: If False, the value of this bone should be hidden from the user. This does
302 *not* protect the value from being exposed in a template, nor from being transferred
303 to the client (ie to the admin or as hidden-value in html-form)
304 :param compute: If set, the bone's value will be computed in the given method.
306 .. NOTE::
307 The kwarg 'multiple' is not supported by all bones
308 """
309 type = "hidden"
310 isClonedInstance = False
312 skel_cls = None
313 """Skeleton class to which this bone instance belongs"""
315 name = None
316 """Name of this bone (attribute name in the skeletons containing this bone)"""
318 def __init__(
319 self,
320 *,
321 compute: Compute = None,
322 defaultValue: t.Any = None,
323 descr: t.Optional[str | i18n.translate] = None,
324 getEmptyValueFunc: callable = None,
325 indexed: bool = True,
326 isEmptyFunc: callable = None, # fixme: Rename this, see below.
327 languages: None | list[str] = None,
328 multiple: bool | MultipleConstraints = False,
329 params: dict = None,
330 readOnly: bool = None, # fixme: Rename into readonly (all lowercase!) soon.
331 required: bool | list[str] | tuple[str] = False,
332 searchable: bool = False,
333 type_suffix: str = "",
334 unique: None | UniqueValue = None,
335 vfunc: callable = None, # fixme: Rename this, see below.
336 visible: bool = True,
337 clone_behavior: CloneBehavior | CloneStrategy | None = None,
338 ):
339 """
340 Initializes a new Bone.
341 """
342 self.isClonedInstance = getSystemInitialized()
344 # Standard definitions
345 self.descr = descr
346 self.params = params or {}
347 self.multiple = multiple
348 self.required = required
349 self.readOnly = bool(readOnly)
350 self.searchable = searchable
351 self.visible = visible
352 self.indexed = indexed
354 if type_suffix: 354 ↛ 355line 354 didn't jump to line 355 because the condition on line 354 was never true
355 self.type += f".{type_suffix}"
357 if conf.i18n.auto_translate_bones and isinstance(category := self.params.get("category"), str): 357 ↛ 358line 357 didn't jump to line 358 because the condition on line 357 was never true
358 self.params["category"] = i18n.translate(category, hint=f"category of a <{type(self).__name__}>")
360 # Multi-language support
361 if not ( 361 ↛ 366line 361 didn't jump to line 366 because the condition on line 361 was never true
362 languages is None or
363 (isinstance(languages, list) and len(languages) > 0
364 and all([isinstance(x, str) for x in languages]))
365 ):
366 raise ValueError("languages must be None or a list of strings")
368 if languages and "__default__" in languages: 368 ↛ 369line 368 didn't jump to line 369 because the condition on line 368 was never true
369 raise ValueError("__default__ is not supported as a language")
371 if ( 371 ↛ 375line 371 didn't jump to line 375 because the condition on line 371 was never true
372 not isinstance(required, bool)
373 and (not isinstance(required, (tuple, list)) or any(not isinstance(value, str) for value in required))
374 ):
375 raise TypeError(f"required must be boolean or a tuple/list of strings. Got: {required!r}")
377 if isinstance(required, (tuple, list)) and not languages: 377 ↛ 378line 377 didn't jump to line 378 because the condition on line 377 was never true
378 raise ValueError("You set required to a list of languages, but defined no languages.")
380 if isinstance(required, (tuple, list)) and languages and (diff := set(required).difference(languages)): 380 ↛ 381line 380 didn't jump to line 381 because the condition on line 380 was never true
381 raise ValueError(f"The language(s) {', '.join(map(repr, diff))} can not be required, "
382 f"because they're not defined.")
384 if callable(defaultValue): 384 ↛ 386line 384 didn't jump to line 386 because the condition on line 384 was never true
385 # check if the signature of defaultValue can bind two (fictive) parameters.
386 try:
387 inspect.signature(defaultValue).bind("skel", "bone") # the strings are just for the test!
388 except TypeError:
389 raise ValueError(f"Callable {defaultValue=} requires for the parameters 'skel' and 'bone'.")
391 self.languages = languages
393 # Default value
394 # Convert a None default-value to the empty container that's expected if the bone is
395 # multiple or has languages
396 default = [] if defaultValue is None and self.multiple else defaultValue
397 if self.languages:
398 if callable(defaultValue): 398 ↛ 399line 398 didn't jump to line 399 because the condition on line 398 was never true
399 self.defaultValue = defaultValue
400 elif not isinstance(defaultValue, dict): 400 ↛ 402line 400 didn't jump to line 402 because the condition on line 400 was always true
401 self.defaultValue = {lang: default for lang in self.languages}
402 elif "__default__" in defaultValue:
403 self.defaultValue = {lang: defaultValue.get(lang, defaultValue["__default__"])
404 for lang in self.languages}
405 else:
406 self.defaultValue = defaultValue # default will have the same value at this point
407 else:
408 self.defaultValue = default
410 # Unique values
411 if unique: 411 ↛ 412line 411 didn't jump to line 412 because the condition on line 411 was never true
412 if not isinstance(unique, UniqueValue):
413 raise ValueError("Unique must be an instance of UniqueValue")
414 if not self.multiple and unique.method.value != 1:
415 raise ValueError("'SameValue' is the only valid method on non-multiple bones")
417 self.unique = unique
419 # Overwrite some validations and value functions by parameter instead of subclassing
420 # todo: This can be done better and more straightforward.
421 if vfunc:
422 self.isInvalid = vfunc # fixme: why is this called just vfunc, and not isInvalidValue/isInvalidValueFunc?
424 if isEmptyFunc: 424 ↛ 425line 424 didn't jump to line 425 because the condition on line 424 was never true
425 self.isEmpty = isEmptyFunc # fixme: why is this not called isEmptyValue/isEmptyValueFunc?
427 if getEmptyValueFunc:
428 self.getEmptyValue = getEmptyValueFunc
430 if compute:
431 if not isinstance(compute, Compute): 431 ↛ 432line 431 didn't jump to line 432 because the condition on line 431 was never true
432 raise TypeError("compute must be an instanceof of Compute")
433 if not isinstance(compute.fn, t.Callable): 433 ↛ 434line 433 didn't jump to line 434 because the condition on line 433 was never true
434 raise ValueError("'compute.fn' must be callable")
435 # When readOnly is None, handle flag automatically
436 if readOnly is None:
437 self.readOnly = True
438 if not self.readOnly: 438 ↛ 439line 438 didn't jump to line 439 because the condition on line 438 was never true
439 raise ValueError("'compute' can only be used with bones configured as `readOnly=True`")
441 if ( 441 ↛ 445line 441 didn't jump to line 445 because the condition on line 441 was never true
442 compute.interval.method == ComputeMethod.Lifetime
443 and not isinstance(compute.interval.lifetime, timedelta)
444 ):
445 raise ValueError(
446 f"'compute' is configured as ComputeMethod.Lifetime, but {compute.interval.lifetime=} was specified"
447 )
448 # If a RelationalBone is computed and raw is False, the unserialize function is called recursively
449 # and the value is recalculated all the time. This parameter is to prevent this.
450 self._prevent_compute = False
452 self.compute = compute
454 if clone_behavior is None: # auto choose 454 ↛ 460line 454 didn't jump to line 460 because the condition on line 454 was always true
455 if self.unique and self.readOnly: 455 ↛ 456line 455 didn't jump to line 456 because the condition on line 455 was never true
456 self.clone_behavior = CloneBehavior(CloneStrategy.SET_DEFAULT)
457 else:
458 self.clone_behavior = CloneBehavior(CloneStrategy.COPY_VALUE)
459 # TODO: Any different setting for computed bones?
460 elif isinstance(clone_behavior, CloneStrategy):
461 self.clone_behavior = CloneBehavior(strategy=clone_behavior)
462 elif isinstance(clone_behavior, CloneBehavior):
463 self.clone_behavior = clone_behavior
464 else:
465 raise TypeError(f"'clone_behavior' must be an instance of Clone, but {clone_behavior=} was specified")
467 def __set_name__(self, owner: "Skeleton", name: str) -> None:
468 self.skel_cls = owner
469 self.name = name
471 def setSystemInitialized(self) -> None:
472 """
473 For the BaseBone, this performs some automatisms regarding bone descr and translations.
474 It can be overwritten to initialize properties that depend on the Skeleton system being initialized.
475 """
477 # Set descr to the bone_name if no descr argument is given
478 if self.descr is None:
479 # TODO: The super().__setattr__() call is kinda hackish,
480 # but unfortunately viur-core has no *during system initialisation* state
481 super().__setattr__("descr", self.name or "")
483 if conf.i18n.auto_translate_bones and self.descr and isinstance(self.descr, str):
484 # Make sure that it is a :class:i18n.translate` object.
485 super().__setattr__(
486 "descr",
487 i18n.translate(self.descr, hint=f"descr of a <{type(self).__name__}>{self.name}")
488 )
490 def isInvalid(self, value):
491 """
492 Checks if the current value of the bone in the given skeleton is invalid.
493 Returns None if the value would be valid for this bone, an error-message otherwise.
494 """
495 return False
497 def isEmpty(self, value: t.Any) -> bool:
498 """
499 Check if the given single value represents the "empty" value.
500 This usually is the empty string, 0 or False.
502 .. warning:: isEmpty takes precedence over isInvalid! The empty value is always
503 valid - unless the bone is required.
504 But even then the empty value will be reflected back to the client.
506 .. warning:: value might be the string/object received from the user (untrusted
507 input!) or the value returned by get
508 """
509 return not bool(value)
511 def getDefaultValue(self, skeletonInstance):
512 """
513 Retrieves the default value for the bone.
515 This method is called by the framework to obtain the default value of a bone when no value
516 is provided. Derived bone classes can overwrite this method to implement their own logic for
517 providing a default value.
519 :return: The default value of the bone, which can be of any data type.
520 """
521 if callable(self.defaultValue):
522 res = self.defaultValue(skeletonInstance, self)
523 if self.languages and self.multiple:
524 if not isinstance(res, dict):
525 if not isinstance(res, (list, set, tuple)):
526 return {lang: [res] for lang in self.languages}
527 else:
528 return {lang: res for lang in self.languages}
529 elif self.languages:
530 if not isinstance(res, dict):
531 return {lang: res for lang in self.languages}
532 elif self.multiple:
533 if not isinstance(res, (list, set, tuple)):
534 return [res]
535 return res
537 elif isinstance(self.defaultValue, list):
538 return self.defaultValue[:]
539 elif isinstance(self.defaultValue, dict):
540 return self.defaultValue.copy()
541 else:
542 return self.defaultValue
544 def getEmptyValue(self) -> t.Any:
545 """
546 Returns the value representing an empty field for this bone.
547 This might be the empty string for str/text Bones, Zero for numeric bones etc.
548 """
549 return None
551 def __setattr__(self, key, value):
552 """
553 Custom attribute setter for the BaseBone class.
555 This method is used to ensure that certain bone attributes, such as 'multiple', are only
556 set once during the bone's lifetime. Derived bone classes should not need to overwrite this
557 method unless they have additional attributes with similar constraints.
559 :param key: A string representing the attribute name.
560 :param value: The value to be assigned to the attribute.
562 :raises AttributeError: If a protected attribute is attempted to be modified after its initial
563 assignment.
564 """
565 if not self.isClonedInstance and getSystemInitialized() and key != "isClonedInstance" and not key.startswith( 565 ↛ 567line 565 didn't jump to line 567 because the condition on line 565 was never true
566 "_"):
567 raise AttributeError("You cannot modify this Skeleton. Grab a copy using .clone() first")
568 super().__setattr__(key, value)
570 def collectRawClientData(self, name, data, multiple, languages, collectSubfields):
571 """
572 Collects raw client data for the bone and returns it in a dictionary.
574 This method is called by the framework to gather raw data from the client, such as form data or data from a
575 request. Derived bone classes should overwrite this method to implement their own logic for collecting raw data.
577 :param name: A string representing the bone's name.
578 :param data: A dictionary containing the raw data from the client.
579 :param multiple: A boolean indicating whether the bone supports multiple values.
580 :param languages: An optional list of strings representing the supported languages (default: None).
581 :param collectSubfields: A boolean indicating whether to collect data for subfields (default: False).
583 :return: A dictionary containing the collected raw client data.
584 """
585 fieldSubmitted = False
587 if languages:
588 res = {}
589 for lang in languages:
590 if not collectSubfields: 590 ↛ 602line 590 didn't jump to line 602 because the condition on line 590 was always true
591 if f"{name}.{lang}" in data:
592 fieldSubmitted = True
593 res[lang] = data[f"{name}.{lang}"]
594 if multiple and not isinstance(res[lang], list): 594 ↛ 595line 594 didn't jump to line 595 because the condition on line 594 was never true
595 res[lang] = [res[lang]]
596 elif not multiple and isinstance(res[lang], list): 596 ↛ 597line 596 didn't jump to line 597 because the condition on line 596 was never true
597 if res[lang]:
598 res[lang] = res[lang][0]
599 else:
600 res[lang] = None
601 else:
602 for key in data.keys(): # Allow setting relations with using, multiple and languages back to none
603 if key == f"{name}.{lang}":
604 fieldSubmitted = True
605 prefix = f"{name}.{lang}."
606 if multiple:
607 tmpDict = {}
608 for key, value in data.items():
609 if not key.startswith(prefix):
610 continue
611 fieldSubmitted = True
612 partKey = key[len(prefix):]
613 firstKey, remainingKey = partKey.split(".", maxsplit=1)
614 try:
615 firstKey = int(firstKey)
616 except:
617 continue
618 if firstKey not in tmpDict:
619 tmpDict[firstKey] = {}
620 tmpDict[firstKey][remainingKey] = value
621 tmpList = list(tmpDict.items())
622 tmpList.sort(key=lambda x: x[0])
623 res[lang] = [x[1] for x in tmpList]
624 else:
625 tmpDict = {}
626 for key, value in data.items():
627 if not key.startswith(prefix):
628 continue
629 fieldSubmitted = True
630 partKey = key[len(prefix):]
631 tmpDict[partKey] = value
632 res[lang] = tmpDict
633 return res, fieldSubmitted
634 else: # No multi-lang
635 if not collectSubfields: 635 ↛ 649line 635 didn't jump to line 649 because the condition on line 635 was always true
636 if name not in data: # Empty! 636 ↛ 637line 636 didn't jump to line 637 because the condition on line 636 was never true
637 return None, False
638 val = data[name]
639 if multiple and not isinstance(val, list): 639 ↛ 640line 639 didn't jump to line 640 because the condition on line 639 was never true
640 return [val], True
641 elif not multiple and isinstance(val, list): 641 ↛ 642line 641 didn't jump to line 642 because the condition on line 641 was never true
642 if val:
643 return val[0], True
644 else:
645 return None, True # Empty!
646 else:
647 return val, True
648 else: # No multi-lang but collect subfields
649 for key in data.keys(): # Allow setting relations with using, multiple and languages back to none
650 if key == name:
651 fieldSubmitted = True
652 prefix = f"{name}."
653 if multiple:
654 tmpDict = {}
655 for key, value in data.items():
656 if not key.startswith(prefix):
657 continue
658 fieldSubmitted = True
659 partKey = key[len(prefix):]
660 try:
661 firstKey, remainingKey = partKey.split(".", maxsplit=1)
662 firstKey = int(firstKey)
663 except:
664 continue
665 if firstKey not in tmpDict:
666 tmpDict[firstKey] = {}
667 tmpDict[firstKey][remainingKey] = value
668 tmpList = list(tmpDict.items())
669 tmpList.sort(key=lambda x: x[0])
670 return [x[1] for x in tmpList], fieldSubmitted
671 else:
672 res = {}
673 for key, value in data.items():
674 if not key.startswith(prefix):
675 continue
676 fieldSubmitted = True
677 subKey = key[len(prefix):]
678 res[subKey] = value
679 return res, fieldSubmitted
681 def parseSubfieldsFromClient(self) -> bool:
682 """
683 Determines whether the function should parse subfields submitted by the client.
684 Set to True only when expecting a list of dictionaries to be transmitted.
685 """
686 return False
688 def singleValueFromClient(self, value: t.Any, skel: 'SkeletonInstance',
689 bone_name: str, client_data: dict
690 ) -> tuple[t.Any, list[ReadFromClientError] | None]:
691 """Load a single value from a client
693 :param value: The single value which should be loaded.
694 :param skel: The SkeletonInstance where the value should be loaded into.
695 :param bone_name: The bone name of this bone in the SkeletonInstance.
696 :param client_data: The data taken from the client,
697 a dictionary with usually bone names as key
698 :return: A tuple. If the value is valid, the first element is
699 the parsed value and the second is None.
700 If the value is invalid or not parseable, the first element is a empty value
701 and the second a list of *ReadFromClientError*.
702 """
703 # The BaseBone will not read any client_data in fromClient. Use rawValueBone if needed.
704 return self.getEmptyValue(), [
705 ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Will not read a BaseBone from client!")
706 ]
708 def fromClient(self, skel: 'SkeletonInstance', name: str, data: dict) -> None | list[ReadFromClientError]:
709 """
710 Reads a value from the client and stores it in the skeleton instance if it is valid for the bone.
712 This function reads a value from the client and processes it according to the bone's configuration.
713 If the value is valid for the bone, it stores the value in the skeleton instance and returns None.
714 Otherwise, the previous value remains unchanged, and a list of ReadFromClientError objects is returned.
716 :param skel: A SkeletonInstance object where the values should be loaded.
717 :param name: A string representing the bone's name.
718 :param data: A dictionary containing the raw data from the client.
719 :return: None if no errors occurred, otherwise a list of ReadFromClientError objects.
720 """
721 subFields = self.parseSubfieldsFromClient()
722 parsedData, fieldSubmitted = self.collectRawClientData(name, data, self.multiple, self.languages, subFields)
723 if not fieldSubmitted: 723 ↛ 724line 723 didn't jump to line 724 because the condition on line 723 was never true
724 return [ReadFromClientError(ReadFromClientErrorSeverity.NotSet)]
726 errors = []
727 isEmpty = True
728 filled_languages = set()
729 if self.languages and self.multiple:
730 res = {}
731 for language in self.languages:
732 res[language] = []
733 if language in parsedData:
734 for idx, singleValue in enumerate(parsedData[language]):
735 if self.isEmpty(singleValue): 735 ↛ 736line 735 didn't jump to line 736 because the condition on line 735 was never true
736 continue
737 isEmpty = False
738 filled_languages.add(language)
739 parsedVal, parseErrors = self.singleValueFromClient(singleValue, skel, name, data)
740 res[language].append(parsedVal)
741 if isinstance(self.multiple, MultipleConstraints) and self.multiple.sorted: 741 ↛ 742line 741 didn't jump to line 742 because the condition on line 741 was never true
742 if callable(self.multiple.sorted):
743 res[language] = sorted(
744 res[language],
745 key=self.multiple.sorted,
746 reverse=self.multiple.reversed,
747 )
748 else:
749 res[language] = sorted(res[language], reverse=self.multiple.reversed)
750 if parseErrors: 750 ↛ 751line 750 didn't jump to line 751 because the condition on line 750 was never true
751 for parseError in parseErrors:
752 parseError.fieldPath[:0] = [language, str(idx)]
753 errors.extend(parseErrors)
754 elif self.languages: # and not self.multiple is implicit - this would have been handled above
755 res = {}
756 for language in self.languages:
757 res[language] = None
758 if language in parsedData:
759 if self.isEmpty(parsedData[language]): 759 ↛ 760line 759 didn't jump to line 760 because the condition on line 759 was never true
760 res[language] = self.getEmptyValue()
761 continue
762 isEmpty = False
763 filled_languages.add(language)
764 parsedVal, parseErrors = self.singleValueFromClient(parsedData[language], skel, name, data)
765 res[language] = parsedVal
766 if parseErrors: 766 ↛ 767line 766 didn't jump to line 767 because the condition on line 766 was never true
767 for parseError in parseErrors:
768 parseError.fieldPath.insert(0, language)
769 errors.extend(parseErrors)
770 elif self.multiple: # and not self.languages is implicit - this would have been handled above
771 res = []
772 for idx, singleValue in enumerate(parsedData):
773 if self.isEmpty(singleValue): 773 ↛ 774line 773 didn't jump to line 774 because the condition on line 773 was never true
774 continue
775 isEmpty = False
776 parsedVal, parseErrors = self.singleValueFromClient(singleValue, skel, name, data)
777 res.append(parsedVal)
779 if parseErrors: 779 ↛ 780line 779 didn't jump to line 780 because the condition on line 779 was never true
780 for parseError in parseErrors:
781 parseError.fieldPath.insert(0, str(idx))
782 errors.extend(parseErrors)
783 if isinstance(self.multiple, MultipleConstraints) and self.multiple.sorted: 783 ↛ 784line 783 didn't jump to line 784 because the condition on line 783 was never true
784 if callable(self.multiple.sorted):
785 res = sorted(res, key=self.multiple.sorted, reverse=self.multiple.reversed)
786 else:
787 res = sorted(res, reverse=self.multiple.reversed)
788 else: # No Languages, not multiple
789 if self.isEmpty(parsedData):
790 res = self.getEmptyValue()
791 isEmpty = True
792 else:
793 isEmpty = False
794 res, parseErrors = self.singleValueFromClient(parsedData, skel, name, data)
795 if parseErrors:
796 errors.extend(parseErrors)
797 skel[name] = res
798 if self.languages and isinstance(self.required, (list, tuple)): 798 ↛ 799line 798 didn't jump to line 799 because the condition on line 798 was never true
799 missing = set(self.required).difference(filled_languages)
800 if missing:
801 return [
802 ReadFromClientError(ReadFromClientErrorSeverity.Empty, fieldPath=[lang])
803 for lang in missing
804 ]
806 if isEmpty:
807 return [ReadFromClientError(ReadFromClientErrorSeverity.Empty)]
809 # Check multiple constraints on demand
810 if self.multiple and isinstance(self.multiple, MultipleConstraints): 810 ↛ 811line 810 didn't jump to line 811 because the condition on line 810 was never true
811 errors.extend(self._validate_multiple_contraints(self.multiple, skel, name))
813 return errors or None
815 def _get_single_destinct_hash(self, value) -> t.Any:
816 """
817 Returns a distinct hash value for a single entry of this bone.
818 The returned value must be hashable.
819 """
820 return value
822 def _get_destinct_hash(self, skel: 'SkeletonInstance', name: str) -> t.Any:
823 """
824 Returns a distinct hash value for this bone.
825 The returned value must be hashable.
826 """
827 values = []
828 for _, _, value in self.iter_bone_value(skel, name):
829 values.append(self._get_single_destinct_hash(value))
831 return tuple(values)
833 def _validate_multiple_contraints(
834 self,
835 constraints: MultipleConstraints,
836 skel: 'SkeletonInstance',
837 name: str
838 ) -> list[ReadFromClientError]:
839 """
840 Validates the value of a bone against its multiple constraints and returns a list of ReadFromClientError
841 objects for each violation, such as too many items or duplicates.
843 :param constraints: The MultipleConstraints definition to apply.
844 :param skel: A SkeletonInstance object where the values should be validated.
845 :param name: A string representing the bone's name.
846 :return: A list of ReadFromClientError objects for each constraint violation.
847 """
848 res = []
849 value = self._get_destinct_hash(skel, name)
851 if constraints.min and len(value) < constraints.min:
852 res.append(
853 ReadFromClientError(
854 ReadFromClientErrorSeverity.Invalid,
855 i18n.translate("core.bones.error.toofewitems", "Too few items")
856 )
857 )
859 if constraints.max and len(value) > constraints.max:
860 res.append(
861 ReadFromClientError(
862 ReadFromClientErrorSeverity.Invalid,
863 i18n.translate("core.bones.error.toomanyitems", "Too many items")
864 )
865 )
867 if not constraints.duplicates:
868 if len(set(value)) != len(value):
869 res.append(
870 ReadFromClientError(
871 ReadFromClientErrorSeverity.Invalid,
872 i18n.translate("core.bones.error.duplicateitems", "Duplicate items"),
873 )
874 )
876 return res
878 def singleValueSerialize(self, value, skel: 'SkeletonInstance', name: str, parentIndexed: bool):
879 """
880 Serializes a single value of the bone for storage in the database.
882 Derived bone classes should overwrite this method to implement their own logic for serializing single
883 values.
884 The serialized value should be suitable for storage in the database.
885 """
886 return value
888 def serialize(self, skel: 'SkeletonInstance', name: str, parentIndexed: bool) -> bool:
889 """
890 Serializes this bone into a format that can be written into the datastore.
892 :param skel: A SkeletonInstance object containing the values to be serialized.
893 :param name: A string representing the property name of the bone in its Skeleton (not the description).
894 :param parentIndexed: A boolean indicating whether the parent bone is indexed.
895 :return: A boolean indicating whether the serialization was successful.
896 """
897 self.serialize_compute(skel, name)
899 if name in skel.accessedValues:
900 empty_value = self.getEmptyValue()
901 newVal = skel.accessedValues[name]
903 if self.languages and self.multiple:
904 res = db.Entity()
905 res["_viurLanguageWrapper_"] = True
906 for language in self.languages:
907 res[language] = []
908 if not self.indexed:
909 res.exclude_from_indexes.add(language)
910 if language in newVal:
911 for singleValue in newVal[language]:
912 value = self.singleValueSerialize(singleValue, skel, name, parentIndexed)
913 if value != empty_value:
914 res[language].append(value)
916 elif self.languages:
917 res = db.Entity()
918 res["_viurLanguageWrapper_"] = True
919 for language in self.languages:
920 res[language] = None
921 if not self.indexed:
922 res.exclude_from_indexes.add(language)
923 if language in newVal:
924 res[language] = self.singleValueSerialize(newVal[language], skel, name, parentIndexed)
926 elif self.multiple:
927 res = []
929 assert newVal is None or isinstance(newVal, (list, tuple)), \
930 f"Cannot handle {repr(newVal)} here. Expecting list or tuple."
932 for singleValue in (newVal or ()):
933 value = self.singleValueSerialize(singleValue, skel, name, parentIndexed)
934 if value != empty_value:
935 res.append(value)
937 else: # No Languages, not Multiple
938 res = self.singleValueSerialize(newVal, skel, name, parentIndexed)
940 skel.dbEntity[name] = res
942 # Ensure our indexed flag is up2date
943 indexed = self.indexed and parentIndexed
944 if indexed and name in skel.dbEntity.exclude_from_indexes:
945 skel.dbEntity.exclude_from_indexes.discard(name)
946 elif not indexed and name not in skel.dbEntity.exclude_from_indexes:
947 skel.dbEntity.exclude_from_indexes.add(name)
948 return True
949 return False
951 def serialize_compute(self, skel: "SkeletonInstance", name: str) -> None:
952 """
953 This function checks whether a bone is computed and if this is the case, it attempts to serialize the
954 value with the appropriate calculation method
956 :param skel: The SkeletonInstance where the current bone is located
957 :param name: The name of the bone in the Skeleton
958 """
959 if not self.compute:
960 return None
962 match self.compute.interval.method:
963 case ComputeMethod.OnWrite:
964 skel.accessedValues[name] = self._compute(skel, name)
966 case ComputeMethod.Lifetime:
967 now = utils.utcNow()
969 last_update = \
970 skel.accessedValues.get(f"_viur_compute_{name}_") \
971 or skel.dbEntity.get(f"_viur_compute_{name}_")
973 if not last_update or last_update + self.compute.interval.lifetime < now:
974 skel.accessedValues[name] = self._compute(skel, name)
975 skel.dbEntity[f"_viur_compute_{name}_"] = now
977 case ComputeMethod.Once:
978 if name not in skel.dbEntity:
979 skel.accessedValues[name] = self._compute(skel, name)
982 def singleValueUnserialize(self, val):
983 """
984 Unserializes a single value of the bone from the stored database value.
986 Derived bone classes should overwrite this method to implement their own logic for unserializing
987 single values. The unserialized value should be suitable for use in the application logic.
988 """
989 return val
991 def unserialize(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> bool:
992 """
993 Deserialize bone data from the datastore and populate the bone with the deserialized values.
995 This function is the inverse of the serialize function. It converts data from the datastore
996 into a format that can be used by the bones in the skeleton.
998 :param skel: A SkeletonInstance object containing the values to be deserialized.
999 :param name: The property name of the bone in its Skeleton (not the description).
1000 :returns: True if deserialization is successful, False otherwise.
1001 """
1002 if name in skel.dbEntity:
1003 loadVal = skel.dbEntity[name]
1004 elif (
1005 # fixme: Remove this piece of sh*t at least with VIUR4
1006 # We're importing from an old ViUR2 instance - there may only be keys prefixed with our name
1007 conf.viur2import_blobsource and any(n.startswith(name + ".") for n in skel.dbEntity)
1008 # ... or computed
1009 or self.compute
1010 ):
1011 loadVal = None
1012 else:
1013 skel.accessedValues[name] = self.getDefaultValue(skel)
1014 return False
1016 if self.unserialize_compute(skel, name):
1017 return True
1019 # unserialize value to given config
1020 if self.languages and self.multiple:
1021 res = {}
1022 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal:
1023 for language in self.languages:
1024 res[language] = []
1025 if language in loadVal:
1026 tmpVal = loadVal[language]
1027 if not isinstance(tmpVal, list):
1028 tmpVal = [tmpVal]
1029 for singleValue in tmpVal:
1030 res[language].append(self.singleValueUnserialize(singleValue))
1031 else: # We could not parse this, maybe it has been written before languages had been set?
1032 for language in self.languages:
1033 res[language] = []
1034 mainLang = self.languages[0]
1035 if loadVal is None:
1036 pass
1037 elif isinstance(loadVal, list):
1038 for singleValue in loadVal:
1039 res[mainLang].append(self.singleValueUnserialize(singleValue))
1040 else: # Hopefully it's a value stored before languages and multiple has been set
1041 res[mainLang].append(self.singleValueUnserialize(loadVal))
1042 elif self.languages:
1043 res = {}
1044 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal:
1045 for language in self.languages:
1046 res[language] = None
1047 if language in loadVal:
1048 tmpVal = loadVal[language]
1049 if isinstance(tmpVal, list) and tmpVal:
1050 tmpVal = tmpVal[0]
1051 res[language] = self.singleValueUnserialize(tmpVal)
1052 else: # We could not parse this, maybe it has been written before languages had been set?
1053 for language in self.languages:
1054 res[language] = None
1055 oldKey = f"{name}.{language}"
1056 if oldKey in skel.dbEntity and skel.dbEntity[oldKey]:
1057 res[language] = self.singleValueUnserialize(skel.dbEntity[oldKey])
1058 loadVal = None # Don't try to import later again, this format takes precedence
1059 mainLang = self.languages[0]
1060 if loadVal is None:
1061 pass
1062 elif isinstance(loadVal, list) and loadVal:
1063 res[mainLang] = self.singleValueUnserialize(loadVal)
1064 else: # Hopefully it's a value stored before languages and multiple has been set
1065 res[mainLang] = self.singleValueUnserialize(loadVal)
1066 elif self.multiple:
1067 res = []
1068 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal:
1069 # Pick one language we'll use
1070 if conf.i18n.default_language in loadVal:
1071 loadVal = loadVal[conf.i18n.default_language]
1072 else:
1073 loadVal = [x for x in loadVal.values() if x is not True]
1074 if loadVal and not isinstance(loadVal, list):
1075 loadVal = [loadVal]
1076 if loadVal:
1077 for val in loadVal:
1078 res.append(self.singleValueUnserialize(val))
1079 else: # Not multiple, no languages
1080 res = None
1081 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal:
1082 # Pick one language we'll use
1083 if conf.i18n.default_language in loadVal:
1084 loadVal = loadVal[conf.i18n.default_language]
1085 else:
1086 loadVal = [x for x in loadVal.values() if x is not True]
1087 if loadVal and isinstance(loadVal, list):
1088 loadVal = loadVal[0]
1089 if loadVal is not None:
1090 res = self.singleValueUnserialize(loadVal)
1092 skel.accessedValues[name] = res
1093 return True
1095 def unserialize_compute(self, skel: "SkeletonInstance", name: str) -> bool:
1096 """
1097 This function checks whether a bone is computed and if this is the case, it attempts to deserialise the
1098 value with the appropriate calculation method
1100 :param skel : The SkeletonInstance where the current Bone is located
1101 :param name: The name of the Bone in the Skeleton
1102 :return: True if the Bone was unserialized, False otherwise
1103 """
1104 if not self.compute or self._prevent_compute or skel._cascade_deletion:
1105 return False
1107 match self.compute.interval.method:
1108 # Computation is bound to a lifetime?
1109 case ComputeMethod.Lifetime:
1110 now = utils.utcNow()
1111 from viur.core.skeleton import RefSkel # noqa: E402 # import works only here because circular imports
1113 if skel["key"] and skel.dbEntity:
1114 if issubclass(skel.skeletonCls, RefSkel): # we have a ref skel we must load the complete Entity
1115 db_obj = db.get(skel["key"])
1116 last_update = db_obj.get(f"_viur_compute_{name}_")
1117 else:
1118 last_update = skel.dbEntity.get(f"_viur_compute_{name}_")
1119 skel.accessedValues[f"_viur_compute_{name}_"] = last_update or now
1121 if not last_update or last_update + self.compute.interval.lifetime <= now:
1122 # if so, recompute and refresh updated value
1123 skel.accessedValues[name] = value = self._compute(skel, name)
1125 def transact():
1126 db_obj = db.get(skel["key"])
1127 db_obj[f"_viur_compute_{name}_"] = now
1128 db_obj[name] = value
1129 db.put(db_obj)
1131 if db.is_in_transaction():
1132 transact()
1133 else:
1134 db.run_in_transaction(transact)
1136 else:
1137 # Run like ComputeMethod.Always on unwritten skeleton
1138 skel.accessedValues[name] = self._compute(skel, name)
1140 return True
1142 # Compute on every deserialization
1143 case ComputeMethod.Always:
1144 skel.accessedValues[name] = self._compute(skel, name)
1145 return True
1147 return False
1149 def delete(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str):
1150 """
1151 Like postDeletedHandler, but runs inside the transaction
1152 """
1153 pass
1155 def buildDBFilter(self,
1156 name: str,
1157 skel: 'viur.core.skeleton.SkeletonInstance',
1158 dbFilter: db.Query,
1159 rawFilter: dict,
1160 prefix: t.Optional[str] = None) -> db.Query:
1161 """
1162 Parses the searchfilter a client specified in his Request into
1163 something understood by the datastore.
1164 This function must:
1166 * - Ignore all filters not targeting this bone
1167 * - Safely handle malformed data in rawFilter (this parameter is directly controlled by the client)
1169 :param name: The property-name this bone has in its Skeleton (not the description!)
1170 :param skel: The :class:`viur.core.db.Query` this bone is part of
1171 :param dbFilter: The current :class:`viur.core.db.Query` instance the filters should be applied to
1172 :param rawFilter: The dictionary of filters the client wants to have applied
1173 :returns: The modified :class:`viur.core.db.Query`
1174 """
1175 myKeys = [key for key in rawFilter.keys() if (key == name or key.startswith(name + "$"))]
1177 if len(myKeys) == 0:
1178 return dbFilter
1180 for key in myKeys:
1181 value = rawFilter[key]
1182 tmpdata = key.split("$")
1184 if len(tmpdata) > 1:
1185 if isinstance(value, list):
1186 continue
1187 if tmpdata[1] == "lt":
1188 dbFilter.filter((prefix or "") + tmpdata[0] + " <", value)
1189 elif tmpdata[1] == "le":
1190 dbFilter.filter((prefix or "") + tmpdata[0] + " <=", value)
1191 elif tmpdata[1] == "gt":
1192 dbFilter.filter((prefix or "") + tmpdata[0] + " >", value)
1193 elif tmpdata[1] == "ge":
1194 dbFilter.filter((prefix or "") + tmpdata[0] + " >=", value)
1195 elif tmpdata[1] == "lk":
1196 dbFilter.filter((prefix or "") + tmpdata[0] + " =", value)
1197 else:
1198 dbFilter.filter((prefix or "") + tmpdata[0] + " =", value)
1199 else:
1200 if isinstance(value, list):
1201 dbFilter.filter((prefix or "") + key + " IN", value)
1202 else:
1203 dbFilter.filter((prefix or "") + key + " =", value)
1205 return dbFilter
1207 def buildDBSort(
1208 self,
1209 name: str,
1210 skel: "SkeletonInstance",
1211 query: db.Query,
1212 params: dict,
1213 postfix: str = "",
1214 ) -> t.Optional[db.Query]:
1215 """
1216 Same as buildDBFilter, but this time its not about filtering
1217 the results, but by sorting them.
1218 Again: query is controlled by the client, so you *must* expect and safely handle
1219 malformed data!
1221 :param name: The property-name this bone has in its Skeleton (not the description!)
1222 :param skel: The :class:`viur.core.skeleton.Skeleton` instance this bone is part of
1223 :param dbFilter: The current :class:`viur.core.db.Query` instance the filters should
1224 be applied to
1225 :param query: The dictionary of filters the client wants to have applied
1226 :param postfix: Inherited classes may use this to add a postfix to the porperty name
1227 :returns: The modified :class:`viur.core.db.Query`,
1228 None if the query is unsatisfiable.
1229 """
1230 if query.queries and (orderby := params.get("orderby")) and utils.string.is_prefix(orderby, name):
1231 if self.languages:
1232 lang = None
1233 prefix = f"{name}."
1234 if orderby.startswith(prefix):
1235 lng = orderby[len(prefix):]
1236 if lng in self.languages:
1237 lang = lng
1239 if lang is None:
1240 lang = current.language.get()
1241 if not lang or lang not in self.languages:
1242 lang = self.languages[0]
1244 prop = f"{name}.{lang}"
1245 else:
1246 prop = name
1248 # In case this is a multiple query, check if all filters are valid
1249 if isinstance(query.queries, list):
1250 in_eq_filter = None
1252 for item in query.queries:
1253 new_in_eq_filter = [
1254 key for key in item.filters.keys()
1255 if key.rstrip().endswith(("<", ">", "!="))
1256 ]
1257 if in_eq_filter and new_in_eq_filter and in_eq_filter != new_in_eq_filter:
1258 raise NotImplementedError("Impossible ordering!")
1260 in_eq_filter = new_in_eq_filter
1262 else:
1263 in_eq_filter = [
1264 key for key in query.queries.filters.keys()
1265 if key.rstrip().endswith(("<", ">", "!="))
1266 ]
1268 if in_eq_filter:
1269 orderby_prop = in_eq_filter[0].split(" ", 1)[0]
1270 if orderby_prop != prop:
1271 logging.warning(
1272 f"The query was rewritten; Impossible ordering changed from {prop!r} into {orderby_prop!r}"
1273 )
1274 prop = orderby_prop
1276 query.order((prop + postfix, utils.parse.sortorder(params.get("orderdir"))))
1278 return query
1280 def _hashValueForUniquePropertyIndex(
1281 self,
1282 value: str | int | float | db.Key | list[str | int | float | db.Key],
1283 ) -> list[str]:
1284 """
1285 Generates a hash of the given value for creating unique property indexes.
1287 This method is called by the framework to create a consistent hash representation of a value
1288 for constructing unique property indexes. Derived bone classes should overwrite this method to
1289 implement their own logic for hashing values.
1291 :param value: The value(s) to be hashed.
1293 :return: A list containing a string representation of the hashed value. If the bone is multiple,
1294 the list may contain more than one hashed value.
1295 """
1297 def hashValue(value: str | int | float | db.Key) -> str:
1298 h = hashlib.sha256()
1299 h.update(str(value).encode("UTF-8"))
1300 res = h.hexdigest()
1301 if isinstance(value, int | float):
1302 return f"I-{res}"
1303 elif isinstance(value, str):
1304 return f"S-{res}"
1305 elif isinstance(value, db.Key):
1306 # We Hash the keys here by our self instead of relying on str() or to_legacy_urlsafe()
1307 # as these may change in the future, which would invalidate all existing locks
1308 def keyHash(key):
1309 if key is None:
1310 return "-"
1311 return f"{hashValue(key.kind)}-{hashValue(key.id_or_name)}-<{keyHash(key.parent)}>"
1313 return f"K-{keyHash(value)}"
1314 raise NotImplementedError(f"Type {type(value)} can't be safely used in an uniquePropertyIndex")
1316 if not value and not self.unique.lockEmpty:
1317 return [] # We are zero/empty string and these should not be locked
1318 if not self.multiple and not isinstance(value, list):
1319 return [hashValue(value)]
1320 # We have a multiple bone or multiple values here
1321 if not isinstance(value, list):
1322 value = [value]
1323 tmpList = [hashValue(x) for x in value]
1324 if self.unique.method == UniqueLockMethod.SameValue:
1325 # We should lock each entry individually; lock each value
1326 return tmpList
1327 elif self.unique.method == UniqueLockMethod.SameSet:
1328 # We should ignore the sort-order; so simply sort that List
1329 tmpList.sort()
1330 # Lock the value for that specific list
1331 return [hashValue(", ".join(tmpList))]
1333 def getUniquePropertyIndexValues(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> list[str]:
1334 """
1335 Returns a list of hashes for the current value(s) of a bone in the skeleton, used for storing in the
1336 unique property value index.
1338 :param skel: A SkeletonInstance object representing the current skeleton.
1339 :param name: The property-name of the bone in the skeleton for which the unique property index values
1340 are required (not the description!).
1342 :return: A list of strings representing the hashed values for the current bone value(s) in the skeleton.
1343 If the bone has no value, an empty list is returned.
1344 """
1345 val = skel[name]
1346 if val is None:
1347 return []
1348 return self._hashValueForUniquePropertyIndex(val)
1350 def getReferencedBlobs(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> set[str]:
1351 """
1352 Returns a set of blob keys referenced from this bone
1353 """
1354 return set()
1356 def performMagic(self, valuesCache: dict, name: str, isAdd: bool):
1357 """
1358 This function applies "magically" functionality which f.e. inserts the current Date
1359 or the current user.
1360 :param isAdd: Signals wherever this is an add or edit operation.
1361 """
1362 pass # We do nothing by default
1364 def postSavedHandler(self, skel: "SkeletonInstance", boneName: str, key: db.Key | None) -> None:
1365 """
1366 Can be overridden to perform further actions after the main entity has been written.
1368 :param boneName: Name of this bone
1369 :param skel: The skeleton this bone belongs to
1370 :param key: The (new?) Database Key we've written to. In case of a RelSkel the key is None.
1371 """
1372 pass
1374 def postDeletedHandler(self, skel: 'viur.core.skeleton.SkeletonInstance', boneName: str, key: str):
1375 """
1376 Can be overridden to perform further actions after the main entity has been deleted.
1378 :param skel: The skeleton this bone belongs to
1379 :param boneName: Name of this bone
1380 :param key: The old Database Key of the entity we've deleted
1381 """
1382 pass
1384 def clone_value(self, skel: "SkeletonInstance", src_skel: "SkeletonInstance", bone_name: str) -> None:
1385 """Clone / Set the value for this bone depending on :attr:`clone_behavior`"""
1386 match self.clone_behavior.strategy:
1387 case CloneStrategy.COPY_VALUE:
1388 try:
1389 skel.accessedValues[bone_name] = copy.deepcopy(src_skel.accessedValues[bone_name])
1390 except KeyError:
1391 pass # bone_name is not in accessedValues, cannot clone it
1392 try:
1393 skel.renderAccessedValues[bone_name] = copy.deepcopy(src_skel.renderAccessedValues[bone_name])
1394 except KeyError:
1395 pass # bone_name is not in renderAccessedValues, cannot clone it
1396 case CloneStrategy.SET_NULL:
1397 skel.accessedValues[bone_name] = None
1398 case CloneStrategy.SET_DEFAULT:
1399 skel.accessedValues[bone_name] = self.getDefaultValue(skel)
1400 case CloneStrategy.SET_EMPTY:
1401 skel.accessedValues[bone_name] = self.getEmptyValue()
1402 case CloneStrategy.CUSTOM:
1403 skel.accessedValues[bone_name] = self.clone_behavior.custom_func(skel, src_skel, bone_name)
1404 case other:
1405 raise NotImplementedError(other)
1407 def refresh(self, skel: 'viur.core.skeleton.SkeletonInstance', boneName: str) -> None:
1408 """
1409 Refresh all values we might have cached from other entities.
1410 """
1411 pass
1413 def mergeFrom(self, valuesCache: dict, boneName: str, otherSkel: 'viur.core.skeleton.SkeletonInstance'):
1414 """
1415 Merges the values from another skeleton instance into the current instance, given that the bone types match.
1417 :param valuesCache: A dictionary containing the cached values for each bone in the skeleton.
1418 :param boneName: The property-name of the bone in the skeleton whose values are to be merged.
1419 :param otherSkel: A SkeletonInstance object representing the other skeleton from which the values \
1420 are to be merged.
1422 This function clones the values from the specified bone in the other skeleton instance into the current
1423 instance, provided that the bone types match. If the bone types do not match, a warning is logged, and the merge
1424 is ignored. If the bone in the other skeleton has no value, the function returns without performing any merge
1425 operation.
1426 """
1427 if getattr(otherSkel, boneName) is None:
1428 return
1429 if not isinstance(getattr(otherSkel, boneName), type(self)):
1430 logging.error(f"Ignoring values from conflicting boneType ({getattr(otherSkel, boneName)} is not a "
1431 f"instance of {type(self)})!")
1432 return
1433 valuesCache[boneName] = copy.deepcopy(otherSkel.valuesCache.get(boneName, None))
1435 def setBoneValue(self,
1436 skel: 'SkeletonInstance',
1437 boneName: str,
1438 value: t.Any,
1439 append: bool,
1440 language: None | str = None) -> bool:
1441 """
1442 Sets the value of a bone in a skeleton instance, with optional support for appending and language-specific
1443 values. Sanity checks are being performed.
1445 :param skel: The SkeletonInstance object representing the skeleton to which the bone belongs.
1446 :param boneName: The property-name of the bone in the skeleton whose value should be set or modified.
1447 :param value: The value to be assigned. Its type depends on the type of the bone.
1448 :param append: If True, the given value is appended to the bone's values instead of replacing it. \
1449 Only supported for bones with multiple=True.
1450 :param language: The language code for which the value should be set or appended, \
1451 if the bone supports languages.
1453 :return: A boolean indicating whether the operation was successful or not.
1455 This function sets or modifies the value of a bone in a skeleton instance, performing sanity checks to ensure
1456 the value is valid. If the value is invalid, no modification occurs. The function supports appending values to
1457 bones with multiple=True and setting or appending language-specific values for bones that support languages.
1458 """
1459 assert not (bool(self.languages) ^ bool(language)), f"language is required or not supported on {boneName!r}"
1460 assert not append or self.multiple, "Can't append - bone is not multiple"
1462 if not append and self.multiple:
1463 # set multiple values at once
1464 val = []
1465 errors = []
1466 for singleValue in value:
1467 singleValue, singleError = self.singleValueFromClient(singleValue, skel, boneName, {boneName: value})
1468 val.append(singleValue)
1469 if singleError: 1469 ↛ 1470line 1469 didn't jump to line 1470 because the condition on line 1469 was never true
1470 errors.extend(singleError)
1471 else:
1472 # set or append one value
1473 val, errors = self.singleValueFromClient(value, skel, boneName, {boneName: value})
1475 if errors:
1476 for e in errors: 1476 ↛ 1481line 1476 didn't jump to line 1481 because the loop on line 1476 didn't complete
1477 if e.severity in [ReadFromClientErrorSeverity.Invalid, ReadFromClientErrorSeverity.NotSet]: 1477 ↛ 1476line 1477 didn't jump to line 1476 because the condition on line 1477 was always true
1478 # If an invalid datatype (or a non-parseable structure) have been passed, abort the store
1479 logging.error(e)
1480 return False
1481 if not append and not language:
1482 skel[boneName] = val
1483 elif append and language: 1483 ↛ 1484line 1483 didn't jump to line 1484 because the condition on line 1483 was never true
1484 if not language in skel[boneName] or not isinstance(skel[boneName][language], list):
1485 skel[boneName][language] = []
1486 skel[boneName][language].append(val)
1487 elif append: 1487 ↛ 1492line 1487 didn't jump to line 1492 because the condition on line 1487 was always true
1488 if not isinstance(skel[boneName], list): 1488 ↛ 1489line 1488 didn't jump to line 1489 because the condition on line 1488 was never true
1489 skel[boneName] = []
1490 skel[boneName].append(val)
1491 else: # Just language
1492 skel[boneName][language] = val
1493 return True
1495 def getSearchTags(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> set[str]:
1496 """
1497 Returns a set of strings as search index for this bone.
1499 This function extracts a set of search tags from the given bone's value in the skeleton
1500 instance. The resulting set can be used for indexing or searching purposes.
1502 :param skel: The skeleton instance where the values should be loaded from. This is an instance
1503 of a class derived from `viur.core.skeleton.SkeletonInstance`.
1504 :param name: The name of the bone, which is a string representing the key for the bone in
1505 the skeleton. This should correspond to an existing bone in the skeleton instance.
1506 :return: A set of strings, extracted from the bone value. If the bone value doesn't have
1507 any searchable content, an empty set is returned.
1508 """
1509 return set()
1511 def iter_bone_value(
1512 self, skel: 'viur.core.skeleton.SkeletonInstance', name: str
1513 ) -> t.Iterator[tuple[t.Optional[int], t.Optional[str], t.Any]]:
1514 """
1515 Yield all values from the Skeleton related to this bone instance.
1517 This method handles multiple/languages cases, which could save a lot of if/elifs.
1518 It always yields a triplet: index, language, value.
1519 Where index is the index (int) of a value inside a multiple bone,
1520 language is the language (str) of a multi-language-bone,
1521 and value is the value inside this container.
1522 index or language is None if the bone is single or not multi-lang.
1524 This function can be used to conveniently iterate through all the values of a specific bone
1525 in a skeleton instance, taking into account multiple and multi-language bones.
1527 :param skel: The skeleton instance where the values should be loaded from. This is an instance
1528 of a class derived from `viur.core.skeleton.SkeletonInstance`.
1529 :param name: The name of the bone, which is a string representing the key for the bone in
1530 the skeleton. This should correspond to an existing bone in the skeleton instance.
1532 :return: A generator which yields triplets (index, language, value), where index is the index
1533 of a value inside a multiple bone, language is the language of a multi-language bone,
1534 and value is the value inside this container. index or language is None if the bone is
1535 single or not multi-lang.
1536 """
1537 value = skel[name]
1538 if not value:
1539 return None
1541 if self.languages and isinstance(value, dict):
1542 for idx, (lang, values) in enumerate(value.items()):
1543 if self.multiple:
1544 if not values:
1545 continue
1546 for val in values:
1547 yield idx, lang, val
1548 else:
1549 yield None, lang, values
1550 else:
1551 if self.multiple:
1552 for idx, val in enumerate(value):
1553 yield idx, None, val
1554 else:
1555 yield None, None, value
1557 def _compute(self, skel: 'viur.core.skeleton.SkeletonInstance', bone_name: str):
1558 """Performs the evaluation of a bone configured as compute"""
1559 from ..skeleton.utils import without_render_preparation
1561 compute_fn_parameters = inspect.signature(self.compute.fn).parameters
1562 compute_fn_args = {}
1563 skel = without_render_preparation(skel)
1565 if "skel" in compute_fn_parameters:
1566 skel.accessedValues[bone_name] = None # remove value from accessedValues to avoid endless recursion
1567 compute_fn_args["skel"] = skel
1569 if "bone" in compute_fn_parameters:
1570 compute_fn_args["bone"] = getattr(skel, bone_name)
1572 if "bone_name" in compute_fn_parameters:
1573 compute_fn_args["bone_name"] = bone_name
1575 ret = self.compute.fn(**compute_fn_args)
1577 def unserialize_raw_value(raw_value: list[dict] | dict | None):
1578 if self.multiple:
1579 return [self.singleValueUnserialize(inner_value) for inner_value in raw_value]
1580 return self.singleValueUnserialize(raw_value)
1582 if self.compute.raw:
1583 if self.languages:
1584 return {
1585 lang: unserialize_raw_value(ret.get(lang, [] if self.multiple else None))
1586 for lang in self.languages
1587 }
1589 return unserialize_raw_value(ret)
1591 self._prevent_compute = True
1592 if errors := self.fromClient(skel, bone_name, {bone_name: ret}):
1593 raise ValueError(f"Computed value fromClient failed with {errors!r}")
1594 self._prevent_compute = False
1596 return skel[bone_name]
1598 def structure(self) -> dict:
1599 """
1600 Describes the bone and its settings as an JSON-serializable dict.
1601 This function has to be implemented for subsequent, specialized bone types.
1602 """
1603 ret = {
1604 "descr": self.descr,
1605 "type": self.type,
1606 "required": self.required and not self.readOnly,
1607 "params": self.params,
1608 "visible": self.visible,
1609 "readonly": self.readOnly,
1610 "unique": self.unique.method.value if self.unique else False,
1611 "languages": self.languages,
1612 "emptyvalue": self.getEmptyValue(),
1613 "indexed": self.indexed,
1614 "clone_behavior": {
1615 "strategy": self.clone_behavior.strategy,
1616 },
1617 }
1619 # Provide a defaultvalue, if it's not a function.
1620 if not callable(self.defaultValue) and self.defaultValue is not None:
1621 ret["defaultvalue"] = self.defaultValue
1623 # Provide a multiple setting
1624 if self.multiple and isinstance(self.multiple, MultipleConstraints):
1625 ret["multiple"] = {
1626 "duplicates": self.multiple.duplicates,
1627 "max": self.multiple.max,
1628 "min": self.multiple.min,
1629 }
1630 else:
1631 ret["multiple"] = self.multiple
1633 # Provide compute information
1634 if self.compute:
1635 ret["compute"] = {
1636 "method": self.compute.interval.method.name
1637 }
1639 if self.compute.interval.lifetime:
1640 ret["compute"]["lifetime"] = self.compute.interval.lifetime.total_seconds()
1642 return ret
1644 def dump(self, skel: "SkeletonInstance", bone_name: str) -> t.Any:
1645 """
1646 Returns the value of a bone in a JSON-serializable format.
1648 The function is not called "to_json()" because the JSON-serializable
1649 format can be used for different purposes and renderings, not just
1650 JSON.
1652 :param skel: The SkeletonInstance that contains the bone.
1653 :param bone_name: The name of the bone to in the skeleton.
1655 :return: The value of the bone in a JSON-serializable version.
1656 """
1657 ret = {}
1658 bone_value = skel[bone_name]
1659 if self.languages and self.multiple:
1660 for language in self.languages:
1661 if bone_value and language in bone_value and bone_value[language]:
1662 ret[language] = [self._atomic_dump(value) for value in bone_value[language]]
1663 else:
1664 ret[language] = []
1665 elif self.languages:
1666 for language in self.languages:
1667 if bone_value and language in bone_value and bone_value[language]:
1668 ret[language] = self._atomic_dump(bone_value[language])
1669 else:
1670 ret[language] = None
1671 elif self.multiple:
1672 ret = [self._atomic_dump(value) for value in bone_value or ()]
1674 else:
1675 ret = self._atomic_dump(bone_value)
1676 return ret
1678 def _atomic_dump(self, value):
1679 """
1680 One atomic value of the bone.
1681 """
1682 return value