Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/bones/base.py: 32%

825 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-01 22:44 +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""" 

7 

8import copy 

9import enum 

10import hashlib 

11import inspect 

12import logging 

13import typing as t 

14from collections.abc import Iterable 

15from dataclasses import dataclass, field 

16from datetime import timedelta 

17from enum import Enum 

18 

19from viur.core import current, db, i18n, utils 

20from viur.core.config import conf 

21 

22if t.TYPE_CHECKING: 22 ↛ 23line 22 didn't jump to line 23 because the condition on line 22 was never true

23 from ..skeleton import Skeleton, SkeletonInstance 

24 

25__system_initialized = False 

26""" 

27Initializes the global variable __system_initialized 

28""" 

29 

30 

31def setSystemInitialized(): 

32 """ 

33 Sets the global __system_initialized variable to True, indicating that the system is 

34 initialized and ready for use. This function should be called once all necessary setup 

35 tasks have been completed. It also iterates over all skeleton classes and calls their 

36 setSystemInitialized() method. 

37 

38 Global variables: 

39 __system_initialized: A boolean flag indicating if the system is initialized. 

40 """ 

41 global __system_initialized 

42 from viur.core.skeleton import iterAllSkelClasses 

43 

44 for skelCls in iterAllSkelClasses(): 

45 skelCls.setSystemInitialized() 

46 

47 __system_initialized = True 

48 

49 

50def getSystemInitialized(): 

51 """ 

52 Retrieves the current state of the system initialization by returning the value of the 

53 global variable __system_initialized. 

54 """ 

55 global __system_initialized 

56 return __system_initialized 

57 

58 

59class ReadFromClientErrorSeverity(Enum): 

60 """ 

61 ReadFromClientErrorSeverity is an enumeration that represents the severity levels of errors 

62 that can occur while reading data from the client. 

63 """ 

64 NotSet = 0 

65 """No error occurred""" 

66 InvalidatesOther = 1 

67 # TODO: what is this error about? 

68 """The data is valid, for this bone, but in relation to other invalid""" 

69 Empty = 2 

70 """The data is empty, but the bone requires a value""" 

71 Invalid = 3 

72 """The data is invalid, but the bone requires a value""" 

73 

74 

75@dataclass 

76class ReadFromClientError: 

77 """ 

78 The ReadFromClientError class represents an error that occurs while reading data from the client. 

79 This class is used to store information about the error, including its severity, an error message, 

80 the field path where the error occurred, and a list of invalidated fields. 

81 """ 

82 severity: ReadFromClientErrorSeverity 

83 """A ReadFromClientErrorSeverity enumeration value representing the severity of the error.""" 

84 errorMessage: t.Optional[str] = None 

85 """A string containing a human-readable error message describing the issue.""" 

86 fieldPath: list[str] = field(default_factory=list) 

87 """A list of strings representing the path to the field where the error occurred.""" 

88 invalidatedFields: list[str] = None 

89 """A list of strings containing the names of invalidated fields, if any.""" 

90 

91 def __post_init__(self): 

92 if not self.errorMessage: 

93 self.errorMessage = { 

94 ReadFromClientErrorSeverity.NotSet: 

95 i18n.translate("core.bones.error.notset", "Field not submitted"), 

96 ReadFromClientErrorSeverity.InvalidatesOther: 

97 i18n.translate("core.bones.error.invalidatesother", "Field invalidates another field"), 

98 ReadFromClientErrorSeverity.Empty: 

99 i18n.translate("core.bones.error.empty", "Field not set"), 

100 ReadFromClientErrorSeverity.Invalid: 

101 i18n.translate("core.bones.error.invalid", "Invalid value provided"), 

102 }[self.severity] 

103 

104 def __str__(self): 

105 return f"{'.'.join(self.fieldPath)}: {self.errorMessage} [{self.severity.name}]" 

106 

107 

108class ReadFromClientException(Exception): 

109 """ 

110 ReadFromClientError as an Exception to raise. 

111 """ 

112 

113 def __init__(self, errors: ReadFromClientError | t.Iterable[ReadFromClientError]): 

114 """ 

115 This is an exception holding ReadFromClientErrors. 

116 

117 :param errors: Either one or an iterable of errors. 

118 """ 

119 super().__init__() 

120 

121 # Allow to specifiy a single ReadFromClientError 

122 if isinstance(errors, ReadFromClientError): 

123 errors = (ReadFromClientError, ) 

124 

125 self.errors = tuple(error for error in errors if isinstance(error, ReadFromClientError)) 

126 

127 # Disallow ReadFromClientException without any ReadFromClientErrors 

128 if not self.errors: 

129 raise ValueError("ReadFromClientException requires for at least one ReadFromClientError") 

130 

131 # Either show any errors with severity greater ReadFromClientErrorSeverity.NotSet to the Exception notes, 

132 # or otherwise all errors (all have ReadFromClientErrorSeverity.NotSet then) 

133 notes_errors = tuple( 

134 error for error in self.errors if error.severity.value > ReadFromClientErrorSeverity.NotSet.value 

135 ) 

136 

137 self.add_note("\n".join(str(error) for error in notes_errors or self.errors)) 

138 

139 

140class UniqueLockMethod(Enum): 

141 """ 

142 UniqueLockMethod is an enumeration that represents different locking methods for unique constraints 

143 on bones. This is used to specify how the uniqueness of a value or a set of values should be 

144 enforced. 

145 """ 

146 SameValue = 1 # Lock this value for just one entry or each value individually if bone is multiple 

147 """ 

148 Lock this value so that there is only one entry, or lock each value individually if the bone 

149 is multiple. 

150 """ 

151 SameSet = 2 # Same Set of entries (including duplicates), any order 

152 """Lock the same set of entries (including duplicates) regardless of their order.""" 

153 SameList = 3 # Same Set of entries (including duplicates), in this specific order 

154 """Lock the same set of entries (including duplicates) in a specific order.""" 

155 

156 

157@dataclass 

158class UniqueValue: # Mark a bone as unique (it must have a different value for each entry) 

159 """ 

160 The UniqueValue class represents a unique constraint on a bone, ensuring that it must have a 

161 different value for each entry. This class is used to store information about the unique 

162 constraint, such as the locking method, whether to lock empty values, and an error message to 

163 display to the user if the requested value is already taken. 

164 """ 

165 method: UniqueLockMethod # How to handle multiple values (for bones with multiple=True) 

166 """ 

167 A UniqueLockMethod enumeration value specifying how to handle multiple values for bones with 

168 multiple=True. 

169 """ 

170 lockEmpty: bool # If False, empty values ("", 0) are not locked - needed if unique but not required 

171 """ 

172 A boolean value indicating if empty values ("", 0) should be locked. If False, empty values are not 

173 locked, which is needed if a field is unique but not required. 

174 """ 

175 message: str # Error-Message displayed to the user if the requested value is already taken 

176 """ 

177 A string containing an error message displayed to the user if the requested value is already 

178 taken. 

179 """ 

180 

181 

182@dataclass 

183class MultipleConstraints: 

184 """ 

185 The MultipleConstraints class is used to define constraints on multiple bones, such as the minimum 

186 and maximum number of entries allowed and whether value duplicates are allowed. 

187 """ 

188 min: int = 0 

189 """An integer representing the lower bound of how many entries can be submitted (default: 0).""" 

190 max: int = 0 

191 """An integer representing the upper bound of how many entries can be submitted (default: 0 = unlimited).""" 

192 duplicates: bool = False 

193 """A boolean indicating if the same value can be used multiple times (default: False).""" 

194 sorted: bool | t.Callable = False 

195 """A boolean value or a method indicating if the value must be sorted (default: False).""" 

196 reversed: bool = False 

197 """ 

198 A boolean value indicating if sorted values shall be sorted in reversed order (default: False). 

199 It is only applied when the `sorted`-flag is set accordingly. 

200 """ 

201 

202 

203class ComputeMethod(Enum): 

204 Always = 0 

205 """Always compute on deserialization""" 

206 Lifetime = 1 

207 """Update only when given lifetime is outrun; value is only being stored when the skeleton is written""" 

208 Once = 2 

209 """Compute only once, when it is unset""" 

210 OnWrite = 3 

211 """Compute before every write of the skeleton""" 

212 

213 

214@dataclass 

215class ComputeInterval: 

216 method: ComputeMethod = ComputeMethod.Always 

217 """The compute-method to use for this bone""" 

218 lifetime: timedelta = None 

219 """Defines a timedelta until which the value stays valid (only used by `ComputeMethod.Lifetime`)""" 

220 

221 

222@dataclass 

223class Compute: 

224 fn: callable 

225 """The callable computing the value""" 

226 interval: ComputeInterval = field(default_factory=ComputeInterval) 

227 """The value caching interval""" 

228 raw: bool = True 

229 """Defines whether the value returned by fn is used as is, or is passed through `bone.fromClient()`""" 

230 

231 

232class CloneStrategy(enum.StrEnum): 

233 """Strategy for selecting the value of a cloned skeleton""" 

234 

235 SET_NULL = enum.auto() 

236 """Sets the cloned bone value to None.""" 

237 

238 SET_DEFAULT = enum.auto() 

239 """Sets the cloned bone value to its defaultValue.""" 

240 

241 SET_EMPTY = enum.auto() 

242 """Sets the cloned bone value to its emptyValue.""" 

243 

244 COPY_VALUE = enum.auto() 

245 """Copies the bone value from the source skeleton.""" 

246 

247 CUSTOM = enum.auto() 

248 """Uses a custom-defined logic for setting the cloned value. 

249 Requires :attr:`CloneBehavior.custom_func` to be set. 

250 """ 

251 

252 

253class CloneCustomFunc(t.Protocol): 

254 """Type for a custom clone function assigned to :attr:`CloneBehavior.custom_func`""" 

255 

256 def __call__(self, skel: "SkeletonInstance", src_skel: "SkeletonInstance", bone_name: str) -> t.Any: 

257 """Return the value for the cloned bone""" 

258 ... 

259 

260 

261@dataclass 

262class CloneBehavior: 

263 """Strategy configuration for selecting the value of a cloned skeleton""" 

264 

265 strategy: CloneStrategy 

266 """The strategy used to select a value from a cloned skeleton""" 

267 

268 custom_func: CloneCustomFunc = None 

269 """custom-defined logic for setting the cloned value 

270 Only required when :attr:`strategy` is set to :attr:`CloneStrategy.CUSTOM`. 

271 """ 

272 

273 def __post_init__(self): 

274 """Validate this configuration.""" 

275 if self.strategy == CloneStrategy.CUSTOM and self.custom_func is None: 275 ↛ 276line 275 didn't jump to line 276 because the condition on line 275 was never true

276 raise ValueError("CloneStrategy is CUSTOM, but custom_func is not set") 

277 elif self.strategy != CloneStrategy.CUSTOM and self.custom_func is not None: 277 ↛ 278line 277 didn't jump to line 278 because the condition on line 277 was never true

278 raise ValueError("custom_func is set, but CloneStrategy is not CUSTOM") 

279 

280 

281class BaseBone(object): 

282 """ 

283 The BaseBone class serves as the base class for all bone types in the ViUR framework. 

284 It defines the core functionality and properties that all bones should implement. 

285 

286 :param descr: Textual, human-readable description of that bone. Will be translated. 

287 :param defaultValue: If set, this bone will be preinitialized with this value 

288 :param required: If True, the user must enter a valid value for this bone (the viur.core refuses 

289 to save the skeleton otherwise). If a list/tuple of languages (strings) is provided, these 

290 language must be entered. 

291 :param multiple: If True, multiple values can be given. (ie. n:m relations instead of n:1) 

292 :param searchable: If True, this bone will be included in the fulltext search. Can be used 

293 without the need of also been indexed. 

294 :param type_suffix: Allows to specify an optional suffix for the bone-type, for bone customization 

295 :param vfunc: If given, a callable validating the user-supplied value for this bone. 

296 This callable must return None if the value is valid, a String containing an meaningful 

297 error-message for the user otherwise. 

298 :param readOnly: If True, the user is unable to change the value of this bone. If a value for this 

299 bone is given along the POST-Request during Add/Edit, this value will be ignored. Its still 

300 possible for the developer to modify this value by assigning skel.bone.value. 

301 :param visible: If False, the value of this bone should be hidden from the user. This does 

302 *not* protect the value from being exposed in a template, nor from being transferred 

303 to the client (ie to the admin or as hidden-value in html-form) 

304 :param compute: If set, the bone's value will be computed in the given method. 

305 

306 .. NOTE:: 

307 The kwarg 'multiple' is not supported by all bones 

308 """ 

309 type = "hidden" 

310 isClonedInstance = False 

311 

312 skel_cls = None 

313 """Skeleton class to which this bone instance belongs""" 

314 

315 name = None 

316 """Name of this bone (attribute name in the skeletons containing this bone)""" 

317 

318 def __init__( 

319 self, 

320 *, 

321 compute: Compute = None, 

322 defaultValue: t.Any = None, 

323 descr: t.Optional[str | i18n.translate] = None, 

324 getEmptyValueFunc: callable = None, 

325 indexed: bool = True, 

326 isEmptyFunc: callable = None, # fixme: Rename this, see below. 

327 languages: None | list[str] = None, 

328 multiple: bool | MultipleConstraints = False, 

329 params: dict = None, 

330 readOnly: bool = None, # fixme: Rename into readonly (all lowercase!) soon. 

331 required: bool | list[str] | tuple[str] = False, 

332 searchable: bool = False, 

333 type_suffix: str = "", 

334 unique: None | UniqueValue = None, 

335 vfunc: callable = None, # fixme: Rename this, see below. 

336 visible: bool = True, 

337 clone_behavior: CloneBehavior | CloneStrategy | None = None, 

338 ): 

339 """ 

340 Initializes a new Bone. 

341 """ 

342 self.isClonedInstance = getSystemInitialized() 

343 

344 # Standard definitions 

345 self.descr = descr 

346 self.params = params or {} 

347 self.multiple = multiple 

348 self.required = required 

349 self.readOnly = bool(readOnly) 

350 self.searchable = searchable 

351 self.visible = visible 

352 self.indexed = indexed 

353 

354 if type_suffix: 

355 self.type += f".{type_suffix}" 

356 

357 if conf.i18n.auto_translate_bones and isinstance(category := self.params.get("category"), str): 357 ↛ 358line 357 didn't jump to line 358 because the condition on line 357 was never true

358 self.params["category"] = i18n.translate(category, hint=f"category of a <{type(self).__name__}>") 

359 

360 # Multi-language support 

361 if not ( 361 ↛ 366line 361 didn't jump to line 366 because the condition on line 361 was never true

362 languages is None or 

363 (isinstance(languages, list) and len(languages) > 0 

364 and all([isinstance(x, str) for x in languages])) 

365 ): 

366 raise ValueError("languages must be None or a list of strings") 

367 

368 if languages and "__default__" in languages: 368 ↛ 369line 368 didn't jump to line 369 because the condition on line 368 was never true

369 raise ValueError("__default__ is not supported as a language") 

370 

371 if ( 371 ↛ 375line 371 didn't jump to line 375 because the condition on line 371 was never true

372 not isinstance(required, bool) 

373 and (not isinstance(required, (tuple, list)) or any(not isinstance(value, str) for value in required)) 

374 ): 

375 raise TypeError(f"required must be boolean or a tuple/list of strings. Got: {required!r}") 

376 

377 if isinstance(required, (tuple, list)) and not languages: 377 ↛ 378line 377 didn't jump to line 378 because the condition on line 377 was never true

378 raise ValueError("You set required to a list of languages, but defined no languages.") 

379 

380 if isinstance(required, (tuple, list)) and languages and (diff := set(required).difference(languages)): 380 ↛ 381line 380 didn't jump to line 381 because the condition on line 380 was never true

381 raise ValueError(f"The language(s) {', '.join(map(repr, diff))} can not be required, " 

382 f"because they're not defined.") 

383 

384 if callable(defaultValue): 

385 # check if the signature of defaultValue can bind two (fictive) parameters. 

386 try: 

387 inspect.signature(defaultValue).bind("skel", "bone") # the strings are just for the test! 

388 except TypeError: 

389 raise ValueError(f"Callable {defaultValue=} requires for the parameters 'skel' and 'bone'.") 

390 

391 self.languages = languages 

392 

393 # Default value 

394 # Convert a None default-value to the empty container that's expected if the bone is 

395 # multiple or has languages 

396 default = [] if defaultValue is None and self.multiple else defaultValue 

397 if self.languages: 

398 if callable(defaultValue): 398 ↛ 399line 398 didn't jump to line 399 because the condition on line 398 was never true

399 self.defaultValue = defaultValue 

400 elif not isinstance(defaultValue, dict): 

401 self.defaultValue = {lang: default for lang in self.languages} 

402 elif "__default__" in defaultValue: 402 ↛ 406line 402 didn't jump to line 406 because the condition on line 402 was always true

403 self.defaultValue = {lang: defaultValue.get(lang, defaultValue["__default__"]) 

404 for lang in self.languages} 

405 else: 

406 self.defaultValue = defaultValue # default will have the same value at this point 

407 else: 

408 self.defaultValue = default 

409 

410 # Unique values 

411 if unique: 411 ↛ 412line 411 didn't jump to line 412 because the condition on line 411 was never true

412 if not isinstance(unique, UniqueValue): 

413 raise ValueError("Unique must be an instance of UniqueValue") 

414 if not self.multiple and unique.method.value != 1: 

415 raise ValueError("'SameValue' is the only valid method on non-multiple bones") 

416 

417 self.unique = unique 

418 

419 # Overwrite some validations and value functions by parameter instead of subclassing 

420 # todo: This can be done better and more straightforward. 

421 if vfunc: 

422 self.isInvalid = vfunc # fixme: why is this called just vfunc, and not isInvalidValue/isInvalidValueFunc? 

423 

424 if isEmptyFunc: 424 ↛ 425line 424 didn't jump to line 425 because the condition on line 424 was never true

425 self.isEmpty = isEmptyFunc # fixme: why is this not called isEmptyValue/isEmptyValueFunc? 

426 

427 if getEmptyValueFunc: 

428 self.getEmptyValue = getEmptyValueFunc 

429 

430 if compute: 

431 if not isinstance(compute, Compute): 431 ↛ 432line 431 didn't jump to line 432 because the condition on line 431 was never true

432 raise TypeError("compute must be an instanceof of Compute") 

433 if not isinstance(compute.fn, t.Callable): 433 ↛ 434line 433 didn't jump to line 434 because the condition on line 433 was never true

434 raise ValueError("'compute.fn' must be callable") 

435 # When readOnly is None, handle flag automatically 

436 if readOnly is None: 

437 self.readOnly = True 

438 if not self.readOnly: 438 ↛ 439line 438 didn't jump to line 439 because the condition on line 438 was never true

439 raise ValueError("'compute' can only be used with bones configured as `readOnly=True`") 

440 

441 if ( 441 ↛ 445line 441 didn't jump to line 445 because the condition on line 441 was never true

442 compute.interval.method == ComputeMethod.Lifetime 

443 and not isinstance(compute.interval.lifetime, timedelta) 

444 ): 

445 raise ValueError( 

446 f"'compute' is configured as ComputeMethod.Lifetime, but {compute.interval.lifetime=} was specified" 

447 ) 

448 # If a RelationalBone is computed and raw is False, the unserialize function is called recursively 

449 # and the value is recalculated all the time. This parameter is to prevent this. 

450 self._prevent_compute = False 

451 

452 self.compute = compute 

453 

454 if clone_behavior is None: # auto choose 

455 if self.unique and self.readOnly: 455 ↛ 456line 455 didn't jump to line 456 because the condition on line 455 was never true

456 self.clone_behavior = CloneBehavior(CloneStrategy.SET_DEFAULT) 

457 else: 

458 self.clone_behavior = CloneBehavior(CloneStrategy.COPY_VALUE) 

459 # TODO: Any different setting for computed bones? 

460 elif isinstance(clone_behavior, CloneStrategy): 460 ↛ 461line 460 didn't jump to line 461 because the condition on line 460 was never true

461 self.clone_behavior = CloneBehavior(strategy=clone_behavior) 

462 elif isinstance(clone_behavior, CloneBehavior): 462 ↛ 465line 462 didn't jump to line 465 because the condition on line 462 was always true

463 self.clone_behavior = clone_behavior 

464 else: 

465 raise TypeError(f"'clone_behavior' must be an instance of Clone, but {clone_behavior=} was specified") 

466 

467 def __set_name__(self, owner: "Skeleton", name: str) -> None: 

468 self.skel_cls = owner 

469 self.name = name 

470 

471 def setSystemInitialized(self) -> None: 

472 """ 

473 For the BaseBone, this performs some automatisms regarding bone descr and translations. 

474 It can be overwritten to initialize properties that depend on the Skeleton system being initialized. 

475 """ 

476 

477 # Set descr to the bone_name if no descr argument is given 

478 if self.descr is None: 

479 # TODO: The super().__setattr__() call is kinda hackish, 

480 # but unfortunately viur-core has no *during system initialisation* state 

481 super().__setattr__("descr", self.name or "") 

482 

483 if conf.i18n.auto_translate_bones and self.descr and isinstance(self.descr, str): 

484 # Make sure that it is a :class:i18n.translate` object. 

485 super().__setattr__( 

486 "descr", 

487 i18n.translate(self.descr, hint=f"descr of a <{type(self).__name__}>{self.name}") 

488 ) 

489 

490 def isInvalid(self, value): 

491 """ 

492 Checks if the current value of the bone in the given skeleton is invalid. 

493 Returns None if the value would be valid for this bone, an error-message otherwise. 

494 """ 

495 return False 

496 

497 def isEmpty(self, value: t.Any) -> bool: 

498 """ 

499 Check if the given single value represents the "empty" value. 

500 This usually is the empty string, 0 or False. 

501 

502 .. warning:: isEmpty takes precedence over isInvalid! The empty value is always 

503 valid - unless the bone is required. 

504 But even then the empty value will be reflected back to the client. 

505 

506 .. warning:: value might be the string/object received from the user (untrusted 

507 input!) or the value returned by get 

508 """ 

509 return not bool(value) 

510 

511 def getDefaultValue(self, skeletonInstance): 

512 """ 

513 Retrieves the default value for the bone. 

514 

515 This method is called by the framework to obtain the default value of a bone when no value 

516 is provided. Derived bone classes can overwrite this method to implement their own logic for 

517 providing a default value. 

518 

519 :return: The default value of the bone, which can be of any data type. 

520 """ 

521 if callable(self.defaultValue): 521 ↛ 522line 521 didn't jump to line 522 because the condition on line 521 was never true

522 res = self.defaultValue(skeletonInstance, self) 

523 if self.languages and self.multiple: 

524 if not isinstance(res, dict): 

525 if not isinstance(res, (list, set, tuple)): 

526 return {lang: [res] for lang in self.languages} 

527 else: 

528 return {lang: res for lang in self.languages} 

529 elif self.languages: 

530 if not isinstance(res, dict): 

531 return {lang: res for lang in self.languages} 

532 elif self.multiple: 

533 if not isinstance(res, (list, set, tuple)): 

534 return [res] 

535 return res 

536 

537 elif isinstance(self.defaultValue, list): 

538 return self.defaultValue[:] 

539 elif isinstance(self.defaultValue, dict): 539 ↛ 548line 539 didn't jump to line 548 because the condition on line 539 was always true

540 # A shallow dict copy is not enough: the inner lists must be copied as well, 

541 # otherwise all languages share one list instance living on the bone, and any 

542 # mutation bleeds into the other languages and into every other skeleton. 

543 return { 

544 lang: value[:] if isinstance(value, list) else value 

545 for lang, value in self.defaultValue.items() 

546 } 

547 else: 

548 return self.defaultValue 

549 

550 def getEmptyValue(self) -> t.Any: 

551 """ 

552 Returns the value representing an empty field for this bone. 

553 This might be the empty string for str/text Bones, Zero for numeric bones etc. 

554 """ 

555 return None 

556 

557 def __setattr__(self, key, value): 

558 """ 

559 Custom attribute setter for the BaseBone class. 

560 

561 This method is used to ensure that certain bone attributes, such as 'multiple', are only 

562 set once during the bone's lifetime. Derived bone classes should not need to overwrite this 

563 method unless they have additional attributes with similar constraints. 

564 

565 :param key: A string representing the attribute name. 

566 :param value: The value to be assigned to the attribute. 

567 

568 :raises AttributeError: If a protected attribute is attempted to be modified after its initial 

569 assignment. 

570 """ 

571 if not self.isClonedInstance and getSystemInitialized() and key != "isClonedInstance" and not key.startswith( 571 ↛ 573line 571 didn't jump to line 573 because the condition on line 571 was never true

572 "_"): 

573 raise AttributeError("You cannot modify this Skeleton. Grab a copy using .clone() first") 

574 super().__setattr__(key, value) 

575 

576 def collectRawClientData(self, name, data, multiple, languages, collectSubfields): 

577 """ 

578 Collects raw client data for the bone and returns it in a dictionary. 

579 

580 This method is called by the framework to gather raw data from the client, such as form data or data from a 

581 request. Derived bone classes should overwrite this method to implement their own logic for collecting raw data. 

582 

583 :param name: A string representing the bone's name. 

584 :param data: A dictionary containing the raw data from the client. 

585 :param multiple: A boolean indicating whether the bone supports multiple values. 

586 :param languages: An optional list of strings representing the supported languages (default: None). 

587 :param collectSubfields: A boolean indicating whether to collect data for subfields (default: False). 

588 

589 :return: A dictionary containing the collected raw client data. 

590 """ 

591 fieldSubmitted = False 

592 

593 if languages: 

594 res = {} 

595 for lang in languages: 

596 if not collectSubfields: 596 ↛ 608line 596 didn't jump to line 608 because the condition on line 596 was always true

597 if f"{name}.{lang}" in data: 

598 fieldSubmitted = True 

599 res[lang] = data[f"{name}.{lang}"] 

600 if multiple and not isinstance(res[lang], list): 600 ↛ 601line 600 didn't jump to line 601 because the condition on line 600 was never true

601 res[lang] = [res[lang]] 

602 elif not multiple and isinstance(res[lang], list): 602 ↛ 603line 602 didn't jump to line 603 because the condition on line 602 was never true

603 if res[lang]: 

604 res[lang] = res[lang][0] 

605 else: 

606 res[lang] = None 

607 else: 

608 for key in data.keys(): # Allow setting relations with using, multiple and languages back to none 

609 if key == f"{name}.{lang}": 

610 fieldSubmitted = True 

611 prefix = f"{name}.{lang}." 

612 if multiple: 

613 tmpDict = {} 

614 for key, value in data.items(): 

615 if not key.startswith(prefix): 

616 continue 

617 fieldSubmitted = True 

618 partKey = key[len(prefix):] 

619 firstKey, remainingKey = partKey.split(".", maxsplit=1) 

620 try: 

621 firstKey = int(firstKey) 

622 except: 

623 continue 

624 if firstKey not in tmpDict: 

625 tmpDict[firstKey] = {} 

626 tmpDict[firstKey][remainingKey] = value 

627 tmpList = list(tmpDict.items()) 

628 tmpList.sort(key=lambda x: x[0]) 

629 res[lang] = [x[1] for x in tmpList] 

630 else: 

631 tmpDict = {} 

632 for key, value in data.items(): 

633 if not key.startswith(prefix): 

634 continue 

635 fieldSubmitted = True 

636 partKey = key[len(prefix):] 

637 tmpDict[partKey] = value 

638 res[lang] = tmpDict 

639 return res, fieldSubmitted 

640 else: # No multi-lang 

641 if not collectSubfields: 641 ↛ 655line 641 didn't jump to line 655 because the condition on line 641 was always true

642 if name not in data: # Empty! 642 ↛ 643line 642 didn't jump to line 643 because the condition on line 642 was never true

643 return None, False 

644 val = data[name] 

645 if multiple and not isinstance(val, list): 645 ↛ 646line 645 didn't jump to line 646 because the condition on line 645 was never true

646 return [val], True 

647 elif not multiple and isinstance(val, list): 647 ↛ 648line 647 didn't jump to line 648 because the condition on line 647 was never true

648 if val: 

649 return val[0], True 

650 else: 

651 return None, True # Empty! 

652 else: 

653 return val, True 

654 else: # No multi-lang but collect subfields 

655 for key in data.keys(): # Allow setting relations with using, multiple and languages back to none 

656 if key == name: 

657 fieldSubmitted = True 

658 prefix = f"{name}." 

659 if multiple: 

660 tmpDict = {} 

661 for key, value in data.items(): 

662 if not key.startswith(prefix): 

663 continue 

664 fieldSubmitted = True 

665 partKey = key[len(prefix):] 

666 try: 

667 firstKey, remainingKey = partKey.split(".", maxsplit=1) 

668 firstKey = int(firstKey) 

669 except: 

670 continue 

671 if firstKey not in tmpDict: 

672 tmpDict[firstKey] = {} 

673 tmpDict[firstKey][remainingKey] = value 

674 tmpList = list(tmpDict.items()) 

675 tmpList.sort(key=lambda x: x[0]) 

676 return [x[1] for x in tmpList], fieldSubmitted 

677 else: 

678 res = {} 

679 for key, value in data.items(): 

680 if not key.startswith(prefix): 

681 continue 

682 fieldSubmitted = True 

683 subKey = key[len(prefix):] 

684 res[subKey] = value 

685 return res, fieldSubmitted 

686 

687 def parseSubfieldsFromClient(self) -> bool: 

688 """ 

689 Determines whether the function should parse subfields submitted by the client. 

690 Set to True only when expecting a list of dictionaries to be transmitted. 

691 """ 

692 return False 

693 

694 def singleValueFromClient(self, value: t.Any, skel: 'SkeletonInstance', 

695 bone_name: str, client_data: dict 

696 ) -> tuple[t.Any, list[ReadFromClientError] | None]: 

697 """Load a single value from a client 

698 

699 :param value: The single value which should be loaded. 

700 :param skel: The SkeletonInstance where the value should be loaded into. 

701 :param bone_name: The bone name of this bone in the SkeletonInstance. 

702 :param client_data: The data taken from the client, 

703 a dictionary with usually bone names as key 

704 :return: A tuple. If the value is valid, the first element is 

705 the parsed value and the second is None. 

706 If the value is invalid or not parseable, the first element is a empty value 

707 and the second a list of *ReadFromClientError*. 

708 """ 

709 # The BaseBone will not read any client_data in fromClient. Use rawValueBone if needed. 

710 return self.getEmptyValue(), [ 

711 ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Will not read a BaseBone from client!") 

712 ] 

713 

714 def fromClient(self, skel: 'SkeletonInstance', name: str, data: dict) -> None | list[ReadFromClientError]: 

715 """ 

716 Reads a value from the client and stores it in the skeleton instance if it is valid for the bone. 

717 

718 This function reads a value from the client and processes it according to the bone's configuration. 

719 If the value is valid for the bone, it stores the value in the skeleton instance and returns None. 

720 Otherwise, the previous value remains unchanged, and a list of ReadFromClientError objects is returned. 

721 

722 :param skel: A SkeletonInstance object where the values should be loaded. 

723 :param name: A string representing the bone's name. 

724 :param data: A dictionary containing the raw data from the client. 

725 :return: None if no errors occurred, otherwise a list of ReadFromClientError objects. 

726 """ 

727 subFields = self.parseSubfieldsFromClient() 

728 parsedData, fieldSubmitted = self.collectRawClientData(name, data, self.multiple, self.languages, subFields) 

729 if not fieldSubmitted: 729 ↛ 730line 729 didn't jump to line 730 because the condition on line 729 was never true

730 return [ReadFromClientError(ReadFromClientErrorSeverity.NotSet)] 

731 

732 errors = [] 

733 isEmpty = True 

734 filled_languages = set() 

735 if self.languages and self.multiple: 

736 res = {} 

737 for language in self.languages: 

738 res[language] = [] 

739 if language in parsedData: 

740 for idx, singleValue in enumerate(parsedData[language]): 

741 if self.isEmpty(singleValue): 741 ↛ 742line 741 didn't jump to line 742 because the condition on line 741 was never true

742 continue 

743 isEmpty = False 

744 filled_languages.add(language) 

745 parsedVal, parseErrors = self.singleValueFromClient(singleValue, skel, name, data) 

746 res[language].append(parsedVal) 

747 if isinstance(self.multiple, MultipleConstraints) and self.multiple.sorted: 747 ↛ 748line 747 didn't jump to line 748 because the condition on line 747 was never true

748 if callable(self.multiple.sorted): 

749 res[language] = sorted( 

750 res[language], 

751 key=self.multiple.sorted, 

752 reverse=self.multiple.reversed, 

753 ) 

754 else: 

755 res[language] = sorted(res[language], reverse=self.multiple.reversed) 

756 if parseErrors: 756 ↛ 757line 756 didn't jump to line 757 because the condition on line 756 was never true

757 for parseError in parseErrors: 

758 parseError.fieldPath[:0] = [language, str(idx)] 

759 errors.extend(parseErrors) 

760 elif self.languages: # and not self.multiple is implicit - this would have been handled above 

761 res = {} 

762 for language in self.languages: 

763 res[language] = None 

764 if language in parsedData: 

765 if self.isEmpty(parsedData[language]): 765 ↛ 766line 765 didn't jump to line 766 because the condition on line 765 was never true

766 res[language] = self.getEmptyValue() 

767 continue 

768 isEmpty = False 

769 filled_languages.add(language) 

770 parsedVal, parseErrors = self.singleValueFromClient(parsedData[language], skel, name, data) 

771 res[language] = parsedVal 

772 if parseErrors: 772 ↛ 773line 772 didn't jump to line 773 because the condition on line 772 was never true

773 for parseError in parseErrors: 

774 parseError.fieldPath.insert(0, language) 

775 errors.extend(parseErrors) 

776 elif self.multiple: # and not self.languages is implicit - this would have been handled above 

777 res = [] 

778 for idx, singleValue in enumerate(parsedData): 

779 if self.isEmpty(singleValue): 779 ↛ 780line 779 didn't jump to line 780 because the condition on line 779 was never true

780 continue 

781 isEmpty = False 

782 parsedVal, parseErrors = self.singleValueFromClient(singleValue, skel, name, data) 

783 res.append(parsedVal) 

784 

785 if parseErrors: 785 ↛ 786line 785 didn't jump to line 786 because the condition on line 785 was never true

786 for parseError in parseErrors: 

787 parseError.fieldPath.insert(0, str(idx)) 

788 errors.extend(parseErrors) 

789 if isinstance(self.multiple, MultipleConstraints) and self.multiple.sorted: 789 ↛ 790line 789 didn't jump to line 790 because the condition on line 789 was never true

790 if callable(self.multiple.sorted): 

791 res = sorted(res, key=self.multiple.sorted, reverse=self.multiple.reversed) 

792 else: 

793 res = sorted(res, reverse=self.multiple.reversed) 

794 else: # No Languages, not multiple 

795 if self.isEmpty(parsedData): 

796 res = self.getEmptyValue() 

797 isEmpty = True 

798 else: 

799 isEmpty = False 

800 res, parseErrors = self.singleValueFromClient(parsedData, skel, name, data) 

801 if parseErrors: 

802 errors.extend(parseErrors) 

803 skel[name] = res 

804 if self.languages and isinstance(self.required, (list, tuple)): 804 ↛ 805line 804 didn't jump to line 805 because the condition on line 804 was never true

805 missing = set(self.required).difference(filled_languages) 

806 if missing: 

807 return [ 

808 ReadFromClientError(ReadFromClientErrorSeverity.Empty, fieldPath=[lang]) 

809 for lang in missing 

810 ] 

811 

812 if isEmpty: 

813 return [ReadFromClientError(ReadFromClientErrorSeverity.Empty)] 

814 

815 # Check multiple constraints on demand 

816 if self.multiple and isinstance(self.multiple, MultipleConstraints): 816 ↛ 817line 816 didn't jump to line 817 because the condition on line 816 was never true

817 errors.extend(self._validate_multiple_contraints(self.multiple, skel, name)) 

818 

819 return errors or None 

820 

821 def _get_single_destinct_hash(self, value) -> t.Any: 

822 """ 

823 Returns a distinct hash value for a single entry of this bone. 

824 The returned value must be hashable. 

825 """ 

826 return value 

827 

828 def _get_destinct_hash(self, skel: 'SkeletonInstance', name: str) -> t.Any: 

829 """ 

830 Returns a distinct hash value for this bone. 

831 The returned value must be hashable. 

832 """ 

833 values = [] 

834 for _, _, value in self.iter_bone_value(skel, name): 

835 values.append(self._get_single_destinct_hash(value)) 

836 

837 return tuple(values) 

838 

839 def _validate_multiple_contraints( 

840 self, 

841 constraints: MultipleConstraints, 

842 skel: 'SkeletonInstance', 

843 name: str 

844 ) -> list[ReadFromClientError]: 

845 """ 

846 Validates the value of a bone against its multiple constraints and returns a list of ReadFromClientError 

847 objects for each violation, such as too many items or duplicates. 

848 

849 :param constraints: The MultipleConstraints definition to apply. 

850 :param skel: A SkeletonInstance object where the values should be validated. 

851 :param name: A string representing the bone's name. 

852 :return: A list of ReadFromClientError objects for each constraint violation. 

853 """ 

854 res = [] 

855 value = self._get_destinct_hash(skel, name) 

856 

857 if constraints.min and len(value) < constraints.min: 

858 res.append( 

859 ReadFromClientError( 

860 ReadFromClientErrorSeverity.Invalid, 

861 i18n.translate("core.bones.error.toofewitems", "Too few items") 

862 ) 

863 ) 

864 

865 if constraints.max and len(value) > constraints.max: 

866 res.append( 

867 ReadFromClientError( 

868 ReadFromClientErrorSeverity.Invalid, 

869 i18n.translate("core.bones.error.toomanyitems", "Too many items") 

870 ) 

871 ) 

872 

873 if not constraints.duplicates: 

874 if len(set(value)) != len(value): 

875 res.append( 

876 ReadFromClientError( 

877 ReadFromClientErrorSeverity.Invalid, 

878 i18n.translate("core.bones.error.duplicateitems", "Duplicate items"), 

879 ) 

880 ) 

881 

882 return res 

883 

884 def singleValueSerialize(self, value, skel: 'SkeletonInstance', name: str, parentIndexed: bool): 

885 """ 

886 Serializes a single value of the bone for storage in the database. 

887 

888 Derived bone classes should overwrite this method to implement their own logic for serializing single 

889 values. 

890 The serialized value should be suitable for storage in the database. 

891 """ 

892 return value 

893 

894 def serialize(self, skel: 'SkeletonInstance', name: str, parentIndexed: bool) -> bool: 

895 """ 

896 Serializes this bone into a format that can be written into the datastore. 

897 

898 :param skel: A SkeletonInstance object containing the values to be serialized. 

899 :param name: A string representing the property name of the bone in its Skeleton (not the description). 

900 :param parentIndexed: A boolean indicating whether the parent bone is indexed. 

901 :return: A boolean indicating whether the serialization was successful. 

902 """ 

903 self.serialize_compute(skel, name) 

904 

905 if name in skel.accessedValues: 

906 empty_value = self.getEmptyValue() 

907 newVal = skel.accessedValues[name] 

908 

909 if self.languages and self.multiple: 

910 res = db.Entity() 

911 res["_viurLanguageWrapper_"] = True 

912 for language in self.languages: 

913 res[language] = [] 

914 if not self.indexed: 

915 res.exclude_from_indexes.add(language) 

916 if language in newVal: 

917 for singleValue in newVal[language]: 

918 value = self.singleValueSerialize(singleValue, skel, name, parentIndexed) 

919 if value != empty_value: 

920 res[language].append(value) 

921 

922 elif self.languages: 

923 res = db.Entity() 

924 res["_viurLanguageWrapper_"] = True 

925 for language in self.languages: 

926 res[language] = None 

927 if not self.indexed: 

928 res.exclude_from_indexes.add(language) 

929 if language in newVal: 

930 res[language] = self.singleValueSerialize(newVal[language], skel, name, parentIndexed) 

931 

932 elif self.multiple: 

933 res = [] 

934 

935 assert newVal is None or isinstance(newVal, (list, tuple)), \ 

936 f"Cannot handle {repr(newVal)} here. Expecting list or tuple." 

937 

938 for singleValue in (newVal or ()): 

939 value = self.singleValueSerialize(singleValue, skel, name, parentIndexed) 

940 if value != empty_value: 

941 res.append(value) 

942 

943 else: # No Languages, not Multiple 

944 res = self.singleValueSerialize(newVal, skel, name, parentIndexed) 

945 

946 skel.dbEntity[name] = res 

947 

948 # Ensure our indexed flag is up2date 

949 indexed = self.indexed and parentIndexed 

950 if indexed and name in skel.dbEntity.exclude_from_indexes: 

951 skel.dbEntity.exclude_from_indexes.discard(name) 

952 elif not indexed and name not in skel.dbEntity.exclude_from_indexes: 

953 skel.dbEntity.exclude_from_indexes.add(name) 

954 return True 

955 return False 

956 

957 def serialize_compute(self, skel: "SkeletonInstance", name: str) -> None: 

958 """ 

959 This function checks whether a bone is computed and if this is the case, it attempts to serialize the 

960 value with the appropriate calculation method 

961 

962 :param skel: The SkeletonInstance where the current bone is located 

963 :param name: The name of the bone in the Skeleton 

964 """ 

965 if not self.compute: 

966 return None 

967 

968 match self.compute.interval.method: 

969 case ComputeMethod.OnWrite: 

970 skel.accessedValues[name] = self._compute(skel, name) 

971 

972 case ComputeMethod.Lifetime: 

973 now = utils.utcNow() 

974 

975 last_update = \ 

976 skel.accessedValues.get(f"_viur_compute_{name}_") \ 

977 or skel.dbEntity.get(f"_viur_compute_{name}_") 

978 

979 if not last_update or last_update + self.compute.interval.lifetime < now: 

980 skel.accessedValues[name] = self._compute(skel, name) 

981 skel.dbEntity[f"_viur_compute_{name}_"] = now 

982 

983 case ComputeMethod.Once: 

984 if name not in skel.dbEntity: 

985 skel.accessedValues[name] = self._compute(skel, name) 

986 

987 def singleValueUnserialize(self, val): 

988 """ 

989 Unserializes a single value of the bone from the stored database value. 

990 

991 Derived bone classes should overwrite this method to implement their own logic for unserializing 

992 single values. The unserialized value should be suitable for use in the application logic. 

993 """ 

994 return val 

995 

996 def unserialize(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> bool: 

997 """ 

998 Deserialize bone data from the datastore and populate the bone with the deserialized values. 

999 

1000 This function is the inverse of the serialize function. It converts data from the datastore 

1001 into a format that can be used by the bones in the skeleton. 

1002 

1003 :param skel: A SkeletonInstance object containing the values to be deserialized. 

1004 :param name: The property name of the bone in its Skeleton (not the description). 

1005 :returns: True if deserialization is successful, False otherwise. 

1006 """ 

1007 if name in skel.dbEntity: 

1008 loadVal = skel.dbEntity[name] 

1009 elif ( 

1010 # fixme: Remove this piece of sh*t at least with VIUR4 

1011 # We're importing from an old ViUR2 instance - there may only be keys prefixed with our name 

1012 conf.viur2import_blobsource and any(n.startswith(name + ".") for n in skel.dbEntity) 

1013 # ... or computed 

1014 or self.compute 

1015 ): 

1016 loadVal = None 

1017 else: 

1018 skel.accessedValues[name] = self.getDefaultValue(skel) 

1019 return False 

1020 

1021 if self.unserialize_compute(skel, name): 

1022 return True 

1023 

1024 # unserialize value to given config 

1025 if self.languages and self.multiple: 

1026 res = {} 

1027 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal: 

1028 for language in self.languages: 

1029 res[language] = [] 

1030 if language in loadVal: 

1031 tmpVal = loadVal[language] 

1032 if not isinstance(tmpVal, list): 

1033 tmpVal = [tmpVal] 

1034 for singleValue in tmpVal: 

1035 res[language].append(self.singleValueUnserialize(singleValue)) 

1036 else: # We could not parse this, maybe it has been written before languages had been set? 

1037 for language in self.languages: 

1038 res[language] = [] 

1039 mainLang = self.languages[0] 

1040 if loadVal is None: 

1041 pass 

1042 elif isinstance(loadVal, list): 

1043 for singleValue in loadVal: 

1044 res[mainLang].append(self.singleValueUnserialize(singleValue)) 

1045 else: # Hopefully it's a value stored before languages and multiple has been set 

1046 res[mainLang].append(self.singleValueUnserialize(loadVal)) 

1047 elif self.languages: 

1048 res = {} 

1049 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal: 

1050 for language in self.languages: 

1051 res[language] = None 

1052 if language in loadVal: 

1053 tmpVal = loadVal[language] 

1054 if isinstance(tmpVal, list) and tmpVal: 

1055 tmpVal = tmpVal[0] 

1056 res[language] = self.singleValueUnserialize(tmpVal) 

1057 else: # We could not parse this, maybe it has been written before languages had been set? 

1058 for language in self.languages: 

1059 res[language] = None 

1060 oldKey = f"{name}.{language}" 

1061 if oldKey in skel.dbEntity and skel.dbEntity[oldKey]: 

1062 res[language] = self.singleValueUnserialize(skel.dbEntity[oldKey]) 

1063 loadVal = None # Don't try to import later again, this format takes precedence 

1064 mainLang = self.languages[0] 

1065 if loadVal is None: 

1066 pass 

1067 elif isinstance(loadVal, list) and loadVal: 

1068 res[mainLang] = self.singleValueUnserialize(loadVal) 

1069 else: # Hopefully it's a value stored before languages and multiple has been set 

1070 res[mainLang] = self.singleValueUnserialize(loadVal) 

1071 elif self.multiple: 

1072 res = [] 

1073 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal: 

1074 # Pick one language we'll use 

1075 if conf.i18n.default_language in loadVal: 

1076 loadVal = loadVal[conf.i18n.default_language] 

1077 else: 

1078 loadVal = [x for x in loadVal.values() if x is not True] 

1079 if loadVal and not isinstance(loadVal, list): 

1080 loadVal = [loadVal] 

1081 if loadVal: 

1082 for val in loadVal: 

1083 res.append(self.singleValueUnserialize(val)) 

1084 else: # Not multiple, no languages 

1085 res = None 

1086 if isinstance(loadVal, dict) and "_viurLanguageWrapper_" in loadVal: 

1087 # Pick one language we'll use 

1088 if conf.i18n.default_language in loadVal: 

1089 loadVal = loadVal[conf.i18n.default_language] 

1090 else: 

1091 loadVal = [x for x in loadVal.values() if x is not True] 

1092 if loadVal and isinstance(loadVal, list): 

1093 loadVal = loadVal[0] 

1094 if loadVal is not None: 

1095 res = self.singleValueUnserialize(loadVal) 

1096 

1097 skel.accessedValues[name] = res 

1098 return True 

1099 

1100 def unserialize_compute(self, skel: "SkeletonInstance", name: str) -> bool: 

1101 """ 

1102 This function checks whether a bone is computed and if this is the case, it attempts to deserialise the 

1103 value with the appropriate calculation method 

1104 

1105 :param skel : The SkeletonInstance where the current Bone is located 

1106 :param name: The name of the Bone in the Skeleton 

1107 :return: True if the Bone was unserialized, False otherwise 

1108 """ 

1109 if not self.compute or self._prevent_compute or skel._cascade_deletion: 

1110 return False 

1111 

1112 match self.compute.interval.method: 

1113 # Computation is bound to a lifetime? 

1114 case ComputeMethod.Lifetime: 

1115 now = utils.utcNow() 

1116 from viur.core.skeleton import RefSkel # noqa: E402 # import works only here because circular imports 

1117 

1118 if skel["key"] and skel.dbEntity: 

1119 if issubclass(skel.skeletonCls, RefSkel): # we have a ref skel we must load the complete Entity 

1120 db_obj = db.get(skel["key"]) 

1121 last_update = db_obj.get(f"_viur_compute_{name}_") 

1122 else: 

1123 last_update = skel.dbEntity.get(f"_viur_compute_{name}_") 

1124 skel.accessedValues[f"_viur_compute_{name}_"] = last_update or now 

1125 

1126 if not last_update or last_update + self.compute.interval.lifetime <= now: 

1127 # if so, recompute and refresh updated value 

1128 skel.accessedValues[name] = value = self._compute(skel, name) 

1129 

1130 def transact(): 

1131 db_obj = db.get(skel["key"]) 

1132 db_obj[f"_viur_compute_{name}_"] = now 

1133 db_obj[name] = value 

1134 db.put(db_obj) 

1135 

1136 if db.is_in_transaction(): 

1137 transact() 

1138 else: 

1139 db.run_in_transaction(transact) 

1140 

1141 else: 

1142 # Run like ComputeMethod.Always on unwritten skeleton 

1143 skel.accessedValues[name] = self._compute(skel, name) 

1144 

1145 return True 

1146 

1147 # Compute on every deserialization 

1148 case ComputeMethod.Always: 

1149 skel.accessedValues[name] = self._compute(skel, name) 

1150 return True 

1151 

1152 return False 

1153 

1154 def delete(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str): 

1155 """ 

1156 Like postDeletedHandler, but runs inside the transaction 

1157 """ 

1158 pass 

1159 

1160 def buildDBFilter(self, 

1161 name: str, 

1162 skel: 'viur.core.skeleton.SkeletonInstance', 

1163 dbFilter: db.Query, 

1164 rawFilter: dict, 

1165 prefix: t.Optional[str] = None) -> db.Query: 

1166 """ 

1167 Parses the searchfilter a client specified in his Request into 

1168 something understood by the datastore. 

1169 This function must: 

1170 

1171 * - Ignore all filters not targeting this bone 

1172 * - Safely handle malformed data in rawFilter (this parameter is directly controlled by the client) 

1173 

1174 :param name: The property-name this bone has in its Skeleton (not the description!) 

1175 :param skel: The :class:`viur.core.db.Query` this bone is part of 

1176 :param dbFilter: The current :class:`viur.core.db.Query` instance the filters should be applied to 

1177 :param rawFilter: The dictionary of filters the client wants to have applied 

1178 :returns: The modified :class:`viur.core.db.Query` 

1179 """ 

1180 myKeys = [key for key in rawFilter.keys() if (key == name or key.startswith(name + "$"))] 

1181 

1182 if len(myKeys) == 0: 

1183 return dbFilter 

1184 

1185 for key in myKeys: 

1186 value = rawFilter[key] 

1187 tmpdata = key.split("$") 

1188 

1189 if len(tmpdata) > 1: 

1190 if isinstance(value, list): 

1191 continue 

1192 if tmpdata[1] == "lt": 

1193 dbFilter.filter((prefix or "") + tmpdata[0] + " <", value) 

1194 elif tmpdata[1] == "le": 

1195 dbFilter.filter((prefix or "") + tmpdata[0] + " <=", value) 

1196 elif tmpdata[1] == "gt": 

1197 dbFilter.filter((prefix or "") + tmpdata[0] + " >", value) 

1198 elif tmpdata[1] == "ge": 

1199 dbFilter.filter((prefix or "") + tmpdata[0] + " >=", value) 

1200 elif tmpdata[1] == "lk": 

1201 dbFilter.filter((prefix or "") + tmpdata[0] + " =", value) 

1202 else: 

1203 dbFilter.filter((prefix or "") + tmpdata[0] + " =", value) 

1204 else: 

1205 if isinstance(value, list): 

1206 dbFilter.filter((prefix or "") + key + " IN", value) 

1207 else: 

1208 dbFilter.filter((prefix or "") + key + " =", value) 

1209 

1210 return dbFilter 

1211 

1212 def buildDBSort( 

1213 self, 

1214 name: str, 

1215 skel: "SkeletonInstance", 

1216 query: db.Query, 

1217 params: dict, 

1218 postfix: str = "", 

1219 ) -> t.Optional[db.Query]: 

1220 """ 

1221 Same as buildDBFilter, but this time its not about filtering 

1222 the results, but by sorting them. 

1223 Again: query is controlled by the client, so you *must* expect and safely handle 

1224 malformed data! 

1225 

1226 :param name: The property-name this bone has in its Skeleton (not the description!) 

1227 :param skel: The :class:`viur.core.skeleton.Skeleton` instance this bone is part of 

1228 :param dbFilter: The current :class:`viur.core.db.Query` instance the filters should 

1229 be applied to 

1230 :param query: The dictionary of filters the client wants to have applied 

1231 :param postfix: Inherited classes may use this to add a postfix to the porperty name 

1232 :returns: The modified :class:`viur.core.db.Query`, 

1233 None if the query is unsatisfiable. 

1234 """ 

1235 if query.queries and (orderby := params.get("orderby")) and utils.string.is_prefix(orderby, name): 

1236 if self.languages: 

1237 lang = None 

1238 prefix = f"{name}." 

1239 if orderby.startswith(prefix): 

1240 lng = orderby[len(prefix):] 

1241 if lng in self.languages: 

1242 lang = lng 

1243 

1244 if lang is None: 

1245 lang = current.language.get() 

1246 if not lang or lang not in self.languages: 

1247 lang = self.languages[0] 

1248 

1249 prop = f"{name}.{lang}" 

1250 else: 

1251 prop = name 

1252 

1253 # In case this is a multiple query, check if all filters are valid 

1254 if isinstance(query.queries, list): 

1255 in_eq_filter = None 

1256 

1257 for item in query.queries: 

1258 new_in_eq_filter = [ 

1259 key for key in item.filters.keys() 

1260 if key.rstrip().endswith(("<", ">", "!=")) 

1261 ] 

1262 if in_eq_filter and new_in_eq_filter and in_eq_filter != new_in_eq_filter: 

1263 raise NotImplementedError("Impossible ordering!") 

1264 

1265 in_eq_filter = new_in_eq_filter 

1266 

1267 else: 

1268 in_eq_filter = [ 

1269 key for key in query.queries.filters.keys() 

1270 if key.rstrip().endswith(("<", ">", "!=")) 

1271 ] 

1272 

1273 if in_eq_filter: 

1274 orderby_prop = in_eq_filter[0].split(" ", 1)[0] 

1275 if orderby_prop != prop: 

1276 logging.warning( 

1277 f"The query was rewritten; Impossible ordering changed from {prop!r} into {orderby_prop!r}" 

1278 ) 

1279 prop = orderby_prop 

1280 

1281 query.order((prop + postfix, utils.parse.sortorder(params.get("orderdir")))) 

1282 

1283 return query 

1284 

1285 def _hashValueForUniquePropertyIndex( 

1286 self, 

1287 value: str | int | float | db.Key | list[str | int | float | db.Key], 

1288 ) -> list[str]: 

1289 """ 

1290 Generates a hash of the given value for creating unique property indexes. 

1291 

1292 This method is called by the framework to create a consistent hash representation of a value 

1293 for constructing unique property indexes. Derived bone classes should overwrite this method to 

1294 implement their own logic for hashing values. 

1295 

1296 :param value: The value(s) to be hashed. 

1297 

1298 :return: A list containing a string representation of the hashed value. If the bone is multiple, 

1299 the list may contain more than one hashed value. 

1300 """ 

1301 

1302 def hashValue(value: str | int | float | db.Key) -> str: 

1303 h = hashlib.sha256() 

1304 h.update(str(value).encode("UTF-8")) 

1305 res = h.hexdigest() 

1306 if isinstance(value, int | float): 

1307 return f"I-{res}" 

1308 elif isinstance(value, str): 

1309 return f"S-{res}" 

1310 elif isinstance(value, db.Key): 

1311 # We Hash the keys here by our self instead of relying on str() or to_legacy_urlsafe() 

1312 # as these may change in the future, which would invalidate all existing locks 

1313 def keyHash(key): 

1314 if key is None: 

1315 return "-" 

1316 return f"{hashValue(key.kind)}-{hashValue(key.id_or_name)}-<{keyHash(key.parent)}>" 

1317 

1318 return f"K-{keyHash(value)}" 

1319 raise NotImplementedError(f"Type {type(value)} can't be safely used in an uniquePropertyIndex") 

1320 

1321 if not value and not self.unique.lockEmpty: 

1322 return [] # We are zero/empty string and these should not be locked 

1323 if not self.multiple and not isinstance(value, list): 

1324 return [hashValue(value)] 

1325 # We have a multiple bone or multiple values here 

1326 if not isinstance(value, list): 

1327 value = [value] 

1328 tmpList = [hashValue(x) for x in value] 

1329 if self.unique.method == UniqueLockMethod.SameValue: 

1330 # We should lock each entry individually; lock each value 

1331 return tmpList 

1332 elif self.unique.method == UniqueLockMethod.SameSet: 

1333 # We should ignore the sort-order; so simply sort that List 

1334 tmpList.sort() 

1335 # Lock the value for that specific list 

1336 return [hashValue(", ".join(tmpList))] 

1337 

1338 def getUniquePropertyIndexValues(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> list[str]: 

1339 """ 

1340 Returns a list of hashes for the current value(s) of a bone in the skeleton, used for storing in the 

1341 unique property value index. 

1342 

1343 :param skel: A SkeletonInstance object representing the current skeleton. 

1344 :param name: The property-name of the bone in the skeleton for which the unique property index values 

1345 are required (not the description!). 

1346 

1347 :return: A list of strings representing the hashed values for the current bone value(s) in the skeleton. 

1348 If the bone has no value, an empty list is returned. 

1349 """ 

1350 val = skel[name] 

1351 if val is None: 

1352 return [] 

1353 return self._hashValueForUniquePropertyIndex(val) 

1354 

1355 def getReferencedBlobs(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> set[str]: 

1356 """ 

1357 Returns a set of blob keys referenced from this bone 

1358 """ 

1359 return set() 

1360 

1361 def performMagic(self, valuesCache: dict, name: str, isAdd: bool): 

1362 """ 

1363 This function applies "magically" functionality which f.e. inserts the current Date 

1364 or the current user. 

1365 :param isAdd: Signals wherever this is an add or edit operation. 

1366 """ 

1367 pass # We do nothing by default 

1368 

1369 def postSavedHandler(self, skel: "SkeletonInstance", boneName: str, key: db.Key | None) -> None: 

1370 """ 

1371 Can be overridden to perform further actions after the main entity has been written. 

1372 

1373 :param boneName: Name of this bone 

1374 :param skel: The skeleton this bone belongs to 

1375 :param key: The (new?) Database Key we've written to. In case of a RelSkel the key is None. 

1376 """ 

1377 pass 

1378 

1379 def postDeletedHandler(self, skel: 'viur.core.skeleton.SkeletonInstance', boneName: str, key: str): 

1380 """ 

1381 Can be overridden to perform further actions after the main entity has been deleted. 

1382 

1383 :param skel: The skeleton this bone belongs to 

1384 :param boneName: Name of this bone 

1385 :param key: The old Database Key of the entity we've deleted 

1386 """ 

1387 pass 

1388 

1389 def clone_value(self, skel: "SkeletonInstance", src_skel: "SkeletonInstance", bone_name: str) -> None: 

1390 """Clone / Set the value for this bone depending on :attr:`clone_behavior`""" 

1391 match self.clone_behavior.strategy: 

1392 case CloneStrategy.COPY_VALUE: 

1393 try: 

1394 skel.accessedValues[bone_name] = copy.deepcopy(src_skel.accessedValues[bone_name]) 

1395 except KeyError: 

1396 pass # bone_name is not in accessedValues, cannot clone it 

1397 try: 

1398 skel.renderAccessedValues[bone_name] = copy.deepcopy(src_skel.renderAccessedValues[bone_name]) 

1399 except KeyError: 

1400 pass # bone_name is not in renderAccessedValues, cannot clone it 

1401 case CloneStrategy.SET_NULL: 

1402 skel.accessedValues[bone_name] = None 

1403 case CloneStrategy.SET_DEFAULT: 

1404 skel.accessedValues[bone_name] = self.getDefaultValue(skel) 

1405 case CloneStrategy.SET_EMPTY: 

1406 skel.accessedValues[bone_name] = self.getEmptyValue() 

1407 case CloneStrategy.CUSTOM: 

1408 skel.accessedValues[bone_name] = self.clone_behavior.custom_func(skel, src_skel, bone_name) 

1409 case other: 

1410 raise NotImplementedError(other) 

1411 

1412 def refresh(self, skel: 'viur.core.skeleton.SkeletonInstance', boneName: str) -> None: 

1413 """ 

1414 Refresh all values we might have cached from other entities. 

1415 """ 

1416 pass 

1417 

1418 def mergeFrom(self, valuesCache: dict, boneName: str, otherSkel: 'viur.core.skeleton.SkeletonInstance'): 

1419 """ 

1420 Merges the values from another skeleton instance into the current instance, given that the bone types match. 

1421 

1422 :param valuesCache: A dictionary containing the cached values for each bone in the skeleton. 

1423 :param boneName: The property-name of the bone in the skeleton whose values are to be merged. 

1424 :param otherSkel: A SkeletonInstance object representing the other skeleton from which the values \ 

1425 are to be merged. 

1426 

1427 This function clones the values from the specified bone in the other skeleton instance into the current 

1428 instance, provided that the bone types match. If the bone types do not match, a warning is logged, and the merge 

1429 is ignored. If the bone in the other skeleton has no value, the function returns without performing any merge 

1430 operation. 

1431 """ 

1432 if getattr(otherSkel, boneName) is None: 

1433 return 

1434 if not isinstance(getattr(otherSkel, boneName), type(self)): 

1435 logging.error(f"Ignoring values from conflicting boneType ({getattr(otherSkel, boneName)} is not a " 

1436 f"instance of {type(self)})!") 

1437 return 

1438 valuesCache[boneName] = copy.deepcopy(otherSkel.valuesCache.get(boneName, None)) 

1439 

1440 def setBoneValue(self, 

1441 skel: 'SkeletonInstance', 

1442 boneName: str, 

1443 value: t.Any, 

1444 append: bool, 

1445 language: None | str = None) -> bool: 

1446 """ 

1447 Sets the value of a bone in a skeleton instance, with optional support for appending and language-specific 

1448 values. Sanity checks are being performed. 

1449 

1450 :param skel: The SkeletonInstance object representing the skeleton to which the bone belongs. 

1451 :param boneName: The property-name of the bone in the skeleton whose value should be set or modified. 

1452 :param value: The value to be assigned. Its type depends on the type of the bone. 

1453 :param append: If True, the given value is appended to the bone's values instead of replacing it. \ 

1454 Only supported for bones with multiple=True. 

1455 :param language: The language code for which the value should be set or appended, \ 

1456 if the bone supports languages. 

1457 

1458 :return: A boolean indicating whether the operation was successful or not. 

1459 

1460 This function sets or modifies the value of a bone in a skeleton instance, performing sanity checks to ensure 

1461 the value is valid. If the value is invalid, no modification occurs. The function supports appending values to 

1462 bones with multiple=True and setting or appending language-specific values for bones that support languages. 

1463 """ 

1464 assert not (bool(self.languages) ^ bool(language)), f"language is required or not supported on {boneName!r}" 

1465 assert not append or self.multiple, "Can't append - bone is not multiple" 

1466 

1467 if not append and self.multiple: 

1468 # set multiple values at once 

1469 val = [] 

1470 errors = [] 

1471 for singleValue in value: 

1472 singleValue, singleError = self.singleValueFromClient(singleValue, skel, boneName, {boneName: value}) 

1473 val.append(singleValue) 

1474 if singleError: 1474 ↛ 1475line 1474 didn't jump to line 1475 because the condition on line 1474 was never true

1475 errors.extend(singleError) 

1476 else: 

1477 # set or append one value 

1478 val, errors = self.singleValueFromClient(value, skel, boneName, {boneName: value}) 

1479 

1480 if errors: 

1481 for e in errors: 1481 ↛ 1486line 1481 didn't jump to line 1486 because the loop on line 1481 didn't complete

1482 if e.severity in [ReadFromClientErrorSeverity.Invalid, ReadFromClientErrorSeverity.NotSet]: 1482 ↛ 1481line 1482 didn't jump to line 1481 because the condition on line 1482 was always true

1483 # If an invalid datatype (or a non-parseable structure) have been passed, abort the store 

1484 logging.error(e) 

1485 return False 

1486 if not append and not language: 

1487 skel[boneName] = val 

1488 elif append and language: 1488 ↛ 1489line 1488 didn't jump to line 1489 because the condition on line 1488 was never true

1489 if not language in skel[boneName] or not isinstance(skel[boneName][language], list): 

1490 skel[boneName][language] = [] 

1491 skel[boneName][language].append(val) 

1492 elif append: 1492 ↛ 1497line 1492 didn't jump to line 1497 because the condition on line 1492 was always true

1493 if not isinstance(skel[boneName], list): 1493 ↛ 1494line 1493 didn't jump to line 1494 because the condition on line 1493 was never true

1494 skel[boneName] = [] 

1495 skel[boneName].append(val) 

1496 else: # Just language 

1497 skel[boneName][language] = val 

1498 return True 

1499 

1500 def getSearchTags(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> set[str]: 

1501 """ 

1502 Returns a set of strings as search index for this bone. 

1503 

1504 This function extracts a set of search tags from the given bone's value in the skeleton 

1505 instance. The resulting set can be used for indexing or searching purposes. 

1506 

1507 :param skel: The skeleton instance where the values should be loaded from. This is an instance 

1508 of a class derived from `viur.core.skeleton.SkeletonInstance`. 

1509 :param name: The name of the bone, which is a string representing the key for the bone in 

1510 the skeleton. This should correspond to an existing bone in the skeleton instance. 

1511 :return: A set of strings, extracted from the bone value. If the bone value doesn't have 

1512 any searchable content, an empty set is returned. 

1513 """ 

1514 return set() 

1515 

1516 def iter_bone_value( 

1517 self, skel: 'viur.core.skeleton.SkeletonInstance', name: str 

1518 ) -> t.Iterator[tuple[t.Optional[int], t.Optional[str], t.Any]]: 

1519 """ 

1520 Yield all values from the Skeleton related to this bone instance. 

1521 

1522 This method handles multiple/languages cases, which could save a lot of if/elifs. 

1523 It always yields a triplet: index, language, value. 

1524 Where index is the index (int) of a value inside a multiple bone, 

1525 language is the language (str) of a multi-language-bone, 

1526 and value is the value inside this container. 

1527 index or language is None if the bone is single or not multi-lang. 

1528 

1529 This function can be used to conveniently iterate through all the values of a specific bone 

1530 in a skeleton instance, taking into account multiple and multi-language bones. 

1531 

1532 :param skel: The skeleton instance where the values should be loaded from. This is an instance 

1533 of a class derived from `viur.core.skeleton.SkeletonInstance`. 

1534 :param name: The name of the bone, which is a string representing the key for the bone in 

1535 the skeleton. This should correspond to an existing bone in the skeleton instance. 

1536 

1537 :return: A generator which yields triplets (index, language, value), where index is the index 

1538 of a value inside a multiple bone, language is the language of a multi-language bone, 

1539 and value is the value inside this container. index or language is None if the bone is 

1540 single or not multi-lang. 

1541 """ 

1542 value = skel[name] 

1543 if not value: 

1544 return None 

1545 

1546 if self.languages and isinstance(value, dict): 

1547 for idx, (lang, values) in enumerate(value.items()): 

1548 if self.multiple: 

1549 if not values: 

1550 continue 

1551 for val in values: 

1552 yield idx, lang, val 

1553 else: 

1554 yield None, lang, values 

1555 else: 

1556 if self.multiple: 

1557 for idx, val in enumerate(value): 

1558 yield idx, None, val 

1559 else: 

1560 yield None, None, value 

1561 

1562 def _compute(self, skel: 'viur.core.skeleton.SkeletonInstance', bone_name: str): 

1563 """Performs the evaluation of a bone configured as compute""" 

1564 from ..skeleton.utils import without_render_preparation 

1565 

1566 compute_fn_parameters = inspect.signature(self.compute.fn).parameters 

1567 compute_fn_args = {} 

1568 skel = without_render_preparation(skel) 

1569 

1570 if "skel" in compute_fn_parameters: 

1571 skel.accessedValues[bone_name] = None # remove value from accessedValues to avoid endless recursion 

1572 compute_fn_args["skel"] = skel 

1573 

1574 if "bone" in compute_fn_parameters: 

1575 compute_fn_args["bone"] = getattr(skel, bone_name) 

1576 

1577 if "bone_name" in compute_fn_parameters: 

1578 compute_fn_args["bone_name"] = bone_name 

1579 

1580 ret = self.compute.fn(**compute_fn_args) 

1581 

1582 def unserialize_raw_value(raw_value: list[dict] | dict | None): 

1583 if self.multiple: 

1584 return [self.singleValueUnserialize(inner_value) for inner_value in raw_value] 

1585 return self.singleValueUnserialize(raw_value) 

1586 

1587 if self.compute.raw: 

1588 if self.languages: 

1589 return { 

1590 lang: unserialize_raw_value(ret.get(lang, [] if self.multiple else None)) 

1591 for lang in self.languages 

1592 } 

1593 

1594 return unserialize_raw_value(ret) 

1595 

1596 self._prevent_compute = True 

1597 if errors := self.fromClient(skel, bone_name, {bone_name: ret}): 

1598 raise ValueError(f"Computed value fromClient failed with {errors!r}") 

1599 self._prevent_compute = False 

1600 

1601 return skel[bone_name] 

1602 

1603 def structure(self) -> dict: 

1604 """ 

1605 Describes the bone and its settings as an JSON-serializable dict. 

1606 This function has to be implemented for subsequent, specialized bone types. 

1607 """ 

1608 ret = { 

1609 "descr": self.descr, 

1610 "type": self.type, 

1611 "required": self.required and not self.readOnly, 

1612 "params": self.params, 

1613 "visible": self.visible, 

1614 "readonly": self.readOnly, 

1615 "unique": self.unique.method.value if self.unique else False, 

1616 "languages": self.languages, 

1617 "emptyvalue": self.getEmptyValue(), 

1618 "indexed": self.indexed, 

1619 "clone_behavior": { 

1620 "strategy": self.clone_behavior.strategy, 

1621 }, 

1622 } 

1623 

1624 # Provide a defaultvalue, if it's not a function. 

1625 if not callable(self.defaultValue) and self.defaultValue is not None: 

1626 ret["defaultvalue"] = self.defaultValue 

1627 

1628 # Provide a multiple setting 

1629 if self.multiple and isinstance(self.multiple, MultipleConstraints): 

1630 ret["multiple"] = { 

1631 "duplicates": self.multiple.duplicates, 

1632 "max": self.multiple.max, 

1633 "min": self.multiple.min, 

1634 } 

1635 else: 

1636 ret["multiple"] = self.multiple 

1637 

1638 # Provide compute information 

1639 if self.compute: 

1640 ret["compute"] = { 

1641 "method": self.compute.interval.method.name 

1642 } 

1643 

1644 if self.compute.interval.lifetime: 

1645 ret["compute"]["lifetime"] = self.compute.interval.lifetime.total_seconds() 

1646 

1647 return ret 

1648 

1649 def dump(self, skel: "SkeletonInstance", bone_name: str) -> t.Any: 

1650 """ 

1651 Returns the value of a bone in a JSON-serializable format. 

1652 

1653 The function is not called "to_json()" because the JSON-serializable 

1654 format can be used for different purposes and renderings, not just 

1655 JSON. 

1656 

1657 :param skel: The SkeletonInstance that contains the bone. 

1658 :param bone_name: The name of the bone to in the skeleton. 

1659 

1660 :return: The value of the bone in a JSON-serializable version. 

1661 """ 

1662 ret = {} 

1663 bone_value = skel[bone_name] 

1664 if self.languages and self.multiple: 

1665 for language in self.languages: 

1666 if bone_value and language in bone_value and bone_value[language]: 

1667 ret[language] = [self._atomic_dump(value) for value in bone_value[language]] 

1668 else: 

1669 ret[language] = [] 

1670 elif self.languages: 

1671 for language in self.languages: 

1672 if bone_value and language in bone_value and bone_value[language]: 

1673 ret[language] = self._atomic_dump(bone_value[language]) 

1674 else: 

1675 ret[language] = None 

1676 elif self.multiple: 

1677 ret = [self._atomic_dump(value) for value in bone_value or ()] 

1678 

1679 else: 

1680 ret = self._atomic_dump(bone_value) 

1681 return ret 

1682 

1683 def _atomic_dump(self, value): 

1684 """ 

1685 One atomic value of the bone. 

1686 """ 

1687 return value