96 lines
3.9 KiB
Python
96 lines
3.9 KiB
Python
"""Who may do what with a department's budget.
|
|
|
|
Everyone signed in can view every budget. Changing and approving is limited to the budget's own
|
|
department, following the workflow the administrator set up for it. Upper Management only views,
|
|
downloads and emails budgets and reports, in every department.
|
|
"""
|
|
from fastapi import HTTPException
|
|
|
|
from .models import BudgetYear, Department, User, WorkflowStep
|
|
from .security import ROLES
|
|
|
|
|
|
def rank(user: User) -> int:
|
|
return ROLES.index(user.role)
|
|
|
|
|
|
def is_admin(user: User) -> bool:
|
|
return user.role == "admin"
|
|
|
|
|
|
def is_management(user: User) -> bool:
|
|
return user.role == "management"
|
|
|
|
|
|
def member(user: User, department_id: int) -> bool:
|
|
return user.department_id == department_id
|
|
|
|
|
|
def can_edit_department(user: User, department_id: int) -> bool:
|
|
"""Create, import and edit draft budgets of the department."""
|
|
return is_admin(user) or (member(user, department_id) and rank(user) >= ROLES.index("editor"))
|
|
|
|
|
|
def steps_of(department: Department) -> list[WorkflowStep]:
|
|
return sorted(department.steps, key=lambda s: (s.position, s.id))
|
|
|
|
|
|
def can_approve_step(user: User, step: WorkflowStep | None, department_id: int) -> bool:
|
|
if step is None:
|
|
# No workflow configured: the department's approvers sign off in one step.
|
|
return member(user, department_id) and rank(user) >= ROLES.index("approver")
|
|
if step.approver_mode == "users":
|
|
return user.id in (step.approver_ids or []) and not is_management(user)
|
|
return member(user, department_id) and rank(user) >= ROLES.index("approver")
|
|
|
|
|
|
def current_step(budget: BudgetYear) -> WorkflowStep | None:
|
|
steps = steps_of(budget.department)
|
|
if budget.status != "in_review" or not steps:
|
|
return None
|
|
index = min(budget.current_step or 0, len(steps) - 1)
|
|
return steps[index]
|
|
|
|
|
|
def can_reopen(user: User, budget: BudgetYear) -> bool:
|
|
return is_admin(user) or (member(user, budget.department_id) and rank(user) >= ROLES.index("approver"))
|
|
|
|
|
|
def pending_reopen(budget: BudgetYear):
|
|
return next((r for r in budget.reopen_requests if r.status == "pending"), None)
|
|
|
|
|
|
def allowed_actions(user: User, budget: BudgetYear) -> dict:
|
|
if budget.archived:
|
|
# a replaced version: read-only for everyone; administrators may still delete it
|
|
return {"edit": False, "submit": False, "approve": False, "return": False, "reopen": False,
|
|
"request_reopen": False, "decide_reopen": False, "upload": False, "delete": is_admin(user)}
|
|
dept = budget.department_id
|
|
editor = can_edit_department(user, dept)
|
|
approver = budget.status == "in_review" and can_approve_step(user, current_step(budget), dept)
|
|
return {
|
|
"edit": budget.status == "draft" and editor,
|
|
"submit": budget.status == "draft" and editor,
|
|
"approve": approver,
|
|
"return": approver or (budget.status == "in_review" and is_admin(user)),
|
|
"reopen": budget.status == "approved" and can_reopen(user, budget),
|
|
# anyone may ask for an approved budget to be reopened (approvers reopen it straight away)
|
|
"request_reopen": budget.status == "approved" and pending_reopen(budget) is None and not is_management(user),
|
|
"decide_reopen": budget.status == "approved" and pending_reopen(budget) is not None and can_reopen(user, budget),
|
|
"upload": budget.status == "draft" and editor,
|
|
# Deleting a budget removes it and its reports for everyone, so only administrators may, in any status.
|
|
"delete": is_admin(user),
|
|
}
|
|
|
|
|
|
def require_action(user: User, budget: BudgetYear, action: str) -> None:
|
|
if not allowed_actions(user, budget)[action]:
|
|
if action == "edit" and budget.status != "draft":
|
|
raise HTTPException(409, "year_not_draft")
|
|
raise HTTPException(403, "forbidden")
|
|
|
|
|
|
def require_department_editor(user: User, department_id: int) -> None:
|
|
if not can_edit_department(user, department_id):
|
|
raise HTTPException(403, "forbidden")
|