| """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") |
|
|
| |
| |
| _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, |
| |
| "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): |
| |
| |
| 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"): |
| |
| |
| 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: |
| |
| |
| failed.append({"customer": name, "error": str(e)[:200]}) |
| return {"sent": sent, "failed": failed, "skipped": skipped, |
| "safeMode": bool(cs.SAFE_MODE), "test": bool(override)} |
|
|