File size: 8,129 Bytes
62d663e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""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)}