Flatten budget-app contents into repo root
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
"""Mail account settings and the mail log (administrators), and sending reports by email (everyone)."""
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import String, cast, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import mailer
|
||||
from ..audit import diff, record, snapshot
|
||||
from ..db import get_db
|
||||
from ..models import MailLog, MailSettings, User
|
||||
from ..security import current_user, require
|
||||
from .reports import FORMATS, KINDS, MIME, render_report, report_lang, workbook_file
|
||||
from .years import get_year
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["mail"])
|
||||
|
||||
PLAIN_FIELDS = ["enabled", "smtp_host", "smtp_port", "smtp_security", "smtp_username", "from_address", "from_name",
|
||||
"reply_to", "signature", "save_to_sent", "imap_host", "imap_port", "imap_security",
|
||||
"imap_same_login", "imap_username", "sent_folder"]
|
||||
SECURITY = ("starttls", "ssl", "none")
|
||||
MAX_ATTACHMENTS_BYTES = 20 * 1024 * 1024
|
||||
|
||||
|
||||
def settings_out(cfg: MailSettings) -> dict:
|
||||
out = {f: getattr(cfg, f) for f in PLAIN_FIELDS}
|
||||
out.update(smtp_password_set=bool(cfg.smtp_password), imap_password_set=bool(cfg.imap_password),
|
||||
ready=mailer.is_ready(cfg), updated_at=cfg.updated_at.isoformat() if cfg.updated_at else None,
|
||||
updated_by=cfg.updated_by.name if cfg.updated_by else None)
|
||||
return out
|
||||
|
||||
|
||||
class MailSettingsIn(BaseModel):
|
||||
enabled: bool | None = None
|
||||
smtp_host: str | None = Field(default=None, max_length=255)
|
||||
smtp_port: int | None = Field(default=None, ge=1, le=65535)
|
||||
smtp_security: str | None = None
|
||||
smtp_username: str | None = Field(default=None, max_length=255)
|
||||
smtp_password: str | None = Field(default=None, max_length=500) # empty or missing: keep the saved one
|
||||
from_address: str | None = Field(default=None, max_length=255)
|
||||
from_name: str | None = Field(default=None, max_length=255)
|
||||
reply_to: str | None = Field(default=None, max_length=255)
|
||||
signature: str | None = Field(default=None, max_length=4000)
|
||||
save_to_sent: bool | None = None
|
||||
imap_host: str | None = Field(default=None, max_length=255)
|
||||
imap_port: int | None = Field(default=None, ge=1, le=65535)
|
||||
imap_security: str | None = None
|
||||
imap_same_login: bool | None = None
|
||||
imap_username: str | None = Field(default=None, max_length=255)
|
||||
imap_password: str | None = Field(default=None, max_length=500)
|
||||
sent_folder: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
@router.get("/mail/settings")
|
||||
def read_settings(db: Session = Depends(get_db), _: User = Depends(require("admin"))):
|
||||
cfg = mailer.get_settings(db)
|
||||
db.commit()
|
||||
return settings_out(cfg)
|
||||
|
||||
|
||||
@router.put("/mail/settings")
|
||||
def save_settings(body: MailSettingsIn, db: Session = Depends(get_db), admin: User = Depends(require("admin"))):
|
||||
cfg = mailer.get_settings(db)
|
||||
before = snapshot(cfg, PLAIN_FIELDS)
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
for key in ("smtp_security", "imap_security"):
|
||||
if key in data and data[key] not in SECURITY:
|
||||
raise HTTPException(400, "invalid_security")
|
||||
for key in ("from_address", "reply_to"):
|
||||
if data.get(key):
|
||||
try:
|
||||
mailer.clean_addresses([data[key]])
|
||||
except mailer.MailError:
|
||||
raise HTTPException(400, "invalid_address")
|
||||
for key in PLAIN_FIELDS:
|
||||
if key in data and data[key] is not None:
|
||||
setattr(cfg, key, data[key].strip() if isinstance(data[key], str) and key != "signature" else data[key])
|
||||
changed_passwords = []
|
||||
for key in ("smtp_password", "imap_password"):
|
||||
if data.get(key):
|
||||
setattr(cfg, key, mailer.encrypt(data[key]))
|
||||
changed_passwords.append(key)
|
||||
if cfg.enabled and not (cfg.smtp_host and cfg.from_address):
|
||||
raise HTTPException(400, "mail_incomplete")
|
||||
cfg.updated_at, cfg.updated_by_id = datetime.now(timezone.utc), admin.id
|
||||
changes = diff(before, snapshot(cfg, PLAIN_FIELDS))
|
||||
for key in changed_passwords:
|
||||
changes[key] = ["•••", "••• (new)"]
|
||||
record(db, admin, "mail_settings", 1, "update", ", ".join(changes) or "no changes", changes)
|
||||
db.commit()
|
||||
return settings_out(cfg)
|
||||
|
||||
|
||||
class TestIn(BaseModel):
|
||||
to: str | None = None
|
||||
|
||||
|
||||
@router.post("/mail/test")
|
||||
def test_mail(body: TestIn, db: Session = Depends(get_db), admin: User = Depends(require("admin"))):
|
||||
"""Without an address: sign in only. With one: also send a short test message (logged)."""
|
||||
cfg = mailer.get_settings(db)
|
||||
if not (cfg.smtp_host and cfg.from_address):
|
||||
raise HTTPException(400, "mail_incomplete")
|
||||
if not body.to:
|
||||
result = mailer.check(cfg)
|
||||
record(db, admin, "mail_settings", 1, "check", f"smtp {result['smtp']}; imap {result['imap']}")
|
||||
db.commit()
|
||||
return result
|
||||
try:
|
||||
to = mailer.clean_addresses([body.to])
|
||||
except mailer.MailError as exc:
|
||||
raise HTTPException(400, f"invalid_address:{exc.detail}")
|
||||
log = mailer.send(db, admin, cfg, to=to, cc=[], kind="test", context="test",
|
||||
subject="Budget & Capex — test", attachments=[],
|
||||
body="Mensagem de teste enviada pela aplicação Orçamento & Capex.\n"
|
||||
"Test message sent by the Budget & Capex app.")
|
||||
return log_out(log)
|
||||
|
||||
|
||||
@router.get("/mail/status")
|
||||
def mail_status(db: Session = Depends(get_db), _: User = Depends(current_user)):
|
||||
cfg = db.get(MailSettings, 1)
|
||||
return {"enabled": bool(cfg and mailer.is_ready(cfg)), "from": cfg.from_address if cfg else "",
|
||||
"signature": cfg.signature if cfg else ""}
|
||||
|
||||
|
||||
class ReportRef(BaseModel):
|
||||
kind: str
|
||||
fmt: str
|
||||
|
||||
|
||||
class EmailIn(BaseModel):
|
||||
to: list[str] = Field(default_factory=list)
|
||||
cc: list[str] = Field(default_factory=list)
|
||||
subject: str = Field(default="", max_length=300)
|
||||
message: str = Field(default="", max_length=10000)
|
||||
reports: list[ReportRef] = Field(default_factory=list)
|
||||
lang: str | None = None
|
||||
site_id: int | None = None
|
||||
include_empty: bool = False
|
||||
copy_me: bool = False
|
||||
|
||||
|
||||
@router.post("/years/{year_id}/email")
|
||||
def email_reports(year_id: int, body: EmailIn, db: Session = Depends(get_db), user: User = Depends(current_user)):
|
||||
cfg = db.get(MailSettings, 1)
|
||||
if not cfg or not mailer.is_ready(cfg):
|
||||
raise HTTPException(409, "mail_not_configured")
|
||||
year = get_year(db, year_id)
|
||||
try:
|
||||
to = mailer.clean_addresses(body.to)
|
||||
cc = mailer.clean_addresses(body.cc + ([user.email] if body.copy_me else []))
|
||||
except mailer.MailError as exc:
|
||||
raise HTTPException(400, f"invalid_address:{exc.detail}")
|
||||
cc = [a for a in cc if a.lower() not in {t.lower() for t in to}]
|
||||
if not to:
|
||||
raise HTTPException(400, "no_recipients")
|
||||
if len(to) + len(cc) > mailer.MAX_RECIPIENTS:
|
||||
raise HTTPException(400, "too_many_recipients")
|
||||
refs = {(r.kind, r.fmt) for r in body.reports}
|
||||
if not refs or any(not ((k in KINDS and f in FORMATS and f != "html") or (k, f) == ("workbook", "xlsx"))
|
||||
for k, f in refs):
|
||||
raise HTTPException(400, "no_attachments")
|
||||
lang = report_lang(body.lang, user)
|
||||
attachments = []
|
||||
for kind, fmt in sorted(refs):
|
||||
if kind == "workbook": # the editable budget file
|
||||
data, name = workbook_file(db, year, lang)
|
||||
else:
|
||||
data, name = render_report(db, year, kind, fmt, lang, body.site_id, body.include_empty)
|
||||
attachments.append(mailer.Attachment(name, MIME[fmt], data))
|
||||
if sum(len(a.data) for a in attachments) > MAX_ATTACHMENTS_BYTES:
|
||||
raise HTTPException(400, "file_too_large")
|
||||
subject = body.subject.strip() or f"{year.department.name} {year.year} — {year.title}"
|
||||
log = mailer.send(db, user, cfg, to=to, cc=cc, subject=subject, body=body.message, attachments=attachments,
|
||||
year_id=year.id, context=f"{year.department.name} {year.year}")
|
||||
if log.status != "sent":
|
||||
raise HTTPException(502, log.error.split(":")[0] or "smtp_error")
|
||||
return log_out(log)
|
||||
|
||||
|
||||
def log_out(m: MailLog) -> dict:
|
||||
return {"id": m.id, "at": m.at.isoformat(), "user": m.user.name if m.user else None,
|
||||
"user_email": m.user.email if m.user else None, "kind": m.kind, "year_id": m.year_id,
|
||||
"context": m.context, "sender": m.sender, "to": (m.recipients or {}).get("to", []),
|
||||
"cc": (m.recipients or {}).get("cc", []), "subject": m.subject, "body": m.body,
|
||||
"attachments": m.attachments or [], "status": m.status, "error": m.error,
|
||||
"sent_copy": m.sent_copy, "sent_copy_detail": m.sent_copy_detail, "message_id": m.message_id}
|
||||
|
||||
|
||||
def _log_query(q: str | None, status: str | None, user_id: int | None, date_from: str | None, date_to: str | None):
|
||||
query = select(MailLog)
|
||||
if status:
|
||||
query = query.where(MailLog.status == status)
|
||||
if user_id:
|
||||
query = query.where(MailLog.user_id == user_id)
|
||||
if date_from:
|
||||
query = query.where(MailLog.at >= _date(date_from))
|
||||
if date_to:
|
||||
query = query.where(MailLog.at < _date(date_to, end=True))
|
||||
if q:
|
||||
like = f"%{q.strip().lower()}%"
|
||||
query = query.where(or_(func.lower(MailLog.subject).like(like), func.lower(MailLog.context).like(like),
|
||||
func.lower(cast(MailLog.recipients, String)).like(like)))
|
||||
return query
|
||||
|
||||
|
||||
def _date(text: str, end: bool = False) -> datetime:
|
||||
try:
|
||||
d = datetime.fromisoformat(text[:10]).replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "invalid_date")
|
||||
return d + timedelta(days=1) if end else d
|
||||
|
||||
|
||||
@router.get("/mail/log")
|
||||
def mail_log(q: str | None = None, status: str | None = None, user_id: int | None = None,
|
||||
date_from: str | None = None, date_to: str | None = None, limit: int = 100, offset: int = 0,
|
||||
db: Session = Depends(get_db), _: User = Depends(require("admin"))):
|
||||
query = _log_query(q, status, user_id, date_from, date_to)
|
||||
total = db.scalar(select(func.count()).select_from(query.subquery()))
|
||||
rows = db.scalars(query.order_by(MailLog.at.desc(), MailLog.id.desc())
|
||||
.limit(min(max(limit, 1), 500)).offset(max(offset, 0))).all()
|
||||
return {"total": total, "rows": [log_out(m) for m in rows]}
|
||||
|
||||
|
||||
@router.get("/mail/log.csv")
|
||||
def mail_log_csv(q: str | None = None, status: str | None = None, user_id: int | None = None,
|
||||
date_from: str | None = None, date_to: str | None = None,
|
||||
db: Session = Depends(get_db), _: User = Depends(require("admin"))):
|
||||
rows = db.scalars(_log_query(q, status, user_id, date_from, date_to).order_by(MailLog.at.desc())).all()
|
||||
out = io.StringIO()
|
||||
w = csv.writer(out, delimiter=";")
|
||||
w.writerow(["date", "user", "kind", "budget", "from", "to", "cc", "subject", "attachments", "status", "error",
|
||||
"sent_copy", "sent_copy_detail"])
|
||||
for m in rows:
|
||||
o = log_out(m)
|
||||
w.writerow([o["at"], o["user"], o["kind"], o["context"], o["sender"], ", ".join(o["to"]), ", ".join(o["cc"]),
|
||||
o["subject"], ", ".join(a["name"] for a in o["attachments"]), o["status"], o["error"],
|
||||
o["sent_copy"], o["sent_copy_detail"]])
|
||||
return Response("" + out.getvalue(), media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": 'attachment; filename="mail_log.csv"'})
|
||||
Reference in New Issue
Block a user