Flatten budget-app contents into repo root

This commit is contained in:
jpmvaz
2026-10-29 20:41:01 +00:00
parent c9fd26f1c7
commit 93f2696764
95 changed files with 184 additions and 187 deletions
View File
+25
View File
@@ -0,0 +1,25 @@
from decimal import Decimal
from sqlalchemy.orm import Session
from .models import AuditEntry, User
def _plain(value):
if isinstance(value, Decimal):
return str(value.normalize()) if value == value.to_integral() else str(value)
return value
def snapshot(obj, fields: list[str]) -> dict:
return {f: _plain(getattr(obj, f)) for f in fields}
def diff(before: dict, after: dict) -> dict:
return {k: [before.get(k), after.get(k)] for k in after if before.get(k) != after.get(k)}
def record(db: Session, user: User | None, entity: str, entity_id: int | None, action: str,
summary: str = "", changes: dict | None = None, year_id: int | None = None) -> None:
db.add(AuditEntry(user_id=user.id if user else None, entity=entity, entity_id=entity_id,
action=action, summary=summary[:500], changes=changes, year_id=year_id))
+38
View File
@@ -0,0 +1,38 @@
"""Run once at container start: creates the first admin and the default categories and sites."""
from sqlalchemy import func, select
from .config import settings
from .db import SessionLocal
from .models import Category, Department, Site, User
from .security import hash_password
from .seed import CATEGORIES, SITES
DEFAULT_DEPARTMENT = "IT"
def seed_categories(db, department: Department) -> None:
for i, (en, pt, section) in enumerate(CATEGORIES):
db.add(Category(department_id=department.id, name_en=en, name_pt=pt, section=section, sort_order=(i + 1) * 10))
def bootstrap() -> None:
with SessionLocal() as db:
if not db.scalar(select(func.count(Department.id))):
dept = Department(name=DEFAULT_DEPARTMENT)
db.add(dept)
db.flush()
seed_categories(db, dept)
if not db.scalar(select(func.count(User.id))):
db.add(User(email=settings.admin_email.lower(), name=settings.admin_name,
password_hash=hash_password(settings.admin_password), role="admin",
language=settings.default_language,
department_id=db.scalar(select(Department.id).order_by(Department.id))))
print(f"Created admin user {settings.admin_email}")
if not db.scalar(select(func.count(Site.id))):
for name in SITES:
db.add(Site(name=name))
db.commit()
if __name__ == "__main__":
bootstrap()
+163
View File
@@ -0,0 +1,163 @@
"""Amount expressions and budget arithmetic. All money math uses Decimal."""
import ast
import operator
from dataclasses import dataclass, field
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
MONTHS = 12
ZERO = Decimal("0")
_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
}
class ExpressionError(ValueError):
pass
def normalize_number_text(text: str) -> str:
"""Accept '1 234,56', '1.234,56' and '1,234.56' as well as plain numbers."""
s = text.replace(" ", "").replace(" ", "").replace("€", "")
if "," in s and "." in s:
if s.rfind(",") > s.rfind("."):
s = s.replace(".", "").replace(",", ".")
else:
s = s.replace(",", "")
elif "," in s:
s = s.replace(",", ".")
return s
def evaluate(expr: str | None) -> Decimal:
"""Evaluate a typed amount such as '=5512*12' or '3292.56+(12*839.67)'."""
if expr is None:
return ZERO
s = str(expr).strip()
if s.startswith("="):
s = s[1:]
if not s:
return ZERO
s = normalize_number_text(s)
if len(s) > 400:
raise ExpressionError("expression too long")
try:
tree = ast.parse(s, mode="eval")
except SyntaxError as exc:
raise ExpressionError(f"invalid expression: {expr}") from exc
def walk(node: ast.AST) -> Decimal:
if isinstance(node, ast.Expression):
return walk(node.body)
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)) and not isinstance(node.value, bool):
return Decimal(str(node.value))
if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
left, right = walk(node.left), walk(node.right)
if isinstance(node.op, ast.Div) and right == 0:
raise ExpressionError("division by zero")
return _OPS[type(node.op)](left, right)
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
value = walk(node.operand)
return -value if isinstance(node.op, ast.USub) else value
raise ExpressionError(f"invalid expression: {expr}")
try:
return walk(tree)
except InvalidOperation as exc:
raise ExpressionError(f"invalid expression: {expr}") from exc
def round2(value: Decimal) -> Decimal:
return Decimal(value).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def factor_for(adj_kind: str, adj_value: Decimal, year_rate: Decimal) -> Decimal:
adj_value = Decimal(adj_value or 0)
if adj_kind == "year_rate":
return Decimal(1) + Decimal(year_rate or 0)
if adj_kind == "percent":
return Decimal(1) + adj_value / Decimal(100)
if adj_kind == "factor":
return adj_value
return Decimal(1)
@dataclass
class LineFigures:
prior: Decimal
base: Decimal
factor: Decimal
adjusted: Decimal
months: list[Decimal]
@property
def change(self) -> Decimal:
return self.adjusted - self.prior
@property
def change_pct(self) -> Decimal | None:
return None if self.prior == 0 else self.change / self.prior * 100
def line_figures(line, year_rate: Decimal) -> LineFigures:
# Each line is rounded to cents before it is summed, as the finance reports do.
prior = round2(Decimal(line.prior_actual or 0))
if line.monthly_kind == "manual":
months = [Decimal(str(v or 0)) for v in (line.monthly_values or [])][:MONTHS]
months += [ZERO] * (MONTHS - len(months))
total = sum(months, ZERO)
return LineFigures(prior, total, Decimal(1), total, months)
base = round2(Decimal(line.base_amount or 0))
factor = factor_for(line.adj_kind, line.adj_value, year_rate)
adjusted = round2(Decimal(line.base_amount or 0) * factor)
if line.monthly_kind == "selected":
return LineFigures(prior, base, factor, adjusted, selected_spread(adjusted, line.monthly_months))
return LineFigures(prior, base, factor, adjusted, even_spread(adjusted))
def even_spread(total: Decimal) -> list[Decimal]:
"""Twelve months in cents; December takes the rounding remainder so the months add up exactly."""
month = round2(total / MONTHS)
return [month] * (MONTHS - 1) + [total - month * (MONTHS - 1)]
def clean_months(months) -> list[int]:
"""Month numbers 1-12, sorted and without repeats."""
return sorted({int(m) for m in (months or []) if 1 <= int(m) <= MONTHS})
def selected_spread(total: Decimal, months) -> list[Decimal]:
"""Equal amounts in cents over the chosen months only (1 = January); the last chosen month takes the
rounding remainder. No months chosen means all twelve."""
chosen = clean_months(months) or list(range(1, MONTHS + 1))
share = round2(total / len(chosen))
out = [ZERO] * MONTHS
for m in chosen:
out[m - 1] = share
out[chosen[-1] - 1] = total - share * (len(chosen) - 1)
return out
@dataclass
class Totals:
prior: Decimal = ZERO
base: Decimal = ZERO
adjusted: Decimal = ZERO
months: list[Decimal] = field(default_factory=lambda: [ZERO] * MONTHS)
def add(self, f: LineFigures) -> None:
self.prior += f.prior
self.base += f.base
self.adjusted += f.adjusted
self.months = [a + b for a, b in zip(self.months, f.months)]
@property
def change(self) -> Decimal:
return self.adjusted - self.prior
@property
def change_pct(self) -> Decimal | None:
return None if self.prior == 0 else self.change / self.prior * 100
+16
View File
@@ -0,0 +1,16 @@
import os
class Settings:
database_url: str = os.getenv("DATABASE_URL", "sqlite:///./budget.db")
secret_key: str = os.getenv("SECRET_KEY", "change-me")
admin_email: str = os.getenv("ADMIN_EMAIL", "admin@example.com")
admin_password: str = os.getenv("ADMIN_PASSWORD", "admin")
admin_name: str = os.getenv("ADMIN_NAME", "Administrator")
default_language: str = os.getenv("DEFAULT_LANGUAGE", "pt")
cookie_secure: bool = os.getenv("COOKIE_SECURE", "false").lower() == "true"
session_hours: int = int(os.getenv("SESSION_HOURS", "12"))
organization: str = os.getenv("ORGANIZATION_NAME", "")
settings = Settings()
+20
View File
@@ -0,0 +1,20 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from .config import settings
_connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
engine = create_engine(settings.database_url, connect_args=_connect_args, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+255
View File
@@ -0,0 +1,255 @@
"""Imports a workbook laid out like 'Budget para 2027.xlsx'.
Sheet1 holds one row per cost line (prior actual, base, adjusted, remarks); the 'Budget IT'
sheet says which category each row belongs to (column P = '=Sheet1!D<row>') and holds the
revenue lines with typed monthly values.
"""
import os
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from decimal import Decimal
from io import BytesIO
from zoneinfo import ZoneInfo
import openpyxl
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .audit import record
from .calc import ExpressionError, evaluate
from .models import BudgetYear, Category, CostLine, Site, User
REF_RE = re.compile(r"^=\s*'?Sheet1'?!\$?D\$?(\d+)\s*$", re.I)
FACTOR_RE = re.compile(r"^=\s*\$?C\$?\d+\s*\*\s*([\d.]+)\s*$", re.I)
ARITH_RE = re.compile(r"^=?[\d\s.+\-*/()]+$")
HEADER_RE = re.compile(r"^(\d{4})\s+(ATUAL|ACTUAL|SEM AJUSTE|COM AJUSTE)", re.I)
# Reasons the budget owner gave for factors that differ from the year's rate.
KNOWN_REASONS = {
"hardware": "Aumento de preço do hardware / Hardware price increase",
}
class ImportErrorWithDetail(ValueError):
pass
@dataclass
class ImportResult:
year: int
lines: int = 0
categories_created: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
def _sheet(wb, *names):
for ws in wb.worksheets:
if ws.title.strip().lower() in [n.lower() for n in names]:
return ws
return None
def _amount(formula_cell, value_cell, warnings: list[str], where: str) -> tuple[str, Decimal]:
"""Keep a pure-arithmetic formula as the typed expression; otherwise take Excel's cached value."""
raw = formula_cell.value
if raw is None or raw == "":
return "0", Decimal("0")
if isinstance(raw, (int, float)):
return str(raw), Decimal(str(raw))
text = str(raw).strip()
if ARITH_RE.match(text):
try:
return text.lstrip("="), evaluate(text)
except ExpressionError:
pass
cached = value_cell.value
if isinstance(cached, (int, float)):
return str(round(cached, 6)), Decimal(str(cached))
warnings.append(f"{where}: could not read '{text}', used 0")
return "0", Decimal("0")
def _category_index(db: Session, department_id: int) -> dict[str, Category]:
cats = db.scalars(select(Category).where(Category.department_id == department_id))
index = {}
for c in cats:
index[c.name_en.strip().lower()] = c
if c.name_pt:
index.setdefault(c.name_pt.strip().lower(), c)
return index
def current_year(db: Session, department_id: int, year: int) -> BudgetYear | None:
return db.scalar(select(BudgetYear).where(BudgetYear.department_id == department_id, BudgetYear.year == year,
BudgetYear.archived.is_(False)))
def archived_name(text: str, when: datetime) -> str:
"""'Orcamento_IT_2027.xlsx' -> 'Orcamento_IT_2027_2026-10-28_2250.xlsx'; titles get ' (2026-10-28 22:50)'."""
if "." in text and text.rsplit(".", 1)[1].lower() in ("xlsx", "xlsm", "xls"):
stem, ext = text.rsplit(".", 1)
return f"{stem}_{when:%Y-%m-%d_%H%M}.{ext}"
return f"{text} ({when:%Y-%m-%d %H:%M})"
def archive_year(db: Session, existing: BudgetYear, user: User | None) -> None:
"""Keep a replaced budget, renamed with the current date and time and locked against changes."""
when = datetime.now(ZoneInfo(os.getenv("TZ") or "Europe/Lisbon"))
old_title, old_file = existing.title, existing.source_file
existing.title = archived_name(existing.title or f"Budget {existing.year}", when)[:200]
if existing.source_file:
existing.source_file = archived_name(existing.source_file, when)[:255]
existing.archived, existing.archived_at = True, datetime.now(timezone.utc)
existing.archived_by_id = user.id if user else None
db.flush()
record(db, user, "year", existing.id, "archive",
f"{existing.department.name} {existing.year}: '{old_title}' → '{existing.title}'"
+ (f" ({old_file} → {existing.source_file})" if old_file else ""), year_id=existing.id)
def prepare_year(db: Session, department_id: int, year: int, rate: Decimal, replace: bool,
user: User | None = None, source_file: str = "") -> BudgetYear:
existing = current_year(db, department_id, year)
if existing and not replace:
raise ImportErrorWithDetail("year_exists")
if existing and existing.status != "draft":
raise ImportErrorWithDetail("year_not_draft")
if existing:
archive_year(db, existing, user)
by = BudgetYear(department_id=department_id, year=year, default_rate=rate, source_file=source_file[:255],
title=existing_title(existing, year), notes=existing.notes if existing else "")
db.add(by)
db.flush()
return by
def existing_title(existing: BudgetYear | None, year: int) -> str:
if existing is None:
return f"Budget {year}"
# the replacement keeps the original title, without the archive date
title = existing.title
return title[: title.rfind(" (")] if title.endswith(")") and " (" in title else title
def import_workbook(db: Session, data: bytes, user: User | None, department_id: int, year: int | None = None,
replace: bool = False, filename: str = "", check_only: bool = False) -> ImportResult:
"""Imports the file into a new budget. With check_only, only reads which year it is for."""
try:
wb_f = openpyxl.load_workbook(BytesIO(data))
wb_v = openpyxl.load_workbook(BytesIO(data), data_only=True)
except Exception as exc: # openpyxl raises several unrelated types for a bad file
raise ImportErrorWithDetail("not_an_xlsx") from exc
from .template import TEMPLATE_SHEETS, import_template
if any(ws.title in TEMPLATE_SHEETS for ws in wb_v.worksheets):
return import_template(db, wb_v, wb_f, user, department_id, year, replace, filename, check_only)
inputs_f, inputs_v = _sheet(wb_f, "Sheet1", "Input", "Inputs"), _sheet(wb_v, "Sheet1", "Input", "Inputs")
budget_f = _sheet(wb_f, "Budget IT", "Budget")
if inputs_f is None or budget_f is None:
raise ImportErrorWithDetail("missing_sheets")
budget_v = wb_v[budget_f.title]
# The target year comes from the header row ("2026 ATUAL | 2027 SEM AJUSTE | 2027 COM AJUSTE").
detected = None
for row in inputs_f.iter_rows(min_row=1, max_row=10):
for cell in row:
m = HEADER_RE.match(str(cell.value or ""))
if m and "AJUSTE" in m.group(2).upper():
detected = int(m.group(1))
target_year = year or detected
if not target_year:
raise ImportErrorWithDetail("year_not_found")
rate = inputs_v["B1"].value if isinstance(inputs_v["B1"].value, (int, float)) else 0
rate = Decimal(str(rate))
result = ImportResult(year=target_year)
if check_only:
return result
by = prepare_year(db, department_id, target_year, rate, replace, user, filename)
cats = _category_index(db, department_id)
next_order = (db.scalar(select(func.max(Category.sort_order)).where(Category.department_id == department_id)) or 0) + 10
sites = list(db.scalars(select(Site)))
def category(name: str) -> Category:
nonlocal next_order
key = name.strip().lower()
if key not in cats:
cat = Category(department_id=department_id,
name_en=name.strip().title() if name.isupper() else name.strip(),
section="revenue" if "revenue" in key else "cost", sort_order=next_order)
next_order += 10
db.add(cat)
db.flush()
cats[key] = cat
result.categories_created.append(cat.name_en)
return cats[key]
def site_for(name: str):
for s in sites:
if re.search(rf"\b{re.escape(s.name)}\b", name, re.I):
return s
return None
# Walk 'Budget IT': headings, then lines that point at Sheet1 or hold typed monthly values.
current = None
order = 0
for r in range(1, budget_f.max_row + 1):
label = budget_f.cell(r, 1).value
p = budget_f.cell(r, 16).value
months_f = [budget_f.cell(r, c).value for c in range(3, 15)]
if not isinstance(label, str) or not label.strip() or label.strip().startswith("…"):
continue
label = label.strip()
if label.lower().startswith("sub total"):
current = None
continue
if p is None and all(v is None for v in months_f):
if r > 9:
current = category(label)
continue
if current is None:
continue
order += 10
m = REF_RE.match(str(p or ""))
if m:
sr = int(m.group(1))
short = inputs_f.cell(sr, 1).value
name = str(short).strip() if short else label
where = f"Sheet1 row {sr}"
prior_expr, prior = _amount(inputs_f.cell(sr, 2), inputs_v.cell(sr, 2), result.warnings, where)
base_expr, base = _amount(inputs_f.cell(sr, 3), inputs_v.cell(sr, 3), result.warnings, where)
d_formula = str(inputs_f.cell(sr, 4).value or "")
fm = FACTOR_RE.match(d_formula)
if fm:
factor = Decimal(fm.group(1))
else:
d_val = inputs_v.cell(sr, 4).value
factor = (Decimal(str(d_val)) / base) if base and isinstance(d_val, (int, float)) else Decimal(1)
if factor == 1 + rate:
adj_kind, adj_value, reason = "year_rate", Decimal(0), ""
else:
adj_kind, adj_value = "factor", factor
reason = KNOWN_REASONS.get(current.name_en.lower(), "") if factor >= 2 else ""
line = CostLine(
year_id=by.id, category_id=current.id, site_id=(s.id if (s := site_for(label + " " + name)) else None),
name=name, description=label if label != name else "",
remarks=str(inputs_f.cell(sr, 5).value or ""),
prior_expr=prior_expr, prior_actual=prior, base_expr=base_expr, base_amount=base,
adj_kind=adj_kind, adj_value=adj_value, adj_reason=reason, sort_order=order)
else:
values = []
for c in range(3, 15):
v = budget_v.cell(r, c).value
values.append(float(v) if isinstance(v, (int, float)) else 0.0)
line = CostLine(year_id=by.id, category_id=current.id, name=label, monthly_kind="manual",
monthly_values=values, adj_kind="none", sort_order=order)
db.add(line)
result.lines += 1
record(db, user, "year", by.id, "import", f"Imported {result.lines} lines into {target_year}", year_id=by.id)
db.commit()
return result
+334
View File
@@ -0,0 +1,334 @@
"""Sending reports by email through the configured account, with a copy in its Sent folder.
SMTP sends the message. When "save to Sent" is on, the same message is then appended over IMAP to the
mailbox's Sent folder (found by its \\Sent flag, or by a usual name), so it shows up there like any
message sent from a mail program. Every attempt is written to the mail log, whatever the outcome.
"""
import base64
import hashlib
import imaplib
import re
import smtplib
import socket
import ssl
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from email.message import EmailMessage
from email.utils import formataddr, formatdate, make_msgid
from cryptography.fernet import Fernet, InvalidToken
from sqlalchemy.orm import Session
from .audit import record
from .config import settings
from .models import MailLog, MailSettings, User
TIMEOUT = 30
MAX_RECIPIENTS = 25
ADDRESS = re.compile(r"^[^@\s<>,;\"']+@[^@\s<>,;\"']+\.[^@\s<>,;\"']+$")
SENT_NAMES = ("sent", "sent items", "sent messages", "sent mail", "enviados", "itens enviados",
"mensagens enviadas", "elementos enviados", "correo enviado")
class MailError(Exception):
def __init__(self, code: str, detail: str = ""):
super().__init__(code)
self.code = code
self.detail = detail[:500]
@dataclass
class Attachment:
name: str
mime: str
data: bytes
# --- stored passwords -------------------------------------------------------------------------
def _fernet() -> Fernet:
key = hashlib.sha256(("mail-password:" + settings.secret_key).encode()).digest()
return Fernet(base64.urlsafe_b64encode(key))
def encrypt(password: str) -> str:
return _fernet().encrypt(password.encode()).decode() if password else ""
def decrypt(token: str) -> str:
if not token:
return ""
try:
return _fernet().decrypt(token.encode()).decode()
except InvalidToken:
# SECRET_KEY changed since the password was saved
raise MailError("password_unreadable")
def get_settings(db: Session) -> MailSettings:
cfg = db.get(MailSettings, 1)
if cfg is None:
cfg = MailSettings(id=1)
db.add(cfg)
db.flush()
return cfg
def is_ready(cfg: MailSettings) -> bool:
return bool(cfg.enabled and cfg.smtp_host and cfg.from_address)
def clean_addresses(values: list[str] | None) -> list[str]:
out = []
for raw in values or []:
for part in re.split(r"[,;\s]+", raw or ""):
part = part.strip()
if not part:
continue
if not ADDRESS.match(part):
raise MailError("invalid_address", part)
if part.lower() not in (a.lower() for a in out):
out.append(part)
return out
# --- SMTP -------------------------------------------------------------------------------------
def _smtp(cfg: MailSettings) -> smtplib.SMTP:
context = ssl.create_default_context()
try:
if cfg.smtp_security == "ssl":
server = smtplib.SMTP_SSL(cfg.smtp_host, cfg.smtp_port, timeout=TIMEOUT, context=context)
else:
server = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=TIMEOUT)
server.ehlo()
if cfg.smtp_security == "starttls":
server.starttls(context=context)
server.ehlo()
if cfg.smtp_username:
server.login(cfg.smtp_username, decrypt(cfg.smtp_password))
return server
except MailError:
raise
except smtplib.SMTPAuthenticationError as exc:
raise MailError("smtp_auth", _smtp_text(exc))
except ssl.SSLError as exc:
raise MailError("smtp_tls", str(exc))
except smtplib.SMTPNotSupportedError as exc:
raise MailError("smtp_tls", str(exc))
except (socket.gaierror, socket.timeout, ConnectionError, OSError) as exc:
raise MailError("smtp_connect", f"{cfg.smtp_host}:{cfg.smtp_port} — {exc}")
except smtplib.SMTPException as exc:
raise MailError("smtp_error", _smtp_text(exc))
def _smtp_text(exc: Exception) -> str:
if isinstance(exc, smtplib.SMTPResponseException):
msg = exc.smtp_error.decode(errors="replace") if isinstance(exc.smtp_error, bytes) else str(exc.smtp_error)
return f"{exc.smtp_code} {msg}"
return str(exc)
def smtp_send(cfg: MailSettings, msg: EmailMessage, recipients: list[str]) -> None:
server = _smtp(cfg)
try:
refused = server.send_message(msg, from_addr=cfg.from_address, to_addrs=recipients)
if refused:
raise MailError("smtp_recipients", ", ".join(refused))
except smtplib.SMTPRecipientsRefused as exc:
raise MailError("smtp_recipients", ", ".join(exc.recipients))
except smtplib.SMTPException as exc:
raise MailError("smtp_error", _smtp_text(exc))
except OSError as exc:
raise MailError("smtp_connect", str(exc))
finally:
try:
server.quit()
except Exception:
pass
# --- IMAP (copy in the Sent folder) -----------------------------------------------------------
def _imap(cfg: MailSettings) -> imaplib.IMAP4:
host = cfg.imap_host or cfg.smtp_host
username = cfg.smtp_username if cfg.imap_same_login else cfg.imap_username
password = cfg.smtp_password if cfg.imap_same_login else cfg.imap_password
context = ssl.create_default_context()
try:
if cfg.imap_security == "ssl":
box = imaplib.IMAP4_SSL(host, cfg.imap_port, ssl_context=context, timeout=TIMEOUT)
else:
box = imaplib.IMAP4(host, cfg.imap_port, timeout=TIMEOUT)
if cfg.imap_security == "starttls":
box.starttls(ssl_context=context)
box.login(username, decrypt(password))
return box
except MailError:
raise
except imaplib.IMAP4.error as exc:
raise MailError("imap_auth", _imap_text(exc))
except ssl.SSLError as exc:
raise MailError("imap_tls", str(exc))
except (socket.gaierror, socket.timeout, ConnectionError, OSError) as exc:
raise MailError("imap_connect", f"{host}:{cfg.imap_port} — {exc}")
def _imap_text(exc: Exception) -> str:
text = exc.args[0] if exc.args else str(exc)
return text.decode(errors="replace") if isinstance(text, bytes) else str(text)
_LIST_LINE = re.compile(r'^\((?P<flags>[^)]*)\)\s+(?P<delim>"(?:[^"\\]|\\.)*"|NIL)\s+(?P<name>.+)$')
def _unquote(text: str) -> str:
text = text.strip()
if len(text) >= 2 and text[0] == text[-1] == '"':
return re.sub(r"\\(.)", r"\1", text[1:-1])
return text
def list_folders(box: imaplib.IMAP4) -> list[tuple[str, str, set[str]]]:
"""(name, delimiter, flags) of every folder. Names stay in IMAP's own encoding for APPEND."""
typ, data = box.list()
if typ != "OK":
raise MailError("imap_error", "LIST failed")
folders = []
for item in data:
if isinstance(item, tuple): # name sent as a literal: b'(... ) "/" {12}', b'Itens Enviad'
head, literal = item
line = head.decode(errors="replace")
m = _LIST_LINE.match(re.sub(r"\{\d+\}$", '""', line))
if m:
folders.append((literal.decode(errors="replace"), _unquote(m["delim"]),
set(m["flags"].lower().split())))
continue
if not item:
continue
m = _LIST_LINE.match(item.decode(errors="replace"))
if m:
delim = "" if m["delim"] == "NIL" else _unquote(m["delim"])
folders.append((_unquote(m["name"]), delim, set(m["flags"].lower().split())))
return folders
def find_sent_folder(box: imaplib.IMAP4, configured: str) -> str:
folders = list_folders(box)
if configured:
if any(name == configured for name, _, _ in folders):
return configured
raise MailError("sent_folder_not_found", configured)
for name, _, flags in folders:
if "\\sent" in flags:
return name
for name, delim, _ in folders:
leaf = name.split(delim)[-1] if delim else name
if leaf.lower() in SENT_NAMES:
return name
raise MailError("sent_folder_not_found", ", ".join(name for name, _, _ in folders)[:400])
def _quote(name: str) -> str:
return '"' + name.replace("\\", "\\\\").replace('"', '\\"') + '"'
def save_copy(cfg: MailSettings, msg: EmailMessage) -> str:
box = _imap(cfg)
try:
folder = find_sent_folder(box, cfg.sent_folder.strip())
typ, data = box.append(_quote(folder), r"(\Seen)", imaplib.Time2Internaldate(time.time()), msg.as_bytes())
if typ != "OK":
raise MailError("imap_error", _imap_text(Exception(*(data or [b"APPEND failed"]))))
return folder
except imaplib.IMAP4.error as exc:
raise MailError("imap_error", _imap_text(exc))
finally:
try:
box.logout()
except Exception:
pass
# --- the whole send ---------------------------------------------------------------------------
def build_message(cfg: MailSettings, to: list[str], cc: list[str], subject: str, body: str,
attachments: list[Attachment]) -> EmailMessage:
msg = EmailMessage()
msg["From"] = formataddr((cfg.from_name or "", cfg.from_address))
msg["To"] = ", ".join(to)
if cc:
msg["Cc"] = ", ".join(cc)
if cfg.reply_to:
msg["Reply-To"] = cfg.reply_to
msg["Subject"] = " ".join(subject.split())
msg["Date"] = formatdate(localtime=True)
msg["Message-ID"] = make_msgid(domain=cfg.from_address.split("@")[-1])
text = body.strip()
if cfg.signature.strip():
text += "\n\n-- \n" + cfg.signature.strip()
msg.set_content(text + "\n")
for a in attachments:
maintype, subtype = a.mime.split(";")[0].split("/", 1)
msg.add_attachment(a.data, maintype=maintype, subtype=subtype, filename=a.name)
return msg
def send(db: Session, user: User, cfg: MailSettings, *, to: list[str], cc: list[str], subject: str, body: str,
attachments: list[Attachment], kind: str = "report", year_id: int | None = None,
context: str = "") -> MailLog:
"""Send, save the Sent copy, and log it. Never raises for delivery problems: the log says what happened."""
log = MailLog(at=datetime.now(timezone.utc), user_id=user.id, kind=kind, year_id=year_id, context=context[:255],
sender=cfg.from_address, recipients={"to": to, "cc": cc}, subject=subject[:500], body=body,
attachments=[{"name": a.name, "size": len(a.data)} for a in attachments],
status="failed", error="", sent_copy="off", sent_copy_detail="", message_id="")
try:
msg = build_message(cfg, to, cc, subject, body, attachments)
log.message_id = msg["Message-ID"]
smtp_send(cfg, msg, to + cc)
log.status = "sent"
except MailError as exc:
log.error = (exc.code + (f": {exc.detail}" if exc.detail else ""))[:1000]
except (ValueError, TypeError) as exc:
log.error = f"invalid_message: {exc}"[:1000]
if log.status == "sent" and cfg.save_to_sent:
try:
folder = save_copy(cfg, msg)
log.sent_copy, log.sent_copy_detail = "saved", folder[:500]
except MailError as exc:
log.sent_copy = "failed"
log.sent_copy_detail = (exc.code + (f": {exc.detail}" if exc.detail else ""))[:500]
db.add(log)
db.flush()
where = ", ".join(to + cc)
record(db, user, "mail", log.id, "send" if log.status == "sent" else "send_failed",
f"{subject} → {where}"[:500], year_id=year_id)
db.commit()
return log
def check(cfg: MailSettings) -> dict:
"""Sign in to SMTP (and IMAP when the Sent copy is on) without sending anything."""
result = {"smtp": "ok", "imap": "off", "sent_folder": None}
try:
server = _smtp(cfg)
server.quit()
except MailError as exc:
result["smtp"] = exc.code + (f": {exc.detail}" if exc.detail else "")
if cfg.save_to_sent:
try:
box = _imap(cfg)
try:
result["sent_folder"] = find_sent_folder(box, cfg.sent_folder.strip())
result["imap"] = "ok"
finally:
try:
box.logout()
except Exception:
pass
except MailError as exc:
result["imap"] = exc.code + (f": {exc.detail}" if exc.detail else "")
return result
+25
View File
@@ -0,0 +1,25 @@
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from . import models # noqa: F401 (registers tables)
from .routers import activity, admin, auth, mail, reports, years
app = FastAPI(title="Budget & Capex", docs_url="/api/docs", openapi_url="/api/openapi.json")
@app.middleware("http")
async def csrf_guard(request: Request, call_next):
# Cookies are SameSite=Lax; requiring a custom header on writes also blocks cross-site form posts.
if request.method not in ("GET", "HEAD", "OPTIONS") and request.url.path.startswith("/api/"):
if request.headers.get("x-requested-with") != "budget-app":
return JSONResponse({"detail": "missing_header"}, status_code=403)
return await call_next(request)
@app.get("/api/health")
def health():
return {"ok": True}
for r in (auth.router, admin.router, years.router, reports.router, mail.router, activity.router):
app.include_router(r)
+121
View File
@@ -0,0 +1,121 @@
"""MFA with an authenticator app (TOTP, RFC 6238: 6 digits, 30 seconds, SHA-1).
Works with Microsoft Authenticator, Google Authenticator, Authy, 1Password and similar apps.
"""
import base64
import hashlib
import hmac
import secrets
import struct
import time
from datetime import datetime, timedelta, timezone
from urllib.parse import quote
import segno
from cryptography.fernet import Fernet, InvalidToken
from .config import settings
from .models import User
ISSUER = "Budget & Capex"
STEP = 30
WINDOW = 1 # also accept the code just before and just after, for clocks that are slightly off
MAX_FAILED = 5
LOCK_MINUTES = 5
RECOVERY_CODES = 8
def _fernet() -> Fernet:
key = hashlib.sha256(("mfa-secret:" + settings.secret_key).encode()).digest()
return Fernet(base64.urlsafe_b64encode(key))
def new_secret() -> str:
return base64.b32encode(secrets.token_bytes(20)).decode().rstrip("=")
def seal(secret: str) -> str:
return _fernet().encrypt(secret.encode()).decode()
def unseal(token: str | None) -> str | None:
try:
return _fernet().decrypt(token.encode()).decode() if token else None
except InvalidToken: # SECRET_KEY changed: an administrator has to reset this user's MFA
return None
def code_at(secret: str, step: int) -> str:
key = base64.b32decode(secret + "=" * (-len(secret) % 8))
digest = hmac.new(key, struct.pack(">Q", step), hashlib.sha1).digest()
offset = digest[-1] & 0x0F
value = struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF
return f"{value % 1_000_000:06d}"
def matching_step(secret: str, code: str, last_step: int | None = None, at: float | None = None) -> int | None:
"""The time step the code belongs to, or None. A step already used is refused (no replay)."""
code = "".join(ch for ch in code if ch.isdigit())
if len(code) != 6:
return None
now = int((at or time.time()) // STEP)
for step in range(now - WINDOW, now + WINDOW + 1):
if hmac.compare_digest(code_at(secret, step), code) and (last_step is None or step > last_step):
return step
return None
def otpauth_uri(secret: str, email: str) -> str:
label = quote(f"{ISSUER}:{email}")
return f"otpauth://totp/{label}?secret={secret}&issuer={quote(ISSUER)}&algorithm=SHA1&digits=6&period={STEP}"
def qr_svg(uri: str) -> str:
return segno.make(uri, error="m").svg_inline(scale=5, dark="#000", light="#fff", border=2)
def _hash(code: str) -> str:
return hashlib.sha256(code.replace("-", "").strip().lower().encode()).hexdigest()
def new_recovery_codes() -> tuple[list[str], list[str]]:
"""Codes to show once, and their hashes to keep."""
alphabet = "abcdefghjkmnpqrstuvwxyz23456789"
codes = ["".join(secrets.choice(alphabet) for _ in range(4)) + "-" + "".join(secrets.choice(alphabet) for _ in range(4))
for _ in range(RECOVERY_CODES)]
return codes, [_hash(c) for c in codes]
def locked(user: User) -> bool:
until = user.mfa_locked_until
if until and until.tzinfo is None:
until = until.replace(tzinfo=timezone.utc)
return bool(until and until > datetime.now(timezone.utc))
def check(user: User, code: str) -> str | None:
"""Checks an authenticator code or a recovery code. Returns 'totp', 'recovery' or None, and updates
the user's counters (a recovery code is used up; too many wrong codes lock MFA for a few minutes)."""
if locked(user):
return None
code = (code or "").strip()
secret = unseal(user.mfa_secret)
kind = None
if secret and (step := matching_step(secret, code, user.mfa_last_step)) is not None:
user.mfa_last_step, kind = step, "totp"
elif any(ch.isalpha() for ch in code) and _hash(code) in (user.mfa_recovery or []):
user.mfa_recovery = [h for h in user.mfa_recovery if h != _hash(code)]
kind = "recovery"
if kind:
user.mfa_failed, user.mfa_locked_until = 0, None
else:
user.mfa_failed = (user.mfa_failed or 0) + 1
if user.mfa_failed >= MAX_FAILED:
user.mfa_failed, user.mfa_locked_until = 0, datetime.now(timezone.utc) + timedelta(minutes=LOCK_MINUTES)
return kind
def disable(user: User) -> None:
user.mfa_enabled, user.mfa_secret, user.mfa_pending_secret = False, None, None
user.mfa_enabled_at, user.mfa_last_step, user.mfa_recovery = None, None, None
user.mfa_failed, user.mfa_locked_until = 0, None
+256
View File
@@ -0,0 +1,256 @@
from datetime import datetime, timezone
from decimal import Decimal
from sqlalchemy import (JSON, BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, LargeBinary, Numeric, String, Text,
UniqueConstraint, false)
from sqlalchemy import text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .db import Base
Money = Numeric(18, 6)
def now() -> datetime:
return datetime.now(timezone.utc)
class Department(Base):
__tablename__ = "departments"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(120), unique=True)
active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
steps: Mapped[list["WorkflowStep"]] = relationship(
back_populates="department", cascade="all, delete-orphan", order_by="WorkflowStep.position")
class WorkflowStep(Base):
"""One approval step of a department's workflow. Steps run in `position` order after submission."""
__tablename__ = "workflow_steps"
id: Mapped[int] = mapped_column(primary_key=True)
department_id: Mapped[int] = mapped_column(ForeignKey("departments.id", ondelete="CASCADE"), index=True)
position: Mapped[int] = mapped_column(Integer, default=0)
name: Mapped[str] = mapped_column(String(120))
# role: any approver (or admin-role member) of the department; users: only the listed users
approver_mode: Mapped[str] = mapped_column(String(10), default="role")
approver_ids: Mapped[list | None] = mapped_column(JSON, nullable=True)
department: Mapped[Department] = relationship(back_populates="steps")
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
name: Mapped[str] = mapped_column(String(255))
password_hash: Mapped[str] = mapped_column(String(255))
# admin: everything; approver: edit + approve own department; editor: edit own department; viewer: read
role: Mapped[str] = mapped_column(String(20), default="viewer")
department_id: Mapped[int | None] = mapped_column(ForeignKey("departments.id"), nullable=True)
language: Mapped[str] = mapped_column(String(5), default="pt")
active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
# light, dark, or system (follow the computer's setting)
theme: Mapped[str] = mapped_column(String(10), default="system", server_default="system")
# MFA with an authenticator app (TOTP). Secrets are stored encrypted; recovery codes as SHA-256 hashes.
mfa_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
mfa_secret: Mapped[str | None] = mapped_column(String(255), nullable=True)
mfa_pending_secret: Mapped[str | None] = mapped_column(String(255), nullable=True)
mfa_enabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
mfa_last_step: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # a code works only once
mfa_recovery: Mapped[list | None] = mapped_column(JSON, nullable=True)
mfa_failed: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
mfa_locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# profile picture, resized to at most 256 px
avatar: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True, deferred=True)
avatar_type: Mapped[str | None] = mapped_column(String(30), nullable=True)
avatar_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
department: Mapped[Department | None] = relationship()
class Site(Base):
__tablename__ = "sites"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(120), unique=True)
active: Mapped[bool] = mapped_column(Boolean, default=True)
class Category(Base):
__tablename__ = "categories"
__table_args__ = (UniqueConstraint("department_id", "name_en", name="uq_categories_department_name"),)
id: Mapped[int] = mapped_column(primary_key=True)
department_id: Mapped[int] = mapped_column(ForeignKey("departments.id", ondelete="CASCADE"), index=True)
name_en: Mapped[str] = mapped_column(String(200))
name_pt: Mapped[str] = mapped_column(String(200), default="")
# revenue lines are shown in the budget but left out of the cost report
section: Mapped[str] = mapped_column(String(20), default="cost")
sort_order: Mapped[int] = mapped_column(Integer, default=0)
show_when_empty: Mapped[bool] = mapped_column(Boolean, default=True)
active: Mapped[bool] = mapped_column(Boolean, default=True)
class BudgetYear(Base):
__tablename__ = "budget_years"
# One current budget per department and year; replaced versions are archived next to it.
__table_args__ = (Index("uq_budget_years_current", "department_id", "year", unique=True,
postgresql_where=text("NOT archived"), sqlite_where=text("NOT archived")),)
id: Mapped[int] = mapped_column(primary_key=True)
department_id: Mapped[int] = mapped_column(ForeignKey("departments.id"), index=True)
year: Mapped[int] = mapped_column(Integer)
title: Mapped[str] = mapped_column(String(200), default="")
default_rate: Mapped[Decimal] = mapped_column(Numeric(9, 6), default=Decimal("0"))
# draft -> in_review (one round per workflow step) -> approved; only drafts can be edited
status: Mapped[str] = mapped_column(String(20), default="draft")
current_step: Mapped[int | None] = mapped_column(Integer, nullable=True)
notes: Mapped[str] = mapped_column(Text, default="")
submitted_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
approved_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
# the Excel file the budget was last loaded from, if any
source_file: Mapped[str] = mapped_column(String(255), default="", server_default="")
# an archived budget was replaced by an upload: kept for reference, read-only
archived: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
archived_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
department: Mapped[Department] = relationship()
lines: Mapped[list["CostLine"]] = relationship(back_populates="budget_year", cascade="all, delete-orphan")
submitted_by: Mapped[User | None] = relationship(foreign_keys=[submitted_by_id])
approved_by: Mapped[User | None] = relationship(foreign_keys=[approved_by_id])
archived_by: Mapped[User | None] = relationship(foreign_keys=[archived_by_id])
reopen_requests: Mapped[list["ReopenRequest"]] = relationship(
back_populates="budget_year", cascade="all, delete-orphan", order_by="ReopenRequest.id")
class CostLine(Base):
__tablename__ = "cost_lines"
id: Mapped[int] = mapped_column(primary_key=True)
year_id: Mapped[int] = mapped_column(ForeignKey("budget_years.id", ondelete="CASCADE"), index=True)
category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"))
site_id: Mapped[int | None] = mapped_column(ForeignKey("sites.id"), nullable=True)
name: Mapped[str] = mapped_column(String(255))
description: Mapped[str] = mapped_column(String(500), default="")
remarks: Mapped[str] = mapped_column(Text, default="")
# amounts may be typed as arithmetic, e.g. "3292.56+(12*839.67)"; the evaluated value is stored next to it
prior_expr: Mapped[str] = mapped_column(String(500), default="0")
prior_actual: Mapped[Decimal] = mapped_column(Money, default=Decimal("0"))
base_expr: Mapped[str] = mapped_column(String(500), default="0")
base_amount: Mapped[Decimal] = mapped_column(Money, default=Decimal("0"))
# year_rate: base * (1 + year rate); percent: base * (1 + value/100); factor: base * value; none: base
adj_kind: Mapped[str] = mapped_column(String(20), default="year_rate")
adj_value: Mapped[Decimal] = mapped_column(Numeric(12, 6), default=Decimal("0"))
adj_reason: Mapped[str] = mapped_column(String(255), default="")
# even: adjusted / 12; manual: twelve typed amounts, whose sum is the budget
monthly_kind: Mapped[str] = mapped_column(String(10), default="even")
monthly_values: Mapped[list | None] = mapped_column(JSON, nullable=True)
# monthly_kind "selected": the adjusted amount is split equally over these months (1 = January)
monthly_months: Mapped[list | None] = mapped_column(JSON, nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
budget_year: Mapped[BudgetYear] = relationship(back_populates="lines")
category: Mapped[Category] = relationship()
site: Mapped[Site | None] = relationship()
class AuditEntry(Base):
__tablename__ = "audit_log"
id: Mapped[int] = mapped_column(primary_key=True)
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
year_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
entity: Mapped[str] = mapped_column(String(40))
entity_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
action: Mapped[str] = mapped_column(String(40))
summary: Mapped[str] = mapped_column(String(500), default="")
changes: Mapped[dict | None] = mapped_column(JSON, nullable=True)
user: Mapped[User | None] = relationship()
class MailSettings(Base):
"""The one mail account the app sends from (row id 1). Passwords are stored encrypted."""
__tablename__ = "mail_settings"
id: Mapped[int] = mapped_column(primary_key=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=False)
smtp_host: Mapped[str] = mapped_column(String(255), default="")
smtp_port: Mapped[int] = mapped_column(Integer, default=587)
smtp_security: Mapped[str] = mapped_column(String(10), default="starttls") # starttls | ssl | none
smtp_username: Mapped[str] = mapped_column(String(255), default="")
smtp_password: Mapped[str] = mapped_column(Text, default="")
from_address: Mapped[str] = mapped_column(String(255), default="")
from_name: Mapped[str] = mapped_column(String(255), default="")
reply_to: Mapped[str] = mapped_column(String(255), default="")
signature: Mapped[str] = mapped_column(Text, default="")
# copy of every sent message in the mailbox's Sent folder, over IMAP
save_to_sent: Mapped[bool] = mapped_column(Boolean, default=True)
imap_host: Mapped[str] = mapped_column(String(255), default="")
imap_port: Mapped[int] = mapped_column(Integer, default=993)
imap_security: Mapped[str] = mapped_column(String(10), default="ssl")
imap_same_login: Mapped[bool] = mapped_column(Boolean, default=True)
imap_username: Mapped[str] = mapped_column(String(255), default="")
imap_password: Mapped[str] = mapped_column(Text, default="")
sent_folder: Mapped[str] = mapped_column(String(255), default="") # empty: find it automatically
updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
updated_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
updated_by: Mapped[User | None] = relationship()
class MailLog(Base):
"""One message the app tried to send: who, to whom, what, and how it went."""
__tablename__ = "mail_log"
id: Mapped[int] = mapped_column(primary_key=True)
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
kind: Mapped[str] = mapped_column(String(20), default="report") # report | test
year_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
context: Mapped[str] = mapped_column(String(255), default="")
sender: Mapped[str] = mapped_column(String(255), default="")
recipients: Mapped[dict] = mapped_column(JSON, default=dict) # {"to": [...], "cc": [...]}
subject: Mapped[str] = mapped_column(String(500), default="")
body: Mapped[str] = mapped_column(Text, default="")
attachments: Mapped[list] = mapped_column(JSON, default=list) # [{"name": ..., "size": ...}]
status: Mapped[str] = mapped_column(String(20), default="sent") # sent | failed
error: Mapped[str] = mapped_column(String(1000), default="")
sent_copy: Mapped[str] = mapped_column(String(20), default="off") # saved | failed | off
sent_copy_detail: Mapped[str] = mapped_column(String(500), default="")
message_id: Mapped[str] = mapped_column(String(255), default="")
user: Mapped[User | None] = relationship()
class ReopenRequest(Base):
"""A user asks for an approved budget to be reopened, saying why; an approver accepts or declines."""
__tablename__ = "reopen_requests"
id: Mapped[int] = mapped_column(primary_key=True)
year_id: Mapped[int] = mapped_column(ForeignKey("budget_years.id", ondelete="CASCADE"), index=True)
requested_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
requested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
reason: Mapped[str] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(20), default="pending") # pending | accepted | declined
decided_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
decision_note: Mapped[str] = mapped_column(Text, default="")
budget_year: Mapped[BudgetYear] = relationship(back_populates="reopen_requests")
requested_by: Mapped[User | None] = relationship(foreign_keys=[requested_by_id])
decided_by: Mapped[User | None] = relationship(foreign_keys=[decided_by_id])
+33
View File
@@ -0,0 +1,33 @@
"""Short emails about reopen requests, sent through the configured account when email is on.
Best effort: a failure is written to the mail log and never blocks the action itself.
"""
from sqlalchemy import select
from sqlalchemy.orm import Session
from . import mailer
from .models import BudgetYear, MailSettings, User
def _ready(db: Session) -> MailSettings | None:
cfg = db.get(MailSettings, 1)
return cfg if cfg and mailer.is_ready(cfg) else None
def reopeners(db: Session, budget: BudgetYear) -> list[User]:
"""Who can reopen the budget: administrators and the department's approvers."""
users = db.scalars(select(User).where(User.active)).all()
return [u for u in users if u.role == "admin" or (u.role == "approver" and u.department_id == budget.department_id)]
def send(db: Session, actor: User, budget: BudgetYear, to: list[User], subject: str, body: str) -> None:
cfg = _ready(db)
addresses = sorted({u.email for u in to if u.email and u.id != actor.id})
if not cfg or not addresses:
return
try:
addresses = mailer.clean_addresses(addresses)
except mailer.MailError:
return
mailer.send(db, actor, cfg, to=addresses, cc=[], subject=subject, body=body, attachments=[], kind="notice",
year_id=budget.id, context=f"{budget.department.name} {budget.year}")
+95
View File
@@ -0,0 +1,95 @@
"""Who may do what with a department's budget.
Everyone signed in can view every budget. Changing and approving is limited to the budget's own
department, following the workflow the administrator set up for it. Upper Management only views,
downloads and emails budgets and reports, in every department.
"""
from fastapi import HTTPException
from .models import BudgetYear, Department, User, WorkflowStep
from .security import ROLES
def rank(user: User) -> int:
return ROLES.index(user.role)
def is_admin(user: User) -> bool:
return user.role == "admin"
def is_management(user: User) -> bool:
return user.role == "management"
def member(user: User, department_id: int) -> bool:
return user.department_id == department_id
def can_edit_department(user: User, department_id: int) -> bool:
"""Create, import and edit draft budgets of the department."""
return is_admin(user) or (member(user, department_id) and rank(user) >= ROLES.index("editor"))
def steps_of(department: Department) -> list[WorkflowStep]:
return sorted(department.steps, key=lambda s: (s.position, s.id))
def can_approve_step(user: User, step: WorkflowStep | None, department_id: int) -> bool:
if step is None:
# No workflow configured: the department's approvers sign off in one step.
return member(user, department_id) and rank(user) >= ROLES.index("approver")
if step.approver_mode == "users":
return user.id in (step.approver_ids or []) and not is_management(user)
return member(user, department_id) and rank(user) >= ROLES.index("approver")
def current_step(budget: BudgetYear) -> WorkflowStep | None:
steps = steps_of(budget.department)
if budget.status != "in_review" or not steps:
return None
index = min(budget.current_step or 0, len(steps) - 1)
return steps[index]
def can_reopen(user: User, budget: BudgetYear) -> bool:
return is_admin(user) or (member(user, budget.department_id) and rank(user) >= ROLES.index("approver"))
def pending_reopen(budget: BudgetYear):
return next((r for r in budget.reopen_requests if r.status == "pending"), None)
def allowed_actions(user: User, budget: BudgetYear) -> dict:
if budget.archived:
# a replaced version: read-only for everyone; administrators may still delete it
return {"edit": False, "submit": False, "approve": False, "return": False, "reopen": False,
"request_reopen": False, "decide_reopen": False, "upload": False, "delete": is_admin(user)}
dept = budget.department_id
editor = can_edit_department(user, dept)
approver = budget.status == "in_review" and can_approve_step(user, current_step(budget), dept)
return {
"edit": budget.status == "draft" and editor,
"submit": budget.status == "draft" and editor,
"approve": approver,
"return": approver or (budget.status == "in_review" and is_admin(user)),
"reopen": budget.status == "approved" and can_reopen(user, budget),
# anyone may ask for an approved budget to be reopened (approvers reopen it straight away)
"request_reopen": budget.status == "approved" and pending_reopen(budget) is None and not is_management(user),
"decide_reopen": budget.status == "approved" and pending_reopen(budget) is not None and can_reopen(user, budget),
"upload": budget.status == "draft" and editor,
# Deleting a budget removes it and its reports for everyone, so only administrators may, in any status.
"delete": is_admin(user),
}
def require_action(user: User, budget: BudgetYear, action: str) -> None:
if not allowed_actions(user, budget)[action]:
if action == "edit" and budget.status != "draft":
raise HTTPException(409, "year_not_draft")
raise HTTPException(403, "forbidden")
def require_department_editor(user: User, department_id: int) -> None:
if not can_edit_department(user, department_id):
raise HTTPException(403, "forbidden")
+59
View File
@@ -0,0 +1,59 @@
"""Run before `alembic upgrade`: when there are database changes to apply, ask the backup container for a
fresh dump and wait for it, so every upgrade can be rolled back to the moment just before it.
The api stops (and Docker retries it) if the backup fails, unless SKIP_PRE_UPGRADE_BACKUP=true.
"""
import os
import sys
import time
import uuid
from pathlib import Path
from alembic.config import Config
from alembic.runtime.migration import MigrationContext
from alembic.script import ScriptDirectory
from .db import engine
REQUESTS = Path(os.getenv("BACKUP_REQUEST_DIR", "/requests"))
TIMEOUT = int(os.getenv("PRE_UPGRADE_BACKUP_TIMEOUT", "900"))
def pending() -> tuple[str | None, str]:
head = ScriptDirectory.from_config(Config("alembic.ini")).get_current_head()
with engine.connect() as conn:
current = MigrationContext.configure(conn).get_current_revision()
return current, head
def main() -> int:
current, head = pending()
if current is None or current == head:
return 0 # new database, or nothing to migrate
if os.getenv("SKIP_PRE_UPGRADE_BACKUP", "").lower() == "true":
print(f"pre-upgrade backup skipped ({current} -> {head})", flush=True)
return 0
if not REQUESTS.is_dir():
print(f"pre-upgrade backup: {REQUESTS} not mounted, cannot ask the backup container", file=sys.stderr, flush=True)
return 1
rid = time.strftime("%Y%m%d%H%M%S") + "-" + uuid.uuid4().hex[:6]
(REQUESTS / f"{rid}.request").write_text(f"database upgrade {current} -> {head}\n")
print(f"database upgrade {current} -> {head}: waiting for the backup container to take a backup", flush=True)
done = REQUESTS / f"{rid}.done"
deadline = time.time() + TIMEOUT
while time.time() < deadline:
if done.exists():
answer = done.read_text().strip() # the backup container clears old answers itself
if answer == "ok":
print("pre-upgrade backup written", flush=True)
return 0
print("pre-upgrade backup FAILED; not upgrading. See: docker compose logs backup", file=sys.stderr, flush=True)
return 1
time.sleep(2)
print("pre-upgrade backup did not answer in time; not upgrading. Is the backup container running?",
file=sys.stderr, flush=True)
return 1
if __name__ == "__main__":
sys.exit(main())
View File
+110
View File
@@ -0,0 +1,110 @@
"""Inline SVG charts, so the same markup renders in the browser and in WeasyPrint."""
import math
from html import escape
BLUE = "#2f6fd6"
ORANGE = "#e8632b"
INK = "#1d1d1f"
MUTED = "#6b6b70"
GRID = "#e4e4e8"
def nice_ticks(lo: float, hi: float, count: int = 8) -> list[float]:
if hi <= lo:
hi = lo + 1
raw = (hi - lo) / count
mag = 10 ** math.floor(math.log10(raw))
step = next(m * mag for m in (1, 2, 2.5, 5, 10) if m * mag >= raw)
start = math.floor(lo / step) * step
ticks, v = [], start
while v <= hi + step * 0.001:
ticks.append(round(v, 10))
v += step
if ticks[-1] < hi:
ticks.append(ticks[-1] + step)
return ticks
def _wrap(text: str, width: int = 30, lines: int = 2) -> list[str]:
words, out, cur = text.split(), [], ""
for w in words:
if len(cur) + len(w) + (1 if cur else 0) <= width:
cur = f"{cur} {w}".strip()
else:
out.append(cur)
cur = w
out.append(cur)
out = [o for o in out if o]
if len(out) > lines:
out = out[:lines]
out[-1] = out[-1][: width - 1].rstrip() + "…"
return [o if len(o) <= width else o[: width - 1] + "…" for o in out] or [""]
def grouped_bars(rows: list[tuple[str, float, float]], fmt_tick, fmt_value=None, width: int = 900,
height: int = 490) -> str:
"""Horizontal bars, two per row (prior in blue, adjusted in orange), sized to fill width x height."""
if not rows:
return ""
label_w = 250
top, bottom = 8, 30
row_h = min(130.0, (height - top - bottom) / len(rows))
bar_h = min(row_h * 0.34, 36)
plot_w = width - label_w - (80 if fmt_value else 24)
height = round(top + bottom + row_h * len(rows))
hi = max(max(p, a) for _, p, a in rows) or 1
ticks = nice_ticks(0, hi, 8)
x = lambda v: label_w + plot_w * v / ticks[-1]
parts = [f'<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" class="chart">']
for t in ticks:
parts.append(f'<line x1="{x(t):.1f}" x2="{x(t):.1f}" y1="{top}" y2="{height - bottom}" stroke="{GRID}"/>')
parts.append(f'<text x="{x(t):.1f}" y="{height - bottom + 18}" font-size="12" text-anchor="middle" fill="{MUTED}">{escape(fmt_tick(t))}</text>')
for i, (label, prior, adjusted) in enumerate(rows):
y0 = top + i * row_h
cy = y0 + row_h / 2
fs = 13 if row_h >= 22 else 11.5
wrapped = _wrap(label, 34, 2 if row_h >= 30 else 1)
for j, chunk in enumerate(wrapped):
dy = (j - (len(wrapped) - 1) / 2) * (fs + 2) + fs / 3
parts.append(f'<text x="{label_w - 10}" y="{cy + dy:.1f}" font-size="{fs}" text-anchor="end" fill="{INK}">{escape(chunk)}</text>')
for k, (value, colour) in enumerate(((prior, BLUE), (adjusted, ORANGE))):
by = cy - bar_h - 1 + k * (bar_h + 2)
w = max(x(max(value, 0)) - label_w, 0)
parts.append(f'<rect x="{label_w}" y="{by:.1f}" width="{w:.1f}" height="{bar_h:.1f}" fill="{colour}"/>')
if fmt_value and row_h >= 26:
vfs = min(12, bar_h + 1)
parts.append(f'<text x="{label_w + w + 5:.1f}" y="{by + bar_h / 2 + vfs / 3:.1f}" font-size="{vfs:.1f}" fill="{MUTED}">{escape(fmt_value(value))}</text>')
parts.append(f'<line x1="{label_w}" x2="{label_w}" y1="{top}" y2="{height - bottom}" stroke="{MUTED}"/>')
parts.append("</svg>")
return "".join(parts)
def change_bars(rows: list[tuple[str, float]], fmt_value, width: int = 900, height: int = 520) -> str:
"""Ranked % change per category; increases orange, decreases blue."""
if not rows:
return ""
label_w, top, bottom = 270, 8, 30
row_h = min(60.0, (height - top - bottom) / len(rows))
bar = min(row_h * 0.6, 28)
plot_w = width - label_w - 90
height = round(top + bottom + row_h * len(rows))
lo = min(0.0, min(v for _, v in rows))
hi = max(0.0, max(v for _, v in rows)) or 1
ticks = nice_ticks(lo, hi, 8)
span = ticks[-1] - ticks[0]
x = lambda v: label_w + plot_w * (v - ticks[0]) / span
parts = [f'<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" class="chart">']
for t in ticks:
parts.append(f'<line x1="{x(t):.1f}" x2="{x(t):.1f}" y1="{top}" y2="{height - bottom}" stroke="{GRID}"/>')
parts.append(f'<text x="{x(t):.1f}" y="{height - bottom + 18}" font-size="12" text-anchor="middle" fill="{MUTED}">{"+" if t > 0 else ""}{t:g}%</text>')
for i, (label, value) in enumerate(rows):
cy = top + i * row_h + row_h / 2
x0, x1 = sorted((x(0), x(value)))
colour = ORANGE if value > 0 else BLUE
parts.append(f'<text x="{label_w - 10}" y="{cy + 4.5:.1f}" font-size="13" text-anchor="end" fill="{INK}">{escape(_wrap(label, 38, 1)[0])}</text>')
parts.append(f'<rect x="{x0:.1f}" y="{cy - bar / 2:.1f}" width="{max(x1 - x0, 1):.1f}" height="{bar:.1f}" fill="{colour}"/>')
tx, anchor = (x1 + 6, "start") if value >= 0 else (x0 - 6, "end")
parts.append(f'<text x="{tx:.1f}" y="{cy + 4.5:.1f}" font-size="12.5" font-weight="bold" text-anchor="{anchor}" fill="{INK}">{escape(fmt_value(value))}</text>')
parts.append(f'<line x1="{x(0):.1f}" x2="{x(0):.1f}" y1="{top}" y2="{height - bottom}" stroke="{MUTED}"/>')
parts.append("</svg>")
return "".join(parts)
+196
View File
@@ -0,0 +1,196 @@
"""Excel exports with live formulas, so totals keep working when people edit the file."""
import re
from datetime import datetime
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from ..summary import YearView
from .i18n import T
MONEY = '#,##0.00;[Red]-#,##0.00'
PCT = '0.00%;[Red]-0.00%'
HEAD_FILL = PatternFill("solid", fgColor="1F3B63")
CAT_FILL = PatternFill("solid", fgColor="EEF2F8")
WHITE_BOLD = Font(bold=True, color="FFFFFF")
BOLD = Font(bold=True)
THIN_TOP = Border(top=Side(style="thin", color="999999"))
def _safe_sheet_name(name: str, used: set[str]) -> str:
base = re.sub(r"[\[\]:*?/\\]", "-", name)[:31] or "Sheet"
candidate, n = base, 2
while candidate.lower() in used:
suffix = f" ({n})"
candidate, n = base[: 31 - len(suffix)] + suffix, n + 1
used.add(candidate.lower())
return candidate
def _header(ws, row: int, labels: list[str]) -> None:
for i, label in enumerate(labels, start=1):
c = ws.cell(row, i, label)
c.font, c.fill = WHITE_BOLD, HEAD_FILL
c.alignment = Alignment(horizontal="left" if i <= 2 else "right", vertical="center")
def _title(ws, title: str, sub: str) -> None:
ws["A1"] = title
ws["A1"].font = Font(bold=True, size=14)
ws["A2"] = sub
ws["A2"].font = Font(color="666666")
def _out(wb: Workbook) -> bytes:
buf = BytesIO()
wb.save(buf)
return buf.getvalue()
def _meta(view: YearView, t: T, site: str | None) -> str:
bits = [f"{t('status')}: {t(view.year.status)}", f"{t('exported')}: {datetime.now():%Y-%m-%d %H:%M}"]
if site:
bits.insert(0, t("site_filter", site=site))
return " · ".join(bits)
def budget_xlsx(view: YearView, lang: str, site: str | None = None, include_empty: bool = True) -> bytes:
t = T(lang, view.year.year, view.year.department.name)
wb = Workbook()
ws = wb.active
ws.title = _safe_sheet_name(t("budget_title"), set())
_title(ws, t("budget_title"), t("budget_sub") + " · " + _meta(view, t, site))
months = t("months")
_header(ws, 4, [t("line"), t("site"), *months, t("total")])
row = 5
cost_subtotals = []
cats = sorted([c for c in view.categories if c.lines or include_empty],
key=lambda c: 0 if c.category.section == "revenue" else 1)
for cv in cats:
ws.cell(row, 1, cv.name(lang)).font = BOLD
for col in range(1, 16):
ws.cell(row, col).fill = CAT_FILL
row += 1
first = row
for lv in cv.lines:
ws.cell(row, 1, lv.line.name)
ws.cell(row, 2, lv.line.site.name if lv.line.site else t("group"))
for m, value in enumerate(lv.figures.months):
ws.cell(row, 3 + m, float(round(value, 2))).number_format = MONEY
ws.cell(row, 15, f"=SUM(C{row}:N{row})").number_format = MONEY
row += 1
label = t("revenue_total") if cv.category.section == "revenue" else t("sub_total", name=cv.name(lang))
ws.cell(row, 1, label).font = BOLD
for col in range(3, 16):
letter = get_column_letter(col)
formula = f"=SUM({letter}{first}:{letter}{row - 1})" if cv.lines else 0
c = ws.cell(row, col, formula)
c.number_format, c.font, c.border = MONEY, BOLD, THIN_TOP
if cv.category.section != "revenue":
cost_subtotals.append(row)
row += 2
ws.cell(row, 1, t("total_costs")).font = WHITE_BOLD
for col in range(1, 16):
ws.cell(row, col).fill = HEAD_FILL
for col in range(3, 16):
letter = get_column_letter(col)
formula = "=" + "+".join(f"{letter}{r}" for r in cost_subtotals) if cost_subtotals else 0
c = ws.cell(row, col, formula)
c.number_format, c.font = MONEY, WHITE_BOLD
ws.column_dimensions["A"].width = 52
ws.column_dimensions["B"].width = 12
for col in range(3, 16):
ws.column_dimensions[get_column_letter(col)].width = 13
ws.freeze_panes = "C5"
_inputs_sheet(wb.create_sheet(_safe_sheet_name(t("inputs"), {ws.title.lower()})), view, t, site)
return _out(wb)
def _inputs_sheet(ws, view: YearView, t: T, site: str | None) -> None:
"""The Sheet1 layout: actual, base, factor, adjusted, change, remarks."""
_title(ws, t("inputs") + f" {view.year.year}", _meta(view, t, site))
_header(ws, 4, [t("category"), t("line"), t("site"), t("prior_actual"), t("base"), t("factor"),
t("adjusted"), t("change_eur"), t("change_pct"), t("reason"), t("remarks")])
row = 5
for cv in view.with_lines():
for lv in cv.lines:
f, line = lv.figures, lv.line
ws.cell(row, 1, cv.name(t.lang))
ws.cell(row, 2, line.name)
ws.cell(row, 3, line.site.name if line.site else t("group"))
ws.cell(row, 4, float(f.prior)).number_format = MONEY
ws.cell(row, 5, float(f.base)).number_format = MONEY
ws.cell(row, 6, float(f.factor)).number_format = "0.0000"
ws.cell(row, 7, f"=ROUND(E{row}*F{row},2)").number_format = MONEY
ws.cell(row, 8, f"=G{row}-D{row}").number_format = MONEY
ws.cell(row, 9, f'=IF(D{row}=0,"",H{row}/D{row})').number_format = PCT
ws.cell(row, 10, line.adj_reason)
ws.cell(row, 11, line.remarks)
row += 1
ws.cell(row, 1, t("total")).font = BOLD
for col, letter in ((4, "D"), (5, "E"), (7, "G"), (8, "H")):
c = ws.cell(row, col, f"=SUM({letter}5:{letter}{row - 1})")
c.number_format, c.font, c.border = MONEY, BOLD, THIN_TOP
c = ws.cell(row, 9, f'=IF(D{row}=0,"",H{row}/D{row})')
c.number_format, c.font = PCT, BOLD
for letter, width in zip("ABCDEFGHIJK", (34, 46, 12, 16, 16, 9, 18, 15, 12, 30, 40)):
ws.column_dimensions[letter].width = width
ws.freeze_panes = "C5"
def cost_report_xlsx(view: YearView, lang: str, site: str | None = None) -> bytes:
t = T(lang, view.year.year, view.year.department.name)
wb = Workbook()
ws = wb.active
used: set[str] = set()
ws.title = _safe_sheet_name(t("summary"), used)
_title(ws, t("cost_report_title"), t("summary_sub") + " · " + _meta(view, t, site))
_header(ws, 4, [t("category"), t("prior_actual"), t("adjusted"), t("change_eur"), t("change_pct")])
cats = view.with_lines("cost")
row = 5
for cv in cats:
sheet = wb.create_sheet(_safe_sheet_name(cv.name(lang), used))
total_row = _category_sheet(sheet, cv, t)
ref = f"'{sheet.title}'"
ws.cell(row, 1, cv.name(lang))
ws.cell(row, 2, f"={ref}!B{total_row}").number_format = MONEY
ws.cell(row, 3, f"={ref}!C{total_row}").number_format = MONEY
ws.cell(row, 4, f"=C{row}-B{row}").number_format = MONEY
ws.cell(row, 5, f'=IF(B{row}=0,"",D{row}/B{row})').number_format = PCT
row += 1
ws.cell(row, 1, t("total")).font = BOLD
for col, letter in ((2, "B"), (3, "C")):
c = ws.cell(row, col, f"=SUM({letter}5:{letter}{row - 1})")
c.number_format, c.font, c.border = MONEY, BOLD, THIN_TOP
ws.cell(row, 4, f"=C{row}-B{row}").number_format = MONEY
ws.cell(row, 5, f'=IF(B{row}=0,"",D{row}/B{row})').number_format = PCT
ws.cell(row, 4).font = ws.cell(row, 5).font = BOLD
for letter, width in zip("ABCDE", (40, 20, 22, 18, 14)):
ws.column_dimensions[letter].width = width
return _out(wb)
def _category_sheet(ws, cv, t: T) -> int:
_title(ws, cv.name(t.lang), t("cost_lines", n=len(cv.lines)))
_header(ws, 4, [t("line"), t("prior_actual"), t("adjusted"), t("change_eur"), t("change_pct"), t("site"), t("remarks")])
row = 5
for lv in cv.lines:
ws.cell(row, 1, lv.line.name)
ws.cell(row, 2, float(lv.figures.prior)).number_format = MONEY
ws.cell(row, 3, float(lv.figures.adjusted)).number_format = MONEY
ws.cell(row, 4, f"=C{row}-B{row}").number_format = MONEY
ws.cell(row, 5, f'=IF(B{row}=0,"",D{row}/B{row})').number_format = PCT
ws.cell(row, 6, lv.line.site.name if lv.line.site else t("group"))
ws.cell(row, 7, lv.line.remarks)
row += 1
ws.cell(row, 1, t("total")).font = BOLD
for col, letter in ((2, "B"), (3, "C")):
c = ws.cell(row, col, f"=SUM({letter}5:{letter}{row - 1})")
c.number_format, c.font, c.border = MONEY, BOLD, THIN_TOP
ws.cell(row, 4, f"=C{row}-B{row}").number_format = MONEY
ws.cell(row, 5, f'=IF(B{row}=0,"",D{row}/B{row})').number_format = PCT
for letter, width in zip("ABCDEFG", (46, 18, 20, 16, 12, 12, 40)):
ws.column_dimensions[letter].width = width
return row
+136
View File
@@ -0,0 +1,136 @@
from decimal import Decimal
LABELS = {
"en": {
"cost_report_title": "Capex and Costs {year}",
"cost_report_subtitle": "{dept} · cost budget: {prior} actual vs. {year} with adjustment",
"total_across": "Total across {n} categories",
"by_category": "Budget by category",
"by_category_sub": "Total cost per category, {prior} vs. {year}",
"change_by_category": "Change by category",
"change_by_category_sub": "Percentage change from {prior} actual to {year} with adjustment",
"change_legend": "Orange = cost increase · Blue = cost reduction",
"prior_actual": "{prior} actual",
"adjusted": "{year} with adjustment",
"base": "{year} without adjustment",
"change_eur": "Change (€)",
"change_pct": "Change (%)",
"cost_lines": "{n} cost lines",
"cost_line": "1 cost line",
"summary": "Summary",
"summary_sub": "All categories, {prior} actual vs. {year} with adjustment",
"category": "Category",
"line": "Line",
"site": "Site",
"group": "Group",
"total": "Total",
"factor": "Factor",
"reason": "Reason",
"remarks": "Remarks",
"budget_title": "Budget {year} · {dept}",
"budget_sub": "Monthly budget by category",
"revenue_total": "Sub total revenue",
"sub_total": "Sub total {name}",
"total_costs": "Total costs",
"status": "Status",
"exported": "Exported",
"draft": "Draft",
"in_review": "In review",
"approved": "Approved",
"inputs": "Inputs",
"months": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
"no_data": "No cost lines yet.",
"site_filter": "Site: {site}",
},
"pt": {
"cost_report_title": "Capex e Custos para {year}",
"cost_report_subtitle": "{dept} · orçamento de custos: real {prior} vs. {year} com ajuste",
"total_across": "Total de {n} categorias",
"by_category": "Orçamento por categoria",
"by_category_sub": "Custo total por categoria, {prior} vs. {year}",
"change_by_category": "Variação por categoria",
"change_by_category_sub": "Variação percentual do real {prior} para {year} com ajuste",
"change_legend": "Laranja = aumento de custo · Azul = redução de custo",
"prior_actual": "{prior} real",
"adjusted": "{year} com ajuste",
"base": "{year} sem ajuste",
"change_eur": "Variação (€)",
"change_pct": "Variação (%)",
"cost_lines": "{n} linhas de custo",
"cost_line": "1 linha de custo",
"summary": "Resumo",
"summary_sub": "Todas as categorias, real {prior} vs. {year} com ajuste",
"category": "Categoria",
"line": "Linha",
"site": "Unidade",
"group": "Grupo",
"total": "Total",
"factor": "Fator",
"reason": "Motivo",
"remarks": "Observações",
"budget_title": "Orçamento {year} · {dept}",
"budget_sub": "Orçamento mensal por categoria",
"revenue_total": "Subtotal receitas",
"sub_total": "Subtotal {name}",
"total_costs": "Total de custos",
"status": "Estado",
"exported": "Exportado",
"draft": "Rascunho",
"in_review": "Em revisão",
"approved": "Aprovado",
"inputs": "Dados",
"months": ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"],
"no_data": "Ainda não há linhas de custo.",
"site_filter": "Unidade: {site}",
},
}
class T:
def __init__(self, lang: str, year: int, dept: str = ""):
self.lang = lang if lang in LABELS else "pt"
self.labels = LABELS[self.lang]
self.ctx = {"year": year, "prior": year - 1, "dept": dept}
def __call__(self, key: str, **kw):
value = self.labels[key]
return value.format(**{**self.ctx, **kw}) if isinstance(value, str) else value
def _group(self, value: Decimal, decimals: int) -> str:
q = Decimal(1).scaleb(-decimals) if decimals else Decimal(1)
text = f"{abs(Decimal(value)).quantize(q):,.{decimals}f}"
whole, _, frac = text.partition(".")
whole = whole.replace(",", " ")
if not frac:
return whole
return whole + ("," if self.lang == "pt" else ".") + frac
def eur(self, value, decimals: int = 2, signed: bool = False) -> str:
value = Decimal(value or 0)
sign = ("+" if value > 0 else "−" if value < 0 else "") if signed else ("−" if value < 0 else "")
body = self._group(value, decimals)
return f"{sign}{body} €" if self.lang == "pt" else f"{sign}€{body}"
def num(self, value, decimals: int = 2) -> str:
value = Decimal(value or 0)
return ("\u2212" if value < 0 else "") + self._group(value, decimals)
def pct(self, value, signed: bool = True) -> str:
if value is None:
return "—"
value = Decimal(value)
sign = ("+" if value > 0 else "−" if value < 0 else "") if signed else ""
return f"{sign}{self._group(value, 2)}%"
def short_eur(self, value: float) -> str:
v = abs(value)
sign = "−" if value < 0 else ""
if v >= 1_000_000:
body = f"{v / 1_000_000:g}M"
elif v >= 1000:
body = f"{v / 1000:g}k"
else:
body = f"{v:g}"
if self.lang == "pt":
return f"{sign}{body.replace('.', ',')} €"
return f"{sign}€{body}"
+52
View File
@@ -0,0 +1,52 @@
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
from jinja2 import Environment, FileSystemLoader, select_autoescape
from ..summary import YearView
from .charts import change_bars, grouped_bars
from .i18n import T
TEMPLATES = Path(__file__).parent / "templates"
env = Environment(loader=FileSystemLoader(TEMPLATES), autoescape=select_autoescape(["html"]))
CSS = (TEMPLATES / "report.css").read_text()
def _exported() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M")
def cost_report_html(view: YearView, lang: str, site: str | None = None, screen: bool = True) -> str:
t = T(lang, view.year.year, view.year.department.name)
cost_cats = view.with_lines("cost")
cats = []
for cv in cost_cats:
rows = [(lv.line.name, float(lv.figures.prior), float(lv.figures.adjusted)) for lv in cv.lines]
cats.append(SimpleNamespace(
name=cv.name(lang), count=len(cv.lines), totals=cv.totals,
chart=grouped_bars(rows, t.short_eur, lambda v: t.eur(v, 0))))
overview = grouped_bars([(c.name, float(c.totals.prior), float(c.totals.adjusted)) for c in cats], t.short_eur)
ranked = sorted([c for c in cats if c.totals.change_pct is not None],
key=lambda c: c.totals.change_pct, reverse=True)
change = change_bars([(c.name, float(c.totals.change_pct)) for c in ranked], lambda v: t.pct(v))
return env.get_template("cost_report.html").render(
t=t, title=t("cost_report_title"), year=view.year, site=site, cats=cats, costs=view.costs,
overview_chart=overview, change_chart=change, exported=_exported(), css=CSS, screen=screen)
def budget_html(view: YearView, lang: str, site: str | None = None, include_empty: bool = False) -> str:
t = T(lang, view.year.year, view.year.department.name)
cats = [SimpleNamespace(name=cv.name(lang), category=cv.category, lines=cv.lines, totals=cv.totals)
for cv in view.categories if cv.lines or include_empty]
# revenue first, as in the Budget IT sheet
cats.sort(key=lambda c: 0 if c.category.section == "revenue" else 1)
return env.get_template("budget.html").render(
t=t, title=t("budget_title"), year=view.year, site=site, cats=cats, costs=view.costs,
exported=_exported(), css=CSS)
def to_pdf(html: str) -> bytes:
from weasyprint import HTML # imported lazily: it loads system libraries
return HTML(string=html).write_pdf()
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="{{ t.lang }}">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title><style>{{ css|safe }}</style></head>
<body>
<section class="page budget">
<h1>{{ title }}</h1>
<div class="sub">{{ t('budget_sub') }}{% if site %} · {{ t('site_filter', site=site) }}{% endif %} · {{ t('status') }}: {{ t(year.status) }} · {{ t('exported') }}: {{ exported }}</div>
<table class="grid">
<thead><tr><th>{{ t('line') }}</th><th>{{ t('site') }}</th>{% for m in t('months') %}<th>{{ m }}</th>{% endfor %}<th>{{ t("total") }} (€)</th></tr></thead>
<tbody>
{% for c in cats %}
<tr class="cat"><td colspan="15">{{ c.name }}</td></tr>
{% for l in c.lines %}
<tr><td class="name">{{ l.line.name }}</td><td>{{ l.line.site.name if l.line.site else t('group') }}</td>
{% for m in l.figures.months %}<td class="num">{{ t.num(m) }}</td>{% endfor %}<td class="num">{{ t.num(l.figures.adjusted) }}</td></tr>
{% endfor %}
<tr class="subtotal"><td colspan="2">{{ t('revenue_total') if c.category.section == 'revenue' else t('sub_total', name=c.name) }}</td>
{% for m in c.totals.months %}<td class="num">{{ t.num(m) }}</td>{% endfor %}<td class="num">{{ t.num(c.totals.adjusted) }}</td></tr>
{% endfor %}
<tr class="grand"><td colspan="2">{{ t('total_costs') }}</td>{% for m in costs.months %}<td class="num">{{ t.num(m) }}</td>{% endfor %}<td class="num">{{ t.num(costs.adjusted) }}</td></tr>
</tbody>
</table>
</section>
</body></html>
@@ -0,0 +1,75 @@
<!doctype html>
<html lang="{{ t.lang }}">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title><style>{{ css|safe }}</style>
{% if screen %}<script>
function fit(){var w=document.documentElement.clientWidth-32;var z=Math.min(1,w/1280);document.documentElement.style.setProperty('--zoom',z);}
window.addEventListener('resize',fit);document.addEventListener('DOMContentLoaded',fit);
</script>{% endif %}
</head>
<body>
{% set total_pages = 3 + cats|length %}
<section class="page cover">
<h1>{{ title }}</h1>
<p class="sub">{{ t('cost_report_subtitle') }}{% if site %} · {{ t('site_filter', site=site) }}{% endif %}</p>
<div class="kicker">{{ t('total_across', n=cats|length) }}</div>
<div class="big">{{ t.eur(costs.prior, 0) }} &nbsp;→&nbsp; {{ t.eur(costs.adjusted, 0) }}</div>
<div class="delta {{ 'up' if costs.change > 0 else 'down' }}">{{ t.pct(costs.change_pct) }} &nbsp;({{ t.eur(costs.change, 0, true) }})</div>
<div class="meta">{{ t('status') }}: {{ t(year.status) }} · {{ t('exported') }}: {{ exported }}</div>
</section>
<section class="page">
<h1>{{ t('by_category') }}</h1>
<p class="sub">{{ t('by_category_sub') }}</p>
<div class="rule"></div>
<div class="legend"><span><i style="background:#2f6fd6"></i>{{ t('prior_actual') }}</span><span><i style="background:#e8632b"></i>{{ t('adjusted') }}</span></div>
<div class="chartbox full">{{ overview_chart|safe }}</div>
<div class="footer">{{ title }}<span class="n">2 / {{ total_pages }}</span></div>
</section>
<section class="page">
<h1>{{ t('change_by_category') }}</h1>
<p class="sub">{{ t('change_by_category_sub') }}</p>
<div class="rule"></div>
<div class="legend" style="text-align:center">{{ t('change_legend') }}</div>
<div class="chartbox full">{{ change_chart|safe }}</div>
<div class="footer">{{ title }}<span class="n">3 / {{ total_pages }}</span></div>
</section>
{% for c in cats %}
<section class="page">
<h1>{{ c.name }}</h1>
<p class="sub">{{ t('cost_line') if c.count == 1 else t('cost_lines', n=c.count) }}</p>
<div class="rule"></div>
<div class="legend"><span><i style="background:#2f6fd6"></i>{{ t('prior_actual') }}</span><span><i style="background:#e8632b"></i>{{ t('adjusted') }}</span></div>
<div class="body">
<div class="chartbox">{{ c.chart|safe }}</div>
<div class="kpis">
<div class="kpi"><div class="k">{{ t('prior_actual') }}</div><div class="v">{{ t.eur(c.totals.prior) }}</div></div>
<div class="kpi"><div class="k">{{ t('adjusted') }}</div><div class="v">{{ t.eur(c.totals.adjusted) }}</div></div>
<div class="kpi"><div class="k">{{ t('change_eur') }}</div><div class="v {{ 'up' if c.totals.change > 0 else 'down' }}">{{ t.eur(c.totals.change, 2, true) }}</div></div>
<div class="kpi"><div class="k">{{ t('change_pct') }}</div><div class="v {{ 'up' if c.totals.change > 0 else 'down' }}">{{ t.pct(c.totals.change_pct) }}</div></div>
</div>
</div>
<div class="footer">{{ title }}<span class="n">{{ 3 + loop.index }} / {{ total_pages }}</span></div>
</section>
{% endfor %}
<section class="page">
<h1>{{ t('summary') }}</h1>
<p class="sub">{{ t('summary_sub') }}</p>
<div class="rule"></div>
<table class="summary">
<thead><tr><th>{{ t('category') }}</th><th class="num">{{ t('prior_actual') }}</th><th class="num">{{ t('adjusted') }}</th><th class="num">{{ t('change_eur') }}</th><th class="num">{{ t('change_pct') }}</th></tr></thead>
<tbody>
{% for c in cats %}
<tr><td>{{ c.name }}</td><td class="num">{{ t.eur(c.totals.prior) }}</td><td class="num">{{ t.eur(c.totals.adjusted) }}</td>
<td class="num {{ 'up' if c.totals.change > 0 else 'down' }}">{{ t.eur(c.totals.change, 2, true) }}</td><td class="num {{ 'up' if c.totals.change > 0 else 'down' }}">{{ t.pct(c.totals.change_pct) }}</td></tr>
{% endfor %}
<tr class="total"><td>{{ t('total') }}</td><td class="num">{{ t.eur(costs.prior) }}</td><td class="num">{{ t.eur(costs.adjusted) }}</td>
<td class="num {{ 'up' if costs.change > 0 else 'down' }}">{{ t.eur(costs.change, 2, true) }}</td><td class="num {{ 'up' if costs.change > 0 else 'down' }}">{{ t.pct(costs.change_pct) }}</td></tr>
</tbody>
</table>
<div class="footer">{{ title }}<span class="n">{{ total_pages }} / {{ total_pages }}</span></div>
</section>
</body></html>
+62
View File
@@ -0,0 +1,62 @@
@page { size: 338.67mm 190.5mm; margin: 0; }
@page budget { size: A3 landscape; margin: 12mm 10mm 14mm 10mm;
@bottom-left { content: string(doc-title); font: 8pt Helvetica, Arial, sans-serif; color: #888; }
@bottom-right { content: counter(page) " / " counter(pages); font: 8pt Helvetica, Arial, sans-serif; color: #888; } }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { font-family: "DejaVu Sans", "Liberation Sans", Arial, sans-serif; color: #1d1d1f; background: #ececee; }
.page { position: relative; width: 338.67mm; height: 190.5mm; padding: 13mm 16mm 12mm; background: #fbfbf9;
overflow: hidden; page-break-after: always; break-after: page; }
.page:last-child { page-break-after: auto; break-after: auto; }
.page h1 { font-size: 22pt; margin: 0 0 1.5mm; }
.page .sub { font-size: 10pt; color: #444; margin: 0 0 3mm; }
.rule { border-top: 1px solid #ddd; margin: 2mm 0 3mm; }
.legend { font-size: 8.5pt; color: #333; margin-bottom: 2mm; }
.legend span { display: inline-block; margin-right: 18mm; }
.legend i { display: inline-block; width: 3mm; height: 3mm; margin-right: 2mm; vertical-align: -0.4mm; }
.footer { position: absolute; left: 16mm; right: 16mm; bottom: 6mm; font-size: 7pt; color: #888; }
.footer .n { float: right; }
.cover { padding-left: 22mm; }
.cover::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4mm; background: #2f6fd6; }
.cover h1 { font-size: 34pt; margin-top: 48mm; }
.cover .sub { font-size: 13pt; }
.cover .kicker { margin-top: 16mm; font-size: 10pt; color: #444; }
.cover .big { font-size: 22pt; font-weight: bold; margin: 2mm 0; }
.cover .delta { font-size: 13pt; font-weight: bold; }
.up { color: #c2410c; } .down { color: #15803d; }
.body { display: flex; gap: 8mm; }
.chartbox { flex: 1 1 auto; height: 128mm; }
.chartbox.full { height: 136mm; }
.chartbox svg { width: 100%; height: 100%; }
.kpis { flex: 0 0 72mm; padding-top: 2mm; }
.kpi { margin-bottom: 7mm; }
.kpi .k { font-size: 9pt; color: #333; }
.kpi .v { font-size: 18pt; font-weight: bold; margin-top: 1.5mm; }
table.summary { width: 100%; border-collapse: collapse; font-size: 9pt; margin-top: 3mm; }
table.summary th { text-align: left; font-weight: bold; border-bottom: 1px solid #bbb; padding: 2mm; }
table.summary td { padding: 1.6mm 2mm; border-bottom: 1px solid #e4e4e4; }
table.summary .num { text-align: right; white-space: nowrap; }
table.summary tr.total td { font-weight: bold; border-top: 1px solid #999; }
.meta { font-size: 8pt; color: #777; margin-top: 2mm; }
/* annual budget */
.budget { page: budget; background: #fff; padding: 0; width: auto; height: auto; overflow: visible; }
.budget h1 { font-size: 16pt; margin: 0 0 1mm; string-set: doc-title content(); }
.budget .sub { font-size: 9pt; color: #555; margin-bottom: 4mm; }
table.grid { width: 100%; border-collapse: collapse; font-size: 7.2pt; }
table.grid th, table.grid td { padding: 1.1mm 1.2mm; border-bottom: 1px solid #eee; }
table.grid thead th { background: #1f3b63; color: #fff; text-align: right; font-weight: bold; }
table.grid thead th:first-child, table.grid thead th:nth-child(2) { text-align: left; }
table.grid thead { display: table-header-group; }
table.grid td.num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
table.grid tr.cat td { font-weight: bold; background: #eef2f8; padding-top: 2mm; }
table.grid tr.subtotal td { font-weight: bold; border-top: 1px solid #9aa; border-bottom: 1px solid #9aa; }
table.grid tr.grand td { font-weight: bold; background: #1f3b63; color: #fff; }
table.grid tr { page-break-inside: avoid; }
table.grid td.name { max-width: 70mm; }
@media screen {
body { padding: 16px 0; }
.page { margin: 0 auto 16px; box-shadow: 0 1px 4px rgba(0,0,0,.15); zoom: var(--zoom, 1); }
.budget { width: auto; max-width: 1600px; height: auto; padding: 16px 20px; margin: 0 auto; overflow: visible; }
}
View File
+76
View File
@@ -0,0 +1,76 @@
"""The activity log: everything users did, newest first (administrators)."""
import csv
import io
from fastapi import APIRouter, Depends
from fastapi.responses import Response
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import AuditEntry, BudgetYear, Department, User
from ..security import require
from .mail import _date
router = APIRouter(prefix="/api", tags=["activity"])
def _query(q, user_id, entity, action, year_id, date_from, date_to):
query = select(AuditEntry)
if user_id:
query = query.where(AuditEntry.user_id == user_id)
if entity:
query = query.where(AuditEntry.entity == entity)
if action:
query = query.where(AuditEntry.action == action)
if year_id:
query = query.where(AuditEntry.year_id == year_id)
if date_from:
query = query.where(AuditEntry.at >= _date(date_from))
if date_to:
query = query.where(AuditEntry.at < _date(date_to, end=True))
if q:
like = f"%{q.strip().lower()}%"
query = query.where(or_(func.lower(AuditEntry.summary).like(like),
AuditEntry.user_id.in_(select(User.id).where(func.lower(User.name).like(like)))))
return query
def _rows(db: Session, entries: list[AuditEntry]) -> list[dict]:
year_ids = {a.year_id for a in entries if a.year_id}
labels = {y.id: f"{d.name} {y.year}" for y, d in db.execute(
select(BudgetYear, Department).join(Department).where(BudgetYear.id.in_(year_ids)))} if year_ids else {}
return [{"id": a.id, "at": a.at.isoformat(), "user": a.user.name if a.user else None, "user_id": a.user_id,
"entity": a.entity, "entity_id": a.entity_id, "action": a.action, "summary": a.summary,
"changes": a.changes, "year_id": a.year_id,
"budget": labels.get(a.year_id) if a.year_id else None,
"budget_exists": a.year_id in labels} for a in entries]
@router.get("/activity")
def activity(q: str | None = None, user_id: int | None = None, entity: str | None = None, action: str | None = None,
year_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 = _query(q, user_id, entity, action, year_id, date_from, date_to)
total = db.scalar(select(func.count()).select_from(query.subquery()))
entries = db.scalars(query.order_by(AuditEntry.at.desc(), AuditEntry.id.desc())
.limit(min(max(limit, 1), 500)).offset(max(offset, 0))).all()
kinds = db.execute(select(AuditEntry.entity, AuditEntry.action).distinct()).all()
return {"total": total, "rows": _rows(db, entries),
"entities": sorted({e for e, _ in kinds}), "actions": sorted({a for _, a in kinds})}
@router.get("/activity.csv")
def activity_csv(q: str | None = None, user_id: int | None = None, entity: str | None = None,
action: str | None = None, year_id: int | None = None, date_from: str | None = None,
date_to: str | None = None, db: Session = Depends(get_db), _: User = Depends(require("admin"))):
entries = db.scalars(_query(q, user_id, entity, action, year_id, date_from, date_to)
.order_by(AuditEntry.at.desc(), AuditEntry.id.desc())).all()
out = io.StringIO()
w = csv.writer(out, delimiter=";")
w.writerow(["date", "user", "area", "action", "budget", "summary", "changes"])
for r in _rows(db, entries):
w.writerow([r["at"], r["user"] or "", r["entity"], r["action"], r["budget"] or "", r["summary"],
"" if not r["changes"] else str(r["changes"])])
return Response("" + out.getvalue(), media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="activity_log.csv"'})
+398
View File
@@ -0,0 +1,398 @@
"""Departments and their workflows, users, sites and categories.
Reading is open to every signed-in user (except the user list). Departments, workflows, users and
sites are managed by administrators; a department's categories also by that department's approvers.
"""
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .. import mfa
from ..audit import diff, record, snapshot
from ..bootstrap import seed_categories
from ..db import get_db
from ..models import BudgetYear, Category, CostLine, Department, Site, User, WorkflowStep
from ..permissions import is_admin, member
from ..security import ROLES, current_user, hash_password, require
from ..serializers import category_out, department_out, site_out, user_out
from ..summary import build_year_view
from ..template import build_template
router = APIRouter(prefix="/api", tags=["admin"])
class UserIn(BaseModel):
department_id: int | None = None
email: str | None = None
name: str | None = None
role: str | None = None
language: str | None = None
active: bool | None = None
password: str | None = None
@router.get("/users")
def list_users(db: Session = Depends(get_db), _: User = Depends(require("admin"))):
return [user_out(u) for u in db.scalars(select(User).order_by(User.name))]
def _apply_user(u: User, body: UserIn, db: Session):
if "department_id" in body.model_fields_set:
if body.department_id is not None and not db.get(Department, body.department_id):
raise HTTPException(400, "invalid_department")
u.department_id = body.department_id
if body.email is not None:
email = body.email.strip().lower()
if "@" not in email:
raise HTTPException(400, "invalid_email")
clash = db.scalar(select(User).where(func.lower(User.email) == email, User.id != (u.id or 0)))
if clash:
raise HTTPException(400, "email_taken")
u.email = email
if body.name is not None:
u.name = body.name.strip()
if body.role is not None:
if body.role not in ROLES:
raise HTTPException(400, "invalid_role")
u.role = body.role
if body.language in ("pt", "en"):
u.language = body.language
if body.active is not None:
u.active = body.active
if body.password:
if len(body.password) < 8:
raise HTTPException(400, "password_too_short")
u.password_hash = hash_password(body.password)
@router.post("/users")
def create_user(body: UserIn, db: Session = Depends(get_db), admin: User = Depends(require("admin"))):
if not body.email or not body.name or not body.password:
raise HTTPException(400, "missing_fields")
u = User(role="viewer", language="pt", active=True, password_hash="")
_apply_user(u, body, db)
db.add(u)
db.flush()
record(db, admin, "user", u.id, "create", f"{u.name} <{u.email}> as {u.role}")
db.commit()
return user_out(u)
@router.put("/users/{user_id}")
def update_user(user_id: int, body: UserIn, db: Session = Depends(get_db), admin: User = Depends(require("admin"))):
u = db.get(User, user_id)
if not u:
raise HTTPException(404, "not_found")
if u.id == admin.id and (body.role not in (None, "admin") or body.active is False):
raise HTTPException(400, "cannot_demote_self")
fields = ["email", "name", "role", "active", "department_id"]
before = snapshot(u, fields)
_apply_user(u, body, db)
changes = diff(before, snapshot(u, fields))
if body.password:
changes["password"] = ["", "reset"]
record(db, admin, "user", u.id, "update", u.name, changes)
db.commit()
return user_out(u)
@router.post("/users/{user_id}/mfa/reset")
def reset_user_mfa(user_id: int, db: Session = Depends(get_db), admin: User = Depends(require("admin"))):
"""For a user who lost their phone: MFA goes off and they can set it up again after signing in."""
u = db.get(User, user_id)
if not u:
raise HTTPException(404, "not_found")
mfa.disable(u)
record(db, admin, "user", u.id, "mfa_reset", u.name)
db.commit()
return user_out(u)
@router.get("/users/{user_id}/avatar")
def user_avatar(user_id: int, db: Session = Depends(get_db), _: User = Depends(current_user)):
u = db.get(User, user_id)
if not u or not u.avatar:
raise HTTPException(404, "not_found")
return Response(u.avatar, media_type=u.avatar_type or "image/jpeg",
headers={"Cache-Control": "private, max-age=31536000, immutable"})
class SiteIn(BaseModel):
name: str | None = Field(default=None, max_length=120)
active: bool | None = None
@router.get("/sites")
def list_sites(db: Session = Depends(get_db), _: User = Depends(current_user)):
return [site_out(s) for s in db.scalars(select(Site).order_by(Site.name))]
@router.post("/sites")
def create_site(body: SiteIn, db: Session = Depends(get_db), admin: User = Depends(require("admin"))):
if not body.name or not body.name.strip():
raise HTTPException(400, "missing_fields")
if db.scalar(select(Site).where(func.lower(Site.name) == body.name.strip().lower())):
raise HTTPException(400, "name_taken")
s = Site(name=body.name.strip(), active=True)
db.add(s)
db.flush()
record(db, admin, "site", s.id, "create", s.name)
db.commit()
return site_out(s)
@router.put("/sites/{site_id}")
def update_site(site_id: int, body: SiteIn, db: Session = Depends(get_db), admin: User = Depends(require("admin"))):
s = db.get(Site, site_id)
if not s:
raise HTTPException(404, "not_found")
if body.name:
s.name = body.name.strip()
if body.active is not None:
s.active = body.active
record(db, admin, "site", s.id, "update", s.name)
db.commit()
return site_out(s)
class CategoryIn(BaseModel):
department_id: int | None = None
name_en: str | None = Field(default=None, max_length=200)
name_pt: str | None = Field(default=None, max_length=200)
section: str | None = None
sort_order: int | None = None
show_when_empty: bool | None = None
active: bool | None = None
def can_manage_categories(user: User, department_id: int) -> bool:
return is_admin(user) or (member(user, department_id) and ROLES.index(user.role) >= ROLES.index("approver"))
def _require_categories(user: User, department_id: int) -> None:
if not can_manage_categories(user, department_id):
raise HTTPException(403, "forbidden")
@router.get("/categories")
def list_categories(department_id: int | None = None, db: Session = Depends(get_db), _: User = Depends(current_user)):
q = select(Category).order_by(Category.department_id, Category.sort_order, Category.id)
if department_id:
q = q.where(Category.department_id == department_id)
return [category_out(c) for c in db.scalars(q)]
def _apply_category(c: Category, body: CategoryIn, db: Session):
if body.name_en is not None:
name = body.name_en.strip()
if not name:
raise HTTPException(400, "missing_fields")
if db.scalar(select(Category).where(Category.department_id == c.department_id,
func.lower(Category.name_en) == name.lower(), Category.id != (c.id or 0))):
raise HTTPException(400, "name_taken")
c.name_en = name
if body.name_pt is not None:
c.name_pt = body.name_pt.strip()
if body.section is not None:
if body.section not in ("cost", "revenue"):
raise HTTPException(400, "invalid_section")
c.section = body.section
for f in ("sort_order", "show_when_empty", "active"):
if getattr(body, f) is not None:
setattr(c, f, getattr(body, f))
@router.post("/categories")
def create_category(body: CategoryIn, db: Session = Depends(get_db), admin: User = Depends(current_user)):
if not body.name_en or not body.department_id or not db.get(Department, body.department_id):
raise HTTPException(400, "missing_fields")
_require_categories(admin, body.department_id)
top = db.scalar(select(func.max(Category.sort_order)).where(Category.department_id == body.department_id))
c = Category(department_id=body.department_id, section="cost", name_pt="", show_when_empty=True, active=True,
sort_order=(top or 0) + 10)
_apply_category(c, body, db)
db.add(c)
db.flush()
record(db, admin, "category", c.id, "create", c.name_en)
db.commit()
return category_out(c)
@router.put("/categories/{cat_id}")
def update_category(cat_id: int, body: CategoryIn, db: Session = Depends(get_db), admin: User = Depends(current_user)):
c = db.get(Category, cat_id)
if not c:
raise HTTPException(404, "not_found")
_require_categories(admin, c.department_id)
_apply_category(c, body, db)
record(db, admin, "category", c.id, "update", c.name_en)
db.commit()
return category_out(c)
@router.delete("/categories/{cat_id}")
def delete_category(cat_id: int, db: Session = Depends(get_db), admin: User = Depends(current_user)):
c = db.get(Category, cat_id)
if not c:
raise HTTPException(404, "not_found")
_require_categories(admin, c.department_id)
if db.scalar(select(func.count(CostLine.id)).where(CostLine.category_id == cat_id)):
raise HTTPException(400, "category_in_use")
record(db, admin, "category", c.id, "delete", c.name_en)
db.delete(c)
db.commit()
return {"ok": True}
class DepartmentIn(BaseModel):
name: str | None = Field(default=None, max_length=120)
active: bool | None = None
# where the new department's categories come from: "defaults", "none" or another department's id
categories_from: str | int | None = "defaults"
@router.get("/departments")
def list_departments(db: Session = Depends(get_db), _: User = Depends(current_user)):
return [department_out(d) for d in db.scalars(select(Department).order_by(Department.name))]
def _copy_categories(db: Session, target: Department, source) -> None:
if source in (None, "none", ""):
return
if source == "defaults":
seed_categories(db, target)
return
src = db.get(Department, int(source))
if not src:
raise HTTPException(400, "invalid_department")
existing = {n.lower() for n in db.scalars(select(Category.name_en).where(Category.department_id == target.id))}
for c in db.scalars(select(Category).where(Category.department_id == src.id).order_by(Category.sort_order)):
if c.name_en.lower() not in existing:
db.add(Category(department_id=target.id, name_en=c.name_en, name_pt=c.name_pt, section=c.section,
sort_order=c.sort_order, show_when_empty=c.show_when_empty, active=c.active))
@router.post("/departments")
def create_department(body: DepartmentIn, db: Session = Depends(get_db), admin: User = Depends(require("admin"))):
name = (body.name or "").strip()
if not name:
raise HTTPException(400, "missing_fields")
if db.scalar(select(Department).where(func.lower(Department.name) == name.lower())):
raise HTTPException(400, "name_taken")
d = Department(name=name, active=True)
db.add(d)
db.flush()
_copy_categories(db, d, body.categories_from)
record(db, admin, "department", d.id, "create", d.name)
db.commit()
db.refresh(d)
return department_out(d)
@router.put("/departments/{dept_id}")
def update_department(dept_id: int, body: DepartmentIn, db: Session = Depends(get_db),
admin: User = Depends(require("admin"))):
d = db.get(Department, dept_id)
if not d:
raise HTTPException(404, "not_found")
if body.name and body.name.strip() != d.name:
if db.scalar(select(Department).where(func.lower(Department.name) == body.name.strip().lower())):
raise HTTPException(400, "name_taken")
d.name = body.name.strip()
if body.active is not None:
d.active = body.active
record(db, admin, "department", d.id, "update", d.name)
db.commit()
return department_out(d)
class CopyCategoriesIn(BaseModel):
source: str | int
@router.post("/departments/{dept_id}/categories/copy")
def copy_categories(dept_id: int, body: CopyCategoriesIn, db: Session = Depends(get_db),
user: User = Depends(current_user)):
d = db.get(Department, dept_id)
if not d:
raise HTTPException(404, "not_found")
_require_categories(user, d.id)
if body.source == "defaults":
existing = {n.lower() for n in db.scalars(select(Category.name_en).where(Category.department_id == d.id))}
from ..seed import CATEGORIES
top = db.scalar(select(func.max(Category.sort_order)).where(Category.department_id == d.id)) or 0
for i, (en, pt, section) in enumerate(CATEGORIES):
if en.lower() not in existing:
db.add(Category(department_id=d.id, name_en=en, name_pt=pt, section=section, sort_order=top + (i + 1) * 10))
else:
_copy_categories(db, d, body.source)
record(db, user, "department", d.id, "copy_categories", f"from {body.source}")
db.commit()
return {"ok": True}
class StepIn(BaseModel):
name: str = Field(max_length=120)
approver_mode: str = "role"
approver_ids: list[int] = []
class WorkflowIn(BaseModel):
steps: list[StepIn]
@router.put("/departments/{dept_id}/workflow")
def set_workflow(dept_id: int, body: WorkflowIn, db: Session = Depends(get_db), admin: User = Depends(require("admin"))):
d = db.get(Department, dept_id)
if not d:
raise HTTPException(404, "not_found")
if db.scalar(select(func.count(BudgetYear.id)).where(BudgetYear.department_id == d.id,
BudgetYear.status == "in_review")):
raise HTTPException(409, "workflow_in_use")
if len(body.steps) > 10:
raise HTTPException(400, "too_many_steps")
valid_users = set(db.scalars(select(User.id).where(User.role != "management"))) # Upper Management never approves
before = [s.name for s in d.steps]
d.steps.clear()
db.flush()
for i, step in enumerate(body.steps):
if not step.name.strip() or step.approver_mode not in ("role", "users"):
raise HTTPException(400, "invalid_step")
ids = [u for u in step.approver_ids if u in valid_users] if step.approver_mode == "users" else []
if step.approver_mode == "users" and not ids:
raise HTTPException(400, "step_needs_approvers")
d.steps.append(WorkflowStep(position=i, name=step.name.strip(), approver_mode=step.approver_mode,
approver_ids=ids))
record(db, admin, "department", d.id, "workflow", d.name, {"steps": [before, [s.name for s in body.steps]]})
db.commit()
db.refresh(d)
return department_out(d)
XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
@router.get("/departments/{dept_id}/template.xlsx")
def download_template(dept_id: int, lang: str | None = None, year: int | None = None, rate: float | None = None,
from_year_id: int | None = None, db: Session = Depends(get_db), user: User = Depends(current_user)):
from datetime import date
from decimal import Decimal
d = db.get(Department, dept_id)
if not d:
raise HTTPException(404, "not_found")
lang = lang if lang in ("pt", "en") else user.language
lines = None
source = db.get(BudgetYear, from_year_id) if from_year_id else None
if source and source.department_id == d.id:
lines = [lv for cv in build_year_view(db, source).categories for lv in cv.lines]
year = year or (source.year + 1 if source else date.today().year + 1)
rate_value = Decimal(str(rate)) / 100 if rate is not None else (source.default_rate if source else Decimal(0))
data = build_template(db, d, lang, year, rate_value, lines)
safe = "".join(ch if ch.isalnum() else "_" for ch in d.name)
name = ("Modelo_Orcamento" if lang == "pt" else "Budget_Template") + f"_{safe}_{year}.xlsx"
record(db, user, "template", d.id, "download", name)
db.commit()
return Response(data, media_type=XLSX, headers={"Content-Disposition": f'attachment; filename="{name}"'})
+208
View File
@@ -0,0 +1,208 @@
from datetime import datetime, timezone
from io import BytesIO
from fastapi import APIRouter, Depends, File, HTTPException, Request, Response, UploadFile
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .. import mfa
from ..audit import record
from ..config import settings
from ..db import get_db
from ..models import User
from ..security import COOKIE, current_user, hash_password, make_token, verify_password
from ..serializers import user_out
router = APIRouter(prefix="/api/auth", tags=["auth"])
class LoginIn(BaseModel):
email: str
password: str
code: str | None = None # authenticator or recovery code, for users with MFA on
class ProfileIn(BaseModel):
name: str | None = None
language: str | None = None
theme: str | None = None
current_password: str | None = None
new_password: str | None = None
@router.post("/login")
def login(body: LoginIn, response: Response, db: Session = Depends(get_db)):
user = db.scalar(select(User).where(func.lower(User.email) == body.email.strip().lower()))
if not user or not user.active or not verify_password(body.password, user.password_hash):
record(db, user if user and user.active else None, "user", user.id if user else None, "login_failed",
body.email.strip()[:200])
db.commit()
raise HTTPException(401, "invalid_credentials")
if user.mfa_enabled:
if not (body.code or "").strip():
# password is right: the sign-in screen now asks for the code
return {"mfa_required": True}
if mfa.locked(user):
raise HTTPException(429, "mfa_locked")
kind = mfa.check(user, body.code)
if not kind:
record(db, user, "user", user.id, "mfa_failed", user.email)
db.commit()
raise HTTPException(429 if mfa.locked(user) else 401, "mfa_locked" if mfa.locked(user) else "invalid_mfa_code")
if kind == "recovery":
record(db, user, "user", user.id, "mfa_recovery_used", f"{len(user.mfa_recovery or [])} recovery codes left")
response.set_cookie(COOKIE, make_token(user), httponly=True, samesite="lax",
secure=settings.cookie_secure, max_age=settings.session_hours * 3600)
record(db, user, "user", user.id, "login")
db.commit()
return user_out(user)
@router.post("/logout")
def logout(request: Request, response: Response, db: Session = Depends(get_db)):
try:
user = current_user(request, db)
record(db, user, "user", user.id, "logout")
db.commit()
except HTTPException:
pass
response.delete_cookie(COOKIE)
return {"ok": True}
@router.get("/me")
def me(user: User = Depends(current_user)):
return user_out(user)
@router.put("/me")
def update_me(body: ProfileIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
if body.name:
user.name = body.name.strip()
if body.language in ("pt", "en"):
user.language = body.language
if body.theme in ("system", "light", "dark"):
user.theme = body.theme
if body.new_password:
if not body.current_password or not verify_password(body.current_password, user.password_hash):
raise HTTPException(400, "wrong_password")
if len(body.new_password) < 8:
raise HTTPException(400, "password_too_short")
user.password_hash = hash_password(body.new_password)
record(db, user, "user", user.id, "password_changed")
db.commit()
return user_out(user)
# --- MFA ----------------------------------------------------------------------------------------
class CodeIn(BaseModel):
code: str = ""
password: str = ""
@router.post("/mfa/setup")
def mfa_setup(user: User = Depends(current_user), db: Session = Depends(get_db)):
"""Starts MFA set-up: a new secret to scan as a QR code. MFA is only on after a code is confirmed."""
if user.mfa_enabled:
raise HTTPException(409, "mfa_already_on")
secret = mfa.new_secret()
user.mfa_pending_secret = mfa.seal(secret)
db.commit()
uri = mfa.otpauth_uri(secret, user.email)
return {"secret": secret, "uri": uri, "qr_svg": mfa.qr_svg(uri)}
@router.post("/mfa/enable")
def mfa_enable(body: CodeIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
secret = mfa.unseal(user.mfa_pending_secret)
if user.mfa_enabled or not secret:
raise HTTPException(409, "mfa_setup_first")
step = mfa.matching_step(secret, body.code)
if step is None:
raise HTTPException(400, "invalid_mfa_code")
codes, hashes = mfa.new_recovery_codes()
user.mfa_secret, user.mfa_pending_secret, user.mfa_enabled = user.mfa_pending_secret, None, True
user.mfa_enabled_at, user.mfa_last_step, user.mfa_recovery = datetime.now(timezone.utc), step, hashes
user.mfa_failed, user.mfa_locked_until = 0, None
record(db, user, "user", user.id, "mfa_enabled", user.email)
db.commit()
return {"user": user_out(user), "recovery_codes": codes}
@router.post("/mfa/recovery-codes")
def mfa_new_recovery_codes(body: CodeIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
"""New recovery codes (the old ones stop working). Needs a current code."""
if not user.mfa_enabled:
raise HTTPException(409, "mfa_off")
if not mfa.check(user, body.code):
db.commit()
raise HTTPException(400, "invalid_mfa_code")
codes, user.mfa_recovery = mfa.new_recovery_codes()
record(db, user, "user", user.id, "mfa_recovery_renewed", user.email)
db.commit()
return {"user": user_out(user), "recovery_codes": codes}
@router.post("/mfa/disable")
def mfa_disable(body: CodeIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
"""Turning MFA off needs the password and a current code (or a recovery code)."""
if not user.mfa_enabled:
raise HTTPException(409, "mfa_off")
if not verify_password(body.password, user.password_hash):
raise HTTPException(400, "wrong_password")
if not mfa.check(user, body.code):
db.commit()
raise HTTPException(400, "invalid_mfa_code")
mfa.disable(user)
record(db, user, "user", user.id, "mfa_disabled", user.email)
db.commit()
return user_out(user)
# --- profile picture ----------------------------------------------------------------------------
AVATAR_MAX_BYTES = 5 * 1024 * 1024
AVATAR_SIZE = 256
def resize_avatar(data: bytes) -> tuple[bytes, str]:
"""Any common picture -> a square JPEG (or PNG when it has transparency) of at most 256 px."""
from PIL import Image, ImageOps, UnidentifiedImageError
try:
img = Image.open(BytesIO(data))
img.load()
except (UnidentifiedImageError, OSError, Image.DecompressionBombError):
raise HTTPException(400, "not_an_image")
img = ImageOps.exif_transpose(img)
side = min(img.size)
left, top = (img.width - side) // 2, (img.height - side) // 2
img = img.crop((left, top, left + side, top + side))
img.thumbnail((AVATAR_SIZE, AVATAR_SIZE))
out = BytesIO()
if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
img.convert("RGBA").save(out, "PNG", optimize=True)
return out.getvalue(), "image/png"
img.convert("RGB").save(out, "JPEG", quality=88, optimize=True)
return out.getvalue(), "image/jpeg"
@router.put("/me/avatar")
async def upload_avatar(file: UploadFile = File(...), user: User = Depends(current_user), db: Session = Depends(get_db)):
data = await file.read(AVATAR_MAX_BYTES + 1)
if len(data) > AVATAR_MAX_BYTES:
raise HTTPException(413, "image_too_large")
user.avatar, user.avatar_type = resize_avatar(data)
user.avatar_updated_at = datetime.now(timezone.utc)
record(db, user, "user", user.id, "avatar_changed", user.email)
db.commit()
return user_out(user)
@router.delete("/me/avatar")
def delete_avatar(user: User = Depends(current_user), db: Session = Depends(get_db)):
user.avatar = user.avatar_type = user.avatar_updated_at = None
record(db, user, "user", user.id, "avatar_removed", user.email)
db.commit()
return user_out(user)
+246
View File
@@ -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"'})
+91
View File
@@ -0,0 +1,91 @@
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)})
+460
View File
@@ -0,0 +1,460 @@
import os
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from decimal import Decimal
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..audit import diff, record, snapshot
from ..calc import ExpressionError, clean_months, evaluate, line_figures
from ..db import get_db
from .. import notify
from ..importer import ImportErrorWithDetail, archived_name, current_year, import_workbook
from ..models import AuditEntry, BudgetYear, Category, CostLine, Department, ReopenRequest, Site, User
from ..permissions import (allowed_actions, is_management, can_reopen, current_step, pending_reopen, require_action,
require_department_editor, steps_of)
from ..security import current_user
from ..serializers import reopen_out, year_detail, year_out
from ..summary import build_year_view
router = APIRouter(prefix="/api", tags=["years"])
LINE_FIELDS = ["category_id", "site_id", "name", "description", "remarks", "prior_expr", "base_expr",
"adj_kind", "adj_value", "adj_reason", "monthly_kind", "monthly_values", "monthly_months", "sort_order"]
def get_year(db: Session, year_id: int) -> BudgetYear:
y = db.get(BudgetYear, year_id)
if not y:
raise HTTPException(404, "not_found")
return y
def get_department(db: Session, department_id: int | None) -> Department:
d = db.get(Department, department_id or 0)
if not d:
raise HTTPException(400, "invalid_department")
return d
@router.get("/years")
def list_years(department_id: int | None = None, archived: bool = False, db: Session = Depends(get_db),
user: User = Depends(current_user)):
"""Current budgets, or with archived=true the versions that were replaced by an upload."""
q = (select(BudgetYear).where(BudgetYear.archived.is_(archived))
.order_by(BudgetYear.year.desc(), BudgetYear.department_id, BudgetYear.archived_at.desc()))
if department_id:
q = q.where(BudgetYear.department_id == department_id)
return [year_out(y, build_year_view(db, y), user) for y in db.scalars(q)]
class YearIn(BaseModel):
department_id: int | None = None
year: int | None = Field(default=None, ge=2000, le=2100)
title: str | None = None
default_rate: float | None = Field(default=None, ge=-1, le=10)
notes: str | None = None
copy_from_id: int | None = None
@router.post("/years")
def create_year(body: YearIn, db: Session = Depends(get_db), user: User = Depends(current_user)):
dept = get_department(db, body.department_id or user.department_id)
require_department_editor(user, dept.id)
if not body.year:
raise HTTPException(400, "missing_fields")
if current_year(db, dept.id, body.year):
raise HTTPException(400, "year_exists")
src = get_year(db, body.copy_from_id) if body.copy_from_id else None
if src and src.department_id != dept.id:
raise HTTPException(400, "invalid_department")
rate = Decimal(str(body.default_rate)) if body.default_rate is not None else (src.default_rate if src else Decimal(0))
y = BudgetYear(department_id=dept.id, year=body.year, title=body.title or f"Budget {body.year}",
default_rate=rate, notes=body.notes or "")
db.add(y)
db.flush()
if src:
# Last year's adjusted budget becomes this year's starting point for both actual and base;
# users then replace the actual with the real figure.
for line in src.lines:
f = line_figures(line, src.default_rate)
start = str(f.adjusted)
db.add(CostLine(
year_id=y.id, category_id=line.category_id, site_id=line.site_id, name=line.name,
description=line.description, remarks="", prior_expr=start, prior_actual=f.adjusted,
base_expr=start, base_amount=f.adjusted, adj_kind=line.adj_kind, adj_value=line.adj_value,
adj_reason=line.adj_reason, monthly_kind=line.monthly_kind, monthly_values=line.monthly_values,
monthly_months=line.monthly_months,
sort_order=line.sort_order))
record(db, user, "year", y.id, "create", f"{dept.name} {y.year}" + (f" from {src.year}" if src else ""),
year_id=y.id)
db.commit()
return year_out(y)
@router.post("/years/import")
async def import_year(file: UploadFile = File(...), year: int | None = Form(None), replace: bool = Form(False),
department_id: int | None = Form(None),
db: Session = Depends(get_db), user: User = Depends(current_user)):
dept = get_department(db, department_id or user.department_id)
require_department_editor(user, dept.id)
data = await file.read()
if len(data) > 20 * 1024 * 1024:
raise HTTPException(400, "file_too_large")
try:
result = import_workbook(db, data, user, dept.id, year=year, replace=replace,
filename=_clean_filename(file.filename))
except ImportErrorWithDetail as exc:
db.rollback()
raise HTTPException(409 if str(exc) in ("year_exists", "year_not_draft") else 400, str(exc))
y = current_year(db, dept.id, result.year)
archived = db.scalars(select(BudgetYear).where(BudgetYear.department_id == dept.id, BudgetYear.year == result.year,
BudgetYear.archived.is_(True))
.order_by(BudgetYear.archived_at.desc())).first() if replace else None
return {"archived": year_out(archived) if archived else None,"year": year_out(y), "lines": result.lines, "categories_created": result.categories_created,
"warnings": result.warnings}
def _clean_filename(name: str | None) -> str:
return (name or "").replace("\\", "/").rsplit("/", 1)[-1].strip()[:255]
@router.post("/years/import/check")
async def check_import(file: UploadFile = File(...), year: int | None = Form(None),
department_id: int | None = Form(None),
db: Session = Depends(get_db), user: User = Depends(current_user)):
"""Which budget an upload would create, and whether it would replace (and archive) a current one."""
dept = get_department(db, department_id or user.department_id)
require_department_editor(user, dept.id)
data = await file.read()
if len(data) > 20 * 1024 * 1024:
raise HTTPException(400, "file_too_large")
try:
result = import_workbook(db, data, user, dept.id, year=year, check_only=True)
except ImportErrorWithDetail as exc:
raise HTTPException(400, str(exc))
existing = current_year(db, dept.id, result.year)
name = _clean_filename(file.filename)
out = {"year": result.year, "department": dept.name, "filename": name, "existing": None}
if existing:
now = datetime.now(ZoneInfo(os.getenv("TZ") or "Europe/Lisbon"))
out["existing"] = {
**year_out(existing, build_year_view(db, existing)), "can_replace": existing.status == "draft",
"same_file": bool(existing.source_file) and existing.source_file.lower() == name.lower(),
"archived_title": archived_name(existing.title or f"Budget {existing.year}", now),
"archived_file": archived_name(existing.source_file, now) if existing.source_file else "",
}
return out
@router.get("/years/{year_id}")
def read_year(year_id: int, site_id: int | None = None, db: Session = Depends(get_db),
user: User = Depends(current_user)):
return year_detail(build_year_view(db, get_year(db, year_id), site_id), user)
@router.put("/years/{year_id}")
def update_year(year_id: int, body: YearIn, db: Session = Depends(get_db), user: User = Depends(current_user)):
y = get_year(db, year_id)
require_action(user, y, "edit")
before = snapshot(y, ["title", "default_rate", "notes"])
if body.title is not None:
y.title = body.title
if body.default_rate is not None:
y.default_rate = Decimal(str(body.default_rate))
if body.notes is not None:
y.notes = body.notes
record(db, user, "year", y.id, "update", str(y.year), diff(before, snapshot(y, ["title", "default_rate", "notes"])),
year_id=y.id)
db.commit()
return year_out(y, user=user)
@router.delete("/years/{year_id}")
def delete_year(year_id: int, db: Session = Depends(get_db), user: User = Depends(current_user)):
y = get_year(db, year_id)
require_action(user, y, "delete")
record(db, user, "year", y.id, "delete",
f"{y.department.name} {y.year} · {y.title} ({y.status}, {len(y.lines)} lines)", year_id=y.id)
db.delete(y)
db.commit()
return {"ok": True}
class StatusIn(BaseModel):
action: str # submit | approve | return | reopen
comment: str | None = Field(default=None, max_length=500)
@router.post("/years/{year_id}/status")
def change_status(year_id: int, body: StatusIn, db: Session = Depends(get_db), user: User = Depends(current_user)):
y = get_year(db, year_id)
if body.action not in ("submit", "approve", "return", "reopen"):
raise HTTPException(400, "invalid_action")
expected = {"submit": "draft", "approve": "in_review", "return": "in_review", "reopen": "approved"}[body.action]
if y.status != expected:
raise HTTPException(409, "invalid_transition")
if not allowed_actions(user, y)[body.action]:
raise HTTPException(403, "forbidden")
if body.action == "reopen" and not (body.comment or "").strip():
raise HTTPException(400, "reason_required")
now = datetime.now(timezone.utc)
before = y.status
steps = steps_of(y.department)
step = current_step(y)
summary = body.comment or ""
if body.action == "submit":
y.status, y.current_step = "in_review", 0
y.submitted_by_id, y.submitted_at = user.id, now
y.approved_by_id = y.approved_at = None
elif body.action == "approve":
index = y.current_step or 0
summary = (f"{step.name}: " if step else "") + summary
if index + 1 < len(steps):
y.current_step = index + 1
else:
y.status, y.current_step = "approved", None
y.approved_by_id, y.approved_at = user.id, now
else: # return / reopen
y.status, y.current_step = "draft", None
y.approved_by_id = y.approved_at = None
if body.action == "reopen":
_reopen(y, user, now)
record(db, user, "year", y.id, body.action, summary.strip(" :"),
{"status": [before, y.status], **({"step": step.name} if step else {})}, year_id=y.id)
db.commit()
return year_out(y, user=user)
def _reopen(y: BudgetYear, user: User, now: datetime) -> None:
y.status, y.current_step = "draft", None
y.approved_by_id = y.approved_at = None
pending = pending_reopen(y)
if pending: # reopening answers an open request
pending.status, pending.decided_by_id, pending.decided_at = "accepted", user.id, now
class ReopenIn(BaseModel):
reason: str = Field(default="", max_length=2000)
@router.post("/years/{year_id}/reopen-requests")
def request_reopen(year_id: int, body: ReopenIn, db: Session = Depends(get_db), user: User = Depends(current_user)):
"""Anyone may ask for an approved budget to be reopened, saying why. Approvers reopen it at once."""
y = get_year(db, year_id)
if is_management(user):
raise HTTPException(403, "forbidden")
reason = body.reason.strip()
if len(reason) < 5:
raise HTTPException(400, "reason_required")
if y.archived or y.status != "approved":
raise HTTPException(409, "invalid_transition")
if pending_reopen(y) and not can_reopen(user, y):
raise HTTPException(409, "reopen_already_requested")
now = datetime.now(timezone.utc)
req = ReopenRequest(year_id=y.id, requested_by_id=user.id, requested_at=now, reason=reason, status="pending",
decision_note="")
label = f"{y.department.name} {y.year}"
if can_reopen(user, y):
pending = pending_reopen(y)
if pending: # an approver reopening answers the open request
req = pending
req.decision_note = reason
else:
db.add(req)
db.flush()
db.refresh(y)
_reopen(y, user, now)
record(db, user, "year", y.id, "reopen", reason, {"status": ["approved", "draft"]}, year_id=y.id)
db.commit()
return {"reopened": True, "request": reopen_out(req), "year": year_out(y, user=user)}
db.add(req)
db.flush()
db.refresh(y)
record(db, user, "year", y.id, "reopen_requested", reason, year_id=y.id)
db.commit()
notify.send(db, user, y, notify.reopeners(db, y), f"Pedido de reabertura / Reopen request: {label}",
f"{user.name} pediu a reabertura do orçamento {label} ({y.title}).\n"
f"{user.name} asked for the {label} budget ({y.title}) to be reopened.\n\n"
f"Motivo / Reason:\n{reason}\n")
return {"reopened": False, "request": reopen_out(req), "year": year_out(y, user=user)}
class DecisionIn(BaseModel):
accept: bool
note: str = Field(default="", max_length=2000)
@router.post("/reopen-requests/{request_id}/decide")
def decide_reopen(request_id: int, body: DecisionIn, db: Session = Depends(get_db), user: User = Depends(current_user)):
req = db.get(ReopenRequest, request_id)
if not req:
raise HTTPException(404, "not_found")
y = req.budget_year
if req.status != "pending" or y.status != "approved":
raise HTTPException(409, "invalid_transition")
if not can_reopen(user, y) or y.archived:
raise HTTPException(403, "forbidden")
if not body.accept and not body.note.strip():
raise HTTPException(400, "reason_required")
now = datetime.now(timezone.utc)
note = body.note.strip()
label = f"{y.department.name} {y.year}"
if body.accept:
_reopen(y, user, now)
req.decision_note = note
record(db, user, "year", y.id, "reopen", f"{req.reason}" + (f" — {note}" if note else ""),
{"status": ["approved", "draft"], "requested_by": req.requested_by.name if req.requested_by else None},
year_id=y.id)
else:
req.status, req.decided_by_id, req.decided_at, req.decision_note = "declined", user.id, now, note
record(db, user, "year", y.id, "reopen_declined", note, year_id=y.id)
db.commit()
if req.requested_by:
verdict = ("aceite / accepted: o orçamento voltou a rascunho / the budget is back in draft" if body.accept
else "recusado / declined")
notify.send(db, user, y, [req.requested_by], f"Pedido de reabertura {label}: {verdict.split(':')[0]}",
f"O seu pedido de reabertura do orçamento {label} foi {verdict}.\n"
f"Your request to reopen the {label} budget was {verdict}.\n\n"
f"{user.name}" + (f":\n{note}\n" if note else "\n"))
return {"request": reopen_out(req), "year": year_out(y, user=user)}
@router.get("/years/{year_id}/audit")
def year_audit(year_id: int, limit: int = 200, db: Session = Depends(get_db), _: User = Depends(current_user)):
get_year(db, year_id)
rows = db.scalars(select(AuditEntry).where(AuditEntry.year_id == year_id)
.order_by(AuditEntry.at.desc()).limit(min(limit, 1000))).all()
return [{"at": a.at.isoformat(), "user": a.user.name if a.user else None, "entity": a.entity,
"entity_id": a.entity_id, "action": a.action, "summary": a.summary, "changes": a.changes}
for a in rows]
class LineIn(BaseModel):
category_id: int | None = None
site_id: int | None = None
name: str | None = Field(default=None, max_length=255)
description: str | None = Field(default=None, max_length=500)
remarks: str | None = None
prior_expr: str | None = Field(default=None, max_length=400)
base_expr: str | None = Field(default=None, max_length=400)
adj_kind: str | None = None
adj_value: float | None = None
adj_reason: str | None = Field(default=None, max_length=255)
monthly_kind: str | None = None
monthly_values: list[float] | None = None
monthly_months: list[int] | None = None
sort_order: int | None = None
def _apply_line(line: CostLine, body: LineIn, db: Session, department_id: int) -> None:
data = body.model_dump(exclude_unset=True)
if "category_id" in data:
cat = db.get(Category, data["category_id"] or 0)
if not cat or cat.department_id != department_id:
raise HTTPException(400, "invalid_category")
line.category_id = data["category_id"]
if "site_id" in data:
if data["site_id"] is not None and not db.get(Site, data["site_id"]):
raise HTTPException(400, "invalid_site")
line.site_id = data["site_id"]
for f in ("name", "description", "remarks", "adj_reason", "sort_order"):
if f in data and data[f] is not None:
setattr(line, f, data[f].strip() if isinstance(data[f], str) else data[f])
for f, target in (("prior_expr", "prior_actual"), ("base_expr", "base_amount")):
if f in data and data[f] is not None:
try:
value = evaluate(data[f])
except ExpressionError:
raise HTTPException(400, f"invalid_expression:{f}")
setattr(line, f, data[f].strip().lstrip("=") or "0")
setattr(line, target, value)
if "adj_kind" in data:
if data["adj_kind"] not in ("year_rate", "percent", "factor", "none"):
raise HTTPException(400, "invalid_adjustment")
line.adj_kind = data["adj_kind"]
if data.get("adj_value") is not None:
line.adj_value = Decimal(str(data["adj_value"]))
if "monthly_kind" in data:
if data["monthly_kind"] not in ("even", "selected", "manual"):
raise HTTPException(400, "invalid_monthly")
if data["monthly_kind"] == "manual" and line.monthly_kind != "manual" and not data.get("monthly_values"):
# Switching to typed months starts from the current even spread.
year = db.get(BudgetYear, line.year_id)
spread = line_figures(line, year.default_rate if year else Decimal(0)).months
line.monthly_values = [float(m.quantize(Decimal("0.01"))) for m in spread]
if line.monthly_kind == "manual" and data["monthly_kind"] != "manual" and data.get("base_expr") is None:
# Leaving typed months keeps the year total: it becomes the base, without a further adjustment.
total = sum((Decimal(str(v or 0)) for v in (line.monthly_values or [])), Decimal(0))
line.base_expr, line.base_amount = str(total), total
if data.get("adj_kind") is None:
line.adj_kind, line.adj_value = "none", Decimal(0)
line.monthly_kind = data["monthly_kind"]
if "monthly_values" in data:
values = data["monthly_values"]
if values is not None and len(values) != 12:
raise HTTPException(400, "invalid_monthly")
line.monthly_values = values
if line.monthly_kind == "manual" and not line.monthly_values:
line.monthly_values = [0.0] * 12
if "monthly_months" in data:
values = data["monthly_months"]
if values is not None and any(not 1 <= m <= 12 for m in values):
raise HTTPException(400, "invalid_monthly")
line.monthly_months = clean_months(values) or None
if line.monthly_kind == "selected" and not line.monthly_months:
raise HTTPException(400, "no_months_selected")
if not line.name:
raise HTTPException(400, "missing_fields")
@router.post("/years/{year_id}/lines")
def create_line(year_id: int, body: LineIn, db: Session = Depends(get_db), user: User = Depends(current_user)):
y = get_year(db, year_id)
require_action(user, y, "edit")
if not body.category_id or not body.name:
raise HTTPException(400, "missing_fields")
line = CostLine(year_id=y.id, name="", description="", remarks="", prior_expr="0", prior_actual=Decimal(0),
base_expr="0", base_amount=Decimal(0), adj_kind="year_rate", adj_value=Decimal(0),
adj_reason="", monthly_kind="even", sort_order=0)
_apply_line(line, body, db, y.department_id)
if not line.sort_order:
line.sort_order = max([l.sort_order for l in y.lines if l.category_id == line.category_id] or [0]) + 10
db.add(line)
db.flush()
record(db, user, "line", line.id, "create", line.name, snapshot(line, LINE_FIELDS), year_id=y.id)
db.commit()
return {"id": line.id}
def _line_and_year(db: Session, line_id: int) -> tuple[CostLine, BudgetYear]:
line = db.get(CostLine, line_id)
if not line:
raise HTTPException(404, "not_found")
return line, line.budget_year
@router.put("/lines/{line_id}")
def update_line(line_id: int, body: LineIn, db: Session = Depends(get_db), user: User = Depends(current_user)):
line, y = _line_and_year(db, line_id)
require_action(user, y, "edit")
before = snapshot(line, LINE_FIELDS)
_apply_line(line, body, db, y.department_id)
changes = diff(before, snapshot(line, LINE_FIELDS))
if changes:
record(db, user, "line", line.id, "update", line.name, changes, year_id=y.id)
db.commit()
return {"id": line.id}
@router.delete("/lines/{line_id}")
def delete_line(line_id: int, db: Session = Depends(get_db), user: User = Depends(current_user)):
line, y = _line_and_year(db, line_id)
require_action(user, y, "edit")
record(db, user, "line", line.id, "delete", line.name, snapshot(line, LINE_FIELDS), year_id=y.id)
db.delete(line)
db.commit()
return {"ok": True}
+54
View File
@@ -0,0 +1,54 @@
from datetime import datetime, timedelta, timezone
import bcrypt
import jwt
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from .config import settings
from .db import get_db
from .models import User
COOKIE = "budget_session"
# Ordered by what each role may change. "management" (Upper Management) sits below editor: it only
# views, downloads and emails budgets and reports, in every department.
ROLES = ("viewer", "management", "editor", "approver", "admin")
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(password: str, hashed: str) -> bool:
try:
return bcrypt.checkpw(password.encode(), hashed.encode())
except ValueError:
return False
def make_token(user: User) -> str:
exp = datetime.now(timezone.utc) + timedelta(hours=settings.session_hours)
return jwt.encode({"sub": str(user.id), "exp": exp}, settings.secret_key, algorithm="HS256")
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
token = request.cookies.get(COOKIE)
if not token:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not_authenticated")
try:
payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"])
except jwt.PyJWTError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not_authenticated")
user = db.get(User, int(payload["sub"]))
if not user or not user.active:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not_authenticated")
return user
def require(min_role: str):
def dep(user: User = Depends(current_user)) -> User:
if ROLES.index(user.role) < ROLES.index(min_role):
raise HTTPException(status.HTTP_403_FORBIDDEN, "forbidden")
return user
return dep
+56
View File
@@ -0,0 +1,56 @@
"""Default chart of accounts, taken from the 'Budget IT' sheet (same order)."""
CATEGORIES = [
("Revenue", "Receitas", "revenue"),
("Cost of Cell Phones", "Custos com telemóveis", "cost"),
("Cost of Internet Services", "Serviços de internet", "cost"),
("Cost of Local Calls", "Chamadas locais", "cost"),
("Cost of Long Distance Calls", "Chamadas internacionais", "cost"),
("Other Cost of Services", "Outros custos de serviços", "cost"),
("Administrative and General", "Administrativos e gerais", "cost"),
("Centralized Information System Charges", "Encargos de sistemas de informação centralizados", "cost"),
("Energy Management", "Gestão de energia", "cost"),
("Food and Beverage", "Comidas e bebidas", "cost"),
("Golf", "Golfe", "cost"),
("Hardware", "Hardware", "cost"),
("Health Club/Spa", "Health club/Spa", "cost"),
("Human Resources", "Recursos humanos", "cost"),
("Information Security", "Segurança da informação", "cost"),
("Information Systems", "Sistemas de informação", "cost"),
("Other", "Outros", "cost"),
("Parking", "Estacionamento", "cost"),
("Property Operation and Maintenance", "Operação e manutenção do edifício", "cost"),
("Rooms", "Quartos", "cost"),
("Sales and Marketing", "Vendas e marketing", "cost"),
("Telecommunications", "Telecomunicações", "cost"),
("Other IT Equipment", "Outro equipamento informático", "cost"),
("System Storage and Optimization", "Armazenamento e otimização de sistemas", "cost"),
("Complimentary Services and Gifts", "Serviços de cortesia e ofertas", "cost"),
("Contract Services", "Serviços contratados", "cost"),
("Corporate Office Reimbursables", "Reembolsos à sede", "cost"),
("Dues and Subscriptions", "Quotas e subscrições", "cost"),
("Entertainment-In-House", "Entretenimento interno", "cost"),
("Equipment Rental", "Aluguer de equipamento", "cost"),
("Miscellaneous", "Diversos", "cost"),
("Cleaning Supplies", "Material de limpeza", "cost"),
("Food Supplies", "Abastecimento alimentar", "cost"),
("Give Aways Supplies", "Brindes", "cost"),
("Guest Supplies", "Amenidades para hóspedes", "cost"),
("Laundry Supplies", "Material de lavandaria", "cost"),
("Office Supplies", "Material de escritório", "cost"),
("Operating Supplies", "Material operacional", "cost"),
("Other Beverage Supplies", "Outras bebidas", "cost"),
("Other Supplies", "Outros materiais", "cost"),
("Paper and Plastics Disposables", "Descartáveis de papel e plástico", "cost"),
("Spa Supplies", "Material de spa", "cost"),
("Tobacco Supplies", "Tabaco", "cost"),
("Wine Supplies", "Vinhos", "cost"),
("Training", "Formação", "cost"),
("Travel-Meals and Entertainment", "Viagens - refeições e entretenimento", "cost"),
("Travel-Other", "Viagens - outros", "cost"),
("Uniform Costs", "Fardamentos", "cost"),
("Uniform Laundry", "Lavandaria de fardamentos", "cost"),
("Internal Recharge", "Imputações internas", "cost"),
]
SITES = ["Sagres", "Quinta", "Chiado", "Oriente"]
+103
View File
@@ -0,0 +1,103 @@
from decimal import Decimal
from .calc import round2
from .models import BudgetYear, Category, Department, Site, User, WorkflowStep
from .permissions import allowed_actions, steps_of
from .summary import LineView, YearView
def money(value: Decimal | None) -> float | None:
return None if value is None else float(round2(value))
def pct(value: Decimal | None) -> float | None:
return None if value is None else float(Decimal(value).quantize(Decimal("0.01")))
def user_out(u: User) -> dict:
return {"id": u.id, "email": u.email, "name": u.name, "role": u.role, "language": u.language,
"active": u.active, "department_id": u.department_id,
"department": u.department.name if u.department else None, "theme": u.theme or "system",
"mfa_enabled": bool(u.mfa_enabled), "mfa_enabled_at": u.mfa_enabled_at.isoformat() if u.mfa_enabled_at else None,
"mfa_recovery_left": len(u.mfa_recovery or []) if u.mfa_enabled else 0,
# the timestamp changes the picture's URL, so browsers fetch a new one after a change
"avatar_url": f"/api/users/{u.id}/avatar?v={int(u.avatar_updated_at.timestamp())}" if u.avatar_updated_at else None}
def step_out(s: WorkflowStep) -> dict:
return {"id": s.id, "position": s.position, "name": s.name, "approver_mode": s.approver_mode,
"approver_ids": s.approver_ids or []}
def department_out(d: Department) -> dict:
return {"id": d.id, "name": d.name, "active": d.active, "steps": [step_out(s) for s in steps_of(d)]}
def site_out(s: Site) -> dict:
return {"id": s.id, "name": s.name, "active": s.active}
def category_out(c: Category) -> dict:
return {"id": c.id, "department_id": c.department_id, "name_en": c.name_en, "name_pt": c.name_pt, "section": c.section,
"sort_order": c.sort_order, "show_when_empty": c.show_when_empty, "active": c.active}
def totals_out(t) -> dict:
return {"prior": money(t.prior), "base": money(t.base), "adjusted": money(t.adjusted),
"change": money(t.change), "change_pct": pct(t.change_pct), "months": [money(m) for m in t.months]}
def line_out(lv: LineView) -> dict:
line, f = lv.line, lv.figures
return {
"id": line.id, "category_id": line.category_id, "site_id": line.site_id, "name": line.name,
"description": line.description, "remarks": line.remarks,
"prior_expr": line.prior_expr, "base_expr": line.base_expr,
"adj_kind": line.adj_kind, "adj_value": float(line.adj_value or 0), "adj_reason": line.adj_reason,
"monthly_kind": line.monthly_kind, "monthly_values": line.monthly_values,
"monthly_months": line.monthly_months,
"sort_order": line.sort_order,
"prior": money(f.prior), "base": money(f.base), "factor": float(f.factor), "adjusted": money(f.adjusted),
"change": money(f.change), "change_pct": pct(f.change_pct), "months": [money(m) for m in f.months],
}
def year_out(y: BudgetYear, view: YearView | None = None, user: User | None = None) -> dict:
out = {
"id": y.id, "year": y.year, "title": y.title, "default_rate": float(y.default_rate or 0),
"department_id": y.department_id, "department": y.department.name,
"status": y.status, "notes": y.notes, "current_step": y.current_step,
"workflow": [step_out(s) for s in steps_of(y.department)],
"submitted_by": y.submitted_by.name if y.submitted_by else None,
"submitted_at": y.submitted_at.isoformat() if y.submitted_at else None,
"approved_by": y.approved_by.name if y.approved_by else None,
"approved_at": y.approved_at.isoformat() if y.approved_at else None,
"source_file": y.source_file or "", "archived": bool(y.archived),
"archived_at": y.archived_at.isoformat() if y.archived_at else None,
"archived_by": y.archived_by.name if y.archived_by else None,
"reopen_request": next((reopen_out(r) for r in y.reopen_requests if r.status == "pending"), None),
}
if user is not None:
out["actions"] = allowed_actions(user, y)
if view is not None:
out["costs"] = totals_out(view.costs)
out["revenue"] = totals_out(view.revenue)
return out
def reopen_out(r) -> dict:
return {"id": r.id, "status": r.status, "reason": r.reason, "requested_by": r.requested_by.name if r.requested_by else None,
"requested_at": r.requested_at.isoformat() if r.requested_at else None,
"decided_by": r.decided_by.name if r.decided_by else None,
"decided_at": r.decided_at.isoformat() if r.decided_at else None, "decision_note": r.decision_note}
def year_detail(view: YearView, user: User | None = None) -> dict:
out = year_out(view.year, view, user)
out["reopen_requests"] = [reopen_out(r) for r in reversed(view.year.reopen_requests)]
out["categories"] = [
{"category": category_out(cv.category), "totals": totals_out(cv.totals),
"lines": [line_out(lv) for lv in cv.lines]}
for cv in view.categories
]
return out
+66
View File
@@ -0,0 +1,66 @@
"""Groups a year's lines by category with computed figures; shared by the API and every report."""
from dataclasses import dataclass, field
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from .calc import LineFigures, Totals, line_figures
from .models import BudgetYear, Category, CostLine
@dataclass
class LineView:
line: CostLine
figures: LineFigures
@dataclass
class CategoryView:
category: Category
lines: list[LineView] = field(default_factory=list)
totals: Totals = field(default_factory=Totals)
def name(self, lang: str) -> str:
return (self.category.name_pt if lang == "pt" else "") or self.category.name_en
@dataclass
class YearView:
year: BudgetYear
categories: list[CategoryView]
revenue: Totals
costs: Totals
def with_lines(self, section: str | None = None) -> list[CategoryView]:
return [c for c in self.categories if c.lines and (section is None or c.category.section == section)]
def build_year_view(db: Session, year: BudgetYear, site_id: int | None = None) -> YearView:
categories = db.scalars(select(Category).where(Category.department_id == year.department_id)
.order_by(Category.sort_order, Category.id)).all()
lines = db.scalars(
select(CostLine)
.where(CostLine.year_id == year.id)
.options(selectinload(CostLine.site), selectinload(CostLine.category))
.order_by(CostLine.sort_order, CostLine.id)
).all()
views = {c.id: CategoryView(c) for c in categories}
for line in lines:
if site_id is not None and line.site_id != site_id:
continue
lv = LineView(line, line_figures(line, year.default_rate))
cv = views.get(line.category_id)
if cv is None: # a line whose category was moved to another department
cv = views[line.category_id] = CategoryView(line.category)
cv.lines.append(lv)
cv.totals.add(lv.figures)
revenue, costs = Totals(), Totals()
shown = []
for cv in views.values():
if not cv.lines and not (cv.category.active and cv.category.show_when_empty):
continue
shown.append(cv)
target = revenue if cv.category.section == "revenue" else costs
for lv in cv.lines:
target.add(lv.figures)
return YearView(year, shown, revenue, costs)
+316
View File
@@ -0,0 +1,316 @@
"""Blank (or pre-filled) Excel template for a department's budget, and the import of a filled one."""
from datetime import datetime
from decimal import Decimal
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
from openpyxl.worksheet.datavalidation import DataValidation
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .audit import record
from .calc import ExpressionError, evaluate
from .models import Category, Department, Site, User
TEMPLATE_SHEETS = {"Lines", "Linhas"}
HEADER_ROW = 8
TEXT = {
"en": {
"sheet": "Lines", "lists": "Lists", "help": "Instructions",
"title": "Budget template · {dept}",
"year": "Year", "rate": "Year rate (%)", "dept": "Department",
"cols": ["Category", "Line", "Description", "Site", "Prior-year actual", "Base (without adjustment)",
"Adjustment", "Adjustment value", "Reason for adjustment", "Remarks"],
"months": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
"split": "Split over months",
"adj": {"year_rate": "Year rate", "percent": "Percent", "factor": "Factor", "none": "None"},
"group": "Group",
"instructions": [
"How to fill in this template",
"1. On the 'Lines' sheet, set the year and the year rate (for example 5.5 for 5.5%).",
"2. Add one row per cost line, starting on row 9. Category and Line are required.",
"3. Category and Site have drop-down lists. Leave Site empty for costs shared by the group.",
"4. Prior-year actual and Base accept numbers or calculations such as =12*839.67.",
"5. Adjustment: 'Year rate' applies the year rate to the base; 'Percent' uses the Adjustment value as a %;",
" 'Factor' multiplies the base by the Adjustment value (e.g. 1.15); 'None' keeps the base.",
"6. Fill in the months (Jan to Dec) only for lines with uneven monthly amounts; the year total is then",
" the sum of the months and Base and Adjustment are ignored. Otherwise the year is split over 12.",
" To split the adjusted amount equally over some months only, list them in the last column",
" 'Split over months', for example 1-4 or Jan, Feb, Mar, Apr (leave Jan to Dec empty).",
"7. In the app, go to Budgets > Import Excel, choose the department and upload this file.",
],
},
"pt": {
"sheet": "Linhas", "lists": "Listas", "help": "Instruções",
"title": "Modelo de orçamento · {dept}",
"year": "Ano", "rate": "Taxa do ano (%)", "dept": "Departamento",
"cols": ["Categoria", "Linha", "Descrição", "Unidade", "Real do ano anterior", "Base (sem ajuste)",
"Ajuste", "Valor do ajuste", "Motivo do ajuste", "Observações"],
"months": ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"],
"split": "Dividir pelos meses",
"adj": {"year_rate": "Taxa do ano", "percent": "Percentagem", "factor": "Fator", "none": "Sem ajuste"},
"group": "Grupo",
"instructions": [
"Como preencher este modelo",
"1. Na folha 'Linhas', indique o ano e a taxa do ano (por exemplo 5,5 para 5,5%).",
"2. Acrescente uma linha por custo, a partir da linha 9. Categoria e Linha são obrigatórias.",
"3. Categoria e Unidade têm listas. Deixe a Unidade vazia para custos do grupo.",
"4. Real do ano anterior e Base aceitam números ou cálculos como =12*839,67.",
"5. Ajuste: 'Taxa do ano' aplica a taxa do ano à base; 'Percentagem' usa o Valor do ajuste como %;",
" 'Fator' multiplica a base pelo Valor do ajuste (ex.: 1,15); 'Sem ajuste' mantém a base.",
"6. Preencha os meses (Jan a Dez) só para linhas com valores mensais diferentes; o total do ano passa",
" a ser a soma dos meses e a Base e o Ajuste são ignorados. Caso contrário o ano é dividido por 12.",
" Para dividir o valor com ajuste em partes iguais só por alguns meses, indique-os na última coluna",
" 'Dividir pelos meses', por exemplo 1-4 ou Jan, Fev, Mar, Abr (deixe Jan a Dez vazios).",
"7. Na aplicação, vá a Orçamentos > Importar Excel, escolha o departamento e carregue este ficheiro.",
],
},
}
MONTH_NAMES = {name.lower(): i for t in TEXT.values() for i, name in enumerate(t["months"], start=1)}
MONTH_NAMES.update({"feb": 2, "fev": 2, "apr": 4, "abr": 4, "may": 5, "mai": 5, "aug": 8, "ago": 8,
"sep": 9, "set": 9, "oct": 10, "out": 10, "dec": 12, "dez": 12})
def _month(token: str) -> int | None:
token = token.strip().lower().rstrip(".")
if token.isdigit():
return int(token) if 1 <= int(token) <= 12 else None
return MONTH_NAMES.get(token[:3])
def parse_months(value) -> list[int]:
"""'1-4', '1,2,3,4', 'Jan-Abr', 'jan; fev; mar' -> [1, 2, 3, 4]. Unreadable text gives []."""
if isinstance(value, (int, float)):
value = str(int(value))
out = set()
for part in str(value).replace(";", ",").replace("/", ",").replace(" e ", ",").replace(" and ", ",").split(","):
part = part.strip()
if not part:
continue
if "-" in part or "–" in part:
a, _, b = part.replace("–", "-").partition("-")
start, end = _month(a), _month(b)
if start is None or end is None or end < start:
return []
out.update(range(start, end + 1))
else:
m = _month(part)
if m is None:
return []
out.add(m)
return sorted(out)
def months_text(months: list[int]) -> str:
"""[1, 2, 3, 4, 7] -> '1-4, 7'."""
months, runs = sorted(months), []
for m in months:
if runs and m == runs[-1][1] + 1:
runs[-1][1] = m
else:
runs.append([m, m])
return ", ".join(str(a) if a == b else f"{a}-{b}" for a, b in runs)
HEAD_FILL = PatternFill("solid", fgColor="1F3B63")
INPUT_FILL = PatternFill("solid", fgColor="FFF7E0")
def build_template(db: Session, department: Department, lang: str, year: int, rate: Decimal,
lines: list | None = None) -> bytes:
tx = TEXT.get(lang, TEXT["pt"])
cats = db.scalars(select(Category).where(Category.department_id == department.id, Category.active)
.order_by(Category.sort_order, Category.id)).all()
sites = db.scalars(select(Site).where(Site.active).order_by(Site.name)).all()
cat_name = (lambda c: c.name_pt or c.name_en) if lang == "pt" else (lambda c: c.name_en)
wb = Workbook()
ws = wb.active
ws.title = tx["sheet"]
ws["A1"] = tx["title"].format(dept=department.name)
ws["A1"].font = Font(bold=True, size=14)
for row, (label, value) in enumerate(((tx["dept"], department.name), (tx["year"], year),
(tx["rate"], float(rate * 100))), start=3):
ws.cell(row, 1, label).font = Font(bold=True)
c = ws.cell(row, 2, value)
if row > 3:
c.fill = INPUT_FILL
ws["B5"].number_format = "0.00"
headers = tx["cols"] + tx["months"] + [tx["split"]]
for col, label in enumerate(headers, start=1):
c = ws.cell(HEADER_ROW, col, label)
c.font, c.fill = Font(bold=True, color="FFFFFF"), HEAD_FILL
c.alignment = Alignment(wrap_text=True, vertical="center")
ws.row_dimensions[HEADER_ROW].height = 32
lists = wb.create_sheet(tx["lists"])
for i, c in enumerate(cats, start=1):
lists.cell(i, 1, cat_name(c))
for i, s in enumerate(sites, start=1):
lists.cell(i, 2, s.name)
for i, label in enumerate(tx["adj"].values(), start=1):
lists.cell(i, 3, label)
lists.sheet_state = "hidden"
last = HEADER_ROW + max(500, len(lines or []) + 200)
rng = lambda col: f"{get_column_letter(col)}{HEADER_ROW + 1}:{get_column_letter(col)}{last}"
for col, src, n in ((1, "A", len(cats)), (4, "B", len(sites)), (7, "C", 4)):
if n:
dv = DataValidation(type="list", formula1=f"'{tx['lists']}'!${src}$1:${src}${n}", allow_blank=True)
dv.error, dv.showErrorMessage = "", col != 1 # unknown categories are allowed and created on import
ws.add_data_validation(dv)
dv.add(rng(col))
row = HEADER_ROW + 1
for line in lines or []:
f = line.figures
ln = line.line
values = [cat_name(ln.category), ln.name, ln.description, ln.site.name if ln.site else "",
float(f.prior), float(f.base) if ln.monthly_kind != "manual" else None,
tx["adj"][ln.adj_kind] if ln.monthly_kind != "manual" else None,
float(ln.adj_value) if ln.adj_kind in ("percent", "factor") else None,
ln.adj_reason, ln.remarks]
if ln.monthly_kind == "manual":
values += [float(m) for m in f.months]
elif ln.monthly_kind == "selected" and ln.monthly_months:
values += [None] * 12 + [months_text(ln.monthly_months)]
for col, v in enumerate(values, start=1):
if v not in (None, ""):
ws.cell(row, col, v)
row += 1
for col in range(5, 7):
for r in range(HEADER_ROW + 1, last + 1):
ws.cell(r, col).number_format = "#,##0.00"
for col, width in zip(range(1, 11), (34, 40, 30, 14, 18, 20, 16, 14, 30, 36)):
ws.column_dimensions[get_column_letter(col)].width = width
for col in range(11, 23):
ws.column_dimensions[get_column_letter(col)].width = 11
ws.column_dimensions[get_column_letter(23)].width = 22
ws.freeze_panes = ws.cell(HEADER_ROW + 1, 3)
helpws = wb.create_sheet(tx["help"], 0)
for i, text in enumerate(tx["instructions"], start=1):
helpws.cell(i, 1, text).font = Font(bold=(i == 1), size=13 if i == 1 else 11)
helpws.cell(len(tx["instructions"]) + 2, 1, f"{tx['dept']}: {department.name} · {datetime.now():%Y-%m-%d}").font = Font(color="888888")
helpws.column_dimensions["A"].width = 110
wb.active = 1
buf = BytesIO()
wb.save(buf)
return buf.getvalue()
def _norm(value) -> str:
return str(value or "").strip().lower()
def _amount(value, warnings, where) -> tuple[str, Decimal]:
if value is None or value == "":
return "0", Decimal(0)
if isinstance(value, (int, float)):
return str(value), Decimal(str(value))
try:
return str(value).strip().lstrip("="), evaluate(str(value))
except ExpressionError:
warnings.append(f"{where}: '{value}' is not a number, used 0")
return "0", Decimal(0)
def import_template(db: Session, wb, wb_formulas, user: User | None, department_id: int, year: int | None,
replace: bool, filename: str = "", check_only: bool = False):
from .importer import ImportErrorWithDetail, ImportResult, _category_index, prepare_year
from .models import CostLine
ws = next(w for w in wb.worksheets if w.title in TEMPLATE_SHEETS)
wsf = wb_formulas[ws.title]
def cell(r: int, c: int):
# Excel stores the computed value of =4*300; files written by other tools may only hold the formula.
v = ws.cell(r, c).value
return wsf.cell(r, c).value if v is None else v
labels = {**{_norm(t["year"]): "year" for t in TEXT.values()}, **{_norm(t["rate"]): "rate" for t in TEXT.values()}}
header_row, meta = None, {}
for r in range(1, 30):
a = _norm(ws.cell(r, 1).value)
if a in labels:
meta[labels[a]] = ws.cell(r, 2).value
if a in (_norm(t["cols"][0]) for t in TEXT.values()):
header_row = r
break
if header_row is None:
raise ImportErrorWithDetail("template_header_missing")
target_year = year or (int(meta["year"]) if isinstance(meta.get("year"), (int, float)) else None)
if not target_year:
raise ImportErrorWithDetail("year_not_found")
rate_value = meta.get("rate")
try:
rate = evaluate(str(rate_value)) / 100 if rate_value not in (None, "") else Decimal(0)
except ExpressionError:
rate = Decimal(0)
result = ImportResult(year=target_year)
if check_only:
return result
by = prepare_year(db, department_id, target_year, rate, replace, user, filename)
cats = _category_index(db, department_id)
sites = {_norm(s.name): s for s in db.scalars(select(Site))}
adj_kinds = {_norm(label): kind for t in TEXT.values() for kind, label in t["adj"].items()}
adj_kinds.update({"%": "percent", "factor ×": "factor", "fator ×": "factor"})
group_words = {_norm(t["group"]) for t in TEXT.values()}
next_order = (db.scalar(select(func.max(Category.sort_order)).where(Category.department_id == department_id)) or 0) + 10
order = 0
for r in range(header_row + 1, ws.max_row + 1):
row = [cell(r, c) for c in range(1, 24)]
cat_label, name = str(row[0] or "").strip(), str(row[1] or "").strip()
if not cat_label and not name:
continue
where = f"{ws.title} row {r}"
if not cat_label or not name:
result.warnings.append(f"{where}: category and line are required, row skipped")
continue
cat = cats.get(cat_label.lower())
if cat is None:
cat = Category(department_id=department_id, name_en=cat_label, name_pt=cat_label, section="cost",
sort_order=next_order)
next_order += 10
db.add(cat)
db.flush()
cats[cat_label.lower()] = cat
result.categories_created.append(cat_label)
site = None
if row[3] not in (None, "") and _norm(row[3]) not in group_words:
site = sites.get(_norm(row[3]))
if site is None:
result.warnings.append(f"{where}: unknown site '{row[3]}', saved as group")
prior_expr, prior = _amount(row[4], result.warnings, where)
base_expr, base = _amount(row[5], result.warnings, where)
kind = adj_kinds.get(_norm(row[6]), "year_rate") if row[6] not in (None, "") else "year_rate"
_, adj_value = _amount(row[7], result.warnings, where)
months = row[10:22]
manual = any(m not in (None, "") for m in months)
month_values = [float(_amount(m, result.warnings, where)[1]) for m in months] if manual else None
selected = None
if not manual and row[22] not in (None, ""):
selected = parse_months(row[22])
if not selected:
result.warnings.append(f"{where}: could not read the months '{row[22]}', split over 12")
order += 10
db.add(CostLine(
year_id=by.id, category_id=cat.id, site_id=site.id if site else None, name=name,
description=str(row[2] or ""), remarks=str(row[9] or ""), prior_expr=prior_expr, prior_actual=prior,
base_expr=base_expr, base_amount=base, adj_kind="none" if manual else kind, adj_value=adj_value,
adj_reason=str(row[8] or ""), monthly_values=month_values, monthly_months=selected,
monthly_kind="manual" if manual else "selected" if selected else "even",
sort_order=order))
result.lines += 1
record(db, user, "year", by.id, "import", f"Imported {result.lines} lines from template into {target_year}",
year_id=by.id)
db.commit()
return result