Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/skeleton.py: 0%

958 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-02-07 19:28 +0000

1from __future__ import annotations # noqa: required for pre-defined annotations 

2 

3import copy 

4import fnmatch 

5import inspect 

6import logging 

7import os 

8import string 

9import sys 

10import time 

11import typing as t 

12import warnings 

13from deprecated.sphinx import deprecated 

14from functools import partial 

15from itertools import chain 

16from viur.core import conf, current, db, email, errors, translate, utils 

17from viur.core.bones import ( 

18 BaseBone, 

19 DateBone, 

20 KeyBone, 

21 ReadFromClientException, 

22 RelationalBone, 

23 RelationalConsistency, 

24 RelationalUpdateLevel, 

25 SelectBone, 

26 StringBone, 

27) 

28from viur.core.bones.base import ( 

29 Compute, 

30 ComputeInterval, 

31 ComputeMethod, 

32 ReadFromClientError, 

33 ReadFromClientErrorSeverity, 

34 getSystemInitialized, 

35) 

36from viur.core.tasks import CallDeferred, CallableTask, CallableTaskBase, QueryIter 

37 

38_UNDEFINED = object() 

39ABSTRACT_SKEL_CLS_SUFFIX = "AbstractSkel" 

40KeyType: t.TypeAlias = db.Key | str | int 

41 

42 

43class MetaBaseSkel(type): 

44 """ 

45 This is the metaclass for Skeletons. 

46 It is used to enforce several restrictions on bone names, etc. 

47 """ 

48 _skelCache = {} # Mapping kindName -> SkelCls 

49 _allSkelClasses = set() # list of all known skeleton classes (including Ref and Mail-Skels) 

50 

51 # List of reserved keywords and function names 

52 __reserved_keywords = { 

53 "all", 

54 "bounce", 

55 "clone", 

56 "cursor", 

57 "delete", 

58 "errors", 

59 "fromClient", 

60 "fromDB", 

61 "get", 

62 "getCurrentSEOKeys", 

63 "items", 

64 "keys", 

65 "limit", 

66 "orderby", 

67 "orderdir", 

68 "patch", 

69 "postDeletedHandler", 

70 "postSavedHandler", 

71 "preProcessBlobLocks", 

72 "preProcessSerializedData", 

73 "read", 

74 "refresh", 

75 "self", 

76 "serialize", 

77 "setBoneValue", 

78 "structure", 

79 "style", 

80 "toDB", 

81 "unserialize", 

82 "values", 

83 "write", 

84 } 

85 

86 __allowed_chars = string.ascii_letters + string.digits + "_" 

87 

88 def __init__(cls, name, bases, dct, **kwargs): 

89 cls.__boneMap__ = MetaBaseSkel.generate_bonemap(cls) 

90 

91 if not getSystemInitialized() and not cls.__name__.endswith(ABSTRACT_SKEL_CLS_SUFFIX): 

92 MetaBaseSkel._allSkelClasses.add(cls) 

93 

94 super().__init__(name, bases, dct) 

95 

96 @staticmethod 

97 def generate_bonemap(cls): 

98 """ 

99 Recursively constructs a dict of bones from 

100 """ 

101 map = {} 

102 

103 for base in cls.__bases__: 

104 if "__viurBaseSkeletonMarker__" in dir(base): 

105 map |= MetaBaseSkel.generate_bonemap(base) 

106 

107 for key in cls.__dict__: 

108 prop = getattr(cls, key) 

109 

110 if isinstance(prop, BaseBone): 

111 if not all([c in MetaBaseSkel.__allowed_chars for c in key]): 

112 raise AttributeError(f"Invalid bone name: {key!r} contains invalid characters") 

113 elif key in MetaBaseSkel.__reserved_keywords: 

114 raise AttributeError(f"Invalid bone name: {key!r} is reserved and cannot be used") 

115 

116 map[key] = prop 

117 

118 elif prop is None and key in map: # Allow removing a bone in a subclass by setting it to None 

119 del map[key] 

120 

121 return map 

122 

123 def __setattr__(self, key, value): 

124 super().__setattr__(key, value) 

125 if isinstance(value, BaseBone): 

126 # Call BaseBone.__set_name__ manually for bones that are assigned at runtime 

127 value.__set_name__(self, key) 

128 

129 

130class SkeletonInstance: 

131 """ 

132 The actual wrapper around a Skeleton-Class. An object of this class is what's actually returned when you 

133 call a Skeleton-Class. With ViUR3, you don't get an instance of a Skeleton-Class any more - it's always this 

134 class. This is much faster as this is a small class. 

135 """ 

136 __slots__ = { 

137 "accessedValues", 

138 "boneMap", 

139 "dbEntity", 

140 "errors", 

141 "is_cloned", 

142 "renderAccessedValues", 

143 "renderPreparation", 

144 "skeletonCls", 

145 } 

146 

147 def __init__( 

148 self, 

149 skel_cls: t.Type[Skeleton], 

150 *, 

151 bones: t.Iterable[str] = (), 

152 bone_map: t.Optional[t.Dict[str, BaseBone]] = None, 

153 clone: bool = False, 

154 # FIXME: BELOW IS DEPRECATED! 

155 clonedBoneMap: t.Optional[t.Dict[str, BaseBone]] = None, 

156 ): 

157 """ 

158 Creates a new SkeletonInstance based on `skel_cls`. 

159 

160 :param skel_cls: Is the base skeleton class to inherit from and reference to. 

161 :param bones: If given, defines an iterable of bones that are take into the SkeletonInstance. 

162 The order of the bones defines the order in the SkeletonInstance. 

163 :param bone_map: A pre-defined bone map to use, or extend. 

164 :param clone: If set True, performs a cloning of the used bone map, to be entirely stand-alone. 

165 """ 

166 

167 # TODO: Remove with ViUR-core 3.8; required by viur-datastore :'-( 

168 if clonedBoneMap: 

169 msg = "'clonedBoneMap' was renamed into 'bone_map'" 

170 warnings.warn(msg, DeprecationWarning, stacklevel=2) 

171 # logging.warning(msg, stacklevel=2) 

172 

173 if bone_map: 

174 raise ValueError("Can't provide both 'bone_map' and 'clonedBoneMap'") 

175 

176 bone_map = clonedBoneMap 

177 

178 bone_map = bone_map or {} 

179 

180 if bones: 

181 names = ("key", ) + tuple(bones) 

182 

183 # generate full keys sequence based on definition; keeps order of patterns! 

184 keys = [] 

185 for name in names: 

186 if name in skel_cls.__boneMap__: 

187 keys.append(name) 

188 else: 

189 keys.extend(fnmatch.filter(skel_cls.__boneMap__.keys(), name)) 

190 

191 if clone: 

192 bone_map |= {k: copy.deepcopy(skel_cls.__boneMap__[k]) for k in keys if skel_cls.__boneMap__[k]} 

193 else: 

194 bone_map |= {k: skel_cls.__boneMap__[k] for k in keys if skel_cls.__boneMap__[k]} 

195 

196 elif clone: 

197 if bone_map: 

198 bone_map = copy.deepcopy(bone_map) 

199 else: 

200 bone_map = copy.deepcopy(skel_cls.__boneMap__) 

201 

202 # generated or use provided bone_map 

203 if bone_map: 

204 self.boneMap = bone_map 

205 

206 else: # No Subskel, no Clone 

207 self.boneMap = skel_cls.__boneMap__.copy() 

208 

209 if clone: 

210 for v in self.boneMap.values(): 

211 v.isClonedInstance = True 

212 

213 self.accessedValues = {} 

214 self.dbEntity = None 

215 self.errors = [] 

216 self.is_cloned = clone 

217 self.renderAccessedValues = {} 

218 self.renderPreparation = None 

219 self.skeletonCls = skel_cls 

220 

221 def items(self, yieldBoneValues: bool = False) -> t.Iterable[tuple[str, BaseBone]]: 

222 if yieldBoneValues: 

223 for key in self.boneMap.keys(): 

224 yield key, self[key] 

225 else: 

226 yield from self.boneMap.items() 

227 

228 def keys(self) -> t.Iterable[str]: 

229 yield from self.boneMap.keys() 

230 

231 def values(self) -> t.Iterable[t.Any]: 

232 yield from self.boneMap.values() 

233 

234 def __iter__(self) -> t.Iterable[str]: 

235 yield from self.keys() 

236 

237 def __contains__(self, item): 

238 return item in self.boneMap 

239 

240 def get(self, item, default=None): 

241 if item not in self: 

242 return default 

243 

244 return self[item] 

245 

246 def update(self, *args, **kwargs) -> None: 

247 self.__ior__(dict(*args, **kwargs)) 

248 

249 def __setitem__(self, key, value): 

250 assert self.renderPreparation is None, "Cannot modify values while rendering" 

251 if isinstance(value, BaseBone): 

252 raise AttributeError(f"Don't assign this bone object as skel[\"{key}\"] = ... anymore to the skeleton. " 

253 f"Use skel.{key} = ... for bone to skeleton assignment!") 

254 self.accessedValues[key] = value 

255 

256 def __getitem__(self, key): 

257 if self.renderPreparation: 

258 if key in self.renderAccessedValues: 

259 return self.renderAccessedValues[key] 

260 if key not in self.accessedValues: 

261 boneInstance = self.boneMap.get(key, None) 

262 if boneInstance: 

263 if self.dbEntity is not None: 

264 boneInstance.unserialize(self, key) 

265 else: 

266 self.accessedValues[key] = boneInstance.getDefaultValue(self) 

267 if not self.renderPreparation: 

268 return self.accessedValues.get(key) 

269 value = self.renderPreparation(getattr(self, key), self, key, self.accessedValues.get(key)) 

270 self.renderAccessedValues[key] = value 

271 return value 

272 

273 def __getattr__(self, item: str): 

274 """ 

275 Get a special attribute from the SkeletonInstance 

276 

277 __getattr__ is called when an attribute access fails with an 

278 AttributeError. So we know that this is not a real attribute of 

279 the SkeletonInstance. But there are still a few special cases in which 

280 attributes are loaded from the skeleton class. 

281 """ 

282 if item == "boneMap": 

283 return {} # There are __setAttr__ calls before __init__ has run 

284 

285 # Load attribute value from the Skeleton class 

286 elif item in { 

287 "database_adapters", 

288 "interBoneValidations", 

289 "kindName", 

290 }: 

291 return getattr(self.skeletonCls, item) 

292 

293 # FIXME: viur-datastore backward compatiblity REMOVE WITH VIUR4 

294 elif item == "customDatabaseAdapter": 

295 if prop := getattr(self.skeletonCls, "database_adapters"): 

296 return prop[0] # viur-datastore assumes there is only ONE! 

297 

298 return None 

299 

300 # Load a @classmethod from the Skeleton class and bound this SkeletonInstance 

301 elif item in { 

302 "all", 

303 "delete", 

304 "patch", 

305 "fromClient", 

306 "fromDB", 

307 "getCurrentSEOKeys", 

308 "postDeletedHandler", 

309 "postSavedHandler", 

310 "preProcessBlobLocks", 

311 "preProcessSerializedData", 

312 "read", 

313 "refresh", 

314 "serialize", 

315 "setBoneValue", 

316 "toDB", 

317 "unserialize", 

318 "write", 

319 }: 

320 return partial(getattr(self.skeletonCls, item), self) 

321 

322 # Load a @property from the Skeleton class 

323 try: 

324 # Use try/except to save an if check 

325 class_value = getattr(self.skeletonCls, item) 

326 

327 except AttributeError: 

328 # Not inside the Skeleton class, okay at this point. 

329 pass 

330 

331 else: 

332 if isinstance(class_value, property): 

333 # The attribute is a @property and can be called 

334 # Note: `self` is this SkeletonInstance, not the Skeleton class. 

335 # Therefore, you can access values inside the property method 

336 # with item-access like `self["key"]`. 

337 try: 

338 return class_value.fget(self) 

339 except AttributeError as exc: 

340 # The AttributeError cannot be re-raised any further at this point. 

341 # Since this would then be evaluated as an access error 

342 # to the property attribute. 

343 # Otherwise, it would be lost that it is an incorrect attribute access 

344 # within this property (during the method call). 

345 msg, *args = exc.args 

346 msg = f"AttributeError: {msg}" 

347 raise ValueError(msg, *args) from exc 

348 # Load the bone instance from the bone map of this SkeletonInstance 

349 try: 

350 return self.boneMap[item] 

351 except KeyError as exc: 

352 raise AttributeError(f"{self.__class__.__name__!r} object has no attribute '{item}'") from exc 

353 

354 def __delattr__(self, item): 

355 del self.boneMap[item] 

356 if item in self.accessedValues: 

357 del self.accessedValues[item] 

358 if item in self.renderAccessedValues: 

359 del self.renderAccessedValues[item] 

360 

361 def __setattr__(self, key, value): 

362 if key in self.boneMap or isinstance(value, BaseBone): 

363 if value is None: 

364 del self.boneMap[key] 

365 else: 

366 value.__set_name__(self.skeletonCls, key) 

367 self.boneMap[key] = value 

368 elif key == "renderPreparation": 

369 super().__setattr__(key, value) 

370 self.renderAccessedValues.clear() 

371 else: 

372 super().__setattr__(key, value) 

373 

374 def __repr__(self) -> str: 

375 return f"<SkeletonInstance of {self.skeletonCls.__name__} with {dict(self)}>" 

376 

377 def __str__(self) -> str: 

378 return str(dict(self)) 

379 

380 def __len__(self) -> int: 

381 return len(self.boneMap) 

382 

383 def __ior__(self, other: dict | SkeletonInstance | db.Entity) -> SkeletonInstance: 

384 if isinstance(other, dict): 

385 for key, value in other.items(): 

386 self.setBoneValue(key, value) 

387 elif isinstance(other, db.Entity): 

388 new_entity = self.dbEntity or db.Entity() 

389 # We're not overriding the key 

390 for key, value in other.items(): 

391 new_entity[key] = value 

392 self.setEntity(new_entity) 

393 elif isinstance(other, SkeletonInstance): 

394 for key, value in other.accessedValues.items(): 

395 self.accessedValues[key] = value 

396 for key, value in other.dbEntity.items(): 

397 self.dbEntity[key] = value 

398 else: 

399 raise ValueError("Unsupported Type") 

400 return self 

401 

402 def clone(self): 

403 """ 

404 Clones a SkeletonInstance into a modificable, stand-alone instance. 

405 This will also allow to modify the underlying data model. 

406 """ 

407 res = SkeletonInstance(self.skeletonCls, bone_map=self.boneMap, clone=True) 

408 res.accessedValues = copy.deepcopy(self.accessedValues) 

409 res.dbEntity = copy.deepcopy(self.dbEntity) 

410 res.is_cloned = True 

411 res.renderAccessedValues = copy.deepcopy(self.renderAccessedValues) 

412 return res 

413 

414 def ensure_is_cloned(self): 

415 """ 

416 Ensured this SkeletonInstance is a stand-alone clone, which can be modified. 

417 Does nothing in case it was already cloned before. 

418 """ 

419 if not self.is_cloned: 

420 return self.clone() 

421 

422 return self 

423 

424 def setEntity(self, entity: db.Entity): 

425 self.dbEntity = entity 

426 self.accessedValues = {} 

427 self.renderAccessedValues = {} 

428 

429 def structure(self) -> dict: 

430 return { 

431 key: bone.structure() | {"sortindex": i} 

432 for i, (key, bone) in enumerate(self.items()) 

433 } 

434 

435 def __deepcopy__(self, memodict): 

436 res = self.clone() 

437 memodict[id(self)] = res 

438 return res 

439 

440 

441class BaseSkeleton(object, metaclass=MetaBaseSkel): 

442 """ 

443 This is a container-object holding information about one database entity. 

444 

445 It has to be sub-classed with individual information about the kindName of the entities 

446 and its specific data attributes, the so called bones. 

447 The Skeleton stores its bones in an :class:`OrderedDict`-Instance, so the definition order of the 

448 contained bones remains constant. 

449 

450 :ivar key: This bone stores the current database key of this entity. \ 

451 Assigning to this bones value is dangerous and does *not* affect the actual key its stored in. 

452 

453 :vartype key: server.bones.BaseBone 

454 

455 :ivar creationdate: The date and time where this entity has been created. 

456 :vartype creationdate: server.bones.DateBone 

457 

458 :ivar changedate: The date and time of the last change to this entity. 

459 :vartype changedate: server.bones.DateBone 

460 """ 

461 __viurBaseSkeletonMarker__ = True 

462 boneMap = None 

463 

464 @classmethod 

465 @deprecated( 

466 version="3.7.0", 

467 reason="Function renamed. Use subskel function as alternative implementation.", 

468 ) 

469 def subSkel(cls, *subskel_names, fullClone: bool = False, **kwargs) -> SkeletonInstance: 

470 return cls.subskel(*subskel_names, clone=fullClone) # FIXME: REMOVE WITH VIUR4 

471 

472 @classmethod 

473 def subskel( 

474 cls, 

475 *names: str, 

476 bones: t.Iterable[str] = (), 

477 clone: bool = False, 

478 ) -> SkeletonInstance: 

479 """ 

480 Creates a new sub-skeleton from the current skeleton. 

481 

482 A sub-skeleton is a copy of the original skeleton, containing only a subset of its bones. 

483 

484 Sub-skeletons can either be defined using the the subSkels property of the Skeleton object, 

485 or freely by giving patterns for bone names which shall be part of the sub-skeleton. 

486 

487 1. Giving names as parameter merges the bones of all Skeleton.subSkels-configurations together. 

488 This is the usual behavior. By passing multiple sub-skeleton names to this function, a sub-skeleton 

489 with the union of all bones of the specified sub-skeletons is returned. If an entry called "*" 

490 exists in the subSkels-dictionary, the bones listed in this entry will always be part of the 

491 generated sub-skeleton. 

492 2. Given the *bones* parameter allows to freely specify a sub-skeleton; One specialty here is, 

493 that the order of the bones can also be changed in this mode. This mode is the new way of defining 

494 sub-skeletons, and might become the primary way to define sub-skeletons in future. 

495 3. Both modes (1 + 2) can be combined, but then the original order of the bones is kept. 

496 4. The "key" bone is automatically available in each sub-skeleton. 

497 5. An fnmatch-compatible wildcard pattern is allowed both in the subSkels-bone-list and the 

498 free bone list. 

499 

500 Example (TodoSkel is the example skeleton from viur-base): 

501 ```py 

502 # legacy mode (see 1) 

503 subskel = TodoSkel.subskel("add") 

504 # creates subskel: key, firstname, lastname, subject 

505 

506 # free mode (see 2) allows to specify a different order! 

507 subskel = TodoSkel.subskel(bones=("subject", "message", "*stname")) 

508 # creates subskel: key, subject, message, firstname, lastname 

509 

510 # mixed mode (see 3) 

511 subskel = TodoSkel.subskel("add", bones=("message", )) 

512 # creates subskel: key, firstname, lastname, subject, message 

513 ``` 

514 

515 :param bones: Allows to specify an iterator of bone names (more precisely, fnmatch-wildards) which allow 

516 to freely define a subskel. If *only* this parameter is given, the order of the specification also 

517 defines, the order of the list. Otherwise, the original order as defined in the skeleton is kept. 

518 :param clone: If set True, performs a cloning of the used bone map, to be entirely stand-alone. 

519 

520 :return: The sub-skeleton of the specified type. 

521 """ 

522 from_subskel = False 

523 bones = list(bones) 

524 

525 for name in names: 

526 # a str refers to a subskel name from the cls.subSkel dict 

527 if isinstance(name, str): 

528 # add bones from "*" subskel once 

529 if not from_subskel: 

530 bones.extend(cls.subSkels.get("*") or ()) 

531 from_subskel = True 

532 

533 bones.extend(cls.subSkels.get(name) or ()) 

534 

535 else: 

536 raise ValueError(f"Invalid subskel definition: {name!r}") 

537 

538 if from_subskel: 

539 # when from_subskel is True, create bone names based on the order of the bones in the original skeleton 

540 bones = tuple(k for k in cls.__boneMap__.keys() if any(fnmatch.fnmatch(k, n) for n in bones)) 

541 

542 if not bones: 

543 raise ValueError("The given subskel definition doesn't contain any bones!") 

544 

545 return cls(bones=bones, clone=clone) 

546 

547 @classmethod 

548 def setSystemInitialized(cls): 

549 for attrName in dir(cls): 

550 bone = getattr(cls, attrName) 

551 if isinstance(bone, BaseBone): 

552 bone.setSystemInitialized() 

553 

554 @classmethod 

555 def setBoneValue( 

556 cls, 

557 skel: SkeletonInstance, 

558 boneName: str, 

559 value: t.Any, 

560 append: bool = False, 

561 language: t.Optional[str] = None 

562 ) -> bool: 

563 """ 

564 Allows for setting a bones value without calling fromClient or assigning a value directly. 

565 Sanity-Checks are performed; if the value is invalid, that bone flips back to its original 

566 (default) value and false is returned. 

567 

568 :param boneName: The name of the bone to be modified 

569 :param value: The value that should be assigned. It's type depends on the type of that bone 

570 :param append: If True, the given value is appended to the values of that bone instead of 

571 replacing it. Only supported on bones with multiple=True 

572 :param language: Language to set 

573 

574 :return: Wherever that operation succeeded or not. 

575 """ 

576 bone = getattr(skel, boneName, None) 

577 

578 if not isinstance(bone, BaseBone): 

579 raise ValueError(f"{boneName!r} is no valid bone on this skeleton ({skel!r})") 

580 

581 if language: 

582 if not bone.languages: 

583 raise ValueError("The bone {boneName!r} has no language setting") 

584 elif language not in bone.languages: 

585 raise ValueError("The language {language!r} is not available for bone {boneName!r}") 

586 

587 if value is None: 

588 if append: 

589 raise ValueError("Cannot append None-value to bone {boneName!r}") 

590 

591 if language: 

592 skel[boneName][language] = [] if bone.multiple else None 

593 else: 

594 skel[boneName] = [] if bone.multiple else None 

595 

596 return True 

597 

598 _ = skel[boneName] # ensure the bone is being unserialized first 

599 return bone.setBoneValue(skel, boneName, value, append, language) 

600 

601 @classmethod 

602 def fromClient( 

603 cls, 

604 skel: SkeletonInstance, 

605 data: dict[str, list[str] | str], 

606 *, 

607 amend: bool = False, 

608 ignore: t.Optional[t.Iterable[str]] = None, 

609 ) -> bool: 

610 """ 

611 Load supplied *data* into Skeleton. 

612 

613 This function works similar to :func:`~viur.core.skeleton.Skeleton.setValues`, except that 

614 the values retrieved from *data* are checked against the bones and their validity checks. 

615 

616 Even if this function returns False, all bones are guaranteed to be in a valid state. 

617 The ones which have been read correctly are set to their valid values; 

618 Bones with invalid values are set back to a safe default (None in most cases). 

619 So its possible to call :func:`~viur.core.skeleton.Skeleton.write` afterwards even if reading 

620 data with this function failed (through this might violates the assumed consistency-model). 

621 

622 :param skel: The skeleton instance to be filled. 

623 :param data: Dictionary from which the data is read. 

624 :param amend: Defines whether content of data may be incomplete to amend the skel, 

625 which is useful for edit-actions. 

626 :param ignore: optional list of bones to be ignored; Defaults to all readonly-bones when set to None. 

627 

628 :returns: True if all data was successfully read and complete. \ 

629 False otherwise (e.g. some required fields where missing or where invalid). 

630 """ 

631 complete = True 

632 skel.errors = [] 

633 

634 for key, bone in skel.items(): 

635 if (ignore is None and bone.readOnly) or key in (ignore or ()): 

636 continue 

637 

638 if errors := bone.fromClient(skel, key, data): 

639 for error in errors: 

640 # insert current bone name into error's fieldPath 

641 error.fieldPath.insert(0, str(key)) 

642 

643 # logging.debug(f"BaseSkel.fromClient {key=} {error=}") 

644 

645 incomplete = ( 

646 # always when something is invalid 

647 error.severity == ReadFromClientErrorSeverity.Invalid 

648 or ( 

649 # only when path is top-level 

650 len(error.fieldPath) == 1 

651 and ( 

652 # bone is generally required 

653 bool(bone.required) 

654 and ( 

655 # and value is either empty 

656 error.severity == ReadFromClientErrorSeverity.Empty 

657 # or when not amending, not set 

658 or (not amend and error.severity == ReadFromClientErrorSeverity.NotSet) 

659 ) 

660 ) 

661 ) 

662 ) 

663 

664 # in case there are language requirements, test additionally 

665 if bone.languages and isinstance(bone.required, (list, tuple)): 

666 incomplete &= any([key, lang] == error.fieldPath for lang in bone.required) 

667 

668 # logging.debug(f"BaseSkel.fromClient {incomplete=} {error.severity=} {bone.required=}") 

669 

670 if incomplete: 

671 complete = False 

672 

673 if conf.debug.skeleton_from_client: 

674 logging.error( 

675 f"""{getattr(cls, "kindName", cls.__name__)}: {".".join(error.fieldPath)}: """ 

676 f"""({error.severity}) {error.errorMessage}""" 

677 ) 

678 

679 skel.errors += errors 

680 

681 return complete 

682 

683 @classmethod 

684 def refresh(cls, skel: SkeletonInstance): 

685 """ 

686 Refresh the bones current content. 

687 

688 This function causes a refresh of all relational bones and their associated 

689 information. 

690 """ 

691 logging.debug(f"""Refreshing {skel["key"]!r} ({skel.get("name")!r})""") 

692 

693 for key, bone in skel.items(): 

694 if not isinstance(bone, BaseBone): 

695 continue 

696 

697 _ = skel[key] # Ensure value gets loaded 

698 bone.refresh(skel, key) 

699 

700 def __new__(cls, *args, **kwargs) -> SkeletonInstance: 

701 return SkeletonInstance(cls, *args, **kwargs) 

702 

703 

704class MetaSkel(MetaBaseSkel): 

705 

706 def __init__(cls, name, bases, dct, **kwargs): 

707 super().__init__(name, bases, dct, **kwargs) 

708 

709 relNewFileName = inspect.getfile(cls) \ 

710 .replace(str(conf.instance.project_base_path), "") \ 

711 .replace(str(conf.instance.core_base_path), "") 

712 

713 # Check if we have an abstract skeleton 

714 if cls.__name__.endswith(ABSTRACT_SKEL_CLS_SUFFIX): 

715 # Ensure that it doesn't have a kindName 

716 assert cls.kindName is _UNDEFINED or cls.kindName is None, "Abstract Skeletons can't have a kindName" 

717 # Prevent any further processing by this class; it has to be sub-classed before it can be used 

718 return 

719 

720 # Automatic determination of the kindName, if the class is not part of viur.core. 

721 if (cls.kindName is _UNDEFINED 

722 and not relNewFileName.strip(os.path.sep).startswith("viur") 

723 and not "viur_doc_build" in dir(sys)): 

724 if cls.__name__.endswith("Skel"): 

725 cls.kindName = cls.__name__.lower()[:-4] 

726 else: 

727 cls.kindName = cls.__name__.lower() 

728 

729 # Try to determine which skeleton definition takes precedence 

730 if cls.kindName and cls.kindName is not _UNDEFINED and cls.kindName in MetaBaseSkel._skelCache: 

731 relOldFileName = inspect.getfile(MetaBaseSkel._skelCache[cls.kindName]) \ 

732 .replace(str(conf.instance.project_base_path), "") \ 

733 .replace(str(conf.instance.core_base_path), "") 

734 idxOld = min( 

735 [x for (x, y) in enumerate(conf.skeleton_search_path) if relOldFileName.startswith(y)] + [999]) 

736 idxNew = min( 

737 [x for (x, y) in enumerate(conf.skeleton_search_path) if relNewFileName.startswith(y)] + [999]) 

738 if idxNew == 999: 

739 # We could not determine a priority for this class as its from a path not listed in the config 

740 raise NotImplementedError( 

741 "Skeletons must be defined in a folder listed in conf.skeleton_search_path") 

742 elif idxOld < idxNew: # Lower index takes precedence 

743 # The currently processed skeleton has a lower priority than the one we already saw - just ignore it 

744 return 

745 elif idxOld > idxNew: 

746 # The currently processed skeleton has a higher priority, use that from now 

747 MetaBaseSkel._skelCache[cls.kindName] = cls 

748 else: # They seem to be from the same Package - raise as something is messed up 

749 raise ValueError(f"Duplicate definition for {cls.kindName} in {relNewFileName} and {relOldFileName}") 

750 

751 # Ensure that all skeletons are defined in folders listed in conf.skeleton_search_path 

752 if (not any([relNewFileName.startswith(x) for x in conf.skeleton_search_path]) 

753 and not "viur_doc_build" in dir(sys)): # Do not check while documentation build 

754 raise NotImplementedError( 

755 f"""{relNewFileName} must be defined in a folder listed in {conf.skeleton_search_path}""") 

756 

757 if cls.kindName and cls.kindName is not _UNDEFINED: 

758 MetaBaseSkel._skelCache[cls.kindName] = cls 

759 

760 # Auto-Add ViUR Search Tags Adapter if the skeleton has no adapter attached 

761 if cls.database_adapters is _UNDEFINED: 

762 cls.database_adapters = ViurTagsSearchAdapter() 

763 

764 # Always ensure that skel.database_adapters is an iterable 

765 cls.database_adapters = utils.ensure_iterable(cls.database_adapters) 

766 

767 

768class DatabaseAdapter: 

769 """ 

770 Adapter class used to bind or use other databases and hook operations when working with a Skeleton. 

771 """ 

772 

773 providesFulltextSearch: bool = False 

774 """Set to True if we can run a fulltext search using this database.""" 

775 

776 fulltextSearchGuaranteesQueryConstrains = False 

777 """Are results returned by `meth:fulltextSearch` guaranteed to also match the databaseQuery""" 

778 

779 providesCustomQueries: bool = False 

780 """Indicate that we can run more types of queries than originally supported by datastore""" 

781 

782 def prewrite(self, skel: SkeletonInstance, is_add: bool, change_list: t.Iterable[str] = ()): 

783 """ 

784 Hook being called on a add, edit or delete operation before the skeleton-specific action is performed. 

785 

786 The hook can be used to modifiy the skeleton before writing. 

787 The raw entity can be obainted using `skel.dbEntity`. 

788 

789 :param action: Either contains "add", "edit" or "delete", depending on the operation. 

790 :param skel: is the skeleton that is being read before written. 

791 :param change_list: is a list of bone names which are being changed within the write. 

792 """ 

793 pass 

794 

795 def write(self, skel: SkeletonInstance, is_add: bool, change_list: t.Iterable[str] = ()): 

796 """ 

797 Hook being called on a write operations after the skeleton is written. 

798 

799 The raw entity can be obainted using `skel.dbEntity`. 

800 

801 :param action: Either contains "add" or "edit", depending on the operation. 

802 :param skel: is the skeleton that is being read before written. 

803 :param change_list: is a list of bone names which are being changed within the write. 

804 """ 

805 pass 

806 

807 def delete(self, skel: SkeletonInstance): 

808 """ 

809 Hook being called on a delete operation after the skeleton is deleted. 

810 """ 

811 pass 

812 

813 def fulltextSearch(self, queryString: str, databaseQuery: db.Query) -> list[db.Entity]: 

814 """ 

815 If this database supports fulltext searches, this method has to implement them. 

816 If it's a plain fulltext search engine, leave 'prop:fulltextSearchGuaranteesQueryConstrains' set to False, 

817 then the server will post-process the list of entries returned from this function and drop any entry that 

818 cannot be returned due to other constrains set in 'param:databaseQuery'. If you can obey *every* constrain 

819 set in that Query, we can skip this post-processing and save some CPU-cycles. 

820 :param queryString: the string as received from the user (no quotation or other safety checks applied!) 

821 :param databaseQuery: The query containing any constrains that returned entries must also match 

822 :return: 

823 """ 

824 raise NotImplementedError 

825 

826 

827class ViurTagsSearchAdapter(DatabaseAdapter): 

828 """ 

829 This Adapter implements a simple fulltext search on top of the datastore. 

830 

831 On skel.write(), all words from String-/TextBones are collected with all *min_length* postfixes and dumped 

832 into the property `viurTags`. When queried, we'll run a prefix-match against this property - thus returning 

833 entities with either an exact match or a match within a word. 

834 

835 Example: 

836 For the word "hello" we'll write "hello", "ello" and "llo" into viurTags. 

837 When queried with "hello" we'll have an exact match. 

838 When queried with "hel" we'll match the prefix for "hello" 

839 When queried with "ell" we'll prefix-match "ello" - this is only enabled when substring_matching is True. 

840 

841 We'll automatically add this adapter if a skeleton has no other database adapter defined. 

842 """ 

843 providesFulltextSearch = True 

844 fulltextSearchGuaranteesQueryConstrains = True 

845 

846 def __init__(self, min_length: int = 2, max_length: int = 50, substring_matching: bool = False): 

847 super().__init__() 

848 self.min_length = min_length 

849 self.max_length = max_length 

850 self.substring_matching = substring_matching 

851 

852 def _tags_from_str(self, value: str) -> set[str]: 

853 """ 

854 Extract all words including all min_length postfixes from given string 

855 """ 

856 res = set() 

857 

858 for tag in value.split(" "): 

859 tag = "".join([x for x in tag.lower() if x in conf.search_valid_chars]) 

860 

861 if len(tag) >= self.min_length: 

862 res.add(tag) 

863 

864 if self.substring_matching: 

865 for i in range(1, 1 + len(tag) - self.min_length): 

866 res.add(tag[i:]) 

867 

868 return res 

869 

870 def prewrite(self, skel: SkeletonInstance, *args, **kwargs): 

871 """ 

872 Collect searchTags from skeleton and build viurTags 

873 """ 

874 tags = set() 

875 

876 for name, bone in skel.items(): 

877 if bone.searchable: 

878 tags = tags.union(bone.getSearchTags(skel, name)) 

879 

880 skel.dbEntity["viurTags"] = list( 

881 chain(*[self._tags_from_str(tag) for tag in tags if len(tag) <= self.max_length]) 

882 ) 

883 

884 def fulltextSearch(self, queryString: str, databaseQuery: db.Query) -> list[db.Entity]: 

885 """ 

886 Run a fulltext search 

887 """ 

888 keywords = list(self._tags_from_str(queryString))[:10] 

889 resultScoreMap = {} 

890 resultEntryMap = {} 

891 

892 for keyword in keywords: 

893 qryBase = databaseQuery.clone() 

894 for entry in qryBase.filter("viurTags >=", keyword).filter("viurTags <", keyword + "\ufffd").run(): 

895 if not entry.key in resultScoreMap: 

896 resultScoreMap[entry.key] = 1 

897 else: 

898 resultScoreMap[entry.key] += 1 

899 if not entry.key in resultEntryMap: 

900 resultEntryMap[entry.key] = entry 

901 

902 resultList = [(k, v) for k, v in resultScoreMap.items()] 

903 resultList.sort(key=lambda x: x[1], reverse=True) 

904 

905 return [resultEntryMap[x[0]] for x in resultList[:databaseQuery.queries.limit]] 

906 

907 

908class SeoKeyBone(StringBone): 

909 """ 

910 Special kind of StringBone saving its contents as `viurCurrentSeoKeys` into the entity's `viur` dict. 

911 """ 

912 

913 def unserialize(self, skel: SkeletonInstance, name: str) -> bool: 

914 try: 

915 skel.accessedValues[name] = skel.dbEntity["viur"]["viurCurrentSeoKeys"] 

916 except KeyError: 

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

918 

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

920 # Serialize also to skel["viur"]["viurCurrentSeoKeys"], so we can use this bone in relations 

921 if name in skel.accessedValues: 

922 newVal = skel.accessedValues[name] 

923 if not skel.dbEntity.get("viur"): 

924 skel.dbEntity["viur"] = db.Entity() 

925 res = db.Entity() 

926 res["_viurLanguageWrapper_"] = True 

927 for language in (self.languages or []): 

928 if not self.indexed: 

929 res.exclude_from_indexes.add(language) 

930 res[language] = None 

931 if language in newVal: 

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

933 skel.dbEntity["viur"]["viurCurrentSeoKeys"] = res 

934 return True 

935 

936 

937class Skeleton(BaseSkeleton, metaclass=MetaSkel): 

938 kindName: str = _UNDEFINED 

939 """ 

940 Specifies the entity kind name this Skeleton is associated with. 

941 Will be determined automatically when not explicitly set. 

942 """ 

943 

944 database_adapters: DatabaseAdapter | t.Iterable[DatabaseAdapter] | None = _UNDEFINED 

945 """ 

946 Custom database adapters. 

947 Allows to hook special functionalities that during skeleton modifications. 

948 """ 

949 

950 subSkels = {} # List of pre-defined sub-skeletons of this type 

951 

952 interBoneValidations: list[ 

953 t.Callable[[Skeleton], list[ReadFromClientError]]] = [] # List of functions checking inter-bone dependencies 

954 

955 __seo_key_trans = str.maketrans( 

956 {"<": "", 

957 ">": "", 

958 "\"": "", 

959 "'": "", 

960 "\n": "", 

961 "\0": "", 

962 "/": "", 

963 "\\": "", 

964 "?": "", 

965 "&": "", 

966 "#": "" 

967 }) 

968 

969 # The "key" bone stores the current database key of this skeleton. 

970 # Warning: Assigning to this bones value now *will* set the key 

971 # it gets stored in. Must be kept readOnly to avoid security-issues with add/edit. 

972 key = KeyBone( 

973 descr="Key" 

974 ) 

975 

976 name = StringBone( 

977 descr="Name", 

978 visible=False, 

979 compute=Compute( 

980 fn=lambda skel: str(skel["key"]), 

981 interval=ComputeInterval(ComputeMethod.OnWrite) 

982 ) 

983 ) 

984 

985 # The date (including time) when this entry has been created 

986 creationdate = DateBone( 

987 descr="created at", 

988 readOnly=True, 

989 visible=False, 

990 indexed=True, 

991 compute=Compute(fn=utils.utcNow, interval=ComputeInterval(ComputeMethod.Once)), 

992 ) 

993 

994 # The last date (including time) when this entry has been updated 

995 

996 changedate = DateBone( 

997 descr="updated at", 

998 readOnly=True, 

999 visible=False, 

1000 indexed=True, 

1001 compute=Compute(fn=utils.utcNow, interval=ComputeInterval(ComputeMethod.OnWrite)), 

1002 ) 

1003 

1004 viurCurrentSeoKeys = SeoKeyBone( 

1005 descr="SEO-Keys", 

1006 readOnly=True, 

1007 visible=False, 

1008 languages=conf.i18n.available_languages 

1009 ) 

1010 

1011 def __repr__(self): 

1012 return "<skeleton %s with data=%r>" % (self.kindName, {k: self[k] for k in self.keys()}) 

1013 

1014 def __str__(self): 

1015 return str({k: self[k] for k in self.keys()}) 

1016 

1017 def __init__(self, *args, **kwargs): 

1018 super(Skeleton, self).__init__(*args, **kwargs) 

1019 assert self.kindName and self.kindName is not _UNDEFINED, "You must set kindName on this skeleton!" 

1020 

1021 @classmethod 

1022 def all(cls, skel, **kwargs) -> db.Query: 

1023 """ 

1024 Create a query with the current Skeletons kindName. 

1025 

1026 :returns: A db.Query object which allows for entity filtering and sorting. 

1027 """ 

1028 return db.Query(skel.kindName, srcSkelClass=skel, **kwargs) 

1029 

1030 @classmethod 

1031 def fromClient( 

1032 cls, 

1033 skel: SkeletonInstance, 

1034 data: dict[str, list[str] | str], 

1035 *, 

1036 amend: bool = False, 

1037 ignore: t.Optional[t.Iterable[str]] = None, 

1038 ) -> bool: 

1039 """ 

1040 This function works similar to :func:`~viur.core.skeleton.Skeleton.setValues`, except that 

1041 the values retrieved from *data* are checked against the bones and their validity checks. 

1042 

1043 Even if this function returns False, all bones are guaranteed to be in a valid state. 

1044 The ones which have been read correctly are set to their valid values; 

1045 Bones with invalid values are set back to a safe default (None in most cases). 

1046 So its possible to call :func:`~viur.core.skeleton.Skeleton.write` afterwards even if reading 

1047 data with this function failed (through this might violates the assumed consistency-model). 

1048 

1049 :param skel: The skeleton instance to be filled. 

1050 :param data: Dictionary from which the data is read. 

1051 :param amend: Defines whether content of data may be incomplete to amend the skel, 

1052 which is useful for edit-actions. 

1053 :param ignore: optional list of bones to be ignored; Defaults to all readonly-bones when set to None. 

1054 

1055 :returns: True if all data was successfully read and complete. \ 

1056 False otherwise (e.g. some required fields where missing or where invalid). 

1057 """ 

1058 assert skel.renderPreparation is None, "Cannot modify values while rendering" 

1059 

1060 # Load data into this skeleton 

1061 complete = bool(data) and super().fromClient(skel, data, amend=amend, ignore=ignore) 

1062 

1063 if ( 

1064 not data # in case data is empty 

1065 or (len(data) == 1 and "key" in data) 

1066 or (utils.parse.bool(data.get("nomissing"))) 

1067 ): 

1068 skel.errors = [] 

1069 

1070 # Check if all unique values are available 

1071 for boneName, boneInstance in skel.items(): 

1072 if boneInstance.unique: 

1073 lockValues = boneInstance.getUniquePropertyIndexValues(skel, boneName) 

1074 for lockValue in lockValues: 

1075 dbObj = db.Get(db.Key(f"{skel.kindName}_{boneName}_uniquePropertyIndex", lockValue)) 

1076 if dbObj and (not skel["key"] or dbObj["references"] != skel["key"].id_or_name): 

1077 # This value is taken (sadly, not by us) 

1078 complete = False 

1079 errorMsg = boneInstance.unique.message 

1080 skel.errors.append( 

1081 ReadFromClientError(ReadFromClientErrorSeverity.Invalid, errorMsg, [boneName])) 

1082 

1083 # Check inter-Bone dependencies 

1084 for checkFunc in skel.interBoneValidations: 

1085 errors = checkFunc(skel) 

1086 if errors: 

1087 for error in errors: 

1088 if error.severity.value > 1: 

1089 complete = False 

1090 if conf.debug.skeleton_from_client: 

1091 logging.debug(f"{cls.kindName}: {error.fieldPath}: {error.errorMessage!r}") 

1092 

1093 skel.errors.extend(errors) 

1094 

1095 return complete 

1096 

1097 @classmethod 

1098 @deprecated( 

1099 version="3.7.0", 

1100 reason="Use skel.read() instead of skel.fromDB()", 

1101 ) 

1102 def fromDB(cls, skel: SkeletonInstance, key: KeyType) -> bool: 

1103 """ 

1104 Deprecated function, replaced by Skeleton.read(). 

1105 """ 

1106 return bool(cls.read(skel, key, _check_legacy=False)) 

1107 

1108 @classmethod 

1109 def read( 

1110 cls, 

1111 skel: SkeletonInstance, 

1112 key: t.Optional[KeyType] = None, 

1113 *, 

1114 create: bool | dict | t.Callable[[SkeletonInstance], None] = False, 

1115 _check_legacy: bool = True 

1116 ) -> t.Optional[SkeletonInstance]: 

1117 """ 

1118 Read Skeleton with *key* from the datastore into the Skeleton. 

1119 If not key is given, skel["key"] will be used. 

1120 

1121 Reads all available data of entity kind *kindName* and the key *key* 

1122 from the Datastore into the Skeleton structure's bones. Any previous 

1123 data of the bones will discard. 

1124 

1125 To store a Skeleton object to the Datastore, see :func:`~viur.core.skeleton.Skeleton.write`. 

1126 

1127 :param key: A :class:`viur.core.db.Key`, string, or int; from which the data shall be fetched. 

1128 If not provided, skel["key"] will be used. 

1129 :param create: Allows to specify a dict or initial callable that is executed in case the Skeleton with the 

1130 given key does not exist, it will be created. 

1131 

1132 :returns: None on error, or the given SkeletonInstance on success. 

1133 

1134 """ 

1135 # FIXME VIUR4: Stay backward compatible, call sub-classed fromDB if available first! 

1136 if _check_legacy and "fromDB" in cls.__dict__: 

1137 with warnings.catch_warnings(): 

1138 warnings.simplefilter("ignore", DeprecationWarning) 

1139 return cls.fromDB(skel, key=key) 

1140 

1141 assert skel.renderPreparation is None, "Cannot modify values while rendering" 

1142 

1143 try: 

1144 db_key = db.keyHelper(key or skel["key"], skel.kindName) 

1145 except (ValueError, NotImplementedError): # This key did not parse 

1146 return None 

1147 

1148 if db_res := db.Get(db_key): 

1149 skel.setEntity(db_res) 

1150 return skel 

1151 elif create in (False, None): 

1152 return None 

1153 elif isinstance(create, dict): 

1154 if create and not skel.fromClient(create, amend=True): 

1155 raise ReadFromClientException(skel.errors) 

1156 elif callable(create): 

1157 create(skel) 

1158 elif create is not True: 

1159 raise ValueError("'create' must either be dict, a callable or True.") 

1160 

1161 return skel.write() 

1162 

1163 @classmethod 

1164 @deprecated( 

1165 version="3.7.0", 

1166 reason="Use skel.write() instead of skel.toDB()", 

1167 ) 

1168 def toDB(cls, skel: SkeletonInstance, update_relations: bool = True, **kwargs) -> db.Key: 

1169 """ 

1170 Deprecated function, replaced by Skeleton.write(). 

1171 """ 

1172 

1173 # TODO: Remove with ViUR4 

1174 if "clearUpdateTag" in kwargs: 

1175 msg = "clearUpdateTag was replaced by update_relations" 

1176 warnings.warn(msg, DeprecationWarning, stacklevel=3) 

1177 logging.warning(msg, stacklevel=3) 

1178 update_relations = not kwargs["clearUpdateTag"] 

1179 

1180 skel = cls.write(skel, update_relations=update_relations, _check_legacy=False) 

1181 return skel["key"] 

1182 

1183 @classmethod 

1184 def write( 

1185 cls, 

1186 skel: SkeletonInstance, 

1187 key: t.Optional[KeyType] = None, 

1188 *, 

1189 update_relations: bool = True, 

1190 _check_legacy: bool = True, 

1191 ) -> SkeletonInstance: 

1192 """ 

1193 Write current Skeleton to the datastore. 

1194 

1195 Stores the current data of this instance into the database. 

1196 If an *key* value is set to the object, this entity will ne updated; 

1197 Otherwise a new entity will be created. 

1198 

1199 To read a Skeleton object from the data store, see :func:`~viur.core.skeleton.Skeleton.read`. 

1200 

1201 :param key: Allows to specify a key that is set to the skeleton and used for writing. 

1202 :param update_relations: If False, this entity won't be marked dirty; 

1203 This avoids from being fetched by the background task updating relations. 

1204 

1205 :returns: The Skeleton. 

1206 """ 

1207 # FIXME VIUR4: Stay backward compatible, call sub-classed toDB if available first! 

1208 if _check_legacy and "toDB" in cls.__dict__: 

1209 with warnings.catch_warnings(): 

1210 warnings.simplefilter("ignore", DeprecationWarning) 

1211 return cls.toDB(skel, update_relations=update_relations) 

1212 

1213 assert skel.renderPreparation is None, "Cannot modify values while rendering" 

1214 

1215 def __txn_write(write_skel): 

1216 db_key = write_skel["key"] 

1217 skel = write_skel.skeletonCls() 

1218 

1219 blob_list = set() 

1220 change_list = [] 

1221 old_copy = {} 

1222 # Load the current values from Datastore or create a new, empty db.Entity 

1223 if not db_key: 

1224 # We'll generate the key we'll be stored under early so we can use it for locks etc 

1225 db_key = db.AllocateIDs(db.Key(skel.kindName)) 

1226 skel.dbEntity = db.Entity(db_key) 

1227 is_add = True 

1228 else: 

1229 db_key = db.keyHelper(db_key, skel.kindName) 

1230 if db_obj := db.Get(db_key): 

1231 skel.dbEntity = db_obj 

1232 old_copy = {k: v for k, v in skel.dbEntity.items()} 

1233 is_add = False 

1234 else: 

1235 skel.dbEntity = db.Entity(db_key) 

1236 is_add = True 

1237 

1238 skel.dbEntity.setdefault("viur", {}) 

1239 

1240 # Merge values and assemble unique properties 

1241 # Move accessed Values from srcSkel over to skel 

1242 skel.accessedValues = write_skel.accessedValues 

1243 skel["key"] = db_key # Ensure key stays set 

1244 

1245 for bone_name, bone in skel.items(): 

1246 if bone_name == "key": # Explicitly skip key on top-level - this had been set above 

1247 continue 

1248 

1249 # Allow bones to perform outstanding "magic" operations before saving to db 

1250 bone.performMagic(skel, bone_name, isAdd=is_add) # FIXME VIUR4: ANY MAGIC IN OUR CODE IS DEPRECATED!!! 

1251 

1252 if not (bone_name in skel.accessedValues or bone.compute) and bone_name not in skel.dbEntity: 

1253 _ = skel[bone_name] # Ensure the datastore is filled with the default value 

1254 

1255 if ( 

1256 bone_name in skel.accessedValues or bone.compute # We can have a computed value on store 

1257 or bone_name not in skel.dbEntity # It has not been written and is not in the database 

1258 ): 

1259 # Serialize bone into entity 

1260 try: 

1261 bone.serialize(skel, bone_name, True) 

1262 except Exception as e: 

1263 logging.error( 

1264 f"Failed to serialize {bone_name=} ({bone=}): {skel.accessedValues[bone_name]=}" 

1265 ) 

1266 raise e 

1267 

1268 # Obtain referenced blobs 

1269 blob_list.update(bone.getReferencedBlobs(skel, bone_name)) 

1270 

1271 # Check if the value has actually changed 

1272 if skel.dbEntity.get(bone_name) != old_copy.get(bone_name): 

1273 change_list.append(bone_name) 

1274 

1275 # Lock hashes from bones that must have unique values 

1276 if bone.unique: 

1277 # Remember old hashes for bones that must have an unique value 

1278 old_unique_values = [] 

1279 

1280 if f"{bone_name}_uniqueIndexValue" in skel.dbEntity["viur"]: 

1281 old_unique_values = skel.dbEntity["viur"][f"{bone_name}_uniqueIndexValue"] 

1282 # Check if the property is unique 

1283 new_unique_values = bone.getUniquePropertyIndexValues(skel, bone_name) 

1284 new_lock_kind = f"{skel.kindName}_{bone_name}_uniquePropertyIndex" 

1285 for new_lock_value in new_unique_values: 

1286 new_lock_key = db.Key(new_lock_kind, new_lock_value) 

1287 if lock_db_obj := db.Get(new_lock_key): 

1288 

1289 # There's already a lock for that value, check if we hold it 

1290 if lock_db_obj["references"] != skel.dbEntity.key.id_or_name: 

1291 # This value has already been claimed, and not by us 

1292 # TODO: Use a custom exception class which is catchable with an try/except 

1293 raise ValueError( 

1294 f"The unique value {skel[bone_name]!r} of bone {bone_name!r} " 

1295 f"has been recently claimed!") 

1296 else: 

1297 # This value is locked for the first time, create a new lock-object 

1298 lock_obj = db.Entity(new_lock_key) 

1299 lock_obj["references"] = skel.dbEntity.key.id_or_name 

1300 db.Put(lock_obj) 

1301 if new_lock_value in old_unique_values: 

1302 old_unique_values.remove(new_lock_value) 

1303 skel.dbEntity["viur"][f"{bone_name}_uniqueIndexValue"] = new_unique_values 

1304 

1305 # Remove any lock-object we're holding for values that we don't have anymore 

1306 for old_unique_value in old_unique_values: 

1307 # Try to delete the old lock 

1308 

1309 old_lock_key = db.Key(f"{skel.kindName}_{bone_name}_uniquePropertyIndex", old_unique_value) 

1310 if old_lock_obj := db.Get(old_lock_key): 

1311 if old_lock_obj["references"] != skel.dbEntity.key.id_or_name: 

1312 

1313 # We've been supposed to have that lock - but we don't. 

1314 # Don't remove that lock as it now belongs to a different entry 

1315 logging.critical("Detected Database corruption! A Value-Lock had been reassigned!") 

1316 else: 

1317 # It's our lock which we don't need anymore 

1318 db.Delete(old_lock_key) 

1319 else: 

1320 logging.critical("Detected Database corruption! Could not delete stale lock-object!") 

1321 

1322 # Delete legacy property (PR #1244) #TODO: Remove in ViUR4 

1323 skel.dbEntity.pop("viur_incomming_relational_locks", None) 

1324 

1325 # Ensure the SEO-Keys are up-to-date 

1326 last_requested_seo_keys = skel.dbEntity["viur"].get("viurLastRequestedSeoKeys") or {} 

1327 last_set_seo_keys = skel.dbEntity["viur"].get("viurCurrentSeoKeys") or {} 

1328 # Filter garbage serialized into this field by the SeoKeyBone 

1329 last_set_seo_keys = {k: v for k, v in last_set_seo_keys.items() if not k.startswith("_") and v} 

1330 

1331 if not isinstance(skel.dbEntity["viur"].get("viurCurrentSeoKeys"), dict): 

1332 skel.dbEntity["viur"]["viurCurrentSeoKeys"] = {} 

1333 

1334 if current_seo_keys := skel.getCurrentSEOKeys(): 

1335 # Convert to lower-case and remove certain characters 

1336 for lang, value in current_seo_keys.items(): 

1337 current_seo_keys[lang] = value.lower().translate(Skeleton.__seo_key_trans).strip() 

1338 

1339 for language in (conf.i18n.available_languages or [conf.i18n.default_language]): 

1340 if current_seo_keys and language in current_seo_keys: 

1341 current_seo_key = current_seo_keys[language] 

1342 

1343 if current_seo_key != last_requested_seo_keys.get(language): # This one is new or has changed 

1344 new_seo_key = current_seo_keys[language] 

1345 

1346 for _ in range(0, 3): 

1347 entry_using_key = db.Query(skel.kindName).filter( 

1348 "viur.viurActiveSeoKeys =", new_seo_key).getEntry() 

1349 

1350 if entry_using_key and entry_using_key.key != skel.dbEntity.key: 

1351 # It's not unique; append a random string and try again 

1352 new_seo_key = f"{current_seo_keys[language]}-{utils.string.random(5).lower()}" 

1353 

1354 else: 

1355 # We found a new SeoKey 

1356 break 

1357 else: 

1358 raise ValueError("Could not generate an unique seo key in 3 attempts") 

1359 else: 

1360 new_seo_key = current_seo_key 

1361 last_set_seo_keys[language] = new_seo_key 

1362 

1363 else: 

1364 # We'll use the database-key instead 

1365 last_set_seo_keys[language] = str(skel.dbEntity.key.id_or_name) 

1366 

1367 # Store the current, active key for that language 

1368 skel.dbEntity["viur"]["viurCurrentSeoKeys"][language] = last_set_seo_keys[language] 

1369 

1370 skel.dbEntity["viur"].setdefault("viurActiveSeoKeys", []) 

1371 for language, seo_key in last_set_seo_keys.items(): 

1372 if skel.dbEntity["viur"]["viurCurrentSeoKeys"][language] not in \ 

1373 skel.dbEntity["viur"]["viurActiveSeoKeys"]: 

1374 # Ensure the current, active seo key is in the list of all seo keys 

1375 skel.dbEntity["viur"]["viurActiveSeoKeys"].insert(0, seo_key) 

1376 if str(skel.dbEntity.key.id_or_name) not in skel.dbEntity["viur"]["viurActiveSeoKeys"]: 

1377 # Ensure that key is also in there 

1378 skel.dbEntity["viur"]["viurActiveSeoKeys"].insert(0, str(skel.dbEntity.key.id_or_name)) 

1379 # Trim to the last 200 used entries 

1380 skel.dbEntity["viur"]["viurActiveSeoKeys"] = skel.dbEntity["viur"]["viurActiveSeoKeys"][:200] 

1381 # Store lastRequestedKeys so further updates can run more efficient 

1382 skel.dbEntity["viur"]["viurLastRequestedSeoKeys"] = current_seo_keys 

1383 

1384 # mark entity as "dirty" when update_relations is set, to zero otherwise. 

1385 skel.dbEntity["viur"]["delayedUpdateTag"] = time.time() if update_relations else 0 

1386 

1387 skel.dbEntity = skel.preProcessSerializedData(skel.dbEntity) 

1388 

1389 # Allow the database adapter to apply last minute changes to the object 

1390 for adapter in skel.database_adapters: 

1391 adapter.prewrite(skel, is_add, change_list) 

1392 

1393 # ViUR2 import compatibility - remove properties containing. if we have a dict with the same name 

1394 def fixDotNames(entity): 

1395 for k, v in list(entity.items()): 

1396 if isinstance(v, dict): 

1397 for k2, v2 in list(entity.items()): 

1398 if k2.startswith(f"{k}."): 

1399 del entity[k2] 

1400 backupKey = k2.replace(".", "__") 

1401 entity[backupKey] = v2 

1402 entity.exclude_from_indexes = set(entity.exclude_from_indexes) | {backupKey} 

1403 fixDotNames(v) 

1404 elif isinstance(v, list): 

1405 for x in v: 

1406 if isinstance(x, dict): 

1407 fixDotNames(x) 

1408 

1409 # FIXME: REMOVE IN VIUR4 

1410 if conf.viur2import_blobsource: # Try to fix these only when converting from ViUR2 

1411 fixDotNames(skel.dbEntity) 

1412 

1413 # Write the core entry back 

1414 db.Put(skel.dbEntity) 

1415 

1416 # Now write the blob-lock object 

1417 blob_list = skel.preProcessBlobLocks(blob_list) 

1418 if blob_list is None: 

1419 raise ValueError("Did you forget to return the blob_list somewhere inside getReferencedBlobs()?") 

1420 if None in blob_list: 

1421 msg = f"None is not valid in {blob_list=}" 

1422 logging.error(msg) 

1423 raise ValueError(msg) 

1424 

1425 if not is_add and (old_blob_lock_obj := db.Get(db.Key("viur-blob-locks", db_key.id_or_name))): 

1426 removed_blobs = set(old_blob_lock_obj.get("active_blob_references", [])) - blob_list 

1427 old_blob_lock_obj["active_blob_references"] = list(blob_list) 

1428 if old_blob_lock_obj["old_blob_references"] is None: 

1429 old_blob_lock_obj["old_blob_references"] = list(removed_blobs) 

1430 else: 

1431 old_blob_refs = set(old_blob_lock_obj["old_blob_references"]) 

1432 old_blob_refs.update(removed_blobs) # Add removed blobs 

1433 old_blob_refs -= blob_list # Remove active blobs 

1434 old_blob_lock_obj["old_blob_references"] = list(old_blob_refs) 

1435 

1436 old_blob_lock_obj["has_old_blob_references"] = bool(old_blob_lock_obj["old_blob_references"]) 

1437 old_blob_lock_obj["is_stale"] = False 

1438 db.Put(old_blob_lock_obj) 

1439 else: # We need to create a new blob-lock-object 

1440 blob_lock_obj = db.Entity(db.Key("viur-blob-locks", skel.dbEntity.key.id_or_name)) 

1441 blob_lock_obj["active_blob_references"] = list(blob_list) 

1442 blob_lock_obj["old_blob_references"] = [] 

1443 blob_lock_obj["has_old_blob_references"] = False 

1444 blob_lock_obj["is_stale"] = False 

1445 db.Put(blob_lock_obj) 

1446 

1447 return skel.dbEntity.key, skel, change_list, is_add 

1448 

1449 # Parse provided key, if any, and set it to skel["key"] 

1450 if key: 

1451 skel["key"] = db.keyHelper(key, skel.kindName) 

1452 

1453 # Run transactional function 

1454 if db.IsInTransaction(): 

1455 key, skel, change_list, is_add = __txn_write(skel) 

1456 else: 

1457 key, skel, change_list, is_add = db.RunInTransaction(__txn_write, skel) 

1458 

1459 for bone_name, bone in skel.items(): 

1460 bone.postSavedHandler(skel, bone_name, key) 

1461 

1462 skel.postSavedHandler(key, skel.dbEntity) 

1463 

1464 if update_relations and not is_add: 

1465 if change_list and len(change_list) < 5: # Only a few bones have changed, process these individually 

1466 for idx, changed_bone in enumerate(change_list): 

1467 updateRelations(key, time.time() + 1, changed_bone, _countdown=10 * idx) 

1468 else: # Update all inbound relations, regardless of which bones they mirror 

1469 updateRelations(key, time.time() + 1, None) 

1470 

1471 # Trigger the database adapter of the changes made to the entry 

1472 for adapter in skel.database_adapters: 

1473 adapter.write(skel, is_add, change_list) 

1474 

1475 return skel 

1476 

1477 @classmethod 

1478 def delete(cls, skel: SkeletonInstance, key: t.Optional[KeyType] = None) -> None: 

1479 """ 

1480 Deletes the entity associated with the current Skeleton from the data store. 

1481 

1482 :param key: Allows to specify a key that is used for deletion, otherwise skel["key"] will be used. 

1483 """ 

1484 

1485 def __txn_delete(skel: SkeletonInstance, key: db.Key): 

1486 if not skel.read(key): 

1487 raise ValueError("This skeleton is not in the database (anymore?)!") 

1488 

1489 # Is there any relation to this Skeleton which prevents the deletion? 

1490 locked_relation = ( 

1491 db.Query("viur-relations") 

1492 .filter("dest.__key__ =", key) 

1493 .filter("viur_relational_consistency =", RelationalConsistency.PreventDeletion.value) 

1494 ).getEntry() 

1495 

1496 if locked_relation is not None: 

1497 raise errors.Locked("This entry is still referenced by other Skeletons, which prevents deleting!") 

1498 

1499 # Ensure that any value lock objects remaining for this entry are being deleted 

1500 viur_data = skel.dbEntity.get("viur") or {} 

1501 

1502 for boneName, bone in skel.items(): 

1503 bone.delete(skel, boneName) 

1504 if bone.unique: 

1505 flushList = [] 

1506 for lockValue in viur_data.get(f"{boneName}_uniqueIndexValue") or []: 

1507 lockKey = db.Key(f"{skel.kindName}_{boneName}_uniquePropertyIndex", lockValue) 

1508 lockObj = db.Get(lockKey) 

1509 if not lockObj: 

1510 logging.error(f"{lockKey=} missing!") 

1511 elif lockObj["references"] != key.id_or_name: 

1512 logging.error( 

1513 f"""{key!r} does not hold lock for {lockKey!r}""") 

1514 else: 

1515 flushList.append(lockObj) 

1516 if flushList: 

1517 db.Delete(flushList) 

1518 

1519 # Delete the blob-key lock object 

1520 lockObjectKey = db.Key("viur-blob-locks", key.id_or_name) 

1521 lockObj = db.Get(lockObjectKey) 

1522 

1523 if lockObj is not None: 

1524 if lockObj["old_blob_references"] is None and lockObj["active_blob_references"] is None: 

1525 db.Delete(lockObjectKey) # Nothing to do here 

1526 else: 

1527 if lockObj["old_blob_references"] is None: 

1528 # No old stale entries, move active_blob_references -> old_blob_references 

1529 lockObj["old_blob_references"] = lockObj["active_blob_references"] 

1530 elif lockObj["active_blob_references"] is not None: 

1531 # Append the current references to the list of old & stale references 

1532 lockObj["old_blob_references"] += lockObj["active_blob_references"] 

1533 lockObj["active_blob_references"] = [] # There are no active ones left 

1534 lockObj["is_stale"] = True 

1535 lockObj["has_old_blob_references"] = True 

1536 db.Put(lockObj) 

1537 

1538 db.Delete(key) 

1539 processRemovedRelations(key) 

1540 

1541 if key := (key or skel["key"]): 

1542 key = db.keyHelper(key, skel.kindName) 

1543 else: 

1544 raise ValueError("This skeleton has no key!") 

1545 

1546 # Full skeleton is required to have all bones! 

1547 skel = skeletonByKind(skel.kindName)() 

1548 

1549 if db.IsInTransaction(): 

1550 __txn_delete(skel, key) 

1551 else: 

1552 db.RunInTransaction(__txn_delete, skel, key) 

1553 

1554 for boneName, bone in skel.items(): 

1555 bone.postDeletedHandler(skel, boneName, key) 

1556 

1557 skel.postDeletedHandler(key) 

1558 

1559 # Inform the custom DB Adapter 

1560 for adapter in skel.database_adapters: 

1561 adapter.delete(skel) 

1562 

1563 @classmethod 

1564 def patch( 

1565 cls, 

1566 skel: SkeletonInstance, 

1567 values: t.Optional[dict | t.Callable[[SkeletonInstance], None]] = {}, 

1568 *, 

1569 key: t.Optional[db.Key | int | str] = None, 

1570 check: t.Optional[dict | t.Callable[[SkeletonInstance], None]] = None, 

1571 create: t.Optional[bool | dict | t.Callable[[SkeletonInstance], None]] = None, 

1572 update_relations: bool = True, 

1573 ignore: t.Optional[t.Iterable[str]] = (), 

1574 retry: int = 0, 

1575 ) -> SkeletonInstance: 

1576 """ 

1577 Performs an edit operation on a Skeleton within a transaction. 

1578 

1579 The transaction performs a read, sets bones and afterwards does a write with exclusive access on the 

1580 given Skeleton and its underlying database entity. 

1581 

1582 All value-dicts that are being fed to this function are provided to `skel.fromClient()`. Instead of dicts, 

1583 a callable can also be given that can individually modify the Skeleton that is edited. 

1584 

1585 :param values: A dict of key-values to update on the entry, or a callable that is executed within 

1586 the transaction. 

1587 

1588 This dict allows for a special notation: Keys starting with "+" or "-" are added or substracted to the 

1589 given value, which can be used for counters. 

1590 :param key: A :class:`viur.core.db.Key`, string, or int; from which the data shall be fetched. 

1591 If not provided, skel["key"] will be used. 

1592 :param check: An optional dict of key-values or a callable to check on the Skeleton before updating. 

1593 If something fails within this check, an AssertionError is being raised. 

1594 :param create: Allows to specify a dict or initial callable that is executed in case the Skeleton with the 

1595 given key does not exist. 

1596 :param update_relations: Trigger update relations task on success. Defaults to False. 

1597 :param trust: Use internal `fromClient` with trusted data (may change readonly-bones) 

1598 :param retry: On ViurDatastoreError, retry for this amount of times. 

1599 

1600 If the function does not raise an Exception, all went well. The function always returns the input Skeleton. 

1601 

1602 Raises: 

1603 ValueError: In case parameters where given wrong or incomplete. 

1604 AssertionError: In case an asserted check parameter did not match. 

1605 ReadFromClientException: In case a skel.fromClient() failed with a high severity. 

1606 """ 

1607 

1608 # Transactional function 

1609 def __update_txn(): 

1610 # Try to read the skeleton, create on demand 

1611 if not skel.read(key): 

1612 if create is None or create is False: 

1613 raise ValueError("Creation during update is forbidden - explicitly provide `create=True` to allow.") 

1614 

1615 if not (key or skel["key"]) and create in (False, None): 

1616 return ValueError("No valid key provided") 

1617 

1618 if key or skel["key"]: 

1619 skel["key"] = db.keyHelper(key or skel["key"], skel.kindName) 

1620 

1621 if isinstance(create, dict): 

1622 if create and not skel.fromClient(create, amend=True, ignore=ignore): 

1623 raise ReadFromClientException(skel.errors) 

1624 elif callable(create): 

1625 create(skel) 

1626 elif create is not True: 

1627 raise ValueError("'create' must either be dict or a callable.") 

1628 

1629 # Handle check 

1630 if isinstance(check, dict): 

1631 for bone, value in check.items(): 

1632 if skel[bone] != value: 

1633 raise AssertionError(f"{bone} contains {skel[bone]!r}, expecting {value!r}") 

1634 

1635 elif callable(check): 

1636 check(skel) 

1637 

1638 # Set values 

1639 if isinstance(values, dict): 

1640 if values and not skel.fromClient(values, amend=True, ignore=ignore): 

1641 raise ReadFromClientException(skel.errors) 

1642 

1643 # Special-feature: "+" and "-" prefix for simple calculations 

1644 # TODO: This can maybe integrated into skel.fromClient() later... 

1645 for name, value in values.items(): 

1646 match name[0]: 

1647 case "+": # Increment by value? 

1648 skel[name[1:]] += value 

1649 case "-": # Decrement by value? 

1650 skel[name[1:]] -= value 

1651 

1652 elif callable(values): 

1653 values(skel) 

1654 

1655 else: 

1656 raise ValueError("'values' must either be dict or a callable.") 

1657 

1658 return skel.write(update_relations=update_relations) 

1659 

1660 if not db.IsInTransaction: 

1661 # Retry loop 

1662 while True: 

1663 try: 

1664 return db.RunInTransaction(__update_txn) 

1665 

1666 except db.ViurDatastoreError as e: 

1667 retry -= 1 

1668 if retry < 0: 

1669 raise 

1670 

1671 logging.debug(f"{e}, retrying {retry} more times") 

1672 

1673 time.sleep(1) 

1674 else: 

1675 return __update_txn() 

1676 

1677 @classmethod 

1678 def preProcessBlobLocks(cls, skel: SkeletonInstance, locks): 

1679 """ 

1680 Can be overridden to modify the list of blobs referenced by this skeleton 

1681 """ 

1682 return locks 

1683 

1684 @classmethod 

1685 def preProcessSerializedData(cls, skel: SkeletonInstance, entity): 

1686 """ 

1687 Can be overridden to modify the :class:`viur.core.db.Entity` before its actually 

1688 written to the data store. 

1689 """ 

1690 return entity 

1691 

1692 @classmethod 

1693 def postSavedHandler(cls, skel: SkeletonInstance, key, dbObj): 

1694 """ 

1695 Can be overridden to perform further actions after the entity has been written 

1696 to the data store. 

1697 """ 

1698 pass 

1699 

1700 @classmethod 

1701 def postDeletedHandler(cls, skel: SkeletonInstance, key): 

1702 """ 

1703 Can be overridden to perform further actions after the entity has been deleted 

1704 from the data store. 

1705 """ 

1706 pass 

1707 

1708 @classmethod 

1709 def getCurrentSEOKeys(cls, skel: SkeletonInstance) -> None | dict[str, str]: 

1710 """ 

1711 Should be overridden to return a dictionary of language -> SEO-Friendly key 

1712 this entry should be reachable under. How theses names are derived are entirely up to the application. 

1713 If the name is already in use for this module, the server will automatically append some random string 

1714 to make it unique. 

1715 :return: 

1716 """ 

1717 return 

1718 

1719 

1720class RelSkel(BaseSkeleton): 

1721 """ 

1722 This is a Skeleton-like class that acts as a container for Skeletons used as a 

1723 additional information data skeleton for 

1724 :class:`~viur.core.bones.extendedRelationalBone.extendedRelationalBone`. 

1725 

1726 It needs to be sub-classed where information about the kindName and its attributes 

1727 (bones) are specified. 

1728 

1729 The Skeleton stores its bones in an :class:`OrderedDict`-Instance, so the definition order of the 

1730 contained bones remains constant. 

1731 """ 

1732 

1733 def serialize(self, parentIndexed): 

1734 if self.dbEntity is None: 

1735 self.dbEntity = db.Entity() 

1736 for key, _bone in self.items(): 

1737 # if key in self.accessedValues: 

1738 _bone.serialize(self, key, parentIndexed) 

1739 # if "key" in self: # Write the key seperatly, as the base-bone doesn't store it 

1740 # dbObj["key"] = self["key"] 

1741 # FIXME: is this a good idea? Any other way to ensure only bones present in refKeys are serialized? 

1742 return self.dbEntity 

1743 

1744 def unserialize(self, values: db.Entity | dict): 

1745 """ 

1746 Loads 'values' into this skeleton. 

1747 

1748 :param values: dict with values we'll assign to our bones 

1749 """ 

1750 if not isinstance(values, db.Entity): 

1751 self.dbEntity = db.Entity() 

1752 

1753 if values: 

1754 self.dbEntity.update(values) 

1755 else: 

1756 self.dbEntity = values 

1757 

1758 self.accessedValues = {} 

1759 self.renderAccessedValues = {} 

1760 

1761 

1762class RefSkel(RelSkel): 

1763 @classmethod 

1764 def fromSkel(cls, kindName: str, *args: list[str]) -> t.Type[RefSkel]: 

1765 """ 

1766 Creates a ``RefSkel`` from a skeleton-class using only the bones explicitly named in ``args``. 

1767 

1768 :param args: List of bone names we'll adapt 

1769 :return: A new instance of RefSkel 

1770 """ 

1771 newClass = type("RefSkelFor" + kindName, (RefSkel,), {}) 

1772 fromSkel = skeletonByKind(kindName) 

1773 newClass.kindName = kindName 

1774 bone_map = {} 

1775 for arg in args: 

1776 bone_map |= {k: fromSkel.__boneMap__[k] for k in fnmatch.filter(fromSkel.__boneMap__.keys(), arg)} 

1777 newClass.__boneMap__ = bone_map 

1778 return newClass 

1779 

1780 def read(self, key: t.Optional[db.Key | str | int] = None) -> SkeletonInstance: 

1781 """ 

1782 Read full skeleton instance referenced by the RefSkel from the database. 

1783 

1784 Can be used for reading the full Skeleton from a RefSkel. 

1785 The `key` parameter also allows to read another, given key from the related kind. 

1786 

1787 :raise ValueError: If the entry is no longer in the database. 

1788 """ 

1789 skel = skeletonByKind(self.kindName)() 

1790 

1791 if not skel.read(key or self["key"]): 

1792 raise ValueError(f"""The key {key or self["key"]!r} seems to be gone""") 

1793 

1794 return skel 

1795 

1796 

1797class SkelList(list): 

1798 """ 

1799 This class is used to hold multiple skeletons together with other, commonly used information. 

1800 

1801 SkelLists are returned by Skel().all()...fetch()-constructs and provide additional information 

1802 about the data base query, for fetching additional entries. 

1803 

1804 :ivar cursor: Holds the cursor within a query. 

1805 :vartype cursor: str 

1806 """ 

1807 

1808 __slots__ = ( 

1809 "baseSkel", 

1810 "customQueryInfo", 

1811 "getCursor", 

1812 "get_orders", 

1813 "renderPreparation", 

1814 ) 

1815 

1816 def __init__(self, baseSkel=None): 

1817 """ 

1818 :param baseSkel: The baseclass for all entries in this list 

1819 """ 

1820 super(SkelList, self).__init__() 

1821 self.baseSkel = baseSkel or {} 

1822 self.getCursor = lambda: None 

1823 self.get_orders = lambda: None 

1824 self.renderPreparation = None 

1825 self.customQueryInfo = {} 

1826 

1827 

1828# Module functions 

1829 

1830 

1831def skeletonByKind(kindName: str) -> t.Type[Skeleton]: 

1832 """ 

1833 Returns the Skeleton-Class for the given kindName. That skeleton must exist, otherwise an exception is raised. 

1834 :param kindName: The kindname to retreive the skeleton for 

1835 :return: The skeleton-class for that kind 

1836 """ 

1837 assert kindName in MetaBaseSkel._skelCache, f"Unknown skeleton {kindName=}" 

1838 return MetaBaseSkel._skelCache[kindName] 

1839 

1840 

1841def listKnownSkeletons() -> list[str]: 

1842 """ 

1843 :return: A list of all known kindnames (all kindnames for which a skeleton is defined) 

1844 """ 

1845 return list(MetaBaseSkel._skelCache.keys())[:] 

1846 

1847 

1848def iterAllSkelClasses() -> t.Iterable[Skeleton]: 

1849 """ 

1850 :return: An iterator that yields each Skeleton-Class once. (Only top-level skeletons are returned, so no 

1851 RefSkel classes will be included) 

1852 """ 

1853 for cls in list(MetaBaseSkel._allSkelClasses): # We'll add new classes here during setSystemInitialized() 

1854 yield cls 

1855 

1856 

1857### Tasks ### 

1858 

1859@CallDeferred 

1860def processRemovedRelations(removedKey, cursor=None): 

1861 updateListQuery = ( 

1862 db.Query("viur-relations") 

1863 .filter("dest.__key__ =", removedKey) 

1864 .filter("viur_relational_consistency >", RelationalConsistency.PreventDeletion.value) 

1865 ) 

1866 updateListQuery = updateListQuery.setCursor(cursor) 

1867 updateList = updateListQuery.run(limit=5) 

1868 

1869 for entry in updateList: 

1870 skel = skeletonByKind(entry["viur_src_kind"])() 

1871 

1872 if not skel.read(entry["src"].key): 

1873 raise ValueError(f"processRemovedRelations detects inconsistency on src={entry['src'].key!r}") 

1874 

1875 if entry["viur_relational_consistency"] == RelationalConsistency.SetNull.value: 

1876 found = False 

1877 

1878 for key, bone in skel.items(): 

1879 if isinstance(bone, RelationalBone): 

1880 if relational_value := skel[key]: 

1881 if isinstance(relational_value, dict) and relational_value["dest"]["key"] == removedKey: 

1882 skel[key] = None 

1883 found = True 

1884 

1885 elif isinstance(relational_value, list): 

1886 skel[key] = [entry for entry in relational_value if entry["dest"]["key"] != removedKey] 

1887 found = True 

1888 

1889 else: 

1890 raise NotImplementedError(f"In {entry['src'].key!r}, no handling for {relational_value=}") 

1891 

1892 if found: 

1893 skel.write(update_relations=False) 

1894 

1895 else: 

1896 logging.critical(f"""Cascade deletion of {skel["key"]!r}""") 

1897 skel.delete() 

1898 

1899 if len(updateList) == 5: 

1900 processRemovedRelations(removedKey, updateListQuery.getCursor()) 

1901 

1902 

1903@CallDeferred 

1904def updateRelations(destKey: db.Key, minChangeTime: int, changedBone: t.Optional[str], cursor: t.Optional[str] = None): 

1905 """ 

1906 This function updates Entities, which may have a copy of values from another entity which has been recently 

1907 edited (updated). In ViUR, relations are implemented by copying the values from the referenced entity into the 

1908 entity that's referencing them. This allows ViUR to run queries over properties of referenced entities and 

1909 prevents additional db.Get's to these referenced entities if the main entity is read. However, this forces 

1910 us to track changes made to entities as we might have to update these mirrored values. This is the deferred 

1911 call from meth:`viur.core.skeleton.Skeleton.write()` after an update (edit) on one Entity to do exactly that. 

1912 

1913 :param destKey: The database-key of the entity that has been edited 

1914 :param minChangeTime: The timestamp on which the edit occurred. As we run deferred, and the entity might have 

1915 been edited multiple times before we get acutally called, we can ignore entities that have been updated 

1916 in the meantime as they're already up2date 

1917 :param changedBone: If set, we'll update only entites that have a copy of that bone. Relations mirror only 

1918 key and name by default, so we don't have to update these if only another bone has been changed. 

1919 :param cursor: The database cursor for the current request as we only process five entities at once and then 

1920 defer again. 

1921 """ 

1922 logging.debug(f"Starting updateRelations for {destKey=}; {minChangeTime=}, {changedBone=}, {cursor=}") 

1923 updateListQuery = ( 

1924 db.Query("viur-relations") 

1925 .filter("dest.__key__ =", destKey) 

1926 .filter("viur_delayed_update_tag <", minChangeTime) 

1927 .filter("viur_relational_updateLevel =", RelationalUpdateLevel.Always.value) 

1928 ) 

1929 if changedBone: 

1930 updateListQuery.filter("viur_foreign_keys =", changedBone) 

1931 if cursor: 

1932 updateListQuery.setCursor(cursor) 

1933 updateList = updateListQuery.run(limit=5) 

1934 

1935 def updateTxn(skel, key, srcRelKey): 

1936 if not skel.read(key): 

1937 logging.warning(f"Cannot update stale reference to {key=} (referenced from {srcRelKey=})") 

1938 return 

1939 

1940 skel.refresh() 

1941 skel.write(update_relations=False) 

1942 

1943 for srcRel in updateList: 

1944 try: 

1945 skel = skeletonByKind(srcRel["viur_src_kind"])() 

1946 except AssertionError: 

1947 logging.info(f"""Ignoring {srcRel.key!r} which refers to unknown kind {srcRel["viur_src_kind"]!r}""") 

1948 continue 

1949 if db.IsInTransaction(): 

1950 updateTxn(skel, srcRel["src"].key, srcRel.key) 

1951 else: 

1952 db.RunInTransaction(updateTxn, skel, srcRel["src"].key, srcRel.key) 

1953 nextCursor = updateListQuery.getCursor() 

1954 if len(updateList) == 5 and nextCursor: 

1955 updateRelations(destKey, minChangeTime, changedBone, nextCursor) 

1956 

1957 

1958@CallableTask 

1959class TaskUpdateSearchIndex(CallableTaskBase): 

1960 """ 

1961 This tasks loads and saves *every* entity of the given module. 

1962 This ensures an updated searchIndex and verifies consistency of this data. 

1963 """ 

1964 key = "rebuildSearchIndex" 

1965 name = "Rebuild search index" 

1966 descr = "This task can be called to update search indexes and relational information." 

1967 

1968 def canCall(self) -> bool: 

1969 """Checks wherever the current user can execute this task""" 

1970 user = current.user.get() 

1971 return user is not None and "root" in user["access"] 

1972 

1973 def dataSkel(self): 

1974 modules = ["*"] + listKnownSkeletons() 

1975 modules.sort() 

1976 skel = BaseSkeleton().clone() 

1977 skel.module = SelectBone(descr="Module", values={x: translate(x) for x in modules}, required=True) 

1978 return skel 

1979 

1980 def execute(self, module, *args, **kwargs): 

1981 usr = current.user.get() 

1982 if not usr: 

1983 logging.warning("Don't know who to inform after rebuilding finished") 

1984 notify = None 

1985 else: 

1986 notify = usr["name"] 

1987 

1988 if module == "*": 

1989 for module in listKnownSkeletons(): 

1990 logging.info("Rebuilding search index for module %r", module) 

1991 self._run(module, notify) 

1992 else: 

1993 self._run(module, notify) 

1994 

1995 @staticmethod 

1996 def _run(module: str, notify: str): 

1997 Skel = skeletonByKind(module) 

1998 if not Skel: 

1999 logging.error("TaskUpdateSearchIndex: Invalid module") 

2000 return 

2001 RebuildSearchIndex.startIterOnQuery(Skel().all(), {"notify": notify, "module": module}) 

2002 

2003 

2004class RebuildSearchIndex(QueryIter): 

2005 @classmethod 

2006 def handleEntry(cls, skel: SkeletonInstance, customData: dict[str, str]): 

2007 skel.refresh() 

2008 skel.write(update_relations=False) 

2009 

2010 @classmethod 

2011 def handleFinish(cls, totalCount: int, customData: dict[str, str]): 

2012 QueryIter.handleFinish(totalCount, customData) 

2013 if not customData["notify"]: 

2014 return 

2015 txt = ( 

2016 f"{conf.instance.project_id}: Rebuild search index finished for {customData['module']}\n\n" 

2017 f"ViUR finished to rebuild the search index for module {customData['module']}.\n" 

2018 f"{totalCount} records updated in total on this kind." 

2019 ) 

2020 try: 

2021 email.send_email(dests=customData["notify"], stringTemplate=txt, skel=None) 

2022 except Exception as exc: # noqa; OverQuota, whatever 

2023 logging.exception(f'Failed to notify {customData["notify"]}') 

2024 

2025 

2026### Vacuum Relations 

2027 

2028@CallableTask 

2029class TaskVacuumRelations(TaskUpdateSearchIndex): 

2030 """ 

2031 Checks entries in viur-relations and verifies that the src-kind 

2032 and it's RelationalBone still exists. 

2033 """ 

2034 key = "vacuumRelations" 

2035 name = "Vacuum viur-relations (dangerous)" 

2036 descr = "Drop stale inbound relations for the given kind" 

2037 

2038 def execute(self, module: str, *args, **kwargs): 

2039 usr = current.user.get() 

2040 if not usr: 

2041 logging.warning("Don't know who to inform after rebuilding finished") 

2042 notify = None 

2043 else: 

2044 notify = usr["name"] 

2045 processVacuumRelationsChunk(module.strip(), None, notify=notify) 

2046 

2047 

2048@CallDeferred 

2049def processVacuumRelationsChunk( 

2050 module: str, cursor, count_total: int = 0, count_removed: int = 0, notify=None 

2051): 

2052 """ 

2053 Processes 25 Entries and calls the next batch 

2054 """ 

2055 query = db.Query("viur-relations") 

2056 if module != "*": 

2057 query.filter("viur_src_kind =", module) 

2058 query.setCursor(cursor) 

2059 for relation_object in query.run(25): 

2060 count_total += 1 

2061 if not (src_kind := relation_object.get("viur_src_kind")): 

2062 logging.critical("We got an relation-object without a src_kind!") 

2063 continue 

2064 if not (src_prop := relation_object.get("viur_src_property")): 

2065 logging.critical("We got an relation-object without a src_prop!") 

2066 continue 

2067 try: 

2068 skel = skeletonByKind(src_kind)() 

2069 except AssertionError: 

2070 # The referenced skeleton does not exist in this data model -> drop that relation object 

2071 logging.info(f"Deleting {relation_object.key} which refers to unknown kind {src_kind}") 

2072 db.Delete(relation_object) 

2073 count_removed += 1 

2074 continue 

2075 if src_prop not in skel: 

2076 logging.info(f"Deleting {relation_object.key} which refers to " 

2077 f"non-existing RelationalBone {src_prop} of {src_kind}") 

2078 db.Delete(relation_object) 

2079 count_removed += 1 

2080 logging.info(f"END processVacuumRelationsChunk {module}, " 

2081 f"{count_total} records processed, {count_removed} removed") 

2082 if new_cursor := query.getCursor(): 

2083 # Start processing of the next chunk 

2084 processVacuumRelationsChunk(module, new_cursor, count_total, count_removed, notify) 

2085 elif notify: 

2086 txt = ( 

2087 f"{conf.instance.project_id}: Vacuum relations finished for {module}\n\n" 

2088 f"ViUR finished to vacuum viur-relations for module {module}.\n" 

2089 f"{count_total} records processed, " 

2090 f"{count_removed} entries removed" 

2091 ) 

2092 try: 

2093 email.send_email(dests=notify, stringTemplate=txt, skel=None) 

2094 except Exception as exc: # noqa; OverQuota, whatever 

2095 logging.exception(f"Failed to notify {notify}") 

2096 

2097 

2098# Forward our references to SkelInstance to the database (needed for queries) 

2099db.config["SkeletonInstanceRef"] = SkeletonInstance 

2100 

2101# DEPRECATED ATTRIBUTES HANDLING 

2102 

2103__DEPRECATED_NAMES = { 

2104 # stuff prior viur-core < 3.6 

2105 "seoKeyBone": ("SeoKeyBone", SeoKeyBone), 

2106} 

2107 

2108 

2109def __getattr__(attr: str) -> object: 

2110 if entry := __DEPRECATED_NAMES.get(attr): 

2111 func = entry[1] 

2112 msg = f"{attr} was replaced by {entry[0]}" 

2113 warnings.warn(msg, DeprecationWarning, stacklevel=2) 

2114 logging.warning(msg, stacklevel=2) 

2115 return func 

2116 

2117 return super(__import__(__name__).__class__).__getattribute__(attr)