loopable / api /routes_statements.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
62d663e verified
Raw
History Blame Contribute Delete
8.13 kB
"""routes_statements.py — the statement-of-account sender, ported off Streamlit (EXIT-6).
⛔ THIS IS THE ONE SANCTIONED ODOO WRITER IN THE ENTIRE SYSTEM. Everything else in AIOS is
read-only on Odoo by hard block. `modules/collections_send.py` owns a narrow client that whitelists
exactly `mail.mail create` (queueing an outbound email) and nothing else; these routes call it and
add no write of their own.
WHY IT EXISTS SEPARATELY FROM THE COLLECTIONS PAGE. Owner ruling wave-17 item 15 retired the
Collections *dashboard* — the worklist is the shared "Collections" view on the Customer grid, from
the same reconciled blocks. What that ruling explicitly left "untouched" is this send workflow, so
when `app.py` is deleted it is the ONLY live Streamlit-only feature, and it moves here rather than
dying with the host. Ported faithfully: same tiers, same filters, same template placeholders, same
preview, same test-send, same two-step confirm.
THE THREE GUARDRAILS, and where each is enforced:
1. SAFE_MODE (default ON) — in the DATA LAYER (`collections_send.queue_statement`), so no route,
payload or UI can bypass it. These routes only REPORT it; they never re-implement the check.
2. Admin only — `admin_gate` (role, fail-closed), mirroring the Streamlit `if not is_admin()`.
3. ⭐ TENANT — NEW HERE, and it did not exist in Streamlit because it could not. `cs_mod.Odoo()`
reads Odoo credentials from the ENVIRONMENT, which after the keychain cutover belongs to
TENANT #0 ALONE. On the multi-tenant API an unguarded route would let a nurilab or gtmlab
admin queue mail from Royal Imports' Odoo, as Royal Imports. `_royal_only` closes that; the
single-tenant Streamlit host never had the exposure, so this is a port that must ADD a wall
rather than copy one.
NOTHING SENDS ON A GET. The send route requires an explicit customer list in the body; there is no
"send all" parameter, deliberately — the confirm step is a product requirement, not a formality.
"""
from fastapi import APIRouter, Body, Depends
from deps import Session, err
from routes_admin import admin_gate
router = APIRouter(prefix="/api/v1")
#: Cache the Odoo follow-up pull briefly. The Streamlit page used `@st.cache_data(ttl=1800)`; the
#: list moves slowly (it is a dunning worklist, not a live feed) and the pull is a multi-model read.
_TTL = 1800
_cache = {"at": 0.0, "rows": None}
def _cs():
import modules.collections_send as cs
return cs
def _royal_only(session: Session) -> Session:
"""⛔ See the module docstring, guardrail 3. The send client is env-credentialed, so it is
tenant #0's and only tenant #0's. Refuse for anyone else rather than send as the wrong company.
Keyed on the runtime, never on a request field: a tenant is a property of the SESSION."""
if getattr(session.runtime, "key", None) != "royal-imports":
raise err(404, "not_found", "statements are not configured for this workspace")
return session
def _gate(session: Session = Depends(admin_gate)) -> Session:
return _royal_only(session)
def _rows(force=False):
import time
cs = _cs()
if force or _cache["rows"] is None or (time.time() - _cache["at"]) > _TTL:
_cache["rows"] = cs.load_collection_list(cs.Odoo())
_cache["at"] = time.time()
return _cache["rows"], time.strftime("%Y-%m-%d %H:%M", time.localtime(_cache["at"]))
def _public(row):
"""Strip the internals the Streamlit grid also hid (`_`-prefixed + partner_id is kept, because
the client needs a stable row identity that is not the display name)."""
return {k: v for k, v in row.items() if not str(k).startswith("_")}
@router.get("/admin/statements")
def statements(refresh: int = 0, session: Session = Depends(_gate)):
"""The worklist + everything the sender UI needs to render itself honestly."""
cs = _cs()
try:
rows, loaded_at = _rows(force=bool(refresh))
except Exception as e:
raise err(502, "odoo_unavailable", f"could not load the collection list: {str(e)[:200]}")
return {
"rows": [_public(r) for r in rows],
"loadedAt": loaded_at,
# The guardrail is reported, never decided, here — the data layer owns it.
"safeMode": bool(cs.SAFE_MODE),
"safeRecipients": sorted(cs.SAFE_RECIPIENTS),
"sender": {"name": cs.SENDER_NAME, "email": cs.SENDER_EMAIL,
"replyTo": cs.REPLY_TO, "company": cs.COMPANY},
"templates": {"subject": cs.DEFAULT_SUBJECT, "intro": cs.DEFAULT_INTRO,
"footer": cs.DEFAULT_FOOTER},
"tiers": ["A-Urgent", "B-Active", "C-Light", "Monitor"],
}
def _find(rows, customer):
return next((r for r in rows if r.get("Customer") == customer), None)
@router.post("/admin/statements/preview")
def preview(body: dict = Body(default=None), session: Session = Depends(_gate)):
"""Render ONE customer's statement exactly as the send path would."""
cs = _cs()
body = body or {}
rows, _ = _rows()
row = _find(rows, body.get("customer"))
if row is None:
raise err(404, "not_found", "no such customer on the collection list")
t = body.get("templates") or {}
import datetime as dt
month = dt.date.today().strftime("%B %Y")
subject = (t.get("subject") or cs.DEFAULT_SUBJECT)
try:
subject = subject.format(customer=row["Customer"], company=cs.COMPANY, month=month)
except (KeyError, IndexError):
# An unknown placeholder is the user's typo, not a 500. Show the template verbatim so they
# can see what they typed rather than getting an opaque error.
pass
return {
"html": cs.render_statement_html(row, t.get("intro") or cs.DEFAULT_INTRO,
t.get("footer") or cs.DEFAULT_FOOTER),
"to": row.get("Email") or "",
"subject": subject,
}
@router.post("/admin/statements/send")
def send(body: dict = Body(default=None), session: Session = Depends(_gate)):
"""Queue statements. Returns per-customer outcomes — NEVER a bare count.
`overrideTo` is the test-send path: one customer, one address. SAFE_MODE still applies (the
data layer refuses an address outside the allow-list), which is why the route does not check it.
"""
cs = _cs()
body = body or {}
names = [str(n) for n in (body.get("customers") or []) if str(n).strip()]
if not names:
raise err(400, "bad_request", "name at least one customer")
override = (body.get("overrideTo") or "").strip() or None
if override and len(names) != 1:
raise err(400, "bad_request", "a test send takes exactly one customer")
t = body.get("templates") or {}
rows, _ = _rows()
sent, failed, skipped = [], [], []
for name in names:
row = _find(rows, name)
if row is None:
failed.append({"customer": name, "error": "not on the current collection list"})
continue
if not override and not row.get("Email"):
# The Streamlit page warned and skipped these. Reporting them SEPARATELY from failures
# keeps "we could not" distinct from "there was nowhere to send".
skipped.append({"customer": name, "reason": "no email address on the customer record"})
continue
try:
mid = cs.queue_statement(cs.Odoo(), row, t.get("subject") or cs.DEFAULT_SUBJECT,
t.get("intro") or cs.DEFAULT_INTRO,
t.get("footer") or cs.DEFAULT_FOOTER, override_to=override)
sent.append({"customer": name, "to": override or row.get("Email"), "mailId": mid})
except Exception as e:
# SafeModeBlocked lands here too, and that is correct: to the caller a guardrail refusal
# and an Odoo error are both "this one did not go", each with its own honest message.
failed.append({"customer": name, "error": str(e)[:200]})
return {"sent": sent, "failed": failed, "skipped": skipped,
"safeMode": bool(cs.SAFE_MODE), "test": bool(override)}