portal: budget notices gain mode=test (email one sample, no ledger row)
Lets the letter be read in a mail client before the sender is armed. Uses the member's real numbers when the address matches one, writes nothing to budget_notices so the once-per-crossing latch is not consumed, and works while BUDGET_NOTICES is off.
This commit is contained in:
1 parent
c21421a89b
commit
b998d8d535
1 file changed
+44
-5
+44
-5
@@ -4171,16 +4171,55 @@ async def budget_notice_sweep(send: bool = False) -> dict:
|
||||
"would_notify": len(preview), "notices": preview}
|
||||
|
||||
|
||||
async def budget_notice_test(dest: str) -> dict:
|
||||
"""Email one sample low-balance letter to `dest`, using that member's real numbers.
|
||||
|
||||
Deliberately does NOT write to budget_notices: a test must not consume the real
|
||||
once-per-crossing latch, or reviewing the copy would silence the actual notice.
|
||||
Works while BUDGET_NOTICES is off, so copy can be reviewed before arming anything.
|
||||
"""
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT slug FROM members WHERE email = ?", (dest,)).fetchone()
|
||||
name = ((row[0] or "").strip() if row else "") or "there"
|
||||
conn.close()
|
||||
|
||||
budget, spend = 15.0, 13.34 # sample numbers if they aren't a member
|
||||
if row:
|
||||
team_id = await litellm_team_id_for_email(dest)
|
||||
stats = (get_team_budget_from_db(team_id) or {}) if team_id else {}
|
||||
if stats.get("max_budget"):
|
||||
budget = float(stats["max_budget"])
|
||||
spend = float(stats.get("spend") or 0.0)
|
||||
|
||||
remaining = budget - spend
|
||||
subject, text_body, html_body = budget_notice_email(
|
||||
name, spend, budget, remaining, credit_pack_url()
|
||||
)
|
||||
ok = send_email(dest, subject, text_body, html_body)
|
||||
return {
|
||||
"mode": "test", "to": dest, "sent": bool(ok), "subject": subject,
|
||||
"rendered": {"name": name, "spend": round(spend, 2), "budget": round(budget, 2),
|
||||
"remaining": round(remaining, 2)},
|
||||
"note": "no ledger row written — the real once-per-crossing latch is untouched",
|
||||
}
|
||||
|
||||
|
||||
@app.post("/admin/budget-notices")
|
||||
@limiter.limit("10/minute")
|
||||
async def admin_budget_notices(request: Request, mode: str = "dryrun"):
|
||||
"""Preview (default) or send the monthly budget notices.
|
||||
async def admin_budget_notices(request: Request, mode: str = "dryrun", to: str | None = None):
|
||||
"""Preview (default), send, or email a single sample.
|
||||
|
||||
Authenticated by X-Admin-Token. `mode=dryrun` never emails; `mode=send`
|
||||
requires BUDGET_NOTICES=on and refuses otherwise, so a preview can be run at
|
||||
any time without arming the sender.
|
||||
Authenticated by X-Admin-Token. `mode=dryrun` never emails; `mode=send` requires
|
||||
BUDGET_NOTICES=on and refuses otherwise; `mode=test&to=<address>` emails one sample
|
||||
using that member's real numbers so the copy can be read in a mail client first.
|
||||
A test works while the flag is off and leaves no trace in the ledger.
|
||||
"""
|
||||
_verify_admin_token(request)
|
||||
if mode == "test":
|
||||
dest = (to or "").strip()
|
||||
if not dest:
|
||||
return {"refused": "mode=test needs &to=<address>"}
|
||||
return await budget_notice_test(dest)
|
||||
if mode == "send":
|
||||
if budget_notices_enabled() != "on":
|
||||
return {"refused": f"BUDGET_NOTICES is '{budget_notices_enabled()}', not 'on'"
|
||||
|
||||
Reference in new issue
Block a user