File size: 9,648 Bytes
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Daily agent digest β€” "Your book this morning": one email per user, their queue only.

Delivery reuses the ONE sanctioned Odoo write path (modules/collections_send.py: mail.mail
create through the Office 365 relay) including its SAFE_MODE allow-list β€” while the guardrail
is ON no digest can leave the allow-list. Content rules (adoption canon, engagement-adoption
brief): ≀10 lines, only non-empty sections, every line deep-links back to the exact row
(?src=digest), and the email is SKIPPED entirely when there is nothing to act on β€” silence
keeps opens high. Internal mail: no model/res_id (these must NOT land in customer chatter).

Scheduling: app.py starts one daemon ticker (in the cache_resource singleton) that calls
run_due() every few minutes; run_due sends at most once per (weekday, user) after
DIGEST_UTC_HOUR (default 11:00 UTC = 7am ET in summer), idempotent across Space restarts via
the store key 'digest_log' (empty-skips are logged too, so a quiet day isn't rebuilt all day).

BU isolation carries into email: a single-BU user's digest is scoped to that BU's team_id.
"""
import os
import html as _html
import datetime as dt

import core.store as store
import core.users as users
import core.links as links
import modules.collections_send as cs
import modules.customer_data as md   # call-list / win-back queues (ex-myday, ex-customer_list)
import modules.tasks as tasks
import modules.customers as cust

KEY = 'digest_log'
UTC_HOUR = int(os.environ.get('DIGEST_UTC_HOUR', '11'))
WEEKDAYS_ONLY = os.environ.get('DIGEST_WEEKDAYS_ONLY', '1').strip().lower() \
    not in ('0', 'false', 'no', '')
ENABLED = os.environ.get('DIGEST_ENABLED', '1').strip().lower() not in ('0', 'false', 'no', '')

_NAVY, _GOLD, _MUTED = '#5B8FD9', '#D9A93A', '#6B7280'


def _team_for(user):
    """Single-BU users get a digest scoped to that BU β€” isolation carries into email."""
    bus = (user or {}).get('bus', 'all')
    if isinstance(bus, (list, tuple)) and len(bus) == 1:
        return bus[0]
    return None


def _m(x):
    try:
        return f"${float(x):,.0f}"
    except (TypeError, ValueError):
        return 'β€”'


def _line(text, href, meta=''):
    t = _html.escape(str(text))
    m = f" <span style='color:{_MUTED}'>{_html.escape(str(meta))}</span>" if meta else ''
    return (f"<tr><td style='padding:6px 0;border-bottom:1px solid #ECEFF3;font-size:14px'>"
            f"<a href='{href}' style='color:{_NAVY};text-decoration:none;font-weight:600'>{t}</a>"
            f"{m}</td></tr>")


def _sec(title):
    return (f"<tr><td style='padding:14px 0 4px;font-size:12.5px;font-weight:700;"
            f"color:{_MUTED}'>{_html.escape(title)}</td></tr>")


def build(user, t=None):
    """(subject, html, n_actionable) for one user. n_actionable == 0 β†’ skip the send."""
    t = t or dt.date.today()
    uname = user.get('username', '')
    agent = user.get('agent')
    team_id = _team_for(user)
    pids = md.agent_pids(agent)

    today = t.isoformat()
    my = [x for x in tasks.for_owner(uname)] if store.available() else []
    due = [x for x in my if (x.get('due') or '9999-12-31') <= today]
    q = md.queues(agent, team_id, limit=25)
    calls, risk = q['calls'][:5], q['risk'][:3]

    # yesterday's orders for the book (one read_group; skip silently on a transport error β€”
    # the digest must never fail because one section couldn't be pulled)
    yday = (t - dt.timedelta(days=1)).isoformat()
    y_rev = y_orders = 0
    try:
        rev_map = cust._cust_rev(yday, yday, team_id, pids)
        y_rev = sum(v.get('rev', 0.0) for v in rev_map.values())
        y_orders = sum(v.get('orders', 0) for v in rev_map.values())
    except Exception:
        pass

    n = len(due) + len(calls) + len(risk)
    if n == 0 and y_orders == 0:
        return None, None, 0

    rows = []
    if due:
        rows.append(_sec(f'Tasks due ({len(due)})'))
        for x in due[:5]:
            ent = x.get('entity') or {}
            href = links.deeplink('customer_data', src='digest')
            if ent.get('kind') and ent.get('id') is not None:
                href = links.deeplink('customer_data', ent['kind'], ent['id'], src='digest')
            meta = f"due {x.get('due') or 'β€”'}"
            rows.append(_line(x.get('title', 'Task'), href, meta))
    if calls:
        rows.append(_sec('Call today β€” overdue vs their own cadence'))
        for r in calls:
            href = links.deeplink('customer_data', 'customer', r['pid'], src='digest')
            meta = (f"{int(r.get('overdue_days') or 0)}d past cycle Β· "
                    f"est. missed {_m(r.get('est_missed'))}")
            rows.append(_line(r.get('customer', '?'), href, meta))
    if risk:
        rows.append(_sec('Win-back β€” down vs last year'))
        for r in risk:
            href = links.deeplink('customer_data', 'customer', r['pid'], src='digest')
            meta = f"{_m(r.get('at_risk'))} at risk Β· {r.get('status', '')}"
            rows.append(_line(r.get('customer', '?'), href, meta))
    if y_orders:
        rows.append(_sec('Yesterday'))
        # Wave 16: the Sales page is retired (registry row archived) β€” yesterday's orders now
        # link to the Customer grid, the surface that carries the book. A link to an archived
        # page would fall through to the landing and read as a broken link.
        rows.append(_line(f"{y_orders} orders Β· {_m(y_rev)}",
                          links.deeplink('customer_data', src='digest')))

    scope = f"{agent}'s book" if agent else 'the whole book'
    list_url = links.deeplink('customer_data', src='digest')
    html_body = f"""
<div style='font-family:Calibri,Arial,sans-serif;color:#1A2332;max-width:600px;margin:0 auto'>
  <div style='border-top:3px solid {_GOLD};padding:16px 0 4px'>
    <div style='font-size:18px;font-weight:700;color:{_NAVY}'>Your book this morning</div>
    <div style='font-size:12px;color:{_MUTED}'>{t.strftime('%A, %B %d')} Β· {scope}</div>
  </div>
  <table style='width:100%;border-collapse:collapse'>{''.join(rows)}</table>
  <div style='padding:16px 0;font-size:13px'>
    <a href='{list_url}' style='color:{_NAVY};font-weight:700'>Open your Customer List β†’</a>
  </div>
  <div style='font-size:11px;color:{_MUTED};border-top:1px solid #ECEFF3;padding-top:8px'>
    Internal daily digest from Loopable.
    Sent only on days with something to act on.
  </div>
</div>"""
    subject = f"Your book this morning β€” {n} to act on Β· {t.strftime('%b %d')}"
    return subject, html_body, n


def send_one(user, odoo=None, override_to=None):
    """Build + queue one digest. Returns a short result string (never raises past itself)."""
    to = override_to or user.get('email')
    if not to:
        return 'no-email'
    subject, body, n = build(user)
    if n == 0:
        return 'empty-skip'
    if not cs.safe_recipient_ok(to):
        return 'safe-mode-blocked'
    odoo = odoo or cs.Odoo()
    mid = odoo.queue_mail({
        'subject': subject, 'body_html': body,
        'email_to': to, 'email_from': cs.SENDER_DISPLAY, 'reply_to': cs.REPLY_TO,
        'mail_server_id': cs.ROYAL_MAIL_SERVER_ID, 'author_id': cs.ROYAL_AUTHOR_ID,
        'state': 'outgoing', 'auto_delete': False,
        # This Odoo runs the custom rt_multi_dba module whose mail-create hook browses
        # env[mail.model].browse(mail.res_id) β€” a mail WITHOUT model/res_id crashes with
        # KeyError: False (verified 2026-07-05). Anchor internal mail on the house partner
        # (Royal Imports, cs.ROYAL_AUTHOR_ID) so nothing lands in a CUSTOMER's chatter.
        'model': 'res.partner', 'res_id': cs.ROYAL_AUTHOR_ID,
    })
    return f'queued:{mid}'


def run_due(force_user=None, override_to=None):
    """The ticker entry point. Normal path: after UTC_HOUR on weekdays, send each active
    emailed user their digest at most once per day (dedup via digest_log, including
    empty-skips). force_user bypasses the schedule/dedup (the 'Send my digest now' button)
    but NEVER the SAFE_MODE guardrail; forced sends are not logged (the 7am run still runs)."""
    if not store.available():
        return {}
    now = dt.datetime.utcnow()
    today = now.date().isoformat()
    if force_user is None:
        if not ENABLED:
            return {}
        if WEEKDAYS_ONLY and now.weekday() >= 5:
            return {}
        if now.hour < UTC_HOUR:
            return {}
    try:
        reg = users.registry()
    except Exception:
        return {}
    already = (store.get(KEY) or {}).get(today, {}) if force_user is None else {}
    results = {}
    odoo = None
    for un, u in sorted(reg.items()):
        if force_user is not None and un != force_user:
            continue
        if not u.get('active', True) or not u.get('email'):
            continue
        if force_user is None and un in already:
            continue
        pub = users._public(un, u)
        try:
            if odoo is None:
                odoo = cs.Odoo()
            results[un] = send_one(pub, odoo, override_to=override_to)
        except cs.SafeModeBlocked:
            results[un] = 'safe-mode-blocked'
        except Exception as e:
            results[un] = f'error:{str(e)[:80]}'
    if force_user is None and results:
        stamp = now.strftime('%H:%M')

        def _rec(d):
            day = d.setdefault(today, {})
            for un, r in results.items():
                day[un] = {'at': stamp, 'result': r}
            return d
        try:
            store.update(KEY, _rec)
        except Exception:
            pass
    return results


def today_log():
    return (store.get(KEY) or {}).get(dt.date.today().isoformat(), {})