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

798 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-03 12:27 +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 dataclasses 

10import enum 

11import hashlib 

12import inspect 

13import logging 

14import typing as t 

15from collections.abc import Iterable 

16from dataclasses import dataclass, field 

17from datetime import timedelta 

18from enum import Enum 

19 

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

21from viur.core.config import conf 

22 

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

24 from ..skeleton import Skeleton, SkeletonInstance 

25 

26__system_initialized = False 

27""" 

28Initializes the global variable __system_initialized 

29""" 

30 

31 

32def setSystemInitialized(): 

33 """ 

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

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

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

37 setSystemInitialized() method. 

38 

39 Global variables: 

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

41 """ 

42 global __system_initialized 

43 from viur.core.skeleton import iterAllSkelClasses 

44 

45 for skelCls in iterAllSkelClasses(): 

46 skelCls.setSystemInitialized() 

47 

48 __system_initialized = True 

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

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 __str__(self): 

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

93 

94 

95class ReadFromClientException(Exception): 

96 """ 

97 ReadFromClientError as an Exception to raise. 

98 """ 

99 

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

101 """ 

102 This is an exception holding ReadFromClientErrors. 

103 

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

105 """ 

106 super().__init__() 

107 

108 # Allow to specifiy a single ReadFromClientError 

109 if isinstance(errors, ReadFromClientError): 

110 errors = (ReadFromClientError, ) 

111 

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

113 

114 # Disallow ReadFromClientException without any ReadFromClientErrors 

115 if not self.errors: 

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

117 

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

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

120 notes_errors = tuple( 

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

122 ) 

123 

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

125 

126 

127class UniqueLockMethod(Enum): 

128 """ 

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

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

131 enforced. 

132 """ 

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

134 """ 

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

136 is multiple. 

137 """ 

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

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

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

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

142 

143 

144@dataclass 

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

146 """ 

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

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

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

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

151 """ 

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

153 """ 

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

155 multiple=True. 

156 """ 

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

158 """ 

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

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

161 """ 

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

163 """ 

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

165 taken. 

166 """ 

167 

168 

169@dataclass 

170class MultipleConstraints: 

171 """ 

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

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

174 """ 

175 min: int = 0 

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

177 max: int = 0 

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

179 duplicates: bool = False 

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

181 sorted: bool | t.Callable = False 

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

183 reversed: bool = False 

184 """ 

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

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

187 """ 

188 

189class ComputeMethod(Enum): 

190 Always = 0 # Always compute on deserialization 

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

192 Once = 2 # Compute only once 

193 OnWrite = 3 # Compute before written 

194 

195 

196@dataclass 

197class ComputeInterval: 

198 method: ComputeMethod = ComputeMethod.Always 

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

200 

201 

202@dataclass 

203class Compute: 

204 fn: callable # the callable computing the value 

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

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

207 

208 

209class CloneStrategy(enum.StrEnum): 

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

211 

212 SET_NULL = enum.auto() 

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

214 

215 SET_DEFAULT = enum.auto() 

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

217 

218 SET_EMPTY = enum.auto() 

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

220 

221 COPY_VALUE = enum.auto() 

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

223 

224 CUSTOM = enum.auto() 

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

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

227 """ 

228 

229 

230class CloneCustomFunc(t.Protocol): 

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

232 

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

234 """Return the value for the cloned bone""" 

235 ... 

236 

237 

238@dataclass 

239class CloneBehavior: 

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

241 

242 strategy: CloneStrategy 

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

244 

245 custom_func: CloneCustomFunc = None 

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

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

248 """ 

249 

250 def __post_init__(self): 

251 """Validate this configuration.""" 

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

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

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

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

256 

257 

258class BaseBone(object): 

259 """ 

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

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

262 

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

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

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

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

267 language must be entered. 

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

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

270 without the need of also been indexed. 

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

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

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

274 error-message for the user otherwise. 

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

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

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

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

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

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

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

282 

283 .. NOTE:: 

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

285 """ 

286 type = "hidden" 

287 isClonedInstance = False 

288 

289 skel_cls = None 

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

291 

292 name = None 

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

294 

295 def __init__( 

296 self, 

297 *, 

298 compute: Compute = None, 

299 defaultValue: t.Any = None, 

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

301 getEmptyValueFunc: callable = None, 

302 indexed: bool = True, 

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

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

305 multiple: bool | MultipleConstraints = False, 

306 params: dict = None, 

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

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

309 searchable: bool = False, 

310 type_suffix: str = "", 

311 unique: None | UniqueValue = None, 

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

313 visible: bool = True, 

314 clone_behavior: CloneBehavior | CloneStrategy | None = None, 

315 ): 

316 """ 

317 Initializes a new Bone. 

318 """ 

319 self.isClonedInstance = getSystemInitialized() 

320 

321 # Standard definitions 

322 self.descr = descr 

323 self.params = params or {} 

324 self.multiple = multiple 

325 self.required = required 

326 self.readOnly = bool(readOnly) 

327 self.searchable = searchable 

328 self.visible = visible 

329 self.indexed = indexed 

330 

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

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

333 

334 if isinstance(category := self.params.get("category"), str): 334 ↛ 335line 334 didn't jump to line 335 because the condition on line 334 was never true

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

336 

337 # Multi-language support 

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

339 languages is None or 

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

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

342 ): 

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

344 

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

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

347 

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

349 not isinstance(required, bool) 

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

351 ): 

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

353 

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

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

356 

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

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

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

360 

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

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

363 try: 

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

365 except TypeError: 

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

367 

368 self.languages = languages 

369 

370 # Default value 

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

372 # multiple or has languages 

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

374 if self.languages: 

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

376 self.defaultValue = defaultValue 

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

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

379 elif "__default__" in defaultValue: 

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

381 for lang in self.languages} 

382 else: 

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

384 else: 

385 self.defaultValue = default 

386 

387 # Unique values 

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

389 if not isinstance(unique, UniqueValue): 

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

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

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

393 

394 self.unique = unique 

395 

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

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

398 if vfunc: 

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

400 

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

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

403 

404 if getEmptyValueFunc: 

405 self.getEmptyValue = getEmptyValueFunc 

406 

407 if compute: 407 ↛ 408line 407 didn't jump to line 408 because the condition on line 407 was never true

408 if not isinstance(compute, Compute): 

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

410 if not isinstance(compute.fn, t.Callable): 

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

412 # When readOnly is None, handle flag automatically 

413 if readOnly is None: 

414 self.readOnly = True 

415 if not self.readOnly: 

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

417 

418 if ( 

419 compute.interval.method == ComputeMethod.Lifetime 

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

421 ): 

422 raise ValueError( 

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

424 ) 

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

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

427 self._prevent_compute = False 

428 

429 self.compute = compute 

430 

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

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

433 self.clone_behavior = CloneBehavior(CloneStrategy.SET_DEFAULT) 

434 else: 

435 self.clone_behavior = CloneBehavior(CloneStrategy.COPY_VALUE) 

436 # TODO: Any different setting for computed bones? 

437 elif isinstance(clone_behavior, CloneStrategy): 

438 self.clone_behavior = CloneBehavior(strategy=clone_behavior) 

439 elif isinstance(clone_behavior, CloneBehavior): 

440 self.clone_behavior = clone_behavior 

441 else: 

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

443 

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

445 self.skel_cls = owner 

446 self.name = name 

447 

448 def setSystemInitialized(self) -> None: 

449 """ 

450 Can be overridden to initialize properties that depend on the Skeleton system 

451 being initialized. 

452 

453 Here, in the BaseBone, we set descr to the bone_name if no descr argument 

454 was given in __init__ and make sure that it is a :class:i18n.translate` object. 

455 """ 

456 if self.descr is None: 

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

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

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

460 if self.descr and isinstance(self.descr, str): 

461 super().__setattr__( 

462 "descr", 

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

464 ) 

465 

466 def isInvalid(self, value): 

467 """ 

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

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

470 """ 

471 return False 

472 

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

474 """ 

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

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

477 

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

479 valid - unless the bone is required. 

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

481 

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

483 input!) or the value returned by get 

484 """ 

485 return not bool(value) 

486 

487 def getDefaultValue(self, skeletonInstance): 

488 """ 

489 Retrieves the default value for the bone. 

490 

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

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

493 providing a default value. 

494 

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

496 """ 

497 if callable(self.defaultValue): 

498 res = self.defaultValue(skeletonInstance, self) 

499 if self.languages and self.multiple: 

500 if not isinstance(res, dict): 

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

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

503 else: 

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

505 elif self.languages: 

506 if not isinstance(res, dict): 

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

508 elif self.multiple: 

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

510 return [res] 

511 return res 

512 

513 elif isinstance(self.defaultValue, list): 

514 return self.defaultValue[:] 

515 elif isinstance(self.defaultValue, dict): 

516 return self.defaultValue.copy() 

517 else: 

518 return self.defaultValue 

519 

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

521 """ 

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

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

524 """ 

525 return None 

526 

527 def __setattr__(self, key, value): 

528 """ 

529 Custom attribute setter for the BaseBone class. 

530 

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

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

533 method unless they have additional attributes with similar constraints. 

534 

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

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

537 

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

539 assignment. 

540 """ 

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

542 "_"): 

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

544 super().__setattr__(key, value) 

545 

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

547 """ 

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

549 

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

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

552 

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

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

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

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

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

558 

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

560 """ 

561 fieldSubmitted = False 

562 

563 if languages: 

564 res = {} 

565 for lang in languages: 

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

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

568 fieldSubmitted = True 

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

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

571 res[lang] = [res[lang]] 

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

573 if res[lang]: 

574 res[lang] = res[lang][0] 

575 else: 

576 res[lang] = None 

577 else: 

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

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

580 fieldSubmitted = True 

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

582 if multiple: 

583 tmpDict = {} 

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

585 if not key.startswith(prefix): 

586 continue 

587 fieldSubmitted = True 

588 partKey = key[len(prefix):] 

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

590 try: 

591 firstKey = int(firstKey) 

592 except: 

593 continue 

594 if firstKey not in tmpDict: 

595 tmpDict[firstKey] = {} 

596 tmpDict[firstKey][remainingKey] = value 

597 tmpList = list(tmpDict.items()) 

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

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

600 else: 

601 tmpDict = {} 

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

603 if not key.startswith(prefix): 

604 continue 

605 fieldSubmitted = True 

606 partKey = key[len(prefix):] 

607 tmpDict[partKey] = value 

608 res[lang] = tmpDict 

609 return res, fieldSubmitted 

610 else: # No multi-lang 

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

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

613 return None, False 

614 val = data[name] 

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

616 return [val], True 

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

618 if val: 

619 return val[0], True 

620 else: 

621 return None, True # Empty! 

622 else: 

623 return val, True 

624 else: # No multi-lang but collect subfields 

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

626 if key == name: 

627 fieldSubmitted = True 

628 prefix = f"{name}." 

629 if multiple: 

630 tmpDict = {} 

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

632 if not key.startswith(prefix): 

633 continue 

634 fieldSubmitted = True 

635 partKey = key[len(prefix):] 

636 try: 

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

638 firstKey = int(firstKey) 

639 except: 

640 continue 

641 if firstKey not in tmpDict: 

642 tmpDict[firstKey] = {} 

643 tmpDict[firstKey][remainingKey] = value 

644 tmpList = list(tmpDict.items()) 

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

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

647 else: 

648 res = {} 

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

650 if not key.startswith(prefix): 

651 continue 

652 fieldSubmitted = True 

653 subKey = key[len(prefix):] 

654 res[subKey] = value 

655 return res, fieldSubmitted 

656 

657 def parseSubfieldsFromClient(self) -> bool: 

658 """ 

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

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

661 """ 

662 return False 

663 

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

665 bone_name: str, client_data: dict 

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

667 """Load a single value from a client 

668 

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

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

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

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

673 a dictionary with usually bone names as key 

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

675 the parsed value and the second is None. 

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

677 and the second a list of *ReadFromClientError*. 

678 """ 

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

680 return self.getEmptyValue(), [ 

681 ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Will not read a BaseBone fromClient!")] 

682 

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

684 """ 

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

686 

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

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

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

690 

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

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

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

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

695 """ 

696 subFields = self.parseSubfieldsFromClient() 

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

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

699 return [ReadFromClientError(ReadFromClientErrorSeverity.NotSet, "Field not submitted")] 

700 errors = [] 

701 isEmpty = True 

702 filled_languages = set() 

703 if self.languages and self.multiple: 

704 res = {} 

705 for language in self.languages: 

706 res[language] = [] 

707 if language in parsedData: 

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

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

710 continue 

711 isEmpty = False 

712 filled_languages.add(language) 

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

714 res[language].append(parsedVal) 

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

716 if callable(self.multiple.sorted): 

717 res[language] = sorted( 

718 res[language], 

719 key=self.multiple.sorted, 

720 reverse=self.multiple.reversed, 

721 ) 

722 else: 

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

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

725 for parseError in parseErrors: 

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

727 errors.extend(parseErrors) 

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

729 res = {} 

730 for language in self.languages: 

731 res[language] = None 

732 if language in parsedData: 

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

734 res[language] = self.getEmptyValue() 

735 continue 

736 isEmpty = False 

737 filled_languages.add(language) 

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

739 res[language] = parsedVal 

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

741 for parseError in parseErrors: 

742 parseError.fieldPath.insert(0, language) 

743 errors.extend(parseErrors) 

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

745 res = [] 

746 for idx, singleValue in enumerate(parsedData): 

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

748 continue 

749 isEmpty = False 

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

751 res.append(parsedVal) 

752 

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

754 for parseError in parseErrors: 

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

756 errors.extend(parseErrors) 

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

758 if callable(self.multiple.sorted): 

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

760 else: 

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

762 else: # No Languages, not multiple 

763 if self.isEmpty(parsedData): 

764 res = self.getEmptyValue() 

765 isEmpty = True 

766 else: 

767 isEmpty = False 

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

769 if parseErrors: 

770 errors.extend(parseErrors) 

771 skel[name] = res 

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

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

774 if missing: 

775 return [ReadFromClientError(ReadFromClientErrorSeverity.Empty, "Field not set", fieldPath=[lang]) 

776 for lang in missing] 

777 if isEmpty: 

778 return [ReadFromClientError(ReadFromClientErrorSeverity.Empty, "Field not set")] 

779 

780 # Check multiple constraints on demand 

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

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

783 

784 return errors or None 

785 

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

787 """ 

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

789 The returned value must be hashable. 

790 """ 

791 return value 

792 

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

794 """ 

795 Returns a distinct hash value for this bone. 

796 The returned value must be hashable. 

797 """ 

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

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

800 

801 return value 

802 

803 def _validate_multiple_contraints( 

804 self, 

805 constraints: MultipleConstraints, 

806 skel: 'SkeletonInstance', 

807 name: str 

808 ) -> list[ReadFromClientError]: 

809 """ 

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

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

812 

813 :param constraints: The MultipleConstraints definition to apply. 

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

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

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

817 """ 

818 res = [] 

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

820 

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

822 res.append(ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Too few items")) 

823 

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

825 res.append(ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Too many items")) 

826 

827 if not constraints.duplicates: 

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

829 res.append(ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Duplicate items")) 

830 

831 return res 

832 

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

834 """ 

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

836 

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

838 values. 

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

840 """ 

841 return value 

842 

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

844 """ 

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

846 

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

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

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

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

851 """ 

852 self.serialize_compute(skel, name) 

853 

854 if name in skel.accessedValues: 

855 newVal = skel.accessedValues[name] 

856 if self.languages and self.multiple: 

857 res = db.Entity() 

858 res["_viurLanguageWrapper_"] = True 

859 for language in self.languages: 

860 res[language] = [] 

861 if not self.indexed: 

862 res.exclude_from_indexes.add(language) 

863 if language in newVal: 

864 for singleValue in newVal[language]: 

865 res[language].append(self.singleValueSerialize(singleValue, skel, name, parentIndexed)) 

866 elif self.languages: 

867 res = db.Entity() 

868 res["_viurLanguageWrapper_"] = True 

869 for language in self.languages: 

870 res[language] = None 

871 if not self.indexed: 

872 res.exclude_from_indexes.add(language) 

873 if language in newVal: 

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

875 elif self.multiple: 

876 res = [] 

877 

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

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

880 

881 for singleValue in (newVal or ()): 

882 res.append(self.singleValueSerialize(singleValue, skel, name, parentIndexed)) 

883 

884 else: # No Languages, not Multiple 

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

886 skel.dbEntity[name] = res 

887 # Ensure our indexed flag is up2date 

888 indexed = self.indexed and parentIndexed 

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

890 skel.dbEntity.exclude_from_indexes.discard(name) 

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

892 skel.dbEntity.exclude_from_indexes.add(name) 

893 return True 

894 return False 

895 

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

897 """ 

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

899 value with the appropriate calculation method 

900 

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

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

903 """ 

904 if not self.compute: 

905 return None 

906 match self.compute.interval.method: 

907 case ComputeMethod.OnWrite: 

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

909 

910 case ComputeMethod.Lifetime: 

911 now = utils.utcNow() 

912 

913 last_update = \ 

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

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

916 

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

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

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

920 

921 case ComputeMethod.Once: 

922 if name not in skel.dbEntity: 

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

924 

925 

926 def singleValueUnserialize(self, val): 

927 """ 

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

929 

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

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

932 """ 

933 return val 

934 

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

936 """ 

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

938 

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

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

941 

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

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

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

945 """ 

946 if name in skel.dbEntity: 

947 loadVal = skel.dbEntity[name] 

948 elif ( 

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

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

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

952 # ... or computed 

953 or self.compute 

954 ): 

955 loadVal = None 

956 else: 

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

958 return False 

959 

960 if self.unserialize_compute(skel, name): 

961 return True 

962 

963 # unserialize value to given config 

964 if self.languages and self.multiple: 

965 res = {} 

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

967 for language in self.languages: 

968 res[language] = [] 

969 if language in loadVal: 

970 tmpVal = loadVal[language] 

971 if not isinstance(tmpVal, list): 

972 tmpVal = [tmpVal] 

973 for singleValue in tmpVal: 

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

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

976 for language in self.languages: 

977 res[language] = [] 

978 mainLang = self.languages[0] 

979 if loadVal is None: 

980 pass 

981 elif isinstance(loadVal, list): 

982 for singleValue in loadVal: 

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

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

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

986 elif self.languages: 

987 res = {} 

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

989 for language in self.languages: 

990 res[language] = None 

991 if language in loadVal: 

992 tmpVal = loadVal[language] 

993 if isinstance(tmpVal, list) and tmpVal: 

994 tmpVal = tmpVal[0] 

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

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

997 for language in self.languages: 

998 res[language] = None 

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

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

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

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

1003 mainLang = self.languages[0] 

1004 if loadVal is None: 

1005 pass 

1006 elif isinstance(loadVal, list) and loadVal: 

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

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

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

1010 elif self.multiple: 

1011 res = [] 

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

1013 # Pick one language we'll use 

1014 if conf.i18n.default_language in loadVal: 

1015 loadVal = loadVal[conf.i18n.default_language] 

1016 else: 

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

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

1019 loadVal = [loadVal] 

1020 if loadVal: 

1021 for val in loadVal: 

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

1023 else: # Not multiple, no languages 

1024 res = None 

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

1026 # Pick one language we'll use 

1027 if conf.i18n.default_language in loadVal: 

1028 loadVal = loadVal[conf.i18n.default_language] 

1029 else: 

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

1031 if loadVal and isinstance(loadVal, list): 

1032 loadVal = loadVal[0] 

1033 if loadVal is not None: 

1034 res = self.singleValueUnserialize(loadVal) 

1035 

1036 skel.accessedValues[name] = res 

1037 return True 

1038 

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

1040 """ 

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

1042 value with the appropriate calculation method 

1043 

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

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

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

1047 """ 

1048 if not self.compute or self._prevent_compute: 

1049 return False 

1050 

1051 match self.compute.interval.method: 

1052 # Computation is bound to a lifetime? 

1053 case ComputeMethod.Lifetime: 

1054 now = utils.utcNow() 

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

1056 

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

1058 db_obj = db.Get(skel["key"]) 

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

1060 else: 

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

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

1063 

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

1065 # if so, recompute and refresh updated value 

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

1067 def transact(): 

1068 db_obj = db.Get(skel["key"]) 

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

1070 db_obj[name] = value 

1071 db.Put(db_obj) 

1072 

1073 if db.IsInTransaction(): 

1074 transact() 

1075 else: 

1076 db.RunInTransaction(transact) 

1077 

1078 return True 

1079 

1080 # Compute on every deserialization 

1081 case ComputeMethod.Always: 

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

1083 return True 

1084 

1085 return False 

1086 

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

1088 """ 

1089 Like postDeletedHandler, but runs inside the transaction 

1090 """ 

1091 pass 

1092 

1093 def buildDBFilter(self, 

1094 name: str, 

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

1096 dbFilter: db.Query, 

1097 rawFilter: dict, 

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

1099 """ 

1100 Parses the searchfilter a client specified in his Request into 

1101 something understood by the datastore. 

1102 This function must: 

1103 

1104 * - Ignore all filters not targeting this bone 

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

1106 

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

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

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

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

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

1112 """ 

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

1114 

1115 if len(myKeys) == 0: 

1116 return dbFilter 

1117 

1118 for key in myKeys: 

1119 value = rawFilter[key] 

1120 tmpdata = key.split("$") 

1121 

1122 if len(tmpdata) > 1: 

1123 if isinstance(value, list): 

1124 continue 

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

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

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

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

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

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

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

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

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

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

1135 else: 

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

1137 else: 

1138 if isinstance(value, list): 

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

1140 else: 

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

1142 

1143 return dbFilter 

1144 

1145 def buildDBSort( 

1146 self, 

1147 name: str, 

1148 skel: "SkeletonInstance", 

1149 query: db.Query, 

1150 params: dict, 

1151 postfix: str = "", 

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

1153 """ 

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

1155 the results, but by sorting them. 

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

1157 malformed data! 

1158 

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

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

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

1162 be applied to 

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

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

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

1166 None if the query is unsatisfiable. 

1167 """ 

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

1169 if self.languages: 

1170 lang = None 

1171 prefix = f"{name}." 

1172 if orderby.startswith(prefix): 

1173 lng = orderby[len(prefix):] 

1174 if lng in self.languages: 

1175 lang = lng 

1176 

1177 if lang is None: 

1178 lang = current.language.get() 

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

1180 lang = self.languages[0] 

1181 

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

1183 else: 

1184 prop = name 

1185 

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

1187 if isinstance(query.queries, list): 

1188 in_eq_filter = None 

1189 

1190 for item in query.queries: 

1191 new_in_eq_filter = [ 

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

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

1194 ] 

1195 if in_eq_filter and new_in_eq_filter and in_eq_filter != new_in_eq_filter: 

1196 raise NotImplementedError("Impossible ordering!") 

1197 

1198 in_eq_filter = new_in_eq_filter 

1199 

1200 else: 

1201 in_eq_filter = [ 

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

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

1204 ] 

1205 

1206 if in_eq_filter: 

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

1208 if orderby_prop != prop: 

1209 logging.warning( 

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

1211 ) 

1212 prop = orderby_prop 

1213 

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

1215 

1216 return query 

1217 

1218 def _hashValueForUniquePropertyIndex( 

1219 self, 

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

1221 ) -> list[str]: 

1222 """ 

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

1224 

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

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

1227 implement their own logic for hashing values. 

1228 

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

1230 

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

1232 the list may contain more than one hashed value. 

1233 """ 

1234 

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

1236 h = hashlib.sha256() 

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

1238 res = h.hexdigest() 

1239 if isinstance(value, int | float): 

1240 return f"I-{res}" 

1241 elif isinstance(value, str): 

1242 return f"S-{res}" 

1243 elif isinstance(value, db.Key): 

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

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

1246 def keyHash(key): 

1247 if key is None: 

1248 return "-" 

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

1250 

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

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

1253 

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

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

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

1257 return [hashValue(value)] 

1258 # We have a multiple bone or multiple values here 

1259 if not isinstance(value, list): 

1260 value = [value] 

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

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

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

1264 return tmpList 

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

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

1267 tmpList.sort() 

1268 # Lock the value for that specific list 

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

1270 

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

1272 """ 

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

1274 unique property value index. 

1275 

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

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

1278 are required (not the description!). 

1279 

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

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

1282 """ 

1283 val = skel[name] 

1284 if val is None: 

1285 return [] 

1286 return self._hashValueForUniquePropertyIndex(val) 

1287 

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

1289 """ 

1290 Returns a set of blob keys referenced from this bone 

1291 """ 

1292 return set() 

1293 

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

1295 """ 

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

1297 or the current user. 

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

1299 """ 

1300 pass # We do nothing by default 

1301 

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

1303 """ 

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

1305 

1306 :param boneName: Name of this bone 

1307 :param skel: The skeleton this bone belongs to 

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

1309 """ 

1310 pass 

1311 

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

1313 """ 

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

1315 

1316 :param skel: The skeleton this bone belongs to 

1317 :param boneName: Name of this bone 

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

1319 """ 

1320 pass 

1321 

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

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

1324 match self.clone_behavior.strategy: 

1325 case CloneStrategy.COPY_VALUE: 

1326 try: 

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

1328 except KeyError: 

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

1330 try: 

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

1332 except KeyError: 

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

1334 case CloneStrategy.SET_NULL: 

1335 skel.accessedValues[bone_name] = None 

1336 case CloneStrategy.SET_DEFAULT: 

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

1338 case CloneStrategy.SET_EMPTY: 

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

1340 case CloneStrategy.CUSTOM: 

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

1342 case other: 

1343 raise NotImplementedError(other) 

1344 

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

1346 """ 

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

1348 """ 

1349 pass 

1350 

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

1352 """ 

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

1354 

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

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

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

1358 are to be merged. 

1359 

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

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

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

1363 operation. 

1364 """ 

1365 if getattr(otherSkel, boneName) is None: 

1366 return 

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

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

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

1370 return 

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

1372 

1373 def setBoneValue(self, 

1374 skel: 'SkeletonInstance', 

1375 boneName: str, 

1376 value: t.Any, 

1377 append: bool, 

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

1379 """ 

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

1381 values. Sanity checks are being performed. 

1382 

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

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

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

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

1387 Only supported for bones with multiple=True. 

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

1389 if the bone supports languages. 

1390 

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

1392 

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

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

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

1396 """ 

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

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

1399 

1400 if not append and self.multiple: 

1401 # set multiple values at once 

1402 val = [] 

1403 errors = [] 

1404 for singleValue in value: 

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

1406 val.append(singleValue) 

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

1408 errors.extend(singleError) 

1409 else: 

1410 # set or append one value 

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

1412 

1413 if errors: 

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

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

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

1417 logging.error(e) 

1418 return False 

1419 if not append and not language: 

1420 skel[boneName] = val 

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

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

1423 skel[boneName][language] = [] 

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

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

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

1427 skel[boneName] = [] 

1428 skel[boneName].append(val) 

1429 else: # Just language 

1430 skel[boneName][language] = val 

1431 return True 

1432 

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

1434 """ 

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

1436 

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

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

1439 

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

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

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

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

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

1445 any searchable content, an empty set is returned. 

1446 """ 

1447 return set() 

1448 

1449 def iter_bone_value( 

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

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

1452 """ 

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

1454 

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

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

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

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

1459 and value is the value inside this container. 

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

1461 

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

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

1464 

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

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

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

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

1469 

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

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

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

1473 single or not multi-lang. 

1474 """ 

1475 value = skel[name] 

1476 if not value: 

1477 return None 

1478 

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

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

1481 if self.multiple: 

1482 if not values: 

1483 continue 

1484 for val in values: 

1485 yield idx, lang, val 

1486 else: 

1487 yield None, lang, values 

1488 else: 

1489 if self.multiple: 

1490 for idx, val in enumerate(value): 

1491 yield idx, None, val 

1492 else: 

1493 yield None, None, value 

1494 

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

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

1497 

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

1499 compute_fn_args = {} 

1500 if "skel" in compute_fn_parameters: 

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

1502 

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

1504 cloned_skel = skeletonByKind(skel.kindName)() 

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

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

1507 else: 

1508 cloned_skel = skel.clone() 

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

1510 compute_fn_args["skel"] = cloned_skel 

1511 

1512 if "bone" in compute_fn_parameters: 

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

1514 

1515 if "bone_name" in compute_fn_parameters: 

1516 compute_fn_args["bone_name"] = bone_name 

1517 

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

1519 

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

1521 if self.multiple: 

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

1523 return self.singleValueUnserialize(raw_value) 

1524 

1525 if self.compute.raw: 

1526 if self.languages: 

1527 return { 

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

1529 for lang in self.languages 

1530 } 

1531 return unserialize_raw_value(ret) 

1532 self._prevent_compute = True 

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

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

1535 self._prevent_compute = False 

1536 return skel[bone_name] 

1537 

1538 def structure(self) -> dict: 

1539 """ 

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

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

1542 """ 

1543 ret = { 

1544 "descr": self.descr, 

1545 "type": self.type, 

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

1547 "params": self.params, 

1548 "visible": self.visible, 

1549 "readonly": self.readOnly, 

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

1551 "languages": self.languages, 

1552 "emptyvalue": self.getEmptyValue(), 

1553 "indexed": self.indexed, 

1554 "clone_behavior": { 

1555 "strategy": self.clone_behavior.strategy, 

1556 }, 

1557 } 

1558 

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

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

1561 ret["defaultvalue"] = self.defaultValue 

1562 

1563 # Provide a multiple setting 

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

1565 ret["multiple"] = { 

1566 "duplicates": self.multiple.duplicates, 

1567 "max": self.multiple.max, 

1568 "min": self.multiple.min, 

1569 } 

1570 else: 

1571 ret["multiple"] = self.multiple 

1572 

1573 # Provide compute information 

1574 if self.compute: 

1575 ret["compute"] = { 

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

1577 } 

1578 

1579 if self.compute.interval.lifetime: 

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

1581 

1582 return ret