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

739 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-02-07 19:28 +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 hashlib 

10import inspect 

11import logging 

12import typing as t 

13from collections.abc import Iterable 

14from dataclasses import dataclass, field 

15from datetime import timedelta 

16from enum import Enum 

17 

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

19from viur.core.config import conf 

20 

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

22 from ..skeleton import Skeleton, SkeletonInstance 

23 

24__system_initialized = False 

25""" 

26Initializes the global variable __system_initialized 

27""" 

28 

29 

30def setSystemInitialized(): 

31 """ 

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

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

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

35 setSystemInitialized() method. 

36 

37 Global variables: 

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

39 """ 

40 global __system_initialized 

41 from viur.core.skeleton import iterAllSkelClasses 

42 

43 for skelCls in iterAllSkelClasses(): 

44 skelCls.setSystemInitialized() 

45 

46 __system_initialized = True 

47 

48def getSystemInitialized(): 

49 """ 

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

51 global variable __system_initialized. 

52 """ 

53 global __system_initialized 

54 return __system_initialized 

55 

56 

57class ReadFromClientErrorSeverity(Enum): 

58 """ 

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

60 that can occur while reading data from the client. 

61 """ 

62 NotSet = 0 

63 """No error occurred""" 

64 InvalidatesOther = 1 

65 # TODO: what is this error about? 

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

67 Empty = 2 

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

69 Invalid = 3 

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

71 

72 

73@dataclass 

74class ReadFromClientError: 

75 """ 

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

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

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

79 """ 

80 severity: ReadFromClientErrorSeverity 

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

82 errorMessage: str 

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

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

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

86 invalidatedFields: list[str] = None 

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

88 

89 def __str__(self): 

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

91 

92 

93class ReadFromClientException(Exception): 

94 """ 

95 ReadFromClientError as an Exception to raise. 

96 """ 

97 

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

99 """ 

100 This is an exception holding ReadFromClientErrors. 

101 

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

103 """ 

104 super().__init__() 

105 

106 # Allow to specifiy a single ReadFromClientError 

107 if isinstance(errors, ReadFromClientError): 

108 errors = (ReadFromClientError, ) 

109 

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

111 

112 # Disallow ReadFromClientException without any ReadFromClientErrors 

113 if not self.errors: 

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

115 

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

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

118 notes_errors = tuple( 

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

120 ) 

121 

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

123 

124 

125class UniqueLockMethod(Enum): 

126 """ 

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

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

129 enforced. 

130 """ 

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

132 """ 

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

134 is multiple. 

135 """ 

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

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

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

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

140 

141 

142@dataclass 

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

144 """ 

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

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

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

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

149 """ 

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

151 """ 

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

153 multiple=True. 

154 """ 

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

156 """ 

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

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

159 """ 

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

161 """ 

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

163 taken. 

164 """ 

165 

166 

167@dataclass 

168class MultipleConstraints: 

169 """ 

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

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

172 """ 

173 min: int = 0 

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

175 max: int = 0 

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

177 duplicates: bool = False 

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

179 sorted: bool | t.Callable = False 

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

181 reversed: bool = False 

182 """ 

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

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

185 """ 

186 

187class ComputeMethod(Enum): 

188 Always = 0 # Always compute on deserialization 

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

190 Once = 2 # Compute only once 

191 OnWrite = 3 # Compute before written 

192 

193 

194@dataclass 

195class ComputeInterval: 

196 method: ComputeMethod = ComputeMethod.Always 

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

198 

199 

200@dataclass 

201class Compute: 

202 fn: callable # the callable computing the value 

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

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

205 

206 

207class BaseBone(object): 

208 """ 

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

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

211 

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

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

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

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

216 language must be entered. 

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

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

219 without the need of also been indexed. 

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

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

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

223 error-message for the user otherwise. 

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

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

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

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

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

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

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

231 

232 .. NOTE:: 

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

234 """ 

235 type = "hidden" 

236 isClonedInstance = False 

237 

238 skel_cls = None 

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

240 

241 name = None 

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

243 

244 def __init__( 

245 self, 

246 *, 

247 compute: Compute = None, 

248 defaultValue: t.Any = None, 

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

250 getEmptyValueFunc: callable = None, 

251 indexed: bool = True, 

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

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

254 multiple: bool | MultipleConstraints = False, 

255 params: dict = None, 

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

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

258 searchable: bool = False, 

259 type_suffix: str = "", 

260 unique: None | UniqueValue = None, 

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

262 visible: bool = True, 

263 ): 

264 """ 

265 Initializes a new Bone. 

266 """ 

267 self.isClonedInstance = getSystemInitialized() 

268 

269 # Standard definitions 

270 self.descr = descr 

271 self.params = params or {} 

272 self.multiple = multiple 

273 self.required = required 

274 self.readOnly = bool(readOnly) 

275 self.searchable = searchable 

276 self.visible = visible 

277 self.indexed = indexed 

278 

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

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

281 

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

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

284 

285 # Multi-language support 

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

287 languages is None or 

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

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

290 ): 

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

292 

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

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

295 

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

297 not isinstance(required, bool) 

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

299 ): 

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

301 

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

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

304 

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

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

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

308 

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

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

311 try: 

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

313 except TypeError: 

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

315 

316 self.languages = languages 

317 

318 # Default value 

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

320 # multiple or has languages 

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

322 if self.languages: 

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

324 self.defaultValue = defaultValue 

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

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

327 elif "__default__" in defaultValue: 

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

329 for lang in self.languages} 

330 else: 

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

332 else: 

333 self.defaultValue = default 

334 

335 # Unique values 

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

337 if not isinstance(unique, UniqueValue): 

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

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

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

341 

342 self.unique = unique 

343 

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

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

346 if vfunc: 

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

348 

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

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

351 

352 if getEmptyValueFunc: 

353 self.getEmptyValue = getEmptyValueFunc 

354 

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

356 if not isinstance(compute, Compute): 

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

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

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

360 # When readOnly is None, handle flag automatically 

361 if readOnly is None: 

362 self.readOnly = True 

363 if not self.readOnly: 

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

365 

366 if ( 

367 compute.interval.method == ComputeMethod.Lifetime 

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

369 ): 

370 raise ValueError( 

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

372 ) 

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

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

375 self._prevent_compute = False 

376 

377 self.compute = compute 

378 

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

380 self.skel_cls = owner 

381 self.name = name 

382 

383 def setSystemInitialized(self) -> None: 

384 """ 

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

386 being initialized. 

387 

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

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

390 """ 

391 if self.descr is None: 

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

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

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

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

396 super().__setattr__( 

397 "descr", 

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

399 ) 

400 

401 def isInvalid(self, value): 

402 """ 

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

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

405 """ 

406 return False 

407 

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

409 """ 

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

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

412 

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

414 valid - unless the bone is required. 

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

416 

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

418 input!) or the value returned by get 

419 """ 

420 return not bool(value) 

421 

422 def getDefaultValue(self, skeletonInstance): 

423 """ 

424 Retrieves the default value for the bone. 

425 

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

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

428 providing a default value. 

429 

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

431 """ 

432 if callable(self.defaultValue): 

433 res = self.defaultValue(skeletonInstance, self) 

434 if self.languages and self.multiple: 

435 if not isinstance(res, dict): 

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

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

438 else: 

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

440 elif self.languages: 

441 if not isinstance(res, dict): 

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

443 elif self.multiple: 

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

445 return [res] 

446 return res 

447 

448 elif isinstance(self.defaultValue, list): 

449 return self.defaultValue[:] 

450 elif isinstance(self.defaultValue, dict): 

451 return self.defaultValue.copy() 

452 else: 

453 return self.defaultValue 

454 

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

456 """ 

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

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

459 """ 

460 return None 

461 

462 def __setattr__(self, key, value): 

463 """ 

464 Custom attribute setter for the BaseBone class. 

465 

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

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

468 method unless they have additional attributes with similar constraints. 

469 

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

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

472 

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

474 assignment. 

475 """ 

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

477 "_"): 

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

479 super().__setattr__(key, value) 

480 

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

482 """ 

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

484 

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

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

487 

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

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

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

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

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

493 

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

495 """ 

496 fieldSubmitted = False 

497 if languages: 

498 res = {} 

499 for lang in languages: 

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

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

502 fieldSubmitted = True 

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

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

505 res[lang] = [res[lang]] 

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

507 if res[lang]: 

508 res[lang] = res[lang][0] 

509 else: 

510 res[lang] = None 

511 else: 

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

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

514 fieldSubmitted = True 

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

516 if multiple: 

517 tmpDict = {} 

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

519 if not key.startswith(prefix): 

520 continue 

521 fieldSubmitted = True 

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

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

524 try: 

525 firstKey = int(firstKey) 

526 except: 

527 continue 

528 if firstKey not in tmpDict: 

529 tmpDict[firstKey] = {} 

530 tmpDict[firstKey][remainingKey] = value 

531 tmpList = list(tmpDict.items()) 

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

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

534 else: 

535 tmpDict = {} 

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

537 if not key.startswith(prefix): 

538 continue 

539 fieldSubmitted = True 

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

541 tmpDict[partKey] = value 

542 res[lang] = tmpDict 

543 return res, fieldSubmitted 

544 else: # No multi-lang 

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

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

547 return None, False 

548 val = data[name] 

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

550 return [val], True 

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

552 if val: 

553 return val[0], True 

554 else: 

555 return None, True # Empty! 

556 else: 

557 return val, True 

558 else: # No multi-lang but collect subfields 

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

560 if key == name: 

561 fieldSubmitted = True 

562 prefix = f"{name}." 

563 if multiple: 

564 tmpDict = {} 

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

566 if not key.startswith(prefix): 

567 continue 

568 fieldSubmitted = True 

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

570 try: 

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

572 firstKey = int(firstKey) 

573 except: 

574 continue 

575 if firstKey not in tmpDict: 

576 tmpDict[firstKey] = {} 

577 tmpDict[firstKey][remainingKey] = value 

578 tmpList = list(tmpDict.items()) 

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

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

581 else: 

582 res = {} 

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

584 if not key.startswith(prefix): 

585 continue 

586 fieldSubmitted = True 

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

588 res[subKey] = value 

589 return res, fieldSubmitted 

590 

591 def parseSubfieldsFromClient(self) -> bool: 

592 """ 

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

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

595 """ 

596 return False 

597 

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

599 bone_name: str, client_data: dict 

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

601 """Load a single value from a client 

602 

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

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

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

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

607 a dictionary with usually bone names as key 

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

609 the parsed value and the second is None. 

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

611 and the second a list of *ReadFromClientError*. 

612 """ 

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

614 return self.getEmptyValue(), [ 

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

616 

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

618 """ 

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

620 

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

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

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

624 

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

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

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

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

629 """ 

630 subFields = self.parseSubfieldsFromClient() 

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

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

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

634 errors = [] 

635 isEmpty = True 

636 filled_languages = set() 

637 if self.languages and self.multiple: 

638 res = {} 

639 for language in self.languages: 

640 res[language] = [] 

641 if language in parsedData: 

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

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

644 continue 

645 isEmpty = False 

646 filled_languages.add(language) 

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

648 res[language].append(parsedVal) 

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

650 if callable(self.multiple.sorted): 

651 res[language] = sorted( 

652 res[language], 

653 key=self.multiple.sorted, 

654 reverse=self.multiple.reversed, 

655 ) 

656 else: 

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

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

659 for parseError in parseErrors: 

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

661 errors.extend(parseErrors) 

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

663 res = {} 

664 for language in self.languages: 

665 res[language] = None 

666 if language in parsedData: 

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

668 res[language] = self.getEmptyValue() 

669 continue 

670 isEmpty = False 

671 filled_languages.add(language) 

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

673 res[language] = parsedVal 

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

675 for parseError in parseErrors: 

676 parseError.fieldPath.insert(0, language) 

677 errors.extend(parseErrors) 

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

679 res = [] 

680 for idx, singleValue in enumerate(parsedData): 

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

682 continue 

683 isEmpty = False 

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

685 res.append(parsedVal) 

686 

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

688 for parseError in parseErrors: 

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

690 errors.extend(parseErrors) 

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

692 if callable(self.multiple.sorted): 

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

694 else: 

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

696 else: # No Languages, not multiple 

697 if self.isEmpty(parsedData): 

698 res = self.getEmptyValue() 

699 isEmpty = True 

700 else: 

701 isEmpty = False 

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

703 if parseErrors: 

704 errors.extend(parseErrors) 

705 skel[name] = res 

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

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

708 if missing: 

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

710 for lang in missing] 

711 if isEmpty: 

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

713 

714 # Check multiple constraints on demand 

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

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

717 

718 return errors or None 

719 

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

721 """ 

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

723 The returned value must be hashable. 

724 """ 

725 return value 

726 

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

728 """ 

729 Returns a distinct hash value for this bone. 

730 The returned value must be hashable. 

731 """ 

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

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

734 

735 return value 

736 

737 def _validate_multiple_contraints( 

738 self, 

739 constraints: MultipleConstraints, 

740 skel: 'SkeletonInstance', 

741 name: str 

742 ) -> list[ReadFromClientError]: 

743 """ 

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

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

746 

747 :param constraints: The MultipleConstraints definition to apply. 

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

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

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

751 """ 

752 res = [] 

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

754 

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

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

757 

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

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

760 

761 if not constraints.duplicates: 

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

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

764 

765 return res 

766 

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

768 """ 

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

770 

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

772 values. 

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

774 """ 

775 return value 

776 

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

778 """ 

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

780 

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

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

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

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

785 """ 

786 self.serialize_compute(skel, name) 

787 

788 if name in skel.accessedValues: 

789 newVal = skel.accessedValues[name] 

790 if self.languages and self.multiple: 

791 res = db.Entity() 

792 res["_viurLanguageWrapper_"] = True 

793 for language in self.languages: 

794 res[language] = [] 

795 if not self.indexed: 

796 res.exclude_from_indexes.add(language) 

797 if language in newVal: 

798 for singleValue in newVal[language]: 

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

800 elif self.languages: 

801 res = db.Entity() 

802 res["_viurLanguageWrapper_"] = True 

803 for language in self.languages: 

804 res[language] = None 

805 if not self.indexed: 

806 res.exclude_from_indexes.add(language) 

807 if language in newVal: 

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

809 elif self.multiple: 

810 res = [] 

811 

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

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

814 

815 for singleValue in (newVal or ()): 

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

817 

818 else: # No Languages, not Multiple 

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

820 skel.dbEntity[name] = res 

821 # Ensure our indexed flag is up2date 

822 indexed = self.indexed and parentIndexed 

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

824 skel.dbEntity.exclude_from_indexes.discard(name) 

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

826 skel.dbEntity.exclude_from_indexes.add(name) 

827 return True 

828 return False 

829 

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

831 """ 

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

833 value with the appropriate calculation method 

834 

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

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

837 """ 

838 if not self.compute: 

839 return None 

840 match self.compute.interval.method: 

841 case ComputeMethod.OnWrite: 

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

843 

844 case ComputeMethod.Lifetime: 

845 now = utils.utcNow() 

846 

847 last_update = \ 

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

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

850 

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

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

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

854 

855 case ComputeMethod.Once: 

856 if name not in skel.dbEntity: 

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

858 

859 

860 def singleValueUnserialize(self, val): 

861 """ 

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

863 

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

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

866 """ 

867 return val 

868 

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

870 """ 

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

872 

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

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

875 

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

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

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

879 """ 

880 if name in skel.dbEntity: 

881 loadVal = skel.dbEntity[name] 

882 elif ( 

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

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

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

886 # ... or computed 

887 or self.compute 

888 ): 

889 loadVal = None 

890 else: 

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

892 return False 

893 

894 if self.unserialize_compute(skel, name): 

895 return True 

896 

897 # unserialize value to given config 

898 if self.languages and self.multiple: 

899 res = {} 

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

901 for language in self.languages: 

902 res[language] = [] 

903 if language in loadVal: 

904 tmpVal = loadVal[language] 

905 if not isinstance(tmpVal, list): 

906 tmpVal = [tmpVal] 

907 for singleValue in tmpVal: 

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

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

910 for language in self.languages: 

911 res[language] = [] 

912 mainLang = self.languages[0] 

913 if loadVal is None: 

914 pass 

915 elif isinstance(loadVal, list): 

916 for singleValue in loadVal: 

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

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

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

920 elif self.languages: 

921 res = {} 

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

923 for language in self.languages: 

924 res[language] = None 

925 if language in loadVal: 

926 tmpVal = loadVal[language] 

927 if isinstance(tmpVal, list) and tmpVal: 

928 tmpVal = tmpVal[0] 

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

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

931 for language in self.languages: 

932 res[language] = None 

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

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

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

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

937 mainLang = self.languages[0] 

938 if loadVal is None: 

939 pass 

940 elif isinstance(loadVal, list) and loadVal: 

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

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

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

944 elif self.multiple: 

945 res = [] 

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

947 # Pick one language we'll use 

948 if conf.i18n.default_language in loadVal: 

949 loadVal = loadVal[conf.i18n.default_language] 

950 else: 

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

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

953 loadVal = [loadVal] 

954 if loadVal: 

955 for val in loadVal: 

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

957 else: # Not multiple, no languages 

958 res = None 

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

960 # Pick one language we'll use 

961 if conf.i18n.default_language in loadVal: 

962 loadVal = loadVal[conf.i18n.default_language] 

963 else: 

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

965 if loadVal and isinstance(loadVal, list): 

966 loadVal = loadVal[0] 

967 if loadVal is not None: 

968 res = self.singleValueUnserialize(loadVal) 

969 

970 skel.accessedValues[name] = res 

971 return True 

972 

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

974 """ 

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

976 value with the appropriate calculation method 

977 

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

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

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

981 """ 

982 if not self.compute or self._prevent_compute: 

983 return False 

984 

985 match self.compute.interval.method: 

986 # Computation is bound to a lifetime? 

987 case ComputeMethod.Lifetime: 

988 now = utils.utcNow() 

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

990 

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

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

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

994 else: 

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

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

997 

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

999 # if so, recompute and refresh updated value 

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

1001 def transact(): 

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

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

1004 db_obj[name] = value 

1005 db.Put(db_obj) 

1006 

1007 if db.IsInTransaction(): 

1008 transact() 

1009 else: 

1010 db.RunInTransaction(transact) 

1011 

1012 return True 

1013 

1014 # Compute on every deserialization 

1015 case ComputeMethod.Always: 

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

1017 return True 

1018 

1019 return False 

1020 

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

1022 """ 

1023 Like postDeletedHandler, but runs inside the transaction 

1024 """ 

1025 pass 

1026 

1027 def buildDBFilter(self, 

1028 name: str, 

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

1030 dbFilter: db.Query, 

1031 rawFilter: dict, 

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

1033 """ 

1034 Parses the searchfilter a client specified in his Request into 

1035 something understood by the datastore. 

1036 This function must: 

1037 

1038 * - Ignore all filters not targeting this bone 

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

1040 

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

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

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

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

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

1046 """ 

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

1048 

1049 if len(myKeys) == 0: 

1050 return dbFilter 

1051 

1052 for key in myKeys: 

1053 value = rawFilter[key] 

1054 tmpdata = key.split("$") 

1055 

1056 if len(tmpdata) > 1: 

1057 if isinstance(value, list): 

1058 continue 

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

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

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

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

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

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

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

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

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

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

1069 else: 

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

1071 else: 

1072 if isinstance(value, list): 

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

1074 else: 

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

1076 

1077 return dbFilter 

1078 

1079 def buildDBSort( 

1080 self, 

1081 name: str, 

1082 skel: "SkeletonInstance", 

1083 query: db.Query, 

1084 params: dict, 

1085 postfix: str = "", 

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

1087 """ 

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

1089 the results, but by sorting them. 

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

1091 malformed data! 

1092 

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

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

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

1096 be applied to 

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

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

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

1100 None if the query is unsatisfiable. 

1101 """ 

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

1103 if self.languages: 

1104 lang = None 

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

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

1107 if lng in self.languages: 

1108 lang = lng 

1109 

1110 if lang is None: 

1111 lang = current.language.get() 

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

1113 lang = self.languages[0] 

1114 

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

1116 else: 

1117 prop = name 

1118 

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

1120 if isinstance(query.queries, list): 

1121 in_eq_filter = None 

1122 

1123 for item in query.queries: 

1124 new_in_eq_filter = [ 

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

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

1127 ] 

1128 if in_eq_filter and new_in_eq_filter and in_eq_filter != new_in_eq_filter: 

1129 raise NotImplementedError("Impossible ordering!") 

1130 

1131 in_eq_filter = new_in_eq_filter 

1132 

1133 else: 

1134 in_eq_filter = [ 

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

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

1137 ] 

1138 

1139 if in_eq_filter: 

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

1141 if orderby_prop != prop: 

1142 logging.warning( 

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

1144 ) 

1145 prop = orderby_prop 

1146 

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

1148 

1149 return query 

1150 

1151 def _hashValueForUniquePropertyIndex( 

1152 self, 

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

1154 ) -> list[str]: 

1155 """ 

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

1157 

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

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

1160 implement their own logic for hashing values. 

1161 

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

1163 

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

1165 the list may contain more than one hashed value. 

1166 """ 

1167 

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

1169 h = hashlib.sha256() 

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

1171 res = h.hexdigest() 

1172 if isinstance(value, int | float): 

1173 return f"I-{res}" 

1174 elif isinstance(value, str): 

1175 return f"S-{res}" 

1176 elif isinstance(value, db.Key): 

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

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

1179 def keyHash(key): 

1180 if key is None: 

1181 return "-" 

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

1183 

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

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

1186 

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

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

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

1190 return [hashValue(value)] 

1191 # We have a multiple bone or multiple values here 

1192 if not isinstance(value, list): 

1193 value = [value] 

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

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

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

1197 return tmpList 

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

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

1200 tmpList.sort() 

1201 # Lock the value for that specific list 

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

1203 

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

1205 """ 

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

1207 unique property value index. 

1208 

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

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

1211 are required (not the description!). 

1212 

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

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

1215 """ 

1216 val = skel[name] 

1217 if val is None: 

1218 return [] 

1219 return self._hashValueForUniquePropertyIndex(val) 

1220 

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

1222 """ 

1223 Returns a set of blob keys referenced from this bone 

1224 """ 

1225 return set() 

1226 

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

1228 """ 

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

1230 or the current user. 

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

1232 """ 

1233 pass # We do nothing by default 

1234 

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

1236 """ 

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

1238 

1239 :param boneName: Name of this bone 

1240 :param skel: The skeleton this bone belongs to 

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

1242 """ 

1243 pass 

1244 

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

1246 """ 

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

1248 

1249 :param skel: The skeleton this bone belongs to 

1250 :param boneName: Name of this bone 

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

1252 """ 

1253 pass 

1254 

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

1256 """ 

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

1258 """ 

1259 pass 

1260 

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

1262 """ 

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

1264 

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

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

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

1268 are to be merged. 

1269 

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

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

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

1273 operation. 

1274 """ 

1275 if getattr(otherSkel, boneName) is None: 

1276 return 

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

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

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

1280 return 

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

1282 

1283 def setBoneValue(self, 

1284 skel: 'SkeletonInstance', 

1285 boneName: str, 

1286 value: t.Any, 

1287 append: bool, 

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

1289 """ 

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

1291 values. Sanity checks are being performed. 

1292 

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

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

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

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

1297 Only supported for bones with multiple=True. 

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

1299 if the bone supports languages. 

1300 

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

1302 

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

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

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

1306 """ 

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

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

1309 

1310 if not append and self.multiple: 

1311 # set multiple values at once 

1312 val = [] 

1313 errors = [] 

1314 for singleValue in value: 

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

1316 val.append(singleValue) 

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

1318 errors.extend(singleError) 

1319 else: 

1320 # set or append one value 

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

1322 

1323 if errors: 

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

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

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

1327 logging.error(e) 

1328 return False 

1329 if not append and not language: 

1330 skel[boneName] = val 

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

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

1333 skel[boneName][language] = [] 

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

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

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

1337 skel[boneName] = [] 

1338 skel[boneName].append(val) 

1339 else: # Just language 

1340 skel[boneName][language] = val 

1341 return True 

1342 

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

1344 """ 

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

1346 

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

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

1349 

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

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

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

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

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

1355 any searchable content, an empty set is returned. 

1356 """ 

1357 return set() 

1358 

1359 def iter_bone_value( 

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

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

1362 """ 

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

1364 

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

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

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

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

1369 and value is the value inside this container. 

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

1371 

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

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

1374 

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

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

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

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

1379 

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

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

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

1383 single or not multi-lang. 

1384 """ 

1385 value = skel[name] 

1386 if not value: 

1387 return None 

1388 

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

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

1391 if self.multiple: 

1392 if not values: 

1393 continue 

1394 for val in values: 

1395 yield idx, lang, val 

1396 else: 

1397 yield None, lang, values 

1398 else: 

1399 if self.multiple: 

1400 for idx, val in enumerate(value): 

1401 yield idx, None, val 

1402 else: 

1403 yield None, None, value 

1404 

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

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

1407 

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

1409 compute_fn_args = {} 

1410 if "skel" in compute_fn_parameters: 

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

1412 

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

1414 cloned_skel = skeletonByKind(skel.kindName)() 

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

1416 else: 

1417 cloned_skel = skel.clone() 

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

1419 compute_fn_args["skel"] = cloned_skel 

1420 

1421 if "bone" in compute_fn_parameters: 

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

1423 

1424 if "bone_name" in compute_fn_parameters: 

1425 compute_fn_args["bone_name"] = bone_name 

1426 

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

1428 

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

1430 if self.multiple: 

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

1432 return self.singleValueUnserialize(raw_value) 

1433 

1434 if self.compute.raw: 

1435 if self.languages: 

1436 return { 

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

1438 for lang in self.languages 

1439 } 

1440 return unserialize_raw_value(ret) 

1441 self._prevent_compute = True 

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

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

1444 self._prevent_compute = False 

1445 return skel[bone_name] 

1446 

1447 def structure(self) -> dict: 

1448 """ 

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

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

1451 """ 

1452 ret = { 

1453 "descr": self.descr, 

1454 "type": self.type, 

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

1456 "params": self.params, 

1457 "visible": self.visible, 

1458 "readonly": self.readOnly, 

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

1460 "languages": self.languages, 

1461 "emptyvalue": self.getEmptyValue(), 

1462 "indexed": self.indexed, 

1463 } 

1464 

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

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

1467 ret["defaultvalue"] = self.defaultValue 

1468 

1469 # Provide a multiple setting 

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

1471 ret["multiple"] = { 

1472 "duplicates": self.multiple.duplicates, 

1473 "max": self.multiple.max, 

1474 "min": self.multiple.min, 

1475 } 

1476 else: 

1477 ret["multiple"] = self.multiple 

1478 

1479 # Provide compute information 

1480 if self.compute: 

1481 ret["compute"] = { 

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

1483 } 

1484 

1485 if self.compute.interval.lifetime: 

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

1487 

1488 return ret