| """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 |
|
|
| |
| |
| ALERTS_KEY = 'alerts' |
| |
| NOTIFICATIONS_KEY = 'alert_notifications' |
|
|
| |
| |
| |
| |
| 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: |
| |
| |
| |
| 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: |
| |
| |
| |
| 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: |
| continue |
| return {'evaluated': n} |
|
|