Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/bones/base.py: 29%
796 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-02-27 07:59 +0000
« prev ^ index » next coverage.py v7.6.12, created at 2025-02-27 07:59 +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
562 if languages:
563 res = {}
564 for lang in languages:
565 if not collectSubfields: 565 ↛ 577line 565 didn't jump to line 577 because the condition on line 565 was always true
566 if f"{name}.{lang}" in data:
567 fieldSubmitted = True
568 res[lang] = data[f"{name}.{lang}"]
569 if multiple and not isinstance(res[lang], list): 569 ↛ 570line 569 didn't jump to line 570 because the condition on line 569 was never true
570 res[lang] = [res[lang]]
571 elif not multiple and isinstance(res[lang], list): 571 ↛ 572line 571 didn't jump to line 572 because the condition on line 571 was never true
572 if res[lang]:
573 res[lang] = res[lang][0]
574 else:
575 res[lang] = None
576 else:
577 for key in data.keys(): # Allow setting relations with using, multiple and languages back to none
578 if key == f"{name}.{lang}":
579 fieldSubmitted = True
580 prefix = f"{name}.{lang}."
581 if multiple:
582 tmpDict = {}
583 for key, value in data.items():
584 if not key.startswith(prefix):
585 continue
586 fieldSubmitted = True
587 partKey = key.replace(prefix, "")
588 firstKey, remainingKey = partKey.split(".", maxsplit=1)
589 try:
590 firstKey = int(firstKey)
591 except:
592 continue
593 if firstKey not in tmpDict:
594 tmpDict[firstKey] = {}
595 tmpDict[firstKey][remainingKey] = value
596 tmpList = list(tmpDict.items())
597 tmpList.sort(key=lambda x: x[0])
598 res[lang] = [x[1] for x in tmpList]
599 else:
600 tmpDict = {}
601 for key, value in data.items():
602 if not key.startswith(prefix):
603 continue
604 fieldSubmitted = True
605 partKey = key.replace(prefix, "")
606 tmpDict[partKey] = value
607 res[lang] = tmpDict
608 return res, fieldSubmitted
609 else: # No multi-lang
610 if not collectSubfields: 610 ↛ 624line 610 didn't jump to line 624 because the condition on line 610 was always true
611 if name not in data: # Empty! 611 ↛ 612line 611 didn't jump to line 612 because the condition on line 611 was never true
612 return None, False
613 val = data[name]
614 if multiple and not isinstance(val, list): 614 ↛ 615line 614 didn't jump to line 615 because the condition on line 614 was never true
615 return [val], True
616 elif not multiple and isinstance(val, list): 616 ↛ 617line 616 didn't jump to line 617 because the condition on line 616 was never true
617 if val:
618 return val[0], True
619 else:
620 return None, True # Empty!
621 else:
622 return val, True
623 else: # No multi-lang but collect subfields
624 for key in data.keys(): # Allow setting relations with using, multiple and languages back to none
625 if key == name:
626 fieldSubmitted = True
627 prefix = f"{name}."
628 if multiple:
629 tmpDict = {}
630 for key, value in data.items():
631 if not key.startswith(prefix):
632 continue
633 fieldSubmitted = True
634 partKey = key.replace(prefix, "")
635 try:
636 firstKey, remainingKey = partKey.split(".", maxsplit=1)
637 firstKey = int(firstKey)
638 except:
639 continue
640 if firstKey not in tmpDict:
641 tmpDict[firstKey] = {}
642 tmpDict[firstKey][remainingKey] = value
643 tmpList = list(tmpDict.items())
644 tmpList.sort(key=lambda x: x[0])
645 return [x[1] for x in tmpList], fieldSubmitted
646 else:
647 res = {}
648 for key, value in data.items():
649 if not key.startswith(prefix):
650 continue
651 fieldSubmitted = True
652 subKey = key.replace(prefix, "")
653 res[subKey] = value
654 return res, fieldSubmitted
656 def parseSubfieldsFromClient(self) -> bool:
657 """
658 Determines whether the function should parse subfields submitted by the client.
659 Set to True only when expecting a list of dictionaries to be transmitted.
660 """
661 return False
663 def singleValueFromClient(self, value: t.Any, skel: 'SkeletonInstance',
664 bone_name: str, client_data: dict
665 ) -> tuple[t.Any, list[ReadFromClientError] | None]:
666 """Load a single value from a client
668 :param value: The single value which should be loaded.
669 :param skel: The SkeletonInstance where the value should be loaded into.
670 :param bone_name: The bone name of this bone in the SkeletonInstance.
671 :param client_data: The data taken from the client,
672 a dictionary with usually bone names as key
673 :return: A tuple. If the value is valid, the first element is
674 the parsed value and the second is None.
675 If the value is invalid or not parseable, the first element is a empty value
676 and the second a list of *ReadFromClientError*.
677 """
678 # The BaseBone will not read any client_data in fromClient. Use rawValueBone if needed.
679 return self.getEmptyValue(), [
680 ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Will not read a BaseBone fromClient!")]
682 def fromClient(self, skel: 'SkeletonInstance', name: str, data: dict) -> None | list[ReadFromClientError]:
683 """
684 Reads a value from the client and stores it in the skeleton instance if it is valid for the bone.
686 This function reads a value from the client and processes it according to the bone's configuration.
687 If the value is valid for the bone, it stores the value in the skeleton instance and returns None.
688 Otherwise, the previous value remains unchanged, and a list of ReadFromClientError objects is returned.
690 :param skel: A SkeletonInstance object where the values should be loaded.
691 :param name: A string representing the bone's name.
692 :param data: A dictionary containing the raw data from the client.
693 :return: None if no errors occurred, otherwise a list of ReadFromClientError objects.
694 """
695 subFields = self.parseSubfieldsFromClient()
696 parsedData, fieldSubmitted = self.collectRawClientData(name, data, self.multiple, self.languages, subFields)
697 if not fieldSubmitted: 697 ↛ 698line 697 didn't jump to line 698 because the condition on line 697 was never true
698 return [ReadFromClientError(ReadFromClientErrorSeverity.NotSet, "Field not submitted")]
699 errors = []
700 isEmpty = True
701 filled_languages = set()
702 if self.languages and self.multiple:
703 res = {}
704 for language in self.languages:
705 res[language] = []
706 if language in parsedData:
707 for idx, singleValue in enumerate(parsedData[language]):
708 if self.isEmpty(singleValue): 708 ↛ 709line 708 didn't jump to line 709 because the condition on line 708 was never true
709 continue
710 isEmpty = False
711 filled_languages.add(language)
712 parsedVal, parseErrors = self.singleValueFromClient(singleValue, skel, name, data)
713 res[language].append(parsedVal)
714 if isinstance(self.multiple, MultipleConstraints) and self.multiple.sorted: 714 ↛ 715line 714 didn't jump to line 715 because the condition on line 714 was never true
715 if callable(self.multiple.sorted):
716 res[language] = sorted(
717 res[language],
718 key=self.multiple.sorted,
719 reverse=self.multiple.reversed,
720 )
721 else:
722 res[language] = sorted(res[language], reverse=self.multiple.reversed)
723 if parseErrors: 723 ↛ 724line 723 didn't jump to line 724 because the condition on line 723 was never true
724 for parseError in parseErrors:
725 parseError.fieldPath[:0] = [language, str(idx)]
726 errors.extend(parseErrors)
727 elif self.languages: # and not self.multiple is implicit - this would have been handled above
728 res = {}
729 for language in self.languages:
730 res[language] = None
731 if language in parsedData:
732 if self.isEmpty(parsedData[language]): 732 ↛ 733line 732 didn't jump to line 733 because the condition on line 732 was never true
733 res[language] = self.getEmptyValue()
734 continue
735 isEmpty = False
736 filled_languages.add(language)
737 parsedVal, parseErrors = self.singleValueFromClient(parsedData[language], skel, name, data)
738 res[language] = parsedVal
739 if parseErrors: 739 ↛ 740line 739 didn't jump to line 740 because the condition on line 739 was never true
740 for parseError in parseErrors:
741 parseError.fieldPath.insert(0, language)
742 errors.extend(parseErrors)
743 elif self.multiple: # and not self.languages is implicit - this would have been handled above
744 res = []
745 for idx, singleValue in enumerate(parsedData):
746 if self.isEmpty(singleValue): 746 ↛ 747line 746 didn't jump to line 747 because the condition on line 746 was never true
747 continue
748 isEmpty = False
749 parsedVal, parseErrors = self.singleValueFromClient(singleValue, skel, name, data)
750 res.append(parsedVal)
752 if parseErrors: 752 ↛ 753line 752 didn't jump to line 753 because the condition on line 752 was never true
753 for parseError in parseErrors:
754 parseError.fieldPath.insert(0, str(idx))
755 errors.extend(parseErrors)
756 if isinstance(self.multiple, MultipleConstraints) and self.multiple.sorted: 756 ↛ 757line 756 didn't jump to line 757 because the condition on line 756 was never true
757 if callable(self.multiple.sorted):
758 res = sorted(res, key=self.multiple.sorted, reverse=self.multiple.reversed)
759 else:
760 res = sorted(res, reverse=self.multiple.reversed)
761 else: # No Languages, not multiple
762 if self.isEmpty(parsedData):
763 res = self.getEmptyValue()
764 isEmpty = True
765 else:
766 isEmpty = False
767 res, parseErrors = self.singleValueFromClient(parsedData, skel, name, data)
768 if parseErrors:
769 errors.extend(parseErrors)
770 skel[name] = res
771 if self.languages and isinstance(self.required, (list, tuple)): 771 ↛ 772line 771 didn't jump to line 772 because the condition on line 771 was never true
772 missing = set(self.required).difference(filled_languages)
773 if missing:
774 return [ReadFromClientError(ReadFromClientErrorSeverity.Empty, "Field not set", fieldPath=[lang])
775 for lang in missing]
776 if isEmpty:
777 return [ReadFromClientError(ReadFromClientErrorSeverity.Empty, "Field not set")]
779 # Check multiple constraints on demand
780 if self.multiple and isinstance(self.multiple, MultipleConstraints): 780 ↛ 781line 780 didn't jump to line 781 because the condition on line 780 was never true
781 errors.extend(self._validate_multiple_contraints(self.multiple, skel, name))
783 return errors or None
785 def _get_single_destinct_hash(self, value) -> t.Any:
786 """
787 Returns a distinct hash value for a single entry of this bone.
788 The returned value must be hashable.
789 """
790 return value
792 def _get_destinct_hash(self, value) -> t.Any:
793 """
794 Returns a distinct hash value for this bone.
795 The returned value must be hashable.
796 """
797 if not isinstance(value, str) and isinstance(value, Iterable):
798 return tuple(self._get_single_destinct_hash(item) for item in value)
800 return value
802 def _validate_multiple_contraints(
803 self,
804 constraints: MultipleConstraints,
805 skel: 'SkeletonInstance',
806 name: str
807 ) -> list[ReadFromClientError]:
808 """
809 Validates the value of a bone against its multiple constraints and returns a list of ReadFromClientError
810 objects for each violation, such as too many items or duplicates.
812 :param constraints: The MultipleConstraints definition to apply.
813 :param skel: A SkeletonInstance object where the values should be validated.
814 :param name: A string representing the bone's name.
815 :return: A list of ReadFromClientError objects for each constraint violation.
816 """
817 res = []
818 value = self._get_destinct_hash(skel[name])
820 if constraints.min and len(value) < constraints.min:
821 res.append(ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Too few items"))
823 if constraints.max and len(value) > constraints.max:
824 res.append(ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Too many items"))
826 if not constraints.duplicates:
827 if len(set(value)) != len(value):
828 res.append(ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Duplicate items"))
830 return res
832 def singleValueSerialize(self, value, skel: 'SkeletonInstance', name: str, parentIndexed: bool):
833 """
834 Serializes a single value of the bone for storage in the database.
836 Derived bone classes should overwrite this method to implement their own logic for serializing single
837 values.
838 The serialized value should be suitable for storage in the database.
839 """
840 return value
842 def serialize(self, skel: 'SkeletonInstance', name: str, parentIndexed: bool) -> bool:
843 """
844 Serializes this bone into a format that can be written into the datastore.
846 :param skel: A SkeletonInstance object containing the values to be serialized.
847 :param name: A string representing the property name of the bone in its Skeleton (not the description).
848 :param parentIndexed: A boolean indicating whether the parent bone is indexed.
849 :return: A boolean indicating whether the serialization was successful.
850 """
851 self.serialize_compute(skel, name)
853 if name in skel.accessedValues:
854 newVal = skel.accessedValues[name]
855 if self.languages and self.multiple:
856 res = db.Entity()
857 res["_viurLanguageWrapper_"] = True
858 for language in self.languages:
859 res[language] = []
860 if not self.indexed:
861 res.exclude_from_indexes.add(language)
862 if language in newVal:
863 for singleValue in newVal[language]:
864 res[language].append(self.singleValueSerialize(singleValue, skel, name, parentIndexed))
865 elif self.languages:
866 res = db.Entity()
867 res["_viurLanguageWrapper_"] = True
868 for language in self.languages:
869 res[language] = None
870 if not self.indexed:
871 res.exclude_from_indexes.add(language)
872 if language in newVal:
873 res[language] = self.singleValueSerialize(newVal[language], skel, name, parentIndexed)
874 elif self.multiple:
875 res = []
877 assert newVal is None or isinstance(newVal, (list, tuple)), \
878 f"Cannot handle {repr(newVal)} here. Expecting list or tuple."
880 for singleValue in (newVal or ()):
881 res.append(self.singleValueSerialize(singleValue, skel, name, parentIndexed))
883 else: # No Languages, not Multiple
884 res = self.singleValueSerialize(newVal, skel, name, parentIndexed)
885 skel.dbEntity[name] = res
886 # Ensure our indexed flag is up2date
887 indexed = self.indexed and parentIndexed
888 if indexed and name in skel.dbEntity.exclude_from_indexes:
889 skel.dbEntity.exclude_from_indexes.discard(name)
890 elif not indexed and name not in skel.dbEntity.exclude_from_indexes:
891 skel.dbEntity.exclude_from_indexes.add(name)
892 return True
893 return False
895 def serialize_compute(self, skel: "SkeletonInstance", name: str) -> None:
896 """
897 This function checks whether a bone is computed and if this is the case, it attempts to serialize the
898 value with the appropriate calculation method
900 :param skel: The SkeletonInstance where the current bone is located
901 :param name: The name of the bone in the Skeleton
902 """
903 if not self.compute:
904 return None
905 match self.compute.interval.method:
906 case ComputeMethod.OnWrite:
907 skel.accessedValues[name] = self._compute(skel, name)
909 case ComputeMethod.Lifetime:
910 now = utils.utcNow()
912 last_update = \
913 skel.accessedValues.get(f"_viur_compute_{name}_") \
914 or skel.dbEntity.get(f"_viur_compute_{name}_")
916 if not last_update or last_update + self.compute.interval.lifetime < now:
917 skel.accessedValues[name] = self._compute(skel, name)
918 skel.dbEntity[f"_viur_compute_{name}_"] = now
920 case ComputeMethod.Once:
921 if name not in skel.dbEntity:
922 skel.accessedValues[name] = self._compute(skel, name)
925 def singleValueUnserialize(self, val):
926 """
927 Unserializes a single value of the bone from the stored database value.
929 Derived bone classes should overwrite this method to implement their own logic for unserializing
930 single values. The unserialized value should be suitable for use in the application logic.
931 """
932 return val
934 def unserialize(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> bool:
935 """
936 Deserialize bone data from the datastore and populate the bone with the deserialized values.
938 This function is the inverse of the serialize function. It converts data from the datastore
939 into a format that can be used by the bones in the skeleton.
941 :param skel: A SkeletonInstance object containing the values to be deserialized.
942 :param name: The property name of the bone in its Skeleton (not the description).
943 :returns: True if deserialization is successful, False otherwise.
944 """
945 if name in skel.dbEntity:
946 loadVal = skel.dbEntity[name]
947 elif (
948 # fixme: Remove this piece of sh*t at least with VIUR4
949 # We're importing from an old ViUR2 instance - there may only be keys prefixed with our name
950 conf.viur2import_blobsource and any(n.startswith(name + ".") for n in skel.dbEntity)
951 # ... or computed
952 or self.compute
953 ):
954 loadVal = None
955 else:
956 skel.accessedValues[name] = self.getDefaultValue(skel)
957 return False
959 if self.unserialize_compute(skel, name):
960 return True
962 # unserialize value to given config
963 if self.languages and self.multiple:
964 res = {}
965 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal:
966 for language in self.languages:
967 res[language] = []
968 if language in loadVal:
969 tmpVal = loadVal[language]
970 if not isinstance(tmpVal, list):
971 tmpVal = [tmpVal]
972 for singleValue in tmpVal:
973 res[language].append(self.singleValueUnserialize(singleValue))
974 else: # We could not parse this, maybe it has been written before languages had been set?
975 for language in self.languages:
976 res[language] = []
977 mainLang = self.languages[0]
978 if loadVal is None:
979 pass
980 elif isinstance(loadVal, list):
981 for singleValue in loadVal:
982 res[mainLang].append(self.singleValueUnserialize(singleValue))
983 else: # Hopefully it's a value stored before languages and multiple has been set
984 res[mainLang].append(self.singleValueUnserialize(loadVal))
985 elif self.languages:
986 res = {}
987 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal:
988 for language in self.languages:
989 res[language] = None
990 if language in loadVal:
991 tmpVal = loadVal[language]
992 if isinstance(tmpVal, list) and tmpVal:
993 tmpVal = tmpVal[0]
994 res[language] = self.singleValueUnserialize(tmpVal)
995 else: # We could not parse this, maybe it has been written before languages had been set?
996 for language in self.languages:
997 res[language] = None
998 oldKey = f"{name}.{language}"
999 if oldKey in skel.dbEntity and skel.dbEntity[oldKey]:
1000 res[language] = self.singleValueUnserialize(skel.dbEntity[oldKey])
1001 loadVal = None # Don't try to import later again, this format takes precedence
1002 mainLang = self.languages[0]
1003 if loadVal is None:
1004 pass
1005 elif isinstance(loadVal, list) and loadVal:
1006 res[mainLang] = self.singleValueUnserialize(loadVal)
1007 else: # Hopefully it's a value stored before languages and multiple has been set
1008 res[mainLang] = self.singleValueUnserialize(loadVal)
1009 elif self.multiple:
1010 res = []
1011 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal:
1012 # Pick one language we'll use
1013 if conf.i18n.default_language in loadVal:
1014 loadVal = loadVal[conf.i18n.default_language]
1015 else:
1016 loadVal = [x for x in loadVal.values() if x is not True]
1017 if loadVal and not isinstance(loadVal, list):
1018 loadVal = [loadVal]
1019 if loadVal:
1020 for val in loadVal:
1021 res.append(self.singleValueUnserialize(val))
1022 else: # Not multiple, no languages
1023 res = None
1024 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal:
1025 # Pick one language we'll use
1026 if conf.i18n.default_language in loadVal:
1027 loadVal = loadVal[conf.i18n.default_language]
1028 else:
1029 loadVal = [x for x in loadVal.values() if x is not True]
1030 if loadVal and isinstance(loadVal, list):
1031 loadVal = loadVal[0]
1032 if loadVal is not None:
1033 res = self.singleValueUnserialize(loadVal)
1035 skel.accessedValues[name] = res
1036 return True
1038 def unserialize_compute(self, skel: "SkeletonInstance", name: str) -> bool:
1039 """
1040 This function checks whether a bone is computed and if this is the case, it attempts to deserialise the
1041 value with the appropriate calculation method
1043 :param skel : The SkeletonInstance where the current Bone is located
1044 :param name: The name of the Bone in the Skeleton
1045 :return: True if the Bone was unserialized, False otherwise
1046 """
1047 if not self.compute or self._prevent_compute:
1048 return False
1050 match self.compute.interval.method:
1051 # Computation is bound to a lifetime?
1052 case ComputeMethod.Lifetime:
1053 now = utils.utcNow()
1054 from viur.core.skeleton import RefSkel # noqa: E402 # import works only here because circular imports
1056 if issubclass(skel.skeletonCls, RefSkel): # we have a ref skel we must load the complete Entity
1057 db_obj = db.Get(skel["key"])
1058 last_update = db_obj.get(f"_viur_compute_{name}_")
1059 else:
1060 last_update = skel.dbEntity.get(f"_viur_compute_{name}_")
1061 skel.accessedValues[f"_viur_compute_{name}_"] = last_update or now
1063 if not last_update or last_update + self.compute.interval.lifetime <= now:
1064 # if so, recompute and refresh updated value
1065 skel.accessedValues[name] = value = self._compute(skel, name)
1066 def transact():
1067 db_obj = db.Get(skel["key"])
1068 db_obj[f"_viur_compute_{name}_"] = now
1069 db_obj[name] = value
1070 db.Put(db_obj)
1072 if db.IsInTransaction():
1073 transact()
1074 else:
1075 db.RunInTransaction(transact)
1077 return True
1079 # Compute on every deserialization
1080 case ComputeMethod.Always:
1081 skel.accessedValues[name] = self._compute(skel, name)
1082 return True
1084 return False
1086 def delete(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str):
1087 """
1088 Like postDeletedHandler, but runs inside the transaction
1089 """
1090 pass
1092 def buildDBFilter(self,
1093 name: str,
1094 skel: 'viur.core.skeleton.SkeletonInstance',
1095 dbFilter: db.Query,
1096 rawFilter: dict,
1097 prefix: t.Optional[str] = None) -> db.Query:
1098 """
1099 Parses the searchfilter a client specified in his Request into
1100 something understood by the datastore.
1101 This function must:
1103 * - Ignore all filters not targeting this bone
1104 * - Safely handle malformed data in rawFilter (this parameter is directly controlled by the client)
1106 :param name: The property-name this bone has in its Skeleton (not the description!)
1107 :param skel: The :class:`viur.core.db.Query` this bone is part of
1108 :param dbFilter: The current :class:`viur.core.db.Query` instance the filters should be applied to
1109 :param rawFilter: The dictionary of filters the client wants to have applied
1110 :returns: The modified :class:`viur.core.db.Query`
1111 """
1112 myKeys = [key for key in rawFilter.keys() if (key == name or key.startswith(name + "$"))]
1114 if len(myKeys) == 0:
1115 return dbFilter
1117 for key in myKeys:
1118 value = rawFilter[key]
1119 tmpdata = key.split("$")
1121 if len(tmpdata) > 1:
1122 if isinstance(value, list):
1123 continue
1124 if tmpdata[1] == "lt":
1125 dbFilter.filter((prefix or "") + tmpdata[0] + " <", value)
1126 elif tmpdata[1] == "le":
1127 dbFilter.filter((prefix or "") + tmpdata[0] + " <=", value)
1128 elif tmpdata[1] == "gt":
1129 dbFilter.filter((prefix or "") + tmpdata[0] + " >", value)
1130 elif tmpdata[1] == "ge":
1131 dbFilter.filter((prefix or "") + tmpdata[0] + " >=", value)
1132 elif tmpdata[1] == "lk":
1133 dbFilter.filter((prefix or "") + tmpdata[0] + " =", value)
1134 else:
1135 dbFilter.filter((prefix or "") + tmpdata[0] + " =", value)
1136 else:
1137 if isinstance(value, list):
1138 dbFilter.filter((prefix or "") + key + " IN", value)
1139 else:
1140 dbFilter.filter((prefix or "") + key + " =", value)
1142 return dbFilter
1144 def buildDBSort(
1145 self,
1146 name: str,
1147 skel: "SkeletonInstance",
1148 query: db.Query,
1149 params: dict,
1150 postfix: str = "",
1151 ) -> t.Optional[db.Query]:
1152 """
1153 Same as buildDBFilter, but this time its not about filtering
1154 the results, but by sorting them.
1155 Again: query is controlled by the client, so you *must* expect and safely handle
1156 malformed data!
1158 :param name: The property-name this bone has in its Skeleton (not the description!)
1159 :param skel: The :class:`viur.core.skeleton.Skeleton` instance this bone is part of
1160 :param dbFilter: The current :class:`viur.core.db.Query` instance the filters should
1161 be applied to
1162 :param query: The dictionary of filters the client wants to have applied
1163 :param postfix: Inherited classes may use this to add a postfix to the porperty name
1164 :returns: The modified :class:`viur.core.db.Query`,
1165 None if the query is unsatisfiable.
1166 """
1167 if query.queries and (orderby := params.get("orderby")) and utils.string.is_prefix(orderby, name):
1168 if self.languages:
1169 lang = None
1170 if orderby.startswith(f"{name}."):
1171 lng = orderby.replace(f"{name}.", "")
1172 if lng in self.languages:
1173 lang = lng
1175 if lang is None:
1176 lang = current.language.get()
1177 if not lang or lang not in self.languages:
1178 lang = self.languages[0]
1180 prop = f"{name}.{lang}"
1181 else:
1182 prop = name
1184 # In case this is a multiple query, check if all filters are valid
1185 if isinstance(query.queries, list):
1186 in_eq_filter = None
1188 for item in query.queries:
1189 new_in_eq_filter = [
1190 key for key in item.filters.keys()
1191 if key.rstrip().endswith(("<", ">", "!="))
1192 ]
1193 if in_eq_filter and new_in_eq_filter and in_eq_filter != new_in_eq_filter:
1194 raise NotImplementedError("Impossible ordering!")
1196 in_eq_filter = new_in_eq_filter
1198 else:
1199 in_eq_filter = [
1200 key for key in query.queries.filters.keys()
1201 if key.rstrip().endswith(("<", ">", "!="))
1202 ]
1204 if in_eq_filter:
1205 orderby_prop = in_eq_filter[0].split(" ", 1)[0]
1206 if orderby_prop != prop:
1207 logging.warning(
1208 f"The query was rewritten; Impossible ordering changed from {prop!r} into {orderby_prop!r}"
1209 )
1210 prop = orderby_prop
1212 query.order((prop + postfix, utils.parse.sortorder(params.get("orderdir"))))
1214 return query
1216 def _hashValueForUniquePropertyIndex(
1217 self,
1218 value: str | int | float | db.Key | list[str | int | float | db.Key],
1219 ) -> list[str]:
1220 """
1221 Generates a hash of the given value for creating unique property indexes.
1223 This method is called by the framework to create a consistent hash representation of a value
1224 for constructing unique property indexes. Derived bone classes should overwrite this method to
1225 implement their own logic for hashing values.
1227 :param value: The value(s) to be hashed.
1229 :return: A list containing a string representation of the hashed value. If the bone is multiple,
1230 the list may contain more than one hashed value.
1231 """
1233 def hashValue(value: str | int | float | db.Key) -> str:
1234 h = hashlib.sha256()
1235 h.update(str(value).encode("UTF-8"))
1236 res = h.hexdigest()
1237 if isinstance(value, int | float):
1238 return f"I-{res}"
1239 elif isinstance(value, str):
1240 return f"S-{res}"
1241 elif isinstance(value, db.Key):
1242 # We Hash the keys here by our self instead of relying on str() or to_legacy_urlsafe()
1243 # as these may change in the future, which would invalidate all existing locks
1244 def keyHash(key):
1245 if key is None:
1246 return "-"
1247 return f"{hashValue(key.kind)}-{hashValue(key.id_or_name)}-<{keyHash(key.parent)}>"
1249 return f"K-{keyHash(value)}"
1250 raise NotImplementedError(f"Type {type(value)} can't be safely used in an uniquePropertyIndex")
1252 if not value and not self.unique.lockEmpty:
1253 return [] # We are zero/empty string and these should not be locked
1254 if not self.multiple and not isinstance(value, list):
1255 return [hashValue(value)]
1256 # We have a multiple bone or multiple values here
1257 if not isinstance(value, list):
1258 value = [value]
1259 tmpList = [hashValue(x) for x in value]
1260 if self.unique.method == UniqueLockMethod.SameValue:
1261 # We should lock each entry individually; lock each value
1262 return tmpList
1263 elif self.unique.method == UniqueLockMethod.SameSet:
1264 # We should ignore the sort-order; so simply sort that List
1265 tmpList.sort()
1266 # Lock the value for that specific list
1267 return [hashValue(", ".join(tmpList))]
1269 def getUniquePropertyIndexValues(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> list[str]:
1270 """
1271 Returns a list of hashes for the current value(s) of a bone in the skeleton, used for storing in the
1272 unique property value index.
1274 :param skel: A SkeletonInstance object representing the current skeleton.
1275 :param name: The property-name of the bone in the skeleton for which the unique property index values
1276 are required (not the description!).
1278 :return: A list of strings representing the hashed values for the current bone value(s) in the skeleton.
1279 If the bone has no value, an empty list is returned.
1280 """
1281 val = skel[name]
1282 if val is None:
1283 return []
1284 return self._hashValueForUniquePropertyIndex(val)
1286 def getReferencedBlobs(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> set[str]:
1287 """
1288 Returns a set of blob keys referenced from this bone
1289 """
1290 return set()
1292 def performMagic(self, valuesCache: dict, name: str, isAdd: bool):
1293 """
1294 This function applies "magically" functionality which f.e. inserts the current Date
1295 or the current user.
1296 :param isAdd: Signals wherever this is an add or edit operation.
1297 """
1298 pass # We do nothing by default
1300 def postSavedHandler(self, skel: "SkeletonInstance", boneName: str, key: db.Key | None) -> None:
1301 """
1302 Can be overridden to perform further actions after the main entity has been written.
1304 :param boneName: Name of this bone
1305 :param skel: The skeleton this bone belongs to
1306 :param key: The (new?) Database Key we've written to. In case of a RelSkel the key is None.
1307 """
1308 pass
1310 def postDeletedHandler(self, skel: 'viur.core.skeleton.SkeletonInstance', boneName: str, key: str):
1311 """
1312 Can be overridden to perform further actions after the main entity has been deleted.
1314 :param skel: The skeleton this bone belongs to
1315 :param boneName: Name of this bone
1316 :param key: The old Database Key of the entity we've deleted
1317 """
1318 pass
1320 def clone_value(self, skel: "SkeletonInstance", src_skel: "SkeletonInstance", bone_name: str) -> None:
1321 """Clone / Set the value for this bone depending on :attr:`clone_behavior`"""
1322 match self.clone_behavior.strategy:
1323 case CloneStrategy.COPY_VALUE:
1324 try:
1325 skel.accessedValues[bone_name] = copy.deepcopy(src_skel.accessedValues[bone_name])
1326 except KeyError:
1327 pass # bone_name is not in accessedValues, cannot clone it
1328 try:
1329 skel.renderAccessedValues[bone_name] = copy.deepcopy(src_skel.renderAccessedValues[bone_name])
1330 except KeyError:
1331 pass # bone_name is not in renderAccessedValues, cannot clone it
1332 case CloneStrategy.SET_NULL:
1333 skel.accessedValues[bone_name] = None
1334 case CloneStrategy.SET_DEFAULT:
1335 skel.accessedValues[bone_name] = self.getDefaultValue(skel)
1336 case CloneStrategy.SET_EMPTY:
1337 skel.accessedValues[bone_name] = self.getEmptyValue()
1338 case CloneStrategy.CUSTOM:
1339 skel.accessedValues[bone_name] = self.clone_behavior.custom_func(skel, src_skel, bone_name)
1340 case other:
1341 raise NotImplementedError(other)
1343 def refresh(self, skel: 'viur.core.skeleton.SkeletonInstance', boneName: str) -> None:
1344 """
1345 Refresh all values we might have cached from other entities.
1346 """
1347 pass
1349 def mergeFrom(self, valuesCache: dict, boneName: str, otherSkel: 'viur.core.skeleton.SkeletonInstance'):
1350 """
1351 Merges the values from another skeleton instance into the current instance, given that the bone types match.
1353 :param valuesCache: A dictionary containing the cached values for each bone in the skeleton.
1354 :param boneName: The property-name of the bone in the skeleton whose values are to be merged.
1355 :param otherSkel: A SkeletonInstance object representing the other skeleton from which the values \
1356 are to be merged.
1358 This function clones the values from the specified bone in the other skeleton instance into the current
1359 instance, provided that the bone types match. If the bone types do not match, a warning is logged, and the merge
1360 is ignored. If the bone in the other skeleton has no value, the function returns without performing any merge
1361 operation.
1362 """
1363 if getattr(otherSkel, boneName) is None:
1364 return
1365 if not isinstance(getattr(otherSkel, boneName), type(self)):
1366 logging.error(f"Ignoring values from conflicting boneType ({getattr(otherSkel, boneName)} is not a "
1367 f"instance of {type(self)})!")
1368 return
1369 valuesCache[boneName] = copy.deepcopy(otherSkel.valuesCache.get(boneName, None))
1371 def setBoneValue(self,
1372 skel: 'SkeletonInstance',
1373 boneName: str,
1374 value: t.Any,
1375 append: bool,
1376 language: None | str = None) -> bool:
1377 """
1378 Sets the value of a bone in a skeleton instance, with optional support for appending and language-specific
1379 values. Sanity checks are being performed.
1381 :param skel: The SkeletonInstance object representing the skeleton to which the bone belongs.
1382 :param boneName: The property-name of the bone in the skeleton whose value should be set or modified.
1383 :param value: The value to be assigned. Its type depends on the type of the bone.
1384 :param append: If True, the given value is appended to the bone's values instead of replacing it. \
1385 Only supported for bones with multiple=True.
1386 :param language: The language code for which the value should be set or appended, \
1387 if the bone supports languages.
1389 :return: A boolean indicating whether the operation was successful or not.
1391 This function sets or modifies the value of a bone in a skeleton instance, performing sanity checks to ensure
1392 the value is valid. If the value is invalid, no modification occurs. The function supports appending values to
1393 bones with multiple=True and setting or appending language-specific values for bones that support languages.
1394 """
1395 assert not (bool(self.languages) ^ bool(language)), f"language is required or not supported on {boneName!r}"
1396 assert not append or self.multiple, "Can't append - bone is not multiple"
1398 if not append and self.multiple:
1399 # set multiple values at once
1400 val = []
1401 errors = []
1402 for singleValue in value:
1403 singleValue, singleError = self.singleValueFromClient(singleValue, skel, boneName, {boneName: value})
1404 val.append(singleValue)
1405 if singleError: 1405 ↛ 1406line 1405 didn't jump to line 1406 because the condition on line 1405 was never true
1406 errors.extend(singleError)
1407 else:
1408 # set or append one value
1409 val, errors = self.singleValueFromClient(value, skel, boneName, {boneName: value})
1411 if errors:
1412 for e in errors: 1412 ↛ 1417line 1412 didn't jump to line 1417 because the loop on line 1412 didn't complete
1413 if e.severity in [ReadFromClientErrorSeverity.Invalid, ReadFromClientErrorSeverity.NotSet]: 1413 ↛ 1412line 1413 didn't jump to line 1412 because the condition on line 1413 was always true
1414 # If an invalid datatype (or a non-parseable structure) have been passed, abort the store
1415 logging.error(e)
1416 return False
1417 if not append and not language:
1418 skel[boneName] = val
1419 elif append and language: 1419 ↛ 1420line 1419 didn't jump to line 1420 because the condition on line 1419 was never true
1420 if not language in skel[boneName] or not isinstance(skel[boneName][language], list):
1421 skel[boneName][language] = []
1422 skel[boneName][language].append(val)
1423 elif append: 1423 ↛ 1428line 1423 didn't jump to line 1428 because the condition on line 1423 was always true
1424 if not isinstance(skel[boneName], list): 1424 ↛ 1425line 1424 didn't jump to line 1425 because the condition on line 1424 was never true
1425 skel[boneName] = []
1426 skel[boneName].append(val)
1427 else: # Just language
1428 skel[boneName][language] = val
1429 return True
1431 def getSearchTags(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> set[str]:
1432 """
1433 Returns a set of strings as search index for this bone.
1435 This function extracts a set of search tags from the given bone's value in the skeleton
1436 instance. The resulting set can be used for indexing or searching purposes.
1438 :param skel: The skeleton instance where the values should be loaded from. This is an instance
1439 of a class derived from `viur.core.skeleton.SkeletonInstance`.
1440 :param name: The name of the bone, which is a string representing the key for the bone in
1441 the skeleton. This should correspond to an existing bone in the skeleton instance.
1442 :return: A set of strings, extracted from the bone value. If the bone value doesn't have
1443 any searchable content, an empty set is returned.
1444 """
1445 return set()
1447 def iter_bone_value(
1448 self, skel: 'viur.core.skeleton.SkeletonInstance', name: str
1449 ) -> t.Iterator[tuple[t.Optional[int], t.Optional[str], t.Any]]:
1450 """
1451 Yield all values from the Skeleton related to this bone instance.
1453 This method handles multiple/languages cases, which could save a lot of if/elifs.
1454 It always yields a triplet: index, language, value.
1455 Where index is the index (int) of a value inside a multiple bone,
1456 language is the language (str) of a multi-language-bone,
1457 and value is the value inside this container.
1458 index or language is None if the bone is single or not multi-lang.
1460 This function can be used to conveniently iterate through all the values of a specific bone
1461 in a skeleton instance, taking into account multiple and multi-language bones.
1463 :param skel: The skeleton instance where the values should be loaded from. This is an instance
1464 of a class derived from `viur.core.skeleton.SkeletonInstance`.
1465 :param name: The name of the bone, which is a string representing the key for the bone in
1466 the skeleton. This should correspond to an existing bone in the skeleton instance.
1468 :return: A generator which yields triplets (index, language, value), where index is the index
1469 of a value inside a multiple bone, language is the language of a multi-language bone,
1470 and value is the value inside this container. index or language is None if the bone is
1471 single or not multi-lang.
1472 """
1473 value = skel[name]
1474 if not value:
1475 return None
1477 if self.languages and isinstance(value, dict):
1478 for idx, (lang, values) in enumerate(value.items()):
1479 if self.multiple:
1480 if not values:
1481 continue
1482 for val in values:
1483 yield idx, lang, val
1484 else:
1485 yield None, lang, values
1486 else:
1487 if self.multiple:
1488 for idx, val in enumerate(value):
1489 yield idx, None, val
1490 else:
1491 yield None, None, value
1493 def _compute(self, skel: 'viur.core.skeleton.SkeletonInstance', bone_name: str):
1494 """Performs the evaluation of a bone configured as compute"""
1496 compute_fn_parameters = inspect.signature(self.compute.fn).parameters
1497 compute_fn_args = {}
1498 if "skel" in compute_fn_parameters:
1499 from viur.core.skeleton import skeletonByKind, RefSkel # noqa: E402 # import works only here because circular imports
1501 if issubclass(skel.skeletonCls, RefSkel): # we have a ref skel we must load the complete skeleton
1502 cloned_skel = skeletonByKind(skel.kindName)()
1503 cloned_skel.read(skel["key"])
1504 else:
1505 cloned_skel = skel.clone()
1506 cloned_skel[bone_name] = None # remove value form accessedValues to avoid endless recursion
1507 compute_fn_args["skel"] = cloned_skel
1509 if "bone" in compute_fn_parameters:
1510 compute_fn_args["bone"] = getattr(skel, bone_name)
1512 if "bone_name" in compute_fn_parameters:
1513 compute_fn_args["bone_name"] = bone_name
1515 ret = self.compute.fn(**compute_fn_args)
1517 def unserialize_raw_value(raw_value: list[dict] | dict | None):
1518 if self.multiple:
1519 return [self.singleValueUnserialize(inner_value) for inner_value in raw_value]
1520 return self.singleValueUnserialize(raw_value)
1522 if self.compute.raw:
1523 if self.languages:
1524 return {
1525 lang: unserialize_raw_value(ret.get(lang, [] if self.multiple else None))
1526 for lang in self.languages
1527 }
1528 return unserialize_raw_value(ret)
1529 self._prevent_compute = True
1530 if errors := self.fromClient(skel, bone_name, {bone_name: ret}):
1531 raise ValueError(f"Computed value fromClient failed with {errors!r}")
1532 self._prevent_compute = False
1533 return skel[bone_name]
1535 def structure(self) -> dict:
1536 """
1537 Describes the bone and its settings as an JSON-serializable dict.
1538 This function has to be implemented for subsequent, specialized bone types.
1539 """
1540 ret = {
1541 "descr": self.descr,
1542 "type": self.type,
1543 "required": self.required and not self.readOnly,
1544 "params": self.params,
1545 "visible": self.visible,
1546 "readonly": self.readOnly,
1547 "unique": self.unique.method.value if self.unique else False,
1548 "languages": self.languages,
1549 "emptyvalue": self.getEmptyValue(),
1550 "indexed": self.indexed,
1551 "clone_behavior": {
1552 "strategy": self.clone_behavior.strategy,
1553 },
1554 }
1556 # Provide a defaultvalue, if it's not a function.
1557 if not callable(self.defaultValue) and self.defaultValue is not None:
1558 ret["defaultvalue"] = self.defaultValue
1560 # Provide a multiple setting
1561 if self.multiple and isinstance(self.multiple, MultipleConstraints):
1562 ret["multiple"] = {
1563 "duplicates": self.multiple.duplicates,
1564 "max": self.multiple.max,
1565 "min": self.multiple.min,
1566 }
1567 else:
1568 ret["multiple"] = self.multiple
1570 # Provide compute information
1571 if self.compute:
1572 ret["compute"] = {
1573 "method": self.compute.interval.method.name
1574 }
1576 if self.compute.interval.lifetime:
1577 ret["compute"]["lifetime"] = self.compute.interval.lifetime.total_seconds()
1579 return ret