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

677 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-09-03 13:41 +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 

12from dataclasses import dataclass, field 

13from datetime import datetime, timedelta 

14from collections.abc import Iterable 

15from enum import Enum 

16import typing as t 

17from viur.core import db, utils, i18n 

18from viur.core.config import conf 

19 

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

21 from ..skeleton import Skeleton 

22 

23__system_initialized = False 

24""" 

25Initializes the global variable __system_initialized 

26""" 

27 

28 

29def setSystemInitialized(): 

30 """ 

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

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

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

34 setSystemInitialized() method. 

35 

36 Global variables: 

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

38 """ 

39 global __system_initialized 

40 from viur.core.skeleton import iterAllSkelClasses 

41 __system_initialized = True 

42 for skelCls in iterAllSkelClasses(): 

43 skelCls.setSystemInitialized() 

44 

45 

46def getSystemInitialized(): 

47 """ 

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

49 global variable __system_initialized. 

50 """ 

51 global __system_initialized 

52 return __system_initialized 

53 

54 

55class ReadFromClientErrorSeverity(Enum): 

56 """ 

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

58 that can occur while reading data from the client. 

59 """ 

60 NotSet = 0 

61 """No error occurred""" 

62 InvalidatesOther = 1 

63 # TODO: what is this error about? 

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

65 Empty = 2 

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

67 Invalid = 3 

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

69 

70 

71@dataclass 

72class ReadFromClientError: 

73 """ 

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

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

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

77 """ 

78 severity: ReadFromClientErrorSeverity 

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

80 errorMessage: str 

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

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

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

84 invalidatedFields: list[str] = None 

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

86 

87 

88class UniqueLockMethod(Enum): 

89 """ 

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

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

92 enforced. 

93 """ 

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

95 """ 

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

97 is multiple. 

98 """ 

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

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

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

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

103 

104 

105@dataclass 

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

107 """ 

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

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

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

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

112 """ 

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

114 """ 

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

116 multiple=True. 

117 """ 

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

119 """ 

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

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

122 """ 

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

124 """ 

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

126 taken. 

127 """ 

128 

129 

130@dataclass 

131class MultipleConstraints: 

132 """ 

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

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

135 """ 

136 min: int = 0 

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

138 max: int = 0 

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

140 duplicates: bool = False 

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

142 

143 

144class ComputeMethod(Enum): 

145 Always = 0 # Always compute on deserialization 

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

147 Once = 2 # Compute only once 

148 OnWrite = 3 # Compute before written 

149 

150 

151@dataclass 

152class ComputeInterval: 

153 method: ComputeMethod = ComputeMethod.Always 

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

155 

156 

157@dataclass 

158class Compute: 

159 fn: callable # the callable computing the value 

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

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

162 

163 

164class BaseBone(object): 

165 """ 

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

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

168 

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

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

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

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

173 language must be entered. 

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

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

176 without the need of also been indexed. 

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

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

179 error-message for the user otherwise. 

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

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

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

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

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

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

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

187 

188 .. NOTE:: 

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

190 """ 

191 type = "hidden" 

192 isClonedInstance = False 

193 

194 skel_cls = None 

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

196 

197 name = None 

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

199 

200 def __init__( 

201 self, 

202 *, 

203 compute: Compute = None, 

204 defaultValue: t.Any = None, 

205 descr: str | i18n.translate = "", 

206 getEmptyValueFunc: callable = None, 

207 indexed: bool = True, 

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

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

210 multiple: bool | MultipleConstraints = False, 

211 params: dict = None, 

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

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

214 searchable: bool = False, 

215 unique: None | UniqueValue = None, 

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

217 visible: bool = True, 

218 ): 

219 """ 

220 Initializes a new Bone. 

221 """ 

222 self.isClonedInstance = getSystemInitialized() 

223 

224 if isinstance(descr, str): 224 ↛ 228line 224 didn't jump to line 228 because the condition on line 224 was always true

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

226 

227 # Standard definitions 

228 self.descr = descr 

229 self.params = params or {} 

230 self.multiple = multiple 

231 self.required = required 

232 self.readOnly = bool(readOnly) 

233 self.searchable = searchable 

234 self.visible = visible 

235 self.indexed = indexed 

236 

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

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

239 

240 # Multi-language support 

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

242 languages is None or 

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

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

245 ): 

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

247 if ( 247 ↛ exit,   247 ↛ 2512 missed branches: 1) line 247 didn't jump to the function exit, 2) line 247 didn't jump to line 251

248 not isinstance(required, bool) 

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

250 ): 

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

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

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

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

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

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

257 

258 self.languages = languages 

259 

260 # Default value 

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

262 # multiple or has languages 

263 if defaultValue is None and self.languages: 

264 self.defaultValue = {} 

265 elif defaultValue is None and self.multiple: 

266 self.defaultValue = [] 

267 else: 

268 self.defaultValue = defaultValue 

269 

270 # Unique values 

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

272 if not isinstance(unique, UniqueValue): 

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

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

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

276 

277 self.unique = unique 

278 

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

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

281 if vfunc: 

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

283 

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

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

286 

287 if getEmptyValueFunc: 

288 self.getEmptyValue = getEmptyValueFunc 

289 

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

291 if not isinstance(compute, Compute): 

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

293 

294 # When readOnly is None, handle flag automatically 

295 if readOnly is None: 

296 self.readOnly = True 

297 if not self.readOnly: 

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

299 

300 if ( 

301 compute.interval.method == ComputeMethod.Lifetime 

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

303 ): 

304 raise ValueError( 

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

306 ) 

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

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

309 self._prevent_compute = False 

310 

311 self.compute = compute 

312 

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

314 self.skel_cls = owner 

315 self.name = name 

316 

317 def setSystemInitialized(self): 

318 """ 

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

320 being initialized 

321 """ 

322 pass 

323 

324 def isInvalid(self, value): 

325 """ 

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

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

328 """ 

329 return False 

330 

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

332 """ 

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

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

335 

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

337 valid - unless the bone is required. 

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

339 

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

341 input!) or the value returned by get 

342 """ 

343 return not bool(value) 

344 

345 def getDefaultValue(self, skeletonInstance): 

346 """ 

347 Retrieves the default value for the bone. 

348 

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

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

351 providing a default value. 

352 

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

354 """ 

355 if callable(self.defaultValue): 

356 return self.defaultValue(skeletonInstance, self) 

357 elif isinstance(self.defaultValue, list): 

358 return self.defaultValue[:] 

359 elif isinstance(self.defaultValue, dict): 

360 return self.defaultValue.copy() 

361 else: 

362 return self.defaultValue 

363 

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

365 """ 

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

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

368 """ 

369 return None 

370 

371 def __setattr__(self, key, value): 

372 """ 

373 Custom attribute setter for the BaseBone class. 

374 

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

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

377 method unless they have additional attributes with similar constraints. 

378 

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

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

381 

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

383 assignment. 

384 """ 

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

386 "_"): 

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

388 super().__setattr__(key, value) 

389 

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

391 """ 

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

393 

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

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

396 

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

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

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

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

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

402 

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

404 """ 

405 fieldSubmitted = False 

406 if languages: 

407 res = {} 

408 for lang in languages: 

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

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

411 fieldSubmitted = True 

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

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

414 res[lang] = [res[lang]] 

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

416 if res[lang]: 

417 res[lang] = res[lang][0] 

418 else: 

419 res[lang] = None 

420 else: 

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

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

423 fieldSubmitted = True 

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

425 if multiple: 

426 tmpDict = {} 

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

428 if not key.startswith(prefix): 

429 continue 

430 fieldSubmitted = True 

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

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

433 try: 

434 firstKey = int(firstKey) 

435 except: 

436 continue 

437 if firstKey not in tmpDict: 

438 tmpDict[firstKey] = {} 

439 tmpDict[firstKey][remainingKey] = value 

440 tmpList = list(tmpDict.items()) 

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

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

443 else: 

444 tmpDict = {} 

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

446 if not key.startswith(prefix): 

447 continue 

448 fieldSubmitted = True 

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

450 tmpDict[partKey] = value 

451 res[lang] = tmpDict 

452 return res, fieldSubmitted 

453 else: # No multi-lang 

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

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

456 return None, False 

457 val = data[name] 

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

459 return [val], True 

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

461 if val: 

462 return val[0], True 

463 else: 

464 return None, True # Empty! 

465 else: 

466 return val, True 

467 else: # No multi-lang but collect subfields 

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

469 if key == name: 

470 fieldSubmitted = True 

471 prefix = f"{name}." 

472 if multiple: 

473 tmpDict = {} 

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

475 if not key.startswith(prefix): 

476 continue 

477 fieldSubmitted = True 

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

479 try: 

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

481 firstKey = int(firstKey) 

482 except: 

483 continue 

484 if firstKey not in tmpDict: 

485 tmpDict[firstKey] = {} 

486 tmpDict[firstKey][remainingKey] = value 

487 tmpList = list(tmpDict.items()) 

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

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

490 else: 

491 res = {} 

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

493 if not key.startswith(prefix): 

494 continue 

495 fieldSubmitted = True 

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

497 res[subKey] = value 

498 return res, fieldSubmitted 

499 

500 def parseSubfieldsFromClient(self) -> bool: 

501 """ 

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

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

504 """ 

505 return False 

506 

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

508 bone_name: str, client_data: dict 

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

510 """Load a single value from a client 

511 

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

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

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

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

516 a dictionary with usually bone names as key 

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

518 the parsed value and the second is None. 

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

520 and the second a list of *ReadFromClientError*. 

521 """ 

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

523 return self.getEmptyValue(), [ 

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

525 

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

527 """ 

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

529 

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

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

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

533 

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

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

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

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

538 """ 

539 subFields = self.parseSubfieldsFromClient() 

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

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

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

543 errors = [] 

544 isEmpty = True 

545 filled_languages = set() 

546 if self.languages and self.multiple: 

547 res = {} 

548 for language in self.languages: 

549 res[language] = [] 

550 if language in parsedData: 

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

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

553 continue 

554 isEmpty = False 

555 filled_languages.add(language) 

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

557 res[language].append(parsedVal) 

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

559 for parseError in parseErrors: 

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

561 errors.extend(parseErrors) 

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

563 res = {} 

564 for language in self.languages: 

565 res[language] = None 

566 if language in parsedData: 

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

568 res[language] = self.getEmptyValue() 

569 continue 

570 isEmpty = False 

571 filled_languages.add(language) 

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

573 res[language] = parsedVal 

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

575 for parseError in parseErrors: 

576 parseError.fieldPath.insert(0, language) 

577 errors.extend(parseErrors) 

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

579 res = [] 

580 for idx, singleValue in enumerate(parsedData): 

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

582 continue 

583 isEmpty = False 

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

585 res.append(parsedVal) 

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

587 for parseError in parseErrors: 

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

589 errors.extend(parseErrors) 

590 else: # No Languages, not multiple 

591 if self.isEmpty(parsedData): 

592 res = self.getEmptyValue() 

593 isEmpty = True 

594 else: 

595 isEmpty = False 

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

597 if parseErrors: 

598 errors.extend(parseErrors) 

599 skel[name] = res 

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

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

602 if missing: 

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

604 for lang in missing] 

605 if isEmpty: 

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

607 

608 # Check multiple constraints on demand 

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

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

611 

612 return errors or None 

613 

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

615 """ 

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

617 The returned value must be hashable. 

618 """ 

619 return value 

620 

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

622 """ 

623 Returns a distinct hash value for this bone. 

624 The returned value must be hashable. 

625 """ 

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

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

628 

629 return value 

630 

631 def _validate_multiple_contraints( 

632 self, 

633 constraints: MultipleConstraints, 

634 skel: 'SkeletonInstance', 

635 name: str 

636 ) -> list[ReadFromClientError]: 

637 """ 

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

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

640 

641 :param constraints: The MultipleConstraints definition to apply. 

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

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

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

645 """ 

646 res = [] 

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

648 

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

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

651 

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

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

654 

655 if not constraints.duplicates: 

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

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

658 

659 return res 

660 

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

662 """ 

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

664 

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

666 values. 

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

668 """ 

669 return value 

670 

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

672 """ 

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

674 

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

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

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

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

679 """ 

680 # Handle compute on write 

681 if self.compute: 

682 match self.compute.interval.method: 

683 case ComputeMethod.OnWrite: 

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

685 

686 case ComputeMethod.Lifetime: 

687 now = utils.utcNow() 

688 

689 last_update = \ 

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

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

692 

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

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

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

696 

697 case ComputeMethod.Once: 

698 if name not in skel.dbEntity: 

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

700 

701 # logging.debug(f"WRITE {name=} {skel.accessedValues=}") 

702 # logging.debug(f"WRITE {name=} {skel.dbEntity=}") 

703 

704 if name in skel.accessedValues: 

705 newVal = skel.accessedValues[name] 

706 if self.languages and self.multiple: 

707 res = db.Entity() 

708 res["_viurLanguageWrapper_"] = True 

709 for language in self.languages: 

710 res[language] = [] 

711 if not self.indexed: 

712 res.exclude_from_indexes.add(language) 

713 if language in newVal: 

714 for singleValue in newVal[language]: 

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

716 elif self.languages: 

717 res = db.Entity() 

718 res["_viurLanguageWrapper_"] = True 

719 for language in self.languages: 

720 res[language] = None 

721 if not self.indexed: 

722 res.exclude_from_indexes.add(language) 

723 if language in newVal: 

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

725 elif self.multiple: 

726 res = [] 

727 

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

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

730 

731 for singleValue in (newVal or ()): 

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

733 

734 else: # No Languages, not Multiple 

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

736 skel.dbEntity[name] = res 

737 # Ensure our indexed flag is up2date 

738 indexed = self.indexed and parentIndexed 

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

740 skel.dbEntity.exclude_from_indexes.discard(name) 

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

742 skel.dbEntity.exclude_from_indexes.add(name) 

743 return True 

744 return False 

745 

746 def singleValueUnserialize(self, val): 

747 """ 

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

749 

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

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

752 """ 

753 return val 

754 

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

756 """ 

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

758 

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

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

761 

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

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

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

765 """ 

766 if name in skel.dbEntity: 

767 loadVal = skel.dbEntity[name] 

768 elif ( 

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

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

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

772 # ... or computed 

773 or self.compute 

774 ): 

775 loadVal = None 

776 else: 

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

778 return False 

779 

780 # Is this value computed? 

781 # In this case, check for configured compute method and if recomputation is required. 

782 # Otherwise, the value from the DB is used as is. 

783 if self.compute and not self._prevent_compute: 

784 match self.compute.interval.method: 

785 # Computation is bound to a lifetime? 

786 case ComputeMethod.Lifetime: 

787 now = utils.utcNow() 

788 

789 # check if lifetime exceeded 

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

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

792 

793 # logging.debug(f"READ {name=} {skel.dbEntity=}") 

794 # logging.debug(f"READ {name=} {skel.accessedValues=}") 

795 

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

797 # if so, recompute and refresh updated value 

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

799 

800 def transact(): 

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

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

803 db_obj[name] = value 

804 db.Put(db_obj) 

805 

806 if db.IsInTransaction(): 

807 transact() 

808 else: 

809 db.RunInTransaction(transact) 

810 

811 return True 

812 

813 # Compute on every deserialization 

814 case ComputeMethod.Always: 

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

816 return True 

817 

818 # Only compute once when loaded value is empty 

819 case ComputeMethod.Once: 

820 if loadVal is None: 

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

822 return True 

823 

824 # unserialize value to given config 

825 if self.languages and self.multiple: 

826 res = {} 

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

828 for language in self.languages: 

829 res[language] = [] 

830 if language in loadVal: 

831 tmpVal = loadVal[language] 

832 if not isinstance(tmpVal, list): 

833 tmpVal = [tmpVal] 

834 for singleValue in tmpVal: 

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

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

837 for language in self.languages: 

838 res[language] = [] 

839 mainLang = self.languages[0] 

840 if loadVal is None: 

841 pass 

842 elif isinstance(loadVal, list): 

843 for singleValue in loadVal: 

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

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

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

847 elif self.languages: 

848 res = {} 

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

850 for language in self.languages: 

851 res[language] = None 

852 if language in loadVal: 

853 tmpVal = loadVal[language] 

854 if isinstance(tmpVal, list) and tmpVal: 

855 tmpVal = tmpVal[0] 

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

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

858 for language in self.languages: 

859 res[language] = None 

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

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

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

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

864 mainLang = self.languages[0] 

865 if loadVal is None: 

866 pass 

867 elif isinstance(loadVal, list) and loadVal: 

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

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

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

871 elif self.multiple: 

872 res = [] 

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

874 # Pick one language we'll use 

875 if conf.i18n.default_language in loadVal: 

876 loadVal = loadVal[conf.i18n.default_language] 

877 else: 

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

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

880 loadVal = [loadVal] 

881 if loadVal: 

882 for val in loadVal: 

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

884 else: # Not multiple, no languages 

885 res = None 

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

887 # Pick one language we'll use 

888 if conf.i18n.default_language in loadVal: 

889 loadVal = loadVal[conf.i18n.default_language] 

890 else: 

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

892 if loadVal and isinstance(loadVal, list): 

893 loadVal = loadVal[0] 

894 if loadVal is not None: 

895 res = self.singleValueUnserialize(loadVal) 

896 

897 skel.accessedValues[name] = res 

898 return True 

899 

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

901 """ 

902 Like postDeletedHandler, but runs inside the transaction 

903 """ 

904 pass 

905 

906 def buildDBFilter(self, 

907 name: str, 

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

909 dbFilter: db.Query, 

910 rawFilter: dict, 

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

912 """ 

913 Parses the searchfilter a client specified in his Request into 

914 something understood by the datastore. 

915 This function must: 

916 

917 * - Ignore all filters not targeting this bone 

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

919 

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

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

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

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

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

925 """ 

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

927 

928 if len(myKeys) == 0: 

929 return dbFilter 

930 

931 for key in myKeys: 

932 value = rawFilter[key] 

933 tmpdata = key.split("$") 

934 

935 if len(tmpdata) > 1: 

936 if isinstance(value, list): 

937 continue 

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

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

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

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

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

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

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

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

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

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

948 else: 

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

950 else: 

951 if isinstance(value, list): 

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

953 else: 

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

955 

956 return dbFilter 

957 

958 def buildDBSort(self, 

959 name: str, 

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

961 dbFilter: db.Query, 

962 rawFilter: dict) -> t.Optional[db.Query]: 

963 """ 

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

965 the results, but by sorting them. 

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

967 malformed data! 

968 

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

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

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

972 be applied to 

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

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

975 None if the query is unsatisfiable. 

976 """ 

977 if "orderby" in rawFilter and rawFilter["orderby"] == name: 

978 if "orderdir" in rawFilter and rawFilter["orderdir"] == "1": 

979 order = (rawFilter["orderby"], db.SortOrder.Descending) 

980 elif "orderdir" in rawFilter and rawFilter["orderdir"] == "2": 

981 order = (rawFilter["orderby"], db.SortOrder.InvertedAscending) 

982 elif "orderdir" in rawFilter and rawFilter["orderdir"] == "3": 

983 order = (rawFilter["orderby"], db.SortOrder.InvertedDescending) 

984 else: 

985 order = (rawFilter["orderby"], db.SortOrder.Ascending) 

986 queries = dbFilter.queries 

987 if queries is None: 

988 return # This query is unsatisfiable 

989 elif isinstance(queries, db.QueryDefinition): 

990 inEqFilter = [x for x in queries.filters.keys() if 

991 (">" in x[-3:] or "<" in x[-3:] or "!=" in x[-4:])] 

992 elif isinstance(queries, list): 

993 inEqFilter = None 

994 for singeFilter in queries: 

995 newInEqFilter = [x for x in singeFilter.filters.keys() if 

996 (">" in x[-3:] or "<" in x[-3:] or "!=" in x[-4:])] 

997 if inEqFilter and newInEqFilter and inEqFilter != newInEqFilter: 

998 raise NotImplementedError("Impossible ordering!") 

999 inEqFilter = newInEqFilter 

1000 if inEqFilter: 

1001 inEqFilter = inEqFilter[0][: inEqFilter[0].find(" ")] 

1002 if inEqFilter != order[0]: 

1003 logging.warning(f"I fixed you query! Impossible ordering changed to {inEqFilter}, {order[0]}") 

1004 dbFilter.order((inEqFilter, order)) 

1005 else: 

1006 dbFilter.order(order) 

1007 else: 

1008 dbFilter.order(order) 

1009 return dbFilter 

1010 

1011 def _hashValueForUniquePropertyIndex(self, value: str | int) -> list[str]: 

1012 """ 

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

1014 

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

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

1017 implement their own logic for hashing values. 

1018 

1019 :param value: The value to be hashed, which can be a string, integer, or a float. 

1020 

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

1022 the list may contain more than one hashed value. 

1023 """ 

1024 def hashValue(value: str | int) -> str: 

1025 h = hashlib.sha256() 

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

1027 res = h.hexdigest() 

1028 if isinstance(value, int) or isinstance(value, float): 

1029 return f"I-{res}" 

1030 elif isinstance(value, str): 

1031 return f"S-{res}" 

1032 elif isinstance(value, db.Key): 

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

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

1035 def keyHash(key): 

1036 if key is None: 

1037 return "-" 

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

1039 

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

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

1042 

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

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

1045 if not self.multiple: 

1046 return [hashValue(value)] 

1047 # We have an multiple bone here 

1048 if not isinstance(value, list): 

1049 value = [value] 

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

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

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

1053 return tmpList 

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

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

1056 tmpList.sort() 

1057 # Lock the value for that specific list 

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

1059 

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

1061 """ 

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

1063 unique property value index. 

1064 

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

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

1067 are required (not the description!). 

1068 

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

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

1071 """ 

1072 val = skel[name] 

1073 if val is None: 

1074 return [] 

1075 return self._hashValueForUniquePropertyIndex(val) 

1076 

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

1078 """ 

1079 Returns a set of blob keys referenced from this bone 

1080 """ 

1081 return set() 

1082 

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

1084 """ 

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

1086 or the current user. 

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

1088 """ 

1089 pass # We do nothing by default 

1090 

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

1092 """ 

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

1094 

1095 :param boneName: Name of this bone 

1096 :param skel: The skeleton this bone belongs to 

1097 :param key: The (new?) Database Key we've written to 

1098 """ 

1099 pass 

1100 

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

1102 """ 

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

1104 

1105 :param skel: The skeleton this bone belongs to 

1106 :param boneName: Name of this bone 

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

1108 """ 

1109 pass 

1110 

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

1112 """ 

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

1114 """ 

1115 pass 

1116 

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

1118 """ 

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

1120 

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

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

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

1124 are to be merged. 

1125 

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

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

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

1129 operation. 

1130 """ 

1131 if getattr(otherSkel, boneName) is None: 

1132 return 

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

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

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

1136 return 

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

1138 

1139 def setBoneValue(self, 

1140 skel: 'SkeletonInstance', 

1141 boneName: str, 

1142 value: t.Any, 

1143 append: bool, 

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

1145 """ 

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

1147 values. Sanity checks are being performed. 

1148 

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

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

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

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

1153 Only supported for bones with multiple=True. 

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

1155 if the bone supports languages. 

1156 

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

1158 

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

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

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

1162 """ 

1163 assert not (bool(self.languages) ^ bool(language)), "Language is required or not supported" 

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

1165 

1166 if not append and self.multiple: 

1167 # set multiple values at once 

1168 val = [] 

1169 errors = [] 

1170 for singleValue in value: 

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

1172 val.append(singleValue) 

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

1174 errors.extend(singleError) 

1175 else: 

1176 # set or append one value 

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

1178 

1179 if errors: 

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

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

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

1183 return False 

1184 if not append and not language: 

1185 skel[boneName] = val 

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

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

1188 skel[boneName][language] = [] 

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

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

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

1192 skel[boneName] = [] 

1193 skel[boneName].append(val) 

1194 else: # Just language 

1195 skel[boneName][language] = val 

1196 return True 

1197 

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

1199 """ 

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

1201 

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

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

1204 

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

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

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

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

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

1210 any searchable content, an empty set is returned. 

1211 """ 

1212 return set() 

1213 

1214 def iter_bone_value( 

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

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

1217 """ 

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

1219 

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

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

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

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

1224 and value is the value inside this container. 

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

1226 

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

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

1229 

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

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

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

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

1234 

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

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

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

1238 single or not multi-lang. 

1239 """ 

1240 value = skel[name] 

1241 if not value: 

1242 return None 

1243 

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

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

1246 if self.multiple: 

1247 if not values: 

1248 continue 

1249 for val in values: 

1250 yield idx, lang, val 

1251 else: 

1252 yield None, lang, values 

1253 else: 

1254 if self.multiple: 

1255 for idx, val in enumerate(value): 

1256 yield idx, None, val 

1257 else: 

1258 yield None, None, value 

1259 

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

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

1262 

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

1264 compute_fn_args = {} 

1265 if "skel" in compute_fn_parameters: 

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

1267 

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

1269 cloned_skel = skeletonByKind(skel.kindName)() 

1270 cloned_skel.fromDB(skel["key"]) 

1271 else: 

1272 cloned_skel = skel.clone() 

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

1274 compute_fn_args["skel"] = cloned_skel 

1275 

1276 if "bone" in compute_fn_parameters: 

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

1278 

1279 if "bone_name" in compute_fn_parameters: 

1280 compute_fn_args["bone_name"] = bone_name 

1281 

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

1283 

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

1285 if self.multiple: 

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

1287 return self.singleValueUnserialize(raw_value) 

1288 

1289 if self.compute.raw: 

1290 if self.languages: 

1291 return { 

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

1293 for lang in self.languages 

1294 } 

1295 return unserialize_raw_value(ret) 

1296 self._prevent_compute = True 

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

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

1299 self._prevent_compute = False 

1300 return skel[bone_name] 

1301 

1302 def structure(self) -> dict: 

1303 """ 

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

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

1306 """ 

1307 ret = { 

1308 "descr": str(self.descr), # need to turn possible translate-object into string 

1309 "type": self.type, 

1310 "required": self.required, 

1311 "params": self.params, 

1312 "visible": self.visible, 

1313 "readonly": self.readOnly, 

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

1315 "languages": self.languages, 

1316 "emptyvalue": self.getEmptyValue(), 

1317 "indexed": self.indexed 

1318 } 

1319 

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

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

1322 ret["defaultvalue"] = self.defaultValue 

1323 

1324 # Provide a multiple setting 

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

1326 ret["multiple"] = { 

1327 "duplicates": self.multiple.duplicates, 

1328 "max": self.multiple.max, 

1329 "min": self.multiple.min, 

1330 } 

1331 else: 

1332 ret["multiple"] = self.multiple 

1333 if self.compute: 

1334 ret["compute"] = { 

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

1336 } 

1337 

1338 if self.compute.interval.lifetime: 

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

1340 

1341 return ret