"""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}"'})