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

420 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-25 15:02 +0000

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

2 

3import logging 

4import time 

5import typing as t 

6import warnings 

7 

8from deprecated.sphinx import deprecated 

9 

10from viur.core import conf, db, errors, utils 

11from . import tasks 

12from .base import BaseSkeleton 

13from .meta import MetaSkel, _UNDEFINED_KINDNAME 

14from .utils import skeletonByKind 

15from ..bones.base import ( 

16 Compute, 

17 ComputeInterval, 

18 ComputeMethod, 

19 ReadFromClientError, 

20 ReadFromClientErrorSeverity, 

21 ReadFromClientException, 

22) 

23from ..bones.date import DateBone 

24from ..bones.key import KeyBone 

25from ..bones.raw import RawBone 

26from ..bones.relational import RelationalConsistency 

27from ..bones.string import StringBone 

28 

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

30 from .instance import SkeletonInstance 

31 from .adapter import DatabaseAdapter 

32 

33 

34class SeoKeyBone(StringBone): 

35 """ 

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

37 """ 

38 

39 def setSystemInitialized(self): 

40 super().setSystemInitialized() 

41 self.languages = conf.i18n.available_languages 

42 

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

44 try: 

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

46 except KeyError: 

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

48 

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

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

51 if name in skel.accessedValues: 51 ↛ 64line 51 didn't jump to line 64 because the condition on line 51 was always true

52 newVal = skel.accessedValues[name] 

53 if not skel.dbEntity.get("viur"): 53 ↛ 54line 53 didn't jump to line 54 because the condition on line 53 was never true

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

55 res = db.Entity() 

56 res["_viurLanguageWrapper_"] = True 

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

58 if not self.indexed: 58 ↛ 59line 58 didn't jump to line 59 because the condition on line 58 was never true

59 res.exclude_from_indexes.add(language) 

60 res[language] = None 

61 if language in newVal: 61 ↛ 57line 61 didn't jump to line 57 because the condition on line 61 was always true

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

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

64 return True 

65 

66 

67class Skeleton(BaseSkeleton, metaclass=MetaSkel): 

68 kindName: str = _UNDEFINED_KINDNAME 

69 """ 

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

71 Will be determined automatically when not explicitly set. 

72 """ 

73 

74 database_adapters: DatabaseAdapter | t.Iterable[DatabaseAdapter] | None = _UNDEFINED_KINDNAME 

75 """ 

76 Custom database adapters. 

77 Allows to hook special functionalities that during skeleton modifications. 

78 """ 

79 

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

81 

82 interBoneValidations: list[ 

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

84 

85 __seo_key_trans = str.maketrans( 

86 {"<": "", 

87 ">": "", 

88 "\"": "", 

89 "'": "", 

90 "\n": "", 

91 "\0": "", 

92 "/": "", 

93 "\\": "", 

94 "?": "", 

95 "&": "", 

96 "#": "" 

97 }) 

98 

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

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

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

102 key = KeyBone( 

103 descr="Key" 

104 ) 

105 

106 shortkey = RawBone( 

107 descr="Shortkey", 

108 compute=Compute(lambda skel: skel["key"].id_or_name if skel["key"] else None), 

109 readOnly=True, 

110 visible=False, 

111 searchable=True, 

112 tags="technical", 

113 ) 

114 

115 name = StringBone( 

116 descr="Name", 

117 visible=False, 

118 compute=Compute( 

119 fn=lambda skel: f"{skel["key"].kind}/{skel["key"].id_or_name}" if skel["key"] else None, 

120 interval=ComputeInterval(ComputeMethod.OnWrite) 

121 ) 

122 ) 

123 

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

125 creationdate = DateBone( 

126 descr="created at", 

127 readOnly=True, 

128 visible=False, 

129 indexed=True, 

130 compute=Compute( 

131 lambda: utils.utcNow().replace(microsecond=0), 

132 interval=ComputeInterval(ComputeMethod.Once) 

133 ), 

134 tags="technical", 

135 ) 

136 

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

138 

139 changedate = DateBone( 

140 descr="updated at", 

141 readOnly=True, 

142 visible=False, 

143 indexed=True, 

144 compute=Compute( 

145 lambda: utils.utcNow().replace(microsecond=0), 

146 interval=ComputeInterval(ComputeMethod.OnWrite) 

147 ), 

148 tags="technical", 

149 ) 

150 

151 viurCurrentSeoKeys = SeoKeyBone( 

152 descr="SEO-Keys", 

153 readOnly=True, 

154 visible=False, 

155 languages=conf.i18n.available_languages, 

156 ) 

157 

158 def __repr__(self): 

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

160 

161 def __str__(self): 

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

163 

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

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

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

167 

168 @classmethod 

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

170 """ 

171 Create a query with the current Skeletons kindName. 

172 

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

174 """ 

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

176 

177 @classmethod 

178 def fromClient( 

179 cls, 

180 skel: "SkeletonInstance[t.Self]", 

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

182 *, 

183 amend: bool = False, 

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

185 ) -> bool: 

186 """ 

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

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

189 

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

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

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

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

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

195 

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

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

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

199 which is useful for edit-actions. 

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

201 

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

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

204 """ 

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

206 

207 # Load data into this skeleton 

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

209 

210 if ( 

211 not data # in case data is empty 

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

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

214 ): 

215 skel.errors = [] 

216 

217 # Check if all unique values are available 

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

219 if boneInstance.unique: 

220 lockValues = boneInstance.getUniquePropertyIndexValues(skel, boneName) 

221 

222 for lockValue in lockValues: 

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

224 lock_entity = db.get(lock_key) 

225 

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

227 logging.error(f"{boneName=} {lock_key=} already taken by {lock_entity["references"]!r}") 

228 

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

230 complete = False 

231 skel.errors.append( 

232 ReadFromClientError( 

233 ReadFromClientErrorSeverity.Invalid, 

234 boneInstance.unique.message, 

235 [boneName] 

236 ) 

237 ) 

238 

239 # Check inter-Bone dependencies 

240 for checkFunc in skel.interBoneValidations: 

241 errors = checkFunc(skel) 

242 if errors: 

243 for error in errors: 

244 if error.severity.value > 1: 

245 complete = False 

246 if conf.debug.skeleton_from_client: 

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

248 

249 skel.errors.extend(errors) 

250 

251 return complete 

252 

253 @classmethod 

254 @deprecated( 

255 version="3.7.0", 

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

257 ) 

258 def fromDB(cls, skel: SkeletonInstance, key: db.KeyType) -> bool: 

259 """ 

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

261 """ 

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

263 

264 @classmethod 

265 def read( 

266 cls, 

267 skel: SkeletonInstance, 

268 key: t.Optional[db.KeyType] = None, 

269 *, 

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

271 _check_legacy: bool = True 

272 ) -> t.Optional[SkeletonInstance]: 

273 """ 

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

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

276 

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

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

279 data of the bones will discard. 

280 

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

282 

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

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

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

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

287 

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

289 

290 """ 

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

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

293 with warnings.catch_warnings(): 

294 warnings.simplefilter("ignore", DeprecationWarning) 

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

296 

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

298 

299 try: 

300 db_key = db.key_helper(key or skel["key"], skel.kindName) 

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

302 return None 

303 

304 if db_res := db.get(db_key): 

305 skel.setEntity(db_res) 

306 return skel 

307 elif create in (False, None): 

308 return None 

309 elif isinstance(create, dict): 

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

311 raise ReadFromClientException(skel.errors) 

312 elif callable(create): 

313 create(skel) 

314 elif create is not True: 

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

316 

317 skel["key"] = db_key 

318 return skel.write() 

319 

320 @classmethod 

321 @deprecated( 

322 version="3.7.0", 

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

324 ) 

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

326 """ 

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

328 """ 

329 

330 # TODO: Remove with ViUR4 

331 if "clearUpdateTag" in kwargs: 

332 msg = "clearUpdateTag was replaced by update_relations" 

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

334 logging.warning(msg, stacklevel=3) 

335 update_relations = not kwargs["clearUpdateTag"] 

336 

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

338 return skel["key"] 

339 

340 @classmethod 

341 def write( 

342 cls, 

343 skel: SkeletonInstance, 

344 key: t.Optional[db.KeyType] = None, 

345 *, 

346 update_relations: bool = True, 

347 _check_legacy: bool = True, 

348 ) -> SkeletonInstance: 

349 """ 

350 Write current Skeleton to the datastore. 

351 

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

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

354 Otherwise a new entity will be created. 

355 

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

357 

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

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

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

361 

362 :returns: The Skeleton. 

363 """ 

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

365 if _check_legacy and "toDB" in cls.__dict__: 365 ↛ 366line 365 didn't jump to line 366 because the condition on line 365 was never true

366 with warnings.catch_warnings(): 

367 warnings.simplefilter("ignore", DeprecationWarning) 

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

369 

370 # FIXME: This check is incomplete as long it does nt check the entire tree! 

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

372 

373 def __txn_write(write_skel): 

374 db_key = write_skel["key"] 

375 skel = write_skel.skeletonCls() 

376 

377 blob_list = set() 

378 change_list = [] 

379 old_copy = {} 

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

381 if not db_key: 381 ↛ 383line 381 didn't jump to line 383 because the condition on line 381 was never true

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

383 db_key = db.allocate_ids(skel.kindName)[0] 

384 skel.dbEntity = db.Entity(db_key) 

385 is_add = True 

386 else: 

387 db_key = db.key_helper(db_key, skel.kindName) 

388 if db_obj := db.get(db_key): 

389 skel.dbEntity = db_obj 

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

391 is_add = False 

392 else: 

393 skel.dbEntity = db.Entity(db_key) 

394 is_add = True 

395 

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

397 

398 # Merge values and assemble unique properties 

399 # Move accessed Values from srcSkel over to skel 

400 skel.accessedValues = write_skel.accessedValues 

401 

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

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

404 

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

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

407 continue 

408 

409 if bone_name not in write_skel.boneMap: 

410 # The bone is not part of write_skel: it was either removed from it with 

411 # `skel.bone = None`, or it never was part of it (a subskel). Don't serialize 

412 # it, so that whatever is stored for it stays untouched. Its blobs are still 

413 # collected, otherwise the blob-lock below would release them for deletion. 

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

415 continue 

416 

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

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

419 

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

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

422 

423 if ( 

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

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

426 ): 

427 # Serialize bone into entity 

428 try: 

429 bone.serialize(skel, bone_name, True) 

430 except Exception as e: 

431 logging.error( 

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

433 ) 

434 raise e 

435 

436 # Obtain referenced blobs 

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

438 

439 # Check if the value has actually changed 

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

441 change_list.append(bone_name) 

442 

443 # Lock hashes from bones that must have unique values 

444 if bone.unique: 

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

446 old_unique_values = [] 

447 

448 if f"{bone_name}_uniqueIndexValue" in skel.dbEntity["viur"]: 448 ↛ 449line 448 didn't jump to line 449 because the condition on line 448 was never true

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

450 # Check if the property is unique 

451 new_unique_values = bone.getUniquePropertyIndexValues(skel, bone_name) 

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

453 for new_lock_value in new_unique_values: 

454 new_lock_key = db.Key(new_lock_kind, new_lock_value) 

455 if lock_db_obj := db.get(new_lock_key): 455 ↛ 458line 455 didn't jump to line 458 because the condition on line 455 was never true

456 

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

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

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

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

461 raise ValueError( 

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

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

464 else: 

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

466 lock_obj = db.Entity(new_lock_key) 

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

468 db.put(lock_obj) 

469 if new_lock_value in old_unique_values: 469 ↛ 470line 469 didn't jump to line 470 because the condition on line 469 was never true

470 old_unique_values.remove(new_lock_value) 

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

472 

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

474 for old_unique_value in old_unique_values: 474 ↛ 477line 474 didn't jump to line 477 because the loop on line 474 never started

475 # Try to delete the old lock 

476 

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

478 if old_lock_obj := db.get(old_lock_key): 

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

480 

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

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

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

484 else: 

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

486 db.delete(old_lock_key) 

487 else: 

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

489 

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

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

492 

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

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

495 # Filter garbage serialized into this field by the SeoKeyBone 

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

497 

498 if not isinstance(skel.dbEntity["viur"].get("viurCurrentSeoKeys"), dict): 498 ↛ 499line 498 didn't jump to line 499 because the condition on line 498 was never true

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

500 

501 if current_seo_keys := skel.getCurrentSEOKeys(): 501 ↛ 503line 501 didn't jump to line 503 because the condition on line 501 was never true

502 # Convert to lower-case and remove certain characters 

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

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

505 

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

507 if current_seo_keys and language in current_seo_keys: 507 ↛ 508line 507 didn't jump to line 508 because the condition on line 507 was never true

508 current_seo_key = current_seo_keys[language] 

509 

510 # Start from the key this entry currently holds: it may carry a suffix 

511 # from an earlier collision, and that suffix has to survive this write. 

512 new_seo_key = last_set_seo_keys.get(language) or current_seo_key 

513 if not (new_seo_key == current_seo_key or new_seo_key.startswith(f"{current_seo_key}-")): 

514 # The held key no longer derives from the requested one 

515 new_seo_key = current_seo_key 

516 

517 for _ in range(0, 3): 

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

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

520 

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

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

523 new_seo_key = f"{current_seo_key}-{utils.string.random(5).lower()}" 

524 

525 else: 

526 # We found a new SeoKey 

527 break 

528 else: 

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

530 

531 last_set_seo_keys[language] = new_seo_key 

532 

533 else: 

534 # We'll use the database-key instead 

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

536 

537 # Store the current, active key for that language 

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

539 

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

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

542 if ( 542 ↛ 541line 542 didn't jump to line 541 because the condition on line 542 was always true

543 skel.dbEntity["viur"]["viurCurrentSeoKeys"][language] 

544 not in skel.dbEntity["viur"]["viurActiveSeoKeys"] 

545 ): 

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

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

548 if str(skel.dbEntity.key.id_or_name) not in skel.dbEntity["viur"]["viurActiveSeoKeys"]: 548 ↛ 550line 548 didn't jump to line 550 because the condition on line 548 was never true

549 # Ensure that key is also in there 

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

551 # Trim to the last 200 used entries 

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

553 # Store the requested keys; kept for applications reading this property 

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

555 

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

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

558 

559 skel.dbEntity = skel.preProcessSerializedData(skel.dbEntity) 

560 

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

562 for adapter in skel.database_adapters: 

563 adapter.prewrite(skel, is_add, change_list) 

564 

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

566 def fixDotNames(entity): 

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

568 if isinstance(v, dict): 

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

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

571 del entity[k2] 

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

573 entity[backupKey] = v2 

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

575 fixDotNames(v) 

576 elif isinstance(v, list): 

577 for x in v: 

578 if isinstance(x, dict): 

579 fixDotNames(x) 

580 

581 # FIXME: REMOVE IN VIUR4 

582 if conf.viur2import_blobsource: # Try to fix these only when converting from ViUR2 582 ↛ 583line 582 didn't jump to line 583 because the condition on line 582 was never true

583 fixDotNames(skel.dbEntity) 

584 

585 # Write the core entry back 

586 db.put(skel.dbEntity) 

587 

588 # Now write the blob-lock object 

589 blob_list = skel.preProcessBlobLocks(blob_list) 

590 if blob_list is None: 590 ↛ 591line 590 didn't jump to line 591 because the condition on line 590 was never true

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

592 if None in blob_list: 592 ↛ 593line 592 didn't jump to line 593 because the condition on line 592 was never true

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

594 logging.error(msg) 

595 raise ValueError(msg) 

596 

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

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

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

600 if old_blob_lock_obj["old_blob_references"] is None: 600 ↛ 601line 600 didn't jump to line 601 because the condition on line 600 was never true

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

602 else: 

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

604 old_blob_refs.update(removed_blobs) # Add removed blobs 

605 old_blob_refs -= blob_list # Remove active blobs 

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

607 

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

609 old_blob_lock_obj["is_stale"] = False 

610 db.put(old_blob_lock_obj) 

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

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

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

614 blob_lock_obj["old_blob_references"] = [] 

615 blob_lock_obj["has_old_blob_references"] = False 

616 blob_lock_obj["is_stale"] = False 

617 db.put(blob_lock_obj) 

618 

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

620 

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

622 if key: 622 ↛ 623line 622 didn't jump to line 623 because the condition on line 622 was never true

623 skel["key"] = db.key_helper(key, skel.kindName) 

624 

625 if skel._cascade_deletion is True: 625 ↛ 626line 625 didn't jump to line 626 because the condition on line 625 was never true

626 if skel["key"]: 

627 logging.info(f"{skel._cascade_deletion=}, will delete {skel["key"]!r}") 

628 skel.delete() 

629 

630 return skel 

631 

632 # Run transactional function 

633 if db.is_in_transaction(): 633 ↛ 636line 633 didn't jump to line 636 because the condition on line 633 was always true

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

635 else: 

636 key, skel, change_list, is_add = db.run_in_transaction(__txn_write, skel) 

637 

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

639 bone.postSavedHandler(skel, bone_name, key) 

640 

641 skel.postSavedHandler(key, skel.dbEntity) 

642 

643 if update_relations and not is_add: 

644 if change_list and len(change_list) < 5: # Only a few bones have changed, process these individually 644 ↛ 645line 644 didn't jump to line 645 because the condition on line 644 was never true

645 tasks.update_relations(key, changed_bones=change_list, _countdown=10) 

646 

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

648 tasks.update_relations(key) 

649 

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

651 for adapter in skel.database_adapters: 

652 adapter.write(skel, is_add, change_list) 

653 

654 return skel 

655 

656 @classmethod 

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

658 """ 

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

660 

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

662 """ 

663 

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

665 if not skel.read(key): 

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

667 

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

669 locked_relation = ( 

670 db.Query("viur-relations") 

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

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

673 ).getEntry() 

674 

675 if locked_relation is not None: 

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

677 

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

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

680 

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

682 bone.delete(skel, boneName) 

683 if bone.unique: 

684 flushList = [] 

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

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

687 lockObj = db.get(lockKey) 

688 if not lockObj: 

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

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

691 logging.error( 

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

693 else: 

694 flushList.append(lockObj) 

695 if flushList: 

696 db.delete(flushList) 

697 

698 # Delete the blob-key lock object 

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

700 lockObj = db.get(lockObjectKey) 

701 

702 if lockObj is not None: 

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

704 db.delete(lockObjectKey) # Nothing to do here 

705 else: 

706 if lockObj["old_blob_references"] is None: 

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

708 lockObj["old_blob_references"] = lockObj["active_blob_references"] 

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

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

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

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

713 lockObj["is_stale"] = True 

714 lockObj["has_old_blob_references"] = True 

715 db.put(lockObj) 

716 

717 db.delete(key) 

718 tasks.update_relations(key) 

719 

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

721 key = db.key_helper(key, skel.kindName) 

722 else: 

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

724 

725 # Full skeleton is required to have all bones! 

726 skel = skeletonByKind(skel.kindName)() 

727 

728 if db.is_in_transaction(): 

729 __txn_delete(skel, key) 

730 else: 

731 db.run_in_transaction(__txn_delete, skel, key) 

732 

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

734 bone.postDeletedHandler(skel, boneName, key) 

735 

736 skel.postDeletedHandler(key) 

737 

738 # Inform the custom DB Adapter 

739 for adapter in skel.database_adapters: 

740 adapter.delete(skel) 

741 

742 @classmethod 

743 def patch( 

744 cls, 

745 skel: SkeletonInstance, 

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

747 *, 

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

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

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

751 internal: bool = True, 

752 key: t.Optional[db.KeyType] = None, 

753 preprocess: t.Optional[t.Callable[[SkeletonInstance], None]] = None, 

754 retry: int = 0, 

755 update_relations: bool = True, 

756 ) -> SkeletonInstance: 

757 """ 

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

759 

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

761 given Skeleton and its underlying database entity. 

762 

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

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

765 

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

767 the transaction. 

768 

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

770 given value, which can be used for counters. 

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

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

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

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

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

776 given key does not exist. 

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

778 :param ignore: optional list of bones to be ignored from values; Defaults to an empty list, 

779 so that all bones are accepted (even read-only ones, as skel.patch() is being used internally) 

780 :param internal: Internal patch does ignore any NotSet and Empty errors that may raise in skel.fromClient() 

781 :param retry: On RuntimeError, retry for this amount of times. - DEPRECATED! 

782 

783 If the function does not raise an Exception, all went well. 

784 The function always returns the input Skeleton. 

785 

786 Raises: 

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

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

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

790 """ 

791 

792 # Transactional function 

793 def __update_txn(): 

794 # Try to read the skeleton, create on demand 

795 if not skel.read(key): 

796 if create is None or create is False: 

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

798 

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

800 return ValueError("No valid key provided") 

801 

802 if key or skel["key"]: 

803 skel["key"] = db.key_helper(key or skel["key"], skel.kindName) 

804 

805 if isinstance(create, dict): 

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

807 raise ReadFromClientException(skel.errors) 

808 elif callable(create): 

809 create(skel) 

810 elif create is not True: 

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

812 

813 # Handle check 

814 if isinstance(check, dict): 

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

816 if skel[bone] != value: 

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

818 

819 elif callable(check): 

820 check(skel) 

821 

822 # Set values 

823 if isinstance(values, dict): 

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

825 raise ReadFromClientException(skel.errors) 

826 

827 # In case we're in internal-mode, only raise fatal errors. 

828 if skel.errors and internal: 

829 for error in skel.errors: 

830 if error.severity in ( 

831 ReadFromClientErrorSeverity.Invalid, 

832 ReadFromClientErrorSeverity.InvalidatesOther, 

833 ): 

834 raise ReadFromClientException(skel.errors) 

835 

836 # otherwise, ignore any reported errors 

837 skel.errors.clear() 

838 

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

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

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

842 match name[0]: 

843 case "+": # Increment by value? 

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

845 case "-": # Decrement by value? 

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

847 

848 elif callable(values): 

849 values(skel) 

850 

851 else: 

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

853 

854 # Run preprocess hook 

855 if preprocess: 

856 preprocess(skel) 

857 

858 # Finally write the skeleton 

859 return skel.write(update_relations=update_relations) 

860 

861 if not db.is_in_transaction(): 

862 # Retry loop 

863 while True: 

864 try: 

865 return db.run_in_transaction(__update_txn) 

866 

867 except RuntimeError as e: 

868 retry -= 1 

869 if retry < 0: 

870 raise 

871 

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

873 

874 time.sleep(1) 

875 else: 

876 return __update_txn() 

877 

878 @classmethod 

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

880 """ 

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

882 """ 

883 return locks 

884 

885 @classmethod 

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

887 """ 

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

889 written to the data store. 

890 """ 

891 return entity 

892 

893 @classmethod 

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

895 """ 

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

897 to the data store. 

898 """ 

899 pass 

900 

901 @classmethod 

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

903 """ 

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

905 from the data store. 

906 """ 

907 pass 

908 

909 @classmethod 

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

911 """ 

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

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

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

915 to make it unique. 

916 :return: 

917 """ 

918 return