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

834 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-11 20:18 +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: 354 ↛ 355line 354 didn't jump to line 355 because the condition on line 354 was never true

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): 384 ↛ 386line 384 didn't jump to line 386 because the condition on line 384 was never true

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): 400 ↛ 402line 400 didn't jump to line 402 because the condition on line 400 was always true

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

402 elif "__default__" in defaultValue: 

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 454 ↛ 460line 454 didn't jump to line 460 because the condition on line 454 was always true

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): 

461 self.clone_behavior = CloneBehavior(strategy=clone_behavior) 

462 elif isinstance(clone_behavior, CloneBehavior): 

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): 

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): 

540 return self.defaultValue.copy() 

541 else: 

542 return self.defaultValue 

543 

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

545 """ 

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

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

548 """ 

549 return None 

550 

551 def __setattr__(self, key, value): 

552 """ 

553 Custom attribute setter for the BaseBone class. 

554 

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

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

557 method unless they have additional attributes with similar constraints. 

558 

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

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

561 

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

563 assignment. 

564 """ 

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

566 "_"): 

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

568 super().__setattr__(key, value) 

569 

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

571 """ 

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

573 

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

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

576 

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

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

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

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

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

582 

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

584 """ 

585 fieldSubmitted = False 

586 

587 if languages: 

588 res = {} 

589 for lang in languages: 

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

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

592 fieldSubmitted = True 

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

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

595 res[lang] = [res[lang]] 

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

597 if res[lang]: 

598 res[lang] = res[lang][0] 

599 else: 

600 res[lang] = None 

601 else: 

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

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

604 fieldSubmitted = True 

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

606 if multiple: 

607 tmpDict = {} 

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

609 if not key.startswith(prefix): 

610 continue 

611 fieldSubmitted = True 

612 partKey = key[len(prefix):] 

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

614 try: 

615 firstKey = int(firstKey) 

616 except: 

617 continue 

618 if firstKey not in tmpDict: 

619 tmpDict[firstKey] = {} 

620 tmpDict[firstKey][remainingKey] = value 

621 tmpList = list(tmpDict.items()) 

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

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

624 else: 

625 tmpDict = {} 

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

627 if not key.startswith(prefix): 

628 continue 

629 fieldSubmitted = True 

630 partKey = key[len(prefix):] 

631 tmpDict[partKey] = value 

632 res[lang] = tmpDict 

633 return res, fieldSubmitted 

634 else: # No multi-lang 

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

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

637 return None, False 

638 val = data[name] 

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

640 return [val], True 

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

642 if val: 

643 return val[0], True 

644 else: 

645 return None, True # Empty! 

646 else: 

647 return val, True 

648 else: # No multi-lang but collect subfields 

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

650 if key == name: 

651 fieldSubmitted = True 

652 prefix = f"{name}." 

653 if multiple: 

654 tmpDict = {} 

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

656 if not key.startswith(prefix): 

657 continue 

658 fieldSubmitted = True 

659 partKey = key[len(prefix):] 

660 try: 

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

662 firstKey = int(firstKey) 

663 except: 

664 continue 

665 if firstKey not in tmpDict: 

666 tmpDict[firstKey] = {} 

667 tmpDict[firstKey][remainingKey] = value 

668 tmpList = list(tmpDict.items()) 

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

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

671 else: 

672 res = {} 

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

674 if not key.startswith(prefix): 

675 continue 

676 fieldSubmitted = True 

677 subKey = key[len(prefix):] 

678 res[subKey] = value 

679 return res, fieldSubmitted 

680 

681 def parseSubfieldsFromClient(self) -> bool: 

682 """ 

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

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

685 """ 

686 return False 

687 

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

689 bone_name: str, client_data: dict 

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

691 """Load a single value from a client 

692 

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

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

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

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

697 a dictionary with usually bone names as key 

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

699 the parsed value and the second is None. 

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

701 and the second a list of *ReadFromClientError*. 

702 """ 

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

704 return self.getEmptyValue(), [ 

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

706 ] 

707 

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

709 """ 

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

711 

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

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

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

715 

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

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

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

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

720 """ 

721 subFields = self.parseSubfieldsFromClient() 

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

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

724 return [ReadFromClientError(ReadFromClientErrorSeverity.NotSet)] 

725 

726 errors = [] 

727 isEmpty = True 

728 filled_languages = set() 

729 if self.languages and self.multiple: 

730 res = {} 

731 for language in self.languages: 

732 res[language] = [] 

733 if language in parsedData: 

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

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

736 continue 

737 isEmpty = False 

738 filled_languages.add(language) 

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

740 res[language].append(parsedVal) 

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

742 if callable(self.multiple.sorted): 

743 res[language] = sorted( 

744 res[language], 

745 key=self.multiple.sorted, 

746 reverse=self.multiple.reversed, 

747 ) 

748 else: 

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

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

751 for parseError in parseErrors: 

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

753 errors.extend(parseErrors) 

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

755 res = {} 

756 for language in self.languages: 

757 res[language] = None 

758 if language in parsedData: 

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

760 res[language] = self.getEmptyValue() 

761 continue 

762 isEmpty = False 

763 filled_languages.add(language) 

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

765 res[language] = parsedVal 

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

767 for parseError in parseErrors: 

768 parseError.fieldPath.insert(0, language) 

769 errors.extend(parseErrors) 

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

771 res = [] 

772 for idx, singleValue in enumerate(parsedData): 

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

774 continue 

775 isEmpty = False 

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

777 res.append(parsedVal) 

778 

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

780 for parseError in parseErrors: 

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

782 errors.extend(parseErrors) 

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

784 if callable(self.multiple.sorted): 

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

786 else: 

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

788 else: # No Languages, not multiple 

789 if self.isEmpty(parsedData): 

790 res = self.getEmptyValue() 

791 isEmpty = True 

792 else: 

793 isEmpty = False 

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

795 if parseErrors: 

796 errors.extend(parseErrors) 

797 skel[name] = res 

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

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

800 if missing: 

801 return [ 

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

803 for lang in missing 

804 ] 

805 

806 if isEmpty: 

807 return [ReadFromClientError(ReadFromClientErrorSeverity.Empty)] 

808 

809 # Check multiple constraints on demand 

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

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

812 

813 return errors or None 

814 

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

816 """ 

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

818 The returned value must be hashable. 

819 """ 

820 return value 

821 

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

823 """ 

824 Returns a distinct hash value for this bone. 

825 The returned value must be hashable. 

826 """ 

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

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

829 

830 return value 

831 

832 def _validate_multiple_contraints( 

833 self, 

834 constraints: MultipleConstraints, 

835 skel: 'SkeletonInstance', 

836 name: str 

837 ) -> list[ReadFromClientError]: 

838 """ 

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

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

841 

842 :param constraints: The MultipleConstraints definition to apply. 

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

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

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

846 """ 

847 res = [] 

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

849 

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

851 res.append( 

852 ReadFromClientError( 

853 ReadFromClientErrorSeverity.Invalid, 

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

855 ) 

856 ) 

857 

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

859 res.append( 

860 ReadFromClientError( 

861 ReadFromClientErrorSeverity.Invalid, 

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

863 ) 

864 ) 

865 

866 if not constraints.duplicates: 

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

868 res.append( 

869 ReadFromClientError( 

870 ReadFromClientErrorSeverity.Invalid, 

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

872 ) 

873 ) 

874 

875 return res 

876 

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

878 """ 

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

880 

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

882 values. 

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

884 """ 

885 return value 

886 

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

888 """ 

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

890 

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

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

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

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

895 """ 

896 self.serialize_compute(skel, name) 

897 

898 if name in skel.accessedValues: 

899 empty_value = self.getEmptyValue() 

900 newVal = skel.accessedValues[name] 

901 

902 if self.languages and self.multiple: 

903 res = db.Entity() 

904 res["_viurLanguageWrapper_"] = True 

905 for language in self.languages: 

906 res[language] = [] 

907 if not self.indexed: 

908 res.exclude_from_indexes.add(language) 

909 if language in newVal: 

910 for singleValue in newVal[language]: 

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

912 if value != empty_value: 

913 res[language].append(value) 

914 

915 elif self.languages: 

916 res = db.Entity() 

917 res["_viurLanguageWrapper_"] = True 

918 for language in self.languages: 

919 res[language] = None 

920 if not self.indexed: 

921 res.exclude_from_indexes.add(language) 

922 if language in newVal: 

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

924 

925 elif self.multiple: 

926 res = [] 

927 

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

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

930 

931 for singleValue in (newVal or ()): 

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

933 if value != empty_value: 

934 res.append(value) 

935 

936 else: # No Languages, not Multiple 

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

938 

939 skel.dbEntity[name] = res 

940 

941 # Ensure our indexed flag is up2date 

942 indexed = self.indexed and parentIndexed 

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

944 skel.dbEntity.exclude_from_indexes.discard(name) 

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

946 skel.dbEntity.exclude_from_indexes.add(name) 

947 return True 

948 return False 

949 

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

951 """ 

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

953 value with the appropriate calculation method 

954 

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

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

957 """ 

958 if not self.compute: 

959 return None 

960 

961 match self.compute.interval.method: 

962 case ComputeMethod.OnWrite: 

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

964 

965 case ComputeMethod.Lifetime: 

966 now = utils.utcNow() 

967 

968 last_update = \ 

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

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

971 

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

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

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

975 

976 case ComputeMethod.Once: 

977 if name not in skel.dbEntity: 

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

979 

980 

981 def singleValueUnserialize(self, val): 

982 """ 

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

984 

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

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

987 """ 

988 return val 

989 

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

991 """ 

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

993 

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

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

996 

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

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

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

1000 """ 

1001 if name in skel.dbEntity: 

1002 loadVal = skel.dbEntity[name] 

1003 elif ( 

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

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

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

1007 # ... or computed 

1008 or self.compute 

1009 ): 

1010 loadVal = None 

1011 else: 

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

1013 return False 

1014 

1015 if self.unserialize_compute(skel, name): 

1016 return True 

1017 

1018 # unserialize value to given config 

1019 if self.languages and self.multiple: 

1020 res = {} 

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

1022 for language in self.languages: 

1023 res[language] = [] 

1024 if language in loadVal: 

1025 tmpVal = loadVal[language] 

1026 if not isinstance(tmpVal, list): 

1027 tmpVal = [tmpVal] 

1028 for singleValue in tmpVal: 

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

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

1031 for language in self.languages: 

1032 res[language] = [] 

1033 mainLang = self.languages[0] 

1034 if loadVal is None: 

1035 pass 

1036 elif isinstance(loadVal, list): 

1037 for singleValue in loadVal: 

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

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

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

1041 elif self.languages: 

1042 res = {} 

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

1044 for language in self.languages: 

1045 res[language] = None 

1046 if language in loadVal: 

1047 tmpVal = loadVal[language] 

1048 if isinstance(tmpVal, list) and tmpVal: 

1049 tmpVal = tmpVal[0] 

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

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

1052 for language in self.languages: 

1053 res[language] = None 

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

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

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

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

1058 mainLang = self.languages[0] 

1059 if loadVal is None: 

1060 pass 

1061 elif isinstance(loadVal, list) and loadVal: 

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

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

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

1065 elif self.multiple: 

1066 res = [] 

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

1068 # Pick one language we'll use 

1069 if conf.i18n.default_language in loadVal: 

1070 loadVal = loadVal[conf.i18n.default_language] 

1071 else: 

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

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

1074 loadVal = [loadVal] 

1075 if loadVal: 

1076 for val in loadVal: 

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

1078 else: # Not multiple, no languages 

1079 res = None 

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

1081 # Pick one language we'll use 

1082 if conf.i18n.default_language in loadVal: 

1083 loadVal = loadVal[conf.i18n.default_language] 

1084 else: 

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

1086 if loadVal and isinstance(loadVal, list): 

1087 loadVal = loadVal[0] 

1088 if loadVal is not None: 

1089 res = self.singleValueUnserialize(loadVal) 

1090 

1091 skel.accessedValues[name] = res 

1092 return True 

1093 

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

1095 """ 

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

1097 value with the appropriate calculation method 

1098 

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

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

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

1102 """ 

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

1104 return False 

1105 

1106 match self.compute.interval.method: 

1107 # Computation is bound to a lifetime? 

1108 case ComputeMethod.Lifetime: 

1109 now = utils.utcNow() 

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

1111 

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

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

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

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

1116 else: 

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

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

1119 

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

1121 # if so, recompute and refresh updated value 

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

1123 

1124 def transact(): 

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

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

1127 db_obj[name] = value 

1128 db.put(db_obj) 

1129 

1130 if db.is_in_transaction(): 

1131 transact() 

1132 else: 

1133 db.run_in_transaction(transact) 

1134 

1135 else: 

1136 # Run like ComputeMethod.Always on unwritten skeleton 

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

1138 

1139 return True 

1140 

1141 # Compute on every deserialization 

1142 case ComputeMethod.Always: 

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

1144 return True 

1145 

1146 return False 

1147 

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

1149 """ 

1150 Like postDeletedHandler, but runs inside the transaction 

1151 """ 

1152 pass 

1153 

1154 def buildDBFilter(self, 

1155 name: str, 

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

1157 dbFilter: db.Query, 

1158 rawFilter: dict, 

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

1160 """ 

1161 Parses the searchfilter a client specified in his Request into 

1162 something understood by the datastore. 

1163 This function must: 

1164 

1165 * - Ignore all filters not targeting this bone 

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

1167 

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

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

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

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

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

1173 """ 

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

1175 

1176 if len(myKeys) == 0: 

1177 return dbFilter 

1178 

1179 for key in myKeys: 

1180 value = rawFilter[key] 

1181 tmpdata = key.split("$") 

1182 

1183 if len(tmpdata) > 1: 

1184 if isinstance(value, list): 

1185 continue 

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

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

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

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

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

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

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

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

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

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

1196 else: 

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

1198 else: 

1199 if isinstance(value, list): 

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

1201 else: 

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

1203 

1204 return dbFilter 

1205 

1206 def buildDBSort( 

1207 self, 

1208 name: str, 

1209 skel: "SkeletonInstance", 

1210 query: db.Query, 

1211 params: dict, 

1212 postfix: str = "", 

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

1214 """ 

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

1216 the results, but by sorting them. 

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

1218 malformed data! 

1219 

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

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

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

1223 be applied to 

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

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

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

1227 None if the query is unsatisfiable. 

1228 """ 

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

1230 if self.languages: 

1231 lang = None 

1232 prefix = f"{name}." 

1233 if orderby.startswith(prefix): 

1234 lng = orderby[len(prefix):] 

1235 if lng in self.languages: 

1236 lang = lng 

1237 

1238 if lang is None: 

1239 lang = current.language.get() 

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

1241 lang = self.languages[0] 

1242 

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

1244 else: 

1245 prop = name 

1246 

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

1248 if isinstance(query.queries, list): 

1249 in_eq_filter = None 

1250 

1251 for item in query.queries: 

1252 new_in_eq_filter = [ 

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

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

1255 ] 

1256 if in_eq_filter and new_in_eq_filter and in_eq_filter != new_in_eq_filter: 

1257 raise NotImplementedError("Impossible ordering!") 

1258 

1259 in_eq_filter = new_in_eq_filter 

1260 

1261 else: 

1262 in_eq_filter = [ 

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

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

1265 ] 

1266 

1267 if in_eq_filter: 

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

1269 if orderby_prop != prop: 

1270 logging.warning( 

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

1272 ) 

1273 prop = orderby_prop 

1274 

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

1276 

1277 return query 

1278 

1279 def _hashValueForUniquePropertyIndex( 

1280 self, 

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

1282 ) -> list[str]: 

1283 """ 

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

1285 

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

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

1288 implement their own logic for hashing values. 

1289 

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

1291 

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

1293 the list may contain more than one hashed value. 

1294 """ 

1295 

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

1297 h = hashlib.sha256() 

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

1299 res = h.hexdigest() 

1300 if isinstance(value, int | float): 

1301 return f"I-{res}" 

1302 elif isinstance(value, str): 

1303 return f"S-{res}" 

1304 elif isinstance(value, db.Key): 

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

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

1307 def keyHash(key): 

1308 if key is None: 

1309 return "-" 

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

1311 

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

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

1314 

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

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

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

1318 return [hashValue(value)] 

1319 # We have a multiple bone or multiple values here 

1320 if not isinstance(value, list): 

1321 value = [value] 

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

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

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

1325 return tmpList 

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

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

1328 tmpList.sort() 

1329 # Lock the value for that specific list 

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

1331 

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

1333 """ 

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

1335 unique property value index. 

1336 

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

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

1339 are required (not the description!). 

1340 

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

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

1343 """ 

1344 val = skel[name] 

1345 if val is None: 

1346 return [] 

1347 return self._hashValueForUniquePropertyIndex(val) 

1348 

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

1350 """ 

1351 Returns a set of blob keys referenced from this bone 

1352 """ 

1353 return set() 

1354 

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

1356 """ 

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

1358 or the current user. 

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

1360 """ 

1361 pass # We do nothing by default 

1362 

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

1364 """ 

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

1366 

1367 :param boneName: Name of this bone 

1368 :param skel: The skeleton this bone belongs to 

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

1370 """ 

1371 pass 

1372 

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

1374 """ 

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

1376 

1377 :param skel: The skeleton this bone belongs to 

1378 :param boneName: Name of this bone 

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

1380 """ 

1381 pass 

1382 

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

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

1385 match self.clone_behavior.strategy: 

1386 case CloneStrategy.COPY_VALUE: 

1387 try: 

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

1389 except KeyError: 

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

1391 try: 

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

1393 except KeyError: 

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

1395 case CloneStrategy.SET_NULL: 

1396 skel.accessedValues[bone_name] = None 

1397 case CloneStrategy.SET_DEFAULT: 

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

1399 case CloneStrategy.SET_EMPTY: 

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

1401 case CloneStrategy.CUSTOM: 

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

1403 case other: 

1404 raise NotImplementedError(other) 

1405 

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

1407 """ 

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

1409 """ 

1410 pass 

1411 

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

1413 """ 

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

1415 

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

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

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

1419 are to be merged. 

1420 

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

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

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

1424 operation. 

1425 """ 

1426 if getattr(otherSkel, boneName) is None: 

1427 return 

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

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

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

1431 return 

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

1433 

1434 def setBoneValue(self, 

1435 skel: 'SkeletonInstance', 

1436 boneName: str, 

1437 value: t.Any, 

1438 append: bool, 

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

1440 """ 

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

1442 values. Sanity checks are being performed. 

1443 

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

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

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

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

1448 Only supported for bones with multiple=True. 

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

1450 if the bone supports languages. 

1451 

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

1453 

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

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

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

1457 """ 

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

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

1460 

1461 if not append and self.multiple: 

1462 # set multiple values at once 

1463 val = [] 

1464 errors = [] 

1465 for singleValue in value: 

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

1467 val.append(singleValue) 

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

1469 errors.extend(singleError) 

1470 else: 

1471 # set or append one value 

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

1473 

1474 if errors: 

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

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

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

1478 logging.error(e) 

1479 return False 

1480 if not append and not language: 

1481 skel[boneName] = val 

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

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

1484 skel[boneName][language] = [] 

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

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

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

1488 skel[boneName] = [] 

1489 skel[boneName].append(val) 

1490 else: # Just language 

1491 skel[boneName][language] = val 

1492 return True 

1493 

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

1495 """ 

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

1497 

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

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

1500 

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

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

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

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

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

1506 any searchable content, an empty set is returned. 

1507 """ 

1508 return set() 

1509 

1510 def iter_bone_value( 

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

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

1513 """ 

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

1515 

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

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

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

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

1520 and value is the value inside this container. 

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

1522 

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

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

1525 

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

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

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

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

1530 

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

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

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

1534 single or not multi-lang. 

1535 """ 

1536 value = skel[name] 

1537 if not value: 

1538 return None 

1539 

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

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

1542 if self.multiple: 

1543 if not values: 

1544 continue 

1545 for val in values: 

1546 yield idx, lang, val 

1547 else: 

1548 yield None, lang, values 

1549 else: 

1550 if self.multiple: 

1551 for idx, val in enumerate(value): 

1552 yield idx, None, val 

1553 else: 

1554 yield None, None, value 

1555 

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

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

1558 

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

1560 compute_fn_args = {} 

1561 if "skel" in compute_fn_parameters: 

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

1563 

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

1565 cloned_skel = skeletonByKind(skel.kindName)() 

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

1567 raise ValueError( 

1568 f"{bone_name!r}: {skel["key"]=!r} does no longer exists. Cannot compute a broken relation" 

1569 ) 

1570 else: 

1571 cloned_skel = skel.clone() 

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

1573 compute_fn_args["skel"] = cloned_skel 

1574 

1575 if "bone" in compute_fn_parameters: 

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

1577 

1578 if "bone_name" in compute_fn_parameters: 

1579 compute_fn_args["bone_name"] = bone_name 

1580 

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

1582 

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

1584 if self.multiple: 

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

1586 return self.singleValueUnserialize(raw_value) 

1587 

1588 if self.compute.raw: 

1589 if self.languages: 

1590 return { 

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

1592 for lang in self.languages 

1593 } 

1594 return unserialize_raw_value(ret) 

1595 self._prevent_compute = True 

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

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

1598 self._prevent_compute = False 

1599 return skel[bone_name] 

1600 

1601 def structure(self) -> dict: 

1602 """ 

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

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

1605 """ 

1606 ret = { 

1607 "descr": self.descr, 

1608 "type": self.type, 

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

1610 "params": self.params, 

1611 "visible": self.visible, 

1612 "readonly": self.readOnly, 

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

1614 "languages": self.languages, 

1615 "emptyvalue": self.getEmptyValue(), 

1616 "indexed": self.indexed, 

1617 "clone_behavior": { 

1618 "strategy": self.clone_behavior.strategy, 

1619 }, 

1620 } 

1621 

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

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

1624 ret["defaultvalue"] = self.defaultValue 

1625 

1626 # Provide a multiple setting 

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

1628 ret["multiple"] = { 

1629 "duplicates": self.multiple.duplicates, 

1630 "max": self.multiple.max, 

1631 "min": self.multiple.min, 

1632 } 

1633 else: 

1634 ret["multiple"] = self.multiple 

1635 

1636 # Provide compute information 

1637 if self.compute: 

1638 ret["compute"] = { 

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

1640 } 

1641 

1642 if self.compute.interval.lifetime: 

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

1644 

1645 return ret 

1646 

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

1648 """ 

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

1650 

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

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

1653 JSON. 

1654 

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

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

1657 

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

1659 """ 

1660 ret = {} 

1661 bone_value = skel[bone_name] 

1662 if self.languages and self.multiple: 

1663 for language in self.languages: 

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

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

1666 else: 

1667 ret[language] = [] 

1668 elif self.languages: 

1669 for language in self.languages: 

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

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

1672 else: 

1673 ret[language] = None 

1674 elif self.multiple: 

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

1676 

1677 else: 

1678 ret = self._atomic_dump(bone_value) 

1679 return ret 

1680 

1681 def _atomic_dump(self, value): 

1682 """ 

1683 One atomic value of the bone. 

1684 """ 

1685 return value