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