Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/bones/base.py: 29%
679 statements
« prev ^ index » next coverage.py v7.6.3, created at 2024-10-16 22:16 +0000
« prev ^ index » next coverage.py v7.6.3, created at 2024-10-16 22:16 +0000
1"""
2This module contains the base classes for the bones in ViUR. Bones are the fundamental building blocks of
3ViUR's data structures, representing the fields and their properties in the entities managed by the
4framework. The base classes defined in this module are the foundation upon which specific bone types are
5built, such as string, numeric, and date/time bones.
6"""
8import copy
9import hashlib
10import inspect
11import logging
12from dataclasses import dataclass, field
13from datetime import datetime, timedelta
14from collections.abc import Iterable
15from enum import Enum
16import typing as t
17from viur.core import db, utils, i18n
18from viur.core.config import conf
20if t.TYPE_CHECKING: 20 ↛ 21line 20 didn't jump to line 21 because the condition on line 20 was never true
21 from ..skeleton import Skeleton
23__system_initialized = False
24"""
25Initializes the global variable __system_initialized
26"""
29def setSystemInitialized():
30 """
31 Sets the global __system_initialized variable to True, indicating that the system is
32 initialized and ready for use. This function should be called once all necessary setup
33 tasks have been completed. It also iterates over all skeleton classes and calls their
34 setSystemInitialized() method.
36 Global variables:
37 __system_initialized: A boolean flag indicating if the system is initialized.
38 """
39 global __system_initialized
40 from viur.core.skeleton import iterAllSkelClasses
41 __system_initialized = True
42 for skelCls in iterAllSkelClasses():
43 skelCls.setSystemInitialized()
46def getSystemInitialized():
47 """
48 Retrieves the current state of the system initialization by returning the value of the
49 global variable __system_initialized.
50 """
51 global __system_initialized
52 return __system_initialized
55class ReadFromClientErrorSeverity(Enum):
56 """
57 ReadFromClientErrorSeverity is an enumeration that represents the severity levels of errors
58 that can occur while reading data from the client.
59 """
60 NotSet = 0
61 """No error occurred"""
62 InvalidatesOther = 1
63 # TODO: what is this error about?
64 """The data is valid, for this bone, but in relation to other invalid"""
65 Empty = 2
66 """The data is empty, but the bone requires a value"""
67 Invalid = 3
68 """The data is invalid, but the bone requires a value"""
71@dataclass
72class ReadFromClientError:
73 """
74 The ReadFromClientError class represents an error that occurs while reading data from the client.
75 This class is used to store information about the error, including its severity, an error message,
76 the field path where the error occurred, and a list of invalidated fields.
77 """
78 severity: ReadFromClientErrorSeverity
79 """A ReadFromClientErrorSeverity enumeration value representing the severity of the error."""
80 errorMessage: str
81 """A string containing a human-readable error message describing the issue."""
82 fieldPath: list[str] = field(default_factory=list)
83 """A list of strings representing the path to the field where the error occurred."""
84 invalidatedFields: list[str] = None
85 """A list of strings containing the names of invalidated fields, if any."""
88class UniqueLockMethod(Enum):
89 """
90 UniqueLockMethod is an enumeration that represents different locking methods for unique constraints
91 on bones. This is used to specify how the uniqueness of a value or a set of values should be
92 enforced.
93 """
94 SameValue = 1 # Lock this value for just one entry or each value individually if bone is multiple
95 """
96 Lock this value so that there is only one entry, or lock each value individually if the bone
97 is multiple.
98 """
99 SameSet = 2 # Same Set of entries (including duplicates), any order
100 """Lock the same set of entries (including duplicates) regardless of their order."""
101 SameList = 3 # Same Set of entries (including duplicates), in this specific order
102 """Lock the same set of entries (including duplicates) in a specific order."""
105@dataclass
106class UniqueValue: # Mark a bone as unique (it must have a different value for each entry)
107 """
108 The UniqueValue class represents a unique constraint on a bone, ensuring that it must have a
109 different value for each entry. This class is used to store information about the unique
110 constraint, such as the locking method, whether to lock empty values, and an error message to
111 display to the user if the requested value is already taken.
112 """
113 method: UniqueLockMethod # How to handle multiple values (for bones with multiple=True)
114 """
115 A UniqueLockMethod enumeration value specifying how to handle multiple values for bones with
116 multiple=True.
117 """
118 lockEmpty: bool # If False, empty values ("", 0) are not locked - needed if unique but not required
119 """
120 A boolean value indicating if empty values ("", 0) should be locked. If False, empty values are not
121 locked, which is needed if a field is unique but not required.
122 """
123 message: str # Error-Message displayed to the user if the requested value is already taken
124 """
125 A string containing an error message displayed to the user if the requested value is already
126 taken.
127 """
130@dataclass
131class MultipleConstraints:
132 """
133 The MultipleConstraints class is used to define constraints on multiple bones, such as the minimum
134 and maximum number of entries allowed and whether value duplicates are allowed.
135 """
136 min: int = 0
137 """An integer representing the lower bound of how many entries can be submitted (default: 0)."""
138 max: int = 0
139 """An integer representing the upper bound of how many entries can be submitted (default: 0 = unlimited)."""
140 duplicates: bool = False
141 """A boolean indicating if the same value can be used multiple times (default: False)."""
144class ComputeMethod(Enum):
145 Always = 0 # Always compute on deserialization
146 Lifetime = 1 # Update only when given lifetime is outrun; value is only being stored when the skeleton is written
147 Once = 2 # Compute only once
148 OnWrite = 3 # Compute before written
151@dataclass
152class ComputeInterval:
153 method: ComputeMethod = ComputeMethod.Always
154 lifetime: timedelta = None # defines a timedelta until which the value stays valid (`ComputeMethod.Lifetime`)
157@dataclass
158class Compute:
159 fn: callable # the callable computing the value
160 interval: ComputeInterval = field(default_factory=ComputeInterval) # the value caching interval
161 raw: bool = True # defines whether the value returned by fn is used as is, or is passed through bone.fromClient
164class BaseBone(object):
165 """
166 The BaseBone class serves as the base class for all bone types in the ViUR framework.
167 It defines the core functionality and properties that all bones should implement.
169 :param descr: Textual, human-readable description of that bone. Will be translated.
170 :param defaultValue: If set, this bone will be preinitialized with this value
171 :param required: If True, the user must enter a valid value for this bone (the viur.core refuses
172 to save the skeleton otherwise). If a list/tuple of languages (strings) is provided, these
173 language must be entered.
174 :param multiple: If True, multiple values can be given. (ie. n:m relations instead of n:1)
175 :param searchable: If True, this bone will be included in the fulltext search. Can be used
176 without the need of also been indexed.
177 :param type_suffix: Allows to specify an optional suffix for the bone-type, for bone customization
178 :param vfunc: If given, a callable validating the user-supplied value for this bone.
179 This callable must return None if the value is valid, a String containing an meaningful
180 error-message for the user otherwise.
181 :param readOnly: If True, the user is unable to change the value of this bone. If a value for this
182 bone is given along the POST-Request during Add/Edit, this value will be ignored. Its still
183 possible for the developer to modify this value by assigning skel.bone.value.
184 :param visible: If False, the value of this bone should be hidden from the user. This does
185 *not* protect the value from being exposed in a template, nor from being transferred
186 to the client (ie to the admin or as hidden-value in html-form)
187 :param compute: If set, the bone's value will be computed in the given method.
189 .. NOTE::
190 The kwarg 'multiple' is not supported by all bones
191 """
192 type = "hidden"
193 isClonedInstance = False
195 skel_cls = None
196 """Skeleton class to which this bone instance belongs"""
198 name = None
199 """Name of this bone (attribute name in the skeletons containing this bone)"""
201 def __init__(
202 self,
203 *,
204 compute: Compute = None,
205 defaultValue: t.Any = None,
206 descr: str | i18n.translate = "",
207 getEmptyValueFunc: callable = None,
208 indexed: bool = True,
209 isEmptyFunc: callable = None, # fixme: Rename this, see below.
210 languages: None | list[str] = None,
211 multiple: bool | MultipleConstraints = False,
212 params: dict = None,
213 readOnly: bool = None, # fixme: Rename into readonly (all lowercase!) soon.
214 required: bool | list[str] | tuple[str] = False,
215 searchable: bool = False,
216 type_suffix: str = "",
217 unique: None | UniqueValue = None,
218 vfunc: callable = None, # fixme: Rename this, see below.
219 visible: bool = True,
220 ):
221 """
222 Initializes a new Bone.
223 """
224 self.isClonedInstance = getSystemInitialized()
226 if isinstance(descr, str): 226 ↛ 230line 226 didn't jump to line 230 because the condition on line 226 was always true
227 descr = i18n.translate(descr, hint=f"descr of a <{type(self).__name__}>")
229 # Standard definitions
230 self.descr = descr
231 self.params = params or {}
232 self.multiple = multiple
233 self.required = required
234 self.readOnly = bool(readOnly)
235 self.searchable = searchable
236 self.visible = visible
237 self.indexed = indexed
239 if type_suffix: 239 ↛ 240line 239 didn't jump to line 240 because the condition on line 239 was never true
240 self.type += f".{type_suffix}"
242 if isinstance(category := self.params.get("category"), str): 242 ↛ 243line 242 didn't jump to line 243 because the condition on line 242 was never true
243 self.params["category"] = i18n.translate(category, hint=f"category of a <{type(self).__name__}>")
245 # Multi-language support
246 if not ( 246 ↛ 251line 246 didn't jump to line 251 because the condition on line 246 was never true
247 languages is None or
248 (isinstance(languages, list) and len(languages) > 0
249 and all([isinstance(x, str) for x in languages]))
250 ):
251 raise ValueError("languages must be None or a list of strings")
252 if ( 252 ↛ 256line 252 didn't jump to line 256
253 not isinstance(required, bool)
254 and (not isinstance(required, (tuple, list)) or any(not isinstance(value, str) for value in required))
255 ):
256 raise TypeError(f"required must be boolean or a tuple/list of strings. Got: {required!r}")
257 if isinstance(required, (tuple, list)) and not languages: 257 ↛ 258line 257 didn't jump to line 258 because the condition on line 257 was never true
258 raise ValueError("You set required to a list of languages, but defined no languages.")
259 if isinstance(required, (tuple, list)) and languages and (diff := set(required).difference(languages)): 259 ↛ 260line 259 didn't jump to line 260 because the condition on line 259 was never true
260 raise ValueError(f"The language(s) {', '.join(map(repr, diff))} can not be required, "
261 f"because they're not defined.")
263 self.languages = languages
265 # Default value
266 # Convert a None default-value to the empty container that's expected if the bone is
267 # multiple or has languages
268 if defaultValue is None and self.languages:
269 self.defaultValue = {}
270 elif defaultValue is None and self.multiple:
271 self.defaultValue = []
272 else:
273 self.defaultValue = defaultValue
275 # Unique values
276 if unique: 276 ↛ 277line 276 didn't jump to line 277 because the condition on line 276 was never true
277 if not isinstance(unique, UniqueValue):
278 raise ValueError("Unique must be an instance of UniqueValue")
279 if not self.multiple and unique.method.value != 1:
280 raise ValueError("'SameValue' is the only valid method on non-multiple bones")
282 self.unique = unique
284 # Overwrite some validations and value functions by parameter instead of subclassing
285 # todo: This can be done better and more straightforward.
286 if vfunc:
287 self.isInvalid = vfunc # fixme: why is this called just vfunc, and not isInvalidValue/isInvalidValueFunc?
289 if isEmptyFunc: 289 ↛ 290line 289 didn't jump to line 290 because the condition on line 289 was never true
290 self.isEmpty = isEmptyFunc # fixme: why is this not called isEmptyValue/isEmptyValueFunc?
292 if getEmptyValueFunc:
293 self.getEmptyValue = getEmptyValueFunc
295 if compute: 295 ↛ 296line 295 didn't jump to line 296 because the condition on line 295 was never true
296 if not isinstance(compute, Compute):
297 raise TypeError("compute must be an instanceof of Compute")
299 # When readOnly is None, handle flag automatically
300 if readOnly is None:
301 self.readOnly = True
302 if not self.readOnly:
303 raise ValueError("'compute' can only be used with bones configured as `readOnly=True`")
305 if (
306 compute.interval.method == ComputeMethod.Lifetime
307 and not isinstance(compute.interval.lifetime, timedelta)
308 ):
309 raise ValueError(
310 f"'compute' is configured as ComputeMethod.Lifetime, but {compute.interval.lifetime=} was specified"
311 )
312 # If a RelationalBone is computed and raw is False, the unserialize function is called recursively
313 # and the value is recalculated all the time. This parameter is to prevent this.
314 self._prevent_compute = False
316 self.compute = compute
318 def __set_name__(self, owner: "Skeleton", name: str) -> None:
319 self.skel_cls = owner
320 self.name = name
322 def setSystemInitialized(self):
323 """
324 Can be overridden to initialize properties that depend on the Skeleton system
325 being initialized
326 """
327 pass
329 def isInvalid(self, value):
330 """
331 Checks if the current value of the bone in the given skeleton is invalid.
332 Returns None if the value would be valid for this bone, an error-message otherwise.
333 """
334 return False
336 def isEmpty(self, value: t.Any) -> bool:
337 """
338 Check if the given single value represents the "empty" value.
339 This usually is the empty string, 0 or False.
341 .. warning:: isEmpty takes precedence over isInvalid! The empty value is always
342 valid - unless the bone is required.
343 But even then the empty value will be reflected back to the client.
345 .. warning:: value might be the string/object received from the user (untrusted
346 input!) or the value returned by get
347 """
348 return not bool(value)
350 def getDefaultValue(self, skeletonInstance):
351 """
352 Retrieves the default value for the bone.
354 This method is called by the framework to obtain the default value of a bone when no value
355 is provided. Derived bone classes can overwrite this method to implement their own logic for
356 providing a default value.
358 :return: The default value of the bone, which can be of any data type.
359 """
360 if callable(self.defaultValue):
361 return self.defaultValue(skeletonInstance, self)
362 elif isinstance(self.defaultValue, list):
363 return self.defaultValue[:]
364 elif isinstance(self.defaultValue, dict):
365 return self.defaultValue.copy()
366 else:
367 return self.defaultValue
369 def getEmptyValue(self) -> t.Any:
370 """
371 Returns the value representing an empty field for this bone.
372 This might be the empty string for str/text Bones, Zero for numeric bones etc.
373 """
374 return None
376 def __setattr__(self, key, value):
377 """
378 Custom attribute setter for the BaseBone class.
380 This method is used to ensure that certain bone attributes, such as 'multiple', are only
381 set once during the bone's lifetime. Derived bone classes should not need to overwrite this
382 method unless they have additional attributes with similar constraints.
384 :param key: A string representing the attribute name.
385 :param value: The value to be assigned to the attribute.
387 :raises AttributeError: If a protected attribute is attempted to be modified after its initial
388 assignment.
389 """
390 if not self.isClonedInstance and getSystemInitialized() and key != "isClonedInstance" and not key.startswith( 390 ↛ 392line 390 didn't jump to line 392 because the condition on line 390 was never true
391 "_"):
392 raise AttributeError("You cannot modify this Skeleton. Grab a copy using .clone() first")
393 super().__setattr__(key, value)
395 def collectRawClientData(self, name, data, multiple, languages, collectSubfields):
396 """
397 Collects raw client data for the bone and returns it in a dictionary.
399 This method is called by the framework to gather raw data from the client, such as form data or data from a
400 request. Derived bone classes should overwrite this method to implement their own logic for collecting raw data.
402 :param name: A string representing the bone's name.
403 :param data: A dictionary containing the raw data from the client.
404 :param multiple: A boolean indicating whether the bone supports multiple values.
405 :param languages: An optional list of strings representing the supported languages (default: None).
406 :param collectSubfields: A boolean indicating whether to collect data for subfields (default: False).
408 :return: A dictionary containing the collected raw client data.
409 """
410 fieldSubmitted = False
411 if languages:
412 res = {}
413 for lang in languages:
414 if not collectSubfields: 414 ↛ 426line 414 didn't jump to line 426 because the condition on line 414 was always true
415 if f"{name}.{lang}" in data:
416 fieldSubmitted = True
417 res[lang] = data[f"{name}.{lang}"]
418 if multiple and not isinstance(res[lang], list): 418 ↛ 419line 418 didn't jump to line 419 because the condition on line 418 was never true
419 res[lang] = [res[lang]]
420 elif not multiple and isinstance(res[lang], list): 420 ↛ 421line 420 didn't jump to line 421 because the condition on line 420 was never true
421 if res[lang]:
422 res[lang] = res[lang][0]
423 else:
424 res[lang] = None
425 else:
426 for key in data.keys(): # Allow setting relations with using, multiple and languages back to none
427 if key == f"{name}.{lang}":
428 fieldSubmitted = True
429 prefix = f"{name}.{lang}."
430 if multiple:
431 tmpDict = {}
432 for key, value in data.items():
433 if not key.startswith(prefix):
434 continue
435 fieldSubmitted = True
436 partKey = key.replace(prefix, "")
437 firstKey, remainingKey = partKey.split(".", maxsplit=1)
438 try:
439 firstKey = int(firstKey)
440 except:
441 continue
442 if firstKey not in tmpDict:
443 tmpDict[firstKey] = {}
444 tmpDict[firstKey][remainingKey] = value
445 tmpList = list(tmpDict.items())
446 tmpList.sort(key=lambda x: x[0])
447 res[lang] = [x[1] for x in tmpList]
448 else:
449 tmpDict = {}
450 for key, value in data.items():
451 if not key.startswith(prefix):
452 continue
453 fieldSubmitted = True
454 partKey = key.replace(prefix, "")
455 tmpDict[partKey] = value
456 res[lang] = tmpDict
457 return res, fieldSubmitted
458 else: # No multi-lang
459 if not collectSubfields: 459 ↛ 473line 459 didn't jump to line 473 because the condition on line 459 was always true
460 if name not in data: # Empty! 460 ↛ 461line 460 didn't jump to line 461 because the condition on line 460 was never true
461 return None, False
462 val = data[name]
463 if multiple and not isinstance(val, list): 463 ↛ 464line 463 didn't jump to line 464 because the condition on line 463 was never true
464 return [val], True
465 elif not multiple and isinstance(val, list): 465 ↛ 466line 465 didn't jump to line 466 because the condition on line 465 was never true
466 if val:
467 return val[0], True
468 else:
469 return None, True # Empty!
470 else:
471 return val, True
472 else: # No multi-lang but collect subfields
473 for key in data.keys(): # Allow setting relations with using, multiple and languages back to none
474 if key == name:
475 fieldSubmitted = True
476 prefix = f"{name}."
477 if multiple:
478 tmpDict = {}
479 for key, value in data.items():
480 if not key.startswith(prefix):
481 continue
482 fieldSubmitted = True
483 partKey = key.replace(prefix, "")
484 try:
485 firstKey, remainingKey = partKey.split(".", maxsplit=1)
486 firstKey = int(firstKey)
487 except:
488 continue
489 if firstKey not in tmpDict:
490 tmpDict[firstKey] = {}
491 tmpDict[firstKey][remainingKey] = value
492 tmpList = list(tmpDict.items())
493 tmpList.sort(key=lambda x: x[0])
494 return [x[1] for x in tmpList], fieldSubmitted
495 else:
496 res = {}
497 for key, value in data.items():
498 if not key.startswith(prefix):
499 continue
500 fieldSubmitted = True
501 subKey = key.replace(prefix, "")
502 res[subKey] = value
503 return res, fieldSubmitted
505 def parseSubfieldsFromClient(self) -> bool:
506 """
507 Determines whether the function should parse subfields submitted by the client.
508 Set to True only when expecting a list of dictionaries to be transmitted.
509 """
510 return False
512 def singleValueFromClient(self, value: t.Any, skel: 'SkeletonInstance',
513 bone_name: str, client_data: dict
514 ) -> tuple[t.Any, list[ReadFromClientError] | None]:
515 """Load a single value from a client
517 :param value: The single value which should be loaded.
518 :param skel: The SkeletonInstance where the value should be loaded into.
519 :param bone_name: The bone name of this bone in the SkeletonInstance.
520 :param client_data: The data taken from the client,
521 a dictionary with usually bone names as key
522 :return: A tuple. If the value is valid, the first element is
523 the parsed value and the second is None.
524 If the value is invalid or not parseable, the first element is a empty value
525 and the second a list of *ReadFromClientError*.
526 """
527 # The BaseBone will not read any client_data in fromClient. Use rawValueBone if needed.
528 return self.getEmptyValue(), [
529 ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Will not read a BaseBone fromClient!")]
531 def fromClient(self, skel: 'SkeletonInstance', name: str, data: dict) -> None | list[ReadFromClientError]:
532 """
533 Reads a value from the client and stores it in the skeleton instance if it is valid for the bone.
535 This function reads a value from the client and processes it according to the bone's configuration.
536 If the value is valid for the bone, it stores the value in the skeleton instance and returns None.
537 Otherwise, the previous value remains unchanged, and a list of ReadFromClientError objects is returned.
539 :param skel: A SkeletonInstance object where the values should be loaded.
540 :param name: A string representing the bone's name.
541 :param data: A dictionary containing the raw data from the client.
542 :return: None if no errors occurred, otherwise a list of ReadFromClientError objects.
543 """
544 subFields = self.parseSubfieldsFromClient()
545 parsedData, fieldSubmitted = self.collectRawClientData(name, data, self.multiple, self.languages, subFields)
546 if not fieldSubmitted: 546 ↛ 547line 546 didn't jump to line 547 because the condition on line 546 was never true
547 return [ReadFromClientError(ReadFromClientErrorSeverity.NotSet, "Field not submitted")]
548 errors = []
549 isEmpty = True
550 filled_languages = set()
551 if self.languages and self.multiple:
552 res = {}
553 for language in self.languages:
554 res[language] = []
555 if language in parsedData:
556 for idx, singleValue in enumerate(parsedData[language]):
557 if self.isEmpty(singleValue): 557 ↛ 558line 557 didn't jump to line 558 because the condition on line 557 was never true
558 continue
559 isEmpty = False
560 filled_languages.add(language)
561 parsedVal, parseErrors = self.singleValueFromClient(singleValue, skel, name, data)
562 res[language].append(parsedVal)
563 if parseErrors: 563 ↛ 564line 563 didn't jump to line 564 because the condition on line 563 was never true
564 for parseError in parseErrors:
565 parseError.fieldPath[:0] = [language, str(idx)]
566 errors.extend(parseErrors)
567 elif self.languages: # and not self.multiple is implicit - this would have been handled above
568 res = {}
569 for language in self.languages:
570 res[language] = None
571 if language in parsedData:
572 if self.isEmpty(parsedData[language]): 572 ↛ 573line 572 didn't jump to line 573 because the condition on line 572 was never true
573 res[language] = self.getEmptyValue()
574 continue
575 isEmpty = False
576 filled_languages.add(language)
577 parsedVal, parseErrors = self.singleValueFromClient(parsedData[language], skel, name, data)
578 res[language] = parsedVal
579 if parseErrors: 579 ↛ 580line 579 didn't jump to line 580 because the condition on line 579 was never true
580 for parseError in parseErrors:
581 parseError.fieldPath.insert(0, language)
582 errors.extend(parseErrors)
583 elif self.multiple: # and not self.languages is implicit - this would have been handled above
584 res = []
585 for idx, singleValue in enumerate(parsedData):
586 if self.isEmpty(singleValue): 586 ↛ 587line 586 didn't jump to line 587 because the condition on line 586 was never true
587 continue
588 isEmpty = False
589 parsedVal, parseErrors = self.singleValueFromClient(singleValue, skel, name, data)
590 res.append(parsedVal)
591 if parseErrors: 591 ↛ 592line 591 didn't jump to line 592 because the condition on line 591 was never true
592 for parseError in parseErrors:
593 parseError.fieldPath.insert(0, str(idx))
594 errors.extend(parseErrors)
595 else: # No Languages, not multiple
596 if self.isEmpty(parsedData):
597 res = self.getEmptyValue()
598 isEmpty = True
599 else:
600 isEmpty = False
601 res, parseErrors = self.singleValueFromClient(parsedData, skel, name, data)
602 if parseErrors:
603 errors.extend(parseErrors)
604 skel[name] = res
605 if self.languages and isinstance(self.required, (list, tuple)): 605 ↛ 606line 605 didn't jump to line 606 because the condition on line 605 was never true
606 missing = set(self.required).difference(filled_languages)
607 if missing:
608 return [ReadFromClientError(ReadFromClientErrorSeverity.Empty, "Field not set", fieldPath=[lang])
609 for lang in missing]
610 if isEmpty:
611 return [ReadFromClientError(ReadFromClientErrorSeverity.Empty, "Field not set")]
613 # Check multiple constraints on demand
614 if self.multiple and isinstance(self.multiple, MultipleConstraints): 614 ↛ 615line 614 didn't jump to line 615 because the condition on line 614 was never true
615 errors.extend(self._validate_multiple_contraints(self.multiple, skel, name))
617 return errors or None
619 def _get_single_destinct_hash(self, value) -> t.Any:
620 """
621 Returns a distinct hash value for a single entry of this bone.
622 The returned value must be hashable.
623 """
624 return value
626 def _get_destinct_hash(self, value) -> t.Any:
627 """
628 Returns a distinct hash value for this bone.
629 The returned value must be hashable.
630 """
631 if not isinstance(value, str) and isinstance(value, Iterable):
632 return tuple(self._get_single_destinct_hash(item) for item in value)
634 return value
636 def _validate_multiple_contraints(
637 self,
638 constraints: MultipleConstraints,
639 skel: 'SkeletonInstance',
640 name: str
641 ) -> list[ReadFromClientError]:
642 """
643 Validates the value of a bone against its multiple constraints and returns a list of ReadFromClientError
644 objects for each violation, such as too many items or duplicates.
646 :param constraints: The MultipleConstraints definition to apply.
647 :param skel: A SkeletonInstance object where the values should be validated.
648 :param name: A string representing the bone's name.
649 :return: A list of ReadFromClientError objects for each constraint violation.
650 """
651 res = []
652 value = self._get_destinct_hash(skel[name])
654 if constraints.min and len(value) < constraints.min:
655 res.append(ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Too few items"))
657 if constraints.max and len(value) > constraints.max:
658 res.append(ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Too many items"))
660 if not constraints.duplicates:
661 if len(set(value)) != len(value):
662 res.append(ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Duplicate items"))
664 return res
666 def singleValueSerialize(self, value, skel: 'SkeletonInstance', name: str, parentIndexed: bool):
667 """
668 Serializes a single value of the bone for storage in the database.
670 Derived bone classes should overwrite this method to implement their own logic for serializing single
671 values.
672 The serialized value should be suitable for storage in the database.
673 """
674 return value
676 def serialize(self, skel: 'SkeletonInstance', name: str, parentIndexed: bool) -> bool:
677 """
678 Serializes this bone into a format that can be written into the datastore.
680 :param skel: A SkeletonInstance object containing the values to be serialized.
681 :param name: A string representing the property name of the bone in its Skeleton (not the description).
682 :param parentIndexed: A boolean indicating whether the parent bone is indexed.
683 :return: A boolean indicating whether the serialization was successful.
684 """
685 # Handle compute on write
686 if self.compute:
687 match self.compute.interval.method:
688 case ComputeMethod.OnWrite:
689 skel.accessedValues[name] = self._compute(skel, name)
691 case ComputeMethod.Lifetime:
692 now = utils.utcNow()
694 last_update = \
695 skel.accessedValues.get(f"_viur_compute_{name}_") \
696 or skel.dbEntity.get(f"_viur_compute_{name}_")
698 if not last_update or last_update + self.compute.interval.lifetime < now:
699 skel.accessedValues[name] = self._compute(skel, name)
700 skel.dbEntity[f"_viur_compute_{name}_"] = now
702 case ComputeMethod.Once:
703 if name not in skel.dbEntity:
704 skel.accessedValues[name] = self._compute(skel, name)
706 # logging.debug(f"WRITE {name=} {skel.accessedValues=}")
707 # logging.debug(f"WRITE {name=} {skel.dbEntity=}")
709 if name in skel.accessedValues:
710 newVal = skel.accessedValues[name]
711 if self.languages and self.multiple:
712 res = db.Entity()
713 res["_viurLanguageWrapper_"] = True
714 for language in self.languages:
715 res[language] = []
716 if not self.indexed:
717 res.exclude_from_indexes.add(language)
718 if language in newVal:
719 for singleValue in newVal[language]:
720 res[language].append(self.singleValueSerialize(singleValue, skel, name, parentIndexed))
721 elif self.languages:
722 res = db.Entity()
723 res["_viurLanguageWrapper_"] = True
724 for language in self.languages:
725 res[language] = None
726 if not self.indexed:
727 res.exclude_from_indexes.add(language)
728 if language in newVal:
729 res[language] = self.singleValueSerialize(newVal[language], skel, name, parentIndexed)
730 elif self.multiple:
731 res = []
733 assert newVal is None or isinstance(newVal, (list, tuple)), \
734 f"Cannot handle {repr(newVal)} here. Expecting list or tuple."
736 for singleValue in (newVal or ()):
737 res.append(self.singleValueSerialize(singleValue, skel, name, parentIndexed))
739 else: # No Languages, not Multiple
740 res = self.singleValueSerialize(newVal, skel, name, parentIndexed)
741 skel.dbEntity[name] = res
742 # Ensure our indexed flag is up2date
743 indexed = self.indexed and parentIndexed
744 if indexed and name in skel.dbEntity.exclude_from_indexes:
745 skel.dbEntity.exclude_from_indexes.discard(name)
746 elif not indexed and name not in skel.dbEntity.exclude_from_indexes:
747 skel.dbEntity.exclude_from_indexes.add(name)
748 return True
749 return False
751 def singleValueUnserialize(self, val):
752 """
753 Unserializes a single value of the bone from the stored database value.
755 Derived bone classes should overwrite this method to implement their own logic for unserializing
756 single values. The unserialized value should be suitable for use in the application logic.
757 """
758 return val
760 def unserialize(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> bool:
761 """
762 Deserialize bone data from the datastore and populate the bone with the deserialized values.
764 This function is the inverse of the serialize function. It converts data from the datastore
765 into a format that can be used by the bones in the skeleton.
767 :param skel: A SkeletonInstance object containing the values to be deserialized.
768 :param name: The property name of the bone in its Skeleton (not the description).
769 :returns: True if deserialization is successful, False otherwise.
770 """
771 if name in skel.dbEntity:
772 loadVal = skel.dbEntity[name]
773 elif (
774 # fixme: Remove this piece of sh*t at least with VIUR4
775 # We're importing from an old ViUR2 instance - there may only be keys prefixed with our name
776 conf.viur2import_blobsource and any(n.startswith(name + ".") for n in skel.dbEntity)
777 # ... or computed
778 or self.compute
779 ):
780 loadVal = None
781 else:
782 skel.accessedValues[name] = self.getDefaultValue(skel)
783 return False
785 # Is this value computed?
786 # In this case, check for configured compute method and if recomputation is required.
787 # Otherwise, the value from the DB is used as is.
788 if self.compute and not self._prevent_compute:
789 match self.compute.interval.method:
790 # Computation is bound to a lifetime?
791 case ComputeMethod.Lifetime:
792 now = utils.utcNow()
794 # check if lifetime exceeded
795 last_update = skel.dbEntity.get(f"_viur_compute_{name}_")
796 skel.accessedValues[f"_viur_compute_{name}_"] = last_update or now
798 # logging.debug(f"READ {name=} {skel.dbEntity=}")
799 # logging.debug(f"READ {name=} {skel.accessedValues=}")
801 if not last_update or last_update + self.compute.interval.lifetime <= now:
802 # if so, recompute and refresh updated value
803 skel.accessedValues[name] = value = self._compute(skel, name)
805 def transact():
806 db_obj = db.Get(skel["key"])
807 db_obj[f"_viur_compute_{name}_"] = now
808 db_obj[name] = value
809 db.Put(db_obj)
811 if db.IsInTransaction():
812 transact()
813 else:
814 db.RunInTransaction(transact)
816 return True
818 # Compute on every deserialization
819 case ComputeMethod.Always:
820 skel.accessedValues[name] = self._compute(skel, name)
821 return True
823 # Only compute once when loaded value is empty
824 case ComputeMethod.Once:
825 if loadVal is None:
826 skel.accessedValues[name] = self._compute(skel, name)
827 return True
829 # unserialize value to given config
830 if self.languages and self.multiple:
831 res = {}
832 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal:
833 for language in self.languages:
834 res[language] = []
835 if language in loadVal:
836 tmpVal = loadVal[language]
837 if not isinstance(tmpVal, list):
838 tmpVal = [tmpVal]
839 for singleValue in tmpVal:
840 res[language].append(self.singleValueUnserialize(singleValue))
841 else: # We could not parse this, maybe it has been written before languages had been set?
842 for language in self.languages:
843 res[language] = []
844 mainLang = self.languages[0]
845 if loadVal is None:
846 pass
847 elif isinstance(loadVal, list):
848 for singleValue in loadVal:
849 res[mainLang].append(self.singleValueUnserialize(singleValue))
850 else: # Hopefully it's a value stored before languages and multiple has been set
851 res[mainLang].append(self.singleValueUnserialize(loadVal))
852 elif self.languages:
853 res = {}
854 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal:
855 for language in self.languages:
856 res[language] = None
857 if language in loadVal:
858 tmpVal = loadVal[language]
859 if isinstance(tmpVal, list) and tmpVal:
860 tmpVal = tmpVal[0]
861 res[language] = self.singleValueUnserialize(tmpVal)
862 else: # We could not parse this, maybe it has been written before languages had been set?
863 for language in self.languages:
864 res[language] = None
865 oldKey = f"{name}.{language}"
866 if oldKey in skel.dbEntity and skel.dbEntity[oldKey]:
867 res[language] = self.singleValueUnserialize(skel.dbEntity[oldKey])
868 loadVal = None # Don't try to import later again, this format takes precedence
869 mainLang = self.languages[0]
870 if loadVal is None:
871 pass
872 elif isinstance(loadVal, list) and loadVal:
873 res[mainLang] = self.singleValueUnserialize(loadVal)
874 else: # Hopefully it's a value stored before languages and multiple has been set
875 res[mainLang] = self.singleValueUnserialize(loadVal)
876 elif self.multiple:
877 res = []
878 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal:
879 # Pick one language we'll use
880 if conf.i18n.default_language in loadVal:
881 loadVal = loadVal[conf.i18n.default_language]
882 else:
883 loadVal = [x for x in loadVal.values() if x is not True]
884 if loadVal and not isinstance(loadVal, list):
885 loadVal = [loadVal]
886 if loadVal:
887 for val in loadVal:
888 res.append(self.singleValueUnserialize(val))
889 else: # Not multiple, no languages
890 res = None
891 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal:
892 # Pick one language we'll use
893 if conf.i18n.default_language in loadVal:
894 loadVal = loadVal[conf.i18n.default_language]
895 else:
896 loadVal = [x for x in loadVal.values() if x is not True]
897 if loadVal and isinstance(loadVal, list):
898 loadVal = loadVal[0]
899 if loadVal is not None:
900 res = self.singleValueUnserialize(loadVal)
902 skel.accessedValues[name] = res
903 return True
905 def delete(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str):
906 """
907 Like postDeletedHandler, but runs inside the transaction
908 """
909 pass
911 def buildDBFilter(self,
912 name: str,
913 skel: 'viur.core.skeleton.SkeletonInstance',
914 dbFilter: db.Query,
915 rawFilter: dict,
916 prefix: t.Optional[str] = None) -> db.Query:
917 """
918 Parses the searchfilter a client specified in his Request into
919 something understood by the datastore.
920 This function must:
922 * - Ignore all filters not targeting this bone
923 * - Safely handle malformed data in rawFilter (this parameter is directly controlled by the client)
925 :param name: The property-name this bone has in its Skeleton (not the description!)
926 :param skel: The :class:`viur.core.db.Query` this bone is part of
927 :param dbFilter: The current :class:`viur.core.db.Query` instance the filters should be applied to
928 :param rawFilter: The dictionary of filters the client wants to have applied
929 :returns: The modified :class:`viur.core.db.Query`
930 """
931 myKeys = [key for key in rawFilter.keys() if (key == name or key.startswith(name + "$"))]
933 if len(myKeys) == 0:
934 return dbFilter
936 for key in myKeys:
937 value = rawFilter[key]
938 tmpdata = key.split("$")
940 if len(tmpdata) > 1:
941 if isinstance(value, list):
942 continue
943 if tmpdata[1] == "lt":
944 dbFilter.filter((prefix or "") + tmpdata[0] + " <", value)
945 elif tmpdata[1] == "le":
946 dbFilter.filter((prefix or "") + tmpdata[0] + " <=", value)
947 elif tmpdata[1] == "gt":
948 dbFilter.filter((prefix or "") + tmpdata[0] + " >", value)
949 elif tmpdata[1] == "ge":
950 dbFilter.filter((prefix or "") + tmpdata[0] + " >=", value)
951 elif tmpdata[1] == "lk":
952 dbFilter.filter((prefix or "") + tmpdata[0] + " =", value)
953 else:
954 dbFilter.filter((prefix or "") + tmpdata[0] + " =", value)
955 else:
956 if isinstance(value, list):
957 dbFilter.filter((prefix or "") + key + " IN", value)
958 else:
959 dbFilter.filter((prefix or "") + key + " =", value)
961 return dbFilter
963 def buildDBSort(self,
964 name: str,
965 skel: 'viur.core.skeleton.SkeletonInstance',
966 dbFilter: db.Query,
967 rawFilter: dict) -> t.Optional[db.Query]:
968 """
969 Same as buildDBFilter, but this time its not about filtering
970 the results, but by sorting them.
971 Again: rawFilter is controlled by the client, so you *must* expect and safely handle
972 malformed data!
974 :param name: The property-name this bone has in its Skeleton (not the description!)
975 :param skel: The :class:`viur.core.skeleton.Skeleton` instance this bone is part of
976 :param dbFilter: The current :class:`viur.core.db.Query` instance the filters should
977 be applied to
978 :param rawFilter: The dictionary of filters the client wants to have applied
979 :returns: The modified :class:`viur.core.db.Query`,
980 None if the query is unsatisfiable.
981 """
982 if "orderby" in rawFilter and rawFilter["orderby"] == name:
983 if "orderdir" in rawFilter and rawFilter["orderdir"] == "1":
984 order = (rawFilter["orderby"], db.SortOrder.Descending)
985 elif "orderdir" in rawFilter and rawFilter["orderdir"] == "2":
986 order = (rawFilter["orderby"], db.SortOrder.InvertedAscending)
987 elif "orderdir" in rawFilter and rawFilter["orderdir"] == "3":
988 order = (rawFilter["orderby"], db.SortOrder.InvertedDescending)
989 else:
990 order = (rawFilter["orderby"], db.SortOrder.Ascending)
991 queries = dbFilter.queries
992 if queries is None:
993 return # This query is unsatisfiable
994 elif isinstance(queries, db.QueryDefinition):
995 inEqFilter = [x for x in queries.filters.keys() if
996 (">" in x[-3:] or "<" in x[-3:] or "!=" in x[-4:])]
997 elif isinstance(queries, list):
998 inEqFilter = None
999 for singeFilter in queries:
1000 newInEqFilter = [x for x in singeFilter.filters.keys() if
1001 (">" in x[-3:] or "<" in x[-3:] or "!=" in x[-4:])]
1002 if inEqFilter and newInEqFilter and inEqFilter != newInEqFilter:
1003 raise NotImplementedError("Impossible ordering!")
1004 inEqFilter = newInEqFilter
1005 if inEqFilter:
1006 inEqFilter = inEqFilter[0][: inEqFilter[0].find(" ")]
1007 if inEqFilter != order[0]:
1008 logging.warning(f"I fixed you query! Impossible ordering changed to {inEqFilter}, {order[0]}")
1009 dbFilter.order((inEqFilter, order))
1010 else:
1011 dbFilter.order(order)
1012 else:
1013 dbFilter.order(order)
1014 return dbFilter
1016 def _hashValueForUniquePropertyIndex(self, value: str | int) -> list[str]:
1017 """
1018 Generates a hash of the given value for creating unique property indexes.
1020 This method is called by the framework to create a consistent hash representation of a value
1021 for constructing unique property indexes. Derived bone classes should overwrite this method to
1022 implement their own logic for hashing values.
1024 :param value: The value to be hashed, which can be a string, integer, or a float.
1026 :return: A list containing a string representation of the hashed value. If the bone is multiple,
1027 the list may contain more than one hashed value.
1028 """
1029 def hashValue(value: str | int) -> str:
1030 h = hashlib.sha256()
1031 h.update(str(value).encode("UTF-8"))
1032 res = h.hexdigest()
1033 if isinstance(value, int) or isinstance(value, float):
1034 return f"I-{res}"
1035 elif isinstance(value, str):
1036 return f"S-{res}"
1037 elif isinstance(value, db.Key):
1038 # We Hash the keys here by our self instead of relying on str() or to_legacy_urlsafe()
1039 # as these may change in the future, which would invalidate all existing locks
1040 def keyHash(key):
1041 if key is None:
1042 return "-"
1043 return f"{hashValue(key.kind)}-{hashValue(key.id_or_name)}-<{keyHash(key.parent)}>"
1045 return f"K-{keyHash(value)}"
1046 raise NotImplementedError(f"Type {type(value)} can't be safely used in an uniquePropertyIndex")
1048 if not value and not self.unique.lockEmpty:
1049 return [] # We are zero/empty string and these should not be locked
1050 if not self.multiple:
1051 return [hashValue(value)]
1052 # We have an multiple bone here
1053 if not isinstance(value, list):
1054 value = [value]
1055 tmpList = [hashValue(x) for x in value]
1056 if self.unique.method == UniqueLockMethod.SameValue:
1057 # We should lock each entry individually; lock each value
1058 return tmpList
1059 elif self.unique.method == UniqueLockMethod.SameSet:
1060 # We should ignore the sort-order; so simply sort that List
1061 tmpList.sort()
1062 # Lock the value for that specific list
1063 return [hashValue(", ".join(tmpList))]
1065 def getUniquePropertyIndexValues(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> list[str]:
1066 """
1067 Returns a list of hashes for the current value(s) of a bone in the skeleton, used for storing in the
1068 unique property value index.
1070 :param skel: A SkeletonInstance object representing the current skeleton.
1071 :param name: The property-name of the bone in the skeleton for which the unique property index values
1072 are required (not the description!).
1074 :return: A list of strings representing the hashed values for the current bone value(s) in the skeleton.
1075 If the bone has no value, an empty list is returned.
1076 """
1077 val = skel[name]
1078 if val is None:
1079 return []
1080 return self._hashValueForUniquePropertyIndex(val)
1082 def getReferencedBlobs(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> set[str]:
1083 """
1084 Returns a set of blob keys referenced from this bone
1085 """
1086 return set()
1088 def performMagic(self, valuesCache: dict, name: str, isAdd: bool):
1089 """
1090 This function applies "magically" functionality which f.e. inserts the current Date
1091 or the current user.
1092 :param isAdd: Signals wherever this is an add or edit operation.
1093 """
1094 pass # We do nothing by default
1096 def postSavedHandler(self, skel: 'viur.core.skeleton.SkeletonInstance', boneName: str, key: str):
1097 """
1098 Can be overridden to perform further actions after the main entity has been written.
1100 :param boneName: Name of this bone
1101 :param skel: The skeleton this bone belongs to
1102 :param key: The (new?) Database Key we've written to
1103 """
1104 pass
1106 def postDeletedHandler(self, skel: 'viur.core.skeleton.SkeletonInstance', boneName: str, key: str):
1107 """
1108 Can be overridden to perform further actions after the main entity has been deleted.
1110 :param skel: The skeleton this bone belongs to
1111 :param boneName: Name of this bone
1112 :param key: The old Database Key of the entity we've deleted
1113 """
1114 pass
1116 def refresh(self, skel: 'viur.core.skeleton.SkeletonInstance', boneName: str) -> None:
1117 """
1118 Refresh all values we might have cached from other entities.
1119 """
1120 pass
1122 def mergeFrom(self, valuesCache: dict, boneName: str, otherSkel: 'viur.core.skeleton.SkeletonInstance'):
1123 """
1124 Merges the values from another skeleton instance into the current instance, given that the bone types match.
1126 :param valuesCache: A dictionary containing the cached values for each bone in the skeleton.
1127 :param boneName: The property-name of the bone in the skeleton whose values are to be merged.
1128 :param otherSkel: A SkeletonInstance object representing the other skeleton from which the values \
1129 are to be merged.
1131 This function clones the values from the specified bone in the other skeleton instance into the current
1132 instance, provided that the bone types match. If the bone types do not match, a warning is logged, and the merge
1133 is ignored. If the bone in the other skeleton has no value, the function returns without performing any merge
1134 operation.
1135 """
1136 if getattr(otherSkel, boneName) is None:
1137 return
1138 if not isinstance(getattr(otherSkel, boneName), type(self)):
1139 logging.error(f"Ignoring values from conflicting boneType ({getattr(otherSkel, boneName)} is not a "
1140 f"instance of {type(self)})!")
1141 return
1142 valuesCache[boneName] = copy.deepcopy(otherSkel.valuesCache.get(boneName, None))
1144 def setBoneValue(self,
1145 skel: 'SkeletonInstance',
1146 boneName: str,
1147 value: t.Any,
1148 append: bool,
1149 language: None | str = None) -> bool:
1150 """
1151 Sets the value of a bone in a skeleton instance, with optional support for appending and language-specific
1152 values. Sanity checks are being performed.
1154 :param skel: The SkeletonInstance object representing the skeleton to which the bone belongs.
1155 :param boneName: The property-name of the bone in the skeleton whose value should be set or modified.
1156 :param value: The value to be assigned. Its type depends on the type of the bone.
1157 :param append: If True, the given value is appended to the bone's values instead of replacing it. \
1158 Only supported for bones with multiple=True.
1159 :param language: The language code for which the value should be set or appended, \
1160 if the bone supports languages.
1162 :return: A boolean indicating whether the operation was successful or not.
1164 This function sets or modifies the value of a bone in a skeleton instance, performing sanity checks to ensure
1165 the value is valid. If the value is invalid, no modification occurs. The function supports appending values to
1166 bones with multiple=True and setting or appending language-specific values for bones that support languages.
1167 """
1168 assert not (bool(self.languages) ^ bool(language)), "Language is required or not supported"
1169 assert not append or self.multiple, "Can't append - bone is not multiple"
1171 if not append and self.multiple:
1172 # set multiple values at once
1173 val = []
1174 errors = []
1175 for singleValue in value:
1176 singleValue, singleError = self.singleValueFromClient(singleValue, skel, boneName, {boneName: value})
1177 val.append(singleValue)
1178 if singleError: 1178 ↛ 1179line 1178 didn't jump to line 1179 because the condition on line 1178 was never true
1179 errors.extend(singleError)
1180 else:
1181 # set or append one value
1182 val, errors = self.singleValueFromClient(value, skel, boneName, {boneName: value})
1184 if errors:
1185 for e in errors: 1185 ↛ 1189line 1185 didn't jump to line 1189 because the loop on line 1185 didn't complete
1186 if e.severity in [ReadFromClientErrorSeverity.Invalid, ReadFromClientErrorSeverity.NotSet]: 1186 ↛ 1185line 1186 didn't jump to line 1185 because the condition on line 1186 was always true
1187 # If an invalid datatype (or a non-parseable structure) have been passed, abort the store
1188 return False
1189 if not append and not language:
1190 skel[boneName] = val
1191 elif append and language: 1191 ↛ 1192line 1191 didn't jump to line 1192 because the condition on line 1191 was never true
1192 if not language in skel[boneName] or not isinstance(skel[boneName][language], list):
1193 skel[boneName][language] = []
1194 skel[boneName][language].append(val)
1195 elif append: 1195 ↛ 1200line 1195 didn't jump to line 1200 because the condition on line 1195 was always true
1196 if not isinstance(skel[boneName], list): 1196 ↛ 1197line 1196 didn't jump to line 1197 because the condition on line 1196 was never true
1197 skel[boneName] = []
1198 skel[boneName].append(val)
1199 else: # Just language
1200 skel[boneName][language] = val
1201 return True
1203 def getSearchTags(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> set[str]:
1204 """
1205 Returns a set of strings as search index for this bone.
1207 This function extracts a set of search tags from the given bone's value in the skeleton
1208 instance. The resulting set can be used for indexing or searching purposes.
1210 :param skel: The skeleton instance where the values should be loaded from. This is an instance
1211 of a class derived from `viur.core.skeleton.SkeletonInstance`.
1212 :param name: The name of the bone, which is a string representing the key for the bone in
1213 the skeleton. This should correspond to an existing bone in the skeleton instance.
1214 :return: A set of strings, extracted from the bone value. If the bone value doesn't have
1215 any searchable content, an empty set is returned.
1216 """
1217 return set()
1219 def iter_bone_value(
1220 self, skel: 'viur.core.skeleton.SkeletonInstance', name: str
1221 ) -> t.Iterator[tuple[t.Optional[int], t.Optional[str], t.Any]]:
1222 """
1223 Yield all values from the Skeleton related to this bone instance.
1225 This method handles multiple/languages cases, which could save a lot of if/elifs.
1226 It always yields a triplet: index, language, value.
1227 Where index is the index (int) of a value inside a multiple bone,
1228 language is the language (str) of a multi-language-bone,
1229 and value is the value inside this container.
1230 index or language is None if the bone is single or not multi-lang.
1232 This function can be used to conveniently iterate through all the values of a specific bone
1233 in a skeleton instance, taking into account multiple and multi-language bones.
1235 :param skel: The skeleton instance where the values should be loaded from. This is an instance
1236 of a class derived from `viur.core.skeleton.SkeletonInstance`.
1237 :param name: The name of the bone, which is a string representing the key for the bone in
1238 the skeleton. This should correspond to an existing bone in the skeleton instance.
1240 :return: A generator which yields triplets (index, language, value), where index is the index
1241 of a value inside a multiple bone, language is the language of a multi-language bone,
1242 and value is the value inside this container. index or language is None if the bone is
1243 single or not multi-lang.
1244 """
1245 value = skel[name]
1246 if not value:
1247 return None
1249 if self.languages and isinstance(value, dict):
1250 for idx, (lang, values) in enumerate(value.items()):
1251 if self.multiple:
1252 if not values:
1253 continue
1254 for val in values:
1255 yield idx, lang, val
1256 else:
1257 yield None, lang, values
1258 else:
1259 if self.multiple:
1260 for idx, val in enumerate(value):
1261 yield idx, None, val
1262 else:
1263 yield None, None, value
1265 def _compute(self, skel: 'viur.core.skeleton.SkeletonInstance', bone_name: str):
1266 """Performs the evaluation of a bone configured as compute"""
1268 compute_fn_parameters = inspect.signature(self.compute.fn).parameters
1269 compute_fn_args = {}
1270 if "skel" in compute_fn_parameters:
1271 from viur.core.skeleton import skeletonByKind, RefSkel # noqa: E402 # import works only here because circular imports
1273 if issubclass(skel.skeletonCls, RefSkel): # we have a ref skel we must load the complete skeleton
1274 cloned_skel = skeletonByKind(skel.kindName)()
1275 cloned_skel.fromDB(skel["key"])
1276 else:
1277 cloned_skel = skel.clone()
1278 cloned_skel[bone_name] = None # remove value form accessedValues to avoid endless recursion
1279 compute_fn_args["skel"] = cloned_skel
1281 if "bone" in compute_fn_parameters:
1282 compute_fn_args["bone"] = getattr(skel, bone_name)
1284 if "bone_name" in compute_fn_parameters:
1285 compute_fn_args["bone_name"] = bone_name
1287 ret = self.compute.fn(**compute_fn_args)
1289 def unserialize_raw_value(raw_value: list[dict] | dict | None):
1290 if self.multiple:
1291 return [self.singleValueUnserialize(inner_value) for inner_value in raw_value]
1292 return self.singleValueUnserialize(raw_value)
1294 if self.compute.raw:
1295 if self.languages:
1296 return {
1297 lang: unserialize_raw_value(ret.get(lang, [] if self.multiple else None))
1298 for lang in self.languages
1299 }
1300 return unserialize_raw_value(ret)
1301 self._prevent_compute = True
1302 if errors := self.fromClient(skel, bone_name, {bone_name: ret}):
1303 raise ValueError(f"Computed value fromClient failed with {errors!r}")
1304 self._prevent_compute = False
1305 return skel[bone_name]
1307 def structure(self) -> dict:
1308 """
1309 Describes the bone and its settings as an JSON-serializable dict.
1310 This function has to be implemented for subsequent, specialized bone types.
1311 """
1312 ret = {
1313 "descr": str(self.descr), # need to turn possible translate-object into string
1314 "type": self.type,
1315 "required": self.required,
1316 "params": self.params,
1317 "visible": self.visible,
1318 "readonly": self.readOnly,
1319 "unique": self.unique.method.value if self.unique else False,
1320 "languages": self.languages,
1321 "emptyvalue": self.getEmptyValue(),
1322 "indexed": self.indexed
1323 }
1325 # Provide a defaultvalue, if it's not a function.
1326 if not callable(self.defaultValue) and self.defaultValue is not None:
1327 ret["defaultvalue"] = self.defaultValue
1329 # Provide a multiple setting
1330 if self.multiple and isinstance(self.multiple, MultipleConstraints):
1331 ret["multiple"] = {
1332 "duplicates": self.multiple.duplicates,
1333 "max": self.multiple.max,
1334 "min": self.multiple.min,
1335 }
1336 else:
1337 ret["multiple"] = self.multiple
1338 if self.compute:
1339 ret["compute"] = {
1340 "method": self.compute.interval.method.name
1341 }
1343 if self.compute.interval.lifetime:
1344 ret["compute"]["lifetime"] = self.compute.interval.lifetime.total_seconds()
1346 return ret