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(), {})
|