File size: 13,621 Bytes
ea2c336 ef68ae0 ea2c336 c3e4cb4 ea2c336 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 | """core/alerts.py β "tell me when a record ENTERS this view" (wave 20, owner item 25).
THE OWNER'S SHAPE, verbatim: *"a user click a view and click 'Create alert'. This way when a
Record gets into that Filter's criteria, a user gets notified in that module β¦ Read/Unread like an
inbox type of thing so they can confirm whether they have seen the Record that got filtered in."*
So an alert is **a view plus a remembered MATCHED SET**, and a notification is a **new entrant** β
a pid that matches now and did not last time. That definition is the whole design, and it is what
makes the module trustworthy rather than noisy:
* a record already in the view when the alert is created is NOT news. The first evaluation
SEEDS the set silently (`seed=True`); without that, creating an alert on a 400-row view
would announce 400 "new" records, and the user would turn the feature off in one click;
* a record that leaves and comes back IS news again β it re-entered the criteria, which is the
event the owner described;
* a record that merely CHANGES while staying in the view is not an entrant. "Still matching" is
not an event. (An on-change alert is a different feature with a different noise budget; it is
NOT smuggled in here under the same name.)
β **WHY THE MATCHED SET IS STORED AND NOT RECOMPUTED FROM HISTORY.** There is no row-level audit
log to diff against β `store` holds current state, and Odoo is read-only. The remembered set IS
the history, so it is written on every evaluation, in the same transaction that queues the
notifications. Losing that write while keeping the notifications would re-announce the same
records forever.
β **WHAT AN UNANSWERABLE LEAF ACTUALLY DOES HERE β CHECKED, NOT ASSUMED.** `harness.filter_eval`'s
`EvalCtx` is explicit that each of its four members "absent, the condition matches NOTHING rather
than everything" β the engine NARROWS on an unresolved measure/cohort/rank set rather than
widening. So the failure mode is not a false alert; it is a **missed** one, and a missed entrant
is re-detected on the next evaluation because `matched` only ever records what genuinely matched.
That is the safe direction, and it is why `partial` is a caller-supplied flag rather than
something inferred in here: the caller (`routes_alerts`) is the only layer that knows whether it
could BUILD a complete context. When it says so, `evaluate()` records the attempt and changes
nothing β updating `matched` from a narrowed evaluation would drop rows out of the remembered set
and then re-announce every one of them as an "entrant" on the next complete pass. That is the
real hazard: not a phantom alert, but a storm of stale ones.
β **EVERY STAMP CARRIES A UTC OFFSET** (DEBT D-18). A browser can only render a timestamp
relatively if it can subtract it from `now`, and a naive container-local stamp is unsubtractable β
it renders VERBATIM or, worse, silently as the reader's own zone. `_now_iso()` is the one place
that is decided.
"""
import datetime as _dt
import core.store as store
#: Alert DEFINITIONS, per tenant: {alert_id: {...}}. Separate from the notifications bucket so a
#: busy inbox never rewrites the definitions (and a definition edit never rewrites the inbox).
ALERTS_KEY = 'alerts'
#: The inbox: {username: [notification, ...]}, newest last.
NOTIFICATIONS_KEY = 'alert_notifications'
#: Per user. An inbox is a WORKING queue, not an archive β the store is a JSON blob read whole on
#: every request, so an unbounded inbox is a payload that grows without limit for a user who
#: never clicks. Oldest READ entries are dropped first; unread ones survive the cap because the
#: whole point is that the user has not seen them yet.
MAX_NOTIFICATIONS = 200
def _now_iso():
"""UTC, WITH the offset β `2026-08-05T09:41:07.123456+00:00`. See the module note (D-18)."""
return _dt.datetime.now(_dt.timezone.utc).isoformat()
def _st(st):
return st if st is not None else store
def list_alerts(user=None, st=None, is_admin=False):
"""Alert definitions this user may see: their own. Admins see all (they support them)."""
try:
recs = _st(st).get(ALERTS_KEY) or {}
except Exception:
return []
out = []
for aid, rec in recs.items():
if not isinstance(rec, dict):
continue
if user and not is_admin and str(rec.get('owner') or '') != str(user):
continue
out.append({**rec, 'id': str(aid)})
return sorted(out, key=lambda r: str(r.get('createdAt') or ''))
def create(alert_id, *, view_id, topic, owner, label='', st=None):
"""Register an alert on a view. Returns the record.
The view is stored BY ID, never by a copy of its filter tree. An alert whose criteria were
snapshotted at creation would silently stop matching the view the moment its owner edited it β
and the user's mental model is "alert me on THIS VIEW", not "on this view as it was in
August".
"""
rec = {'id': str(alert_id), 'viewId': str(view_id), 'topic': str(topic),
'owner': str(owner), 'label': str(label or '')[:160],
'createdAt': _now_iso(), 'matched': [], 'seeded': False,
'lastRunAt': None, 'lastError': None}
def _apply(data):
data[str(alert_id)] = rec
return data
_st(st).update(ALERTS_KEY, _apply, flush='sync')
return rec
def drop_topic(topic, st=None):
"""Delete every alert DEFINITION on a topic (wave 21, item 6a / C3 β a deleted database's
alerts die with it; an alert on a view that no longer exists can only ever error).
Notifications already delivered are left in inboxes: they are history, and history is the
one thing a delete must not rewrite."""
t = str(topic or '')
if not t:
return
def _apply(data):
for aid in [k for k, r in (data or {}).items()
if isinstance(r, dict) and str(r.get('topic') or '') == t]:
data.pop(aid, None)
return data
_st(st).update(ALERTS_KEY, _apply, flush='async')
def delete(alert_id, st=None):
def _apply(data):
data.pop(str(alert_id), None)
return data
_st(st).update(ALERTS_KEY, _apply, flush='sync')
return True
def evaluate(alert_id, matching_pids, *, labels=None, partial=False, st=None):
"""Fold a fresh evaluation of one alert's view into its state; queue any NEW entrants.
`matching_pids` is what the view matches NOW β the caller owns running the filter, because
only it knows the topic's rows and the reader's scope. `partial` says the evaluation could not
answer every leaf; see the module note on why that suppresses everything.
Returns `{'new': [...], 'seeded': bool}` or `{'skipped': 'partial'|'missing'}`.
"""
now_set = {str(p) for p in (matching_pids or ())}
outcome = {}
def _apply(data):
rec = data.get(str(alert_id))
if not isinstance(rec, dict):
outcome['skipped'] = 'missing'
return data
if partial:
# Record the ATTEMPT (so "last checked" is honest) but change nothing else. Writing
# `matched` here would bake a widened set in as truth, and the next COMPLETE
# evaluation would then report every genuinely-absent row as an entrant.
rec['lastRunAt'] = _now_iso()
rec['lastError'] = ('the view could not be fully evaluated (a filter leaf was '
'unanswerable); no alert was raised')
outcome['skipped'] = 'partial'
data[str(alert_id)] = rec
return data
prior = {str(p) for p in (rec.get('matched') or ())}
seeding = not rec.get('seeded')
entrants = [] if seeding else sorted(now_set - prior, key=lambda s: (len(s), s))
rec['matched'] = sorted(now_set, key=lambda s: (len(s), s))
rec['seeded'] = True
rec['lastRunAt'] = _now_iso()
rec['lastError'] = None
data[str(alert_id)] = rec
outcome['new'] = entrants
outcome['seeded'] = seeding
outcome['_rec'] = rec
return data
_st(st).update(ALERTS_KEY, _apply, flush='sync')
if outcome.get('skipped'):
return {'skipped': outcome['skipped']}
rec = outcome.pop('_rec', {}) or {}
if outcome.get('new'):
_queue(rec, outcome['new'], labels or {}, st=st)
return outcome
def _queue(rec, pids, labels, st=None):
"""Append one notification per entrant to the alert owner's inbox."""
at = _now_iso()
items = [{'id': f"{rec.get('id')}:{pid}:{at}",
'alertId': str(rec.get('id')), 'viewId': str(rec.get('viewId')),
'topic': str(rec.get('topic')), 'rowId': str(pid),
'label': str(labels.get(str(pid)) or labels.get(pid) or pid),
'alertLabel': str(rec.get('label') or ''),
'at': at, 'read': False}
for pid in pids]
owner = str(rec.get('owner') or '')
def _apply(data):
inbox = list(data.get(owner) or [])
inbox.extend(items)
if len(inbox) > MAX_NOTIFICATIONS:
# Drop READ entries oldest-first; keep every unread one. A cap that dropped unread
# notifications would silently lose exactly the records the user has not confirmed β
# the one thing this module promises not to do.
unread = [n for n in inbox if not n.get('read')]
read = [n for n in inbox if n.get('read')]
keep_read = read[max(0, len(unread) + len(read) - MAX_NOTIFICATIONS):]
inbox = sorted(unread + keep_read, key=lambda n: str(n.get('at') or ''))
data[owner] = inbox
return data
_st(st).update(NOTIFICATIONS_KEY, _apply, flush='async')
def notify(owner, label, *, topic='automation', key='', row_id='', detail='', st=None):
"""β WAVE 23 (C6) β put ONE notification in a named user's inbox, for a producer that is not
a view-alert evaluation.
The automation engine needs the bell to ring when records arrive at a review gate, and that
is not an alert over a view: there is no filter tree, no `matched` set, no entrant diff. It
reached for `_queue` directly during the wave, which would have made a private function a
cross-module contract β so this is the public door instead, and `_queue` stays the internal
half of `evaluate()`.
The inbox shape is UNCHANGED, deliberately: `AlertsPane` reads one list, and a second row
shape would mean a client that must branch on where a notification came from. `alertId`
carries the producer's key so a click-through can route (W23-W5).
"""
owner = str(owner or '').strip()
if not owner or not label:
return None
at = _now_iso()
item = {'id': f"{key or topic}:{row_id or 'n'}:{at}",
'alertId': str(key or ''), 'viewId': '', 'topic': str(topic),
'rowId': str(row_id or ''), 'label': str(detail or label),
'alertLabel': str(label), 'at': at, 'read': False}
def _apply(data):
box = list(data.get(owner) or [])
box.append(item)
if len(box) > MAX_NOTIFICATIONS:
unread = [n for n in box if not n.get('read')]
read = [n for n in box if n.get('read')]
keep_read = read[max(0, len(unread) + len(read) - MAX_NOTIFICATIONS):]
box = sorted(unread + keep_read, key=lambda n: str(n.get('at') or ''))
data[owner] = box
return data
_st(st).update(NOTIFICATIONS_KEY, _apply, flush='async')
return item
def inbox(user, st=None, limit=100):
"""`{'unread': int, 'items': [...]}` β newest first, for the badge and the pane."""
try:
items = list((_st(st).get(NOTIFICATIONS_KEY) or {}).get(str(user)) or [])
except Exception:
return {'unread': 0, 'items': []}
items = [n for n in items if isinstance(n, dict)]
items.sort(key=lambda n: str(n.get('at') or ''), reverse=True)
return {'unread': sum(1 for n in items if not n.get('read')), 'items': items[:max(0, limit)]}
def mark_read(user, ids, read=True, st=None):
"""Mark specific notifications read/unread. `ids=None` marks every one (mark-all-read)."""
want = None if ids is None else {str(i) for i in ids}
def _apply(data):
inbox_ = [dict(n) for n in (data.get(str(user)) or []) if isinstance(n, dict)]
for n in inbox_:
if want is None or str(n.get('id')) in want:
n['read'] = bool(read)
data[str(user)] = inbox_
return data
_st(st).update(NOTIFICATIONS_KEY, _apply, flush='async')
return inbox(user, st=st)
def after_write(topic_key, st=None, runner=None):
"""The write-path HOOK: re-evaluate every alert whose view belongs to `topic_key`.
β CALLED FROM A WRITE PATH, SO IT MUST NOT RAISE AND MUST NOT BLOCK ON A FULL POOL BUILD.
A failed alert evaluation must never fail the edit that triggered it β the user typed into a
cell; whether an alert fires is not their problem. `runner` is injected by the caller
(`routes_alerts` passes one that can resolve the topic's rows for the alert's owner), so this
module never reaches for a pool itself and stays unit-testable with a fake.
"""
if runner is None:
return {'evaluated': 0}
n = 0
for rec in list_alerts(st=st):
if str(rec.get('topic')) != str(topic_key):
continue
try:
runner(rec)
n += 1
except Exception: # noqa: BLE001 β see the docstring
continue
return {'evaluated': n}
|