Files

461 lines
21 KiB
Python
Raw Permalink Normal View History

2026-10-29 20:40:02 +00:00
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}