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

824 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-13 11:04 +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 # Always compute on deserialization 

205 Lifetime = 1 # Update only when given lifetime is outrun; value is only being stored when the skeleton is written 

206 Once = 2 # Compute only once 

207 OnWrite = 3 # Compute before written 

208 

209 

210@dataclass 

211class ComputeInterval: 

212 method: ComputeMethod = ComputeMethod.Always 

213 lifetime: timedelta = None # defines a timedelta until which the value stays valid (`ComputeMethod.Lifetime`) 

214 

215 

216@dataclass 

217class Compute: 

218 fn: callable # the callable computing the value 

219 interval: ComputeInterval = field(default_factory=ComputeInterval) # the value caching interval 

220 raw: bool = True # defines whether the value returned by fn is used as is, or is passed through bone.fromClient 

221 

222 

223class CloneStrategy(enum.StrEnum): 

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

225 

226 SET_NULL = enum.auto() 

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

228 

229 SET_DEFAULT = enum.auto() 

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

231 

232 SET_EMPTY = enum.auto() 

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

234 

235 COPY_VALUE = enum.auto() 

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

237 

238 CUSTOM = enum.auto() 

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

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

241 """ 

242 

243 

244class CloneCustomFunc(t.Protocol): 

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

246 

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

248 """Return the value for the cloned bone""" 

249 ... 

250 

251 

252@dataclass 

253class CloneBehavior: 

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

255 

256 strategy: CloneStrategy 

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

258 

259 custom_func: CloneCustomFunc = None 

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

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

262 """ 

263 

264 def __post_init__(self): 

265 """Validate this configuration.""" 

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

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

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

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

270 

271 

272class BaseBone(object): 

273 """ 

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

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

276 

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

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

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

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

281 language must be entered. 

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

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

284 without the need of also been indexed. 

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

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

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

288 error-message for the user otherwise. 

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

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

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

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

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

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

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

296 

297 .. NOTE:: 

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

299 """ 

300 type = "hidden" 

301 isClonedInstance = False 

302 

303 skel_cls = None 

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

305 

306 name = None 

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

308 

309 def __init__( 

310 self, 

311 *, 

312 compute: Compute = None, 

313 defaultValue: t.Any = None, 

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

315 getEmptyValueFunc: callable = None, 

316 indexed: bool = True, 

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

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

319 multiple: bool | MultipleConstraints = False, 

320 params: dict = None, 

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

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

323 searchable: bool = False, 

324 type_suffix: str = "", 

325 unique: None | UniqueValue = None, 

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

327 visible: bool = True, 

328 clone_behavior: CloneBehavior | CloneStrategy | None = None, 

329 ): 

330 """ 

331 Initializes a new Bone. 

332 """ 

333 self.isClonedInstance = getSystemInitialized() 

334 

335 # Standard definitions 

336 self.descr = descr 

337 self.params = params or {} 

338 self.multiple = multiple 

339 self.required = required 

340 self.readOnly = bool(readOnly) 

341 self.searchable = searchable 

342 self.visible = visible 

343 self.indexed = indexed 

344 

345 if type_suffix: 345 ↛ 346line 345 didn't jump to line 346 because the condition on line 345 was never true

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

347 

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

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

350 

351 # Multi-language support 

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

353 languages is None or 

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

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

356 ): 

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

358 

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

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

361 

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

363 not isinstance(required, bool) 

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

365 ): 

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

367 

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

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

370 

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

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

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

374 

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

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

377 try: 

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

379 except TypeError: 

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

381 

382 self.languages = languages 

383 

384 # Default value 

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

386 # multiple or has languages 

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

388 if self.languages: 

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

390 self.defaultValue = defaultValue 

391 elif not isinstance(defaultValue, dict): 391 ↛ 393line 391 didn't jump to line 393 because the condition on line 391 was always true

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

393 elif "__default__" in defaultValue: 

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

395 for lang in self.languages} 

396 else: 

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

398 else: 

399 self.defaultValue = default 

400 

401 # Unique values 

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

403 if not isinstance(unique, UniqueValue): 

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

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

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

407 

408 self.unique = unique 

409 

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

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

412 if vfunc: 

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

414 

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

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

417 

418 if getEmptyValueFunc: 

419 self.getEmptyValue = getEmptyValueFunc 

420 

421 if compute: 

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

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

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

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

426 # When readOnly is None, handle flag automatically 

427 if readOnly is None: 

428 self.readOnly = True 

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

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

431 

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

433 compute.interval.method == ComputeMethod.Lifetime 

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

435 ): 

436 raise ValueError( 

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

438 ) 

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

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

441 self._prevent_compute = False 

442 

443 self.compute = compute 

444 

445 if clone_behavior is None: # auto choose 445 ↛ 451line 445 didn't jump to line 451 because the condition on line 445 was always true

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

447 self.clone_behavior = CloneBehavior(CloneStrategy.SET_DEFAULT) 

448 else: 

449 self.clone_behavior = CloneBehavior(CloneStrategy.COPY_VALUE) 

450 # TODO: Any different setting for computed bones? 

451 elif isinstance(clone_behavior, CloneStrategy): 

452 self.clone_behavior = CloneBehavior(strategy=clone_behavior) 

453 elif isinstance(clone_behavior, CloneBehavior): 

454 self.clone_behavior = clone_behavior 

455 else: 

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

457 

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

459 self.skel_cls = owner 

460 self.name = name 

461 

462 def setSystemInitialized(self) -> None: 

463 """ 

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

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

466 """ 

467 

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

469 if self.descr is None: 

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

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

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

473 

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

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

476 super().__setattr__( 

477 "descr", 

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

479 ) 

480 

481 def isInvalid(self, value): 

482 """ 

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

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

485 """ 

486 return False 

487 

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

489 """ 

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

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

492 

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

494 valid - unless the bone is required. 

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

496 

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

498 input!) or the value returned by get 

499 """ 

500 return not bool(value) 

501 

502 def getDefaultValue(self, skeletonInstance): 

503 """ 

504 Retrieves the default value for the bone. 

505 

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

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

508 providing a default value. 

509 

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

511 """ 

512 if callable(self.defaultValue): 

513 res = self.defaultValue(skeletonInstance, self) 

514 if self.languages and self.multiple: 

515 if not isinstance(res, dict): 

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

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

518 else: 

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

520 elif self.languages: 

521 if not isinstance(res, dict): 

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

523 elif self.multiple: 

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

525 return [res] 

526 return res 

527 

528 elif isinstance(self.defaultValue, list): 

529 return self.defaultValue[:] 

530 elif isinstance(self.defaultValue, dict): 

531 return self.defaultValue.copy() 

532 else: 

533 return self.defaultValue 

534 

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

536 """ 

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

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

539 """ 

540 return None 

541 

542 def __setattr__(self, key, value): 

543 """ 

544 Custom attribute setter for the BaseBone class. 

545 

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

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

548 method unless they have additional attributes with similar constraints. 

549 

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

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

552 

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

554 assignment. 

555 """ 

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

557 "_"): 

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

559 super().__setattr__(key, value) 

560 

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

562 """ 

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

564 

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

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

567 

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

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

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

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

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

573 

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

575 """ 

576 fieldSubmitted = False 

577 

578 if languages: 

579 res = {} 

580 for lang in languages: 

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

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

583 fieldSubmitted = True 

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

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

586 res[lang] = [res[lang]] 

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

588 if res[lang]: 

589 res[lang] = res[lang][0] 

590 else: 

591 res[lang] = None 

592 else: 

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

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

595 fieldSubmitted = True 

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

597 if multiple: 

598 tmpDict = {} 

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

600 if not key.startswith(prefix): 

601 continue 

602 fieldSubmitted = True 

603 partKey = key[len(prefix):] 

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

605 try: 

606 firstKey = int(firstKey) 

607 except: 

608 continue 

609 if firstKey not in tmpDict: 

610 tmpDict[firstKey] = {} 

611 tmpDict[firstKey][remainingKey] = value 

612 tmpList = list(tmpDict.items()) 

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

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

615 else: 

616 tmpDict = {} 

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

618 if not key.startswith(prefix): 

619 continue 

620 fieldSubmitted = True 

621 partKey = key[len(prefix):] 

622 tmpDict[partKey] = value 

623 res[lang] = tmpDict 

624 return res, fieldSubmitted 

625 else: # No multi-lang 

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

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

628 return None, False 

629 val = data[name] 

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

631 return [val], True 

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

633 if val: 

634 return val[0], True 

635 else: 

636 return None, True # Empty! 

637 else: 

638 return val, True 

639 else: # No multi-lang but collect subfields 

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

641 if key == name: 

642 fieldSubmitted = True 

643 prefix = f"{name}." 

644 if multiple: 

645 tmpDict = {} 

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

647 if not key.startswith(prefix): 

648 continue 

649 fieldSubmitted = True 

650 partKey = key[len(prefix):] 

651 try: 

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

653 firstKey = int(firstKey) 

654 except: 

655 continue 

656 if firstKey not in tmpDict: 

657 tmpDict[firstKey] = {} 

658 tmpDict[firstKey][remainingKey] = value 

659 tmpList = list(tmpDict.items()) 

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

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

662 else: 

663 res = {} 

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

665 if not key.startswith(prefix): 

666 continue 

667 fieldSubmitted = True 

668 subKey = key[len(prefix):] 

669 res[subKey] = value 

670 return res, fieldSubmitted 

671 

672 def parseSubfieldsFromClient(self) -> bool: 

673 """ 

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

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

676 """ 

677 return False 

678 

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

680 bone_name: str, client_data: dict 

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

682 """Load a single value from a client 

683 

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

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

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

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

688 a dictionary with usually bone names as key 

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

690 the parsed value and the second is None. 

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

692 and the second a list of *ReadFromClientError*. 

693 """ 

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

695 return self.getEmptyValue(), [ 

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

697 ] 

698 

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

700 """ 

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

702 

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

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

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

706 

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

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

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

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

711 """ 

712 subFields = self.parseSubfieldsFromClient() 

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

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

715 return [ReadFromClientError(ReadFromClientErrorSeverity.NotSet)] 

716 

717 errors = [] 

718 isEmpty = True 

719 filled_languages = set() 

720 if self.languages and self.multiple: 

721 res = {} 

722 for language in self.languages: 

723 res[language] = [] 

724 if language in parsedData: 

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

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

727 continue 

728 isEmpty = False 

729 filled_languages.add(language) 

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

731 res[language].append(parsedVal) 

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

733 if callable(self.multiple.sorted): 

734 res[language] = sorted( 

735 res[language], 

736 key=self.multiple.sorted, 

737 reverse=self.multiple.reversed, 

738 ) 

739 else: 

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

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

742 for parseError in parseErrors: 

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

744 errors.extend(parseErrors) 

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

746 res = {} 

747 for language in self.languages: 

748 res[language] = None 

749 if language in parsedData: 

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

751 res[language] = self.getEmptyValue() 

752 continue 

753 isEmpty = False 

754 filled_languages.add(language) 

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

756 res[language] = parsedVal 

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

758 for parseError in parseErrors: 

759 parseError.fieldPath.insert(0, language) 

760 errors.extend(parseErrors) 

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

762 res = [] 

763 for idx, singleValue in enumerate(parsedData): 

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

765 continue 

766 isEmpty = False 

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

768 res.append(parsedVal) 

769 

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

771 for parseError in parseErrors: 

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

773 errors.extend(parseErrors) 

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

775 if callable(self.multiple.sorted): 

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

777 else: 

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

779 else: # No Languages, not multiple 

780 if self.isEmpty(parsedData): 

781 res = self.getEmptyValue() 

782 isEmpty = True 

783 else: 

784 isEmpty = False 

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

786 if parseErrors: 

787 errors.extend(parseErrors) 

788 skel[name] = res 

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

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

791 if missing: 

792 return [ 

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

794 for lang in missing 

795 ] 

796 

797 if isEmpty: 

798 return [ReadFromClientError(ReadFromClientErrorSeverity.Empty)] 

799 

800 # Check multiple constraints on demand 

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

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

803 

804 return errors or None 

805 

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

807 """ 

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

809 The returned value must be hashable. 

810 """ 

811 return value 

812 

813 def _get_destinct_hash(self, value) -> t.Any: 

814 """ 

815 Returns a distinct hash value for this bone. 

816 The returned value must be hashable. 

817 """ 

818 if not isinstance(value, str) and isinstance(value, Iterable): 

819 return tuple(self._get_single_destinct_hash(item) for item in value) 

820 

821 return value 

822 

823 def _validate_multiple_contraints( 

824 self, 

825 constraints: MultipleConstraints, 

826 skel: 'SkeletonInstance', 

827 name: str 

828 ) -> list[ReadFromClientError]: 

829 """ 

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

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

832 

833 :param constraints: The MultipleConstraints definition to apply. 

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

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

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

837 """ 

838 res = [] 

839 value = self._get_destinct_hash(skel[name]) 

840 

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

842 res.append( 

843 ReadFromClientError( 

844 ReadFromClientErrorSeverity.Invalid, 

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

846 ) 

847 ) 

848 

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

850 res.append( 

851 ReadFromClientError( 

852 ReadFromClientErrorSeverity.Invalid, 

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

854 ) 

855 ) 

856 

857 if not constraints.duplicates: 

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

859 res.append( 

860 ReadFromClientError( 

861 ReadFromClientErrorSeverity.Invalid, 

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

863 ) 

864 ) 

865 

866 return res 

867 

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

869 """ 

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

871 

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

873 values. 

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

875 """ 

876 return value 

877 

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

879 """ 

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

881 

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

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

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

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

886 """ 

887 self.serialize_compute(skel, name) 

888 

889 if name in skel.accessedValues: 

890 empty_value = self.getEmptyValue() 

891 newVal = skel.accessedValues[name] 

892 

893 if self.languages and self.multiple: 

894 res = db.Entity() 

895 res["_viurLanguageWrapper_"] = True 

896 for language in self.languages: 

897 res[language] = [] 

898 if not self.indexed: 

899 res.exclude_from_indexes.add(language) 

900 if language in newVal: 

901 for singleValue in newVal[language]: 

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

903 if value != empty_value: 

904 res[language].append(value) 

905 

906 elif self.languages: 

907 res = db.Entity() 

908 res["_viurLanguageWrapper_"] = True 

909 for language in self.languages: 

910 res[language] = None 

911 if not self.indexed: 

912 res.exclude_from_indexes.add(language) 

913 if language in newVal: 

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

915 

916 elif self.multiple: 

917 res = [] 

918 

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

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

921 

922 for singleValue in (newVal or ()): 

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

924 if value != empty_value: 

925 res.append(value) 

926 

927 else: # No Languages, not Multiple 

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

929 

930 skel.dbEntity[name] = res 

931 

932 # Ensure our indexed flag is up2date 

933 indexed = self.indexed and parentIndexed 

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

935 skel.dbEntity.exclude_from_indexes.discard(name) 

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

937 skel.dbEntity.exclude_from_indexes.add(name) 

938 return True 

939 return False 

940 

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

942 """ 

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

944 value with the appropriate calculation method 

945 

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

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

948 """ 

949 if not self.compute: 

950 return None 

951 match self.compute.interval.method: 

952 case ComputeMethod.OnWrite: 

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

954 

955 case ComputeMethod.Lifetime: 

956 now = utils.utcNow() 

957 

958 last_update = \ 

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

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

961 

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

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

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

965 

966 case ComputeMethod.Once: 

967 if name not in skel.dbEntity: 

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

969 

970 

971 def singleValueUnserialize(self, val): 

972 """ 

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

974 

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

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

977 """ 

978 return val 

979 

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

981 """ 

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

983 

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

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

986 

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

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

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

990 """ 

991 if name in skel.dbEntity: 

992 loadVal = skel.dbEntity[name] 

993 elif ( 

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

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

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

997 # ... or computed 

998 or self.compute 

999 ): 

1000 loadVal = None 

1001 else: 

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

1003 return False 

1004 

1005 if self.unserialize_compute(skel, name): 

1006 return True 

1007 

1008 # unserialize value to given config 

1009 if self.languages and self.multiple: 

1010 res = {} 

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

1012 for language in self.languages: 

1013 res[language] = [] 

1014 if language in loadVal: 

1015 tmpVal = loadVal[language] 

1016 if not isinstance(tmpVal, list): 

1017 tmpVal = [tmpVal] 

1018 for singleValue in tmpVal: 

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

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

1021 for language in self.languages: 

1022 res[language] = [] 

1023 mainLang = self.languages[0] 

1024 if loadVal is None: 

1025 pass 

1026 elif isinstance(loadVal, list): 

1027 for singleValue in loadVal: 

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

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

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

1031 elif self.languages: 

1032 res = {} 

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

1034 for language in self.languages: 

1035 res[language] = None 

1036 if language in loadVal: 

1037 tmpVal = loadVal[language] 

1038 if isinstance(tmpVal, list) and tmpVal: 

1039 tmpVal = tmpVal[0] 

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

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

1042 for language in self.languages: 

1043 res[language] = None 

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

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

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

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

1048 mainLang = self.languages[0] 

1049 if loadVal is None: 

1050 pass 

1051 elif isinstance(loadVal, list) and loadVal: 

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

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

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

1055 elif self.multiple: 

1056 res = [] 

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

1058 # Pick one language we'll use 

1059 if conf.i18n.default_language in loadVal: 

1060 loadVal = loadVal[conf.i18n.default_language] 

1061 else: 

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

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

1064 loadVal = [loadVal] 

1065 if loadVal: 

1066 for val in loadVal: 

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

1068 else: # Not multiple, no languages 

1069 res = None 

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

1071 # Pick one language we'll use 

1072 if conf.i18n.default_language in loadVal: 

1073 loadVal = loadVal[conf.i18n.default_language] 

1074 else: 

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

1076 if loadVal and isinstance(loadVal, list): 

1077 loadVal = loadVal[0] 

1078 if loadVal is not None: 

1079 res = self.singleValueUnserialize(loadVal) 

1080 

1081 skel.accessedValues[name] = res 

1082 return True 

1083 

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

1085 """ 

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

1087 value with the appropriate calculation method 

1088 

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

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

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

1092 """ 

1093 if not self.compute or self._prevent_compute: 

1094 return False 

1095 

1096 match self.compute.interval.method: 

1097 # Computation is bound to a lifetime? 

1098 case ComputeMethod.Lifetime: 

1099 now = utils.utcNow() 

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

1101 

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

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

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

1105 else: 

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

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

1108 

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

1110 # if so, recompute and refresh updated value 

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

1112 def transact(): 

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

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

1115 db_obj[name] = value 

1116 db.put(db_obj) 

1117 

1118 if db.is_in_transaction(): 

1119 transact() 

1120 else: 

1121 db.run_in_transaction(transact) 

1122 

1123 return True 

1124 

1125 # Compute on every deserialization 

1126 case ComputeMethod.Always: 

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

1128 return True 

1129 

1130 return False 

1131 

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

1133 """ 

1134 Like postDeletedHandler, but runs inside the transaction 

1135 """ 

1136 pass 

1137 

1138 def buildDBFilter(self, 

1139 name: str, 

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

1141 dbFilter: db.Query, 

1142 rawFilter: dict, 

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

1144 """ 

1145 Parses the searchfilter a client specified in his Request into 

1146 something understood by the datastore. 

1147 This function must: 

1148 

1149 * - Ignore all filters not targeting this bone 

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

1151 

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

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

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

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

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

1157 """ 

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

1159 

1160 if len(myKeys) == 0: 

1161 return dbFilter 

1162 

1163 for key in myKeys: 

1164 value = rawFilter[key] 

1165 tmpdata = key.split("$") 

1166 

1167 if len(tmpdata) > 1: 

1168 if isinstance(value, list): 

1169 continue 

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

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

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

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

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

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

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

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

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

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

1180 else: 

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

1182 else: 

1183 if isinstance(value, list): 

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

1185 else: 

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

1187 

1188 return dbFilter 

1189 

1190 def buildDBSort( 

1191 self, 

1192 name: str, 

1193 skel: "SkeletonInstance", 

1194 query: db.Query, 

1195 params: dict, 

1196 postfix: str = "", 

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

1198 """ 

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

1200 the results, but by sorting them. 

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

1202 malformed data! 

1203 

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

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

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

1207 be applied to 

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

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

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

1211 None if the query is unsatisfiable. 

1212 """ 

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

1214 if self.languages: 

1215 lang = None 

1216 prefix = f"{name}." 

1217 if orderby.startswith(prefix): 

1218 lng = orderby[len(prefix):] 

1219 if lng in self.languages: 

1220 lang = lng 

1221 

1222 if lang is None: 

1223 lang = current.language.get() 

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

1225 lang = self.languages[0] 

1226 

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

1228 else: 

1229 prop = name 

1230 

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

1232 if isinstance(query.queries, list): 

1233 in_eq_filter = None 

1234 

1235 for item in query.queries: 

1236 new_in_eq_filter = [ 

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

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

1239 ] 

1240 if in_eq_filter and new_in_eq_filter and in_eq_filter != new_in_eq_filter: 

1241 raise NotImplementedError("Impossible ordering!") 

1242 

1243 in_eq_filter = new_in_eq_filter 

1244 

1245 else: 

1246 in_eq_filter = [ 

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

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

1249 ] 

1250 

1251 if in_eq_filter: 

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

1253 if orderby_prop != prop: 

1254 logging.warning( 

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

1256 ) 

1257 prop = orderby_prop 

1258 

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

1260 

1261 return query 

1262 

1263 def _hashValueForUniquePropertyIndex( 

1264 self, 

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

1266 ) -> list[str]: 

1267 """ 

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

1269 

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

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

1272 implement their own logic for hashing values. 

1273 

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

1275 

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

1277 the list may contain more than one hashed value. 

1278 """ 

1279 

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

1281 h = hashlib.sha256() 

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

1283 res = h.hexdigest() 

1284 if isinstance(value, int | float): 

1285 return f"I-{res}" 

1286 elif isinstance(value, str): 

1287 return f"S-{res}" 

1288 elif isinstance(value, db.Key): 

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

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

1291 def keyHash(key): 

1292 if key is None: 

1293 return "-" 

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

1295 

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

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

1298 

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

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

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

1302 return [hashValue(value)] 

1303 # We have a multiple bone or multiple values here 

1304 if not isinstance(value, list): 

1305 value = [value] 

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

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

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

1309 return tmpList 

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

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

1312 tmpList.sort() 

1313 # Lock the value for that specific list 

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

1315 

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

1317 """ 

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

1319 unique property value index. 

1320 

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

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

1323 are required (not the description!). 

1324 

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

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

1327 """ 

1328 val = skel[name] 

1329 if val is None: 

1330 return [] 

1331 return self._hashValueForUniquePropertyIndex(val) 

1332 

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

1334 """ 

1335 Returns a set of blob keys referenced from this bone 

1336 """ 

1337 return set() 

1338 

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

1340 """ 

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

1342 or the current user. 

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

1344 """ 

1345 pass # We do nothing by default 

1346 

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

1348 """ 

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

1350 

1351 :param boneName: Name of this bone 

1352 :param skel: The skeleton this bone belongs to 

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

1354 """ 

1355 pass 

1356 

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

1358 """ 

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

1360 

1361 :param skel: The skeleton this bone belongs to 

1362 :param boneName: Name of this bone 

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

1364 """ 

1365 pass 

1366 

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

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

1369 match self.clone_behavior.strategy: 

1370 case CloneStrategy.COPY_VALUE: 

1371 try: 

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

1373 except KeyError: 

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

1375 try: 

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

1377 except KeyError: 

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

1379 case CloneStrategy.SET_NULL: 

1380 skel.accessedValues[bone_name] = None 

1381 case CloneStrategy.SET_DEFAULT: 

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

1383 case CloneStrategy.SET_EMPTY: 

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

1385 case CloneStrategy.CUSTOM: 

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

1387 case other: 

1388 raise NotImplementedError(other) 

1389 

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

1391 """ 

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

1393 """ 

1394 pass 

1395 

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

1397 """ 

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

1399 

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

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

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

1403 are to be merged. 

1404 

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

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

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

1408 operation. 

1409 """ 

1410 if getattr(otherSkel, boneName) is None: 

1411 return 

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

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

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

1415 return 

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

1417 

1418 def setBoneValue(self, 

1419 skel: 'SkeletonInstance', 

1420 boneName: str, 

1421 value: t.Any, 

1422 append: bool, 

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

1424 """ 

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

1426 values. Sanity checks are being performed. 

1427 

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

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

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

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

1432 Only supported for bones with multiple=True. 

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

1434 if the bone supports languages. 

1435 

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

1437 

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

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

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

1441 """ 

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

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

1444 

1445 if not append and self.multiple: 

1446 # set multiple values at once 

1447 val = [] 

1448 errors = [] 

1449 for singleValue in value: 

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

1451 val.append(singleValue) 

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

1453 errors.extend(singleError) 

1454 else: 

1455 # set or append one value 

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

1457 

1458 if errors: 

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

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

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

1462 logging.error(e) 

1463 return False 

1464 if not append and not language: 

1465 skel[boneName] = val 

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

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

1468 skel[boneName][language] = [] 

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

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

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

1472 skel[boneName] = [] 

1473 skel[boneName].append(val) 

1474 else: # Just language 

1475 skel[boneName][language] = val 

1476 return True 

1477 

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

1479 """ 

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

1481 

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

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

1484 

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

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

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

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

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

1490 any searchable content, an empty set is returned. 

1491 """ 

1492 return set() 

1493 

1494 def iter_bone_value( 

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

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

1497 """ 

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

1499 

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

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

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

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

1504 and value is the value inside this container. 

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

1506 

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

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

1509 

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

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

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

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

1514 

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

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

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

1518 single or not multi-lang. 

1519 """ 

1520 value = skel[name] 

1521 if not value: 

1522 return None 

1523 

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

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

1526 if self.multiple: 

1527 if not values: 

1528 continue 

1529 for val in values: 

1530 yield idx, lang, val 

1531 else: 

1532 yield None, lang, values 

1533 else: 

1534 if self.multiple: 

1535 for idx, val in enumerate(value): 

1536 yield idx, None, val 

1537 else: 

1538 yield None, None, value 

1539 

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

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

1542 

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

1544 compute_fn_args = {} 

1545 if "skel" in compute_fn_parameters: 

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

1547 

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

1549 cloned_skel = skeletonByKind(skel.kindName)() 

1550 if not cloned_skel.read(skel["key"]): 

1551 raise ValueError(f'{skel["key"]=!r} does no longer exists. Cannot compute a broken relation') 

1552 else: 

1553 cloned_skel = skel.clone() 

1554 cloned_skel[bone_name] = None # remove value form accessedValues to avoid endless recursion 

1555 compute_fn_args["skel"] = cloned_skel 

1556 

1557 if "bone" in compute_fn_parameters: 

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

1559 

1560 if "bone_name" in compute_fn_parameters: 

1561 compute_fn_args["bone_name"] = bone_name 

1562 

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

1564 

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

1566 if self.multiple: 

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

1568 return self.singleValueUnserialize(raw_value) 

1569 

1570 if self.compute.raw: 

1571 if self.languages: 

1572 return { 

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

1574 for lang in self.languages 

1575 } 

1576 return unserialize_raw_value(ret) 

1577 self._prevent_compute = True 

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

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

1580 self._prevent_compute = False 

1581 return skel[bone_name] 

1582 

1583 def structure(self) -> dict: 

1584 """ 

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

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

1587 """ 

1588 ret = { 

1589 "descr": self.descr, 

1590 "type": self.type, 

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

1592 "params": self.params, 

1593 "visible": self.visible, 

1594 "readonly": self.readOnly, 

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

1596 "languages": self.languages, 

1597 "emptyvalue": self.getEmptyValue(), 

1598 "indexed": self.indexed, 

1599 "clone_behavior": { 

1600 "strategy": self.clone_behavior.strategy, 

1601 }, 

1602 } 

1603 

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

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

1606 ret["defaultvalue"] = self.defaultValue 

1607 

1608 # Provide a multiple setting 

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

1610 ret["multiple"] = { 

1611 "duplicates": self.multiple.duplicates, 

1612 "max": self.multiple.max, 

1613 "min": self.multiple.min, 

1614 } 

1615 else: 

1616 ret["multiple"] = self.multiple 

1617 

1618 # Provide compute information 

1619 if self.compute: 

1620 ret["compute"] = { 

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

1622 } 

1623 

1624 if self.compute.interval.lifetime: 

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

1626 

1627 return ret 

1628 

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

1630 """ 

1631 Returns the value of a bone in a simplified version. 

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

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

1634 :return: The value of the bone in a simplified version. 

1635 """ 

1636 ret = {} 

1637 bone_value = skel[bone_name] 

1638 if self.languages and self.multiple: 

1639 res = {} 

1640 for language in self.languages: 

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

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

1643 else: 

1644 res[language] = [] 

1645 elif self.languages: 

1646 for language in self.languages: 

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

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

1649 else: 

1650 ret[language] = None 

1651 elif self.multiple: 

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

1653 

1654 else: 

1655 ret = self._atomic_dump(bone_value) 

1656 return ret 

1657 

1658 def _atomic_dump(self, value): 

1659 """ 

1660 One atomic value of the bone. 

1661 """ 

1662 return value