Unzip budget-app v7 into repo root

This commit is contained in:
jpmvaz
2026-10-29 20:40:02 +00:00
parent 2554e68315
commit c9fd26f1c7
94 changed files with 12098 additions and 0 deletions
@@ -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}