Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/bones/base.py: 30%
677 statements
« prev ^ index » next coverage.py v7.6.1, created at 2024-09-03 13:41 +0000
« prev ^ index » next coverage.py v7.6.1, created at 2024-09-03 13:41 +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 vfunc: If given, a callable validating the user-supplied value for this bone.
178 This callable must return None if the value is valid, a String containing an meaningful
179 error-message for the user otherwise.
180 :param readOnly: If True, the user is unable to change the value of this bone. If a value for this
181 bone is given along the POST-Request during Add/Edit, this value will be ignored. Its still
182 possible for the developer to modify this value by assigning skel.bone.value.
183 :param visible: If False, the value of this bone should be hidden from the user. This does
184 *not* protect the value from being exposed in a template, nor from being transferred
185 to the client (ie to the admin or as hidden-value in html-form)
186 :param compute: If set, the bone's value will be computed in the given method.
188 .. NOTE::
189 The kwarg 'multiple' is not supported by all bones
190 """
191 type = "hidden"
192 isClonedInstance = False
194 skel_cls = None
195 """Skeleton class to which this bone instance belongs"""
197 name = None
198 """Name of this bone (attribute name in the skeletons containing this bone)"""
200 def __init__(
201 self,
202 *,
203 compute: Compute = None,
204 defaultValue: t.Any = None,
205 descr: str | i18n.translate = "",
206 getEmptyValueFunc: callable = None,
207 indexed: bool = True,
208 isEmptyFunc: callable = None, # fixme: Rename this, see below.
209 languages: None | list[str] = None,
210 multiple: bool | MultipleConstraints = False,
211 params: dict = None,
212 readOnly: bool = None, # fixme: Rename into readonly (all lowercase!) soon.
213 required: bool | list[str] | tuple[str] = False,
214 searchable: bool = False,
215 unique: None | UniqueValue = None,
216 vfunc: callable = None, # fixme: Rename this, see below.
217 visible: bool = True,
218 ):
219 """
220 Initializes a new Bone.
221 """
222 self.isClonedInstance = getSystemInitialized()
224 if isinstance(descr, str): 224 ↛ 228line 224 didn't jump to line 228 because the condition on line 224 was always true
225 descr = i18n.translate(descr, hint=f"descr of a <{type(self).__name__}>")
227 # Standard definitions
228 self.descr = descr
229 self.params = params or {}
230 self.multiple = multiple
231 self.required = required
232 self.readOnly = bool(readOnly)
233 self.searchable = searchable
234 self.visible = visible
235 self.indexed = indexed
237 if isinstance(category := self.params.get("category"), str): 237 ↛ 238line 237 didn't jump to line 238 because the condition on line 237 was never true
238 self.params["category"] = i18n.translate(category, hint=f"category of a <{type(self).__name__}>")
240 # Multi-language support
241 if not ( 241 ↛ 246line 241 didn't jump to line 246 because the condition on line 241 was never true
242 languages is None or
243 (isinstance(languages, list) and len(languages) > 0
244 and all([isinstance(x, str) for x in languages]))
245 ):
246 raise ValueError("languages must be None or a list of strings")
247 if ( 247 ↛ exit, 247 ↛ 2512 missed branches: 1) line 247 didn't jump to the function exit, 2) line 247 didn't jump to line 251
248 not isinstance(required, bool)
249 and (not isinstance(required, (tuple, list)) or any(not isinstance(value, str) for value in required))
250 ):
251 raise TypeError(f"required must be boolean or a tuple/list of strings. Got: {required!r}")
252 if isinstance(required, (tuple, list)) and not languages: 252 ↛ 253line 252 didn't jump to line 253 because the condition on line 252 was never true
253 raise ValueError("You set required to a list of languages, but defined no languages.")
254 if isinstance(required, (tuple, list)) and languages and (diff := set(required).difference(languages)): 254 ↛ 255line 254 didn't jump to line 255 because the condition on line 254 was never true
255 raise ValueError(f"The language(s) {', '.join(map(repr, diff))} can not be required, "
256 f"because they're not defined.")
258 self.languages = languages
260 # Default value
261 # Convert a None default-value to the empty container that's expected if the bone is
262 # multiple or has languages
263 if defaultValue is None and self.languages:
264 self.defaultValue = {}
265 elif defaultValue is None and self.multiple:
266 self.defaultValue = []
267 else:
268 self.defaultValue = defaultValue
270 # Unique values
271 if unique: 271 ↛ 272line 271 didn't jump to line 272 because the condition on line 271 was never true
272 if not isinstance(unique, UniqueValue):
273 raise ValueError("Unique must be an instance of UniqueValue")
274 if not self.multiple and unique.method.value != 1:
275 raise ValueError("'SameValue' is the only valid method on non-multiple bones")
277 self.unique = unique
279 # Overwrite some validations and value functions by parameter instead of subclassing
280 # todo: This can be done better and more straightforward.
281 if vfunc:
282 self.isInvalid = vfunc # fixme: why is this called just vfunc, and not isInvalidValue/isInvalidValueFunc?
284 if isEmptyFunc: 284 ↛ 285line 284 didn't jump to line 285 because the condition on line 284 was never true
285 self.isEmpty = isEmptyFunc # fixme: why is this not called isEmptyValue/isEmptyValueFunc?
287 if getEmptyValueFunc:
288 self.getEmptyValue = getEmptyValueFunc
290 if compute: 290 ↛ 291line 290 didn't jump to line 291 because the condition on line 290 was never true
291 if not isinstance(compute, Compute):
292 raise TypeError("compute must be an instanceof of Compute")
294 # When readOnly is None, handle flag automatically
295 if readOnly is None:
296 self.readOnly = True
297 if not self.readOnly:
298 raise ValueError("'compute' can only be used with bones configured as `readOnly=True`")
300 if (
301 compute.interval.method == ComputeMethod.Lifetime
302 and not isinstance(compute.interval.lifetime, timedelta)
303 ):
304 raise ValueError(
305 f"'compute' is configured as ComputeMethod.Lifetime, but {compute.interval.lifetime=} was specified"
306 )
307 # If a RelationalBone is computed and raw is False, the unserialize function is called recursively
308 # and the value is recalculated all the time. This parameter is to prevent this.
309 self._prevent_compute = False
311 self.compute = compute
313 def __set_name__(self, owner: "Skeleton", name: str) -> None:
314 self.skel_cls = owner
315 self.name = name
317 def setSystemInitialized(self):
318 """
319 Can be overridden to initialize properties that depend on the Skeleton system
320 being initialized
321 """
322 pass
324 def isInvalid(self, value):
325 """
326 Checks if the current value of the bone in the given skeleton is invalid.
327 Returns None if the value would be valid for this bone, an error-message otherwise.
328 """
329 return False
331 def isEmpty(self, value: t.Any) -> bool:
332 """
333 Check if the given single value represents the "empty" value.
334 This usually is the empty string, 0 or False.
336 .. warning:: isEmpty takes precedence over isInvalid! The empty value is always
337 valid - unless the bone is required.
338 But even then the empty value will be reflected back to the client.
340 .. warning:: value might be the string/object received from the user (untrusted
341 input!) or the value returned by get
342 """
343 return not bool(value)
345 def getDefaultValue(self, skeletonInstance):
346 """
347 Retrieves the default value for the bone.
349 This method is called by the framework to obtain the default value of a bone when no value
350 is provided. Derived bone classes can overwrite this method to implement their own logic for
351 providing a default value.
353 :return: The default value of the bone, which can be of any data type.
354 """
355 if callable(self.defaultValue):
356 return self.defaultValue(skeletonInstance, self)
357 elif isinstance(self.defaultValue, list):
358 return self.defaultValue[:]
359 elif isinstance(self.defaultValue, dict):
360 return self.defaultValue.copy()
361 else:
362 return self.defaultValue
364 def getEmptyValue(self) -> t.Any:
365 """
366 Returns the value representing an empty field for this bone.
367 This might be the empty string for str/text Bones, Zero for numeric bones etc.
368 """
369 return None
371 def __setattr__(self, key, value):
372 """
373 Custom attribute setter for the BaseBone class.
375 This method is used to ensure that certain bone attributes, such as 'multiple', are only
376 set once during the bone's lifetime. Derived bone classes should not need to overwrite this
377 method unless they have additional attributes with similar constraints.
379 :param key: A string representing the attribute name.
380 :param value: The value to be assigned to the attribute.
382 :raises AttributeError: If a protected attribute is attempted to be modified after its initial
383 assignment.
384 """
385 if not self.isClonedInstance and getSystemInitialized() and key != "isClonedInstance" and not key.startswith( 385 ↛ 387line 385 didn't jump to line 387 because the condition on line 385 was never true
386 "_"):
387 raise AttributeError("You cannot modify this Skeleton. Grab a copy using .clone() first")
388 super().__setattr__(key, value)
390 def collectRawClientData(self, name, data, multiple, languages, collectSubfields):
391 """
392 Collects raw client data for the bone and returns it in a dictionary.
394 This method is called by the framework to gather raw data from the client, such as form data or data from a
395 request. Derived bone classes should overwrite this method to implement their own logic for collecting raw data.
397 :param name: A string representing the bone's name.
398 :param data: A dictionary containing the raw data from the client.
399 :param multiple: A boolean indicating whether the bone supports multiple values.
400 :param languages: An optional list of strings representing the supported languages (default: None).
401 :param collectSubfields: A boolean indicating whether to collect data for subfields (default: False).
403 :return: A dictionary containing the collected raw client data.
404 """
405 fieldSubmitted = False
406 if languages:
407 res = {}
408 for lang in languages:
409 if not collectSubfields: 409 ↛ 421line 409 didn't jump to line 421 because the condition on line 409 was always true
410 if f"{name}.{lang}" in data:
411 fieldSubmitted = True
412 res[lang] = data[f"{name}.{lang}"]
413 if multiple and not isinstance(res[lang], list): 413 ↛ 414line 413 didn't jump to line 414 because the condition on line 413 was never true
414 res[lang] = [res[lang]]
415 elif not multiple and isinstance(res[lang], list): 415 ↛ 416line 415 didn't jump to line 416 because the condition on line 415 was never true
416 if res[lang]:
417 res[lang] = res[lang][0]
418 else:
419 res[lang] = None
420 else:
421 for key in data.keys(): # Allow setting relations with using, multiple and languages back to none
422 if key == f"{name}.{lang}":
423 fieldSubmitted = True
424 prefix = f"{name}.{lang}."
425 if multiple:
426 tmpDict = {}
427 for key, value in data.items():
428 if not key.startswith(prefix):
429 continue
430 fieldSubmitted = True
431 partKey = key.replace(prefix, "")
432 firstKey, remainingKey = partKey.split(".", maxsplit=1)
433 try:
434 firstKey = int(firstKey)
435 except:
436 continue
437 if firstKey not in tmpDict:
438 tmpDict[firstKey] = {}
439 tmpDict[firstKey][remainingKey] = value
440 tmpList = list(tmpDict.items())
441 tmpList.sort(key=lambda x: x[0])
442 res[lang] = [x[1] for x in tmpList]
443 else:
444 tmpDict = {}
445 for key, value in data.items():
446 if not key.startswith(prefix):
447 continue
448 fieldSubmitted = True
449 partKey = key.replace(prefix, "")
450 tmpDict[partKey] = value
451 res[lang] = tmpDict
452 return res, fieldSubmitted
453 else: # No multi-lang
454 if not collectSubfields: 454 ↛ 468line 454 didn't jump to line 468 because the condition on line 454 was always true
455 if name not in data: # Empty! 455 ↛ 456line 455 didn't jump to line 456 because the condition on line 455 was never true
456 return None, False
457 val = data[name]
458 if multiple and not isinstance(val, list): 458 ↛ 459line 458 didn't jump to line 459 because the condition on line 458 was never true
459 return [val], True
460 elif not multiple and isinstance(val, list): 460 ↛ 461line 460 didn't jump to line 461 because the condition on line 460 was never true
461 if val:
462 return val[0], True
463 else:
464 return None, True # Empty!
465 else:
466 return val, True
467 else: # No multi-lang but collect subfields
468 for key in data.keys(): # Allow setting relations with using, multiple and languages back to none
469 if key == name:
470 fieldSubmitted = True
471 prefix = f"{name}."
472 if multiple:
473 tmpDict = {}
474 for key, value in data.items():
475 if not key.startswith(prefix):
476 continue
477 fieldSubmitted = True
478 partKey = key.replace(prefix, "")
479 try:
480 firstKey, remainingKey = partKey.split(".", maxsplit=1)
481 firstKey = int(firstKey)
482 except:
483 continue
484 if firstKey not in tmpDict:
485 tmpDict[firstKey] = {}
486 tmpDict[firstKey][remainingKey] = value
487 tmpList = list(tmpDict.items())
488 tmpList.sort(key=lambda x: x[0])
489 return [x[1] for x in tmpList], fieldSubmitted
490 else:
491 res = {}
492 for key, value in data.items():
493 if not key.startswith(prefix):
494 continue
495 fieldSubmitted = True
496 subKey = key.replace(prefix, "")
497 res[subKey] = value
498 return res, fieldSubmitted
500 def parseSubfieldsFromClient(self) -> bool:
501 """
502 Determines whether the function should parse subfields submitted by the client.
503 Set to True only when expecting a list of dictionaries to be transmitted.
504 """
505 return False
507 def singleValueFromClient(self, value: t.Any, skel: 'SkeletonInstance',
508 bone_name: str, client_data: dict
509 ) -> tuple[t.Any, list[ReadFromClientError] | None]:
510 """Load a single value from a client
512 :param value: The single value which should be loaded.
513 :param skel: The SkeletonInstance where the value should be loaded into.
514 :param bone_name: The bone name of this bone in the SkeletonInstance.
515 :param client_data: The data taken from the client,
516 a dictionary with usually bone names as key
517 :return: A tuple. If the value is valid, the first element is
518 the parsed value and the second is None.
519 If the value is invalid or not parseable, the first element is a empty value
520 and the second a list of *ReadFromClientError*.
521 """
522 # The BaseBone will not read any client_data in fromClient. Use rawValueBone if needed.
523 return self.getEmptyValue(), [
524 ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Will not read a BaseBone fromClient!")]
526 def fromClient(self, skel: 'SkeletonInstance', name: str, data: dict) -> None | list[ReadFromClientError]:
527 """
528 Reads a value from the client and stores it in the skeleton instance if it is valid for the bone.
530 This function reads a value from the client and processes it according to the bone's configuration.
531 If the value is valid for the bone, it stores the value in the skeleton instance and returns None.
532 Otherwise, the previous value remains unchanged, and a list of ReadFromClientError objects is returned.
534 :param skel: A SkeletonInstance object where the values should be loaded.
535 :param name: A string representing the bone's name.
536 :param data: A dictionary containing the raw data from the client.
537 :return: None if no errors occurred, otherwise a list of ReadFromClientError objects.
538 """
539 subFields = self.parseSubfieldsFromClient()
540 parsedData, fieldSubmitted = self.collectRawClientData(name, data, self.multiple, self.languages, subFields)
541 if not fieldSubmitted: 541 ↛ 542line 541 didn't jump to line 542 because the condition on line 541 was never true
542 return [ReadFromClientError(ReadFromClientErrorSeverity.NotSet, "Field not submitted")]
543 errors = []
544 isEmpty = True
545 filled_languages = set()
546 if self.languages and self.multiple:
547 res = {}
548 for language in self.languages:
549 res[language] = []
550 if language in parsedData:
551 for idx, singleValue in enumerate(parsedData[language]):
552 if self.isEmpty(singleValue): 552 ↛ 553line 552 didn't jump to line 553 because the condition on line 552 was never true
553 continue
554 isEmpty = False
555 filled_languages.add(language)
556 parsedVal, parseErrors = self.singleValueFromClient(singleValue, skel, name, data)
557 res[language].append(parsedVal)
558 if parseErrors: 558 ↛ 559line 558 didn't jump to line 559 because the condition on line 558 was never true
559 for parseError in parseErrors:
560 parseError.fieldPath[:0] = [language, str(idx)]
561 errors.extend(parseErrors)
562 elif self.languages: # and not self.multiple is implicit - this would have been handled above
563 res = {}
564 for language in self.languages:
565 res[language] = None
566 if language in parsedData:
567 if self.isEmpty(parsedData[language]): 567 ↛ 568line 567 didn't jump to line 568 because the condition on line 567 was never true
568 res[language] = self.getEmptyValue()
569 continue
570 isEmpty = False
571 filled_languages.add(language)
572 parsedVal, parseErrors = self.singleValueFromClient(parsedData[language], skel, name, data)
573 res[language] = parsedVal
574 if parseErrors: 574 ↛ 575line 574 didn't jump to line 575 because the condition on line 574 was never true
575 for parseError in parseErrors:
576 parseError.fieldPath.insert(0, language)
577 errors.extend(parseErrors)
578 elif self.multiple: # and not self.languages is implicit - this would have been handled above
579 res = []
580 for idx, singleValue in enumerate(parsedData):
581 if self.isEmpty(singleValue): 581 ↛ 582line 581 didn't jump to line 582 because the condition on line 581 was never true
582 continue
583 isEmpty = False
584 parsedVal, parseErrors = self.singleValueFromClient(singleValue, skel, name, data)
585 res.append(parsedVal)
586 if parseErrors: 586 ↛ 587line 586 didn't jump to line 587 because the condition on line 586 was never true
587 for parseError in parseErrors:
588 parseError.fieldPath.insert(0, str(idx))
589 errors.extend(parseErrors)
590 else: # No Languages, not multiple
591 if self.isEmpty(parsedData):
592 res = self.getEmptyValue()
593 isEmpty = True
594 else:
595 isEmpty = False
596 res, parseErrors = self.singleValueFromClient(parsedData, skel, name, data)
597 if parseErrors:
598 errors.extend(parseErrors)
599 skel[name] = res
600 if self.languages and isinstance(self.required, (list, tuple)): 600 ↛ 601line 600 didn't jump to line 601 because the condition on line 600 was never true
601 missing = set(self.required).difference(filled_languages)
602 if missing:
603 return [ReadFromClientError(ReadFromClientErrorSeverity.Empty, "Field not set", fieldPath=[lang])
604 for lang in missing]
605 if isEmpty:
606 return [ReadFromClientError(ReadFromClientErrorSeverity.Empty, "Field not set")]
608 # Check multiple constraints on demand
609 if self.multiple and isinstance(self.multiple, MultipleConstraints): 609 ↛ 610line 609 didn't jump to line 610 because the condition on line 609 was never true
610 errors.extend(self._validate_multiple_contraints(self.multiple, skel, name))
612 return errors or None
614 def _get_single_destinct_hash(self, value) -> t.Any:
615 """
616 Returns a distinct hash value for a single entry of this bone.
617 The returned value must be hashable.
618 """
619 return value
621 def _get_destinct_hash(self, value) -> t.Any:
622 """
623 Returns a distinct hash value for this bone.
624 The returned value must be hashable.
625 """
626 if not isinstance(value, str) and isinstance(value, Iterable):
627 return tuple(self._get_single_destinct_hash(item) for item in value)
629 return value
631 def _validate_multiple_contraints(
632 self,
633 constraints: MultipleConstraints,
634 skel: 'SkeletonInstance',
635 name: str
636 ) -> list[ReadFromClientError]:
637 """
638 Validates the value of a bone against its multiple constraints and returns a list of ReadFromClientError
639 objects for each violation, such as too many items or duplicates.
641 :param constraints: The MultipleConstraints definition to apply.
642 :param skel: A SkeletonInstance object where the values should be validated.
643 :param name: A string representing the bone's name.
644 :return: A list of ReadFromClientError objects for each constraint violation.
645 """
646 res = []
647 value = self._get_destinct_hash(skel[name])
649 if constraints.min and len(value) < constraints.min:
650 res.append(ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Too few items"))
652 if constraints.max and len(value) > constraints.max:
653 res.append(ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Too many items"))
655 if not constraints.duplicates:
656 if len(set(value)) != len(value):
657 res.append(ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Duplicate items"))
659 return res
661 def singleValueSerialize(self, value, skel: 'SkeletonInstance', name: str, parentIndexed: bool):
662 """
663 Serializes a single value of the bone for storage in the database.
665 Derived bone classes should overwrite this method to implement their own logic for serializing single
666 values.
667 The serialized value should be suitable for storage in the database.
668 """
669 return value
671 def serialize(self, skel: 'SkeletonInstance', name: str, parentIndexed: bool) -> bool:
672 """
673 Serializes this bone into a format that can be written into the datastore.
675 :param skel: A SkeletonInstance object containing the values to be serialized.
676 :param name: A string representing the property name of the bone in its Skeleton (not the description).
677 :param parentIndexed: A boolean indicating whether the parent bone is indexed.
678 :return: A boolean indicating whether the serialization was successful.
679 """
680 # Handle compute on write
681 if self.compute:
682 match self.compute.interval.method:
683 case ComputeMethod.OnWrite:
684 skel.accessedValues[name] = self._compute(skel, name)
686 case ComputeMethod.Lifetime:
687 now = utils.utcNow()
689 last_update = \
690 skel.accessedValues.get(f"_viur_compute_{name}_") \
691 or skel.dbEntity.get(f"_viur_compute_{name}_")
693 if not last_update or last_update + self.compute.interval.lifetime < now:
694 skel.accessedValues[name] = self._compute(skel, name)
695 skel.dbEntity[f"_viur_compute_{name}_"] = now
697 case ComputeMethod.Once:
698 if name not in skel.dbEntity:
699 skel.accessedValues[name] = self._compute(skel, name)
701 # logging.debug(f"WRITE {name=} {skel.accessedValues=}")
702 # logging.debug(f"WRITE {name=} {skel.dbEntity=}")
704 if name in skel.accessedValues:
705 newVal = skel.accessedValues[name]
706 if self.languages and self.multiple:
707 res = db.Entity()
708 res["_viurLanguageWrapper_"] = True
709 for language in self.languages:
710 res[language] = []
711 if not self.indexed:
712 res.exclude_from_indexes.add(language)
713 if language in newVal:
714 for singleValue in newVal[language]:
715 res[language].append(self.singleValueSerialize(singleValue, skel, name, parentIndexed))
716 elif self.languages:
717 res = db.Entity()
718 res["_viurLanguageWrapper_"] = True
719 for language in self.languages:
720 res[language] = None
721 if not self.indexed:
722 res.exclude_from_indexes.add(language)
723 if language in newVal:
724 res[language] = self.singleValueSerialize(newVal[language], skel, name, parentIndexed)
725 elif self.multiple:
726 res = []
728 assert newVal is None or isinstance(newVal, (list, tuple)), \
729 f"Cannot handle {repr(newVal)} here. Expecting list or tuple."
731 for singleValue in (newVal or ()):
732 res.append(self.singleValueSerialize(singleValue, skel, name, parentIndexed))
734 else: # No Languages, not Multiple
735 res = self.singleValueSerialize(newVal, skel, name, parentIndexed)
736 skel.dbEntity[name] = res
737 # Ensure our indexed flag is up2date
738 indexed = self.indexed and parentIndexed
739 if indexed and name in skel.dbEntity.exclude_from_indexes:
740 skel.dbEntity.exclude_from_indexes.discard(name)
741 elif not indexed and name not in skel.dbEntity.exclude_from_indexes:
742 skel.dbEntity.exclude_from_indexes.add(name)
743 return True
744 return False
746 def singleValueUnserialize(self, val):
747 """
748 Unserializes a single value of the bone from the stored database value.
750 Derived bone classes should overwrite this method to implement their own logic for unserializing
751 single values. The unserialized value should be suitable for use in the application logic.
752 """
753 return val
755 def unserialize(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> bool:
756 """
757 Deserialize bone data from the datastore and populate the bone with the deserialized values.
759 This function is the inverse of the serialize function. It converts data from the datastore
760 into a format that can be used by the bones in the skeleton.
762 :param skel: A SkeletonInstance object containing the values to be deserialized.
763 :param name: The property name of the bone in its Skeleton (not the description).
764 :returns: True if deserialization is successful, False otherwise.
765 """
766 if name in skel.dbEntity:
767 loadVal = skel.dbEntity[name]
768 elif (
769 # fixme: Remove this piece of sh*t at least with VIUR4
770 # We're importing from an old ViUR2 instance - there may only be keys prefixed with our name
771 conf.viur2import_blobsource and any(n.startswith(name + ".") for n in skel.dbEntity)
772 # ... or computed
773 or self.compute
774 ):
775 loadVal = None
776 else:
777 skel.accessedValues[name] = self.getDefaultValue(skel)
778 return False
780 # Is this value computed?
781 # In this case, check for configured compute method and if recomputation is required.
782 # Otherwise, the value from the DB is used as is.
783 if self.compute and not self._prevent_compute:
784 match self.compute.interval.method:
785 # Computation is bound to a lifetime?
786 case ComputeMethod.Lifetime:
787 now = utils.utcNow()
789 # check if lifetime exceeded
790 last_update = skel.dbEntity.get(f"_viur_compute_{name}_")
791 skel.accessedValues[f"_viur_compute_{name}_"] = last_update or now
793 # logging.debug(f"READ {name=} {skel.dbEntity=}")
794 # logging.debug(f"READ {name=} {skel.accessedValues=}")
796 if not last_update or last_update + self.compute.interval.lifetime <= now:
797 # if so, recompute and refresh updated value
798 skel.accessedValues[name] = value = self._compute(skel, name)
800 def transact():
801 db_obj = db.Get(skel["key"])
802 db_obj[f"_viur_compute_{name}_"] = now
803 db_obj[name] = value
804 db.Put(db_obj)
806 if db.IsInTransaction():
807 transact()
808 else:
809 db.RunInTransaction(transact)
811 return True
813 # Compute on every deserialization
814 case ComputeMethod.Always:
815 skel.accessedValues[name] = self._compute(skel, name)
816 return True
818 # Only compute once when loaded value is empty
819 case ComputeMethod.Once:
820 if loadVal is None:
821 skel.accessedValues[name] = self._compute(skel, name)
822 return True
824 # unserialize value to given config
825 if self.languages and self.multiple:
826 res = {}
827 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal:
828 for language in self.languages:
829 res[language] = []
830 if language in loadVal:
831 tmpVal = loadVal[language]
832 if not isinstance(tmpVal, list):
833 tmpVal = [tmpVal]
834 for singleValue in tmpVal:
835 res[language].append(self.singleValueUnserialize(singleValue))
836 else: # We could not parse this, maybe it has been written before languages had been set?
837 for language in self.languages:
838 res[language] = []
839 mainLang = self.languages[0]
840 if loadVal is None:
841 pass
842 elif isinstance(loadVal, list):
843 for singleValue in loadVal:
844 res[mainLang].append(self.singleValueUnserialize(singleValue))
845 else: # Hopefully it's a value stored before languages and multiple has been set
846 res[mainLang].append(self.singleValueUnserialize(loadVal))
847 elif self.languages:
848 res = {}
849 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal:
850 for language in self.languages:
851 res[language] = None
852 if language in loadVal:
853 tmpVal = loadVal[language]
854 if isinstance(tmpVal, list) and tmpVal:
855 tmpVal = tmpVal[0]
856 res[language] = self.singleValueUnserialize(tmpVal)
857 else: # We could not parse this, maybe it has been written before languages had been set?
858 for language in self.languages:
859 res[language] = None
860 oldKey = f"{name}.{language}"
861 if oldKey in skel.dbEntity and skel.dbEntity[oldKey]:
862 res[language] = self.singleValueUnserialize(skel.dbEntity[oldKey])
863 loadVal = None # Don't try to import later again, this format takes precedence
864 mainLang = self.languages[0]
865 if loadVal is None:
866 pass
867 elif isinstance(loadVal, list) and loadVal:
868 res[mainLang] = self.singleValueUnserialize(loadVal)
869 else: # Hopefully it's a value stored before languages and multiple has been set
870 res[mainLang] = self.singleValueUnserialize(loadVal)
871 elif self.multiple:
872 res = []
873 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal:
874 # Pick one language we'll use
875 if conf.i18n.default_language in loadVal:
876 loadVal = loadVal[conf.i18n.default_language]
877 else:
878 loadVal = [x for x in loadVal.values() if x is not True]
879 if loadVal and not isinstance(loadVal, list):
880 loadVal = [loadVal]
881 if loadVal:
882 for val in loadVal:
883 res.append(self.singleValueUnserialize(val))
884 else: # Not multiple, no languages
885 res = None
886 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal:
887 # Pick one language we'll use
888 if conf.i18n.default_language in loadVal:
889 loadVal = loadVal[conf.i18n.default_language]
890 else:
891 loadVal = [x for x in loadVal.values() if x is not True]
892 if loadVal and isinstance(loadVal, list):
893 loadVal = loadVal[0]
894 if loadVal is not None:
895 res = self.singleValueUnserialize(loadVal)
897 skel.accessedValues[name] = res
898 return True
900 def delete(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str):
901 """
902 Like postDeletedHandler, but runs inside the transaction
903 """
904 pass
906 def buildDBFilter(self,
907 name: str,
908 skel: 'viur.core.skeleton.SkeletonInstance',
909 dbFilter: db.Query,
910 rawFilter: dict,
911 prefix: t.Optional[str] = None) -> db.Query:
912 """
913 Parses the searchfilter a client specified in his Request into
914 something understood by the datastore.
915 This function must:
917 * - Ignore all filters not targeting this bone
918 * - Safely handle malformed data in rawFilter (this parameter is directly controlled by the client)
920 :param name: The property-name this bone has in its Skeleton (not the description!)
921 :param skel: The :class:`viur.core.db.Query` this bone is part of
922 :param dbFilter: The current :class:`viur.core.db.Query` instance the filters should be applied to
923 :param rawFilter: The dictionary of filters the client wants to have applied
924 :returns: The modified :class:`viur.core.db.Query`
925 """
926 myKeys = [key for key in rawFilter.keys() if (key == name or key.startswith(name + "$"))]
928 if len(myKeys) == 0:
929 return dbFilter
931 for key in myKeys:
932 value = rawFilter[key]
933 tmpdata = key.split("$")
935 if len(tmpdata) > 1:
936 if isinstance(value, list):
937 continue
938 if tmpdata[1] == "lt":
939 dbFilter.filter((prefix or "") + tmpdata[0] + " <", value)
940 elif tmpdata[1] == "le":
941 dbFilter.filter((prefix or "") + tmpdata[0] + " <=", value)
942 elif tmpdata[1] == "gt":
943 dbFilter.filter((prefix or "") + tmpdata[0] + " >", value)
944 elif tmpdata[1] == "ge":
945 dbFilter.filter((prefix or "") + tmpdata[0] + " >=", value)
946 elif tmpdata[1] == "lk":
947 dbFilter.filter((prefix or "") + tmpdata[0] + " =", value)
948 else:
949 dbFilter.filter((prefix or "") + tmpdata[0] + " =", value)
950 else:
951 if isinstance(value, list):
952 dbFilter.filter((prefix or "") + key + " IN", value)
953 else:
954 dbFilter.filter((prefix or "") + key + " =", value)
956 return dbFilter
958 def buildDBSort(self,
959 name: str,
960 skel: 'viur.core.skeleton.SkeletonInstance',
961 dbFilter: db.Query,
962 rawFilter: dict) -> t.Optional[db.Query]:
963 """
964 Same as buildDBFilter, but this time its not about filtering
965 the results, but by sorting them.
966 Again: rawFilter is controlled by the client, so you *must* expect and safely handle
967 malformed data!
969 :param name: The property-name this bone has in its Skeleton (not the description!)
970 :param skel: The :class:`viur.core.skeleton.Skeleton` instance this bone is part of
971 :param dbFilter: The current :class:`viur.core.db.Query` instance the filters should
972 be applied to
973 :param rawFilter: The dictionary of filters the client wants to have applied
974 :returns: The modified :class:`viur.core.db.Query`,
975 None if the query is unsatisfiable.
976 """
977 if "orderby" in rawFilter and rawFilter["orderby"] == name:
978 if "orderdir" in rawFilter and rawFilter["orderdir"] == "1":
979 order = (rawFilter["orderby"], db.SortOrder.Descending)
980 elif "orderdir" in rawFilter and rawFilter["orderdir"] == "2":
981 order = (rawFilter["orderby"], db.SortOrder.InvertedAscending)
982 elif "orderdir" in rawFilter and rawFilter["orderdir"] == "3":
983 order = (rawFilter["orderby"], db.SortOrder.InvertedDescending)
984 else:
985 order = (rawFilter["orderby"], db.SortOrder.Ascending)
986 queries = dbFilter.queries
987 if queries is None:
988 return # This query is unsatisfiable
989 elif isinstance(queries, db.QueryDefinition):
990 inEqFilter = [x for x in queries.filters.keys() if
991 (">" in x[-3:] or "<" in x[-3:] or "!=" in x[-4:])]
992 elif isinstance(queries, list):
993 inEqFilter = None
994 for singeFilter in queries:
995 newInEqFilter = [x for x in singeFilter.filters.keys() if
996 (">" in x[-3:] or "<" in x[-3:] or "!=" in x[-4:])]
997 if inEqFilter and newInEqFilter and inEqFilter != newInEqFilter:
998 raise NotImplementedError("Impossible ordering!")
999 inEqFilter = newInEqFilter
1000 if inEqFilter:
1001 inEqFilter = inEqFilter[0][: inEqFilter[0].find(" ")]
1002 if inEqFilter != order[0]:
1003 logging.warning(f"I fixed you query! Impossible ordering changed to {inEqFilter}, {order[0]}")
1004 dbFilter.order((inEqFilter, order))
1005 else:
1006 dbFilter.order(order)
1007 else:
1008 dbFilter.order(order)
1009 return dbFilter
1011 def _hashValueForUniquePropertyIndex(self, value: str | int) -> list[str]:
1012 """
1013 Generates a hash of the given value for creating unique property indexes.
1015 This method is called by the framework to create a consistent hash representation of a value
1016 for constructing unique property indexes. Derived bone classes should overwrite this method to
1017 implement their own logic for hashing values.
1019 :param value: The value to be hashed, which can be a string, integer, or a float.
1021 :return: A list containing a string representation of the hashed value. If the bone is multiple,
1022 the list may contain more than one hashed value.
1023 """
1024 def hashValue(value: str | int) -> str:
1025 h = hashlib.sha256()
1026 h.update(str(value).encode("UTF-8"))
1027 res = h.hexdigest()
1028 if isinstance(value, int) or isinstance(value, float):
1029 return f"I-{res}"
1030 elif isinstance(value, str):
1031 return f"S-{res}"
1032 elif isinstance(value, db.Key):
1033 # We Hash the keys here by our self instead of relying on str() or to_legacy_urlsafe()
1034 # as these may change in the future, which would invalidate all existing locks
1035 def keyHash(key):
1036 if key is None:
1037 return "-"
1038 return f"{hashValue(key.kind)}-{hashValue(key.id_or_name)}-<{keyHash(key.parent)}>"
1040 return f"K-{keyHash(value)}"
1041 raise NotImplementedError(f"Type {type(value)} can't be safely used in an uniquePropertyIndex")
1043 if not value and not self.unique.lockEmpty:
1044 return [] # We are zero/empty string and these should not be locked
1045 if not self.multiple:
1046 return [hashValue(value)]
1047 # We have an multiple bone here
1048 if not isinstance(value, list):
1049 value = [value]
1050 tmpList = [hashValue(x) for x in value]
1051 if self.unique.method == UniqueLockMethod.SameValue:
1052 # We should lock each entry individually; lock each value
1053 return tmpList
1054 elif self.unique.method == UniqueLockMethod.SameSet:
1055 # We should ignore the sort-order; so simply sort that List
1056 tmpList.sort()
1057 # Lock the value for that specific list
1058 return [hashValue(", ".join(tmpList))]
1060 def getUniquePropertyIndexValues(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> list[str]:
1061 """
1062 Returns a list of hashes for the current value(s) of a bone in the skeleton, used for storing in the
1063 unique property value index.
1065 :param skel: A SkeletonInstance object representing the current skeleton.
1066 :param name: The property-name of the bone in the skeleton for which the unique property index values
1067 are required (not the description!).
1069 :return: A list of strings representing the hashed values for the current bone value(s) in the skeleton.
1070 If the bone has no value, an empty list is returned.
1071 """
1072 val = skel[name]
1073 if val is None:
1074 return []
1075 return self._hashValueForUniquePropertyIndex(val)
1077 def getReferencedBlobs(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> set[str]:
1078 """
1079 Returns a set of blob keys referenced from this bone
1080 """
1081 return set()
1083 def performMagic(self, valuesCache: dict, name: str, isAdd: bool):
1084 """
1085 This function applies "magically" functionality which f.e. inserts the current Date
1086 or the current user.
1087 :param isAdd: Signals wherever this is an add or edit operation.
1088 """
1089 pass # We do nothing by default
1091 def postSavedHandler(self, skel: 'viur.core.skeleton.SkeletonInstance', boneName: str, key: str):
1092 """
1093 Can be overridden to perform further actions after the main entity has been written.
1095 :param boneName: Name of this bone
1096 :param skel: The skeleton this bone belongs to
1097 :param key: The (new?) Database Key we've written to
1098 """
1099 pass
1101 def postDeletedHandler(self, skel: 'viur.core.skeleton.SkeletonInstance', boneName: str, key: str):
1102 """
1103 Can be overridden to perform further actions after the main entity has been deleted.
1105 :param skel: The skeleton this bone belongs to
1106 :param boneName: Name of this bone
1107 :param key: The old Database Key of the entity we've deleted
1108 """
1109 pass
1111 def refresh(self, skel: 'viur.core.skeleton.SkeletonInstance', boneName: str) -> None:
1112 """
1113 Refresh all values we might have cached from other entities.
1114 """
1115 pass
1117 def mergeFrom(self, valuesCache: dict, boneName: str, otherSkel: 'viur.core.skeleton.SkeletonInstance'):
1118 """
1119 Merges the values from another skeleton instance into the current instance, given that the bone types match.
1121 :param valuesCache: A dictionary containing the cached values for each bone in the skeleton.
1122 :param boneName: The property-name of the bone in the skeleton whose values are to be merged.
1123 :param otherSkel: A SkeletonInstance object representing the other skeleton from which the values \
1124 are to be merged.
1126 This function clones the values from the specified bone in the other skeleton instance into the current
1127 instance, provided that the bone types match. If the bone types do not match, a warning is logged, and the merge
1128 is ignored. If the bone in the other skeleton has no value, the function returns without performing any merge
1129 operation.
1130 """
1131 if getattr(otherSkel, boneName) is None:
1132 return
1133 if not isinstance(getattr(otherSkel, boneName), type(self)):
1134 logging.error(f"Ignoring values from conflicting boneType ({getattr(otherSkel, boneName)} is not a "
1135 f"instance of {type(self)})!")
1136 return
1137 valuesCache[boneName] = copy.deepcopy(otherSkel.valuesCache.get(boneName, None))
1139 def setBoneValue(self,
1140 skel: 'SkeletonInstance',
1141 boneName: str,
1142 value: t.Any,
1143 append: bool,
1144 language: None | str = None) -> bool:
1145 """
1146 Sets the value of a bone in a skeleton instance, with optional support for appending and language-specific
1147 values. Sanity checks are being performed.
1149 :param skel: The SkeletonInstance object representing the skeleton to which the bone belongs.
1150 :param boneName: The property-name of the bone in the skeleton whose value should be set or modified.
1151 :param value: The value to be assigned. Its type depends on the type of the bone.
1152 :param append: If True, the given value is appended to the bone's values instead of replacing it. \
1153 Only supported for bones with multiple=True.
1154 :param language: The language code for which the value should be set or appended, \
1155 if the bone supports languages.
1157 :return: A boolean indicating whether the operation was successful or not.
1159 This function sets or modifies the value of a bone in a skeleton instance, performing sanity checks to ensure
1160 the value is valid. If the value is invalid, no modification occurs. The function supports appending values to
1161 bones with multiple=True and setting or appending language-specific values for bones that support languages.
1162 """
1163 assert not (bool(self.languages) ^ bool(language)), "Language is required or not supported"
1164 assert not append or self.multiple, "Can't append - bone is not multiple"
1166 if not append and self.multiple:
1167 # set multiple values at once
1168 val = []
1169 errors = []
1170 for singleValue in value:
1171 singleValue, singleError = self.singleValueFromClient(singleValue, skel, boneName, {boneName: value})
1172 val.append(singleValue)
1173 if singleError: 1173 ↛ 1174line 1173 didn't jump to line 1174 because the condition on line 1173 was never true
1174 errors.extend(singleError)
1175 else:
1176 # set or append one value
1177 val, errors = self.singleValueFromClient(value, skel, boneName, {boneName: value})
1179 if errors:
1180 for e in errors: 1180 ↛ 1184line 1180 didn't jump to line 1184 because the loop on line 1180 didn't complete
1181 if e.severity in [ReadFromClientErrorSeverity.Invalid, ReadFromClientErrorSeverity.NotSet]: 1181 ↛ 1180line 1181 didn't jump to line 1180 because the condition on line 1181 was always true
1182 # If an invalid datatype (or a non-parseable structure) have been passed, abort the store
1183 return False
1184 if not append and not language:
1185 skel[boneName] = val
1186 elif append and language: 1186 ↛ 1187line 1186 didn't jump to line 1187 because the condition on line 1186 was never true
1187 if not language in skel[boneName] or not isinstance(skel[boneName][language], list):
1188 skel[boneName][language] = []
1189 skel[boneName][language].append(val)
1190 elif append: 1190 ↛ 1195line 1190 didn't jump to line 1195 because the condition on line 1190 was always true
1191 if not isinstance(skel[boneName], list): 1191 ↛ 1192line 1191 didn't jump to line 1192 because the condition on line 1191 was never true
1192 skel[boneName] = []
1193 skel[boneName].append(val)
1194 else: # Just language
1195 skel[boneName][language] = val
1196 return True
1198 def getSearchTags(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> set[str]:
1199 """
1200 Returns a set of strings as search index for this bone.
1202 This function extracts a set of search tags from the given bone's value in the skeleton
1203 instance. The resulting set can be used for indexing or searching purposes.
1205 :param skel: The skeleton instance where the values should be loaded from. This is an instance
1206 of a class derived from `viur.core.skeleton.SkeletonInstance`.
1207 :param name: The name of the bone, which is a string representing the key for the bone in
1208 the skeleton. This should correspond to an existing bone in the skeleton instance.
1209 :return: A set of strings, extracted from the bone value. If the bone value doesn't have
1210 any searchable content, an empty set is returned.
1211 """
1212 return set()
1214 def iter_bone_value(
1215 self, skel: 'viur.core.skeleton.SkeletonInstance', name: str
1216 ) -> t.Iterator[tuple[t.Optional[int], t.Optional[str], t.Any]]:
1217 """
1218 Yield all values from the Skeleton related to this bone instance.
1220 This method handles multiple/languages cases, which could save a lot of if/elifs.
1221 It always yields a triplet: index, language, value.
1222 Where index is the index (int) of a value inside a multiple bone,
1223 language is the language (str) of a multi-language-bone,
1224 and value is the value inside this container.
1225 index or language is None if the bone is single or not multi-lang.
1227 This function can be used to conveniently iterate through all the values of a specific bone
1228 in a skeleton instance, taking into account multiple and multi-language bones.
1230 :param skel: The skeleton instance where the values should be loaded from. This is an instance
1231 of a class derived from `viur.core.skeleton.SkeletonInstance`.
1232 :param name: The name of the bone, which is a string representing the key for the bone in
1233 the skeleton. This should correspond to an existing bone in the skeleton instance.
1235 :return: A generator which yields triplets (index, language, value), where index is the index
1236 of a value inside a multiple bone, language is the language of a multi-language bone,
1237 and value is the value inside this container. index or language is None if the bone is
1238 single or not multi-lang.
1239 """
1240 value = skel[name]
1241 if not value:
1242 return None
1244 if self.languages and isinstance(value, dict):
1245 for idx, (lang, values) in enumerate(value.items()):
1246 if self.multiple:
1247 if not values:
1248 continue
1249 for val in values:
1250 yield idx, lang, val
1251 else:
1252 yield None, lang, values
1253 else:
1254 if self.multiple:
1255 for idx, val in enumerate(value):
1256 yield idx, None, val
1257 else:
1258 yield None, None, value
1260 def _compute(self, skel: 'viur.core.skeleton.SkeletonInstance', bone_name: str):
1261 """Performs the evaluation of a bone configured as compute"""
1263 compute_fn_parameters = inspect.signature(self.compute.fn).parameters
1264 compute_fn_args = {}
1265 if "skel" in compute_fn_parameters:
1266 from viur.core.skeleton import skeletonByKind, RefSkel # noqa: E402 # import works only here because circular imports
1268 if issubclass(skel.skeletonCls, RefSkel): # we have a ref skel we must load the complete skeleton
1269 cloned_skel = skeletonByKind(skel.kindName)()
1270 cloned_skel.fromDB(skel["key"])
1271 else:
1272 cloned_skel = skel.clone()
1273 cloned_skel[bone_name] = None # remove value form accessedValues to avoid endless recursion
1274 compute_fn_args["skel"] = cloned_skel
1276 if "bone" in compute_fn_parameters:
1277 compute_fn_args["bone"] = getattr(skel, bone_name)
1279 if "bone_name" in compute_fn_parameters:
1280 compute_fn_args["bone_name"] = bone_name
1282 ret = self.compute.fn(**compute_fn_args)
1284 def unserialize_raw_value(raw_value: list[dict] | dict | None):
1285 if self.multiple:
1286 return [self.singleValueUnserialize(inner_value) for inner_value in raw_value]
1287 return self.singleValueUnserialize(raw_value)
1289 if self.compute.raw:
1290 if self.languages:
1291 return {
1292 lang: unserialize_raw_value(ret.get(lang, [] if self.multiple else None))
1293 for lang in self.languages
1294 }
1295 return unserialize_raw_value(ret)
1296 self._prevent_compute = True
1297 if errors := self.fromClient(skel, bone_name, {bone_name: ret}):
1298 raise ValueError(f"Computed value fromClient failed with {errors!r}")
1299 self._prevent_compute = False
1300 return skel[bone_name]
1302 def structure(self) -> dict:
1303 """
1304 Describes the bone and its settings as an JSON-serializable dict.
1305 This function has to be implemented for subsequent, specialized bone types.
1306 """
1307 ret = {
1308 "descr": str(self.descr), # need to turn possible translate-object into string
1309 "type": self.type,
1310 "required": self.required,
1311 "params": self.params,
1312 "visible": self.visible,
1313 "readonly": self.readOnly,
1314 "unique": self.unique.method.value if self.unique else False,
1315 "languages": self.languages,
1316 "emptyvalue": self.getEmptyValue(),
1317 "indexed": self.indexed
1318 }
1320 # Provide a defaultvalue, if it's not a function.
1321 if not callable(self.defaultValue) and self.defaultValue is not None:
1322 ret["defaultvalue"] = self.defaultValue
1324 # Provide a multiple setting
1325 if self.multiple and isinstance(self.multiple, MultipleConstraints):
1326 ret["multiple"] = {
1327 "duplicates": self.multiple.duplicates,
1328 "max": self.multiple.max,
1329 "min": self.multiple.min,
1330 }
1331 else:
1332 ret["multiple"] = self.multiple
1333 if self.compute:
1334 ret["compute"] = {
1335 "method": self.compute.interval.method.name
1336 }
1338 if self.compute.interval.lifetime:
1339 ret["compute"]["lifetime"] = self.compute.interval.lifetime.total_seconds()
1341 return ret