Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/skeleton/tasks.py: 52%
113 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-01 22:44 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-01 22:44 +0000
1import logging
2import typing as t
3import logics
4import time
6from viur.core import (
7 conf,
8 current,
9 db,
10 email,
11 errors,
12 tasks,
13 utils,
14)
15from .utils import skeletonByKind, listKnownSkeletons
16from .relskel import RelSkel
18from ..bones.raw import RawBone
19from ..bones.record import RecordBone
20from ..bones.relational import RelationalBone, RelationalConsistency, RelationalUpdateLevel
21from ..bones.select import SelectBone
22from ..bones.string import StringBone
25@tasks.CallDeferred
26def update_relations(
27 key: db.Key,
28 *,
29 min_change_time: t.Optional[float] = None,
30 changed_bones: t.Optional[t.Iterable[str] | str] = (),
31 cursor: t.Optional[str] = None,
32 total: int = 0,
33 **kwargs
34):
35 """
36 This function updates Entities, which may have a copy of values from another entity which has been recently
37 edited (updated). In ViUR, relations are implemented by copying the values from the referenced entity into the
38 entity that's referencing them. This allows ViUR to run queries over properties of referenced entities and
39 prevents additional db.Get's to these referenced entities if the main entity is read. However, this forces
40 us to track changes made to entities as we might have to update these mirrored values. This is the deferred
41 call from meth:`viur.core.skeleton.Skeleton.write()` after an update (edit) on one Entity to do exactly that.
43 :param key: The database-key of the entity that has been edited
44 :param min_change_time: The timestamp on which the edit occurred. As we run deferred, and the entity might have
45 been edited multiple times before we get acutally called, we can ignore entities that have been updated
46 in the meantime as they're already up-to-date
47 :param changed_bones: If set, we'll update only entites that have a copy of that bones. Relations mirror only
48 key and name by default, so we don't have to update these if only another bone has been changed.
49 :param cursor: The database cursor for the current request as we only process five entities at once and then
50 defer again.
51 """
52 # TODO: Remove in VIUR4
53 for _dep, _new in {
54 "changedBone": "changed_bones",
55 "minChangeTime": "min_change_time",
56 "destKey": "key",
57 }.items():
58 if _dep in kwargs: 58 ↛ 59line 58 didn't jump to line 59 because the condition on line 58 was never true
59 logging.warning(f"{_dep!r} parameter is deprecated, please use {_new!r} instead",)
60 locals()[_new] = kwargs.pop(_dep)
62 if min_change_time is None: 62 ↛ 65line 62 didn't jump to line 65 because the condition on line 62 was always true
63 min_change_time = time.time() + 1
65 changed_bones = utils.ensure_iterable(changed_bones)
67 if not cursor: 67 ↛ 70line 67 didn't jump to line 70 because the condition on line 67 was always true
68 logging.debug(f"update_relations {key=} {min_change_time=} {changed_bones=}")
70 if request_data := current.request_data.get(): 70 ↛ 73line 70 didn't jump to line 73 because the condition on line 70 was always true
71 request_data["__update_relations_bones"] = changed_bones
73 query = db.Query("viur-relations") \
74 .filter("dest.__key__ =", key) \
75 .filter("viur_delayed_update_tag <", min_change_time) \
76 .filter("viur_relational_updateLevel =", RelationalUpdateLevel.Always.value)
78 if changed_bones: 78 ↛ 81line 78 didn't jump to line 81 because the condition on line 78 was always true
79 query.filter("viur_foreign_keys IN", changed_bones)
81 query.setCursor(cursor)
83 # A single entity can reference the same destination more than once (e.g. a list
84 # bone holding the same reference several times), yielding one relation per
85 # occurrence. Refreshing that entity once is enough -- doing it per relation only
86 # stacks transactions on the very same entity and can exceed the request deadline.
87 seen_src_keys: set[db.Key] = set()
89 for src_rel in query.run():
90 src_key = src_rel["src"].key
91 if src_key in seen_src_keys:
92 continue
93 seen_src_keys.add(src_key)
95 try:
96 skel = skeletonByKind(src_rel["viur_src_kind"])()
97 except AssertionError:
98 logging.info(f"""Ignoring {src_rel.key!r} which refers to unknown kind {src_rel["viur_src_kind"]!r}""")
99 continue
101 try:
102 skel.patch(lambda skel: skel.refresh(), key=src_key, update_relations=False)
103 except ValueError:
104 logging.warning(f"Cannot update stale reference to {src_key!r} referenced by {src_rel.key!r}")
105 continue
107 total += 1
109 if next_cursor := query.getCursor(): 109 ↛ 110line 109 didn't jump to line 110 because the condition on line 109 was never true
110 update_relations(
111 key=key,
112 min_change_time=min_change_time,
113 changed_bones=changed_bones,
114 cursor=next_cursor,
115 total=total
116 )
117 else:
118 logging.debug(f"update_relations finished with {total=} on {key=} {min_change_time=} {changed_bones=}")
121class SkelIterTask(tasks.QueryIter):
122 """
123 Iterates the skeletons of a query, and additionally checks a Logics expression.
124 When the skeleton is valid, it performs the action `data["action"]` on each entry.
125 """
127 @classmethod
128 def handleEntry(cls, skel, data):
129 data["total"] += 1
131 if logics.Logics(data["condition"]).run(skel):
132 data["count"] += 1
134 match data["action"]:
135 case "refresh":
136 skel.refresh()
137 if skel["key"]:
138 skel.write(update_relations=False)
140 case "delete":
141 skel.delete()
143 case other:
144 assert other == "count"
146 @classmethod
147 def handleError(cls, skel, data, exception) -> bool:
148 logging.exception(exception)
150 try:
151 logging.debug(f"{skel=!r}")
152 except Exception: # noqa
153 logging.warning("Failed to dump skel")
154 logging.debug(f"{skel.dbEntity=}")
156 data["error"] += 1
157 return True
159 @classmethod
160 def handleFinish(cls, total, data):
161 super().handleFinish(total, data)
163 if not data["notify"]:
164 return
166 txt = (
167 f"{conf.instance.project_id}: {data['action']!s} finished for {data['kind']!r}: "
168 f"{data['count']} of {data['total']}\n"
169 f"ViUR {data['action']!s}ed {data['count']} skeletons with condition <code>{data['condition']}</code> on a "
170 f"total of {data['total']} ({data['error']} errored) of kind {data['kind']}.\n"
171 )
173 try:
174 email.send_email(dests=data["notify"], stringTemplate=txt, skel=None)
175 except Exception as exc: # noqa; OverQuota, whatever
176 logging.exception(f'Failed to notify {data["notify"]}')
179@tasks.CallableTask
180class SkeletonMaintenanceTask(tasks.CallableTaskBase):
181 key = "SkeletonMaintenanceTask"
182 name = "Skeleton Maintenance"
183 descr = "Perform filtered maintenance operations on skeletons."
185 def canCall(self):
186 user = current.user.get()
187 return user and "root" in user["access"]
189 class dataSkel(RelSkel):
190 task = SelectBone(
191 descr="Task",
192 required=True,
193 values={
194 "count": "Count",
195 "refresh": "Refresh (formerly: RebuildSearchIndex)",
196 "delete": "Delete",
197 },
198 defaultValue="refresh",
199 )
201 kinds = SelectBone(
202 descr="Kind",
203 values=listKnownSkeletons,
204 required=True,
205 multiple=True,
206 )
208 class FilterRowUsingSkel(RelSkel):
209 name = StringBone(
210 required=True,
211 )
213 op = SelectBone(
214 required=True,
215 values={
216 "$eq": "=",
217 "$lt": "<",
218 "$gt": ">",
219 "$lk": "like",
220 },
221 defaultValue=" ",
222 )
224 value = StringBone(
225 required=True,
226 )
228 filters = RecordBone(
229 descr="Filter",
230 using=FilterRowUsingSkel,
231 multiple=True,
232 format="$(name)$(op)=$(value)",
233 )
235 condition = RawBone(
236 descr="Condition",
237 type_suffix="code.python", # Logics expression
238 required=True,
239 defaultValue="False # fused: by default, doesn't affect anything.\n",
240 params={
241 "tooltip": "Enter a Logics expression here to filter entries by specific skeleton values."
242 },
243 )
245 def execute(self, task, kinds, filters, condition):
246 try:
247 logics.Logics(condition)
248 except logics.ParseException as e:
249 raise errors.BadRequest(f"Error parsing condition {e}")
251 notify = current.user.get()["name"]
253 for kind in kinds:
254 q = skeletonByKind(kind)().all()
256 for flt in filters:
257 q.mergeExternalFilter({(flt["name"] + flt["op"]).rstrip("$eq"): flt["value"]})
259 params = {
260 "action": task,
261 "notify": notify,
262 "condition": condition,
263 "kind": kind,
264 "count": 0,
265 "total": 0,
266 "error": 0,
267 }
269 SkelIterTask.startIterOnQuery(q, params)