Flatten budget-app contents into repo root
This commit is contained in:
@@ -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"'})
|
||||
@@ -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}"'})
|
||||
@@ -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)
|
||||
@@ -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"'})
|
||||
@@ -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)})
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user