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}