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

796 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-02-27 07:59 +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 if languages: 

563 res = {} 

564 for lang in languages: 

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

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

567 fieldSubmitted = True 

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

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

570 res[lang] = [res[lang]] 

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

572 if res[lang]: 

573 res[lang] = res[lang][0] 

574 else: 

575 res[lang] = None 

576 else: 

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

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

579 fieldSubmitted = True 

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

581 if multiple: 

582 tmpDict = {} 

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

584 if not key.startswith(prefix): 

585 continue 

586 fieldSubmitted = True 

587 partKey = key.replace(prefix, "") 

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

589 try: 

590 firstKey = int(firstKey) 

591 except: 

592 continue 

593 if firstKey not in tmpDict: 

594 tmpDict[firstKey] = {} 

595 tmpDict[firstKey][remainingKey] = value 

596 tmpList = list(tmpDict.items()) 

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

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

599 else: 

600 tmpDict = {} 

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

602 if not key.startswith(prefix): 

603 continue 

604 fieldSubmitted = True 

605 partKey = key.replace(prefix, "") 

606 tmpDict[partKey] = value 

607 res[lang] = tmpDict 

608 return res, fieldSubmitted 

609 else: # No multi-lang 

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

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

612 return None, False 

613 val = data[name] 

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

615 return [val], True 

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

617 if val: 

618 return val[0], True 

619 else: 

620 return None, True # Empty! 

621 else: 

622 return val, True 

623 else: # No multi-lang but collect subfields 

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

625 if key == name: 

626 fieldSubmitted = True 

627 prefix = f"{name}." 

628 if multiple: 

629 tmpDict = {} 

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

631 if not key.startswith(prefix): 

632 continue 

633 fieldSubmitted = True 

634 partKey = key.replace(prefix, "") 

635 try: 

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

637 firstKey = int(firstKey) 

638 except: 

639 continue 

640 if firstKey not in tmpDict: 

641 tmpDict[firstKey] = {} 

642 tmpDict[firstKey][remainingKey] = value 

643 tmpList = list(tmpDict.items()) 

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

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

646 else: 

647 res = {} 

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

649 if not key.startswith(prefix): 

650 continue 

651 fieldSubmitted = True 

652 subKey = key.replace(prefix, "") 

653 res[subKey] = value 

654 return res, fieldSubmitted 

655 

656 def parseSubfieldsFromClient(self) -> bool: 

657 """ 

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

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

660 """ 

661 return False 

662 

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

664 bone_name: str, client_data: dict 

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

666 """Load a single value from a client 

667 

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

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

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

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

672 a dictionary with usually bone names as key 

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

674 the parsed value and the second is None. 

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

676 and the second a list of *ReadFromClientError*. 

677 """ 

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

679 return self.getEmptyValue(), [ 

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

681 

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

683 """ 

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

685 

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

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

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

689 

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

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

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

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

694 """ 

695 subFields = self.parseSubfieldsFromClient() 

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

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

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

699 errors = [] 

700 isEmpty = True 

701 filled_languages = set() 

702 if self.languages and self.multiple: 

703 res = {} 

704 for language in self.languages: 

705 res[language] = [] 

706 if language in parsedData: 

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

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

709 continue 

710 isEmpty = False 

711 filled_languages.add(language) 

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

713 res[language].append(parsedVal) 

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

715 if callable(self.multiple.sorted): 

716 res[language] = sorted( 

717 res[language], 

718 key=self.multiple.sorted, 

719 reverse=self.multiple.reversed, 

720 ) 

721 else: 

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

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

724 for parseError in parseErrors: 

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

726 errors.extend(parseErrors) 

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

728 res = {} 

729 for language in self.languages: 

730 res[language] = None 

731 if language in parsedData: 

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

733 res[language] = self.getEmptyValue() 

734 continue 

735 isEmpty = False 

736 filled_languages.add(language) 

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

738 res[language] = parsedVal 

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

740 for parseError in parseErrors: 

741 parseError.fieldPath.insert(0, language) 

742 errors.extend(parseErrors) 

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

744 res = [] 

745 for idx, singleValue in enumerate(parsedData): 

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

747 continue 

748 isEmpty = False 

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

750 res.append(parsedVal) 

751 

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

753 for parseError in parseErrors: 

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

755 errors.extend(parseErrors) 

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

757 if callable(self.multiple.sorted): 

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

759 else: 

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

761 else: # No Languages, not multiple 

762 if self.isEmpty(parsedData): 

763 res = self.getEmptyValue() 

764 isEmpty = True 

765 else: 

766 isEmpty = False 

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

768 if parseErrors: 

769 errors.extend(parseErrors) 

770 skel[name] = res 

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

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

773 if missing: 

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

775 for lang in missing] 

776 if isEmpty: 

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

778 

779 # Check multiple constraints on demand 

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

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

782 

783 return errors or None 

784 

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

786 """ 

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

788 The returned value must be hashable. 

789 """ 

790 return value 

791 

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

793 """ 

794 Returns a distinct hash value for this bone. 

795 The returned value must be hashable. 

796 """ 

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

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

799 

800 return value 

801 

802 def _validate_multiple_contraints( 

803 self, 

804 constraints: MultipleConstraints, 

805 skel: 'SkeletonInstance', 

806 name: str 

807 ) -> list[ReadFromClientError]: 

808 """ 

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

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

811 

812 :param constraints: The MultipleConstraints definition to apply. 

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

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

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

816 """ 

817 res = [] 

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

819 

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

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

822 

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

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

825 

826 if not constraints.duplicates: 

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

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

829 

830 return res 

831 

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

833 """ 

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

835 

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

837 values. 

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

839 """ 

840 return value 

841 

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

843 """ 

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

845 

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

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

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

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

850 """ 

851 self.serialize_compute(skel, name) 

852 

853 if name in skel.accessedValues: 

854 newVal = skel.accessedValues[name] 

855 if self.languages and self.multiple: 

856 res = db.Entity() 

857 res["_viurLanguageWrapper_"] = True 

858 for language in self.languages: 

859 res[language] = [] 

860 if not self.indexed: 

861 res.exclude_from_indexes.add(language) 

862 if language in newVal: 

863 for singleValue in newVal[language]: 

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

865 elif self.languages: 

866 res = db.Entity() 

867 res["_viurLanguageWrapper_"] = True 

868 for language in self.languages: 

869 res[language] = None 

870 if not self.indexed: 

871 res.exclude_from_indexes.add(language) 

872 if language in newVal: 

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

874 elif self.multiple: 

875 res = [] 

876 

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

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

879 

880 for singleValue in (newVal or ()): 

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

882 

883 else: # No Languages, not Multiple 

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

885 skel.dbEntity[name] = res 

886 # Ensure our indexed flag is up2date 

887 indexed = self.indexed and parentIndexed 

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

889 skel.dbEntity.exclude_from_indexes.discard(name) 

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

891 skel.dbEntity.exclude_from_indexes.add(name) 

892 return True 

893 return False 

894 

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

896 """ 

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

898 value with the appropriate calculation method 

899 

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

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

902 """ 

903 if not self.compute: 

904 return None 

905 match self.compute.interval.method: 

906 case ComputeMethod.OnWrite: 

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

908 

909 case ComputeMethod.Lifetime: 

910 now = utils.utcNow() 

911 

912 last_update = \ 

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

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

915 

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

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

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

919 

920 case ComputeMethod.Once: 

921 if name not in skel.dbEntity: 

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

923 

924 

925 def singleValueUnserialize(self, val): 

926 """ 

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

928 

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

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

931 """ 

932 return val 

933 

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

935 """ 

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

937 

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

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

940 

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

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

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

944 """ 

945 if name in skel.dbEntity: 

946 loadVal = skel.dbEntity[name] 

947 elif ( 

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

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

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

951 # ... or computed 

952 or self.compute 

953 ): 

954 loadVal = None 

955 else: 

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

957 return False 

958 

959 if self.unserialize_compute(skel, name): 

960 return True 

961 

962 # unserialize value to given config 

963 if self.languages and self.multiple: 

964 res = {} 

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

966 for language in self.languages: 

967 res[language] = [] 

968 if language in loadVal: 

969 tmpVal = loadVal[language] 

970 if not isinstance(tmpVal, list): 

971 tmpVal = [tmpVal] 

972 for singleValue in tmpVal: 

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

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

975 for language in self.languages: 

976 res[language] = [] 

977 mainLang = self.languages[0] 

978 if loadVal is None: 

979 pass 

980 elif isinstance(loadVal, list): 

981 for singleValue in loadVal: 

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

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

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

985 elif self.languages: 

986 res = {} 

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

988 for language in self.languages: 

989 res[language] = None 

990 if language in loadVal: 

991 tmpVal = loadVal[language] 

992 if isinstance(tmpVal, list) and tmpVal: 

993 tmpVal = tmpVal[0] 

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

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

996 for language in self.languages: 

997 res[language] = None 

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

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

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

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

1002 mainLang = self.languages[0] 

1003 if loadVal is None: 

1004 pass 

1005 elif isinstance(loadVal, list) and loadVal: 

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

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

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

1009 elif self.multiple: 

1010 res = [] 

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

1012 # Pick one language we'll use 

1013 if conf.i18n.default_language in loadVal: 

1014 loadVal = loadVal[conf.i18n.default_language] 

1015 else: 

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

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

1018 loadVal = [loadVal] 

1019 if loadVal: 

1020 for val in loadVal: 

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

1022 else: # Not multiple, no languages 

1023 res = None 

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

1025 # Pick one language we'll use 

1026 if conf.i18n.default_language in loadVal: 

1027 loadVal = loadVal[conf.i18n.default_language] 

1028 else: 

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

1030 if loadVal and isinstance(loadVal, list): 

1031 loadVal = loadVal[0] 

1032 if loadVal is not None: 

1033 res = self.singleValueUnserialize(loadVal) 

1034 

1035 skel.accessedValues[name] = res 

1036 return True 

1037 

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

1039 """ 

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

1041 value with the appropriate calculation method 

1042 

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

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

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

1046 """ 

1047 if not self.compute or self._prevent_compute: 

1048 return False 

1049 

1050 match self.compute.interval.method: 

1051 # Computation is bound to a lifetime? 

1052 case ComputeMethod.Lifetime: 

1053 now = utils.utcNow() 

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

1055 

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

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

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

1059 else: 

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

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

1062 

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

1064 # if so, recompute and refresh updated value 

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

1066 def transact(): 

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

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

1069 db_obj[name] = value 

1070 db.Put(db_obj) 

1071 

1072 if db.IsInTransaction(): 

1073 transact() 

1074 else: 

1075 db.RunInTransaction(transact) 

1076 

1077 return True 

1078 

1079 # Compute on every deserialization 

1080 case ComputeMethod.Always: 

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

1082 return True 

1083 

1084 return False 

1085 

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

1087 """ 

1088 Like postDeletedHandler, but runs inside the transaction 

1089 """ 

1090 pass 

1091 

1092 def buildDBFilter(self, 

1093 name: str, 

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

1095 dbFilter: db.Query, 

1096 rawFilter: dict, 

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

1098 """ 

1099 Parses the searchfilter a client specified in his Request into 

1100 something understood by the datastore. 

1101 This function must: 

1102 

1103 * - Ignore all filters not targeting this bone 

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

1105 

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

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

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

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

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

1111 """ 

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

1113 

1114 if len(myKeys) == 0: 

1115 return dbFilter 

1116 

1117 for key in myKeys: 

1118 value = rawFilter[key] 

1119 tmpdata = key.split("$") 

1120 

1121 if len(tmpdata) > 1: 

1122 if isinstance(value, list): 

1123 continue 

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

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

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

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

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

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

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

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

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

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

1134 else: 

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

1136 else: 

1137 if isinstance(value, list): 

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

1139 else: 

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

1141 

1142 return dbFilter 

1143 

1144 def buildDBSort( 

1145 self, 

1146 name: str, 

1147 skel: "SkeletonInstance", 

1148 query: db.Query, 

1149 params: dict, 

1150 postfix: str = "", 

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

1152 """ 

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

1154 the results, but by sorting them. 

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

1156 malformed data! 

1157 

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

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

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

1161 be applied to 

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

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

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

1165 None if the query is unsatisfiable. 

1166 """ 

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

1168 if self.languages: 

1169 lang = None 

1170 if orderby.startswith(f"{name}."): 

1171 lng = orderby.replace(f"{name}.", "") 

1172 if lng in self.languages: 

1173 lang = lng 

1174 

1175 if lang is None: 

1176 lang = current.language.get() 

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

1178 lang = self.languages[0] 

1179 

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

1181 else: 

1182 prop = name 

1183 

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

1185 if isinstance(query.queries, list): 

1186 in_eq_filter = None 

1187 

1188 for item in query.queries: 

1189 new_in_eq_filter = [ 

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

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

1192 ] 

1193 if in_eq_filter and new_in_eq_filter and in_eq_filter != new_in_eq_filter: 

1194 raise NotImplementedError("Impossible ordering!") 

1195 

1196 in_eq_filter = new_in_eq_filter 

1197 

1198 else: 

1199 in_eq_filter = [ 

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

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

1202 ] 

1203 

1204 if in_eq_filter: 

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

1206 if orderby_prop != prop: 

1207 logging.warning( 

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

1209 ) 

1210 prop = orderby_prop 

1211 

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

1213 

1214 return query 

1215 

1216 def _hashValueForUniquePropertyIndex( 

1217 self, 

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

1219 ) -> list[str]: 

1220 """ 

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

1222 

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

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

1225 implement their own logic for hashing values. 

1226 

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

1228 

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

1230 the list may contain more than one hashed value. 

1231 """ 

1232 

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

1234 h = hashlib.sha256() 

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

1236 res = h.hexdigest() 

1237 if isinstance(value, int | float): 

1238 return f"I-{res}" 

1239 elif isinstance(value, str): 

1240 return f"S-{res}" 

1241 elif isinstance(value, db.Key): 

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

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

1244 def keyHash(key): 

1245 if key is None: 

1246 return "-" 

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

1248 

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

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

1251 

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

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

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

1255 return [hashValue(value)] 

1256 # We have a multiple bone or multiple values here 

1257 if not isinstance(value, list): 

1258 value = [value] 

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

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

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

1262 return tmpList 

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

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

1265 tmpList.sort() 

1266 # Lock the value for that specific list 

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

1268 

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

1270 """ 

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

1272 unique property value index. 

1273 

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

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

1276 are required (not the description!). 

1277 

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

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

1280 """ 

1281 val = skel[name] 

1282 if val is None: 

1283 return [] 

1284 return self._hashValueForUniquePropertyIndex(val) 

1285 

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

1287 """ 

1288 Returns a set of blob keys referenced from this bone 

1289 """ 

1290 return set() 

1291 

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

1293 """ 

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

1295 or the current user. 

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

1297 """ 

1298 pass # We do nothing by default 

1299 

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

1301 """ 

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

1303 

1304 :param boneName: Name of this bone 

1305 :param skel: The skeleton this bone belongs to 

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

1307 """ 

1308 pass 

1309 

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

1311 """ 

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

1313 

1314 :param skel: The skeleton this bone belongs to 

1315 :param boneName: Name of this bone 

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

1317 """ 

1318 pass 

1319 

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

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

1322 match self.clone_behavior.strategy: 

1323 case CloneStrategy.COPY_VALUE: 

1324 try: 

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

1326 except KeyError: 

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

1328 try: 

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

1330 except KeyError: 

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

1332 case CloneStrategy.SET_NULL: 

1333 skel.accessedValues[bone_name] = None 

1334 case CloneStrategy.SET_DEFAULT: 

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

1336 case CloneStrategy.SET_EMPTY: 

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

1338 case CloneStrategy.CUSTOM: 

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

1340 case other: 

1341 raise NotImplementedError(other) 

1342 

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

1344 """ 

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

1346 """ 

1347 pass 

1348 

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

1350 """ 

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

1352 

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

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

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

1356 are to be merged. 

1357 

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

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

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

1361 operation. 

1362 """ 

1363 if getattr(otherSkel, boneName) is None: 

1364 return 

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

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

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

1368 return 

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

1370 

1371 def setBoneValue(self, 

1372 skel: 'SkeletonInstance', 

1373 boneName: str, 

1374 value: t.Any, 

1375 append: bool, 

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

1377 """ 

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

1379 values. Sanity checks are being performed. 

1380 

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

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

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

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

1385 Only supported for bones with multiple=True. 

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

1387 if the bone supports languages. 

1388 

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

1390 

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

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

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

1394 """ 

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

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

1397 

1398 if not append and self.multiple: 

1399 # set multiple values at once 

1400 val = [] 

1401 errors = [] 

1402 for singleValue in value: 

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

1404 val.append(singleValue) 

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

1406 errors.extend(singleError) 

1407 else: 

1408 # set or append one value 

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

1410 

1411 if errors: 

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

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

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

1415 logging.error(e) 

1416 return False 

1417 if not append and not language: 

1418 skel[boneName] = val 

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

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

1421 skel[boneName][language] = [] 

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

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

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

1425 skel[boneName] = [] 

1426 skel[boneName].append(val) 

1427 else: # Just language 

1428 skel[boneName][language] = val 

1429 return True 

1430 

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

1432 """ 

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

1434 

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

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

1437 

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

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

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

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

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

1443 any searchable content, an empty set is returned. 

1444 """ 

1445 return set() 

1446 

1447 def iter_bone_value( 

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

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

1450 """ 

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

1452 

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

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

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

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

1457 and value is the value inside this container. 

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

1459 

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

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

1462 

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

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

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

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

1467 

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

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

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

1471 single or not multi-lang. 

1472 """ 

1473 value = skel[name] 

1474 if not value: 

1475 return None 

1476 

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

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

1479 if self.multiple: 

1480 if not values: 

1481 continue 

1482 for val in values: 

1483 yield idx, lang, val 

1484 else: 

1485 yield None, lang, values 

1486 else: 

1487 if self.multiple: 

1488 for idx, val in enumerate(value): 

1489 yield idx, None, val 

1490 else: 

1491 yield None, None, value 

1492 

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

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

1495 

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

1497 compute_fn_args = {} 

1498 if "skel" in compute_fn_parameters: 

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

1500 

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

1502 cloned_skel = skeletonByKind(skel.kindName)() 

1503 cloned_skel.read(skel["key"]) 

1504 else: 

1505 cloned_skel = skel.clone() 

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

1507 compute_fn_args["skel"] = cloned_skel 

1508 

1509 if "bone" in compute_fn_parameters: 

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

1511 

1512 if "bone_name" in compute_fn_parameters: 

1513 compute_fn_args["bone_name"] = bone_name 

1514 

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

1516 

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

1518 if self.multiple: 

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

1520 return self.singleValueUnserialize(raw_value) 

1521 

1522 if self.compute.raw: 

1523 if self.languages: 

1524 return { 

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

1526 for lang in self.languages 

1527 } 

1528 return unserialize_raw_value(ret) 

1529 self._prevent_compute = True 

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

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

1532 self._prevent_compute = False 

1533 return skel[bone_name] 

1534 

1535 def structure(self) -> dict: 

1536 """ 

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

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

1539 """ 

1540 ret = { 

1541 "descr": self.descr, 

1542 "type": self.type, 

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

1544 "params": self.params, 

1545 "visible": self.visible, 

1546 "readonly": self.readOnly, 

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

1548 "languages": self.languages, 

1549 "emptyvalue": self.getEmptyValue(), 

1550 "indexed": self.indexed, 

1551 "clone_behavior": { 

1552 "strategy": self.clone_behavior.strategy, 

1553 }, 

1554 } 

1555 

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

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

1558 ret["defaultvalue"] = self.defaultValue 

1559 

1560 # Provide a multiple setting 

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

1562 ret["multiple"] = { 

1563 "duplicates": self.multiple.duplicates, 

1564 "max": self.multiple.max, 

1565 "min": self.multiple.min, 

1566 } 

1567 else: 

1568 ret["multiple"] = self.multiple 

1569 

1570 # Provide compute information 

1571 if self.compute: 

1572 ret["compute"] = { 

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

1574 } 

1575 

1576 if self.compute.interval.lifetime: 

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

1578 

1579 return ret