loopable / platform /modules /digest.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
bf8519f verified
Raw
History Blame Contribute Delete
9.65 kB
"""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(), {})