Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/bones/base.py: 29%
798 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-03 12:27 +0000
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-03 12:27 +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 dataclasses
10import enum
11import hashlib
12import inspect
13import logging
14import typing as t
15from collections.abc import Iterable
16from dataclasses import dataclass, field
17from datetime import timedelta
18from enum import Enum
20from viur.core import current, db, i18n, utils
21from viur.core.config import conf
23if t.TYPE_CHECKING: 23 ↛ 24line 23 didn't jump to line 24 because the condition on line 23 was never true
24 from ..skeleton import Skeleton, SkeletonInstance
26__system_initialized = False
27"""
28Initializes the global variable __system_initialized
29"""
32def setSystemInitialized():
33 """
34 Sets the global __system_initialized variable to True, indicating that the system is
35 initialized and ready for use. This function should be called once all necessary setup
36 tasks have been completed. It also iterates over all skeleton classes and calls their
37 setSystemInitialized() method.
39 Global variables:
40 __system_initialized: A boolean flag indicating if the system is initialized.
41 """
42 global __system_initialized
43 from viur.core.skeleton import iterAllSkelClasses
45 for skelCls in iterAllSkelClasses():
46 skelCls.setSystemInitialized()
48 __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: str
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 __str__(self):
92 return f"{'.'.join(self.fieldPath)}: {self.errorMessage} [{self.severity.name}]"
95class ReadFromClientException(Exception):
96 """
97 ReadFromClientError as an Exception to raise.
98 """
100 def __init__(self, errors: ReadFromClientError | t.Iterable[ReadFromClientError]):
101 """
102 This is an exception holding ReadFromClientErrors.
104 :param errors: Either one or an iterable of errors.
105 """
106 super().__init__()
108 # Allow to specifiy a single ReadFromClientError
109 if isinstance(errors, ReadFromClientError):
110 errors = (ReadFromClientError, )
112 self.errors = tuple(error for error in errors if isinstance(error, ReadFromClientError))
114 # Disallow ReadFromClientException without any ReadFromClientErrors
115 if not self.errors:
116 raise ValueError("ReadFromClientException requires for at least one ReadFromClientError")
118 # Either show any errors with severity greater ReadFromClientErrorSeverity.NotSet to the Exception notes,
119 # or otherwise all errors (all have ReadFromClientErrorSeverity.NotSet then)
120 notes_errors = tuple(
121 error for error in self.errors if error.severity.value > ReadFromClientErrorSeverity.NotSet.value
122 )
124 self.add_note("\n".join(str(error) for error in notes_errors or self.errors))
127class UniqueLockMethod(Enum):
128 """
129 UniqueLockMethod is an enumeration that represents different locking methods for unique constraints
130 on bones. This is used to specify how the uniqueness of a value or a set of values should be
131 enforced.
132 """
133 SameValue = 1 # Lock this value for just one entry or each value individually if bone is multiple
134 """
135 Lock this value so that there is only one entry, or lock each value individually if the bone
136 is multiple.
137 """
138 SameSet = 2 # Same Set of entries (including duplicates), any order
139 """Lock the same set of entries (including duplicates) regardless of their order."""
140 SameList = 3 # Same Set of entries (including duplicates), in this specific order
141 """Lock the same set of entries (including duplicates) in a specific order."""
144@dataclass
145class UniqueValue: # Mark a bone as unique (it must have a different value for each entry)
146 """
147 The UniqueValue class represents a unique constraint on a bone, ensuring that it must have a
148 different value for each entry. This class is used to store information about the unique
149 constraint, such as the locking method, whether to lock empty values, and an error message to
150 display to the user if the requested value is already taken.
151 """
152 method: UniqueLockMethod # How to handle multiple values (for bones with multiple=True)
153 """
154 A UniqueLockMethod enumeration value specifying how to handle multiple values for bones with
155 multiple=True.
156 """
157 lockEmpty: bool # If False, empty values ("", 0) are not locked - needed if unique but not required
158 """
159 A boolean value indicating if empty values ("", 0) should be locked. If False, empty values are not
160 locked, which is needed if a field is unique but not required.
161 """
162 message: str # Error-Message displayed to the user if the requested value is already taken
163 """
164 A string containing an error message displayed to the user if the requested value is already
165 taken.
166 """
169@dataclass
170class MultipleConstraints:
171 """
172 The MultipleConstraints class is used to define constraints on multiple bones, such as the minimum
173 and maximum number of entries allowed and whether value duplicates are allowed.
174 """
175 min: int = 0
176 """An integer representing the lower bound of how many entries can be submitted (default: 0)."""
177 max: int = 0
178 """An integer representing the upper bound of how many entries can be submitted (default: 0 = unlimited)."""
179 duplicates: bool = False
180 """A boolean indicating if the same value can be used multiple times (default: False)."""
181 sorted: bool | t.Callable = False
182 """A boolean value or a method indicating if the value must be sorted (default: False)."""
183 reversed: bool = False
184 """
185 A boolean value indicating if sorted values shall be sorted in reversed order (default: False).
186 It is only applied when the `sorted`-flag is set accordingly.
187 """
189class ComputeMethod(Enum):
190 Always = 0 # Always compute on deserialization
191 Lifetime = 1 # Update only when given lifetime is outrun; value is only being stored when the skeleton is written
192 Once = 2 # Compute only once
193 OnWrite = 3 # Compute before written
196@dataclass
197class ComputeInterval:
198 method: ComputeMethod = ComputeMethod.Always
199 lifetime: timedelta = None # defines a timedelta until which the value stays valid (`ComputeMethod.Lifetime`)
202@dataclass
203class Compute:
204 fn: callable # the callable computing the value
205 interval: ComputeInterval = field(default_factory=ComputeInterval) # the value caching interval
206 raw: bool = True # defines whether the value returned by fn is used as is, or is passed through bone.fromClient
209class CloneStrategy(enum.StrEnum):
210 """Strategy for selecting the value of a cloned skeleton"""
212 SET_NULL = enum.auto()
213 """Sets the cloned bone value to None."""
215 SET_DEFAULT = enum.auto()
216 """Sets the cloned bone value to its defaultValue."""
218 SET_EMPTY = enum.auto()
219 """Sets the cloned bone value to its emptyValue."""
221 COPY_VALUE = enum.auto()
222 """Copies the bone value from the source skeleton."""
224 CUSTOM = enum.auto()
225 """Uses a custom-defined logic for setting the cloned value.
226 Requires :attr:`CloneBehavior.custom_func` to be set.
227 """
230class CloneCustomFunc(t.Protocol):
231 """Type for a custom clone function assigned to :attr:`CloneBehavior.custom_func`"""
233 def __call__(self, skel: "SkeletonInstance", src_skel: "SkeletonInstance", bone_name: str) -> t.Any:
234 """Return the value for the cloned bone"""
235 ...
238@dataclass
239class CloneBehavior:
240 """Strategy configuration for selecting the value of a cloned skeleton"""
242 strategy: CloneStrategy
243 """The strategy used to select a value from a cloned skeleton"""
245 custom_func: CloneCustomFunc = None
246 """custom-defined logic for setting the cloned value
247 Only required when :attr:`strategy` is set to :attr:`CloneStrategy.CUSTOM`.
248 """
250 def __post_init__(self):
251 """Validate this configuration."""
252 if self.strategy == CloneStrategy.CUSTOM and self.custom_func is None: 252 ↛ 253line 252 didn't jump to line 253 because the condition on line 252 was never true
253 raise ValueError("CloneStrategy is CUSTOM, but custom_func is not set")
254 elif self.strategy != CloneStrategy.CUSTOM and self.custom_func is not None: 254 ↛ 255line 254 didn't jump to line 255 because the condition on line 254 was never true
255 raise ValueError("custom_func is set, but CloneStrategy is not CUSTOM")
258class BaseBone(object):
259 """
260 The BaseBone class serves as the base class for all bone types in the ViUR framework.
261 It defines the core functionality and properties that all bones should implement.
263 :param descr: Textual, human-readable description of that bone. Will be translated.
264 :param defaultValue: If set, this bone will be preinitialized with this value
265 :param required: If True, the user must enter a valid value for this bone (the viur.core refuses
266 to save the skeleton otherwise). If a list/tuple of languages (strings) is provided, these
267 language must be entered.
268 :param multiple: If True, multiple values can be given. (ie. n:m relations instead of n:1)
269 :param searchable: If True, this bone will be included in the fulltext search. Can be used
270 without the need of also been indexed.
271 :param type_suffix: Allows to specify an optional suffix for the bone-type, for bone customization
272 :param vfunc: If given, a callable validating the user-supplied value for this bone.
273 This callable must return None if the value is valid, a String containing an meaningful
274 error-message for the user otherwise.
275 :param readOnly: If True, the user is unable to change the value of this bone. If a value for this
276 bone is given along the POST-Request during Add/Edit, this value will be ignored. Its still
277 possible for the developer to modify this value by assigning skel.bone.value.
278 :param visible: If False, the value of this bone should be hidden from the user. This does
279 *not* protect the value from being exposed in a template, nor from being transferred
280 to the client (ie to the admin or as hidden-value in html-form)
281 :param compute: If set, the bone's value will be computed in the given method.
283 .. NOTE::
284 The kwarg 'multiple' is not supported by all bones
285 """
286 type = "hidden"
287 isClonedInstance = False
289 skel_cls = None
290 """Skeleton class to which this bone instance belongs"""
292 name = None
293 """Name of this bone (attribute name in the skeletons containing this bone)"""
295 def __init__(
296 self,
297 *,
298 compute: Compute = None,
299 defaultValue: t.Any = None,
300 descr: t.Optional[str | i18n.translate] = None,
301 getEmptyValueFunc: callable = None,
302 indexed: bool = True,
303 isEmptyFunc: callable = None, # fixme: Rename this, see below.
304 languages: None | list[str] = None,
305 multiple: bool | MultipleConstraints = False,
306 params: dict = None,
307 readOnly: bool = None, # fixme: Rename into readonly (all lowercase!) soon.
308 required: bool | list[str] | tuple[str] = False,
309 searchable: bool = False,
310 type_suffix: str = "",
311 unique: None | UniqueValue = None,
312 vfunc: callable = None, # fixme: Rename this, see below.
313 visible: bool = True,
314 clone_behavior: CloneBehavior | CloneStrategy | None = None,
315 ):
316 """
317 Initializes a new Bone.
318 """
319 self.isClonedInstance = getSystemInitialized()
321 # Standard definitions
322 self.descr = descr
323 self.params = params or {}
324 self.multiple = multiple
325 self.required = required
326 self.readOnly = bool(readOnly)
327 self.searchable = searchable
328 self.visible = visible
329 self.indexed = indexed
331 if type_suffix: 331 ↛ 332line 331 didn't jump to line 332 because the condition on line 331 was never true
332 self.type += f".{type_suffix}"
334 if isinstance(category := self.params.get("category"), str): 334 ↛ 335line 334 didn't jump to line 335 because the condition on line 334 was never true
335 self.params["category"] = i18n.translate(category, hint=f"category of a <{type(self).__name__}>")
337 # Multi-language support
338 if not ( 338 ↛ 343line 338 didn't jump to line 343 because the condition on line 338 was never true
339 languages is None or
340 (isinstance(languages, list) and len(languages) > 0
341 and all([isinstance(x, str) for x in languages]))
342 ):
343 raise ValueError("languages must be None or a list of strings")
345 if languages and "__default__" in languages: 345 ↛ 346line 345 didn't jump to line 346 because the condition on line 345 was never true
346 raise ValueError("__default__ is not supported as a language")
348 if ( 348 ↛ 352line 348 didn't jump to line 352 because the condition on line 348 was never true
349 not isinstance(required, bool)
350 and (not isinstance(required, (tuple, list)) or any(not isinstance(value, str) for value in required))
351 ):
352 raise TypeError(f"required must be boolean or a tuple/list of strings. Got: {required!r}")
354 if isinstance(required, (tuple, list)) and not languages: 354 ↛ 355line 354 didn't jump to line 355 because the condition on line 354 was never true
355 raise ValueError("You set required to a list of languages, but defined no languages.")
357 if isinstance(required, (tuple, list)) and languages and (diff := set(required).difference(languages)): 357 ↛ 358line 357 didn't jump to line 358 because the condition on line 357 was never true
358 raise ValueError(f"The language(s) {', '.join(map(repr, diff))} can not be required, "
359 f"because they're not defined.")
361 if callable(defaultValue): 361 ↛ 363line 361 didn't jump to line 363 because the condition on line 361 was never true
362 # check if the signature of defaultValue can bind two (fictive) parameters.
363 try:
364 inspect.signature(defaultValue).bind("skel", "bone") # the strings are just for the test!
365 except TypeError:
366 raise ValueError(f"Callable {defaultValue=} requires for the parameters 'skel' and 'bone'.")
368 self.languages = languages
370 # Default value
371 # Convert a None default-value to the empty container that's expected if the bone is
372 # multiple or has languages
373 default = [] if defaultValue is None and self.multiple else defaultValue
374 if self.languages:
375 if callable(defaultValue): 375 ↛ 376line 375 didn't jump to line 376 because the condition on line 375 was never true
376 self.defaultValue = defaultValue
377 elif not isinstance(defaultValue, dict): 377 ↛ 379line 377 didn't jump to line 379 because the condition on line 377 was always true
378 self.defaultValue = {lang: default for lang in self.languages}
379 elif "__default__" in defaultValue:
380 self.defaultValue = {lang: defaultValue.get(lang, defaultValue["__default__"])
381 for lang in self.languages}
382 else:
383 self.defaultValue = defaultValue # default will have the same value at this point
384 else:
385 self.defaultValue = default
387 # Unique values
388 if unique: 388 ↛ 389line 388 didn't jump to line 389 because the condition on line 388 was never true
389 if not isinstance(unique, UniqueValue):
390 raise ValueError("Unique must be an instance of UniqueValue")
391 if not self.multiple and unique.method.value != 1:
392 raise ValueError("'SameValue' is the only valid method on non-multiple bones")
394 self.unique = unique
396 # Overwrite some validations and value functions by parameter instead of subclassing
397 # todo: This can be done better and more straightforward.
398 if vfunc:
399 self.isInvalid = vfunc # fixme: why is this called just vfunc, and not isInvalidValue/isInvalidValueFunc?
401 if isEmptyFunc: 401 ↛ 402line 401 didn't jump to line 402 because the condition on line 401 was never true
402 self.isEmpty = isEmptyFunc # fixme: why is this not called isEmptyValue/isEmptyValueFunc?
404 if getEmptyValueFunc:
405 self.getEmptyValue = getEmptyValueFunc
407 if compute: 407 ↛ 408line 407 didn't jump to line 408 because the condition on line 407 was never true
408 if not isinstance(compute, Compute):
409 raise TypeError("compute must be an instanceof of Compute")
410 if not isinstance(compute.fn, t.Callable):
411 raise ValueError("'compute.fn' must be callable")
412 # When readOnly is None, handle flag automatically
413 if readOnly is None:
414 self.readOnly = True
415 if not self.readOnly:
416 raise ValueError("'compute' can only be used with bones configured as `readOnly=True`")
418 if (
419 compute.interval.method == ComputeMethod.Lifetime
420 and not isinstance(compute.interval.lifetime, timedelta)
421 ):
422 raise ValueError(
423 f"'compute' is configured as ComputeMethod.Lifetime, but {compute.interval.lifetime=} was specified"
424 )
425 # If a RelationalBone is computed and raw is False, the unserialize function is called recursively
426 # and the value is recalculated all the time. This parameter is to prevent this.
427 self._prevent_compute = False
429 self.compute = compute
431 if clone_behavior is None: # auto choose 431 ↛ 437line 431 didn't jump to line 437 because the condition on line 431 was always true
432 if self.unique and self.readOnly: 432 ↛ 433line 432 didn't jump to line 433 because the condition on line 432 was never true
433 self.clone_behavior = CloneBehavior(CloneStrategy.SET_DEFAULT)
434 else:
435 self.clone_behavior = CloneBehavior(CloneStrategy.COPY_VALUE)
436 # TODO: Any different setting for computed bones?
437 elif isinstance(clone_behavior, CloneStrategy):
438 self.clone_behavior = CloneBehavior(strategy=clone_behavior)
439 elif isinstance(clone_behavior, CloneBehavior):
440 self.clone_behavior = clone_behavior
441 else:
442 raise TypeError(f"'clone_behavior' must be an instance of Clone, but {clone_behavior=} was specified")
444 def __set_name__(self, owner: "Skeleton", name: str) -> None:
445 self.skel_cls = owner
446 self.name = name
448 def setSystemInitialized(self) -> None:
449 """
450 Can be overridden to initialize properties that depend on the Skeleton system
451 being initialized.
453 Here, in the BaseBone, we set descr to the bone_name if no descr argument
454 was given in __init__ and make sure that it is a :class:i18n.translate` object.
455 """
456 if self.descr is None:
457 # TODO: The super().__setattr__() call is kinda hackish,
458 # but unfortunately viur-core has no *during system initialisation* state
459 super().__setattr__("descr", self.name or "")
460 if self.descr and isinstance(self.descr, str):
461 super().__setattr__(
462 "descr",
463 i18n.translate(self.descr, hint=f"descr of a <{type(self).__name__}>{self.name}")
464 )
466 def isInvalid(self, value):
467 """
468 Checks if the current value of the bone in the given skeleton is invalid.
469 Returns None if the value would be valid for this bone, an error-message otherwise.
470 """
471 return False
473 def isEmpty(self, value: t.Any) -> bool:
474 """
475 Check if the given single value represents the "empty" value.
476 This usually is the empty string, 0 or False.
478 .. warning:: isEmpty takes precedence over isInvalid! The empty value is always
479 valid - unless the bone is required.
480 But even then the empty value will be reflected back to the client.
482 .. warning:: value might be the string/object received from the user (untrusted
483 input!) or the value returned by get
484 """
485 return not bool(value)
487 def getDefaultValue(self, skeletonInstance):
488 """
489 Retrieves the default value for the bone.
491 This method is called by the framework to obtain the default value of a bone when no value
492 is provided. Derived bone classes can overwrite this method to implement their own logic for
493 providing a default value.
495 :return: The default value of the bone, which can be of any data type.
496 """
497 if callable(self.defaultValue):
498 res = self.defaultValue(skeletonInstance, self)
499 if self.languages and self.multiple:
500 if not isinstance(res, dict):
501 if not isinstance(res, (list, set, tuple)):
502 return {lang: [res] for lang in self.languages}
503 else:
504 return {lang: res for lang in self.languages}
505 elif self.languages:
506 if not isinstance(res, dict):
507 return {lang: res for lang in self.languages}
508 elif self.multiple:
509 if not isinstance(res, (list, set, tuple)):
510 return [res]
511 return res
513 elif isinstance(self.defaultValue, list):
514 return self.defaultValue[:]
515 elif isinstance(self.defaultValue, dict):
516 return self.defaultValue.copy()
517 else:
518 return self.defaultValue
520 def getEmptyValue(self) -> t.Any:
521 """
522 Returns the value representing an empty field for this bone.
523 This might be the empty string for str/text Bones, Zero for numeric bones etc.
524 """
525 return None
527 def __setattr__(self, key, value):
528 """
529 Custom attribute setter for the BaseBone class.
531 This method is used to ensure that certain bone attributes, such as 'multiple', are only
532 set once during the bone's lifetime. Derived bone classes should not need to overwrite this
533 method unless they have additional attributes with similar constraints.
535 :param key: A string representing the attribute name.
536 :param value: The value to be assigned to the attribute.
538 :raises AttributeError: If a protected attribute is attempted to be modified after its initial
539 assignment.
540 """
541 if not self.isClonedInstance and getSystemInitialized() and key != "isClonedInstance" and not key.startswith( 541 ↛ 543line 541 didn't jump to line 543 because the condition on line 541 was never true
542 "_"):
543 raise AttributeError("You cannot modify this Skeleton. Grab a copy using .clone() first")
544 super().__setattr__(key, value)
546 def collectRawClientData(self, name, data, multiple, languages, collectSubfields):
547 """
548 Collects raw client data for the bone and returns it in a dictionary.
550 This method is called by the framework to gather raw data from the client, such as form data or data from a
551 request. Derived bone classes should overwrite this method to implement their own logic for collecting raw data.
553 :param name: A string representing the bone's name.
554 :param data: A dictionary containing the raw data from the client.
555 :param multiple: A boolean indicating whether the bone supports multiple values.
556 :param languages: An optional list of strings representing the supported languages (default: None).
557 :param collectSubfields: A boolean indicating whether to collect data for subfields (default: False).
559 :return: A dictionary containing the collected raw client data.
560 """
561 fieldSubmitted = False
563 if languages:
564 res = {}
565 for lang in languages:
566 if not collectSubfields: 566 ↛ 578line 566 didn't jump to line 578 because the condition on line 566 was always true
567 if f"{name}.{lang}" in data:
568 fieldSubmitted = True
569 res[lang] = data[f"{name}.{lang}"]
570 if multiple and not isinstance(res[lang], list): 570 ↛ 571line 570 didn't jump to line 571 because the condition on line 570 was never true
571 res[lang] = [res[lang]]
572 elif not multiple and isinstance(res[lang], list): 572 ↛ 573line 572 didn't jump to line 573 because the condition on line 572 was never true
573 if res[lang]:
574 res[lang] = res[lang][0]
575 else:
576 res[lang] = None
577 else:
578 for key in data.keys(): # Allow setting relations with using, multiple and languages back to none
579 if key == f"{name}.{lang}":
580 fieldSubmitted = True
581 prefix = f"{name}.{lang}."
582 if multiple:
583 tmpDict = {}
584 for key, value in data.items():
585 if not key.startswith(prefix):
586 continue
587 fieldSubmitted = True
588 partKey = key[len(prefix):]
589 firstKey, remainingKey = partKey.split(".", maxsplit=1)
590 try:
591 firstKey = int(firstKey)
592 except:
593 continue
594 if firstKey not in tmpDict:
595 tmpDict[firstKey] = {}
596 tmpDict[firstKey][remainingKey] = value
597 tmpList = list(tmpDict.items())
598 tmpList.sort(key=lambda x: x[0])
599 res[lang] = [x[1] for x in tmpList]
600 else:
601 tmpDict = {}
602 for key, value in data.items():
603 if not key.startswith(prefix):
604 continue
605 fieldSubmitted = True
606 partKey = key[len(prefix):]
607 tmpDict[partKey] = value
608 res[lang] = tmpDict
609 return res, fieldSubmitted
610 else: # No multi-lang
611 if not collectSubfields: 611 ↛ 625line 611 didn't jump to line 625 because the condition on line 611 was always true
612 if name not in data: # Empty! 612 ↛ 613line 612 didn't jump to line 613 because the condition on line 612 was never true
613 return None, False
614 val = data[name]
615 if multiple and not isinstance(val, list): 615 ↛ 616line 615 didn't jump to line 616 because the condition on line 615 was never true
616 return [val], True
617 elif not multiple and isinstance(val, list): 617 ↛ 618line 617 didn't jump to line 618 because the condition on line 617 was never true
618 if val:
619 return val[0], True
620 else:
621 return None, True # Empty!
622 else:
623 return val, True
624 else: # No multi-lang but collect subfields
625 for key in data.keys(): # Allow setting relations with using, multiple and languages back to none
626 if key == name:
627 fieldSubmitted = True
628 prefix = f"{name}."
629 if multiple:
630 tmpDict = {}
631 for key, value in data.items():
632 if not key.startswith(prefix):
633 continue
634 fieldSubmitted = True
635 partKey = key[len(prefix):]
636 try:
637 firstKey, remainingKey = partKey.split(".", maxsplit=1)
638 firstKey = int(firstKey)
639 except:
640 continue
641 if firstKey not in tmpDict:
642 tmpDict[firstKey] = {}
643 tmpDict[firstKey][remainingKey] = value
644 tmpList = list(tmpDict.items())
645 tmpList.sort(key=lambda x: x[0])
646 return [x[1] for x in tmpList], fieldSubmitted
647 else:
648 res = {}
649 for key, value in data.items():
650 if not key.startswith(prefix):
651 continue
652 fieldSubmitted = True
653 subKey = key[len(prefix):]
654 res[subKey] = value
655 return res, fieldSubmitted
657 def parseSubfieldsFromClient(self) -> bool:
658 """
659 Determines whether the function should parse subfields submitted by the client.
660 Set to True only when expecting a list of dictionaries to be transmitted.
661 """
662 return False
664 def singleValueFromClient(self, value: t.Any, skel: 'SkeletonInstance',
665 bone_name: str, client_data: dict
666 ) -> tuple[t.Any, list[ReadFromClientError] | None]:
667 """Load a single value from a client
669 :param value: The single value which should be loaded.
670 :param skel: The SkeletonInstance where the value should be loaded into.
671 :param bone_name: The bone name of this bone in the SkeletonInstance.
672 :param client_data: The data taken from the client,
673 a dictionary with usually bone names as key
674 :return: A tuple. If the value is valid, the first element is
675 the parsed value and the second is None.
676 If the value is invalid or not parseable, the first element is a empty value
677 and the second a list of *ReadFromClientError*.
678 """
679 # The BaseBone will not read any client_data in fromClient. Use rawValueBone if needed.
680 return self.getEmptyValue(), [
681 ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Will not read a BaseBone fromClient!")]
683 def fromClient(self, skel: 'SkeletonInstance', name: str, data: dict) -> None | list[ReadFromClientError]:
684 """
685 Reads a value from the client and stores it in the skeleton instance if it is valid for the bone.
687 This function reads a value from the client and processes it according to the bone's configuration.
688 If the value is valid for the bone, it stores the value in the skeleton instance and returns None.
689 Otherwise, the previous value remains unchanged, and a list of ReadFromClientError objects is returned.
691 :param skel: A SkeletonInstance object where the values should be loaded.
692 :param name: A string representing the bone's name.
693 :param data: A dictionary containing the raw data from the client.
694 :return: None if no errors occurred, otherwise a list of ReadFromClientError objects.
695 """
696 subFields = self.parseSubfieldsFromClient()
697 parsedData, fieldSubmitted = self.collectRawClientData(name, data, self.multiple, self.languages, subFields)
698 if not fieldSubmitted: 698 ↛ 699line 698 didn't jump to line 699 because the condition on line 698 was never true
699 return [ReadFromClientError(ReadFromClientErrorSeverity.NotSet, "Field not submitted")]
700 errors = []
701 isEmpty = True
702 filled_languages = set()
703 if self.languages and self.multiple:
704 res = {}
705 for language in self.languages:
706 res[language] = []
707 if language in parsedData:
708 for idx, singleValue in enumerate(parsedData[language]):
709 if self.isEmpty(singleValue): 709 ↛ 710line 709 didn't jump to line 710 because the condition on line 709 was never true
710 continue
711 isEmpty = False
712 filled_languages.add(language)
713 parsedVal, parseErrors = self.singleValueFromClient(singleValue, skel, name, data)
714 res[language].append(parsedVal)
715 if isinstance(self.multiple, MultipleConstraints) and self.multiple.sorted: 715 ↛ 716line 715 didn't jump to line 716 because the condition on line 715 was never true
716 if callable(self.multiple.sorted):
717 res[language] = sorted(
718 res[language],
719 key=self.multiple.sorted,
720 reverse=self.multiple.reversed,
721 )
722 else:
723 res[language] = sorted(res[language], reverse=self.multiple.reversed)
724 if parseErrors: 724 ↛ 725line 724 didn't jump to line 725 because the condition on line 724 was never true
725 for parseError in parseErrors:
726 parseError.fieldPath[:0] = [language, str(idx)]
727 errors.extend(parseErrors)
728 elif self.languages: # and not self.multiple is implicit - this would have been handled above
729 res = {}
730 for language in self.languages:
731 res[language] = None
732 if language in parsedData:
733 if self.isEmpty(parsedData[language]): 733 ↛ 734line 733 didn't jump to line 734 because the condition on line 733 was never true
734 res[language] = self.getEmptyValue()
735 continue
736 isEmpty = False
737 filled_languages.add(language)
738 parsedVal, parseErrors = self.singleValueFromClient(parsedData[language], skel, name, data)
739 res[language] = parsedVal
740 if parseErrors: 740 ↛ 741line 740 didn't jump to line 741 because the condition on line 740 was never true
741 for parseError in parseErrors:
742 parseError.fieldPath.insert(0, language)
743 errors.extend(parseErrors)
744 elif self.multiple: # and not self.languages is implicit - this would have been handled above
745 res = []
746 for idx, singleValue in enumerate(parsedData):
747 if self.isEmpty(singleValue): 747 ↛ 748line 747 didn't jump to line 748 because the condition on line 747 was never true
748 continue
749 isEmpty = False
750 parsedVal, parseErrors = self.singleValueFromClient(singleValue, skel, name, data)
751 res.append(parsedVal)
753 if parseErrors: 753 ↛ 754line 753 didn't jump to line 754 because the condition on line 753 was never true
754 for parseError in parseErrors:
755 parseError.fieldPath.insert(0, str(idx))
756 errors.extend(parseErrors)
757 if isinstance(self.multiple, MultipleConstraints) and self.multiple.sorted: 757 ↛ 758line 757 didn't jump to line 758 because the condition on line 757 was never true
758 if callable(self.multiple.sorted):
759 res = sorted(res, key=self.multiple.sorted, reverse=self.multiple.reversed)
760 else:
761 res = sorted(res, reverse=self.multiple.reversed)
762 else: # No Languages, not multiple
763 if self.isEmpty(parsedData):
764 res = self.getEmptyValue()
765 isEmpty = True
766 else:
767 isEmpty = False
768 res, parseErrors = self.singleValueFromClient(parsedData, skel, name, data)
769 if parseErrors:
770 errors.extend(parseErrors)
771 skel[name] = res
772 if self.languages and isinstance(self.required, (list, tuple)): 772 ↛ 773line 772 didn't jump to line 773 because the condition on line 772 was never true
773 missing = set(self.required).difference(filled_languages)
774 if missing:
775 return [ReadFromClientError(ReadFromClientErrorSeverity.Empty, "Field not set", fieldPath=[lang])
776 for lang in missing]
777 if isEmpty:
778 return [ReadFromClientError(ReadFromClientErrorSeverity.Empty, "Field not set")]
780 # Check multiple constraints on demand
781 if self.multiple and isinstance(self.multiple, MultipleConstraints): 781 ↛ 782line 781 didn't jump to line 782 because the condition on line 781 was never true
782 errors.extend(self._validate_multiple_contraints(self.multiple, skel, name))
784 return errors or None
786 def _get_single_destinct_hash(self, value) -> t.Any:
787 """
788 Returns a distinct hash value for a single entry of this bone.
789 The returned value must be hashable.
790 """
791 return value
793 def _get_destinct_hash(self, value) -> t.Any:
794 """
795 Returns a distinct hash value for this bone.
796 The returned value must be hashable.
797 """
798 if not isinstance(value, str) and isinstance(value, Iterable):
799 return tuple(self._get_single_destinct_hash(item) for item in value)
801 return value
803 def _validate_multiple_contraints(
804 self,
805 constraints: MultipleConstraints,
806 skel: 'SkeletonInstance',
807 name: str
808 ) -> list[ReadFromClientError]:
809 """
810 Validates the value of a bone against its multiple constraints and returns a list of ReadFromClientError
811 objects for each violation, such as too many items or duplicates.
813 :param constraints: The MultipleConstraints definition to apply.
814 :param skel: A SkeletonInstance object where the values should be validated.
815 :param name: A string representing the bone's name.
816 :return: A list of ReadFromClientError objects for each constraint violation.
817 """
818 res = []
819 value = self._get_destinct_hash(skel[name])
821 if constraints.min and len(value) < constraints.min:
822 res.append(ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Too few items"))
824 if constraints.max and len(value) > constraints.max:
825 res.append(ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Too many items"))
827 if not constraints.duplicates:
828 if len(set(value)) != len(value):
829 res.append(ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Duplicate items"))
831 return res
833 def singleValueSerialize(self, value, skel: 'SkeletonInstance', name: str, parentIndexed: bool):
834 """
835 Serializes a single value of the bone for storage in the database.
837 Derived bone classes should overwrite this method to implement their own logic for serializing single
838 values.
839 The serialized value should be suitable for storage in the database.
840 """
841 return value
843 def serialize(self, skel: 'SkeletonInstance', name: str, parentIndexed: bool) -> bool:
844 """
845 Serializes this bone into a format that can be written into the datastore.
847 :param skel: A SkeletonInstance object containing the values to be serialized.
848 :param name: A string representing the property name of the bone in its Skeleton (not the description).
849 :param parentIndexed: A boolean indicating whether the parent bone is indexed.
850 :return: A boolean indicating whether the serialization was successful.
851 """
852 self.serialize_compute(skel, name)
854 if name in skel.accessedValues:
855 newVal = skel.accessedValues[name]
856 if self.languages and self.multiple:
857 res = db.Entity()
858 res["_viurLanguageWrapper_"] = True
859 for language in self.languages:
860 res[language] = []
861 if not self.indexed:
862 res.exclude_from_indexes.add(language)
863 if language in newVal:
864 for singleValue in newVal[language]:
865 res[language].append(self.singleValueSerialize(singleValue, skel, name, parentIndexed))
866 elif self.languages:
867 res = db.Entity()
868 res["_viurLanguageWrapper_"] = True
869 for language in self.languages:
870 res[language] = None
871 if not self.indexed:
872 res.exclude_from_indexes.add(language)
873 if language in newVal:
874 res[language] = self.singleValueSerialize(newVal[language], skel, name, parentIndexed)
875 elif self.multiple:
876 res = []
878 assert newVal is None or isinstance(newVal, (list, tuple)), \
879 f"Cannot handle {repr(newVal)} here. Expecting list or tuple."
881 for singleValue in (newVal or ()):
882 res.append(self.singleValueSerialize(singleValue, skel, name, parentIndexed))
884 else: # No Languages, not Multiple
885 res = self.singleValueSerialize(newVal, skel, name, parentIndexed)
886 skel.dbEntity[name] = res
887 # Ensure our indexed flag is up2date
888 indexed = self.indexed and parentIndexed
889 if indexed and name in skel.dbEntity.exclude_from_indexes:
890 skel.dbEntity.exclude_from_indexes.discard(name)
891 elif not indexed and name not in skel.dbEntity.exclude_from_indexes:
892 skel.dbEntity.exclude_from_indexes.add(name)
893 return True
894 return False
896 def serialize_compute(self, skel: "SkeletonInstance", name: str) -> None:
897 """
898 This function checks whether a bone is computed and if this is the case, it attempts to serialize the
899 value with the appropriate calculation method
901 :param skel: The SkeletonInstance where the current bone is located
902 :param name: The name of the bone in the Skeleton
903 """
904 if not self.compute:
905 return None
906 match self.compute.interval.method:
907 case ComputeMethod.OnWrite:
908 skel.accessedValues[name] = self._compute(skel, name)
910 case ComputeMethod.Lifetime:
911 now = utils.utcNow()
913 last_update = \
914 skel.accessedValues.get(f"_viur_compute_{name}_") \
915 or skel.dbEntity.get(f"_viur_compute_{name}_")
917 if not last_update or last_update + self.compute.interval.lifetime < now:
918 skel.accessedValues[name] = self._compute(skel, name)
919 skel.dbEntity[f"_viur_compute_{name}_"] = now
921 case ComputeMethod.Once:
922 if name not in skel.dbEntity:
923 skel.accessedValues[name] = self._compute(skel, name)
926 def singleValueUnserialize(self, val):
927 """
928 Unserializes a single value of the bone from the stored database value.
930 Derived bone classes should overwrite this method to implement their own logic for unserializing
931 single values. The unserialized value should be suitable for use in the application logic.
932 """
933 return val
935 def unserialize(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> bool:
936 """
937 Deserialize bone data from the datastore and populate the bone with the deserialized values.
939 This function is the inverse of the serialize function. It converts data from the datastore
940 into a format that can be used by the bones in the skeleton.
942 :param skel: A SkeletonInstance object containing the values to be deserialized.
943 :param name: The property name of the bone in its Skeleton (not the description).
944 :returns: True if deserialization is successful, False otherwise.
945 """
946 if name in skel.dbEntity:
947 loadVal = skel.dbEntity[name]
948 elif (
949 # fixme: Remove this piece of sh*t at least with VIUR4
950 # We're importing from an old ViUR2 instance - there may only be keys prefixed with our name
951 conf.viur2import_blobsource and any(n.startswith(name + ".") for n in skel.dbEntity)
952 # ... or computed
953 or self.compute
954 ):
955 loadVal = None
956 else:
957 skel.accessedValues[name] = self.getDefaultValue(skel)
958 return False
960 if self.unserialize_compute(skel, name):
961 return True
963 # unserialize value to given config
964 if self.languages and self.multiple:
965 res = {}
966 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal:
967 for language in self.languages:
968 res[language] = []
969 if language in loadVal:
970 tmpVal = loadVal[language]
971 if not isinstance(tmpVal, list):
972 tmpVal = [tmpVal]
973 for singleValue in tmpVal:
974 res[language].append(self.singleValueUnserialize(singleValue))
975 else: # We could not parse this, maybe it has been written before languages had been set?
976 for language in self.languages:
977 res[language] = []
978 mainLang = self.languages[0]
979 if loadVal is None:
980 pass
981 elif isinstance(loadVal, list):
982 for singleValue in loadVal:
983 res[mainLang].append(self.singleValueUnserialize(singleValue))
984 else: # Hopefully it's a value stored before languages and multiple has been set
985 res[mainLang].append(self.singleValueUnserialize(loadVal))
986 elif self.languages:
987 res = {}
988 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal:
989 for language in self.languages:
990 res[language] = None
991 if language in loadVal:
992 tmpVal = loadVal[language]
993 if isinstance(tmpVal, list) and tmpVal:
994 tmpVal = tmpVal[0]
995 res[language] = self.singleValueUnserialize(tmpVal)
996 else: # We could not parse this, maybe it has been written before languages had been set?
997 for language in self.languages:
998 res[language] = None
999 oldKey = f"{name}.{language}"
1000 if oldKey in skel.dbEntity and skel.dbEntity[oldKey]:
1001 res[language] = self.singleValueUnserialize(skel.dbEntity[oldKey])
1002 loadVal = None # Don't try to import later again, this format takes precedence
1003 mainLang = self.languages[0]
1004 if loadVal is None:
1005 pass
1006 elif isinstance(loadVal, list) and loadVal:
1007 res[mainLang] = self.singleValueUnserialize(loadVal)
1008 else: # Hopefully it's a value stored before languages and multiple has been set
1009 res[mainLang] = self.singleValueUnserialize(loadVal)
1010 elif self.multiple:
1011 res = []
1012 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal:
1013 # Pick one language we'll use
1014 if conf.i18n.default_language in loadVal:
1015 loadVal = loadVal[conf.i18n.default_language]
1016 else:
1017 loadVal = [x for x in loadVal.values() if x is not True]
1018 if loadVal and not isinstance(loadVal, list):
1019 loadVal = [loadVal]
1020 if loadVal:
1021 for val in loadVal:
1022 res.append(self.singleValueUnserialize(val))
1023 else: # Not multiple, no languages
1024 res = None
1025 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal:
1026 # Pick one language we'll use
1027 if conf.i18n.default_language in loadVal:
1028 loadVal = loadVal[conf.i18n.default_language]
1029 else:
1030 loadVal = [x for x in loadVal.values() if x is not True]
1031 if loadVal and isinstance(loadVal, list):
1032 loadVal = loadVal[0]
1033 if loadVal is not None:
1034 res = self.singleValueUnserialize(loadVal)
1036 skel.accessedValues[name] = res
1037 return True
1039 def unserialize_compute(self, skel: "SkeletonInstance", name: str) -> bool:
1040 """
1041 This function checks whether a bone is computed and if this is the case, it attempts to deserialise the
1042 value with the appropriate calculation method
1044 :param skel : The SkeletonInstance where the current Bone is located
1045 :param name: The name of the Bone in the Skeleton
1046 :return: True if the Bone was unserialized, False otherwise
1047 """
1048 if not self.compute or self._prevent_compute:
1049 return False
1051 match self.compute.interval.method:
1052 # Computation is bound to a lifetime?
1053 case ComputeMethod.Lifetime:
1054 now = utils.utcNow()
1055 from viur.core.skeleton import RefSkel # noqa: E402 # import works only here because circular imports
1057 if issubclass(skel.skeletonCls, RefSkel): # we have a ref skel we must load the complete Entity
1058 db_obj = db.Get(skel["key"])
1059 last_update = db_obj.get(f"_viur_compute_{name}_")
1060 else:
1061 last_update = skel.dbEntity.get(f"_viur_compute_{name}_")
1062 skel.accessedValues[f"_viur_compute_{name}_"] = last_update or now
1064 if not last_update or last_update + self.compute.interval.lifetime <= now:
1065 # if so, recompute and refresh updated value
1066 skel.accessedValues[name] = value = self._compute(skel, name)
1067 def transact():
1068 db_obj = db.Get(skel["key"])
1069 db_obj[f"_viur_compute_{name}_"] = now
1070 db_obj[name] = value
1071 db.Put(db_obj)
1073 if db.IsInTransaction():
1074 transact()
1075 else:
1076 db.RunInTransaction(transact)
1078 return True
1080 # Compute on every deserialization
1081 case ComputeMethod.Always:
1082 skel.accessedValues[name] = self._compute(skel, name)
1083 return True
1085 return False
1087 def delete(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str):
1088 """
1089 Like postDeletedHandler, but runs inside the transaction
1090 """
1091 pass
1093 def buildDBFilter(self,
1094 name: str,
1095 skel: 'viur.core.skeleton.SkeletonInstance',
1096 dbFilter: db.Query,
1097 rawFilter: dict,
1098 prefix: t.Optional[str] = None) -> db.Query:
1099 """
1100 Parses the searchfilter a client specified in his Request into
1101 something understood by the datastore.
1102 This function must:
1104 * - Ignore all filters not targeting this bone
1105 * - Safely handle malformed data in rawFilter (this parameter is directly controlled by the client)
1107 :param name: The property-name this bone has in its Skeleton (not the description!)
1108 :param skel: The :class:`viur.core.db.Query` this bone is part of
1109 :param dbFilter: The current :class:`viur.core.db.Query` instance the filters should be applied to
1110 :param rawFilter: The dictionary of filters the client wants to have applied
1111 :returns: The modified :class:`viur.core.db.Query`
1112 """
1113 myKeys = [key for key in rawFilter.keys() if (key == name or key.startswith(name + "$"))]
1115 if len(myKeys) == 0:
1116 return dbFilter
1118 for key in myKeys:
1119 value = rawFilter[key]
1120 tmpdata = key.split("$")
1122 if len(tmpdata) > 1:
1123 if isinstance(value, list):
1124 continue
1125 if tmpdata[1] == "lt":
1126 dbFilter.filter((prefix or "") + tmpdata[0] + " <", value)
1127 elif tmpdata[1] == "le":
1128 dbFilter.filter((prefix or "") + tmpdata[0] + " <=", value)
1129 elif tmpdata[1] == "gt":
1130 dbFilter.filter((prefix or "") + tmpdata[0] + " >", value)
1131 elif tmpdata[1] == "ge":
1132 dbFilter.filter((prefix or "") + tmpdata[0] + " >=", value)
1133 elif tmpdata[1] == "lk":
1134 dbFilter.filter((prefix or "") + tmpdata[0] + " =", value)
1135 else:
1136 dbFilter.filter((prefix or "") + tmpdata[0] + " =", value)
1137 else:
1138 if isinstance(value, list):
1139 dbFilter.filter((prefix or "") + key + " IN", value)
1140 else:
1141 dbFilter.filter((prefix or "") + key + " =", value)
1143 return dbFilter
1145 def buildDBSort(
1146 self,
1147 name: str,
1148 skel: "SkeletonInstance",
1149 query: db.Query,
1150 params: dict,
1151 postfix: str = "",
1152 ) -> t.Optional[db.Query]:
1153 """
1154 Same as buildDBFilter, but this time its not about filtering
1155 the results, but by sorting them.
1156 Again: query is controlled by the client, so you *must* expect and safely handle
1157 malformed data!
1159 :param name: The property-name this bone has in its Skeleton (not the description!)
1160 :param skel: The :class:`viur.core.skeleton.Skeleton` instance this bone is part of
1161 :param dbFilter: The current :class:`viur.core.db.Query` instance the filters should
1162 be applied to
1163 :param query: The dictionary of filters the client wants to have applied
1164 :param postfix: Inherited classes may use this to add a postfix to the porperty name
1165 :returns: The modified :class:`viur.core.db.Query`,
1166 None if the query is unsatisfiable.
1167 """
1168 if query.queries and (orderby := params.get("orderby")) and utils.string.is_prefix(orderby, name):
1169 if self.languages:
1170 lang = None
1171 prefix = f"{name}."
1172 if orderby.startswith(prefix):
1173 lng = orderby[len(prefix):]
1174 if lng in self.languages:
1175 lang = lng
1177 if lang is None:
1178 lang = current.language.get()
1179 if not lang or lang not in self.languages:
1180 lang = self.languages[0]
1182 prop = f"{name}.{lang}"
1183 else:
1184 prop = name
1186 # In case this is a multiple query, check if all filters are valid
1187 if isinstance(query.queries, list):
1188 in_eq_filter = None
1190 for item in query.queries:
1191 new_in_eq_filter = [
1192 key for key in item.filters.keys()
1193 if key.rstrip().endswith(("<", ">", "!="))
1194 ]
1195 if in_eq_filter and new_in_eq_filter and in_eq_filter != new_in_eq_filter:
1196 raise NotImplementedError("Impossible ordering!")
1198 in_eq_filter = new_in_eq_filter
1200 else:
1201 in_eq_filter = [
1202 key for key in query.queries.filters.keys()
1203 if key.rstrip().endswith(("<", ">", "!="))
1204 ]
1206 if in_eq_filter:
1207 orderby_prop = in_eq_filter[0].split(" ", 1)[0]
1208 if orderby_prop != prop:
1209 logging.warning(
1210 f"The query was rewritten; Impossible ordering changed from {prop!r} into {orderby_prop!r}"
1211 )
1212 prop = orderby_prop
1214 query.order((prop + postfix, utils.parse.sortorder(params.get("orderdir"))))
1216 return query
1218 def _hashValueForUniquePropertyIndex(
1219 self,
1220 value: str | int | float | db.Key | list[str | int | float | db.Key],
1221 ) -> list[str]:
1222 """
1223 Generates a hash of the given value for creating unique property indexes.
1225 This method is called by the framework to create a consistent hash representation of a value
1226 for constructing unique property indexes. Derived bone classes should overwrite this method to
1227 implement their own logic for hashing values.
1229 :param value: The value(s) to be hashed.
1231 :return: A list containing a string representation of the hashed value. If the bone is multiple,
1232 the list may contain more than one hashed value.
1233 """
1235 def hashValue(value: str | int | float | db.Key) -> str:
1236 h = hashlib.sha256()
1237 h.update(str(value).encode("UTF-8"))
1238 res = h.hexdigest()
1239 if isinstance(value, int | float):
1240 return f"I-{res}"
1241 elif isinstance(value, str):
1242 return f"S-{res}"
1243 elif isinstance(value, db.Key):
1244 # We Hash the keys here by our self instead of relying on str() or to_legacy_urlsafe()
1245 # as these may change in the future, which would invalidate all existing locks
1246 def keyHash(key):
1247 if key is None:
1248 return "-"
1249 return f"{hashValue(key.kind)}-{hashValue(key.id_or_name)}-<{keyHash(key.parent)}>"
1251 return f"K-{keyHash(value)}"
1252 raise NotImplementedError(f"Type {type(value)} can't be safely used in an uniquePropertyIndex")
1254 if not value and not self.unique.lockEmpty:
1255 return [] # We are zero/empty string and these should not be locked
1256 if not self.multiple and not isinstance(value, list):
1257 return [hashValue(value)]
1258 # We have a multiple bone or multiple values here
1259 if not isinstance(value, list):
1260 value = [value]
1261 tmpList = [hashValue(x) for x in value]
1262 if self.unique.method == UniqueLockMethod.SameValue:
1263 # We should lock each entry individually; lock each value
1264 return tmpList
1265 elif self.unique.method == UniqueLockMethod.SameSet:
1266 # We should ignore the sort-order; so simply sort that List
1267 tmpList.sort()
1268 # Lock the value for that specific list
1269 return [hashValue(", ".join(tmpList))]
1271 def getUniquePropertyIndexValues(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> list[str]:
1272 """
1273 Returns a list of hashes for the current value(s) of a bone in the skeleton, used for storing in the
1274 unique property value index.
1276 :param skel: A SkeletonInstance object representing the current skeleton.
1277 :param name: The property-name of the bone in the skeleton for which the unique property index values
1278 are required (not the description!).
1280 :return: A list of strings representing the hashed values for the current bone value(s) in the skeleton.
1281 If the bone has no value, an empty list is returned.
1282 """
1283 val = skel[name]
1284 if val is None:
1285 return []
1286 return self._hashValueForUniquePropertyIndex(val)
1288 def getReferencedBlobs(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> set[str]:
1289 """
1290 Returns a set of blob keys referenced from this bone
1291 """
1292 return set()
1294 def performMagic(self, valuesCache: dict, name: str, isAdd: bool):
1295 """
1296 This function applies "magically" functionality which f.e. inserts the current Date
1297 or the current user.
1298 :param isAdd: Signals wherever this is an add or edit operation.
1299 """
1300 pass # We do nothing by default
1302 def postSavedHandler(self, skel: "SkeletonInstance", boneName: str, key: db.Key | None) -> None:
1303 """
1304 Can be overridden to perform further actions after the main entity has been written.
1306 :param boneName: Name of this bone
1307 :param skel: The skeleton this bone belongs to
1308 :param key: The (new?) Database Key we've written to. In case of a RelSkel the key is None.
1309 """
1310 pass
1312 def postDeletedHandler(self, skel: 'viur.core.skeleton.SkeletonInstance', boneName: str, key: str):
1313 """
1314 Can be overridden to perform further actions after the main entity has been deleted.
1316 :param skel: The skeleton this bone belongs to
1317 :param boneName: Name of this bone
1318 :param key: The old Database Key of the entity we've deleted
1319 """
1320 pass
1322 def clone_value(self, skel: "SkeletonInstance", src_skel: "SkeletonInstance", bone_name: str) -> None:
1323 """Clone / Set the value for this bone depending on :attr:`clone_behavior`"""
1324 match self.clone_behavior.strategy:
1325 case CloneStrategy.COPY_VALUE:
1326 try:
1327 skel.accessedValues[bone_name] = copy.deepcopy(src_skel.accessedValues[bone_name])
1328 except KeyError:
1329 pass # bone_name is not in accessedValues, cannot clone it
1330 try:
1331 skel.renderAccessedValues[bone_name] = copy.deepcopy(src_skel.renderAccessedValues[bone_name])
1332 except KeyError:
1333 pass # bone_name is not in renderAccessedValues, cannot clone it
1334 case CloneStrategy.SET_NULL:
1335 skel.accessedValues[bone_name] = None
1336 case CloneStrategy.SET_DEFAULT:
1337 skel.accessedValues[bone_name] = self.getDefaultValue(skel)
1338 case CloneStrategy.SET_EMPTY:
1339 skel.accessedValues[bone_name] = self.getEmptyValue()
1340 case CloneStrategy.CUSTOM:
1341 skel.accessedValues[bone_name] = self.clone_behavior.custom_func(skel, src_skel, bone_name)
1342 case other:
1343 raise NotImplementedError(other)
1345 def refresh(self, skel: 'viur.core.skeleton.SkeletonInstance', boneName: str) -> None:
1346 """
1347 Refresh all values we might have cached from other entities.
1348 """
1349 pass
1351 def mergeFrom(self, valuesCache: dict, boneName: str, otherSkel: 'viur.core.skeleton.SkeletonInstance'):
1352 """
1353 Merges the values from another skeleton instance into the current instance, given that the bone types match.
1355 :param valuesCache: A dictionary containing the cached values for each bone in the skeleton.
1356 :param boneName: The property-name of the bone in the skeleton whose values are to be merged.
1357 :param otherSkel: A SkeletonInstance object representing the other skeleton from which the values \
1358 are to be merged.
1360 This function clones the values from the specified bone in the other skeleton instance into the current
1361 instance, provided that the bone types match. If the bone types do not match, a warning is logged, and the merge
1362 is ignored. If the bone in the other skeleton has no value, the function returns without performing any merge
1363 operation.
1364 """
1365 if getattr(otherSkel, boneName) is None:
1366 return
1367 if not isinstance(getattr(otherSkel, boneName), type(self)):
1368 logging.error(f"Ignoring values from conflicting boneType ({getattr(otherSkel, boneName)} is not a "
1369 f"instance of {type(self)})!")
1370 return
1371 valuesCache[boneName] = copy.deepcopy(otherSkel.valuesCache.get(boneName, None))
1373 def setBoneValue(self,
1374 skel: 'SkeletonInstance',
1375 boneName: str,
1376 value: t.Any,
1377 append: bool,
1378 language: None | str = None) -> bool:
1379 """
1380 Sets the value of a bone in a skeleton instance, with optional support for appending and language-specific
1381 values. Sanity checks are being performed.
1383 :param skel: The SkeletonInstance object representing the skeleton to which the bone belongs.
1384 :param boneName: The property-name of the bone in the skeleton whose value should be set or modified.
1385 :param value: The value to be assigned. Its type depends on the type of the bone.
1386 :param append: If True, the given value is appended to the bone's values instead of replacing it. \
1387 Only supported for bones with multiple=True.
1388 :param language: The language code for which the value should be set or appended, \
1389 if the bone supports languages.
1391 :return: A boolean indicating whether the operation was successful or not.
1393 This function sets or modifies the value of a bone in a skeleton instance, performing sanity checks to ensure
1394 the value is valid. If the value is invalid, no modification occurs. The function supports appending values to
1395 bones with multiple=True and setting or appending language-specific values for bones that support languages.
1396 """
1397 assert not (bool(self.languages) ^ bool(language)), f"language is required or not supported on {boneName!r}"
1398 assert not append or self.multiple, "Can't append - bone is not multiple"
1400 if not append and self.multiple:
1401 # set multiple values at once
1402 val = []
1403 errors = []
1404 for singleValue in value:
1405 singleValue, singleError = self.singleValueFromClient(singleValue, skel, boneName, {boneName: value})
1406 val.append(singleValue)
1407 if singleError: 1407 ↛ 1408line 1407 didn't jump to line 1408 because the condition on line 1407 was never true
1408 errors.extend(singleError)
1409 else:
1410 # set or append one value
1411 val, errors = self.singleValueFromClient(value, skel, boneName, {boneName: value})
1413 if errors:
1414 for e in errors: 1414 ↛ 1419line 1414 didn't jump to line 1419 because the loop on line 1414 didn't complete
1415 if e.severity in [ReadFromClientErrorSeverity.Invalid, ReadFromClientErrorSeverity.NotSet]: 1415 ↛ 1414line 1415 didn't jump to line 1414 because the condition on line 1415 was always true
1416 # If an invalid datatype (or a non-parseable structure) have been passed, abort the store
1417 logging.error(e)
1418 return False
1419 if not append and not language:
1420 skel[boneName] = val
1421 elif append and language: 1421 ↛ 1422line 1421 didn't jump to line 1422 because the condition on line 1421 was never true
1422 if not language in skel[boneName] or not isinstance(skel[boneName][language], list):
1423 skel[boneName][language] = []
1424 skel[boneName][language].append(val)
1425 elif append: 1425 ↛ 1430line 1425 didn't jump to line 1430 because the condition on line 1425 was always true
1426 if not isinstance(skel[boneName], list): 1426 ↛ 1427line 1426 didn't jump to line 1427 because the condition on line 1426 was never true
1427 skel[boneName] = []
1428 skel[boneName].append(val)
1429 else: # Just language
1430 skel[boneName][language] = val
1431 return True
1433 def getSearchTags(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> set[str]:
1434 """
1435 Returns a set of strings as search index for this bone.
1437 This function extracts a set of search tags from the given bone's value in the skeleton
1438 instance. The resulting set can be used for indexing or searching purposes.
1440 :param skel: The skeleton instance where the values should be loaded from. This is an instance
1441 of a class derived from `viur.core.skeleton.SkeletonInstance`.
1442 :param name: The name of the bone, which is a string representing the key for the bone in
1443 the skeleton. This should correspond to an existing bone in the skeleton instance.
1444 :return: A set of strings, extracted from the bone value. If the bone value doesn't have
1445 any searchable content, an empty set is returned.
1446 """
1447 return set()
1449 def iter_bone_value(
1450 self, skel: 'viur.core.skeleton.SkeletonInstance', name: str
1451 ) -> t.Iterator[tuple[t.Optional[int], t.Optional[str], t.Any]]:
1452 """
1453 Yield all values from the Skeleton related to this bone instance.
1455 This method handles multiple/languages cases, which could save a lot of if/elifs.
1456 It always yields a triplet: index, language, value.
1457 Where index is the index (int) of a value inside a multiple bone,
1458 language is the language (str) of a multi-language-bone,
1459 and value is the value inside this container.
1460 index or language is None if the bone is single or not multi-lang.
1462 This function can be used to conveniently iterate through all the values of a specific bone
1463 in a skeleton instance, taking into account multiple and multi-language bones.
1465 :param skel: The skeleton instance where the values should be loaded from. This is an instance
1466 of a class derived from `viur.core.skeleton.SkeletonInstance`.
1467 :param name: The name of the bone, which is a string representing the key for the bone in
1468 the skeleton. This should correspond to an existing bone in the skeleton instance.
1470 :return: A generator which yields triplets (index, language, value), where index is the index
1471 of a value inside a multiple bone, language is the language of a multi-language bone,
1472 and value is the value inside this container. index or language is None if the bone is
1473 single or not multi-lang.
1474 """
1475 value = skel[name]
1476 if not value:
1477 return None
1479 if self.languages and isinstance(value, dict):
1480 for idx, (lang, values) in enumerate(value.items()):
1481 if self.multiple:
1482 if not values:
1483 continue
1484 for val in values:
1485 yield idx, lang, val
1486 else:
1487 yield None, lang, values
1488 else:
1489 if self.multiple:
1490 for idx, val in enumerate(value):
1491 yield idx, None, val
1492 else:
1493 yield None, None, value
1495 def _compute(self, skel: 'viur.core.skeleton.SkeletonInstance', bone_name: str):
1496 """Performs the evaluation of a bone configured as compute"""
1498 compute_fn_parameters = inspect.signature(self.compute.fn).parameters
1499 compute_fn_args = {}
1500 if "skel" in compute_fn_parameters:
1501 from viur.core.skeleton import skeletonByKind, RefSkel # noqa: E402 # import works only here because circular imports
1503 if issubclass(skel.skeletonCls, RefSkel): # we have a ref skel we must load the complete skeleton
1504 cloned_skel = skeletonByKind(skel.kindName)()
1505 if not cloned_skel.read(skel["key"]):
1506 raise ValueError(f'{skel["key"]=!r} does no longer exists. Cannot compute a broken relation')
1507 else:
1508 cloned_skel = skel.clone()
1509 cloned_skel[bone_name] = None # remove value form accessedValues to avoid endless recursion
1510 compute_fn_args["skel"] = cloned_skel
1512 if "bone" in compute_fn_parameters:
1513 compute_fn_args["bone"] = getattr(skel, bone_name)
1515 if "bone_name" in compute_fn_parameters:
1516 compute_fn_args["bone_name"] = bone_name
1518 ret = self.compute.fn(**compute_fn_args)
1520 def unserialize_raw_value(raw_value: list[dict] | dict | None):
1521 if self.multiple:
1522 return [self.singleValueUnserialize(inner_value) for inner_value in raw_value]
1523 return self.singleValueUnserialize(raw_value)
1525 if self.compute.raw:
1526 if self.languages:
1527 return {
1528 lang: unserialize_raw_value(ret.get(lang, [] if self.multiple else None))
1529 for lang in self.languages
1530 }
1531 return unserialize_raw_value(ret)
1532 self._prevent_compute = True
1533 if errors := self.fromClient(skel, bone_name, {bone_name: ret}):
1534 raise ValueError(f"Computed value fromClient failed with {errors!r}")
1535 self._prevent_compute = False
1536 return skel[bone_name]
1538 def structure(self) -> dict:
1539 """
1540 Describes the bone and its settings as an JSON-serializable dict.
1541 This function has to be implemented for subsequent, specialized bone types.
1542 """
1543 ret = {
1544 "descr": self.descr,
1545 "type": self.type,
1546 "required": self.required and not self.readOnly,
1547 "params": self.params,
1548 "visible": self.visible,
1549 "readonly": self.readOnly,
1550 "unique": self.unique.method.value if self.unique else False,
1551 "languages": self.languages,
1552 "emptyvalue": self.getEmptyValue(),
1553 "indexed": self.indexed,
1554 "clone_behavior": {
1555 "strategy": self.clone_behavior.strategy,
1556 },
1557 }
1559 # Provide a defaultvalue, if it's not a function.
1560 if not callable(self.defaultValue) and self.defaultValue is not None:
1561 ret["defaultvalue"] = self.defaultValue
1563 # Provide a multiple setting
1564 if self.multiple and isinstance(self.multiple, MultipleConstraints):
1565 ret["multiple"] = {
1566 "duplicates": self.multiple.duplicates,
1567 "max": self.multiple.max,
1568 "min": self.multiple.min,
1569 }
1570 else:
1571 ret["multiple"] = self.multiple
1573 # Provide compute information
1574 if self.compute:
1575 ret["compute"] = {
1576 "method": self.compute.interval.method.name
1577 }
1579 if self.compute.interval.lifetime:
1580 ret["compute"]["lifetime"] = self.compute.interval.lifetime.total_seconds()
1582 return ret