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

679 statements  

« prev     ^ index     » next       coverage.py v7.6.3, created at 2024-10-16 22:16 +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 type_suffix: Allows to specify an optional suffix for the bone-type, for bone customization 

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

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

180 error-message for the user otherwise. 

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

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

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

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

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

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

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

188 

189 .. NOTE:: 

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

191 """ 

192 type = "hidden" 

193 isClonedInstance = False 

194 

195 skel_cls = None 

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

197 

198 name = None 

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

200 

201 def __init__( 

202 self, 

203 *, 

204 compute: Compute = None, 

205 defaultValue: t.Any = None, 

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

207 getEmptyValueFunc: callable = None, 

208 indexed: bool = True, 

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

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

211 multiple: bool | MultipleConstraints = False, 

212 params: dict = None, 

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

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

215 searchable: bool = False, 

216 type_suffix: str = "", 

217 unique: None | UniqueValue = None, 

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

219 visible: bool = True, 

220 ): 

221 """ 

222 Initializes a new Bone. 

223 """ 

224 self.isClonedInstance = getSystemInitialized() 

225 

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

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

228 

229 # Standard definitions 

230 self.descr = descr 

231 self.params = params or {} 

232 self.multiple = multiple 

233 self.required = required 

234 self.readOnly = bool(readOnly) 

235 self.searchable = searchable 

236 self.visible = visible 

237 self.indexed = indexed 

238 

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

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

241 

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

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

244 

245 # Multi-language support 

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

247 languages is None or 

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

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

250 ): 

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

252 if ( 252 ↛ 256line 252 didn't jump to line 256

253 not isinstance(required, bool) 

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

255 ): 

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

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

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

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

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

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

262 

263 self.languages = languages 

264 

265 # Default value 

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

267 # multiple or has languages 

268 if defaultValue is None and self.languages: 

269 self.defaultValue = {} 

270 elif defaultValue is None and self.multiple: 

271 self.defaultValue = [] 

272 else: 

273 self.defaultValue = defaultValue 

274 

275 # Unique values 

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

277 if not isinstance(unique, UniqueValue): 

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

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

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

281 

282 self.unique = unique 

283 

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

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

286 if vfunc: 

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

288 

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

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

291 

292 if getEmptyValueFunc: 

293 self.getEmptyValue = getEmptyValueFunc 

294 

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

296 if not isinstance(compute, Compute): 

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

298 

299 # When readOnly is None, handle flag automatically 

300 if readOnly is None: 

301 self.readOnly = True 

302 if not self.readOnly: 

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

304 

305 if ( 

306 compute.interval.method == ComputeMethod.Lifetime 

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

308 ): 

309 raise ValueError( 

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

311 ) 

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

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

314 self._prevent_compute = False 

315 

316 self.compute = compute 

317 

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

319 self.skel_cls = owner 

320 self.name = name 

321 

322 def setSystemInitialized(self): 

323 """ 

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

325 being initialized 

326 """ 

327 pass 

328 

329 def isInvalid(self, value): 

330 """ 

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

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

333 """ 

334 return False 

335 

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

337 """ 

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

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

340 

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

342 valid - unless the bone is required. 

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

344 

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

346 input!) or the value returned by get 

347 """ 

348 return not bool(value) 

349 

350 def getDefaultValue(self, skeletonInstance): 

351 """ 

352 Retrieves the default value for the bone. 

353 

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

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

356 providing a default value. 

357 

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

359 """ 

360 if callable(self.defaultValue): 

361 return self.defaultValue(skeletonInstance, self) 

362 elif isinstance(self.defaultValue, list): 

363 return self.defaultValue[:] 

364 elif isinstance(self.defaultValue, dict): 

365 return self.defaultValue.copy() 

366 else: 

367 return self.defaultValue 

368 

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

370 """ 

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

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

373 """ 

374 return None 

375 

376 def __setattr__(self, key, value): 

377 """ 

378 Custom attribute setter for the BaseBone class. 

379 

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

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

382 method unless they have additional attributes with similar constraints. 

383 

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

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

386 

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

388 assignment. 

389 """ 

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

391 "_"): 

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

393 super().__setattr__(key, value) 

394 

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

396 """ 

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

398 

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

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

401 

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

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

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

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

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

407 

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

409 """ 

410 fieldSubmitted = False 

411 if languages: 

412 res = {} 

413 for lang in languages: 

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

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

416 fieldSubmitted = True 

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

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

419 res[lang] = [res[lang]] 

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

421 if res[lang]: 

422 res[lang] = res[lang][0] 

423 else: 

424 res[lang] = None 

425 else: 

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

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

428 fieldSubmitted = True 

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

430 if multiple: 

431 tmpDict = {} 

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

433 if not key.startswith(prefix): 

434 continue 

435 fieldSubmitted = True 

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

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

438 try: 

439 firstKey = int(firstKey) 

440 except: 

441 continue 

442 if firstKey not in tmpDict: 

443 tmpDict[firstKey] = {} 

444 tmpDict[firstKey][remainingKey] = value 

445 tmpList = list(tmpDict.items()) 

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

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

448 else: 

449 tmpDict = {} 

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

451 if not key.startswith(prefix): 

452 continue 

453 fieldSubmitted = True 

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

455 tmpDict[partKey] = value 

456 res[lang] = tmpDict 

457 return res, fieldSubmitted 

458 else: # No multi-lang 

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

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

461 return None, False 

462 val = data[name] 

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

464 return [val], True 

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

466 if val: 

467 return val[0], True 

468 else: 

469 return None, True # Empty! 

470 else: 

471 return val, True 

472 else: # No multi-lang but collect subfields 

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

474 if key == name: 

475 fieldSubmitted = True 

476 prefix = f"{name}." 

477 if multiple: 

478 tmpDict = {} 

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

480 if not key.startswith(prefix): 

481 continue 

482 fieldSubmitted = True 

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

484 try: 

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

486 firstKey = int(firstKey) 

487 except: 

488 continue 

489 if firstKey not in tmpDict: 

490 tmpDict[firstKey] = {} 

491 tmpDict[firstKey][remainingKey] = value 

492 tmpList = list(tmpDict.items()) 

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

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

495 else: 

496 res = {} 

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

498 if not key.startswith(prefix): 

499 continue 

500 fieldSubmitted = True 

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

502 res[subKey] = value 

503 return res, fieldSubmitted 

504 

505 def parseSubfieldsFromClient(self) -> bool: 

506 """ 

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

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

509 """ 

510 return False 

511 

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

513 bone_name: str, client_data: dict 

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

515 """Load a single value from a client 

516 

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

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

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

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

521 a dictionary with usually bone names as key 

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

523 the parsed value and the second is None. 

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

525 and the second a list of *ReadFromClientError*. 

526 """ 

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

528 return self.getEmptyValue(), [ 

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

530 

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

532 """ 

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

534 

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

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

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

538 

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

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

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

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

543 """ 

544 subFields = self.parseSubfieldsFromClient() 

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

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

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

548 errors = [] 

549 isEmpty = True 

550 filled_languages = set() 

551 if self.languages and self.multiple: 

552 res = {} 

553 for language in self.languages: 

554 res[language] = [] 

555 if language in parsedData: 

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

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

558 continue 

559 isEmpty = False 

560 filled_languages.add(language) 

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

562 res[language].append(parsedVal) 

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

564 for parseError in parseErrors: 

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

566 errors.extend(parseErrors) 

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

568 res = {} 

569 for language in self.languages: 

570 res[language] = None 

571 if language in parsedData: 

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

573 res[language] = self.getEmptyValue() 

574 continue 

575 isEmpty = False 

576 filled_languages.add(language) 

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

578 res[language] = parsedVal 

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

580 for parseError in parseErrors: 

581 parseError.fieldPath.insert(0, language) 

582 errors.extend(parseErrors) 

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

584 res = [] 

585 for idx, singleValue in enumerate(parsedData): 

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

587 continue 

588 isEmpty = False 

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

590 res.append(parsedVal) 

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

592 for parseError in parseErrors: 

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

594 errors.extend(parseErrors) 

595 else: # No Languages, not multiple 

596 if self.isEmpty(parsedData): 

597 res = self.getEmptyValue() 

598 isEmpty = True 

599 else: 

600 isEmpty = False 

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

602 if parseErrors: 

603 errors.extend(parseErrors) 

604 skel[name] = res 

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

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

607 if missing: 

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

609 for lang in missing] 

610 if isEmpty: 

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

612 

613 # Check multiple constraints on demand 

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

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

616 

617 return errors or None 

618 

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

620 """ 

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

622 The returned value must be hashable. 

623 """ 

624 return value 

625 

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

627 """ 

628 Returns a distinct hash value for this bone. 

629 The returned value must be hashable. 

630 """ 

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

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

633 

634 return value 

635 

636 def _validate_multiple_contraints( 

637 self, 

638 constraints: MultipleConstraints, 

639 skel: 'SkeletonInstance', 

640 name: str 

641 ) -> list[ReadFromClientError]: 

642 """ 

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

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

645 

646 :param constraints: The MultipleConstraints definition to apply. 

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

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

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

650 """ 

651 res = [] 

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

653 

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

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

656 

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

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

659 

660 if not constraints.duplicates: 

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

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

663 

664 return res 

665 

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

667 """ 

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

669 

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

671 values. 

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

673 """ 

674 return value 

675 

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

677 """ 

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

679 

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

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

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

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

684 """ 

685 # Handle compute on write 

686 if self.compute: 

687 match self.compute.interval.method: 

688 case ComputeMethod.OnWrite: 

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

690 

691 case ComputeMethod.Lifetime: 

692 now = utils.utcNow() 

693 

694 last_update = \ 

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

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

697 

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

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

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

701 

702 case ComputeMethod.Once: 

703 if name not in skel.dbEntity: 

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

705 

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

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

708 

709 if name in skel.accessedValues: 

710 newVal = skel.accessedValues[name] 

711 if self.languages and self.multiple: 

712 res = db.Entity() 

713 res["_viurLanguageWrapper_"] = True 

714 for language in self.languages: 

715 res[language] = [] 

716 if not self.indexed: 

717 res.exclude_from_indexes.add(language) 

718 if language in newVal: 

719 for singleValue in newVal[language]: 

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

721 elif self.languages: 

722 res = db.Entity() 

723 res["_viurLanguageWrapper_"] = True 

724 for language in self.languages: 

725 res[language] = None 

726 if not self.indexed: 

727 res.exclude_from_indexes.add(language) 

728 if language in newVal: 

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

730 elif self.multiple: 

731 res = [] 

732 

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

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

735 

736 for singleValue in (newVal or ()): 

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

738 

739 else: # No Languages, not Multiple 

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

741 skel.dbEntity[name] = res 

742 # Ensure our indexed flag is up2date 

743 indexed = self.indexed and parentIndexed 

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

745 skel.dbEntity.exclude_from_indexes.discard(name) 

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

747 skel.dbEntity.exclude_from_indexes.add(name) 

748 return True 

749 return False 

750 

751 def singleValueUnserialize(self, val): 

752 """ 

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

754 

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

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

757 """ 

758 return val 

759 

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

761 """ 

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

763 

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

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

766 

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

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

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

770 """ 

771 if name in skel.dbEntity: 

772 loadVal = skel.dbEntity[name] 

773 elif ( 

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

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

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

777 # ... or computed 

778 or self.compute 

779 ): 

780 loadVal = None 

781 else: 

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

783 return False 

784 

785 # Is this value computed? 

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

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

788 if self.compute and not self._prevent_compute: 

789 match self.compute.interval.method: 

790 # Computation is bound to a lifetime? 

791 case ComputeMethod.Lifetime: 

792 now = utils.utcNow() 

793 

794 # check if lifetime exceeded 

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

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

797 

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

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

800 

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

802 # if so, recompute and refresh updated value 

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

804 

805 def transact(): 

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

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

808 db_obj[name] = value 

809 db.Put(db_obj) 

810 

811 if db.IsInTransaction(): 

812 transact() 

813 else: 

814 db.RunInTransaction(transact) 

815 

816 return True 

817 

818 # Compute on every deserialization 

819 case ComputeMethod.Always: 

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

821 return True 

822 

823 # Only compute once when loaded value is empty 

824 case ComputeMethod.Once: 

825 if loadVal is None: 

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

827 return True 

828 

829 # unserialize value to given config 

830 if self.languages and self.multiple: 

831 res = {} 

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

833 for language in self.languages: 

834 res[language] = [] 

835 if language in loadVal: 

836 tmpVal = loadVal[language] 

837 if not isinstance(tmpVal, list): 

838 tmpVal = [tmpVal] 

839 for singleValue in tmpVal: 

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

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

842 for language in self.languages: 

843 res[language] = [] 

844 mainLang = self.languages[0] 

845 if loadVal is None: 

846 pass 

847 elif isinstance(loadVal, list): 

848 for singleValue in loadVal: 

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

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

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

852 elif self.languages: 

853 res = {} 

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

855 for language in self.languages: 

856 res[language] = None 

857 if language in loadVal: 

858 tmpVal = loadVal[language] 

859 if isinstance(tmpVal, list) and tmpVal: 

860 tmpVal = tmpVal[0] 

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

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

863 for language in self.languages: 

864 res[language] = None 

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

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

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

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

869 mainLang = self.languages[0] 

870 if loadVal is None: 

871 pass 

872 elif isinstance(loadVal, list) and loadVal: 

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

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

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

876 elif self.multiple: 

877 res = [] 

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

879 # Pick one language we'll use 

880 if conf.i18n.default_language in loadVal: 

881 loadVal = loadVal[conf.i18n.default_language] 

882 else: 

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

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

885 loadVal = [loadVal] 

886 if loadVal: 

887 for val in loadVal: 

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

889 else: # Not multiple, no languages 

890 res = None 

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

892 # Pick one language we'll use 

893 if conf.i18n.default_language in loadVal: 

894 loadVal = loadVal[conf.i18n.default_language] 

895 else: 

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

897 if loadVal and isinstance(loadVal, list): 

898 loadVal = loadVal[0] 

899 if loadVal is not None: 

900 res = self.singleValueUnserialize(loadVal) 

901 

902 skel.accessedValues[name] = res 

903 return True 

904 

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

906 """ 

907 Like postDeletedHandler, but runs inside the transaction 

908 """ 

909 pass 

910 

911 def buildDBFilter(self, 

912 name: str, 

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

914 dbFilter: db.Query, 

915 rawFilter: dict, 

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

917 """ 

918 Parses the searchfilter a client specified in his Request into 

919 something understood by the datastore. 

920 This function must: 

921 

922 * - Ignore all filters not targeting this bone 

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

924 

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

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

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

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

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

930 """ 

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

932 

933 if len(myKeys) == 0: 

934 return dbFilter 

935 

936 for key in myKeys: 

937 value = rawFilter[key] 

938 tmpdata = key.split("$") 

939 

940 if len(tmpdata) > 1: 

941 if isinstance(value, list): 

942 continue 

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

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

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

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

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

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

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

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

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

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

953 else: 

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

955 else: 

956 if isinstance(value, list): 

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

958 else: 

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

960 

961 return dbFilter 

962 

963 def buildDBSort(self, 

964 name: str, 

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

966 dbFilter: db.Query, 

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

968 """ 

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

970 the results, but by sorting them. 

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

972 malformed data! 

973 

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

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

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

977 be applied to 

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

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

980 None if the query is unsatisfiable. 

981 """ 

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

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

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

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

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

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

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

989 else: 

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

991 queries = dbFilter.queries 

992 if queries is None: 

993 return # This query is unsatisfiable 

994 elif isinstance(queries, db.QueryDefinition): 

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

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

997 elif isinstance(queries, list): 

998 inEqFilter = None 

999 for singeFilter in queries: 

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

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

1002 if inEqFilter and newInEqFilter and inEqFilter != newInEqFilter: 

1003 raise NotImplementedError("Impossible ordering!") 

1004 inEqFilter = newInEqFilter 

1005 if inEqFilter: 

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

1007 if inEqFilter != order[0]: 

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

1009 dbFilter.order((inEqFilter, order)) 

1010 else: 

1011 dbFilter.order(order) 

1012 else: 

1013 dbFilter.order(order) 

1014 return dbFilter 

1015 

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

1017 """ 

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

1019 

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

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

1022 implement their own logic for hashing values. 

1023 

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

1025 

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

1027 the list may contain more than one hashed value. 

1028 """ 

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

1030 h = hashlib.sha256() 

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

1032 res = h.hexdigest() 

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

1034 return f"I-{res}" 

1035 elif isinstance(value, str): 

1036 return f"S-{res}" 

1037 elif isinstance(value, db.Key): 

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

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

1040 def keyHash(key): 

1041 if key is None: 

1042 return "-" 

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

1044 

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

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

1047 

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

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

1050 if not self.multiple: 

1051 return [hashValue(value)] 

1052 # We have an multiple bone here 

1053 if not isinstance(value, list): 

1054 value = [value] 

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

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

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

1058 return tmpList 

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

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

1061 tmpList.sort() 

1062 # Lock the value for that specific list 

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

1064 

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

1066 """ 

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

1068 unique property value index. 

1069 

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

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

1072 are required (not the description!). 

1073 

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

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

1076 """ 

1077 val = skel[name] 

1078 if val is None: 

1079 return [] 

1080 return self._hashValueForUniquePropertyIndex(val) 

1081 

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

1083 """ 

1084 Returns a set of blob keys referenced from this bone 

1085 """ 

1086 return set() 

1087 

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

1089 """ 

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

1091 or the current user. 

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

1093 """ 

1094 pass # We do nothing by default 

1095 

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

1097 """ 

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

1099 

1100 :param boneName: Name of this bone 

1101 :param skel: The skeleton this bone belongs to 

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

1103 """ 

1104 pass 

1105 

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

1107 """ 

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

1109 

1110 :param skel: The skeleton this bone belongs to 

1111 :param boneName: Name of this bone 

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

1113 """ 

1114 pass 

1115 

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

1117 """ 

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

1119 """ 

1120 pass 

1121 

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

1123 """ 

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

1125 

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

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

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

1129 are to be merged. 

1130 

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

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

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

1134 operation. 

1135 """ 

1136 if getattr(otherSkel, boneName) is None: 

1137 return 

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

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

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

1141 return 

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

1143 

1144 def setBoneValue(self, 

1145 skel: 'SkeletonInstance', 

1146 boneName: str, 

1147 value: t.Any, 

1148 append: bool, 

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

1150 """ 

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

1152 values. Sanity checks are being performed. 

1153 

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

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

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

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

1158 Only supported for bones with multiple=True. 

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

1160 if the bone supports languages. 

1161 

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

1163 

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

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

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

1167 """ 

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

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

1170 

1171 if not append and self.multiple: 

1172 # set multiple values at once 

1173 val = [] 

1174 errors = [] 

1175 for singleValue in value: 

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

1177 val.append(singleValue) 

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

1179 errors.extend(singleError) 

1180 else: 

1181 # set or append one value 

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

1183 

1184 if errors: 

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

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

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

1188 return False 

1189 if not append and not language: 

1190 skel[boneName] = val 

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

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

1193 skel[boneName][language] = [] 

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

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

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

1197 skel[boneName] = [] 

1198 skel[boneName].append(val) 

1199 else: # Just language 

1200 skel[boneName][language] = val 

1201 return True 

1202 

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

1204 """ 

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

1206 

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

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

1209 

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

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

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

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

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

1215 any searchable content, an empty set is returned. 

1216 """ 

1217 return set() 

1218 

1219 def iter_bone_value( 

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

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

1222 """ 

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

1224 

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

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

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

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

1229 and value is the value inside this container. 

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

1231 

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

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

1234 

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

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

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

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

1239 

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

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

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

1243 single or not multi-lang. 

1244 """ 

1245 value = skel[name] 

1246 if not value: 

1247 return None 

1248 

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

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

1251 if self.multiple: 

1252 if not values: 

1253 continue 

1254 for val in values: 

1255 yield idx, lang, val 

1256 else: 

1257 yield None, lang, values 

1258 else: 

1259 if self.multiple: 

1260 for idx, val in enumerate(value): 

1261 yield idx, None, val 

1262 else: 

1263 yield None, None, value 

1264 

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

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

1267 

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

1269 compute_fn_args = {} 

1270 if "skel" in compute_fn_parameters: 

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

1272 

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

1274 cloned_skel = skeletonByKind(skel.kindName)() 

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

1276 else: 

1277 cloned_skel = skel.clone() 

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

1279 compute_fn_args["skel"] = cloned_skel 

1280 

1281 if "bone" in compute_fn_parameters: 

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

1283 

1284 if "bone_name" in compute_fn_parameters: 

1285 compute_fn_args["bone_name"] = bone_name 

1286 

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

1288 

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

1290 if self.multiple: 

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

1292 return self.singleValueUnserialize(raw_value) 

1293 

1294 if self.compute.raw: 

1295 if self.languages: 

1296 return { 

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

1298 for lang in self.languages 

1299 } 

1300 return unserialize_raw_value(ret) 

1301 self._prevent_compute = True 

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

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

1304 self._prevent_compute = False 

1305 return skel[bone_name] 

1306 

1307 def structure(self) -> dict: 

1308 """ 

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

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

1311 """ 

1312 ret = { 

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

1314 "type": self.type, 

1315 "required": self.required, 

1316 "params": self.params, 

1317 "visible": self.visible, 

1318 "readonly": self.readOnly, 

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

1320 "languages": self.languages, 

1321 "emptyvalue": self.getEmptyValue(), 

1322 "indexed": self.indexed 

1323 } 

1324 

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

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

1327 ret["defaultvalue"] = self.defaultValue 

1328 

1329 # Provide a multiple setting 

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

1331 ret["multiple"] = { 

1332 "duplicates": self.multiple.duplicates, 

1333 "max": self.multiple.max, 

1334 "min": self.multiple.min, 

1335 } 

1336 else: 

1337 ret["multiple"] = self.multiple 

1338 if self.compute: 

1339 ret["compute"] = { 

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

1341 } 

1342 

1343 if self.compute.interval.lifetime: 

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

1345 

1346 return ret