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

975 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-26 11:31 +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 functools import partial 

14from itertools import chain 

15 

16from deprecated.sphinx import deprecated 

17 

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

19from viur.core.bones import ( 

20 BaseBone, 

21 DateBone, 

22 KeyBone, 

23 ReadFromClientException, 

24 RelationalBone, 

25 RelationalConsistency, 

26 RelationalUpdateLevel, 

27 SelectBone, 

28 StringBone, 

29) 

30from viur.core.bones.base import ( 

31 Compute, 

32 ComputeInterval, 

33 ComputeMethod, 

34 ReadFromClientError, 

35 ReadFromClientErrorSeverity, 

36 getSystemInitialized, 

37) 

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

39 

40_UNDEFINED = object() 

41ABSTRACT_SKEL_CLS_SUFFIX = "AbstractSkel" 

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

43 

44 

45class MetaBaseSkel(type): 

46 """ 

47 This is the metaclass for Skeletons. 

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

49 """ 

50 _skelCache = {} # Mapping kindName -> SkelCls 

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

52 

53 # List of reserved keywords and function names 

54 __reserved_keywords = { 

55 "all", 

56 "bounce", 

57 "clone", 

58 "cursor", 

59 "delete", 

60 "errors", 

61 "fromClient", 

62 "fromDB", 

63 "get", 

64 "getCurrentSEOKeys", 

65 "items", 

66 "keys", 

67 "limit", 

68 "orderby", 

69 "orderdir", 

70 "patch", 

71 "postDeletedHandler", 

72 "postSavedHandler", 

73 "preProcessBlobLocks", 

74 "preProcessSerializedData", 

75 "read", 

76 "refresh", 

77 "self", 

78 "serialize", 

79 "setBoneValue", 

80 "structure", 

81 "style", 

82 "toDB", 

83 "unserialize", 

84 "values", 

85 "write", 

86 } 

87 

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

89 

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

91 cls.__boneMap__ = MetaBaseSkel.generate_bonemap(cls) 

92 

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

94 MetaBaseSkel._allSkelClasses.add(cls) 

95 

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

97 

98 @staticmethod 

99 def generate_bonemap(cls): 

100 """ 

101 Recursively constructs a dict of bones from 

102 """ 

103 map = {} 

104 

105 for base in cls.__bases__: 

106 if "__viurBaseSkeletonMarker__" in dir(base): 

107 map |= MetaBaseSkel.generate_bonemap(base) 

108 

109 for key in cls.__dict__: 

110 prop = getattr(cls, key) 

111 

112 if isinstance(prop, BaseBone): 

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

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

115 elif key in MetaBaseSkel.__reserved_keywords: 

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

117 

118 map[key] = prop 

119 

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

121 del map[key] 

122 

123 return map 

124 

125 def __setattr__(self, key, value): 

126 super().__setattr__(key, value) 

127 if isinstance(value, BaseBone): 

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

129 value.__set_name__(self, key) 

130 

131 

132class SkeletonInstance: 

133 """ 

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

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

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

137 """ 

138 __slots__ = { 

139 "accessedValues", 

140 "boneMap", 

141 "dbEntity", 

142 "errors", 

143 "is_cloned", 

144 "renderAccessedValues", 

145 "renderPreparation", 

146 "skeletonCls", 

147 } 

148 

149 def __init__( 

150 self, 

151 skel_cls: t.Type[Skeleton], 

152 *, 

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

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

155 clone: bool = False, 

156 # FIXME: BELOW IS DEPRECATED! 

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

158 ): 

159 """ 

160 Creates a new SkeletonInstance based on `skel_cls`. 

161 

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

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

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

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

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

167 """ 

168 

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

170 if clonedBoneMap: 

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

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

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

174 

175 if bone_map: 

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

177 

178 bone_map = clonedBoneMap 

179 

180 bone_map = bone_map or {} 

181 

182 if bones: 

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

184 

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

186 keys = [] 

187 for name in names: 

188 if name in skel_cls.__boneMap__: 

189 keys.append(name) 

190 else: 

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

192 

193 if clone: 

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

195 else: 

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

197 

198 elif clone: 

199 if bone_map: 

200 bone_map = copy.deepcopy(bone_map) 

201 else: 

202 bone_map = copy.deepcopy(skel_cls.__boneMap__) 

203 

204 # generated or use provided bone_map 

205 if bone_map: 

206 self.boneMap = bone_map 

207 

208 else: # No Subskel, no Clone 

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

210 

211 if clone: 

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

213 v.isClonedInstance = True 

214 

215 self.accessedValues = {} 

216 self.dbEntity = None 

217 self.errors = [] 

218 self.is_cloned = clone 

219 self.renderAccessedValues = {} 

220 self.renderPreparation = None 

221 self.skeletonCls = skel_cls 

222 

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

224 if yieldBoneValues: 

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

226 yield key, self[key] 

227 else: 

228 yield from self.boneMap.items() 

229 

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

231 yield from self.boneMap.keys() 

232 

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

234 yield from self.boneMap.values() 

235 

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

237 yield from self.keys() 

238 

239 def __contains__(self, item): 

240 return item in self.boneMap 

241 

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

243 if item not in self: 

244 return default 

245 

246 return self[item] 

247 

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

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

250 

251 def __setitem__(self, key, value): 

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

253 if isinstance(value, BaseBone): 

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

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

256 self.accessedValues[key] = value 

257 

258 def __getitem__(self, key): 

259 if self.renderPreparation: 

260 if key in self.renderAccessedValues: 

261 return self.renderAccessedValues[key] 

262 if key not in self.accessedValues: 

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

264 if boneInstance: 

265 if self.dbEntity is not None: 

266 boneInstance.unserialize(self, key) 

267 else: 

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

269 if not self.renderPreparation: 

270 return self.accessedValues.get(key) 

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

272 self.renderAccessedValues[key] = value 

273 return value 

274 

275 def __getattr__(self, item: str): 

276 """ 

277 Get a special attribute from the SkeletonInstance 

278 

279 __getattr__ is called when an attribute access fails with an 

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

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

282 attributes are loaded from the skeleton class. 

283 """ 

284 if item == "boneMap": 

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

286 

287 # Load attribute value from the Skeleton class 

288 elif item in { 

289 "database_adapters", 

290 "interBoneValidations", 

291 "kindName", 

292 }: 

293 return getattr(self.skeletonCls, item) 

294 

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

296 elif item == "customDatabaseAdapter": 

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

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

299 

300 return None 

301 

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

303 elif item in { 

304 "all", 

305 "delete", 

306 "patch", 

307 "fromClient", 

308 "fromDB", 

309 "getCurrentSEOKeys", 

310 "postDeletedHandler", 

311 "postSavedHandler", 

312 "preProcessBlobLocks", 

313 "preProcessSerializedData", 

314 "read", 

315 "refresh", 

316 "serialize", 

317 "setBoneValue", 

318 "toDB", 

319 "unserialize", 

320 "write", 

321 }: 

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

323 

324 # Load a @property from the Skeleton class 

325 try: 

326 # Use try/except to save an if check 

327 class_value = getattr(self.skeletonCls, item) 

328 

329 except AttributeError: 

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

331 pass 

332 

333 else: 

334 if isinstance(class_value, property): 

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

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

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

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

339 try: 

340 return class_value.fget(self) 

341 except AttributeError as exc: 

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

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

344 # to the property attribute. 

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

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

347 msg, *args = exc.args 

348 msg = f"AttributeError: {msg}" 

349 raise ValueError(msg, *args) from exc 

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

351 try: 

352 return self.boneMap[item] 

353 except KeyError as exc: 

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

355 

356 def __delattr__(self, item): 

357 del self.boneMap[item] 

358 if item in self.accessedValues: 

359 del self.accessedValues[item] 

360 if item in self.renderAccessedValues: 

361 del self.renderAccessedValues[item] 

362 

363 def __setattr__(self, key, value): 

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

365 if value is None: 

366 del self.boneMap[key] 

367 else: 

368 value.__set_name__(self.skeletonCls, key) 

369 self.boneMap[key] = value 

370 elif key == "renderPreparation": 

371 super().__setattr__(key, value) 

372 self.renderAccessedValues.clear() 

373 else: 

374 super().__setattr__(key, value) 

375 

376 def __repr__(self) -> str: 

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

378 

379 def __str__(self) -> str: 

380 return str(dict(self)) 

381 

382 def __len__(self) -> int: 

383 return len(self.boneMap) 

384 

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

386 if isinstance(other, dict): 

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

388 self.setBoneValue(key, value) 

389 elif isinstance(other, db.Entity): 

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

391 # We're not overriding the key 

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

393 new_entity[key] = value 

394 self.setEntity(new_entity) 

395 elif isinstance(other, SkeletonInstance): 

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

397 self.accessedValues[key] = value 

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

399 self.dbEntity[key] = value 

400 else: 

401 raise ValueError("Unsupported Type") 

402 return self 

403 

404 def clone(self, *, apply_clone_strategy: bool = False) -> t.Self: 

405 """ 

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

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

408 """ 

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

410 if apply_clone_strategy: 

411 for bone_name, bone_instance in self.items(): 

412 bone_instance.clone_value(res, self, bone_name) 

413 else: 

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

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

416 res.is_cloned = True 

417 if not apply_clone_strategy: 

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

419 # else: Depending on the strategy the values are cloned in bone_instance.clone_value too 

420 

421 return res 

422 

423 def ensure_is_cloned(self): 

424 """ 

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

426 Does nothing in case it was already cloned before. 

427 """ 

428 if not self.is_cloned: 

429 return self.clone() 

430 

431 return self 

432 

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

434 self.dbEntity = entity 

435 self.accessedValues = {} 

436 self.renderAccessedValues = {} 

437 

438 def structure(self) -> dict: 

439 return { 

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

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

442 } 

443 

444 def __deepcopy__(self, memodict): 

445 res = self.clone() 

446 memodict[id(self)] = res 

447 return res 

448 

449 

450class BaseSkeleton(object, metaclass=MetaBaseSkel): 

451 """ 

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

453 

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

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

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

457 contained bones remains constant. 

458 

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

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

461 

462 :vartype key: server.bones.BaseBone 

463 

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

465 :vartype creationdate: server.bones.DateBone 

466 

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

468 :vartype changedate: server.bones.DateBone 

469 """ 

470 __viurBaseSkeletonMarker__ = True 

471 boneMap = None 

472 

473 @classmethod 

474 @deprecated( 

475 version="3.7.0", 

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

477 ) 

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

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

480 

481 @classmethod 

482 def subskel( 

483 cls, 

484 *names: str, 

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

486 clone: bool = False, 

487 ) -> SkeletonInstance: 

488 """ 

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

490 

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

492 

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

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

495 

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

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

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

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

500 generated sub-skeleton. 

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

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

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

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

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

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

507 free bone list. 

508 

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

510 ```py 

511 # legacy mode (see 1) 

512 subskel = TodoSkel.subskel("add") 

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

514 

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

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

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

518 

519 # mixed mode (see 3) 

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

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

522 ``` 

523 

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

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

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

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

528 

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

530 """ 

531 from_subskel = False 

532 bones = list(bones) 

533 

534 for name in names: 

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

536 if isinstance(name, str): 

537 # add bones from "*" subskel once 

538 if not from_subskel: 

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

540 from_subskel = True 

541 

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

543 

544 else: 

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

546 

547 if from_subskel: 

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

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

550 

551 if not bones: 

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

553 

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

555 

556 @classmethod 

557 def setSystemInitialized(cls): 

558 for attrName in dir(cls): 

559 bone = getattr(cls, attrName) 

560 if isinstance(bone, BaseBone): 

561 bone.setSystemInitialized() 

562 

563 @classmethod 

564 def setBoneValue( 

565 cls, 

566 skel: SkeletonInstance, 

567 boneName: str, 

568 value: t.Any, 

569 append: bool = False, 

570 language: t.Optional[str] = None 

571 ) -> bool: 

572 """ 

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

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

575 (default) value and false is returned. 

576 

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

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

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

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

581 :param language: Language to set 

582 

583 :return: Wherever that operation succeeded or not. 

584 """ 

585 bone = getattr(skel, boneName, None) 

586 

587 if not isinstance(bone, BaseBone): 

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

589 

590 if language: 

591 if not bone.languages: 

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

593 elif language not in bone.languages: 

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

595 

596 if value is None: 

597 if append: 

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

599 

600 if language: 

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

602 else: 

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

604 

605 return True 

606 

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

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

609 

610 @classmethod 

611 def fromClient( 

612 cls, 

613 skel: SkeletonInstance, 

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

615 *, 

616 amend: bool = False, 

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

618 ) -> bool: 

619 """ 

620 Load supplied *data* into Skeleton. 

621 

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

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

624 

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

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

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

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

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

630 

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

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

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

634 which is useful for edit-actions. 

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

636 

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

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

639 """ 

640 complete = True 

641 skel.errors = [] 

642 

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

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

645 continue 

646 

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

648 for error in errors: 

649 # insert current bone name into error's fieldPath 

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

651 

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

653 

654 incomplete = ( 

655 # always when something is invalid 

656 error.severity == ReadFromClientErrorSeverity.Invalid 

657 or ( 

658 # only when path is top-level 

659 len(error.fieldPath) == 1 

660 and ( 

661 # bone is generally required 

662 bool(bone.required) 

663 and ( 

664 # and value is either empty 

665 error.severity == ReadFromClientErrorSeverity.Empty 

666 # or when not amending, not set 

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

668 ) 

669 ) 

670 ) 

671 ) 

672 

673 # in case there are language requirements, test additionally 

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

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

676 

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

678 

679 if incomplete: 

680 complete = False 

681 

682 if conf.debug.skeleton_from_client: 

683 logging.error( 

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

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

686 ) 

687 

688 skel.errors += errors 

689 

690 return complete 

691 

692 @classmethod 

693 def refresh(cls, skel: SkeletonInstance): 

694 """ 

695 Refresh the bones current content. 

696 

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

698 information. 

699 """ 

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

701 

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

703 if not isinstance(bone, BaseBone): 

704 continue 

705 

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

707 bone.refresh(skel, key) 

708 

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

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

711 

712 

713class MetaSkel(MetaBaseSkel): 

714 

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

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

717 

718 relNewFileName = inspect.getfile(cls) \ 

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

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

721 

722 # Check if we have an abstract skeleton 

723 if cls.__name__.endswith(ABSTRACT_SKEL_CLS_SUFFIX): 

724 # Ensure that it doesn't have a kindName 

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

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

727 return 

728 

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

730 if (cls.kindName is _UNDEFINED 

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

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

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

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

735 else: 

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

737 

738 # Try to determine which skeleton definition takes precedence 

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

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

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

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

743 idxOld = min( 

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

745 idxNew = min( 

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

747 if idxNew == 999: 

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

749 raise NotImplementedError( 

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

751 elif idxOld < idxNew: # Lower index takes precedence 

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

753 return 

754 elif idxOld > idxNew: 

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

756 MetaBaseSkel._skelCache[cls.kindName] = cls 

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

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

759 

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

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

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

763 raise NotImplementedError( 

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

765 

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

767 MetaBaseSkel._skelCache[cls.kindName] = cls 

768 

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

770 if cls.database_adapters is _UNDEFINED: 

771 cls.database_adapters = ViurTagsSearchAdapter() 

772 

773 # Always ensure that skel.database_adapters is an iterable 

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

775 

776 

777class DatabaseAdapter: 

778 """ 

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

780 """ 

781 

782 providesFulltextSearch: bool = False 

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

784 

785 fulltextSearchGuaranteesQueryConstrains = False 

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

787 

788 providesCustomQueries: bool = False 

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

790 

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

792 """ 

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

794 

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

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

797 

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

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

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

801 """ 

802 pass 

803 

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

805 """ 

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

807 

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

809 

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

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

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

813 """ 

814 pass 

815 

816 def delete(self, skel: SkeletonInstance): 

817 """ 

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

819 """ 

820 pass 

821 

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

823 """ 

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

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

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

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

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

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

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

831 :return: 

832 """ 

833 raise NotImplementedError 

834 

835 

836class ViurTagsSearchAdapter(DatabaseAdapter): 

837 """ 

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

839 

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

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

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

843 

844 Example: 

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

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

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

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

849 

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

851 """ 

852 providesFulltextSearch = True 

853 fulltextSearchGuaranteesQueryConstrains = True 

854 

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

856 super().__init__() 

857 self.min_length = min_length 

858 self.max_length = max_length 

859 self.substring_matching = substring_matching 

860 

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

862 """ 

863 Extract all words including all min_length postfixes from given string 

864 """ 

865 res = set() 

866 

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

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

869 

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

871 res.add(tag) 

872 

873 if self.substring_matching: 

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

875 res.add(tag[i:]) 

876 

877 return res 

878 

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

880 """ 

881 Collect searchTags from skeleton and build viurTags 

882 """ 

883 tags = set() 

884 

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

886 if bone.searchable: 

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

888 

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

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

891 ) 

892 

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

894 """ 

895 Run a fulltext search 

896 """ 

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

898 resultScoreMap = {} 

899 resultEntryMap = {} 

900 

901 for keyword in keywords: 

902 qryBase = databaseQuery.clone() 

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

904 if not entry.key in resultScoreMap: 

905 resultScoreMap[entry.key] = 1 

906 else: 

907 resultScoreMap[entry.key] += 1 

908 if not entry.key in resultEntryMap: 

909 resultEntryMap[entry.key] = entry 

910 

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

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

913 

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

915 

916 

917class SeoKeyBone(StringBone): 

918 """ 

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

920 """ 

921 

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

923 try: 

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

925 except KeyError: 

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

927 

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

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

930 if name in skel.accessedValues: 

931 newVal = skel.accessedValues[name] 

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

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

934 res = db.Entity() 

935 res["_viurLanguageWrapper_"] = True 

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

937 if not self.indexed: 

938 res.exclude_from_indexes.add(language) 

939 res[language] = None 

940 if language in newVal: 

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

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

943 return True 

944 

945 

946class Skeleton(BaseSkeleton, metaclass=MetaSkel): 

947 kindName: str = _UNDEFINED 

948 """ 

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

950 Will be determined automatically when not explicitly set. 

951 """ 

952 

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

954 """ 

955 Custom database adapters. 

956 Allows to hook special functionalities that during skeleton modifications. 

957 """ 

958 

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

960 

961 interBoneValidations: list[ 

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

963 

964 __seo_key_trans = str.maketrans( 

965 {"<": "", 

966 ">": "", 

967 "\"": "", 

968 "'": "", 

969 "\n": "", 

970 "\0": "", 

971 "/": "", 

972 "\\": "", 

973 "?": "", 

974 "&": "", 

975 "#": "" 

976 }) 

977 

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

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

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

981 key = KeyBone( 

982 descr="Key" 

983 ) 

984 

985 name = StringBone( 

986 descr="Name", 

987 visible=False, 

988 compute=Compute( 

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

990 interval=ComputeInterval(ComputeMethod.OnWrite) 

991 ) 

992 ) 

993 

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

995 creationdate = DateBone( 

996 descr="created at", 

997 readOnly=True, 

998 visible=False, 

999 indexed=True, 

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

1001 ) 

1002 

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

1004 

1005 changedate = DateBone( 

1006 descr="updated at", 

1007 readOnly=True, 

1008 visible=False, 

1009 indexed=True, 

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

1011 ) 

1012 

1013 viurCurrentSeoKeys = SeoKeyBone( 

1014 descr="SEO-Keys", 

1015 readOnly=True, 

1016 visible=False, 

1017 languages=conf.i18n.available_languages 

1018 ) 

1019 

1020 def __repr__(self): 

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

1022 

1023 def __str__(self): 

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

1025 

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

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

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

1029 

1030 @classmethod 

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

1032 """ 

1033 Create a query with the current Skeletons kindName. 

1034 

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

1036 """ 

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

1038 

1039 @classmethod 

1040 def fromClient( 

1041 cls, 

1042 skel: SkeletonInstance, 

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

1044 *, 

1045 amend: bool = False, 

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

1047 ) -> bool: 

1048 """ 

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

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

1051 

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

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

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

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

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

1057 

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

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

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

1061 which is useful for edit-actions. 

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

1063 

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

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

1066 """ 

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

1068 

1069 # Load data into this skeleton 

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

1071 

1072 if ( 

1073 not data # in case data is empty 

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

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

1076 ): 

1077 skel.errors = [] 

1078 

1079 # Check if all unique values are available 

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

1081 if boneInstance.unique: 

1082 lockValues = boneInstance.getUniquePropertyIndexValues(skel, boneName) 

1083 for lockValue in lockValues: 

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

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

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

1087 complete = False 

1088 errorMsg = boneInstance.unique.message 

1089 skel.errors.append( 

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

1091 

1092 # Check inter-Bone dependencies 

1093 for checkFunc in skel.interBoneValidations: 

1094 errors = checkFunc(skel) 

1095 if errors: 

1096 for error in errors: 

1097 if error.severity.value > 1: 

1098 complete = False 

1099 if conf.debug.skeleton_from_client: 

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

1101 

1102 skel.errors.extend(errors) 

1103 

1104 return complete 

1105 

1106 @classmethod 

1107 @deprecated( 

1108 version="3.7.0", 

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

1110 ) 

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

1112 """ 

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

1114 """ 

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

1116 

1117 @classmethod 

1118 def read( 

1119 cls, 

1120 skel: SkeletonInstance, 

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

1122 *, 

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

1124 _check_legacy: bool = True 

1125 ) -> t.Optional[SkeletonInstance]: 

1126 """ 

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

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

1129 

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

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

1132 data of the bones will discard. 

1133 

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

1135 

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

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

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

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

1140 

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

1142 

1143 """ 

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

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

1146 with warnings.catch_warnings(): 

1147 warnings.simplefilter("ignore", DeprecationWarning) 

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

1149 

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

1151 

1152 try: 

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

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

1155 return None 

1156 

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

1158 skel.setEntity(db_res) 

1159 return skel 

1160 elif create in (False, None): 

1161 return None 

1162 elif isinstance(create, dict): 

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

1164 raise ReadFromClientException(skel.errors) 

1165 elif callable(create): 

1166 create(skel) 

1167 elif create is not True: 

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

1169 

1170 return skel.write() 

1171 

1172 @classmethod 

1173 @deprecated( 

1174 version="3.7.0", 

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

1176 ) 

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

1178 """ 

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

1180 """ 

1181 

1182 # TODO: Remove with ViUR4 

1183 if "clearUpdateTag" in kwargs: 

1184 msg = "clearUpdateTag was replaced by update_relations" 

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

1186 logging.warning(msg, stacklevel=3) 

1187 update_relations = not kwargs["clearUpdateTag"] 

1188 

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

1190 return skel["key"] 

1191 

1192 @classmethod 

1193 def write( 

1194 cls, 

1195 skel: SkeletonInstance, 

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

1197 *, 

1198 update_relations: bool = True, 

1199 _check_legacy: bool = True, 

1200 ) -> SkeletonInstance: 

1201 """ 

1202 Write current Skeleton to the datastore. 

1203 

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

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

1206 Otherwise a new entity will be created. 

1207 

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

1209 

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

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

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

1213 

1214 :returns: The Skeleton. 

1215 """ 

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

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

1218 with warnings.catch_warnings(): 

1219 warnings.simplefilter("ignore", DeprecationWarning) 

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

1221 

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

1223 

1224 def __txn_write(write_skel): 

1225 db_key = write_skel["key"] 

1226 skel = write_skel.skeletonCls() 

1227 

1228 blob_list = set() 

1229 change_list = [] 

1230 old_copy = {} 

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

1232 if not db_key: 

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

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

1235 skel.dbEntity = db.Entity(db_key) 

1236 is_add = True 

1237 else: 

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

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

1240 skel.dbEntity = db_obj 

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

1242 is_add = False 

1243 else: 

1244 skel.dbEntity = db.Entity(db_key) 

1245 is_add = True 

1246 

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

1248 

1249 # Merge values and assemble unique properties 

1250 # Move accessed Values from srcSkel over to skel 

1251 skel.accessedValues = write_skel.accessedValues 

1252 

1253 write_skel["key"] = skel["key"] = db_key # Ensure key stays set 

1254 write_skel.dbEntity = skel.dbEntity # update write_skel's dbEntity 

1255 

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

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

1258 continue 

1259 

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

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

1262 

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

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

1265 

1266 if ( 

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

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

1269 ): 

1270 # Serialize bone into entity 

1271 try: 

1272 bone.serialize(skel, bone_name, True) 

1273 except Exception as e: 

1274 logging.error( 

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

1276 ) 

1277 raise e 

1278 

1279 # Obtain referenced blobs 

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

1281 

1282 # Check if the value has actually changed 

1283 # Ensure that only bones within the current subskel are processed. 

1284 if bone_name in write_skel and skel.dbEntity.get(bone_name) != old_copy.get(bone_name): 

1285 change_list.append(bone_name) 

1286 

1287 # Lock hashes from bones that must have unique values 

1288 if bone.unique: 

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

1290 old_unique_values = [] 

1291 

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

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

1294 # Check if the property is unique 

1295 new_unique_values = bone.getUniquePropertyIndexValues(skel, bone_name) 

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

1297 for new_lock_value in new_unique_values: 

1298 new_lock_key = db.Key(new_lock_kind, new_lock_value) 

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

1300 

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

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

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

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

1305 raise ValueError( 

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

1307 f"has been recently claimed (by {new_lock_key=}).") 

1308 else: 

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

1310 lock_obj = db.Entity(new_lock_key) 

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

1312 db.Put(lock_obj) 

1313 if new_lock_value in old_unique_values: 

1314 old_unique_values.remove(new_lock_value) 

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

1316 

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

1318 for old_unique_value in old_unique_values: 

1319 # Try to delete the old lock 

1320 

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

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

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

1324 

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

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

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

1328 else: 

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

1330 db.Delete(old_lock_key) 

1331 else: 

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

1333 

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

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

1336 

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

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

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

1340 # Filter garbage serialized into this field by the SeoKeyBone 

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

1342 

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

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

1345 

1346 if current_seo_keys := skel.getCurrentSEOKeys(): 

1347 # Convert to lower-case and remove certain characters 

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

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

1350 

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

1352 if current_seo_keys and language in current_seo_keys: 

1353 current_seo_key = current_seo_keys[language] 

1354 

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

1356 new_seo_key = current_seo_keys[language] 

1357 

1358 for _ in range(0, 3): 

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

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

1361 

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

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

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

1365 

1366 else: 

1367 # We found a new SeoKey 

1368 break 

1369 else: 

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

1371 else: 

1372 new_seo_key = current_seo_key 

1373 last_set_seo_keys[language] = new_seo_key 

1374 

1375 else: 

1376 # We'll use the database-key instead 

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

1378 

1379 # Store the current, active key for that language 

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

1381 

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

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

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

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

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

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

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

1389 # Ensure that key is also in there 

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

1391 # Trim to the last 200 used entries 

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

1393 # Store lastRequestedKeys so further updates can run more efficient 

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

1395 

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

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

1398 

1399 skel.dbEntity = skel.preProcessSerializedData(skel.dbEntity) 

1400 

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

1402 for adapter in skel.database_adapters: 

1403 adapter.prewrite(skel, is_add, change_list) 

1404 

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

1406 def fixDotNames(entity): 

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

1408 if isinstance(v, dict): 

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

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

1411 del entity[k2] 

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

1413 entity[backupKey] = v2 

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

1415 fixDotNames(v) 

1416 elif isinstance(v, list): 

1417 for x in v: 

1418 if isinstance(x, dict): 

1419 fixDotNames(x) 

1420 

1421 # FIXME: REMOVE IN VIUR4 

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

1423 fixDotNames(skel.dbEntity) 

1424 

1425 # Write the core entry back 

1426 db.Put(skel.dbEntity) 

1427 

1428 # Now write the blob-lock object 

1429 blob_list = skel.preProcessBlobLocks(blob_list) 

1430 if blob_list is None: 

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

1432 if None in blob_list: 

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

1434 logging.error(msg) 

1435 raise ValueError(msg) 

1436 

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

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

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

1440 if old_blob_lock_obj["old_blob_references"] is None: 

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

1442 else: 

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

1444 old_blob_refs.update(removed_blobs) # Add removed blobs 

1445 old_blob_refs -= blob_list # Remove active blobs 

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

1447 

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

1449 old_blob_lock_obj["is_stale"] = False 

1450 db.Put(old_blob_lock_obj) 

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

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

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

1454 blob_lock_obj["old_blob_references"] = [] 

1455 blob_lock_obj["has_old_blob_references"] = False 

1456 blob_lock_obj["is_stale"] = False 

1457 db.Put(blob_lock_obj) 

1458 

1459 return skel.dbEntity.key, write_skel, change_list, is_add 

1460 

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

1462 if key: 

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

1464 

1465 # Run transactional function 

1466 if db.IsInTransaction(): 

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

1468 else: 

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

1470 

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

1472 bone.postSavedHandler(skel, bone_name, key) 

1473 

1474 skel.postSavedHandler(key, skel.dbEntity) 

1475 

1476 if update_relations and not is_add: 

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

1478 for idx, changed_bone in enumerate(change_list): 

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

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

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

1482 

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

1484 for adapter in skel.database_adapters: 

1485 adapter.write(skel, is_add, change_list) 

1486 

1487 return skel 

1488 

1489 @classmethod 

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

1491 """ 

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

1493 

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

1495 """ 

1496 

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

1498 if not skel.read(key): 

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

1500 

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

1502 locked_relation = ( 

1503 db.Query("viur-relations") 

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

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

1506 ).getEntry() 

1507 

1508 if locked_relation is not None: 

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

1510 

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

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

1513 

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

1515 bone.delete(skel, boneName) 

1516 if bone.unique: 

1517 flushList = [] 

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

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

1520 lockObj = db.Get(lockKey) 

1521 if not lockObj: 

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

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

1524 logging.error( 

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

1526 else: 

1527 flushList.append(lockObj) 

1528 if flushList: 

1529 db.Delete(flushList) 

1530 

1531 # Delete the blob-key lock object 

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

1533 lockObj = db.Get(lockObjectKey) 

1534 

1535 if lockObj is not None: 

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

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

1538 else: 

1539 if lockObj["old_blob_references"] is None: 

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

1541 lockObj["old_blob_references"] = lockObj["active_blob_references"] 

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

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

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

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

1546 lockObj["is_stale"] = True 

1547 lockObj["has_old_blob_references"] = True 

1548 db.Put(lockObj) 

1549 

1550 db.Delete(key) 

1551 processRemovedRelations(key) 

1552 

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

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

1555 else: 

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

1557 

1558 # Full skeleton is required to have all bones! 

1559 skel = skeletonByKind(skel.kindName)() 

1560 

1561 if db.IsInTransaction(): 

1562 __txn_delete(skel, key) 

1563 else: 

1564 db.RunInTransaction(__txn_delete, skel, key) 

1565 

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

1567 bone.postDeletedHandler(skel, boneName, key) 

1568 

1569 skel.postDeletedHandler(key) 

1570 

1571 # Inform the custom DB Adapter 

1572 for adapter in skel.database_adapters: 

1573 adapter.delete(skel) 

1574 

1575 @classmethod 

1576 def patch( 

1577 cls, 

1578 skel: SkeletonInstance, 

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

1580 *, 

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

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

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

1584 update_relations: bool = True, 

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

1586 retry: int = 0, 

1587 ) -> SkeletonInstance: 

1588 """ 

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

1590 

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

1592 given Skeleton and its underlying database entity. 

1593 

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

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

1596 

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

1598 the transaction. 

1599 

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

1601 given value, which can be used for counters. 

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

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

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

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

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

1607 given key does not exist. 

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

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

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

1611 

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

1613 

1614 Raises: 

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

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

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

1618 """ 

1619 

1620 # Transactional function 

1621 def __update_txn(): 

1622 # Try to read the skeleton, create on demand 

1623 if not skel.read(key): 

1624 if create is None or create is False: 

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

1626 

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

1628 return ValueError("No valid key provided") 

1629 

1630 if key or skel["key"]: 

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

1632 

1633 if isinstance(create, dict): 

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

1635 raise ReadFromClientException(skel.errors) 

1636 elif callable(create): 

1637 create(skel) 

1638 elif create is not True: 

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

1640 

1641 # Handle check 

1642 if isinstance(check, dict): 

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

1644 if skel[bone] != value: 

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

1646 

1647 elif callable(check): 

1648 check(skel) 

1649 

1650 # Set values 

1651 if isinstance(values, dict): 

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

1653 raise ReadFromClientException(skel.errors) 

1654 

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

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

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

1658 match name[0]: 

1659 case "+": # Increment by value? 

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

1661 case "-": # Decrement by value? 

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

1663 

1664 elif callable(values): 

1665 values(skel) 

1666 

1667 else: 

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

1669 

1670 return skel.write(update_relations=update_relations) 

1671 

1672 if not db.IsInTransaction: 

1673 # Retry loop 

1674 while True: 

1675 try: 

1676 return db.RunInTransaction(__update_txn) 

1677 

1678 except db.ViurDatastoreError as e: 

1679 retry -= 1 

1680 if retry < 0: 

1681 raise 

1682 

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

1684 

1685 time.sleep(1) 

1686 else: 

1687 return __update_txn() 

1688 

1689 @classmethod 

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

1691 """ 

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

1693 """ 

1694 return locks 

1695 

1696 @classmethod 

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

1698 """ 

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

1700 written to the data store. 

1701 """ 

1702 return entity 

1703 

1704 @classmethod 

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

1706 """ 

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

1708 to the data store. 

1709 """ 

1710 pass 

1711 

1712 @classmethod 

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

1714 """ 

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

1716 from the data store. 

1717 """ 

1718 pass 

1719 

1720 @classmethod 

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

1722 """ 

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

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

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

1726 to make it unique. 

1727 :return: 

1728 """ 

1729 return 

1730 

1731 

1732class RelSkel(BaseSkeleton): 

1733 """ 

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

1735 additional information data skeleton for 

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

1737 

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

1739 (bones) are specified. 

1740 

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

1742 contained bones remains constant. 

1743 """ 

1744 

1745 def serialize(self, parentIndexed): 

1746 if self.dbEntity is None: 

1747 self.dbEntity = db.Entity() 

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

1749 # if key in self.accessedValues: 

1750 _bone.serialize(self, key, parentIndexed) 

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

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

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

1754 return self.dbEntity 

1755 

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

1757 """ 

1758 Loads 'values' into this skeleton. 

1759 

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

1761 """ 

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

1763 self.dbEntity = db.Entity() 

1764 

1765 if values: 

1766 self.dbEntity.update(values) 

1767 else: 

1768 self.dbEntity = values 

1769 

1770 self.accessedValues = {} 

1771 self.renderAccessedValues = {} 

1772 

1773 

1774class RefSkel(RelSkel): 

1775 @classmethod 

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

1777 """ 

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

1779 

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

1781 :return: A new instance of RefSkel 

1782 """ 

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

1784 fromSkel = skeletonByKind(kindName) 

1785 newClass.kindName = kindName 

1786 bone_map = {} 

1787 for arg in args: 

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

1789 newClass.__boneMap__ = bone_map 

1790 return newClass 

1791 

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

1793 """ 

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

1795 

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

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

1798 

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

1800 """ 

1801 skel = skeletonByKind(self.kindName)() 

1802 

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

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

1805 

1806 return skel 

1807 

1808 

1809class SkelList(list): 

1810 """ 

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

1812 

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

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

1815 

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

1817 :vartype cursor: str 

1818 """ 

1819 

1820 __slots__ = ( 

1821 "baseSkel", 

1822 "customQueryInfo", 

1823 "getCursor", 

1824 "get_orders", 

1825 "renderPreparation", 

1826 ) 

1827 

1828 def __init__(self, baseSkel=None): 

1829 """ 

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

1831 """ 

1832 super(SkelList, self).__init__() 

1833 self.baseSkel = baseSkel or {} 

1834 self.getCursor = lambda: None 

1835 self.get_orders = lambda: None 

1836 self.renderPreparation = None 

1837 self.customQueryInfo = {} 

1838 

1839 

1840# Module functions 

1841 

1842 

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

1844 """ 

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

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

1847 :return: The skeleton-class for that kind 

1848 """ 

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

1850 return MetaBaseSkel._skelCache[kindName] 

1851 

1852 

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

1854 """ 

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

1856 """ 

1857 return sorted(MetaBaseSkel._skelCache.keys()) 

1858 

1859 

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

1861 """ 

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

1863 RefSkel classes will be included) 

1864 """ 

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

1866 yield cls 

1867 

1868 

1869### Tasks ### 

1870 

1871@CallDeferred 

1872def processRemovedRelations(removedKey: db.Key, cursor=None): 

1873 updateListQuery = ( 

1874 db.Query("viur-relations") 

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

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

1877 ) 

1878 updateListQuery = updateListQuery.setCursor(cursor) 

1879 updateList = updateListQuery.run(limit=5) 

1880 

1881 for entry in updateList: 

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

1883 

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

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

1886 

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

1888 found = False 

1889 

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

1891 if isinstance(bone, RelationalBone): 

1892 if relational_value := skel[key]: 

1893 # TODO: LanguageWrapper is not considered here (<RelationalBone(languages=[...])>) 

1894 if isinstance(relational_value, dict): 

1895 if relational_value["dest"]["key"] == removedKey: 

1896 skel[key] = None 

1897 found = True 

1898 

1899 elif isinstance(relational_value, list): 

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

1901 found = True 

1902 

1903 else: 

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

1905 

1906 if found: 

1907 skel.write(update_relations=False) 

1908 

1909 else: 

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

1911 skel.delete() 

1912 

1913 if len(updateList) == 5: 

1914 processRemovedRelations(removedKey, updateListQuery.getCursor()) 

1915 

1916 

1917@CallDeferred 

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

1919 """ 

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

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

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

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

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

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

1926 

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

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

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

1930 in the meantime as they're already up2date 

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

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

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

1934 defer again. 

1935 """ 

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

1937 if request_data := current.request_data.get(): 

1938 request_data["__update_relations_bone"] = changedBone 

1939 updateListQuery = ( 

1940 db.Query("viur-relations") 

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

1942 .filter("viur_delayed_update_tag <", minChangeTime) 

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

1944 ) 

1945 if changedBone: 

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

1947 if cursor: 

1948 updateListQuery.setCursor(cursor) 

1949 updateList = updateListQuery.run(limit=5) 

1950 

1951 def updateTxn(skel, key, srcRelKey): 

1952 if not skel.read(key): 

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

1954 return 

1955 

1956 skel.refresh() 

1957 skel.write(update_relations=False) 

1958 

1959 for srcRel in updateList: 

1960 try: 

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

1962 except AssertionError: 

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

1964 continue 

1965 if db.IsInTransaction(): 

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

1967 else: 

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

1969 nextCursor = updateListQuery.getCursor() 

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

1971 updateRelations(destKey, minChangeTime, changedBone, nextCursor) 

1972 

1973 

1974@CallableTask 

1975class TaskUpdateSearchIndex(CallableTaskBase): 

1976 """ 

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

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

1979 """ 

1980 key = "rebuildSearchIndex" 

1981 name = "Rebuild search index" 

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

1983 

1984 def canCall(self) -> bool: 

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

1986 user = current.user.get() 

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

1988 

1989 def dataSkel(self): 

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

1991 modules.sort() 

1992 skel = BaseSkeleton().clone() 

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

1994 return skel 

1995 

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

1997 usr = current.user.get() 

1998 if not usr: 

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

2000 notify = None 

2001 else: 

2002 notify = usr["name"] 

2003 

2004 if module == "*": 

2005 for module in listKnownSkeletons(): 

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

2007 self._run(module, notify) 

2008 else: 

2009 self._run(module, notify) 

2010 

2011 @staticmethod 

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

2013 Skel = skeletonByKind(module) 

2014 if not Skel: 

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

2016 return 

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

2018 

2019 

2020class RebuildSearchIndex(QueryIter): 

2021 @classmethod 

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

2023 skel.refresh() 

2024 skel.write(update_relations=False) 

2025 

2026 @classmethod 

2027 def handleError(cls, skel, customData, exception) -> bool: 

2028 logging.exception(f'{cls.__qualname__}.handleEntry failed on skel {skel["key"]=!r}: {exception}') 

2029 try: 

2030 logging.debug(f"{skel=!r}") 

2031 except Exception: # noqa 

2032 logging.warning("Failed to dump skel") 

2033 logging.debug(f"{skel.dbEntity=}") 

2034 return True 

2035 

2036 @classmethod 

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

2038 QueryIter.handleFinish(totalCount, customData) 

2039 if not customData["notify"]: 

2040 return 

2041 txt = ( 

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

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

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

2045 ) 

2046 try: 

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

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

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

2050 

2051 

2052### Vacuum Relations 

2053 

2054@CallableTask 

2055class TaskVacuumRelations(TaskUpdateSearchIndex): 

2056 """ 

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

2058 and it's RelationalBone still exists. 

2059 """ 

2060 key = "vacuumRelations" 

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

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

2063 

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

2065 usr = current.user.get() 

2066 if not usr: 

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

2068 notify = None 

2069 else: 

2070 notify = usr["name"] 

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

2072 

2073 

2074@CallDeferred 

2075def processVacuumRelationsChunk( 

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

2077): 

2078 """ 

2079 Processes 25 Entries and calls the next batch 

2080 """ 

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

2082 if module != "*": 

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

2084 query.setCursor(cursor) 

2085 for relation_object in query.run(25): 

2086 count_total += 1 

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

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

2089 continue 

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

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

2092 continue 

2093 try: 

2094 skel = skeletonByKind(src_kind)() 

2095 except AssertionError: 

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

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

2098 db.Delete(relation_object) 

2099 count_removed += 1 

2100 continue 

2101 if src_prop not in skel: 

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

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

2104 db.Delete(relation_object) 

2105 count_removed += 1 

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

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

2108 if new_cursor := query.getCursor(): 

2109 # Start processing of the next chunk 

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

2111 elif notify: 

2112 txt = ( 

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

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

2115 f"{count_total} records processed, " 

2116 f"{count_removed} entries removed" 

2117 ) 

2118 try: 

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

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

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

2122 

2123 

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

2125db.config["SkeletonInstanceRef"] = SkeletonInstance 

2126 

2127# DEPRECATED ATTRIBUTES HANDLING 

2128 

2129__DEPRECATED_NAMES = { 

2130 # stuff prior viur-core < 3.6 

2131 "seoKeyBone": ("SeoKeyBone", SeoKeyBone), 

2132} 

2133 

2134 

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

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

2137 func = entry[1] 

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

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

2140 logging.warning(msg, stacklevel=2) 

2141 return func 

2142 

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