import re from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import HTMLResponse, Response from sqlalchemy.orm import Session from ..audit import record from ..config import settings from ..db import get_db from ..models import BudgetYear, Site, User from ..reports import excel, render from ..security import current_user from ..summary import build_year_view from ..template import build_template from .years import get_year router = APIRouter(prefix="/api", tags=["reports"]) XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" KINDS = ("cost-report", "budget") FORMATS = ("html", "pdf", "xlsx") MIME = {"html": "text/html; charset=utf-8", "pdf": "application/pdf", "xlsx": XLSX} def _filename(kind: str, year: int, dept: str, site: str | None, ext: str) -> str: stem = {"cost-report": "Capex_e_Custos", "budget": "Budget"}[kind] clean = lambda text: re.sub(r"[^A-Za-z0-9]+", "_", text).strip("_") suffix = f"_{clean(site)}" if site else "" return f"{stem}_{clean(dept)}_{year}{suffix}.{ext}" def disposition(name: str) -> str: """Content-Disposition for any file name (accents included).""" from urllib.parse import quote ascii_name = name.encode("ascii", "replace").decode().replace("?", "_").replace('"', "") return f"attachment; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(name)}" def report_lang(lang: str | None, user: User) -> str: return lang if lang in ("pt", "en") else (user.language or settings.default_language) def render_report(db: Session, year: BudgetYear, kind: str, fmt: str, lang: str, site_id: int | None = None, include_empty: bool = False, screen: bool = False) -> tuple[bytes, str]: """The report file and its download name. Shared by the download links and by email.""" site = db.get(Site, site_id).name if site_id and db.get(Site, site_id) else None view = build_year_view(db, year, site_id if site else None) name = _filename(kind, year.year, year.department.name, site, fmt) if fmt == "xlsx": data = (excel.cost_report_xlsx(view, lang, site) if kind == "cost-report" else excel.budget_xlsx(view, lang, site, include_empty=True)) return data, name html = (render.cost_report_html(view, lang, site, screen=screen) if kind == "cost-report" else render.budget_html(view, lang, site, include_empty)) if fmt == "html": return html.encode(), name return render.to_pdf(html), name @router.get("/years/{year_id}/reports/{kind}.{fmt}") def report(year_id: int, kind: str, fmt: str, lang: str | None = None, site_id: int | None = None, include_empty: bool = False, db: Session = Depends(get_db), user: User = Depends(current_user)): if kind not in KINDS or fmt not in FORMATS: raise HTTPException(404, "not_found") year = get_year(db, year_id) data, name = render_report(db, year, kind, fmt, report_lang(lang, user), site_id, include_empty, screen=(fmt == "html")) record(db, user, "report", year.id, "view" if fmt == "html" else "download", f"{year.department.name} {year.year} · {kind}.{fmt}", year_id=year.id) db.commit() if fmt == "html": return HTMLResponse(data.decode()) return Response(data, media_type=MIME[fmt], headers={"Content-Disposition": f'attachment; filename="{name}"'}) def workbook_file(db: Session, year: BudgetYear, lang: str) -> tuple[bytes, str]: lines = [lv for cv in build_year_view(db, year).categories for lv in cv.lines] data = build_template(db, year.department, lang, year.year, year.default_rate or 0, lines) clean = re.sub(r"[^A-Za-z0-9]+", "_", year.department.name).strip("_") return data, year.source_file or f"{'Orcamento' if lang == 'pt' else 'Budget'}_{clean}_{year.year}.xlsx" @router.get("/years/{year_id}/workbook.xlsx") def budget_workbook(year_id: int, lang: str | None = None, db: Session = Depends(get_db), user: User = Depends(current_user)): """The budget as an editable Excel template, ready to be changed and uploaded again.""" year = get_year(db, year_id) data, name = workbook_file(db, year, report_lang(lang, user)) record(db, user, "year", year.id, "download", f"{year.department.name} {year.year} · {name}", year_id=year.id) db.commit() return Response(data, media_type=XLSX, headers={"Content-Disposition": disposition(name)})