Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/bones/base.py: 28%
739 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-02-07 19:28 +0000
« prev ^ index » next coverage.py v7.6.10, created at 2025-02-07 19:28 +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 hashlib
10import inspect
11import logging
12import typing as t
13from collections.abc import Iterable
14from dataclasses import dataclass, field
15from datetime import timedelta
16from enum import Enum
18from viur.core import db, utils, current, i18n
19from viur.core.config import conf
21if t.TYPE_CHECKING: 21 ↛ 22line 21 didn't jump to line 22 because the condition on line 21 was never true
22 from ..skeleton import Skeleton, SkeletonInstance
24__system_initialized = False
25"""
26Initializes the global variable __system_initialized
27"""
30def setSystemInitialized():
31 """
32 Sets the global __system_initialized variable to True, indicating that the system is
33 initialized and ready for use. This function should be called once all necessary setup
34 tasks have been completed. It also iterates over all skeleton classes and calls their
35 setSystemInitialized() method.
37 Global variables:
38 __system_initialized: A boolean flag indicating if the system is initialized.
39 """
40 global __system_initialized
41 from viur.core.skeleton import iterAllSkelClasses
43 for skelCls in iterAllSkelClasses():
44 skelCls.setSystemInitialized()
46 __system_initialized = True
48def getSystemInitialized():
49 """
50 Retrieves the current state of the system initialization by returning the value of the
51 global variable __system_initialized.
52 """
53 global __system_initialized
54 return __system_initialized
57class ReadFromClientErrorSeverity(Enum):
58 """
59 ReadFromClientErrorSeverity is an enumeration that represents the severity levels of errors
60 that can occur while reading data from the client.
61 """
62 NotSet = 0
63 """No error occurred"""
64 InvalidatesOther = 1
65 # TODO: what is this error about?
66 """The data is valid, for this bone, but in relation to other invalid"""
67 Empty = 2
68 """The data is empty, but the bone requires a value"""
69 Invalid = 3
70 """The data is invalid, but the bone requires a value"""
73@dataclass
74class ReadFromClientError:
75 """
76 The ReadFromClientError class represents an error that occurs while reading data from the client.
77 This class is used to store information about the error, including its severity, an error message,
78 the field path where the error occurred, and a list of invalidated fields.
79 """
80 severity: ReadFromClientErrorSeverity
81 """A ReadFromClientErrorSeverity enumeration value representing the severity of the error."""
82 errorMessage: str
83 """A string containing a human-readable error message describing the issue."""
84 fieldPath: list[str] = field(default_factory=list)
85 """A list of strings representing the path to the field where the error occurred."""
86 invalidatedFields: list[str] = None
87 """A list of strings containing the names of invalidated fields, if any."""
89 def __str__(self):
90 return f"{'.'.join(self.fieldPath)}: {self.errorMessage} [{self.severity.name}]"
93class ReadFromClientException(Exception):
94 """
95 ReadFromClientError as an Exception to raise.
96 """
98 def __init__(self, errors: ReadFromClientError | t.Iterable[ReadFromClientError]):
99 """
100 This is an exception holding ReadFromClientErrors.
102 :param errors: Either one or an iterable of errors.
103 """
104 super().__init__()
106 # Allow to specifiy a single ReadFromClientError
107 if isinstance(errors, ReadFromClientError):
108 errors = (ReadFromClientError, )
110 self.errors = tuple(error for error in errors if isinstance(error, ReadFromClientError))
112 # Disallow ReadFromClientException without any ReadFromClientErrors
113 if not self.errors:
114 raise ValueError("ReadFromClientException requires for at least one ReadFromClientError")
116 # Either show any errors with severity greater ReadFromClientErrorSeverity.NotSet to the Exception notes,
117 # or otherwise all errors (all have ReadFromClientErrorSeverity.NotSet then)
118 notes_errors = tuple(
119 error for error in self.errors if error.severity.value > ReadFromClientErrorSeverity.NotSet.value
120 )
122 self.add_note("\n".join(str(error) for error in notes_errors or self.errors))
125class UniqueLockMethod(Enum):
126 """
127 UniqueLockMethod is an enumeration that represents different locking methods for unique constraints
128 on bones. This is used to specify how the uniqueness of a value or a set of values should be
129 enforced.
130 """
131 SameValue = 1 # Lock this value for just one entry or each value individually if bone is multiple
132 """
133 Lock this value so that there is only one entry, or lock each value individually if the bone
134 is multiple.
135 """
136 SameSet = 2 # Same Set of entries (including duplicates), any order
137 """Lock the same set of entries (including duplicates) regardless of their order."""
138 SameList = 3 # Same Set of entries (including duplicates), in this specific order
139 """Lock the same set of entries (including duplicates) in a specific order."""
142@dataclass
143class UniqueValue: # Mark a bone as unique (it must have a different value for each entry)
144 """
145 The UniqueValue class represents a unique constraint on a bone, ensuring that it must have a
146 different value for each entry. This class is used to store information about the unique
147 constraint, such as the locking method, whether to lock empty values, and an error message to
148 display to the user if the requested value is already taken.
149 """
150 method: UniqueLockMethod # How to handle multiple values (for bones with multiple=True)
151 """
152 A UniqueLockMethod enumeration value specifying how to handle multiple values for bones with
153 multiple=True.
154 """
155 lockEmpty: bool # If False, empty values ("", 0) are not locked - needed if unique but not required
156 """
157 A boolean value indicating if empty values ("", 0) should be locked. If False, empty values are not
158 locked, which is needed if a field is unique but not required.
159 """
160 message: str # Error-Message displayed to the user if the requested value is already taken
161 """
162 A string containing an error message displayed to the user if the requested value is already
163 taken.
164 """
167@dataclass
168class MultipleConstraints:
169 """
170 The MultipleConstraints class is used to define constraints on multiple bones, such as the minimum
171 and maximum number of entries allowed and whether value duplicates are allowed.
172 """
173 min: int = 0
174 """An integer representing the lower bound of how many entries can be submitted (default: 0)."""
175 max: int = 0
176 """An integer representing the upper bound of how many entries can be submitted (default: 0 = unlimited)."""
177 duplicates: bool = False
178 """A boolean indicating if the same value can be used multiple times (default: False)."""
179 sorted: bool | t.Callable = False
180 """A boolean value or a method indicating if the value must be sorted (default: False)."""
181 reversed: bool = False
182 """
183 A boolean value indicating if sorted values shall be sorted in reversed order (default: False).
184 It is only applied when the `sorted`-flag is set accordingly.
185 """
187class ComputeMethod(Enum):
188 Always = 0 # Always compute on deserialization
189 Lifetime = 1 # Update only when given lifetime is outrun; value is only being stored when the skeleton is written
190 Once = 2 # Compute only once
191 OnWrite = 3 # Compute before written
194@dataclass
195class ComputeInterval:
196 method: ComputeMethod = ComputeMethod.Always
197 lifetime: timedelta = None # defines a timedelta until which the value stays valid (`ComputeMethod.Lifetime`)
200@dataclass
201class Compute:
202 fn: callable # the callable computing the value
203 interval: ComputeInterval = field(default_factory=ComputeInterval) # the value caching interval
204 raw: bool = True # defines whether the value returned by fn is used as is, or is passed through bone.fromClient
207class BaseBone(object):
208 """
209 The BaseBone class serves as the base class for all bone types in the ViUR framework.
210 It defines the core functionality and properties that all bones should implement.
212 :param descr: Textual, human-readable description of that bone. Will be translated.
213 :param defaultValue: If set, this bone will be preinitialized with this value
214 :param required: If True, the user must enter a valid value for this bone (the viur.core refuses
215 to save the skeleton otherwise). If a list/tuple of languages (strings) is provided, these
216 language must be entered.
217 :param multiple: If True, multiple values can be given. (ie. n:m relations instead of n:1)
218 :param searchable: If True, this bone will be included in the fulltext search. Can be used
219 without the need of also been indexed.
220 :param type_suffix: Allows to specify an optional suffix for the bone-type, for bone customization
221 :param vfunc: If given, a callable validating the user-supplied value for this bone.
222 This callable must return None if the value is valid, a String containing an meaningful
223 error-message for the user otherwise.
224 :param readOnly: If True, the user is unable to change the value of this bone. If a value for this
225 bone is given along the POST-Request during Add/Edit, this value will be ignored. Its still
226 possible for the developer to modify this value by assigning skel.bone.value.
227 :param visible: If False, the value of this bone should be hidden from the user. This does
228 *not* protect the value from being exposed in a template, nor from being transferred
229 to the client (ie to the admin or as hidden-value in html-form)
230 :param compute: If set, the bone's value will be computed in the given method.
232 .. NOTE::
233 The kwarg 'multiple' is not supported by all bones
234 """
235 type = "hidden"
236 isClonedInstance = False
238 skel_cls = None
239 """Skeleton class to which this bone instance belongs"""
241 name = None
242 """Name of this bone (attribute name in the skeletons containing this bone)"""
244 def __init__(
245 self,
246 *,
247 compute: Compute = None,
248 defaultValue: t.Any = None,
249 descr: t.Optional[str | i18n.translate] = None,
250 getEmptyValueFunc: callable = None,
251 indexed: bool = True,
252 isEmptyFunc: callable = None, # fixme: Rename this, see below.
253 languages: None | list[str] = None,
254 multiple: bool | MultipleConstraints = False,
255 params: dict = None,
256 readOnly: bool = None, # fixme: Rename into readonly (all lowercase!) soon.
257 required: bool | list[str] | tuple[str] = False,
258 searchable: bool = False,
259 type_suffix: str = "",
260 unique: None | UniqueValue = None,
261 vfunc: callable = None, # fixme: Rename this, see below.
262 visible: bool = True,
263 ):
264 """
265 Initializes a new Bone.
266 """
267 self.isClonedInstance = getSystemInitialized()
269 # Standard definitions
270 self.descr = descr
271 self.params = params or {}
272 self.multiple = multiple
273 self.required = required
274 self.readOnly = bool(readOnly)
275 self.searchable = searchable
276 self.visible = visible
277 self.indexed = indexed
279 if type_suffix: 279 ↛ 280line 279 didn't jump to line 280 because the condition on line 279 was never true
280 self.type += f".{type_suffix}"
282 if isinstance(category := self.params.get("category"), str): 282 ↛ 283line 282 didn't jump to line 283 because the condition on line 282 was never true
283 self.params["category"] = i18n.translate(category, hint=f"category of a <{type(self).__name__}>")
285 # Multi-language support
286 if not ( 286 ↛ 291line 286 didn't jump to line 291 because the condition on line 286 was never true
287 languages is None or
288 (isinstance(languages, list) and len(languages) > 0
289 and all([isinstance(x, str) for x in languages]))
290 ):
291 raise ValueError("languages must be None or a list of strings")
293 if languages and "__default__" in languages: 293 ↛ 294line 293 didn't jump to line 294 because the condition on line 293 was never true
294 raise ValueError("__default__ is not supported as a language")
296 if ( 296 ↛ 300line 296 didn't jump to line 300 because the condition on line 296 was never true
297 not isinstance(required, bool)
298 and (not isinstance(required, (tuple, list)) or any(not isinstance(value, str) for value in required))
299 ):
300 raise TypeError(f"required must be boolean or a tuple/list of strings. Got: {required!r}")
302 if isinstance(required, (tuple, list)) and not languages: 302 ↛ 303line 302 didn't jump to line 303 because the condition on line 302 was never true
303 raise ValueError("You set required to a list of languages, but defined no languages.")
305 if isinstance(required, (tuple, list)) and languages and (diff := set(required).difference(languages)): 305 ↛ 306line 305 didn't jump to line 306 because the condition on line 305 was never true
306 raise ValueError(f"The language(s) {', '.join(map(repr, diff))} can not be required, "
307 f"because they're not defined.")
309 if callable(defaultValue): 309 ↛ 311line 309 didn't jump to line 311 because the condition on line 309 was never true
310 # check if the signature of defaultValue can bind two (fictive) parameters.
311 try:
312 inspect.signature(defaultValue).bind("skel", "bone") # the strings are just for the test!
313 except TypeError:
314 raise ValueError(f"Callable {defaultValue=} requires for the parameters 'skel' and 'bone'.")
316 self.languages = languages
318 # Default value
319 # Convert a None default-value to the empty container that's expected if the bone is
320 # multiple or has languages
321 default = [] if defaultValue is None and self.multiple else defaultValue
322 if self.languages:
323 if callable(defaultValue): 323 ↛ 324line 323 didn't jump to line 324 because the condition on line 323 was never true
324 self.defaultValue = defaultValue
325 elif not isinstance(defaultValue, dict): 325 ↛ 327line 325 didn't jump to line 327 because the condition on line 325 was always true
326 self.defaultValue = {lang: default for lang in self.languages}
327 elif "__default__" in defaultValue:
328 self.defaultValue = {lang: defaultValue.get(lang, defaultValue["__default__"])
329 for lang in self.languages}
330 else:
331 self.defaultValue = defaultValue # default will have the same value at this point
332 else:
333 self.defaultValue = default
335 # Unique values
336 if unique: 336 ↛ 337line 336 didn't jump to line 337 because the condition on line 336 was never true
337 if not isinstance(unique, UniqueValue):
338 raise ValueError("Unique must be an instance of UniqueValue")
339 if not self.multiple and unique.method.value != 1:
340 raise ValueError("'SameValue' is the only valid method on non-multiple bones")
342 self.unique = unique
344 # Overwrite some validations and value functions by parameter instead of subclassing
345 # todo: This can be done better and more straightforward.
346 if vfunc:
347 self.isInvalid = vfunc # fixme: why is this called just vfunc, and not isInvalidValue/isInvalidValueFunc?
349 if isEmptyFunc: 349 ↛ 350line 349 didn't jump to line 350 because the condition on line 349 was never true
350 self.isEmpty = isEmptyFunc # fixme: why is this not called isEmptyValue/isEmptyValueFunc?
352 if getEmptyValueFunc:
353 self.getEmptyValue = getEmptyValueFunc
355 if compute: 355 ↛ 356line 355 didn't jump to line 356 because the condition on line 355 was never true
356 if not isinstance(compute, Compute):
357 raise TypeError("compute must be an instanceof of Compute")
358 if not isinstance(compute.fn, t.Callable):
359 raise ValueError("'compute.fn' must be callable")
360 # When readOnly is None, handle flag automatically
361 if readOnly is None:
362 self.readOnly = True
363 if not self.readOnly:
364 raise ValueError("'compute' can only be used with bones configured as `readOnly=True`")
366 if (
367 compute.interval.method == ComputeMethod.Lifetime
368 and not isinstance(compute.interval.lifetime, timedelta)
369 ):
370 raise ValueError(
371 f"'compute' is configured as ComputeMethod.Lifetime, but {compute.interval.lifetime=} was specified"
372 )
373 # If a RelationalBone is computed and raw is False, the unserialize function is called recursively
374 # and the value is recalculated all the time. This parameter is to prevent this.
375 self._prevent_compute = False
377 self.compute = compute
379 def __set_name__(self, owner: "Skeleton", name: str) -> None:
380 self.skel_cls = owner
381 self.name = name
383 def setSystemInitialized(self) -> None:
384 """
385 Can be overridden to initialize properties that depend on the Skeleton system
386 being initialized.
388 Here, in the BaseBone, we set descr to the bone_name if no descr argument
389 was given in __init__ and make sure that it is a :class:i18n.translate` object.
390 """
391 if self.descr is None:
392 # TODO: The super().__setattr__() call is kinda hackish,
393 # but unfortunately viur-core has no *during system initialisation* state
394 super().__setattr__("descr", self.name or "")
395 if self.descr and isinstance(self.descr, str):
396 super().__setattr__(
397 "descr",
398 i18n.translate(self.descr, hint=f"descr of a <{type(self).__name__}>{self.name}")
399 )
401 def isInvalid(self, value):
402 """
403 Checks if the current value of the bone in the given skeleton is invalid.
404 Returns None if the value would be valid for this bone, an error-message otherwise.
405 """
406 return False
408 def isEmpty(self, value: t.Any) -> bool:
409 """
410 Check if the given single value represents the "empty" value.
411 This usually is the empty string, 0 or False.
413 .. warning:: isEmpty takes precedence over isInvalid! The empty value is always
414 valid - unless the bone is required.
415 But even then the empty value will be reflected back to the client.
417 .. warning:: value might be the string/object received from the user (untrusted
418 input!) or the value returned by get
419 """
420 return not bool(value)
422 def getDefaultValue(self, skeletonInstance):
423 """
424 Retrieves the default value for the bone.
426 This method is called by the framework to obtain the default value of a bone when no value
427 is provided. Derived bone classes can overwrite this method to implement their own logic for
428 providing a default value.
430 :return: The default value of the bone, which can be of any data type.
431 """
432 if callable(self.defaultValue):
433 res = self.defaultValue(skeletonInstance, self)
434 if self.languages and self.multiple:
435 if not isinstance(res, dict):
436 if not isinstance(res, (list, set, tuple)):
437 return {lang: [res] for lang in self.languages}
438 else:
439 return {lang: res for lang in self.languages}
440 elif self.languages:
441 if not isinstance(res, dict):
442 return {lang: res for lang in self.languages}
443 elif self.multiple:
444 if not isinstance(res, (list, set, tuple)):
445 return [res]
446 return res
448 elif isinstance(self.defaultValue, list):
449 return self.defaultValue[:]
450 elif isinstance(self.defaultValue, dict):
451 return self.defaultValue.copy()
452 else:
453 return self.defaultValue
455 def getEmptyValue(self) -> t.Any:
456 """
457 Returns the value representing an empty field for this bone.
458 This might be the empty string for str/text Bones, Zero for numeric bones etc.
459 """
460 return None
462 def __setattr__(self, key, value):
463 """
464 Custom attribute setter for the BaseBone class.
466 This method is used to ensure that certain bone attributes, such as 'multiple', are only
467 set once during the bone's lifetime. Derived bone classes should not need to overwrite this
468 method unless they have additional attributes with similar constraints.
470 :param key: A string representing the attribute name.
471 :param value: The value to be assigned to the attribute.
473 :raises AttributeError: If a protected attribute is attempted to be modified after its initial
474 assignment.
475 """
476 if not self.isClonedInstance and getSystemInitialized() and key != "isClonedInstance" and not key.startswith( 476 ↛ 478line 476 didn't jump to line 478 because the condition on line 476 was never true
477 "_"):
478 raise AttributeError("You cannot modify this Skeleton. Grab a copy using .clone() first")
479 super().__setattr__(key, value)
481 def collectRawClientData(self, name, data, multiple, languages, collectSubfields):
482 """
483 Collects raw client data for the bone and returns it in a dictionary.
485 This method is called by the framework to gather raw data from the client, such as form data or data from a
486 request. Derived bone classes should overwrite this method to implement their own logic for collecting raw data.
488 :param name: A string representing the bone's name.
489 :param data: A dictionary containing the raw data from the client.
490 :param multiple: A boolean indicating whether the bone supports multiple values.
491 :param languages: An optional list of strings representing the supported languages (default: None).
492 :param collectSubfields: A boolean indicating whether to collect data for subfields (default: False).
494 :return: A dictionary containing the collected raw client data.
495 """
496 fieldSubmitted = False
497 if languages:
498 res = {}
499 for lang in languages:
500 if not collectSubfields: 500 ↛ 512line 500 didn't jump to line 512 because the condition on line 500 was always true
501 if f"{name}.{lang}" in data:
502 fieldSubmitted = True
503 res[lang] = data[f"{name}.{lang}"]
504 if multiple and not isinstance(res[lang], list): 504 ↛ 505line 504 didn't jump to line 505 because the condition on line 504 was never true
505 res[lang] = [res[lang]]
506 elif not multiple and isinstance(res[lang], list): 506 ↛ 507line 506 didn't jump to line 507 because the condition on line 506 was never true
507 if res[lang]:
508 res[lang] = res[lang][0]
509 else:
510 res[lang] = None
511 else:
512 for key in data.keys(): # Allow setting relations with using, multiple and languages back to none
513 if key == f"{name}.{lang}":
514 fieldSubmitted = True
515 prefix = f"{name}.{lang}."
516 if multiple:
517 tmpDict = {}
518 for key, value in data.items():
519 if not key.startswith(prefix):
520 continue
521 fieldSubmitted = True
522 partKey = key.replace(prefix, "")
523 firstKey, remainingKey = partKey.split(".", maxsplit=1)
524 try:
525 firstKey = int(firstKey)
526 except:
527 continue
528 if firstKey not in tmpDict:
529 tmpDict[firstKey] = {}
530 tmpDict[firstKey][remainingKey] = value
531 tmpList = list(tmpDict.items())
532 tmpList.sort(key=lambda x: x[0])
533 res[lang] = [x[1] for x in tmpList]
534 else:
535 tmpDict = {}
536 for key, value in data.items():
537 if not key.startswith(prefix):
538 continue
539 fieldSubmitted = True
540 partKey = key.replace(prefix, "")
541 tmpDict[partKey] = value
542 res[lang] = tmpDict
543 return res, fieldSubmitted
544 else: # No multi-lang
545 if not collectSubfields: 545 ↛ 559line 545 didn't jump to line 559 because the condition on line 545 was always true
546 if name not in data: # Empty! 546 ↛ 547line 546 didn't jump to line 547 because the condition on line 546 was never true
547 return None, False
548 val = data[name]
549 if multiple and not isinstance(val, list): 549 ↛ 550line 549 didn't jump to line 550 because the condition on line 549 was never true
550 return [val], True
551 elif not multiple and isinstance(val, list): 551 ↛ 552line 551 didn't jump to line 552 because the condition on line 551 was never true
552 if val:
553 return val[0], True
554 else:
555 return None, True # Empty!
556 else:
557 return val, True
558 else: # No multi-lang but collect subfields
559 for key in data.keys(): # Allow setting relations with using, multiple and languages back to none
560 if key == name:
561 fieldSubmitted = True
562 prefix = f"{name}."
563 if multiple:
564 tmpDict = {}
565 for key, value in data.items():
566 if not key.startswith(prefix):
567 continue
568 fieldSubmitted = True
569 partKey = key.replace(prefix, "")
570 try:
571 firstKey, remainingKey = partKey.split(".", maxsplit=1)
572 firstKey = int(firstKey)
573 except:
574 continue
575 if firstKey not in tmpDict:
576 tmpDict[firstKey] = {}
577 tmpDict[firstKey][remainingKey] = value
578 tmpList = list(tmpDict.items())
579 tmpList.sort(key=lambda x: x[0])
580 return [x[1] for x in tmpList], fieldSubmitted
581 else:
582 res = {}
583 for key, value in data.items():
584 if not key.startswith(prefix):
585 continue
586 fieldSubmitted = True
587 subKey = key.replace(prefix, "")
588 res[subKey] = value
589 return res, fieldSubmitted
591 def parseSubfieldsFromClient(self) -> bool:
592 """
593 Determines whether the function should parse subfields submitted by the client.
594 Set to True only when expecting a list of dictionaries to be transmitted.
595 """
596 return False
598 def singleValueFromClient(self, value: t.Any, skel: 'SkeletonInstance',
599 bone_name: str, client_data: dict
600 ) -> tuple[t.Any, list[ReadFromClientError] | None]:
601 """Load a single value from a client
603 :param value: The single value which should be loaded.
604 :param skel: The SkeletonInstance where the value should be loaded into.
605 :param bone_name: The bone name of this bone in the SkeletonInstance.
606 :param client_data: The data taken from the client,
607 a dictionary with usually bone names as key
608 :return: A tuple. If the value is valid, the first element is
609 the parsed value and the second is None.
610 If the value is invalid or not parseable, the first element is a empty value
611 and the second a list of *ReadFromClientError*.
612 """
613 # The BaseBone will not read any client_data in fromClient. Use rawValueBone if needed.
614 return self.getEmptyValue(), [
615 ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Will not read a BaseBone fromClient!")]
617 def fromClient(self, skel: 'SkeletonInstance', name: str, data: dict) -> None | list[ReadFromClientError]:
618 """
619 Reads a value from the client and stores it in the skeleton instance if it is valid for the bone.
621 This function reads a value from the client and processes it according to the bone's configuration.
622 If the value is valid for the bone, it stores the value in the skeleton instance and returns None.
623 Otherwise, the previous value remains unchanged, and a list of ReadFromClientError objects is returned.
625 :param skel: A SkeletonInstance object where the values should be loaded.
626 :param name: A string representing the bone's name.
627 :param data: A dictionary containing the raw data from the client.
628 :return: None if no errors occurred, otherwise a list of ReadFromClientError objects.
629 """
630 subFields = self.parseSubfieldsFromClient()
631 parsedData, fieldSubmitted = self.collectRawClientData(name, data, self.multiple, self.languages, subFields)
632 if not fieldSubmitted: 632 ↛ 633line 632 didn't jump to line 633 because the condition on line 632 was never true
633 return [ReadFromClientError(ReadFromClientErrorSeverity.NotSet, "Field not submitted")]
634 errors = []
635 isEmpty = True
636 filled_languages = set()
637 if self.languages and self.multiple:
638 res = {}
639 for language in self.languages:
640 res[language] = []
641 if language in parsedData:
642 for idx, singleValue in enumerate(parsedData[language]):
643 if self.isEmpty(singleValue): 643 ↛ 644line 643 didn't jump to line 644 because the condition on line 643 was never true
644 continue
645 isEmpty = False
646 filled_languages.add(language)
647 parsedVal, parseErrors = self.singleValueFromClient(singleValue, skel, name, data)
648 res[language].append(parsedVal)
649 if isinstance(self.multiple, MultipleConstraints) and self.multiple.sorted: 649 ↛ 650line 649 didn't jump to line 650 because the condition on line 649 was never true
650 if callable(self.multiple.sorted):
651 res[language] = sorted(
652 res[language],
653 key=self.multiple.sorted,
654 reverse=self.multiple.reversed,
655 )
656 else:
657 res[language] = sorted(res[language], reverse=self.multiple.reversed)
658 if parseErrors: 658 ↛ 659line 658 didn't jump to line 659 because the condition on line 658 was never true
659 for parseError in parseErrors:
660 parseError.fieldPath[:0] = [language, str(idx)]
661 errors.extend(parseErrors)
662 elif self.languages: # and not self.multiple is implicit - this would have been handled above
663 res = {}
664 for language in self.languages:
665 res[language] = None
666 if language in parsedData:
667 if self.isEmpty(parsedData[language]): 667 ↛ 668line 667 didn't jump to line 668 because the condition on line 667 was never true
668 res[language] = self.getEmptyValue()
669 continue
670 isEmpty = False
671 filled_languages.add(language)
672 parsedVal, parseErrors = self.singleValueFromClient(parsedData[language], skel, name, data)
673 res[language] = parsedVal
674 if parseErrors: 674 ↛ 675line 674 didn't jump to line 675 because the condition on line 674 was never true
675 for parseError in parseErrors:
676 parseError.fieldPath.insert(0, language)
677 errors.extend(parseErrors)
678 elif self.multiple: # and not self.languages is implicit - this would have been handled above
679 res = []
680 for idx, singleValue in enumerate(parsedData):
681 if self.isEmpty(singleValue): 681 ↛ 682line 681 didn't jump to line 682 because the condition on line 681 was never true
682 continue
683 isEmpty = False
684 parsedVal, parseErrors = self.singleValueFromClient(singleValue, skel, name, data)
685 res.append(parsedVal)
687 if parseErrors: 687 ↛ 688line 687 didn't jump to line 688 because the condition on line 687 was never true
688 for parseError in parseErrors:
689 parseError.fieldPath.insert(0, str(idx))
690 errors.extend(parseErrors)
691 if isinstance(self.multiple, MultipleConstraints) and self.multiple.sorted: 691 ↛ 692line 691 didn't jump to line 692 because the condition on line 691 was never true
692 if callable(self.multiple.sorted):
693 res = sorted(res, key=self.multiple.sorted, reverse=self.multiple.reversed)
694 else:
695 res = sorted(res, reverse=self.multiple.reversed)
696 else: # No Languages, not multiple
697 if self.isEmpty(parsedData):
698 res = self.getEmptyValue()
699 isEmpty = True
700 else:
701 isEmpty = False
702 res, parseErrors = self.singleValueFromClient(parsedData, skel, name, data)
703 if parseErrors:
704 errors.extend(parseErrors)
705 skel[name] = res
706 if self.languages and isinstance(self.required, (list, tuple)): 706 ↛ 707line 706 didn't jump to line 707 because the condition on line 706 was never true
707 missing = set(self.required).difference(filled_languages)
708 if missing:
709 return [ReadFromClientError(ReadFromClientErrorSeverity.Empty, "Field not set", fieldPath=[lang])
710 for lang in missing]
711 if isEmpty:
712 return [ReadFromClientError(ReadFromClientErrorSeverity.Empty, "Field not set")]
714 # Check multiple constraints on demand
715 if self.multiple and isinstance(self.multiple, MultipleConstraints): 715 ↛ 716line 715 didn't jump to line 716 because the condition on line 715 was never true
716 errors.extend(self._validate_multiple_contraints(self.multiple, skel, name))
718 return errors or None
720 def _get_single_destinct_hash(self, value) -> t.Any:
721 """
722 Returns a distinct hash value for a single entry of this bone.
723 The returned value must be hashable.
724 """
725 return value
727 def _get_destinct_hash(self, value) -> t.Any:
728 """
729 Returns a distinct hash value for this bone.
730 The returned value must be hashable.
731 """
732 if not isinstance(value, str) and isinstance(value, Iterable):
733 return tuple(self._get_single_destinct_hash(item) for item in value)
735 return value
737 def _validate_multiple_contraints(
738 self,
739 constraints: MultipleConstraints,
740 skel: 'SkeletonInstance',
741 name: str
742 ) -> list[ReadFromClientError]:
743 """
744 Validates the value of a bone against its multiple constraints and returns a list of ReadFromClientError
745 objects for each violation, such as too many items or duplicates.
747 :param constraints: The MultipleConstraints definition to apply.
748 :param skel: A SkeletonInstance object where the values should be validated.
749 :param name: A string representing the bone's name.
750 :return: A list of ReadFromClientError objects for each constraint violation.
751 """
752 res = []
753 value = self._get_destinct_hash(skel[name])
755 if constraints.min and len(value) < constraints.min:
756 res.append(ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Too few items"))
758 if constraints.max and len(value) > constraints.max:
759 res.append(ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Too many items"))
761 if not constraints.duplicates:
762 if len(set(value)) != len(value):
763 res.append(ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Duplicate items"))
765 return res
767 def singleValueSerialize(self, value, skel: 'SkeletonInstance', name: str, parentIndexed: bool):
768 """
769 Serializes a single value of the bone for storage in the database.
771 Derived bone classes should overwrite this method to implement their own logic for serializing single
772 values.
773 The serialized value should be suitable for storage in the database.
774 """
775 return value
777 def serialize(self, skel: 'SkeletonInstance', name: str, parentIndexed: bool) -> bool:
778 """
779 Serializes this bone into a format that can be written into the datastore.
781 :param skel: A SkeletonInstance object containing the values to be serialized.
782 :param name: A string representing the property name of the bone in its Skeleton (not the description).
783 :param parentIndexed: A boolean indicating whether the parent bone is indexed.
784 :return: A boolean indicating whether the serialization was successful.
785 """
786 self.serialize_compute(skel, name)
788 if name in skel.accessedValues:
789 newVal = skel.accessedValues[name]
790 if self.languages and self.multiple:
791 res = db.Entity()
792 res["_viurLanguageWrapper_"] = True
793 for language in self.languages:
794 res[language] = []
795 if not self.indexed:
796 res.exclude_from_indexes.add(language)
797 if language in newVal:
798 for singleValue in newVal[language]:
799 res[language].append(self.singleValueSerialize(singleValue, skel, name, parentIndexed))
800 elif self.languages:
801 res = db.Entity()
802 res["_viurLanguageWrapper_"] = True
803 for language in self.languages:
804 res[language] = None
805 if not self.indexed:
806 res.exclude_from_indexes.add(language)
807 if language in newVal:
808 res[language] = self.singleValueSerialize(newVal[language], skel, name, parentIndexed)
809 elif self.multiple:
810 res = []
812 assert newVal is None or isinstance(newVal, (list, tuple)), \
813 f"Cannot handle {repr(newVal)} here. Expecting list or tuple."
815 for singleValue in (newVal or ()):
816 res.append(self.singleValueSerialize(singleValue, skel, name, parentIndexed))
818 else: # No Languages, not Multiple
819 res = self.singleValueSerialize(newVal, skel, name, parentIndexed)
820 skel.dbEntity[name] = res
821 # Ensure our indexed flag is up2date
822 indexed = self.indexed and parentIndexed
823 if indexed and name in skel.dbEntity.exclude_from_indexes:
824 skel.dbEntity.exclude_from_indexes.discard(name)
825 elif not indexed and name not in skel.dbEntity.exclude_from_indexes:
826 skel.dbEntity.exclude_from_indexes.add(name)
827 return True
828 return False
830 def serialize_compute(self, skel: "SkeletonInstance", name: str) -> None:
831 """
832 This function checks whether a bone is computed and if this is the case, it attempts to serialize the
833 value with the appropriate calculation method
835 :param skel: The SkeletonInstance where the current bone is located
836 :param name: The name of the bone in the Skeleton
837 """
838 if not self.compute:
839 return None
840 match self.compute.interval.method:
841 case ComputeMethod.OnWrite:
842 skel.accessedValues[name] = self._compute(skel, name)
844 case ComputeMethod.Lifetime:
845 now = utils.utcNow()
847 last_update = \
848 skel.accessedValues.get(f"_viur_compute_{name}_") \
849 or skel.dbEntity.get(f"_viur_compute_{name}_")
851 if not last_update or last_update + self.compute.interval.lifetime < now:
852 skel.accessedValues[name] = self._compute(skel, name)
853 skel.dbEntity[f"_viur_compute_{name}_"] = now
855 case ComputeMethod.Once:
856 if name not in skel.dbEntity:
857 skel.accessedValues[name] = self._compute(skel, name)
860 def singleValueUnserialize(self, val):
861 """
862 Unserializes a single value of the bone from the stored database value.
864 Derived bone classes should overwrite this method to implement their own logic for unserializing
865 single values. The unserialized value should be suitable for use in the application logic.
866 """
867 return val
869 def unserialize(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> bool:
870 """
871 Deserialize bone data from the datastore and populate the bone with the deserialized values.
873 This function is the inverse of the serialize function. It converts data from the datastore
874 into a format that can be used by the bones in the skeleton.
876 :param skel: A SkeletonInstance object containing the values to be deserialized.
877 :param name: The property name of the bone in its Skeleton (not the description).
878 :returns: True if deserialization is successful, False otherwise.
879 """
880 if name in skel.dbEntity:
881 loadVal = skel.dbEntity[name]
882 elif (
883 # fixme: Remove this piece of sh*t at least with VIUR4
884 # We're importing from an old ViUR2 instance - there may only be keys prefixed with our name
885 conf.viur2import_blobsource and any(n.startswith(name + ".") for n in skel.dbEntity)
886 # ... or computed
887 or self.compute
888 ):
889 loadVal = None
890 else:
891 skel.accessedValues[name] = self.getDefaultValue(skel)
892 return False
894 if self.unserialize_compute(skel, name):
895 return True
897 # unserialize value to given config
898 if self.languages and self.multiple:
899 res = {}
900 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal:
901 for language in self.languages:
902 res[language] = []
903 if language in loadVal:
904 tmpVal = loadVal[language]
905 if not isinstance(tmpVal, list):
906 tmpVal = [tmpVal]
907 for singleValue in tmpVal:
908 res[language].append(self.singleValueUnserialize(singleValue))
909 else: # We could not parse this, maybe it has been written before languages had been set?
910 for language in self.languages:
911 res[language] = []
912 mainLang = self.languages[0]
913 if loadVal is None:
914 pass
915 elif isinstance(loadVal, list):
916 for singleValue in loadVal:
917 res[mainLang].append(self.singleValueUnserialize(singleValue))
918 else: # Hopefully it's a value stored before languages and multiple has been set
919 res[mainLang].append(self.singleValueUnserialize(loadVal))
920 elif self.languages:
921 res = {}
922 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal:
923 for language in self.languages:
924 res[language] = None
925 if language in loadVal:
926 tmpVal = loadVal[language]
927 if isinstance(tmpVal, list) and tmpVal:
928 tmpVal = tmpVal[0]
929 res[language] = self.singleValueUnserialize(tmpVal)
930 else: # We could not parse this, maybe it has been written before languages had been set?
931 for language in self.languages:
932 res[language] = None
933 oldKey = f"{name}.{language}"
934 if oldKey in skel.dbEntity and skel.dbEntity[oldKey]:
935 res[language] = self.singleValueUnserialize(skel.dbEntity[oldKey])
936 loadVal = None # Don't try to import later again, this format takes precedence
937 mainLang = self.languages[0]
938 if loadVal is None:
939 pass
940 elif isinstance(loadVal, list) and loadVal:
941 res[mainLang] = self.singleValueUnserialize(loadVal)
942 else: # Hopefully it's a value stored before languages and multiple has been set
943 res[mainLang] = self.singleValueUnserialize(loadVal)
944 elif self.multiple:
945 res = []
946 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal:
947 # Pick one language we'll use
948 if conf.i18n.default_language in loadVal:
949 loadVal = loadVal[conf.i18n.default_language]
950 else:
951 loadVal = [x for x in loadVal.values() if x is not True]
952 if loadVal and not isinstance(loadVal, list):
953 loadVal = [loadVal]
954 if loadVal:
955 for val in loadVal:
956 res.append(self.singleValueUnserialize(val))
957 else: # Not multiple, no languages
958 res = None
959 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal:
960 # Pick one language we'll use
961 if conf.i18n.default_language in loadVal:
962 loadVal = loadVal[conf.i18n.default_language]
963 else:
964 loadVal = [x for x in loadVal.values() if x is not True]
965 if loadVal and isinstance(loadVal, list):
966 loadVal = loadVal[0]
967 if loadVal is not None:
968 res = self.singleValueUnserialize(loadVal)
970 skel.accessedValues[name] = res
971 return True
973 def unserialize_compute(self, skel: "SkeletonInstance", name: str) -> bool:
974 """
975 This function checks whether a bone is computed and if this is the case, it attempts to deserialise the
976 value with the appropriate calculation method
978 :param skel : The SkeletonInstance where the current Bone is located
979 :param name: The name of the Bone in the Skeleton
980 :return: True if the Bone was unserialized, False otherwise
981 """
982 if not self.compute or self._prevent_compute:
983 return False
985 match self.compute.interval.method:
986 # Computation is bound to a lifetime?
987 case ComputeMethod.Lifetime:
988 now = utils.utcNow()
989 from viur.core.skeleton import RefSkel # noqa: E402 # import works only here because circular imports
991 if issubclass(skel.skeletonCls, RefSkel): # we have a ref skel we must load the complete Entity
992 db_obj = db.Get(skel["key"])
993 last_update = db_obj.get(f"_viur_compute_{name}_")
994 else:
995 last_update = skel.dbEntity.get(f"_viur_compute_{name}_")
996 skel.accessedValues[f"_viur_compute_{name}_"] = last_update or now
998 if not last_update or last_update + self.compute.interval.lifetime <= now:
999 # if so, recompute and refresh updated value
1000 skel.accessedValues[name] = value = self._compute(skel, name)
1001 def transact():
1002 db_obj = db.Get(skel["key"])
1003 db_obj[f"_viur_compute_{name}_"] = now
1004 db_obj[name] = value
1005 db.Put(db_obj)
1007 if db.IsInTransaction():
1008 transact()
1009 else:
1010 db.RunInTransaction(transact)
1012 return True
1014 # Compute on every deserialization
1015 case ComputeMethod.Always:
1016 skel.accessedValues[name] = self._compute(skel, name)
1017 return True
1019 return False
1021 def delete(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str):
1022 """
1023 Like postDeletedHandler, but runs inside the transaction
1024 """
1025 pass
1027 def buildDBFilter(self,
1028 name: str,
1029 skel: 'viur.core.skeleton.SkeletonInstance',
1030 dbFilter: db.Query,
1031 rawFilter: dict,
1032 prefix: t.Optional[str] = None) -> db.Query:
1033 """
1034 Parses the searchfilter a client specified in his Request into
1035 something understood by the datastore.
1036 This function must:
1038 * - Ignore all filters not targeting this bone
1039 * - Safely handle malformed data in rawFilter (this parameter is directly controlled by the client)
1041 :param name: The property-name this bone has in its Skeleton (not the description!)
1042 :param skel: The :class:`viur.core.db.Query` this bone is part of
1043 :param dbFilter: The current :class:`viur.core.db.Query` instance the filters should be applied to
1044 :param rawFilter: The dictionary of filters the client wants to have applied
1045 :returns: The modified :class:`viur.core.db.Query`
1046 """
1047 myKeys = [key for key in rawFilter.keys() if (key == name or key.startswith(name + "$"))]
1049 if len(myKeys) == 0:
1050 return dbFilter
1052 for key in myKeys:
1053 value = rawFilter[key]
1054 tmpdata = key.split("$")
1056 if len(tmpdata) > 1:
1057 if isinstance(value, list):
1058 continue
1059 if tmpdata[1] == "lt":
1060 dbFilter.filter((prefix or "") + tmpdata[0] + " <", value)
1061 elif tmpdata[1] == "le":
1062 dbFilter.filter((prefix or "") + tmpdata[0] + " <=", value)
1063 elif tmpdata[1] == "gt":
1064 dbFilter.filter((prefix or "") + tmpdata[0] + " >", value)
1065 elif tmpdata[1] == "ge":
1066 dbFilter.filter((prefix or "") + tmpdata[0] + " >=", value)
1067 elif tmpdata[1] == "lk":
1068 dbFilter.filter((prefix or "") + tmpdata[0] + " =", value)
1069 else:
1070 dbFilter.filter((prefix or "") + tmpdata[0] + " =", value)
1071 else:
1072 if isinstance(value, list):
1073 dbFilter.filter((prefix or "") + key + " IN", value)
1074 else:
1075 dbFilter.filter((prefix or "") + key + " =", value)
1077 return dbFilter
1079 def buildDBSort(
1080 self,
1081 name: str,
1082 skel: "SkeletonInstance",
1083 query: db.Query,
1084 params: dict,
1085 postfix: str = "",
1086 ) -> t.Optional[db.Query]:
1087 """
1088 Same as buildDBFilter, but this time its not about filtering
1089 the results, but by sorting them.
1090 Again: query is controlled by the client, so you *must* expect and safely handle
1091 malformed data!
1093 :param name: The property-name this bone has in its Skeleton (not the description!)
1094 :param skel: The :class:`viur.core.skeleton.Skeleton` instance this bone is part of
1095 :param dbFilter: The current :class:`viur.core.db.Query` instance the filters should
1096 be applied to
1097 :param query: The dictionary of filters the client wants to have applied
1098 :param postfix: Inherited classes may use this to add a postfix to the porperty name
1099 :returns: The modified :class:`viur.core.db.Query`,
1100 None if the query is unsatisfiable.
1101 """
1102 if query.queries and (orderby := params.get("orderby")) and utils.string.is_prefix(orderby, name):
1103 if self.languages:
1104 lang = None
1105 if orderby.startswith(f"{name}."):
1106 lng = orderby.replace(f"{name}.", "")
1107 if lng in self.languages:
1108 lang = lng
1110 if lang is None:
1111 lang = current.language.get()
1112 if not lang or lang not in self.languages:
1113 lang = self.languages[0]
1115 prop = f"{name}.{lang}"
1116 else:
1117 prop = name
1119 # In case this is a multiple query, check if all filters are valid
1120 if isinstance(query.queries, list):
1121 in_eq_filter = None
1123 for item in query.queries:
1124 new_in_eq_filter = [
1125 key for key in item.filters.keys()
1126 if key.rstrip().endswith(("<", ">", "!="))
1127 ]
1128 if in_eq_filter and new_in_eq_filter and in_eq_filter != new_in_eq_filter:
1129 raise NotImplementedError("Impossible ordering!")
1131 in_eq_filter = new_in_eq_filter
1133 else:
1134 in_eq_filter = [
1135 key for key in query.queries.filters.keys()
1136 if key.rstrip().endswith(("<", ">", "!="))
1137 ]
1139 if in_eq_filter:
1140 orderby_prop = in_eq_filter[0].split(" ", 1)[0]
1141 if orderby_prop != prop:
1142 logging.warning(
1143 f"The query was rewritten; Impossible ordering changed from {prop!r} into {orderby_prop!r}"
1144 )
1145 prop = orderby_prop
1147 query.order((prop + postfix, utils.parse.sortorder(params.get("orderdir"))))
1149 return query
1151 def _hashValueForUniquePropertyIndex(
1152 self,
1153 value: str | int | float | db.Key | list[str | int | float | db.Key],
1154 ) -> list[str]:
1155 """
1156 Generates a hash of the given value for creating unique property indexes.
1158 This method is called by the framework to create a consistent hash representation of a value
1159 for constructing unique property indexes. Derived bone classes should overwrite this method to
1160 implement their own logic for hashing values.
1162 :param value: The value(s) to be hashed.
1164 :return: A list containing a string representation of the hashed value. If the bone is multiple,
1165 the list may contain more than one hashed value.
1166 """
1168 def hashValue(value: str | int | float | db.Key) -> str:
1169 h = hashlib.sha256()
1170 h.update(str(value).encode("UTF-8"))
1171 res = h.hexdigest()
1172 if isinstance(value, int | float):
1173 return f"I-{res}"
1174 elif isinstance(value, str):
1175 return f"S-{res}"
1176 elif isinstance(value, db.Key):
1177 # We Hash the keys here by our self instead of relying on str() or to_legacy_urlsafe()
1178 # as these may change in the future, which would invalidate all existing locks
1179 def keyHash(key):
1180 if key is None:
1181 return "-"
1182 return f"{hashValue(key.kind)}-{hashValue(key.id_or_name)}-<{keyHash(key.parent)}>"
1184 return f"K-{keyHash(value)}"
1185 raise NotImplementedError(f"Type {type(value)} can't be safely used in an uniquePropertyIndex")
1187 if not value and not self.unique.lockEmpty:
1188 return [] # We are zero/empty string and these should not be locked
1189 if not self.multiple and not isinstance(value, list):
1190 return [hashValue(value)]
1191 # We have a multiple bone or multiple values here
1192 if not isinstance(value, list):
1193 value = [value]
1194 tmpList = [hashValue(x) for x in value]
1195 if self.unique.method == UniqueLockMethod.SameValue:
1196 # We should lock each entry individually; lock each value
1197 return tmpList
1198 elif self.unique.method == UniqueLockMethod.SameSet:
1199 # We should ignore the sort-order; so simply sort that List
1200 tmpList.sort()
1201 # Lock the value for that specific list
1202 return [hashValue(", ".join(tmpList))]
1204 def getUniquePropertyIndexValues(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> list[str]:
1205 """
1206 Returns a list of hashes for the current value(s) of a bone in the skeleton, used for storing in the
1207 unique property value index.
1209 :param skel: A SkeletonInstance object representing the current skeleton.
1210 :param name: The property-name of the bone in the skeleton for which the unique property index values
1211 are required (not the description!).
1213 :return: A list of strings representing the hashed values for the current bone value(s) in the skeleton.
1214 If the bone has no value, an empty list is returned.
1215 """
1216 val = skel[name]
1217 if val is None:
1218 return []
1219 return self._hashValueForUniquePropertyIndex(val)
1221 def getReferencedBlobs(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> set[str]:
1222 """
1223 Returns a set of blob keys referenced from this bone
1224 """
1225 return set()
1227 def performMagic(self, valuesCache: dict, name: str, isAdd: bool):
1228 """
1229 This function applies "magically" functionality which f.e. inserts the current Date
1230 or the current user.
1231 :param isAdd: Signals wherever this is an add or edit operation.
1232 """
1233 pass # We do nothing by default
1235 def postSavedHandler(self, skel: "SkeletonInstance", boneName: str, key: db.Key | None) -> None:
1236 """
1237 Can be overridden to perform further actions after the main entity has been written.
1239 :param boneName: Name of this bone
1240 :param skel: The skeleton this bone belongs to
1241 :param key: The (new?) Database Key we've written to. In case of a RelSkel the key is None.
1242 """
1243 pass
1245 def postDeletedHandler(self, skel: 'viur.core.skeleton.SkeletonInstance', boneName: str, key: str):
1246 """
1247 Can be overridden to perform further actions after the main entity has been deleted.
1249 :param skel: The skeleton this bone belongs to
1250 :param boneName: Name of this bone
1251 :param key: The old Database Key of the entity we've deleted
1252 """
1253 pass
1255 def refresh(self, skel: 'viur.core.skeleton.SkeletonInstance', boneName: str) -> None:
1256 """
1257 Refresh all values we might have cached from other entities.
1258 """
1259 pass
1261 def mergeFrom(self, valuesCache: dict, boneName: str, otherSkel: 'viur.core.skeleton.SkeletonInstance'):
1262 """
1263 Merges the values from another skeleton instance into the current instance, given that the bone types match.
1265 :param valuesCache: A dictionary containing the cached values for each bone in the skeleton.
1266 :param boneName: The property-name of the bone in the skeleton whose values are to be merged.
1267 :param otherSkel: A SkeletonInstance object representing the other skeleton from which the values \
1268 are to be merged.
1270 This function clones the values from the specified bone in the other skeleton instance into the current
1271 instance, provided that the bone types match. If the bone types do not match, a warning is logged, and the merge
1272 is ignored. If the bone in the other skeleton has no value, the function returns without performing any merge
1273 operation.
1274 """
1275 if getattr(otherSkel, boneName) is None:
1276 return
1277 if not isinstance(getattr(otherSkel, boneName), type(self)):
1278 logging.error(f"Ignoring values from conflicting boneType ({getattr(otherSkel, boneName)} is not a "
1279 f"instance of {type(self)})!")
1280 return
1281 valuesCache[boneName] = copy.deepcopy(otherSkel.valuesCache.get(boneName, None))
1283 def setBoneValue(self,
1284 skel: 'SkeletonInstance',
1285 boneName: str,
1286 value: t.Any,
1287 append: bool,
1288 language: None | str = None) -> bool:
1289 """
1290 Sets the value of a bone in a skeleton instance, with optional support for appending and language-specific
1291 values. Sanity checks are being performed.
1293 :param skel: The SkeletonInstance object representing the skeleton to which the bone belongs.
1294 :param boneName: The property-name of the bone in the skeleton whose value should be set or modified.
1295 :param value: The value to be assigned. Its type depends on the type of the bone.
1296 :param append: If True, the given value is appended to the bone's values instead of replacing it. \
1297 Only supported for bones with multiple=True.
1298 :param language: The language code for which the value should be set or appended, \
1299 if the bone supports languages.
1301 :return: A boolean indicating whether the operation was successful or not.
1303 This function sets or modifies the value of a bone in a skeleton instance, performing sanity checks to ensure
1304 the value is valid. If the value is invalid, no modification occurs. The function supports appending values to
1305 bones with multiple=True and setting or appending language-specific values for bones that support languages.
1306 """
1307 assert not (bool(self.languages) ^ bool(language)), f"language is required or not supported on {boneName!r}"
1308 assert not append or self.multiple, "Can't append - bone is not multiple"
1310 if not append and self.multiple:
1311 # set multiple values at once
1312 val = []
1313 errors = []
1314 for singleValue in value:
1315 singleValue, singleError = self.singleValueFromClient(singleValue, skel, boneName, {boneName: value})
1316 val.append(singleValue)
1317 if singleError: 1317 ↛ 1318line 1317 didn't jump to line 1318 because the condition on line 1317 was never true
1318 errors.extend(singleError)
1319 else:
1320 # set or append one value
1321 val, errors = self.singleValueFromClient(value, skel, boneName, {boneName: value})
1323 if errors:
1324 for e in errors: 1324 ↛ 1329line 1324 didn't jump to line 1329 because the loop on line 1324 didn't complete
1325 if e.severity in [ReadFromClientErrorSeverity.Invalid, ReadFromClientErrorSeverity.NotSet]: 1325 ↛ 1324line 1325 didn't jump to line 1324 because the condition on line 1325 was always true
1326 # If an invalid datatype (or a non-parseable structure) have been passed, abort the store
1327 logging.error(e)
1328 return False
1329 if not append and not language:
1330 skel[boneName] = val
1331 elif append and language: 1331 ↛ 1332line 1331 didn't jump to line 1332 because the condition on line 1331 was never true
1332 if not language in skel[boneName] or not isinstance(skel[boneName][language], list):
1333 skel[boneName][language] = []
1334 skel[boneName][language].append(val)
1335 elif append: 1335 ↛ 1340line 1335 didn't jump to line 1340 because the condition on line 1335 was always true
1336 if not isinstance(skel[boneName], list): 1336 ↛ 1337line 1336 didn't jump to line 1337 because the condition on line 1336 was never true
1337 skel[boneName] = []
1338 skel[boneName].append(val)
1339 else: # Just language
1340 skel[boneName][language] = val
1341 return True
1343 def getSearchTags(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> set[str]:
1344 """
1345 Returns a set of strings as search index for this bone.
1347 This function extracts a set of search tags from the given bone's value in the skeleton
1348 instance. The resulting set can be used for indexing or searching purposes.
1350 :param skel: The skeleton instance where the values should be loaded from. This is an instance
1351 of a class derived from `viur.core.skeleton.SkeletonInstance`.
1352 :param name: The name of the bone, which is a string representing the key for the bone in
1353 the skeleton. This should correspond to an existing bone in the skeleton instance.
1354 :return: A set of strings, extracted from the bone value. If the bone value doesn't have
1355 any searchable content, an empty set is returned.
1356 """
1357 return set()
1359 def iter_bone_value(
1360 self, skel: 'viur.core.skeleton.SkeletonInstance', name: str
1361 ) -> t.Iterator[tuple[t.Optional[int], t.Optional[str], t.Any]]:
1362 """
1363 Yield all values from the Skeleton related to this bone instance.
1365 This method handles multiple/languages cases, which could save a lot of if/elifs.
1366 It always yields a triplet: index, language, value.
1367 Where index is the index (int) of a value inside a multiple bone,
1368 language is the language (str) of a multi-language-bone,
1369 and value is the value inside this container.
1370 index or language is None if the bone is single or not multi-lang.
1372 This function can be used to conveniently iterate through all the values of a specific bone
1373 in a skeleton instance, taking into account multiple and multi-language bones.
1375 :param skel: The skeleton instance where the values should be loaded from. This is an instance
1376 of a class derived from `viur.core.skeleton.SkeletonInstance`.
1377 :param name: The name of the bone, which is a string representing the key for the bone in
1378 the skeleton. This should correspond to an existing bone in the skeleton instance.
1380 :return: A generator which yields triplets (index, language, value), where index is the index
1381 of a value inside a multiple bone, language is the language of a multi-language bone,
1382 and value is the value inside this container. index or language is None if the bone is
1383 single or not multi-lang.
1384 """
1385 value = skel[name]
1386 if not value:
1387 return None
1389 if self.languages and isinstance(value, dict):
1390 for idx, (lang, values) in enumerate(value.items()):
1391 if self.multiple:
1392 if not values:
1393 continue
1394 for val in values:
1395 yield idx, lang, val
1396 else:
1397 yield None, lang, values
1398 else:
1399 if self.multiple:
1400 for idx, val in enumerate(value):
1401 yield idx, None, val
1402 else:
1403 yield None, None, value
1405 def _compute(self, skel: 'viur.core.skeleton.SkeletonInstance', bone_name: str):
1406 """Performs the evaluation of a bone configured as compute"""
1408 compute_fn_parameters = inspect.signature(self.compute.fn).parameters
1409 compute_fn_args = {}
1410 if "skel" in compute_fn_parameters:
1411 from viur.core.skeleton import skeletonByKind, RefSkel # noqa: E402 # import works only here because circular imports
1413 if issubclass(skel.skeletonCls, RefSkel): # we have a ref skel we must load the complete skeleton
1414 cloned_skel = skeletonByKind(skel.kindName)()
1415 cloned_skel.read(skel["key"])
1416 else:
1417 cloned_skel = skel.clone()
1418 cloned_skel[bone_name] = None # remove value form accessedValues to avoid endless recursion
1419 compute_fn_args["skel"] = cloned_skel
1421 if "bone" in compute_fn_parameters:
1422 compute_fn_args["bone"] = getattr(skel, bone_name)
1424 if "bone_name" in compute_fn_parameters:
1425 compute_fn_args["bone_name"] = bone_name
1427 ret = self.compute.fn(**compute_fn_args)
1429 def unserialize_raw_value(raw_value: list[dict] | dict | None):
1430 if self.multiple:
1431 return [self.singleValueUnserialize(inner_value) for inner_value in raw_value]
1432 return self.singleValueUnserialize(raw_value)
1434 if self.compute.raw:
1435 if self.languages:
1436 return {
1437 lang: unserialize_raw_value(ret.get(lang, [] if self.multiple else None))
1438 for lang in self.languages
1439 }
1440 return unserialize_raw_value(ret)
1441 self._prevent_compute = True
1442 if errors := self.fromClient(skel, bone_name, {bone_name: ret}):
1443 raise ValueError(f"Computed value fromClient failed with {errors!r}")
1444 self._prevent_compute = False
1445 return skel[bone_name]
1447 def structure(self) -> dict:
1448 """
1449 Describes the bone and its settings as an JSON-serializable dict.
1450 This function has to be implemented for subsequent, specialized bone types.
1451 """
1452 ret = {
1453 "descr": self.descr,
1454 "type": self.type,
1455 "required": self.required and not self.readOnly,
1456 "params": self.params,
1457 "visible": self.visible,
1458 "readonly": self.readOnly,
1459 "unique": self.unique.method.value if self.unique else False,
1460 "languages": self.languages,
1461 "emptyvalue": self.getEmptyValue(),
1462 "indexed": self.indexed,
1463 }
1465 # Provide a defaultvalue, if it's not a function.
1466 if not callable(self.defaultValue) and self.defaultValue is not None:
1467 ret["defaultvalue"] = self.defaultValue
1469 # Provide a multiple setting
1470 if self.multiple and isinstance(self.multiple, MultipleConstraints):
1471 ret["multiple"] = {
1472 "duplicates": self.multiple.duplicates,
1473 "max": self.multiple.max,
1474 "min": self.multiple.min,
1475 }
1476 else:
1477 ret["multiple"] = self.multiple
1479 # Provide compute information
1480 if self.compute:
1481 ret["compute"] = {
1482 "method": self.compute.interval.method.name
1483 }
1485 if self.compute.interval.lifetime:
1486 ret["compute"]["lifetime"] = self.compute.interval.lifetime.total_seconds()
1488 return ret