Flatten budget-app contents into repo root
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
tests
|
||||
**/__pycache__
|
||||
*.db
|
||||
@@ -0,0 +1,24 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
# WeasyPrint needs Pango; DejaVu fonts give the reports a consistent look.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0 \
|
||||
fonts-dejavu-core \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /srv
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY alembic.ini .
|
||||
COPY alembic ./alembic
|
||||
COPY app ./app
|
||||
COPY docker-entrypoint.sh .
|
||||
RUN chmod +x docker-entrypoint.sh && useradd --system --uid 1000 app && chown -R app /srv
|
||||
USER app
|
||||
|
||||
EXPOSE 8000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')"
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
@@ -0,0 +1,35 @@
|
||||
[alembic]
|
||||
prepend_sys_path = .
|
||||
script_location = alembic
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
@@ -0,0 +1,19 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
|
||||
from app import models # noqa: F401
|
||||
from app.db import Base, engine
|
||||
|
||||
if context.config.config_file_name:
|
||||
fileConfig(context.config.config_file_name)
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
with engine.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=Base.metadata, compare_type=True)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,22 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,121 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-10-28 20:51:51.396857
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '0001'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('categories',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name_en', sa.String(length=200), nullable=False),
|
||||
sa.Column('name_pt', sa.String(length=200), nullable=False),
|
||||
sa.Column('section', sa.String(length=20), nullable=False),
|
||||
sa.Column('sort_order', sa.Integer(), nullable=False),
|
||||
sa.Column('show_when_empty', sa.Boolean(), nullable=False),
|
||||
sa.Column('active', sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('name_en')
|
||||
)
|
||||
op.create_table('sites',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=120), nullable=False),
|
||||
sa.Column('active', sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('name')
|
||||
)
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('email', sa.String(length=255), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=255), nullable=False),
|
||||
sa.Column('role', sa.String(length=20), nullable=False),
|
||||
sa.Column('language', sa.String(length=5), nullable=False),
|
||||
sa.Column('active', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||
op.create_table('audit_log',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('year_id', sa.Integer(), nullable=True),
|
||||
sa.Column('entity', sa.String(length=40), nullable=False),
|
||||
sa.Column('entity_id', sa.Integer(), nullable=True),
|
||||
sa.Column('action', sa.String(length=40), nullable=False),
|
||||
sa.Column('summary', sa.String(length=500), nullable=False),
|
||||
sa.Column('changes', sa.JSON(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_audit_log_at'), 'audit_log', ['at'], unique=False)
|
||||
op.create_index(op.f('ix_audit_log_year_id'), 'audit_log', ['year_id'], unique=False)
|
||||
op.create_table('budget_years',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('year', sa.Integer(), nullable=False),
|
||||
sa.Column('title', sa.String(length=200), nullable=False),
|
||||
sa.Column('default_rate', sa.Numeric(precision=9, scale=6), nullable=False),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('notes', sa.Text(), nullable=False),
|
||||
sa.Column('submitted_by_id', sa.Integer(), nullable=True),
|
||||
sa.Column('submitted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('approved_by_id', sa.Integer(), nullable=True),
|
||||
sa.Column('approved_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['approved_by_id'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['submitted_by_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('year')
|
||||
)
|
||||
op.create_table('cost_lines',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('year_id', sa.Integer(), nullable=False),
|
||||
sa.Column('category_id', sa.Integer(), nullable=False),
|
||||
sa.Column('site_id', sa.Integer(), nullable=True),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.String(length=500), nullable=False),
|
||||
sa.Column('remarks', sa.Text(), nullable=False),
|
||||
sa.Column('prior_expr', sa.String(length=500), nullable=False),
|
||||
sa.Column('prior_actual', sa.Numeric(precision=18, scale=6), nullable=False),
|
||||
sa.Column('base_expr', sa.String(length=500), nullable=False),
|
||||
sa.Column('base_amount', sa.Numeric(precision=18, scale=6), nullable=False),
|
||||
sa.Column('adj_kind', sa.String(length=20), nullable=False),
|
||||
sa.Column('adj_value', sa.Numeric(precision=12, scale=6), nullable=False),
|
||||
sa.Column('adj_reason', sa.String(length=255), nullable=False),
|
||||
sa.Column('monthly_kind', sa.String(length=10), nullable=False),
|
||||
sa.Column('monthly_values', sa.JSON(), nullable=True),
|
||||
sa.Column('sort_order', sa.Integer(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ),
|
||||
sa.ForeignKeyConstraint(['site_id'], ['sites.id'], ),
|
||||
sa.ForeignKeyConstraint(['year_id'], ['budget_years.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_cost_lines_year_id'), 'cost_lines', ['year_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_cost_lines_year_id'), table_name='cost_lines')
|
||||
op.drop_table('cost_lines')
|
||||
op.drop_table('budget_years')
|
||||
op.drop_index(op.f('ix_audit_log_year_id'), table_name='audit_log')
|
||||
op.drop_index(op.f('ix_audit_log_at'), table_name='audit_log')
|
||||
op.drop_table('audit_log')
|
||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||
op.drop_table('users')
|
||||
op.drop_table('sites')
|
||||
op.drop_table('categories')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,74 @@
|
||||
"""departments, per-department categories and budgets, approval workflows
|
||||
|
||||
Existing data is kept: everything created with version 1 is moved into a department called "IT".
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0002"
|
||||
down_revision = "0001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _drop_unique_on(table: str, columns: list[str]) -> None:
|
||||
insp = sa.inspect(op.get_bind())
|
||||
for uc in insp.get_unique_constraints(table):
|
||||
if uc["column_names"] == columns:
|
||||
op.drop_constraint(uc["name"], table, type_="unique")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"departments",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("name", sa.String(length=120), nullable=False, unique=True),
|
||||
sa.Column("active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_table(
|
||||
"workflow_steps",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("department_id", sa.Integer(), sa.ForeignKey("departments.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("name", sa.String(length=120), nullable=False),
|
||||
sa.Column("approver_mode", sa.String(length=10), nullable=False, server_default="role"),
|
||||
sa.Column("approver_ids", sa.JSON(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_workflow_steps_department_id", "workflow_steps", ["department_id"])
|
||||
|
||||
op.add_column("users", sa.Column("department_id", sa.Integer(), nullable=True))
|
||||
op.add_column("categories", sa.Column("department_id", sa.Integer(), nullable=True))
|
||||
op.add_column("budget_years", sa.Column("department_id", sa.Integer(), nullable=True))
|
||||
op.add_column("budget_years", sa.Column("current_step", sa.Integer(), nullable=True))
|
||||
|
||||
conn = op.get_bind()
|
||||
has_data = any(conn.execute(sa.text(f"SELECT 1 FROM {t} LIMIT 1")).first()
|
||||
for t in ("users", "categories", "budget_years"))
|
||||
if has_data:
|
||||
conn.execute(sa.text("INSERT INTO departments (name, active, created_at) VALUES ('IT', true, now())"))
|
||||
dept_id = conn.execute(sa.text("SELECT id FROM departments WHERE name = 'IT'")).scalar_one()
|
||||
for table in ("users", "categories", "budget_years"):
|
||||
conn.execute(sa.text(f"UPDATE {table} SET department_id = :d"), {"d": dept_id})
|
||||
conn.execute(sa.text("UPDATE budget_years SET current_step = 0 WHERE status = 'in_review'"))
|
||||
|
||||
op.alter_column("categories", "department_id", nullable=False)
|
||||
op.alter_column("budget_years", "department_id", nullable=False)
|
||||
op.create_foreign_key("fk_users_department", "users", "departments", ["department_id"], ["id"])
|
||||
op.create_foreign_key("fk_categories_department", "categories", "departments", ["department_id"], ["id"],
|
||||
ondelete="CASCADE")
|
||||
op.create_foreign_key("fk_budget_years_department", "budget_years", "departments", ["department_id"], ["id"])
|
||||
op.create_index("ix_categories_department_id", "categories", ["department_id"])
|
||||
op.create_index("ix_budget_years_department_id", "budget_years", ["department_id"])
|
||||
|
||||
_drop_unique_on("categories", ["name_en"])
|
||||
_drop_unique_on("budget_years", ["year"])
|
||||
op.create_unique_constraint("uq_categories_department_name", "categories", ["department_id", "name_en"])
|
||||
op.create_unique_constraint("uq_budget_years_department_year", "budget_years", ["department_id", "year"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise NotImplementedError("Restore a backup taken before the upgrade instead.")
|
||||
@@ -0,0 +1,69 @@
|
||||
"""mail account settings and the mail log
|
||||
|
||||
Only adds tables; existing data is untouched.
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0003"
|
||||
down_revision = "0002"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"mail_settings",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("smtp_host", sa.String(length=255), nullable=False, server_default=""),
|
||||
sa.Column("smtp_port", sa.Integer(), nullable=False, server_default="587"),
|
||||
sa.Column("smtp_security", sa.String(length=10), nullable=False, server_default="starttls"),
|
||||
sa.Column("smtp_username", sa.String(length=255), nullable=False, server_default=""),
|
||||
sa.Column("smtp_password", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("from_address", sa.String(length=255), nullable=False, server_default=""),
|
||||
sa.Column("from_name", sa.String(length=255), nullable=False, server_default=""),
|
||||
sa.Column("reply_to", sa.String(length=255), nullable=False, server_default=""),
|
||||
sa.Column("signature", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("save_to_sent", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("imap_host", sa.String(length=255), nullable=False, server_default=""),
|
||||
sa.Column("imap_port", sa.Integer(), nullable=False, server_default="993"),
|
||||
sa.Column("imap_security", sa.String(length=10), nullable=False, server_default="ssl"),
|
||||
sa.Column("imap_same_login", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("imap_username", sa.String(length=255), nullable=False, server_default=""),
|
||||
sa.Column("imap_password", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("sent_folder", sa.String(length=255), nullable=False, server_default=""),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("updated_by_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
|
||||
)
|
||||
op.create_table(
|
||||
"mail_log",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column("kind", sa.String(length=20), nullable=False),
|
||||
sa.Column("year_id", sa.Integer(), nullable=True),
|
||||
sa.Column("context", sa.String(length=255), nullable=False),
|
||||
sa.Column("sender", sa.String(length=255), nullable=False),
|
||||
sa.Column("recipients", sa.JSON(), nullable=False),
|
||||
sa.Column("subject", sa.String(length=500), nullable=False),
|
||||
sa.Column("body", sa.Text(), nullable=False),
|
||||
sa.Column("attachments", sa.JSON(), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("error", sa.String(length=1000), nullable=False),
|
||||
sa.Column("sent_copy", sa.String(length=20), nullable=False),
|
||||
sa.Column("sent_copy_detail", sa.String(length=500), nullable=False),
|
||||
sa.Column("message_id", sa.String(length=255), nullable=False),
|
||||
)
|
||||
op.create_index("ix_mail_log_at", "mail_log", ["at"])
|
||||
op.create_index("ix_mail_log_year_id", "mail_log", ["year_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_mail_log_year_id", "mail_log")
|
||||
op.drop_index("ix_mail_log_at", "mail_log")
|
||||
op.drop_table("mail_log")
|
||||
op.drop_table("mail_settings")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""cost lines can be split over selected months only
|
||||
|
||||
Only adds a nullable column; existing lines keep their 12-month or monthly-amount split.
|
||||
|
||||
Revision ID: 0004
|
||||
Revises: 0003
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0004"
|
||||
down_revision = "0003"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("cost_lines", sa.Column("monthly_months", sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# lines split over selected months fall back to the 12-month split
|
||||
op.execute("UPDATE cost_lines SET monthly_kind = 'even' WHERE monthly_kind = 'selected'")
|
||||
op.drop_column("cost_lines", "monthly_months")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""reopen requests, archived budget versions, source file name
|
||||
|
||||
Existing budgets stay as they are (not archived). The one-budget-per-department-and-year rule now only
|
||||
applies to current budgets, so a replaced budget can be kept, archived, next to its replacement.
|
||||
|
||||
Revision ID: 0005
|
||||
Revises: 0004
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0005"
|
||||
down_revision = "0004"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("budget_years", sa.Column("source_file", sa.String(length=255), nullable=False, server_default=""))
|
||||
op.add_column("budget_years", sa.Column("archived", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
op.add_column("budget_years", sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("budget_years", sa.Column("archived_by_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True))
|
||||
op.drop_constraint("uq_budget_years_department_year", "budget_years", type_="unique")
|
||||
op.create_index("uq_budget_years_current", "budget_years", ["department_id", "year"], unique=True,
|
||||
postgresql_where=sa.text("NOT archived"), sqlite_where=sa.text("NOT archived"))
|
||||
op.create_table(
|
||||
"reopen_requests",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("year_id", sa.Integer(), sa.ForeignKey("budget_years.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("requested_by_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column("requested_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("reason", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("decided_by_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("decision_note", sa.Text(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_reopen_requests_year_id", "reopen_requests", ["year_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise NotImplementedError("restore the backup taken before the upgrade instead")
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Upper Management role
|
||||
|
||||
No table changes: users.role already holds the new value "management". The revision exists so that
|
||||
upgrading to version 6 still takes the before-upgrade backup, like every other version.
|
||||
|
||||
Revision ID: 0006
|
||||
Revises: 0005
|
||||
"""
|
||||
|
||||
revision = "0006"
|
||||
down_revision = "0005"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# users given the new role go back to read-only viewers
|
||||
from alembic import op
|
||||
op.execute("UPDATE users SET role = 'viewer' WHERE role = 'management'")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""MFA, profile picture and light/dark theme for users
|
||||
|
||||
Only adds columns to users; existing users keep their data, start without MFA or a picture, and follow
|
||||
the computer's light/dark setting.
|
||||
|
||||
Revision ID: 0007
|
||||
Revises: 0006
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0007"
|
||||
down_revision = "0006"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
COLUMNS = [
|
||||
sa.Column("theme", sa.String(length=10), nullable=False, server_default="system"),
|
||||
sa.Column("mfa_enabled", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("mfa_secret", sa.String(length=255), nullable=True),
|
||||
sa.Column("mfa_pending_secret", sa.String(length=255), nullable=True),
|
||||
sa.Column("mfa_enabled_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("mfa_last_step", sa.BigInteger(), nullable=True),
|
||||
sa.Column("mfa_recovery", sa.JSON(), nullable=True),
|
||||
sa.Column("mfa_failed", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("mfa_locked_until", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("avatar", sa.LargeBinary(), nullable=True),
|
||||
sa.Column("avatar_type", sa.String(length=30), nullable=True),
|
||||
sa.Column("avatar_updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for column in COLUMNS:
|
||||
op.add_column("users", column)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for column in reversed(COLUMNS):
|
||||
op.drop_column("users", column.name)
|
||||
@@ -0,0 +1,25 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import AuditEntry, User
|
||||
|
||||
|
||||
def _plain(value):
|
||||
if isinstance(value, Decimal):
|
||||
return str(value.normalize()) if value == value.to_integral() else str(value)
|
||||
return value
|
||||
|
||||
|
||||
def snapshot(obj, fields: list[str]) -> dict:
|
||||
return {f: _plain(getattr(obj, f)) for f in fields}
|
||||
|
||||
|
||||
def diff(before: dict, after: dict) -> dict:
|
||||
return {k: [before.get(k), after.get(k)] for k in after if before.get(k) != after.get(k)}
|
||||
|
||||
|
||||
def record(db: Session, user: User | None, entity: str, entity_id: int | None, action: str,
|
||||
summary: str = "", changes: dict | None = None, year_id: int | None = None) -> None:
|
||||
db.add(AuditEntry(user_id=user.id if user else None, entity=entity, entity_id=entity_id,
|
||||
action=action, summary=summary[:500], changes=changes, year_id=year_id))
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Run once at container start: creates the first admin and the default categories and sites."""
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from .config import settings
|
||||
from .db import SessionLocal
|
||||
from .models import Category, Department, Site, User
|
||||
from .security import hash_password
|
||||
from .seed import CATEGORIES, SITES
|
||||
|
||||
DEFAULT_DEPARTMENT = "IT"
|
||||
|
||||
|
||||
def seed_categories(db, department: Department) -> None:
|
||||
for i, (en, pt, section) in enumerate(CATEGORIES):
|
||||
db.add(Category(department_id=department.id, name_en=en, name_pt=pt, section=section, sort_order=(i + 1) * 10))
|
||||
|
||||
|
||||
def bootstrap() -> None:
|
||||
with SessionLocal() as db:
|
||||
if not db.scalar(select(func.count(Department.id))):
|
||||
dept = Department(name=DEFAULT_DEPARTMENT)
|
||||
db.add(dept)
|
||||
db.flush()
|
||||
seed_categories(db, dept)
|
||||
if not db.scalar(select(func.count(User.id))):
|
||||
db.add(User(email=settings.admin_email.lower(), name=settings.admin_name,
|
||||
password_hash=hash_password(settings.admin_password), role="admin",
|
||||
language=settings.default_language,
|
||||
department_id=db.scalar(select(Department.id).order_by(Department.id))))
|
||||
print(f"Created admin user {settings.admin_email}")
|
||||
if not db.scalar(select(func.count(Site.id))):
|
||||
for name in SITES:
|
||||
db.add(Site(name=name))
|
||||
db.commit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
bootstrap()
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Amount expressions and budget arithmetic. All money math uses Decimal."""
|
||||
import ast
|
||||
import operator
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
|
||||
MONTHS = 12
|
||||
ZERO = Decimal("0")
|
||||
|
||||
_OPS = {
|
||||
ast.Add: operator.add,
|
||||
ast.Sub: operator.sub,
|
||||
ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv,
|
||||
}
|
||||
|
||||
|
||||
class ExpressionError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def normalize_number_text(text: str) -> str:
|
||||
"""Accept '1 234,56', '1.234,56' and '1,234.56' as well as plain numbers."""
|
||||
s = text.replace(" ", "").replace(" ", "").replace("€", "")
|
||||
if "," in s and "." in s:
|
||||
if s.rfind(",") > s.rfind("."):
|
||||
s = s.replace(".", "").replace(",", ".")
|
||||
else:
|
||||
s = s.replace(",", "")
|
||||
elif "," in s:
|
||||
s = s.replace(",", ".")
|
||||
return s
|
||||
|
||||
|
||||
def evaluate(expr: str | None) -> Decimal:
|
||||
"""Evaluate a typed amount such as '=5512*12' or '3292.56+(12*839.67)'."""
|
||||
if expr is None:
|
||||
return ZERO
|
||||
s = str(expr).strip()
|
||||
if s.startswith("="):
|
||||
s = s[1:]
|
||||
if not s:
|
||||
return ZERO
|
||||
s = normalize_number_text(s)
|
||||
if len(s) > 400:
|
||||
raise ExpressionError("expression too long")
|
||||
try:
|
||||
tree = ast.parse(s, mode="eval")
|
||||
except SyntaxError as exc:
|
||||
raise ExpressionError(f"invalid expression: {expr}") from exc
|
||||
|
||||
def walk(node: ast.AST) -> Decimal:
|
||||
if isinstance(node, ast.Expression):
|
||||
return walk(node.body)
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)) and not isinstance(node.value, bool):
|
||||
return Decimal(str(node.value))
|
||||
if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
|
||||
left, right = walk(node.left), walk(node.right)
|
||||
if isinstance(node.op, ast.Div) and right == 0:
|
||||
raise ExpressionError("division by zero")
|
||||
return _OPS[type(node.op)](left, right)
|
||||
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
|
||||
value = walk(node.operand)
|
||||
return -value if isinstance(node.op, ast.USub) else value
|
||||
raise ExpressionError(f"invalid expression: {expr}")
|
||||
|
||||
try:
|
||||
return walk(tree)
|
||||
except InvalidOperation as exc:
|
||||
raise ExpressionError(f"invalid expression: {expr}") from exc
|
||||
|
||||
|
||||
def round2(value: Decimal) -> Decimal:
|
||||
return Decimal(value).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def factor_for(adj_kind: str, adj_value: Decimal, year_rate: Decimal) -> Decimal:
|
||||
adj_value = Decimal(adj_value or 0)
|
||||
if adj_kind == "year_rate":
|
||||
return Decimal(1) + Decimal(year_rate or 0)
|
||||
if adj_kind == "percent":
|
||||
return Decimal(1) + adj_value / Decimal(100)
|
||||
if adj_kind == "factor":
|
||||
return adj_value
|
||||
return Decimal(1)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LineFigures:
|
||||
prior: Decimal
|
||||
base: Decimal
|
||||
factor: Decimal
|
||||
adjusted: Decimal
|
||||
months: list[Decimal]
|
||||
|
||||
@property
|
||||
def change(self) -> Decimal:
|
||||
return self.adjusted - self.prior
|
||||
|
||||
@property
|
||||
def change_pct(self) -> Decimal | None:
|
||||
return None if self.prior == 0 else self.change / self.prior * 100
|
||||
|
||||
|
||||
def line_figures(line, year_rate: Decimal) -> LineFigures:
|
||||
# Each line is rounded to cents before it is summed, as the finance reports do.
|
||||
prior = round2(Decimal(line.prior_actual or 0))
|
||||
if line.monthly_kind == "manual":
|
||||
months = [Decimal(str(v or 0)) for v in (line.monthly_values or [])][:MONTHS]
|
||||
months += [ZERO] * (MONTHS - len(months))
|
||||
total = sum(months, ZERO)
|
||||
return LineFigures(prior, total, Decimal(1), total, months)
|
||||
base = round2(Decimal(line.base_amount or 0))
|
||||
factor = factor_for(line.adj_kind, line.adj_value, year_rate)
|
||||
adjusted = round2(Decimal(line.base_amount or 0) * factor)
|
||||
if line.monthly_kind == "selected":
|
||||
return LineFigures(prior, base, factor, adjusted, selected_spread(adjusted, line.monthly_months))
|
||||
return LineFigures(prior, base, factor, adjusted, even_spread(adjusted))
|
||||
|
||||
|
||||
def even_spread(total: Decimal) -> list[Decimal]:
|
||||
"""Twelve months in cents; December takes the rounding remainder so the months add up exactly."""
|
||||
month = round2(total / MONTHS)
|
||||
return [month] * (MONTHS - 1) + [total - month * (MONTHS - 1)]
|
||||
|
||||
|
||||
def clean_months(months) -> list[int]:
|
||||
"""Month numbers 1-12, sorted and without repeats."""
|
||||
return sorted({int(m) for m in (months or []) if 1 <= int(m) <= MONTHS})
|
||||
|
||||
|
||||
def selected_spread(total: Decimal, months) -> list[Decimal]:
|
||||
"""Equal amounts in cents over the chosen months only (1 = January); the last chosen month takes the
|
||||
rounding remainder. No months chosen means all twelve."""
|
||||
chosen = clean_months(months) or list(range(1, MONTHS + 1))
|
||||
share = round2(total / len(chosen))
|
||||
out = [ZERO] * MONTHS
|
||||
for m in chosen:
|
||||
out[m - 1] = share
|
||||
out[chosen[-1] - 1] = total - share * (len(chosen) - 1)
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class Totals:
|
||||
prior: Decimal = ZERO
|
||||
base: Decimal = ZERO
|
||||
adjusted: Decimal = ZERO
|
||||
months: list[Decimal] = field(default_factory=lambda: [ZERO] * MONTHS)
|
||||
|
||||
def add(self, f: LineFigures) -> None:
|
||||
self.prior += f.prior
|
||||
self.base += f.base
|
||||
self.adjusted += f.adjusted
|
||||
self.months = [a + b for a, b in zip(self.months, f.months)]
|
||||
|
||||
@property
|
||||
def change(self) -> Decimal:
|
||||
return self.adjusted - self.prior
|
||||
|
||||
@property
|
||||
def change_pct(self) -> Decimal | None:
|
||||
return None if self.prior == 0 else self.change / self.prior * 100
|
||||
@@ -0,0 +1,16 @@
|
||||
import os
|
||||
|
||||
|
||||
class Settings:
|
||||
database_url: str = os.getenv("DATABASE_URL", "sqlite:///./budget.db")
|
||||
secret_key: str = os.getenv("SECRET_KEY", "change-me")
|
||||
admin_email: str = os.getenv("ADMIN_EMAIL", "admin@example.com")
|
||||
admin_password: str = os.getenv("ADMIN_PASSWORD", "admin")
|
||||
admin_name: str = os.getenv("ADMIN_NAME", "Administrator")
|
||||
default_language: str = os.getenv("DEFAULT_LANGUAGE", "pt")
|
||||
cookie_secure: bool = os.getenv("COOKIE_SECURE", "false").lower() == "true"
|
||||
session_hours: int = int(os.getenv("SESSION_HOURS", "12"))
|
||||
organization: str = os.getenv("ORGANIZATION_NAME", "")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,20 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
|
||||
from .config import settings
|
||||
|
||||
_connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
|
||||
engine = create_engine(settings.database_url, connect_args=_connect_args, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Imports a workbook laid out like 'Budget para 2027.xlsx'.
|
||||
|
||||
Sheet1 holds one row per cost line (prior actual, base, adjusted, remarks); the 'Budget IT'
|
||||
sheet says which category each row belongs to (column P = '=Sheet1!D<row>') and holds the
|
||||
revenue lines with typed monthly values.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import openpyxl
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .audit import record
|
||||
from .calc import ExpressionError, evaluate
|
||||
from .models import BudgetYear, Category, CostLine, Site, User
|
||||
|
||||
REF_RE = re.compile(r"^=\s*'?Sheet1'?!\$?D\$?(\d+)\s*$", re.I)
|
||||
FACTOR_RE = re.compile(r"^=\s*\$?C\$?\d+\s*\*\s*([\d.]+)\s*$", re.I)
|
||||
ARITH_RE = re.compile(r"^=?[\d\s.+\-*/()]+$")
|
||||
HEADER_RE = re.compile(r"^(\d{4})\s+(ATUAL|ACTUAL|SEM AJUSTE|COM AJUSTE)", re.I)
|
||||
|
||||
# Reasons the budget owner gave for factors that differ from the year's rate.
|
||||
KNOWN_REASONS = {
|
||||
"hardware": "Aumento de preço do hardware / Hardware price increase",
|
||||
}
|
||||
|
||||
|
||||
class ImportErrorWithDetail(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportResult:
|
||||
year: int
|
||||
lines: int = 0
|
||||
categories_created: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _sheet(wb, *names):
|
||||
for ws in wb.worksheets:
|
||||
if ws.title.strip().lower() in [n.lower() for n in names]:
|
||||
return ws
|
||||
return None
|
||||
|
||||
|
||||
def _amount(formula_cell, value_cell, warnings: list[str], where: str) -> tuple[str, Decimal]:
|
||||
"""Keep a pure-arithmetic formula as the typed expression; otherwise take Excel's cached value."""
|
||||
raw = formula_cell.value
|
||||
if raw is None or raw == "":
|
||||
return "0", Decimal("0")
|
||||
if isinstance(raw, (int, float)):
|
||||
return str(raw), Decimal(str(raw))
|
||||
text = str(raw).strip()
|
||||
if ARITH_RE.match(text):
|
||||
try:
|
||||
return text.lstrip("="), evaluate(text)
|
||||
except ExpressionError:
|
||||
pass
|
||||
cached = value_cell.value
|
||||
if isinstance(cached, (int, float)):
|
||||
return str(round(cached, 6)), Decimal(str(cached))
|
||||
warnings.append(f"{where}: could not read '{text}', used 0")
|
||||
return "0", Decimal("0")
|
||||
|
||||
|
||||
def _category_index(db: Session, department_id: int) -> dict[str, Category]:
|
||||
cats = db.scalars(select(Category).where(Category.department_id == department_id))
|
||||
index = {}
|
||||
for c in cats:
|
||||
index[c.name_en.strip().lower()] = c
|
||||
if c.name_pt:
|
||||
index.setdefault(c.name_pt.strip().lower(), c)
|
||||
return index
|
||||
|
||||
|
||||
def current_year(db: Session, department_id: int, year: int) -> BudgetYear | None:
|
||||
return db.scalar(select(BudgetYear).where(BudgetYear.department_id == department_id, BudgetYear.year == year,
|
||||
BudgetYear.archived.is_(False)))
|
||||
|
||||
|
||||
def archived_name(text: str, when: datetime) -> str:
|
||||
"""'Orcamento_IT_2027.xlsx' -> 'Orcamento_IT_2027_2026-10-28_2250.xlsx'; titles get ' (2026-10-28 22:50)'."""
|
||||
if "." in text and text.rsplit(".", 1)[1].lower() in ("xlsx", "xlsm", "xls"):
|
||||
stem, ext = text.rsplit(".", 1)
|
||||
return f"{stem}_{when:%Y-%m-%d_%H%M}.{ext}"
|
||||
return f"{text} ({when:%Y-%m-%d %H:%M})"
|
||||
|
||||
|
||||
def archive_year(db: Session, existing: BudgetYear, user: User | None) -> None:
|
||||
"""Keep a replaced budget, renamed with the current date and time and locked against changes."""
|
||||
when = datetime.now(ZoneInfo(os.getenv("TZ") or "Europe/Lisbon"))
|
||||
old_title, old_file = existing.title, existing.source_file
|
||||
existing.title = archived_name(existing.title or f"Budget {existing.year}", when)[:200]
|
||||
if existing.source_file:
|
||||
existing.source_file = archived_name(existing.source_file, when)[:255]
|
||||
existing.archived, existing.archived_at = True, datetime.now(timezone.utc)
|
||||
existing.archived_by_id = user.id if user else None
|
||||
db.flush()
|
||||
record(db, user, "year", existing.id, "archive",
|
||||
f"{existing.department.name} {existing.year}: '{old_title}' → '{existing.title}'"
|
||||
+ (f" ({old_file} → {existing.source_file})" if old_file else ""), year_id=existing.id)
|
||||
|
||||
|
||||
def prepare_year(db: Session, department_id: int, year: int, rate: Decimal, replace: bool,
|
||||
user: User | None = None, source_file: str = "") -> BudgetYear:
|
||||
existing = current_year(db, department_id, year)
|
||||
if existing and not replace:
|
||||
raise ImportErrorWithDetail("year_exists")
|
||||
if existing and existing.status != "draft":
|
||||
raise ImportErrorWithDetail("year_not_draft")
|
||||
if existing:
|
||||
archive_year(db, existing, user)
|
||||
by = BudgetYear(department_id=department_id, year=year, default_rate=rate, source_file=source_file[:255],
|
||||
title=existing_title(existing, year), notes=existing.notes if existing else "")
|
||||
db.add(by)
|
||||
db.flush()
|
||||
return by
|
||||
|
||||
|
||||
def existing_title(existing: BudgetYear | None, year: int) -> str:
|
||||
if existing is None:
|
||||
return f"Budget {year}"
|
||||
# the replacement keeps the original title, without the archive date
|
||||
title = existing.title
|
||||
return title[: title.rfind(" (")] if title.endswith(")") and " (" in title else title
|
||||
|
||||
|
||||
def import_workbook(db: Session, data: bytes, user: User | None, department_id: int, year: int | None = None,
|
||||
replace: bool = False, filename: str = "", check_only: bool = False) -> ImportResult:
|
||||
"""Imports the file into a new budget. With check_only, only reads which year it is for."""
|
||||
try:
|
||||
wb_f = openpyxl.load_workbook(BytesIO(data))
|
||||
wb_v = openpyxl.load_workbook(BytesIO(data), data_only=True)
|
||||
except Exception as exc: # openpyxl raises several unrelated types for a bad file
|
||||
raise ImportErrorWithDetail("not_an_xlsx") from exc
|
||||
|
||||
from .template import TEMPLATE_SHEETS, import_template
|
||||
if any(ws.title in TEMPLATE_SHEETS for ws in wb_v.worksheets):
|
||||
return import_template(db, wb_v, wb_f, user, department_id, year, replace, filename, check_only)
|
||||
|
||||
inputs_f, inputs_v = _sheet(wb_f, "Sheet1", "Input", "Inputs"), _sheet(wb_v, "Sheet1", "Input", "Inputs")
|
||||
budget_f = _sheet(wb_f, "Budget IT", "Budget")
|
||||
if inputs_f is None or budget_f is None:
|
||||
raise ImportErrorWithDetail("missing_sheets")
|
||||
budget_v = wb_v[budget_f.title]
|
||||
|
||||
# The target year comes from the header row ("2026 ATUAL | 2027 SEM AJUSTE | 2027 COM AJUSTE").
|
||||
detected = None
|
||||
for row in inputs_f.iter_rows(min_row=1, max_row=10):
|
||||
for cell in row:
|
||||
m = HEADER_RE.match(str(cell.value or ""))
|
||||
if m and "AJUSTE" in m.group(2).upper():
|
||||
detected = int(m.group(1))
|
||||
target_year = year or detected
|
||||
if not target_year:
|
||||
raise ImportErrorWithDetail("year_not_found")
|
||||
|
||||
rate = inputs_v["B1"].value if isinstance(inputs_v["B1"].value, (int, float)) else 0
|
||||
rate = Decimal(str(rate))
|
||||
result = ImportResult(year=target_year)
|
||||
if check_only:
|
||||
return result
|
||||
|
||||
by = prepare_year(db, department_id, target_year, rate, replace, user, filename)
|
||||
|
||||
cats = _category_index(db, department_id)
|
||||
next_order = (db.scalar(select(func.max(Category.sort_order)).where(Category.department_id == department_id)) or 0) + 10
|
||||
sites = list(db.scalars(select(Site)))
|
||||
|
||||
def category(name: str) -> Category:
|
||||
nonlocal next_order
|
||||
key = name.strip().lower()
|
||||
if key not in cats:
|
||||
cat = Category(department_id=department_id,
|
||||
name_en=name.strip().title() if name.isupper() else name.strip(),
|
||||
section="revenue" if "revenue" in key else "cost", sort_order=next_order)
|
||||
next_order += 10
|
||||
db.add(cat)
|
||||
db.flush()
|
||||
cats[key] = cat
|
||||
result.categories_created.append(cat.name_en)
|
||||
return cats[key]
|
||||
|
||||
def site_for(name: str):
|
||||
for s in sites:
|
||||
if re.search(rf"\b{re.escape(s.name)}\b", name, re.I):
|
||||
return s
|
||||
return None
|
||||
|
||||
# Walk 'Budget IT': headings, then lines that point at Sheet1 or hold typed monthly values.
|
||||
current = None
|
||||
order = 0
|
||||
for r in range(1, budget_f.max_row + 1):
|
||||
label = budget_f.cell(r, 1).value
|
||||
p = budget_f.cell(r, 16).value
|
||||
months_f = [budget_f.cell(r, c).value for c in range(3, 15)]
|
||||
if not isinstance(label, str) or not label.strip() or label.strip().startswith("…"):
|
||||
continue
|
||||
label = label.strip()
|
||||
if label.lower().startswith("sub total"):
|
||||
current = None
|
||||
continue
|
||||
if p is None and all(v is None for v in months_f):
|
||||
if r > 9:
|
||||
current = category(label)
|
||||
continue
|
||||
if current is None:
|
||||
continue
|
||||
order += 10
|
||||
m = REF_RE.match(str(p or ""))
|
||||
if m:
|
||||
sr = int(m.group(1))
|
||||
short = inputs_f.cell(sr, 1).value
|
||||
name = str(short).strip() if short else label
|
||||
where = f"Sheet1 row {sr}"
|
||||
prior_expr, prior = _amount(inputs_f.cell(sr, 2), inputs_v.cell(sr, 2), result.warnings, where)
|
||||
base_expr, base = _amount(inputs_f.cell(sr, 3), inputs_v.cell(sr, 3), result.warnings, where)
|
||||
d_formula = str(inputs_f.cell(sr, 4).value or "")
|
||||
fm = FACTOR_RE.match(d_formula)
|
||||
if fm:
|
||||
factor = Decimal(fm.group(1))
|
||||
else:
|
||||
d_val = inputs_v.cell(sr, 4).value
|
||||
factor = (Decimal(str(d_val)) / base) if base and isinstance(d_val, (int, float)) else Decimal(1)
|
||||
if factor == 1 + rate:
|
||||
adj_kind, adj_value, reason = "year_rate", Decimal(0), ""
|
||||
else:
|
||||
adj_kind, adj_value = "factor", factor
|
||||
reason = KNOWN_REASONS.get(current.name_en.lower(), "") if factor >= 2 else ""
|
||||
line = CostLine(
|
||||
year_id=by.id, category_id=current.id, site_id=(s.id if (s := site_for(label + " " + name)) else None),
|
||||
name=name, description=label if label != name else "",
|
||||
remarks=str(inputs_f.cell(sr, 5).value or ""),
|
||||
prior_expr=prior_expr, prior_actual=prior, base_expr=base_expr, base_amount=base,
|
||||
adj_kind=adj_kind, adj_value=adj_value, adj_reason=reason, sort_order=order)
|
||||
else:
|
||||
values = []
|
||||
for c in range(3, 15):
|
||||
v = budget_v.cell(r, c).value
|
||||
values.append(float(v) if isinstance(v, (int, float)) else 0.0)
|
||||
line = CostLine(year_id=by.id, category_id=current.id, name=label, monthly_kind="manual",
|
||||
monthly_values=values, adj_kind="none", sort_order=order)
|
||||
db.add(line)
|
||||
result.lines += 1
|
||||
|
||||
record(db, user, "year", by.id, "import", f"Imported {result.lines} lines into {target_year}", year_id=by.id)
|
||||
db.commit()
|
||||
return result
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Sending reports by email through the configured account, with a copy in its Sent folder.
|
||||
|
||||
SMTP sends the message. When "save to Sent" is on, the same message is then appended over IMAP to the
|
||||
mailbox's Sent folder (found by its \\Sent flag, or by a usual name), so it shows up there like any
|
||||
message sent from a mail program. Every attempt is written to the mail log, whatever the outcome.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import imaplib
|
||||
import re
|
||||
import smtplib
|
||||
import socket
|
||||
import ssl
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr, formatdate, make_msgid
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .audit import record
|
||||
from .config import settings
|
||||
from .models import MailLog, MailSettings, User
|
||||
|
||||
TIMEOUT = 30
|
||||
MAX_RECIPIENTS = 25
|
||||
ADDRESS = re.compile(r"^[^@\s<>,;\"']+@[^@\s<>,;\"']+\.[^@\s<>,;\"']+$")
|
||||
SENT_NAMES = ("sent", "sent items", "sent messages", "sent mail", "enviados", "itens enviados",
|
||||
"mensagens enviadas", "elementos enviados", "correo enviado")
|
||||
|
||||
|
||||
class MailError(Exception):
|
||||
def __init__(self, code: str, detail: str = ""):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.detail = detail[:500]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Attachment:
|
||||
name: str
|
||||
mime: str
|
||||
data: bytes
|
||||
|
||||
|
||||
# --- stored passwords -------------------------------------------------------------------------
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
key = hashlib.sha256(("mail-password:" + settings.secret_key).encode()).digest()
|
||||
return Fernet(base64.urlsafe_b64encode(key))
|
||||
|
||||
|
||||
def encrypt(password: str) -> str:
|
||||
return _fernet().encrypt(password.encode()).decode() if password else ""
|
||||
|
||||
|
||||
def decrypt(token: str) -> str:
|
||||
if not token:
|
||||
return ""
|
||||
try:
|
||||
return _fernet().decrypt(token.encode()).decode()
|
||||
except InvalidToken:
|
||||
# SECRET_KEY changed since the password was saved
|
||||
raise MailError("password_unreadable")
|
||||
|
||||
|
||||
def get_settings(db: Session) -> MailSettings:
|
||||
cfg = db.get(MailSettings, 1)
|
||||
if cfg is None:
|
||||
cfg = MailSettings(id=1)
|
||||
db.add(cfg)
|
||||
db.flush()
|
||||
return cfg
|
||||
|
||||
|
||||
def is_ready(cfg: MailSettings) -> bool:
|
||||
return bool(cfg.enabled and cfg.smtp_host and cfg.from_address)
|
||||
|
||||
|
||||
def clean_addresses(values: list[str] | None) -> list[str]:
|
||||
out = []
|
||||
for raw in values or []:
|
||||
for part in re.split(r"[,;\s]+", raw or ""):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
if not ADDRESS.match(part):
|
||||
raise MailError("invalid_address", part)
|
||||
if part.lower() not in (a.lower() for a in out):
|
||||
out.append(part)
|
||||
return out
|
||||
|
||||
|
||||
# --- SMTP -------------------------------------------------------------------------------------
|
||||
|
||||
def _smtp(cfg: MailSettings) -> smtplib.SMTP:
|
||||
context = ssl.create_default_context()
|
||||
try:
|
||||
if cfg.smtp_security == "ssl":
|
||||
server = smtplib.SMTP_SSL(cfg.smtp_host, cfg.smtp_port, timeout=TIMEOUT, context=context)
|
||||
else:
|
||||
server = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=TIMEOUT)
|
||||
server.ehlo()
|
||||
if cfg.smtp_security == "starttls":
|
||||
server.starttls(context=context)
|
||||
server.ehlo()
|
||||
if cfg.smtp_username:
|
||||
server.login(cfg.smtp_username, decrypt(cfg.smtp_password))
|
||||
return server
|
||||
except MailError:
|
||||
raise
|
||||
except smtplib.SMTPAuthenticationError as exc:
|
||||
raise MailError("smtp_auth", _smtp_text(exc))
|
||||
except ssl.SSLError as exc:
|
||||
raise MailError("smtp_tls", str(exc))
|
||||
except smtplib.SMTPNotSupportedError as exc:
|
||||
raise MailError("smtp_tls", str(exc))
|
||||
except (socket.gaierror, socket.timeout, ConnectionError, OSError) as exc:
|
||||
raise MailError("smtp_connect", f"{cfg.smtp_host}:{cfg.smtp_port} — {exc}")
|
||||
except smtplib.SMTPException as exc:
|
||||
raise MailError("smtp_error", _smtp_text(exc))
|
||||
|
||||
|
||||
def _smtp_text(exc: Exception) -> str:
|
||||
if isinstance(exc, smtplib.SMTPResponseException):
|
||||
msg = exc.smtp_error.decode(errors="replace") if isinstance(exc.smtp_error, bytes) else str(exc.smtp_error)
|
||||
return f"{exc.smtp_code} {msg}"
|
||||
return str(exc)
|
||||
|
||||
|
||||
def smtp_send(cfg: MailSettings, msg: EmailMessage, recipients: list[str]) -> None:
|
||||
server = _smtp(cfg)
|
||||
try:
|
||||
refused = server.send_message(msg, from_addr=cfg.from_address, to_addrs=recipients)
|
||||
if refused:
|
||||
raise MailError("smtp_recipients", ", ".join(refused))
|
||||
except smtplib.SMTPRecipientsRefused as exc:
|
||||
raise MailError("smtp_recipients", ", ".join(exc.recipients))
|
||||
except smtplib.SMTPException as exc:
|
||||
raise MailError("smtp_error", _smtp_text(exc))
|
||||
except OSError as exc:
|
||||
raise MailError("smtp_connect", str(exc))
|
||||
finally:
|
||||
try:
|
||||
server.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# --- IMAP (copy in the Sent folder) -----------------------------------------------------------
|
||||
|
||||
def _imap(cfg: MailSettings) -> imaplib.IMAP4:
|
||||
host = cfg.imap_host or cfg.smtp_host
|
||||
username = cfg.smtp_username if cfg.imap_same_login else cfg.imap_username
|
||||
password = cfg.smtp_password if cfg.imap_same_login else cfg.imap_password
|
||||
context = ssl.create_default_context()
|
||||
try:
|
||||
if cfg.imap_security == "ssl":
|
||||
box = imaplib.IMAP4_SSL(host, cfg.imap_port, ssl_context=context, timeout=TIMEOUT)
|
||||
else:
|
||||
box = imaplib.IMAP4(host, cfg.imap_port, timeout=TIMEOUT)
|
||||
if cfg.imap_security == "starttls":
|
||||
box.starttls(ssl_context=context)
|
||||
box.login(username, decrypt(password))
|
||||
return box
|
||||
except MailError:
|
||||
raise
|
||||
except imaplib.IMAP4.error as exc:
|
||||
raise MailError("imap_auth", _imap_text(exc))
|
||||
except ssl.SSLError as exc:
|
||||
raise MailError("imap_tls", str(exc))
|
||||
except (socket.gaierror, socket.timeout, ConnectionError, OSError) as exc:
|
||||
raise MailError("imap_connect", f"{host}:{cfg.imap_port} — {exc}")
|
||||
|
||||
|
||||
def _imap_text(exc: Exception) -> str:
|
||||
text = exc.args[0] if exc.args else str(exc)
|
||||
return text.decode(errors="replace") if isinstance(text, bytes) else str(text)
|
||||
|
||||
|
||||
_LIST_LINE = re.compile(r'^\((?P<flags>[^)]*)\)\s+(?P<delim>"(?:[^"\\]|\\.)*"|NIL)\s+(?P<name>.+)$')
|
||||
|
||||
|
||||
def _unquote(text: str) -> str:
|
||||
text = text.strip()
|
||||
if len(text) >= 2 and text[0] == text[-1] == '"':
|
||||
return re.sub(r"\\(.)", r"\1", text[1:-1])
|
||||
return text
|
||||
|
||||
|
||||
def list_folders(box: imaplib.IMAP4) -> list[tuple[str, str, set[str]]]:
|
||||
"""(name, delimiter, flags) of every folder. Names stay in IMAP's own encoding for APPEND."""
|
||||
typ, data = box.list()
|
||||
if typ != "OK":
|
||||
raise MailError("imap_error", "LIST failed")
|
||||
folders = []
|
||||
for item in data:
|
||||
if isinstance(item, tuple): # name sent as a literal: b'(... ) "/" {12}', b'Itens Enviad'
|
||||
head, literal = item
|
||||
line = head.decode(errors="replace")
|
||||
m = _LIST_LINE.match(re.sub(r"\{\d+\}$", '""', line))
|
||||
if m:
|
||||
folders.append((literal.decode(errors="replace"), _unquote(m["delim"]),
|
||||
set(m["flags"].lower().split())))
|
||||
continue
|
||||
if not item:
|
||||
continue
|
||||
m = _LIST_LINE.match(item.decode(errors="replace"))
|
||||
if m:
|
||||
delim = "" if m["delim"] == "NIL" else _unquote(m["delim"])
|
||||
folders.append((_unquote(m["name"]), delim, set(m["flags"].lower().split())))
|
||||
return folders
|
||||
|
||||
|
||||
def find_sent_folder(box: imaplib.IMAP4, configured: str) -> str:
|
||||
folders = list_folders(box)
|
||||
if configured:
|
||||
if any(name == configured for name, _, _ in folders):
|
||||
return configured
|
||||
raise MailError("sent_folder_not_found", configured)
|
||||
for name, _, flags in folders:
|
||||
if "\\sent" in flags:
|
||||
return name
|
||||
for name, delim, _ in folders:
|
||||
leaf = name.split(delim)[-1] if delim else name
|
||||
if leaf.lower() in SENT_NAMES:
|
||||
return name
|
||||
raise MailError("sent_folder_not_found", ", ".join(name for name, _, _ in folders)[:400])
|
||||
|
||||
|
||||
def _quote(name: str) -> str:
|
||||
return '"' + name.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def save_copy(cfg: MailSettings, msg: EmailMessage) -> str:
|
||||
box = _imap(cfg)
|
||||
try:
|
||||
folder = find_sent_folder(box, cfg.sent_folder.strip())
|
||||
typ, data = box.append(_quote(folder), r"(\Seen)", imaplib.Time2Internaldate(time.time()), msg.as_bytes())
|
||||
if typ != "OK":
|
||||
raise MailError("imap_error", _imap_text(Exception(*(data or [b"APPEND failed"]))))
|
||||
return folder
|
||||
except imaplib.IMAP4.error as exc:
|
||||
raise MailError("imap_error", _imap_text(exc))
|
||||
finally:
|
||||
try:
|
||||
box.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# --- the whole send ---------------------------------------------------------------------------
|
||||
|
||||
def build_message(cfg: MailSettings, to: list[str], cc: list[str], subject: str, body: str,
|
||||
attachments: list[Attachment]) -> EmailMessage:
|
||||
msg = EmailMessage()
|
||||
msg["From"] = formataddr((cfg.from_name or "", cfg.from_address))
|
||||
msg["To"] = ", ".join(to)
|
||||
if cc:
|
||||
msg["Cc"] = ", ".join(cc)
|
||||
if cfg.reply_to:
|
||||
msg["Reply-To"] = cfg.reply_to
|
||||
msg["Subject"] = " ".join(subject.split())
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
msg["Message-ID"] = make_msgid(domain=cfg.from_address.split("@")[-1])
|
||||
text = body.strip()
|
||||
if cfg.signature.strip():
|
||||
text += "\n\n-- \n" + cfg.signature.strip()
|
||||
msg.set_content(text + "\n")
|
||||
for a in attachments:
|
||||
maintype, subtype = a.mime.split(";")[0].split("/", 1)
|
||||
msg.add_attachment(a.data, maintype=maintype, subtype=subtype, filename=a.name)
|
||||
return msg
|
||||
|
||||
|
||||
def send(db: Session, user: User, cfg: MailSettings, *, to: list[str], cc: list[str], subject: str, body: str,
|
||||
attachments: list[Attachment], kind: str = "report", year_id: int | None = None,
|
||||
context: str = "") -> MailLog:
|
||||
"""Send, save the Sent copy, and log it. Never raises for delivery problems: the log says what happened."""
|
||||
log = MailLog(at=datetime.now(timezone.utc), user_id=user.id, kind=kind, year_id=year_id, context=context[:255],
|
||||
sender=cfg.from_address, recipients={"to": to, "cc": cc}, subject=subject[:500], body=body,
|
||||
attachments=[{"name": a.name, "size": len(a.data)} for a in attachments],
|
||||
status="failed", error="", sent_copy="off", sent_copy_detail="", message_id="")
|
||||
try:
|
||||
msg = build_message(cfg, to, cc, subject, body, attachments)
|
||||
log.message_id = msg["Message-ID"]
|
||||
smtp_send(cfg, msg, to + cc)
|
||||
log.status = "sent"
|
||||
except MailError as exc:
|
||||
log.error = (exc.code + (f": {exc.detail}" if exc.detail else ""))[:1000]
|
||||
except (ValueError, TypeError) as exc:
|
||||
log.error = f"invalid_message: {exc}"[:1000]
|
||||
|
||||
if log.status == "sent" and cfg.save_to_sent:
|
||||
try:
|
||||
folder = save_copy(cfg, msg)
|
||||
log.sent_copy, log.sent_copy_detail = "saved", folder[:500]
|
||||
except MailError as exc:
|
||||
log.sent_copy = "failed"
|
||||
log.sent_copy_detail = (exc.code + (f": {exc.detail}" if exc.detail else ""))[:500]
|
||||
|
||||
db.add(log)
|
||||
db.flush()
|
||||
where = ", ".join(to + cc)
|
||||
record(db, user, "mail", log.id, "send" if log.status == "sent" else "send_failed",
|
||||
f"{subject} → {where}"[:500], year_id=year_id)
|
||||
db.commit()
|
||||
return log
|
||||
|
||||
|
||||
def check(cfg: MailSettings) -> dict:
|
||||
"""Sign in to SMTP (and IMAP when the Sent copy is on) without sending anything."""
|
||||
result = {"smtp": "ok", "imap": "off", "sent_folder": None}
|
||||
try:
|
||||
server = _smtp(cfg)
|
||||
server.quit()
|
||||
except MailError as exc:
|
||||
result["smtp"] = exc.code + (f": {exc.detail}" if exc.detail else "")
|
||||
if cfg.save_to_sent:
|
||||
try:
|
||||
box = _imap(cfg)
|
||||
try:
|
||||
result["sent_folder"] = find_sent_folder(box, cfg.sent_folder.strip())
|
||||
result["imap"] = "ok"
|
||||
finally:
|
||||
try:
|
||||
box.logout()
|
||||
except Exception:
|
||||
pass
|
||||
except MailError as exc:
|
||||
result["imap"] = exc.code + (f": {exc.detail}" if exc.detail else "")
|
||||
return result
|
||||
@@ -0,0 +1,25 @@
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from . import models # noqa: F401 (registers tables)
|
||||
from .routers import activity, admin, auth, mail, reports, years
|
||||
|
||||
app = FastAPI(title="Budget & Capex", docs_url="/api/docs", openapi_url="/api/openapi.json")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def csrf_guard(request: Request, call_next):
|
||||
# Cookies are SameSite=Lax; requiring a custom header on writes also blocks cross-site form posts.
|
||||
if request.method not in ("GET", "HEAD", "OPTIONS") and request.url.path.startswith("/api/"):
|
||||
if request.headers.get("x-requested-with") != "budget-app":
|
||||
return JSONResponse({"detail": "missing_header"}, status_code=403)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
for r in (auth.router, admin.router, years.router, reports.router, mail.router, activity.router):
|
||||
app.include_router(r)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""MFA with an authenticator app (TOTP, RFC 6238: 6 digits, 30 seconds, SHA-1).
|
||||
|
||||
Works with Microsoft Authenticator, Google Authenticator, Authy, 1Password and similar apps.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import struct
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.parse import quote
|
||||
|
||||
import segno
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from .config import settings
|
||||
from .models import User
|
||||
|
||||
ISSUER = "Budget & Capex"
|
||||
STEP = 30
|
||||
WINDOW = 1 # also accept the code just before and just after, for clocks that are slightly off
|
||||
MAX_FAILED = 5
|
||||
LOCK_MINUTES = 5
|
||||
RECOVERY_CODES = 8
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
key = hashlib.sha256(("mfa-secret:" + settings.secret_key).encode()).digest()
|
||||
return Fernet(base64.urlsafe_b64encode(key))
|
||||
|
||||
|
||||
def new_secret() -> str:
|
||||
return base64.b32encode(secrets.token_bytes(20)).decode().rstrip("=")
|
||||
|
||||
|
||||
def seal(secret: str) -> str:
|
||||
return _fernet().encrypt(secret.encode()).decode()
|
||||
|
||||
|
||||
def unseal(token: str | None) -> str | None:
|
||||
try:
|
||||
return _fernet().decrypt(token.encode()).decode() if token else None
|
||||
except InvalidToken: # SECRET_KEY changed: an administrator has to reset this user's MFA
|
||||
return None
|
||||
|
||||
|
||||
def code_at(secret: str, step: int) -> str:
|
||||
key = base64.b32decode(secret + "=" * (-len(secret) % 8))
|
||||
digest = hmac.new(key, struct.pack(">Q", step), hashlib.sha1).digest()
|
||||
offset = digest[-1] & 0x0F
|
||||
value = struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF
|
||||
return f"{value % 1_000_000:06d}"
|
||||
|
||||
|
||||
def matching_step(secret: str, code: str, last_step: int | None = None, at: float | None = None) -> int | None:
|
||||
"""The time step the code belongs to, or None. A step already used is refused (no replay)."""
|
||||
code = "".join(ch for ch in code if ch.isdigit())
|
||||
if len(code) != 6:
|
||||
return None
|
||||
now = int((at or time.time()) // STEP)
|
||||
for step in range(now - WINDOW, now + WINDOW + 1):
|
||||
if hmac.compare_digest(code_at(secret, step), code) and (last_step is None or step > last_step):
|
||||
return step
|
||||
return None
|
||||
|
||||
|
||||
def otpauth_uri(secret: str, email: str) -> str:
|
||||
label = quote(f"{ISSUER}:{email}")
|
||||
return f"otpauth://totp/{label}?secret={secret}&issuer={quote(ISSUER)}&algorithm=SHA1&digits=6&period={STEP}"
|
||||
|
||||
|
||||
def qr_svg(uri: str) -> str:
|
||||
return segno.make(uri, error="m").svg_inline(scale=5, dark="#000", light="#fff", border=2)
|
||||
|
||||
|
||||
def _hash(code: str) -> str:
|
||||
return hashlib.sha256(code.replace("-", "").strip().lower().encode()).hexdigest()
|
||||
|
||||
|
||||
def new_recovery_codes() -> tuple[list[str], list[str]]:
|
||||
"""Codes to show once, and their hashes to keep."""
|
||||
alphabet = "abcdefghjkmnpqrstuvwxyz23456789"
|
||||
codes = ["".join(secrets.choice(alphabet) for _ in range(4)) + "-" + "".join(secrets.choice(alphabet) for _ in range(4))
|
||||
for _ in range(RECOVERY_CODES)]
|
||||
return codes, [_hash(c) for c in codes]
|
||||
|
||||
|
||||
def locked(user: User) -> bool:
|
||||
until = user.mfa_locked_until
|
||||
if until and until.tzinfo is None:
|
||||
until = until.replace(tzinfo=timezone.utc)
|
||||
return bool(until and until > datetime.now(timezone.utc))
|
||||
|
||||
|
||||
def check(user: User, code: str) -> str | None:
|
||||
"""Checks an authenticator code or a recovery code. Returns 'totp', 'recovery' or None, and updates
|
||||
the user's counters (a recovery code is used up; too many wrong codes lock MFA for a few minutes)."""
|
||||
if locked(user):
|
||||
return None
|
||||
code = (code or "").strip()
|
||||
secret = unseal(user.mfa_secret)
|
||||
kind = None
|
||||
if secret and (step := matching_step(secret, code, user.mfa_last_step)) is not None:
|
||||
user.mfa_last_step, kind = step, "totp"
|
||||
elif any(ch.isalpha() for ch in code) and _hash(code) in (user.mfa_recovery or []):
|
||||
user.mfa_recovery = [h for h in user.mfa_recovery if h != _hash(code)]
|
||||
kind = "recovery"
|
||||
if kind:
|
||||
user.mfa_failed, user.mfa_locked_until = 0, None
|
||||
else:
|
||||
user.mfa_failed = (user.mfa_failed or 0) + 1
|
||||
if user.mfa_failed >= MAX_FAILED:
|
||||
user.mfa_failed, user.mfa_locked_until = 0, datetime.now(timezone.utc) + timedelta(minutes=LOCK_MINUTES)
|
||||
return kind
|
||||
|
||||
|
||||
def disable(user: User) -> None:
|
||||
user.mfa_enabled, user.mfa_secret, user.mfa_pending_secret = False, None, None
|
||||
user.mfa_enabled_at, user.mfa_last_step, user.mfa_recovery = None, None, None
|
||||
user.mfa_failed, user.mfa_locked_until = 0, None
|
||||
@@ -0,0 +1,256 @@
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import (JSON, BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, LargeBinary, Numeric, String, Text,
|
||||
UniqueConstraint, false)
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from .db import Base
|
||||
|
||||
Money = Numeric(18, 6)
|
||||
|
||||
|
||||
def now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Department(Base):
|
||||
__tablename__ = "departments"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(120), unique=True)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
|
||||
steps: Mapped[list["WorkflowStep"]] = relationship(
|
||||
back_populates="department", cascade="all, delete-orphan", order_by="WorkflowStep.position")
|
||||
|
||||
|
||||
class WorkflowStep(Base):
|
||||
"""One approval step of a department's workflow. Steps run in `position` order after submission."""
|
||||
__tablename__ = "workflow_steps"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
department_id: Mapped[int] = mapped_column(ForeignKey("departments.id", ondelete="CASCADE"), index=True)
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
name: Mapped[str] = mapped_column(String(120))
|
||||
# role: any approver (or admin-role member) of the department; users: only the listed users
|
||||
approver_mode: Mapped[str] = mapped_column(String(10), default="role")
|
||||
approver_ids: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
department: Mapped[Department] = relationship(back_populates="steps")
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
# admin: everything; approver: edit + approve own department; editor: edit own department; viewer: read
|
||||
role: Mapped[str] = mapped_column(String(20), default="viewer")
|
||||
department_id: Mapped[int | None] = mapped_column(ForeignKey("departments.id"), nullable=True)
|
||||
language: Mapped[str] = mapped_column(String(5), default="pt")
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
# light, dark, or system (follow the computer's setting)
|
||||
theme: Mapped[str] = mapped_column(String(10), default="system", server_default="system")
|
||||
# MFA with an authenticator app (TOTP). Secrets are stored encrypted; recovery codes as SHA-256 hashes.
|
||||
mfa_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
|
||||
mfa_secret: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
mfa_pending_secret: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
mfa_enabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
mfa_last_step: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # a code works only once
|
||||
mfa_recovery: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
mfa_failed: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
mfa_locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# profile picture, resized to at most 256 px
|
||||
avatar: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True, deferred=True)
|
||||
avatar_type: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
avatar_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
department: Mapped[Department | None] = relationship()
|
||||
|
||||
|
||||
class Site(Base):
|
||||
__tablename__ = "sites"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(120), unique=True)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
|
||||
class Category(Base):
|
||||
__tablename__ = "categories"
|
||||
__table_args__ = (UniqueConstraint("department_id", "name_en", name="uq_categories_department_name"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
department_id: Mapped[int] = mapped_column(ForeignKey("departments.id", ondelete="CASCADE"), index=True)
|
||||
name_en: Mapped[str] = mapped_column(String(200))
|
||||
name_pt: Mapped[str] = mapped_column(String(200), default="")
|
||||
# revenue lines are shown in the budget but left out of the cost report
|
||||
section: Mapped[str] = mapped_column(String(20), default="cost")
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
show_when_empty: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
|
||||
class BudgetYear(Base):
|
||||
__tablename__ = "budget_years"
|
||||
# One current budget per department and year; replaced versions are archived next to it.
|
||||
__table_args__ = (Index("uq_budget_years_current", "department_id", "year", unique=True,
|
||||
postgresql_where=text("NOT archived"), sqlite_where=text("NOT archived")),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
department_id: Mapped[int] = mapped_column(ForeignKey("departments.id"), index=True)
|
||||
year: Mapped[int] = mapped_column(Integer)
|
||||
title: Mapped[str] = mapped_column(String(200), default="")
|
||||
default_rate: Mapped[Decimal] = mapped_column(Numeric(9, 6), default=Decimal("0"))
|
||||
# draft -> in_review (one round per workflow step) -> approved; only drafts can be edited
|
||||
status: Mapped[str] = mapped_column(String(20), default="draft")
|
||||
current_step: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
notes: Mapped[str] = mapped_column(Text, default="")
|
||||
submitted_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
approved_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
# the Excel file the budget was last loaded from, if any
|
||||
source_file: Mapped[str] = mapped_column(String(255), default="", server_default="")
|
||||
# an archived budget was replaced by an upload: kept for reference, read-only
|
||||
archived: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
|
||||
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
archived_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
|
||||
department: Mapped[Department] = relationship()
|
||||
lines: Mapped[list["CostLine"]] = relationship(back_populates="budget_year", cascade="all, delete-orphan")
|
||||
submitted_by: Mapped[User | None] = relationship(foreign_keys=[submitted_by_id])
|
||||
approved_by: Mapped[User | None] = relationship(foreign_keys=[approved_by_id])
|
||||
archived_by: Mapped[User | None] = relationship(foreign_keys=[archived_by_id])
|
||||
reopen_requests: Mapped[list["ReopenRequest"]] = relationship(
|
||||
back_populates="budget_year", cascade="all, delete-orphan", order_by="ReopenRequest.id")
|
||||
|
||||
|
||||
class CostLine(Base):
|
||||
__tablename__ = "cost_lines"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
year_id: Mapped[int] = mapped_column(ForeignKey("budget_years.id", ondelete="CASCADE"), index=True)
|
||||
category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"))
|
||||
site_id: Mapped[int | None] = mapped_column(ForeignKey("sites.id"), nullable=True)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
description: Mapped[str] = mapped_column(String(500), default="")
|
||||
remarks: Mapped[str] = mapped_column(Text, default="")
|
||||
# amounts may be typed as arithmetic, e.g. "3292.56+(12*839.67)"; the evaluated value is stored next to it
|
||||
prior_expr: Mapped[str] = mapped_column(String(500), default="0")
|
||||
prior_actual: Mapped[Decimal] = mapped_column(Money, default=Decimal("0"))
|
||||
base_expr: Mapped[str] = mapped_column(String(500), default="0")
|
||||
base_amount: Mapped[Decimal] = mapped_column(Money, default=Decimal("0"))
|
||||
# year_rate: base * (1 + year rate); percent: base * (1 + value/100); factor: base * value; none: base
|
||||
adj_kind: Mapped[str] = mapped_column(String(20), default="year_rate")
|
||||
adj_value: Mapped[Decimal] = mapped_column(Numeric(12, 6), default=Decimal("0"))
|
||||
adj_reason: Mapped[str] = mapped_column(String(255), default="")
|
||||
# even: adjusted / 12; manual: twelve typed amounts, whose sum is the budget
|
||||
monthly_kind: Mapped[str] = mapped_column(String(10), default="even")
|
||||
monthly_values: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
# monthly_kind "selected": the adjusted amount is split equally over these months (1 = January)
|
||||
monthly_months: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
|
||||
|
||||
budget_year: Mapped[BudgetYear] = relationship(back_populates="lines")
|
||||
category: Mapped[Category] = relationship()
|
||||
site: Mapped[Site | None] = relationship()
|
||||
|
||||
|
||||
class AuditEntry(Base):
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
|
||||
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
year_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
entity: Mapped[str] = mapped_column(String(40))
|
||||
entity_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
action: Mapped[str] = mapped_column(String(40))
|
||||
summary: Mapped[str] = mapped_column(String(500), default="")
|
||||
changes: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
user: Mapped[User | None] = relationship()
|
||||
|
||||
|
||||
|
||||
|
||||
class MailSettings(Base):
|
||||
"""The one mail account the app sends from (row id 1). Passwords are stored encrypted."""
|
||||
__tablename__ = "mail_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
smtp_host: Mapped[str] = mapped_column(String(255), default="")
|
||||
smtp_port: Mapped[int] = mapped_column(Integer, default=587)
|
||||
smtp_security: Mapped[str] = mapped_column(String(10), default="starttls") # starttls | ssl | none
|
||||
smtp_username: Mapped[str] = mapped_column(String(255), default="")
|
||||
smtp_password: Mapped[str] = mapped_column(Text, default="")
|
||||
from_address: Mapped[str] = mapped_column(String(255), default="")
|
||||
from_name: Mapped[str] = mapped_column(String(255), default="")
|
||||
reply_to: Mapped[str] = mapped_column(String(255), default="")
|
||||
signature: Mapped[str] = mapped_column(Text, default="")
|
||||
# copy of every sent message in the mailbox's Sent folder, over IMAP
|
||||
save_to_sent: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
imap_host: Mapped[str] = mapped_column(String(255), default="")
|
||||
imap_port: Mapped[int] = mapped_column(Integer, default=993)
|
||||
imap_security: Mapped[str] = mapped_column(String(10), default="ssl")
|
||||
imap_same_login: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
imap_username: Mapped[str] = mapped_column(String(255), default="")
|
||||
imap_password: Mapped[str] = mapped_column(Text, default="")
|
||||
sent_folder: Mapped[str] = mapped_column(String(255), default="") # empty: find it automatically
|
||||
updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
updated_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
|
||||
updated_by: Mapped[User | None] = relationship()
|
||||
|
||||
|
||||
class MailLog(Base):
|
||||
"""One message the app tried to send: who, to whom, what, and how it went."""
|
||||
__tablename__ = "mail_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
|
||||
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
kind: Mapped[str] = mapped_column(String(20), default="report") # report | test
|
||||
year_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
context: Mapped[str] = mapped_column(String(255), default="")
|
||||
sender: Mapped[str] = mapped_column(String(255), default="")
|
||||
recipients: Mapped[dict] = mapped_column(JSON, default=dict) # {"to": [...], "cc": [...]}
|
||||
subject: Mapped[str] = mapped_column(String(500), default="")
|
||||
body: Mapped[str] = mapped_column(Text, default="")
|
||||
attachments: Mapped[list] = mapped_column(JSON, default=list) # [{"name": ..., "size": ...}]
|
||||
status: Mapped[str] = mapped_column(String(20), default="sent") # sent | failed
|
||||
error: Mapped[str] = mapped_column(String(1000), default="")
|
||||
sent_copy: Mapped[str] = mapped_column(String(20), default="off") # saved | failed | off
|
||||
sent_copy_detail: Mapped[str] = mapped_column(String(500), default="")
|
||||
message_id: Mapped[str] = mapped_column(String(255), default="")
|
||||
|
||||
user: Mapped[User | None] = relationship()
|
||||
|
||||
|
||||
class ReopenRequest(Base):
|
||||
"""A user asks for an approved budget to be reopened, saying why; an approver accepts or declines."""
|
||||
__tablename__ = "reopen_requests"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
year_id: Mapped[int] = mapped_column(ForeignKey("budget_years.id", ondelete="CASCADE"), index=True)
|
||||
requested_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
requested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
reason: Mapped[str] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending") # pending | accepted | declined
|
||||
decided_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
decision_note: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
budget_year: Mapped[BudgetYear] = relationship(back_populates="reopen_requests")
|
||||
requested_by: Mapped[User | None] = relationship(foreign_keys=[requested_by_id])
|
||||
decided_by: Mapped[User | None] = relationship(foreign_keys=[decided_by_id])
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Short emails about reopen requests, sent through the configured account when email is on.
|
||||
|
||||
Best effort: a failure is written to the mail log and never blocks the action itself.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import mailer
|
||||
from .models import BudgetYear, MailSettings, User
|
||||
|
||||
|
||||
def _ready(db: Session) -> MailSettings | None:
|
||||
cfg = db.get(MailSettings, 1)
|
||||
return cfg if cfg and mailer.is_ready(cfg) else None
|
||||
|
||||
|
||||
def reopeners(db: Session, budget: BudgetYear) -> list[User]:
|
||||
"""Who can reopen the budget: administrators and the department's approvers."""
|
||||
users = db.scalars(select(User).where(User.active)).all()
|
||||
return [u for u in users if u.role == "admin" or (u.role == "approver" and u.department_id == budget.department_id)]
|
||||
|
||||
|
||||
def send(db: Session, actor: User, budget: BudgetYear, to: list[User], subject: str, body: str) -> None:
|
||||
cfg = _ready(db)
|
||||
addresses = sorted({u.email for u in to if u.email and u.id != actor.id})
|
||||
if not cfg or not addresses:
|
||||
return
|
||||
try:
|
||||
addresses = mailer.clean_addresses(addresses)
|
||||
except mailer.MailError:
|
||||
return
|
||||
mailer.send(db, actor, cfg, to=addresses, cc=[], subject=subject, body=body, attachments=[], kind="notice",
|
||||
year_id=budget.id, context=f"{budget.department.name} {budget.year}")
|
||||
@@ -0,0 +1,95 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Run before `alembic upgrade`: when there are database changes to apply, ask the backup container for a
|
||||
fresh dump and wait for it, so every upgrade can be rolled back to the moment just before it.
|
||||
|
||||
The api stops (and Docker retries it) if the backup fails, unless SKIP_PRE_UPGRADE_BACKUP=true.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from alembic.config import Config
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from alembic.script import ScriptDirectory
|
||||
|
||||
from .db import engine
|
||||
|
||||
REQUESTS = Path(os.getenv("BACKUP_REQUEST_DIR", "/requests"))
|
||||
TIMEOUT = int(os.getenv("PRE_UPGRADE_BACKUP_TIMEOUT", "900"))
|
||||
|
||||
|
||||
def pending() -> tuple[str | None, str]:
|
||||
head = ScriptDirectory.from_config(Config("alembic.ini")).get_current_head()
|
||||
with engine.connect() as conn:
|
||||
current = MigrationContext.configure(conn).get_current_revision()
|
||||
return current, head
|
||||
|
||||
|
||||
def main() -> int:
|
||||
current, head = pending()
|
||||
if current is None or current == head:
|
||||
return 0 # new database, or nothing to migrate
|
||||
if os.getenv("SKIP_PRE_UPGRADE_BACKUP", "").lower() == "true":
|
||||
print(f"pre-upgrade backup skipped ({current} -> {head})", flush=True)
|
||||
return 0
|
||||
if not REQUESTS.is_dir():
|
||||
print(f"pre-upgrade backup: {REQUESTS} not mounted, cannot ask the backup container", file=sys.stderr, flush=True)
|
||||
return 1
|
||||
rid = time.strftime("%Y%m%d%H%M%S") + "-" + uuid.uuid4().hex[:6]
|
||||
(REQUESTS / f"{rid}.request").write_text(f"database upgrade {current} -> {head}\n")
|
||||
print(f"database upgrade {current} -> {head}: waiting for the backup container to take a backup", flush=True)
|
||||
done = REQUESTS / f"{rid}.done"
|
||||
deadline = time.time() + TIMEOUT
|
||||
while time.time() < deadline:
|
||||
if done.exists():
|
||||
answer = done.read_text().strip() # the backup container clears old answers itself
|
||||
if answer == "ok":
|
||||
print("pre-upgrade backup written", flush=True)
|
||||
return 0
|
||||
print("pre-upgrade backup FAILED; not upgrading. See: docker compose logs backup", file=sys.stderr, flush=True)
|
||||
return 1
|
||||
time.sleep(2)
|
||||
print("pre-upgrade backup did not answer in time; not upgrading. Is the backup container running?",
|
||||
file=sys.stderr, flush=True)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Inline SVG charts, so the same markup renders in the browser and in WeasyPrint."""
|
||||
import math
|
||||
from html import escape
|
||||
|
||||
BLUE = "#2f6fd6"
|
||||
ORANGE = "#e8632b"
|
||||
INK = "#1d1d1f"
|
||||
MUTED = "#6b6b70"
|
||||
GRID = "#e4e4e8"
|
||||
|
||||
|
||||
def nice_ticks(lo: float, hi: float, count: int = 8) -> list[float]:
|
||||
if hi <= lo:
|
||||
hi = lo + 1
|
||||
raw = (hi - lo) / count
|
||||
mag = 10 ** math.floor(math.log10(raw))
|
||||
step = next(m * mag for m in (1, 2, 2.5, 5, 10) if m * mag >= raw)
|
||||
start = math.floor(lo / step) * step
|
||||
ticks, v = [], start
|
||||
while v <= hi + step * 0.001:
|
||||
ticks.append(round(v, 10))
|
||||
v += step
|
||||
if ticks[-1] < hi:
|
||||
ticks.append(ticks[-1] + step)
|
||||
return ticks
|
||||
|
||||
|
||||
def _wrap(text: str, width: int = 30, lines: int = 2) -> list[str]:
|
||||
words, out, cur = text.split(), [], ""
|
||||
for w in words:
|
||||
if len(cur) + len(w) + (1 if cur else 0) <= width:
|
||||
cur = f"{cur} {w}".strip()
|
||||
else:
|
||||
out.append(cur)
|
||||
cur = w
|
||||
out.append(cur)
|
||||
out = [o for o in out if o]
|
||||
if len(out) > lines:
|
||||
out = out[:lines]
|
||||
out[-1] = out[-1][: width - 1].rstrip() + "…"
|
||||
return [o if len(o) <= width else o[: width - 1] + "…" for o in out] or [""]
|
||||
|
||||
|
||||
def grouped_bars(rows: list[tuple[str, float, float]], fmt_tick, fmt_value=None, width: int = 900,
|
||||
height: int = 490) -> str:
|
||||
"""Horizontal bars, two per row (prior in blue, adjusted in orange), sized to fill width x height."""
|
||||
if not rows:
|
||||
return ""
|
||||
label_w = 250
|
||||
top, bottom = 8, 30
|
||||
row_h = min(130.0, (height - top - bottom) / len(rows))
|
||||
bar_h = min(row_h * 0.34, 36)
|
||||
plot_w = width - label_w - (80 if fmt_value else 24)
|
||||
height = round(top + bottom + row_h * len(rows))
|
||||
hi = max(max(p, a) for _, p, a in rows) or 1
|
||||
ticks = nice_ticks(0, hi, 8)
|
||||
x = lambda v: label_w + plot_w * v / ticks[-1]
|
||||
parts = [f'<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" class="chart">']
|
||||
for t in ticks:
|
||||
parts.append(f'<line x1="{x(t):.1f}" x2="{x(t):.1f}" y1="{top}" y2="{height - bottom}" stroke="{GRID}"/>')
|
||||
parts.append(f'<text x="{x(t):.1f}" y="{height - bottom + 18}" font-size="12" text-anchor="middle" fill="{MUTED}">{escape(fmt_tick(t))}</text>')
|
||||
for i, (label, prior, adjusted) in enumerate(rows):
|
||||
y0 = top + i * row_h
|
||||
cy = y0 + row_h / 2
|
||||
fs = 13 if row_h >= 22 else 11.5
|
||||
wrapped = _wrap(label, 34, 2 if row_h >= 30 else 1)
|
||||
for j, chunk in enumerate(wrapped):
|
||||
dy = (j - (len(wrapped) - 1) / 2) * (fs + 2) + fs / 3
|
||||
parts.append(f'<text x="{label_w - 10}" y="{cy + dy:.1f}" font-size="{fs}" text-anchor="end" fill="{INK}">{escape(chunk)}</text>')
|
||||
for k, (value, colour) in enumerate(((prior, BLUE), (adjusted, ORANGE))):
|
||||
by = cy - bar_h - 1 + k * (bar_h + 2)
|
||||
w = max(x(max(value, 0)) - label_w, 0)
|
||||
parts.append(f'<rect x="{label_w}" y="{by:.1f}" width="{w:.1f}" height="{bar_h:.1f}" fill="{colour}"/>')
|
||||
if fmt_value and row_h >= 26:
|
||||
vfs = min(12, bar_h + 1)
|
||||
parts.append(f'<text x="{label_w + w + 5:.1f}" y="{by + bar_h / 2 + vfs / 3:.1f}" font-size="{vfs:.1f}" fill="{MUTED}">{escape(fmt_value(value))}</text>')
|
||||
parts.append(f'<line x1="{label_w}" x2="{label_w}" y1="{top}" y2="{height - bottom}" stroke="{MUTED}"/>')
|
||||
parts.append("</svg>")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def change_bars(rows: list[tuple[str, float]], fmt_value, width: int = 900, height: int = 520) -> str:
|
||||
"""Ranked % change per category; increases orange, decreases blue."""
|
||||
if not rows:
|
||||
return ""
|
||||
label_w, top, bottom = 270, 8, 30
|
||||
row_h = min(60.0, (height - top - bottom) / len(rows))
|
||||
bar = min(row_h * 0.6, 28)
|
||||
plot_w = width - label_w - 90
|
||||
height = round(top + bottom + row_h * len(rows))
|
||||
lo = min(0.0, min(v for _, v in rows))
|
||||
hi = max(0.0, max(v for _, v in rows)) or 1
|
||||
ticks = nice_ticks(lo, hi, 8)
|
||||
span = ticks[-1] - ticks[0]
|
||||
x = lambda v: label_w + plot_w * (v - ticks[0]) / span
|
||||
parts = [f'<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" class="chart">']
|
||||
for t in ticks:
|
||||
parts.append(f'<line x1="{x(t):.1f}" x2="{x(t):.1f}" y1="{top}" y2="{height - bottom}" stroke="{GRID}"/>')
|
||||
parts.append(f'<text x="{x(t):.1f}" y="{height - bottom + 18}" font-size="12" text-anchor="middle" fill="{MUTED}">{"+" if t > 0 else ""}{t:g}%</text>')
|
||||
for i, (label, value) in enumerate(rows):
|
||||
cy = top + i * row_h + row_h / 2
|
||||
x0, x1 = sorted((x(0), x(value)))
|
||||
colour = ORANGE if value > 0 else BLUE
|
||||
parts.append(f'<text x="{label_w - 10}" y="{cy + 4.5:.1f}" font-size="13" text-anchor="end" fill="{INK}">{escape(_wrap(label, 38, 1)[0])}</text>')
|
||||
parts.append(f'<rect x="{x0:.1f}" y="{cy - bar / 2:.1f}" width="{max(x1 - x0, 1):.1f}" height="{bar:.1f}" fill="{colour}"/>')
|
||||
tx, anchor = (x1 + 6, "start") if value >= 0 else (x0 - 6, "end")
|
||||
parts.append(f'<text x="{tx:.1f}" y="{cy + 4.5:.1f}" font-size="12.5" font-weight="bold" text-anchor="{anchor}" fill="{INK}">{escape(fmt_value(value))}</text>')
|
||||
parts.append(f'<line x1="{x(0):.1f}" x2="{x(0):.1f}" y1="{top}" y2="{height - bottom}" stroke="{MUTED}"/>')
|
||||
parts.append("</svg>")
|
||||
return "".join(parts)
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Excel exports with live formulas, so totals keep working when people edit the file."""
|
||||
import re
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
from ..summary import YearView
|
||||
from .i18n import T
|
||||
|
||||
MONEY = '#,##0.00;[Red]-#,##0.00'
|
||||
PCT = '0.00%;[Red]-0.00%'
|
||||
HEAD_FILL = PatternFill("solid", fgColor="1F3B63")
|
||||
CAT_FILL = PatternFill("solid", fgColor="EEF2F8")
|
||||
WHITE_BOLD = Font(bold=True, color="FFFFFF")
|
||||
BOLD = Font(bold=True)
|
||||
THIN_TOP = Border(top=Side(style="thin", color="999999"))
|
||||
|
||||
|
||||
def _safe_sheet_name(name: str, used: set[str]) -> str:
|
||||
base = re.sub(r"[\[\]:*?/\\]", "-", name)[:31] or "Sheet"
|
||||
candidate, n = base, 2
|
||||
while candidate.lower() in used:
|
||||
suffix = f" ({n})"
|
||||
candidate, n = base[: 31 - len(suffix)] + suffix, n + 1
|
||||
used.add(candidate.lower())
|
||||
return candidate
|
||||
|
||||
|
||||
def _header(ws, row: int, labels: list[str]) -> None:
|
||||
for i, label in enumerate(labels, start=1):
|
||||
c = ws.cell(row, i, label)
|
||||
c.font, c.fill = WHITE_BOLD, HEAD_FILL
|
||||
c.alignment = Alignment(horizontal="left" if i <= 2 else "right", vertical="center")
|
||||
|
||||
|
||||
def _title(ws, title: str, sub: str) -> None:
|
||||
ws["A1"] = title
|
||||
ws["A1"].font = Font(bold=True, size=14)
|
||||
ws["A2"] = sub
|
||||
ws["A2"].font = Font(color="666666")
|
||||
|
||||
|
||||
def _out(wb: Workbook) -> bytes:
|
||||
buf = BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _meta(view: YearView, t: T, site: str | None) -> str:
|
||||
bits = [f"{t('status')}: {t(view.year.status)}", f"{t('exported')}: {datetime.now():%Y-%m-%d %H:%M}"]
|
||||
if site:
|
||||
bits.insert(0, t("site_filter", site=site))
|
||||
return " · ".join(bits)
|
||||
|
||||
|
||||
def budget_xlsx(view: YearView, lang: str, site: str | None = None, include_empty: bool = True) -> bytes:
|
||||
t = T(lang, view.year.year, view.year.department.name)
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = _safe_sheet_name(t("budget_title"), set())
|
||||
_title(ws, t("budget_title"), t("budget_sub") + " · " + _meta(view, t, site))
|
||||
months = t("months")
|
||||
_header(ws, 4, [t("line"), t("site"), *months, t("total")])
|
||||
row = 5
|
||||
cost_subtotals = []
|
||||
cats = sorted([c for c in view.categories if c.lines or include_empty],
|
||||
key=lambda c: 0 if c.category.section == "revenue" else 1)
|
||||
for cv in cats:
|
||||
ws.cell(row, 1, cv.name(lang)).font = BOLD
|
||||
for col in range(1, 16):
|
||||
ws.cell(row, col).fill = CAT_FILL
|
||||
row += 1
|
||||
first = row
|
||||
for lv in cv.lines:
|
||||
ws.cell(row, 1, lv.line.name)
|
||||
ws.cell(row, 2, lv.line.site.name if lv.line.site else t("group"))
|
||||
for m, value in enumerate(lv.figures.months):
|
||||
ws.cell(row, 3 + m, float(round(value, 2))).number_format = MONEY
|
||||
ws.cell(row, 15, f"=SUM(C{row}:N{row})").number_format = MONEY
|
||||
row += 1
|
||||
label = t("revenue_total") if cv.category.section == "revenue" else t("sub_total", name=cv.name(lang))
|
||||
ws.cell(row, 1, label).font = BOLD
|
||||
for col in range(3, 16):
|
||||
letter = get_column_letter(col)
|
||||
formula = f"=SUM({letter}{first}:{letter}{row - 1})" if cv.lines else 0
|
||||
c = ws.cell(row, col, formula)
|
||||
c.number_format, c.font, c.border = MONEY, BOLD, THIN_TOP
|
||||
if cv.category.section != "revenue":
|
||||
cost_subtotals.append(row)
|
||||
row += 2
|
||||
ws.cell(row, 1, t("total_costs")).font = WHITE_BOLD
|
||||
for col in range(1, 16):
|
||||
ws.cell(row, col).fill = HEAD_FILL
|
||||
for col in range(3, 16):
|
||||
letter = get_column_letter(col)
|
||||
formula = "=" + "+".join(f"{letter}{r}" for r in cost_subtotals) if cost_subtotals else 0
|
||||
c = ws.cell(row, col, formula)
|
||||
c.number_format, c.font = MONEY, WHITE_BOLD
|
||||
ws.column_dimensions["A"].width = 52
|
||||
ws.column_dimensions["B"].width = 12
|
||||
for col in range(3, 16):
|
||||
ws.column_dimensions[get_column_letter(col)].width = 13
|
||||
ws.freeze_panes = "C5"
|
||||
_inputs_sheet(wb.create_sheet(_safe_sheet_name(t("inputs"), {ws.title.lower()})), view, t, site)
|
||||
return _out(wb)
|
||||
|
||||
|
||||
def _inputs_sheet(ws, view: YearView, t: T, site: str | None) -> None:
|
||||
"""The Sheet1 layout: actual, base, factor, adjusted, change, remarks."""
|
||||
_title(ws, t("inputs") + f" {view.year.year}", _meta(view, t, site))
|
||||
_header(ws, 4, [t("category"), t("line"), t("site"), t("prior_actual"), t("base"), t("factor"),
|
||||
t("adjusted"), t("change_eur"), t("change_pct"), t("reason"), t("remarks")])
|
||||
row = 5
|
||||
for cv in view.with_lines():
|
||||
for lv in cv.lines:
|
||||
f, line = lv.figures, lv.line
|
||||
ws.cell(row, 1, cv.name(t.lang))
|
||||
ws.cell(row, 2, line.name)
|
||||
ws.cell(row, 3, line.site.name if line.site else t("group"))
|
||||
ws.cell(row, 4, float(f.prior)).number_format = MONEY
|
||||
ws.cell(row, 5, float(f.base)).number_format = MONEY
|
||||
ws.cell(row, 6, float(f.factor)).number_format = "0.0000"
|
||||
ws.cell(row, 7, f"=ROUND(E{row}*F{row},2)").number_format = MONEY
|
||||
ws.cell(row, 8, f"=G{row}-D{row}").number_format = MONEY
|
||||
ws.cell(row, 9, f'=IF(D{row}=0,"",H{row}/D{row})').number_format = PCT
|
||||
ws.cell(row, 10, line.adj_reason)
|
||||
ws.cell(row, 11, line.remarks)
|
||||
row += 1
|
||||
ws.cell(row, 1, t("total")).font = BOLD
|
||||
for col, letter in ((4, "D"), (5, "E"), (7, "G"), (8, "H")):
|
||||
c = ws.cell(row, col, f"=SUM({letter}5:{letter}{row - 1})")
|
||||
c.number_format, c.font, c.border = MONEY, BOLD, THIN_TOP
|
||||
c = ws.cell(row, 9, f'=IF(D{row}=0,"",H{row}/D{row})')
|
||||
c.number_format, c.font = PCT, BOLD
|
||||
for letter, width in zip("ABCDEFGHIJK", (34, 46, 12, 16, 16, 9, 18, 15, 12, 30, 40)):
|
||||
ws.column_dimensions[letter].width = width
|
||||
ws.freeze_panes = "C5"
|
||||
|
||||
|
||||
def cost_report_xlsx(view: YearView, lang: str, site: str | None = None) -> bytes:
|
||||
t = T(lang, view.year.year, view.year.department.name)
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
used: set[str] = set()
|
||||
ws.title = _safe_sheet_name(t("summary"), used)
|
||||
_title(ws, t("cost_report_title"), t("summary_sub") + " · " + _meta(view, t, site))
|
||||
_header(ws, 4, [t("category"), t("prior_actual"), t("adjusted"), t("change_eur"), t("change_pct")])
|
||||
cats = view.with_lines("cost")
|
||||
row = 5
|
||||
for cv in cats:
|
||||
sheet = wb.create_sheet(_safe_sheet_name(cv.name(lang), used))
|
||||
total_row = _category_sheet(sheet, cv, t)
|
||||
ref = f"'{sheet.title}'"
|
||||
ws.cell(row, 1, cv.name(lang))
|
||||
ws.cell(row, 2, f"={ref}!B{total_row}").number_format = MONEY
|
||||
ws.cell(row, 3, f"={ref}!C{total_row}").number_format = MONEY
|
||||
ws.cell(row, 4, f"=C{row}-B{row}").number_format = MONEY
|
||||
ws.cell(row, 5, f'=IF(B{row}=0,"",D{row}/B{row})').number_format = PCT
|
||||
row += 1
|
||||
ws.cell(row, 1, t("total")).font = BOLD
|
||||
for col, letter in ((2, "B"), (3, "C")):
|
||||
c = ws.cell(row, col, f"=SUM({letter}5:{letter}{row - 1})")
|
||||
c.number_format, c.font, c.border = MONEY, BOLD, THIN_TOP
|
||||
ws.cell(row, 4, f"=C{row}-B{row}").number_format = MONEY
|
||||
ws.cell(row, 5, f'=IF(B{row}=0,"",D{row}/B{row})').number_format = PCT
|
||||
ws.cell(row, 4).font = ws.cell(row, 5).font = BOLD
|
||||
for letter, width in zip("ABCDE", (40, 20, 22, 18, 14)):
|
||||
ws.column_dimensions[letter].width = width
|
||||
return _out(wb)
|
||||
|
||||
|
||||
def _category_sheet(ws, cv, t: T) -> int:
|
||||
_title(ws, cv.name(t.lang), t("cost_lines", n=len(cv.lines)))
|
||||
_header(ws, 4, [t("line"), t("prior_actual"), t("adjusted"), t("change_eur"), t("change_pct"), t("site"), t("remarks")])
|
||||
row = 5
|
||||
for lv in cv.lines:
|
||||
ws.cell(row, 1, lv.line.name)
|
||||
ws.cell(row, 2, float(lv.figures.prior)).number_format = MONEY
|
||||
ws.cell(row, 3, float(lv.figures.adjusted)).number_format = MONEY
|
||||
ws.cell(row, 4, f"=C{row}-B{row}").number_format = MONEY
|
||||
ws.cell(row, 5, f'=IF(B{row}=0,"",D{row}/B{row})').number_format = PCT
|
||||
ws.cell(row, 6, lv.line.site.name if lv.line.site else t("group"))
|
||||
ws.cell(row, 7, lv.line.remarks)
|
||||
row += 1
|
||||
ws.cell(row, 1, t("total")).font = BOLD
|
||||
for col, letter in ((2, "B"), (3, "C")):
|
||||
c = ws.cell(row, col, f"=SUM({letter}5:{letter}{row - 1})")
|
||||
c.number_format, c.font, c.border = MONEY, BOLD, THIN_TOP
|
||||
ws.cell(row, 4, f"=C{row}-B{row}").number_format = MONEY
|
||||
ws.cell(row, 5, f'=IF(B{row}=0,"",D{row}/B{row})').number_format = PCT
|
||||
for letter, width in zip("ABCDEFG", (46, 18, 20, 16, 12, 12, 40)):
|
||||
ws.column_dimensions[letter].width = width
|
||||
return row
|
||||
@@ -0,0 +1,136 @@
|
||||
from decimal import Decimal
|
||||
|
||||
LABELS = {
|
||||
"en": {
|
||||
"cost_report_title": "Capex and Costs {year}",
|
||||
"cost_report_subtitle": "{dept} · cost budget: {prior} actual vs. {year} with adjustment",
|
||||
"total_across": "Total across {n} categories",
|
||||
"by_category": "Budget by category",
|
||||
"by_category_sub": "Total cost per category, {prior} vs. {year}",
|
||||
"change_by_category": "Change by category",
|
||||
"change_by_category_sub": "Percentage change from {prior} actual to {year} with adjustment",
|
||||
"change_legend": "Orange = cost increase · Blue = cost reduction",
|
||||
"prior_actual": "{prior} actual",
|
||||
"adjusted": "{year} with adjustment",
|
||||
"base": "{year} without adjustment",
|
||||
"change_eur": "Change (€)",
|
||||
"change_pct": "Change (%)",
|
||||
"cost_lines": "{n} cost lines",
|
||||
"cost_line": "1 cost line",
|
||||
"summary": "Summary",
|
||||
"summary_sub": "All categories, {prior} actual vs. {year} with adjustment",
|
||||
"category": "Category",
|
||||
"line": "Line",
|
||||
"site": "Site",
|
||||
"group": "Group",
|
||||
"total": "Total",
|
||||
"factor": "Factor",
|
||||
"reason": "Reason",
|
||||
"remarks": "Remarks",
|
||||
"budget_title": "Budget {year} · {dept}",
|
||||
"budget_sub": "Monthly budget by category",
|
||||
"revenue_total": "Sub total revenue",
|
||||
"sub_total": "Sub total {name}",
|
||||
"total_costs": "Total costs",
|
||||
"status": "Status",
|
||||
"exported": "Exported",
|
||||
"draft": "Draft",
|
||||
"in_review": "In review",
|
||||
"approved": "Approved",
|
||||
"inputs": "Inputs",
|
||||
"months": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
|
||||
"no_data": "No cost lines yet.",
|
||||
"site_filter": "Site: {site}",
|
||||
},
|
||||
"pt": {
|
||||
"cost_report_title": "Capex e Custos para {year}",
|
||||
"cost_report_subtitle": "{dept} · orçamento de custos: real {prior} vs. {year} com ajuste",
|
||||
"total_across": "Total de {n} categorias",
|
||||
"by_category": "Orçamento por categoria",
|
||||
"by_category_sub": "Custo total por categoria, {prior} vs. {year}",
|
||||
"change_by_category": "Variação por categoria",
|
||||
"change_by_category_sub": "Variação percentual do real {prior} para {year} com ajuste",
|
||||
"change_legend": "Laranja = aumento de custo · Azul = redução de custo",
|
||||
"prior_actual": "{prior} real",
|
||||
"adjusted": "{year} com ajuste",
|
||||
"base": "{year} sem ajuste",
|
||||
"change_eur": "Variação (€)",
|
||||
"change_pct": "Variação (%)",
|
||||
"cost_lines": "{n} linhas de custo",
|
||||
"cost_line": "1 linha de custo",
|
||||
"summary": "Resumo",
|
||||
"summary_sub": "Todas as categorias, real {prior} vs. {year} com ajuste",
|
||||
"category": "Categoria",
|
||||
"line": "Linha",
|
||||
"site": "Unidade",
|
||||
"group": "Grupo",
|
||||
"total": "Total",
|
||||
"factor": "Fator",
|
||||
"reason": "Motivo",
|
||||
"remarks": "Observações",
|
||||
"budget_title": "Orçamento {year} · {dept}",
|
||||
"budget_sub": "Orçamento mensal por categoria",
|
||||
"revenue_total": "Subtotal receitas",
|
||||
"sub_total": "Subtotal {name}",
|
||||
"total_costs": "Total de custos",
|
||||
"status": "Estado",
|
||||
"exported": "Exportado",
|
||||
"draft": "Rascunho",
|
||||
"in_review": "Em revisão",
|
||||
"approved": "Aprovado",
|
||||
"inputs": "Dados",
|
||||
"months": ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"],
|
||||
"no_data": "Ainda não há linhas de custo.",
|
||||
"site_filter": "Unidade: {site}",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class T:
|
||||
def __init__(self, lang: str, year: int, dept: str = ""):
|
||||
self.lang = lang if lang in LABELS else "pt"
|
||||
self.labels = LABELS[self.lang]
|
||||
self.ctx = {"year": year, "prior": year - 1, "dept": dept}
|
||||
|
||||
def __call__(self, key: str, **kw):
|
||||
value = self.labels[key]
|
||||
return value.format(**{**self.ctx, **kw}) if isinstance(value, str) else value
|
||||
|
||||
def _group(self, value: Decimal, decimals: int) -> str:
|
||||
q = Decimal(1).scaleb(-decimals) if decimals else Decimal(1)
|
||||
text = f"{abs(Decimal(value)).quantize(q):,.{decimals}f}"
|
||||
whole, _, frac = text.partition(".")
|
||||
whole = whole.replace(",", " ")
|
||||
if not frac:
|
||||
return whole
|
||||
return whole + ("," if self.lang == "pt" else ".") + frac
|
||||
|
||||
def eur(self, value, decimals: int = 2, signed: bool = False) -> str:
|
||||
value = Decimal(value or 0)
|
||||
sign = ("+" if value > 0 else "−" if value < 0 else "") if signed else ("−" if value < 0 else "")
|
||||
body = self._group(value, decimals)
|
||||
return f"{sign}{body} €" if self.lang == "pt" else f"{sign}€{body}"
|
||||
|
||||
def num(self, value, decimals: int = 2) -> str:
|
||||
value = Decimal(value or 0)
|
||||
return ("\u2212" if value < 0 else "") + self._group(value, decimals)
|
||||
|
||||
def pct(self, value, signed: bool = True) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
value = Decimal(value)
|
||||
sign = ("+" if value > 0 else "−" if value < 0 else "") if signed else ""
|
||||
return f"{sign}{self._group(value, 2)}%"
|
||||
|
||||
def short_eur(self, value: float) -> str:
|
||||
v = abs(value)
|
||||
sign = "−" if value < 0 else ""
|
||||
if v >= 1_000_000:
|
||||
body = f"{v / 1_000_000:g}M"
|
||||
elif v >= 1000:
|
||||
body = f"{v / 1000:g}k"
|
||||
else:
|
||||
body = f"{v:g}"
|
||||
if self.lang == "pt":
|
||||
return f"{sign}{body.replace('.', ',')} €"
|
||||
return f"{sign}€{body}"
|
||||
@@ -0,0 +1,52 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
from ..summary import YearView
|
||||
from .charts import change_bars, grouped_bars
|
||||
from .i18n import T
|
||||
|
||||
TEMPLATES = Path(__file__).parent / "templates"
|
||||
env = Environment(loader=FileSystemLoader(TEMPLATES), autoescape=select_autoescape(["html"]))
|
||||
CSS = (TEMPLATES / "report.css").read_text()
|
||||
|
||||
|
||||
def _exported() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def cost_report_html(view: YearView, lang: str, site: str | None = None, screen: bool = True) -> str:
|
||||
t = T(lang, view.year.year, view.year.department.name)
|
||||
cost_cats = view.with_lines("cost")
|
||||
cats = []
|
||||
for cv in cost_cats:
|
||||
rows = [(lv.line.name, float(lv.figures.prior), float(lv.figures.adjusted)) for lv in cv.lines]
|
||||
cats.append(SimpleNamespace(
|
||||
name=cv.name(lang), count=len(cv.lines), totals=cv.totals,
|
||||
chart=grouped_bars(rows, t.short_eur, lambda v: t.eur(v, 0))))
|
||||
overview = grouped_bars([(c.name, float(c.totals.prior), float(c.totals.adjusted)) for c in cats], t.short_eur)
|
||||
ranked = sorted([c for c in cats if c.totals.change_pct is not None],
|
||||
key=lambda c: c.totals.change_pct, reverse=True)
|
||||
change = change_bars([(c.name, float(c.totals.change_pct)) for c in ranked], lambda v: t.pct(v))
|
||||
return env.get_template("cost_report.html").render(
|
||||
t=t, title=t("cost_report_title"), year=view.year, site=site, cats=cats, costs=view.costs,
|
||||
overview_chart=overview, change_chart=change, exported=_exported(), css=CSS, screen=screen)
|
||||
|
||||
|
||||
def budget_html(view: YearView, lang: str, site: str | None = None, include_empty: bool = False) -> str:
|
||||
t = T(lang, view.year.year, view.year.department.name)
|
||||
cats = [SimpleNamespace(name=cv.name(lang), category=cv.category, lines=cv.lines, totals=cv.totals)
|
||||
for cv in view.categories if cv.lines or include_empty]
|
||||
# revenue first, as in the Budget IT sheet
|
||||
cats.sort(key=lambda c: 0 if c.category.section == "revenue" else 1)
|
||||
return env.get_template("budget.html").render(
|
||||
t=t, title=t("budget_title"), year=view.year, site=site, cats=cats, costs=view.costs,
|
||||
exported=_exported(), css=CSS)
|
||||
|
||||
|
||||
def to_pdf(html: str) -> bytes:
|
||||
from weasyprint import HTML # imported lazily: it loads system libraries
|
||||
|
||||
return HTML(string=html).write_pdf()
|
||||
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ t.lang }}">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ title }}</title><style>{{ css|safe }}</style></head>
|
||||
<body>
|
||||
<section class="page budget">
|
||||
<h1>{{ title }}</h1>
|
||||
<div class="sub">{{ t('budget_sub') }}{% if site %} · {{ t('site_filter', site=site) }}{% endif %} · {{ t('status') }}: {{ t(year.status) }} · {{ t('exported') }}: {{ exported }}</div>
|
||||
<table class="grid">
|
||||
<thead><tr><th>{{ t('line') }}</th><th>{{ t('site') }}</th>{% for m in t('months') %}<th>{{ m }}</th>{% endfor %}<th>{{ t("total") }} (€)</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in cats %}
|
||||
<tr class="cat"><td colspan="15">{{ c.name }}</td></tr>
|
||||
{% for l in c.lines %}
|
||||
<tr><td class="name">{{ l.line.name }}</td><td>{{ l.line.site.name if l.line.site else t('group') }}</td>
|
||||
{% for m in l.figures.months %}<td class="num">{{ t.num(m) }}</td>{% endfor %}<td class="num">{{ t.num(l.figures.adjusted) }}</td></tr>
|
||||
{% endfor %}
|
||||
<tr class="subtotal"><td colspan="2">{{ t('revenue_total') if c.category.section == 'revenue' else t('sub_total', name=c.name) }}</td>
|
||||
{% for m in c.totals.months %}<td class="num">{{ t.num(m) }}</td>{% endfor %}<td class="num">{{ t.num(c.totals.adjusted) }}</td></tr>
|
||||
{% endfor %}
|
||||
<tr class="grand"><td colspan="2">{{ t('total_costs') }}</td>{% for m in costs.months %}<td class="num">{{ t.num(m) }}</td>{% endfor %}<td class="num">{{ t.num(costs.adjusted) }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</body></html>
|
||||
@@ -0,0 +1,75 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ t.lang }}">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ title }}</title><style>{{ css|safe }}</style>
|
||||
{% if screen %}<script>
|
||||
function fit(){var w=document.documentElement.clientWidth-32;var z=Math.min(1,w/1280);document.documentElement.style.setProperty('--zoom',z);}
|
||||
window.addEventListener('resize',fit);document.addEventListener('DOMContentLoaded',fit);
|
||||
</script>{% endif %}
|
||||
</head>
|
||||
<body>
|
||||
{% set total_pages = 3 + cats|length %}
|
||||
<section class="page cover">
|
||||
<h1>{{ title }}</h1>
|
||||
<p class="sub">{{ t('cost_report_subtitle') }}{% if site %} · {{ t('site_filter', site=site) }}{% endif %}</p>
|
||||
<div class="kicker">{{ t('total_across', n=cats|length) }}</div>
|
||||
<div class="big">{{ t.eur(costs.prior, 0) }} → {{ t.eur(costs.adjusted, 0) }}</div>
|
||||
<div class="delta {{ 'up' if costs.change > 0 else 'down' }}">{{ t.pct(costs.change_pct) }} ({{ t.eur(costs.change, 0, true) }})</div>
|
||||
<div class="meta">{{ t('status') }}: {{ t(year.status) }} · {{ t('exported') }}: {{ exported }}</div>
|
||||
</section>
|
||||
|
||||
<section class="page">
|
||||
<h1>{{ t('by_category') }}</h1>
|
||||
<p class="sub">{{ t('by_category_sub') }}</p>
|
||||
<div class="rule"></div>
|
||||
<div class="legend"><span><i style="background:#2f6fd6"></i>{{ t('prior_actual') }}</span><span><i style="background:#e8632b"></i>{{ t('adjusted') }}</span></div>
|
||||
<div class="chartbox full">{{ overview_chart|safe }}</div>
|
||||
<div class="footer">{{ title }}<span class="n">2 / {{ total_pages }}</span></div>
|
||||
</section>
|
||||
|
||||
<section class="page">
|
||||
<h1>{{ t('change_by_category') }}</h1>
|
||||
<p class="sub">{{ t('change_by_category_sub') }}</p>
|
||||
<div class="rule"></div>
|
||||
<div class="legend" style="text-align:center">{{ t('change_legend') }}</div>
|
||||
<div class="chartbox full">{{ change_chart|safe }}</div>
|
||||
<div class="footer">{{ title }}<span class="n">3 / {{ total_pages }}</span></div>
|
||||
</section>
|
||||
|
||||
{% for c in cats %}
|
||||
<section class="page">
|
||||
<h1>{{ c.name }}</h1>
|
||||
<p class="sub">{{ t('cost_line') if c.count == 1 else t('cost_lines', n=c.count) }}</p>
|
||||
<div class="rule"></div>
|
||||
<div class="legend"><span><i style="background:#2f6fd6"></i>{{ t('prior_actual') }}</span><span><i style="background:#e8632b"></i>{{ t('adjusted') }}</span></div>
|
||||
<div class="body">
|
||||
<div class="chartbox">{{ c.chart|safe }}</div>
|
||||
<div class="kpis">
|
||||
<div class="kpi"><div class="k">{{ t('prior_actual') }}</div><div class="v">{{ t.eur(c.totals.prior) }}</div></div>
|
||||
<div class="kpi"><div class="k">{{ t('adjusted') }}</div><div class="v">{{ t.eur(c.totals.adjusted) }}</div></div>
|
||||
<div class="kpi"><div class="k">{{ t('change_eur') }}</div><div class="v {{ 'up' if c.totals.change > 0 else 'down' }}">{{ t.eur(c.totals.change, 2, true) }}</div></div>
|
||||
<div class="kpi"><div class="k">{{ t('change_pct') }}</div><div class="v {{ 'up' if c.totals.change > 0 else 'down' }}">{{ t.pct(c.totals.change_pct) }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">{{ title }}<span class="n">{{ 3 + loop.index }} / {{ total_pages }}</span></div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
|
||||
<section class="page">
|
||||
<h1>{{ t('summary') }}</h1>
|
||||
<p class="sub">{{ t('summary_sub') }}</p>
|
||||
<div class="rule"></div>
|
||||
<table class="summary">
|
||||
<thead><tr><th>{{ t('category') }}</th><th class="num">{{ t('prior_actual') }}</th><th class="num">{{ t('adjusted') }}</th><th class="num">{{ t('change_eur') }}</th><th class="num">{{ t('change_pct') }}</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in cats %}
|
||||
<tr><td>{{ c.name }}</td><td class="num">{{ t.eur(c.totals.prior) }}</td><td class="num">{{ t.eur(c.totals.adjusted) }}</td>
|
||||
<td class="num {{ 'up' if c.totals.change > 0 else 'down' }}">{{ t.eur(c.totals.change, 2, true) }}</td><td class="num {{ 'up' if c.totals.change > 0 else 'down' }}">{{ t.pct(c.totals.change_pct) }}</td></tr>
|
||||
{% endfor %}
|
||||
<tr class="total"><td>{{ t('total') }}</td><td class="num">{{ t.eur(costs.prior) }}</td><td class="num">{{ t.eur(costs.adjusted) }}</td>
|
||||
<td class="num {{ 'up' if costs.change > 0 else 'down' }}">{{ t.eur(costs.change, 2, true) }}</td><td class="num {{ 'up' if costs.change > 0 else 'down' }}">{{ t.pct(costs.change_pct) }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="footer">{{ title }}<span class="n">{{ total_pages }} / {{ total_pages }}</span></div>
|
||||
</section>
|
||||
</body></html>
|
||||
@@ -0,0 +1,62 @@
|
||||
@page { size: 338.67mm 190.5mm; margin: 0; }
|
||||
@page budget { size: A3 landscape; margin: 12mm 10mm 14mm 10mm;
|
||||
@bottom-left { content: string(doc-title); font: 8pt Helvetica, Arial, sans-serif; color: #888; }
|
||||
@bottom-right { content: counter(page) " / " counter(pages); font: 8pt Helvetica, Arial, sans-serif; color: #888; } }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body { font-family: "DejaVu Sans", "Liberation Sans", Arial, sans-serif; color: #1d1d1f; background: #ececee; }
|
||||
.page { position: relative; width: 338.67mm; height: 190.5mm; padding: 13mm 16mm 12mm; background: #fbfbf9;
|
||||
overflow: hidden; page-break-after: always; break-after: page; }
|
||||
.page:last-child { page-break-after: auto; break-after: auto; }
|
||||
.page h1 { font-size: 22pt; margin: 0 0 1.5mm; }
|
||||
.page .sub { font-size: 10pt; color: #444; margin: 0 0 3mm; }
|
||||
.rule { border-top: 1px solid #ddd; margin: 2mm 0 3mm; }
|
||||
.legend { font-size: 8.5pt; color: #333; margin-bottom: 2mm; }
|
||||
.legend span { display: inline-block; margin-right: 18mm; }
|
||||
.legend i { display: inline-block; width: 3mm; height: 3mm; margin-right: 2mm; vertical-align: -0.4mm; }
|
||||
.footer { position: absolute; left: 16mm; right: 16mm; bottom: 6mm; font-size: 7pt; color: #888; }
|
||||
.footer .n { float: right; }
|
||||
.cover { padding-left: 22mm; }
|
||||
.cover::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4mm; background: #2f6fd6; }
|
||||
.cover h1 { font-size: 34pt; margin-top: 48mm; }
|
||||
.cover .sub { font-size: 13pt; }
|
||||
.cover .kicker { margin-top: 16mm; font-size: 10pt; color: #444; }
|
||||
.cover .big { font-size: 22pt; font-weight: bold; margin: 2mm 0; }
|
||||
.cover .delta { font-size: 13pt; font-weight: bold; }
|
||||
.up { color: #c2410c; } .down { color: #15803d; }
|
||||
.body { display: flex; gap: 8mm; }
|
||||
.chartbox { flex: 1 1 auto; height: 128mm; }
|
||||
.chartbox.full { height: 136mm; }
|
||||
.chartbox svg { width: 100%; height: 100%; }
|
||||
.kpis { flex: 0 0 72mm; padding-top: 2mm; }
|
||||
.kpi { margin-bottom: 7mm; }
|
||||
.kpi .k { font-size: 9pt; color: #333; }
|
||||
.kpi .v { font-size: 18pt; font-weight: bold; margin-top: 1.5mm; }
|
||||
table.summary { width: 100%; border-collapse: collapse; font-size: 9pt; margin-top: 3mm; }
|
||||
table.summary th { text-align: left; font-weight: bold; border-bottom: 1px solid #bbb; padding: 2mm; }
|
||||
table.summary td { padding: 1.6mm 2mm; border-bottom: 1px solid #e4e4e4; }
|
||||
table.summary .num { text-align: right; white-space: nowrap; }
|
||||
table.summary tr.total td { font-weight: bold; border-top: 1px solid #999; }
|
||||
.meta { font-size: 8pt; color: #777; margin-top: 2mm; }
|
||||
|
||||
/* annual budget */
|
||||
.budget { page: budget; background: #fff; padding: 0; width: auto; height: auto; overflow: visible; }
|
||||
.budget h1 { font-size: 16pt; margin: 0 0 1mm; string-set: doc-title content(); }
|
||||
.budget .sub { font-size: 9pt; color: #555; margin-bottom: 4mm; }
|
||||
table.grid { width: 100%; border-collapse: collapse; font-size: 7.2pt; }
|
||||
table.grid th, table.grid td { padding: 1.1mm 1.2mm; border-bottom: 1px solid #eee; }
|
||||
table.grid thead th { background: #1f3b63; color: #fff; text-align: right; font-weight: bold; }
|
||||
table.grid thead th:first-child, table.grid thead th:nth-child(2) { text-align: left; }
|
||||
table.grid thead { display: table-header-group; }
|
||||
table.grid td.num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
table.grid tr.cat td { font-weight: bold; background: #eef2f8; padding-top: 2mm; }
|
||||
table.grid tr.subtotal td { font-weight: bold; border-top: 1px solid #9aa; border-bottom: 1px solid #9aa; }
|
||||
table.grid tr.grand td { font-weight: bold; background: #1f3b63; color: #fff; }
|
||||
table.grid tr { page-break-inside: avoid; }
|
||||
table.grid td.name { max-width: 70mm; }
|
||||
|
||||
@media screen {
|
||||
body { padding: 16px 0; }
|
||||
.page { margin: 0 auto 16px; box-shadow: 0 1px 4px rgba(0,0,0,.15); zoom: var(--zoom, 1); }
|
||||
.budget { width: auto; max-width: 1600px; height: auto; padding: 16px 20px; margin: 0 auto; overflow: visible; }
|
||||
}
|
||||
@@ -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}
|
||||
@@ -0,0 +1,54 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .config import settings
|
||||
from .db import get_db
|
||||
from .models import User
|
||||
|
||||
COOKIE = "budget_session"
|
||||
# Ordered by what each role may change. "management" (Upper Management) sits below editor: it only
|
||||
# views, downloads and emails budgets and reports, in every department.
|
||||
ROLES = ("viewer", "management", "editor", "approver", "admin")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def verify_password(password: str, hashed: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(password.encode(), hashed.encode())
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def make_token(user: User) -> str:
|
||||
exp = datetime.now(timezone.utc) + timedelta(hours=settings.session_hours)
|
||||
return jwt.encode({"sub": str(user.id), "exp": exp}, settings.secret_key, algorithm="HS256")
|
||||
|
||||
|
||||
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
token = request.cookies.get(COOKIE)
|
||||
if not token:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not_authenticated")
|
||||
try:
|
||||
payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"])
|
||||
except jwt.PyJWTError:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not_authenticated")
|
||||
user = db.get(User, int(payload["sub"]))
|
||||
if not user or not user.active:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not_authenticated")
|
||||
return user
|
||||
|
||||
|
||||
def require(min_role: str):
|
||||
def dep(user: User = Depends(current_user)) -> User:
|
||||
if ROLES.index(user.role) < ROLES.index(min_role):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "forbidden")
|
||||
return user
|
||||
|
||||
return dep
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Default chart of accounts, taken from the 'Budget IT' sheet (same order)."""
|
||||
|
||||
CATEGORIES = [
|
||||
("Revenue", "Receitas", "revenue"),
|
||||
("Cost of Cell Phones", "Custos com telemóveis", "cost"),
|
||||
("Cost of Internet Services", "Serviços de internet", "cost"),
|
||||
("Cost of Local Calls", "Chamadas locais", "cost"),
|
||||
("Cost of Long Distance Calls", "Chamadas internacionais", "cost"),
|
||||
("Other Cost of Services", "Outros custos de serviços", "cost"),
|
||||
("Administrative and General", "Administrativos e gerais", "cost"),
|
||||
("Centralized Information System Charges", "Encargos de sistemas de informação centralizados", "cost"),
|
||||
("Energy Management", "Gestão de energia", "cost"),
|
||||
("Food and Beverage", "Comidas e bebidas", "cost"),
|
||||
("Golf", "Golfe", "cost"),
|
||||
("Hardware", "Hardware", "cost"),
|
||||
("Health Club/Spa", "Health club/Spa", "cost"),
|
||||
("Human Resources", "Recursos humanos", "cost"),
|
||||
("Information Security", "Segurança da informação", "cost"),
|
||||
("Information Systems", "Sistemas de informação", "cost"),
|
||||
("Other", "Outros", "cost"),
|
||||
("Parking", "Estacionamento", "cost"),
|
||||
("Property Operation and Maintenance", "Operação e manutenção do edifício", "cost"),
|
||||
("Rooms", "Quartos", "cost"),
|
||||
("Sales and Marketing", "Vendas e marketing", "cost"),
|
||||
("Telecommunications", "Telecomunicações", "cost"),
|
||||
("Other IT Equipment", "Outro equipamento informático", "cost"),
|
||||
("System Storage and Optimization", "Armazenamento e otimização de sistemas", "cost"),
|
||||
("Complimentary Services and Gifts", "Serviços de cortesia e ofertas", "cost"),
|
||||
("Contract Services", "Serviços contratados", "cost"),
|
||||
("Corporate Office Reimbursables", "Reembolsos à sede", "cost"),
|
||||
("Dues and Subscriptions", "Quotas e subscrições", "cost"),
|
||||
("Entertainment-In-House", "Entretenimento interno", "cost"),
|
||||
("Equipment Rental", "Aluguer de equipamento", "cost"),
|
||||
("Miscellaneous", "Diversos", "cost"),
|
||||
("Cleaning Supplies", "Material de limpeza", "cost"),
|
||||
("Food Supplies", "Abastecimento alimentar", "cost"),
|
||||
("Give Aways Supplies", "Brindes", "cost"),
|
||||
("Guest Supplies", "Amenidades para hóspedes", "cost"),
|
||||
("Laundry Supplies", "Material de lavandaria", "cost"),
|
||||
("Office Supplies", "Material de escritório", "cost"),
|
||||
("Operating Supplies", "Material operacional", "cost"),
|
||||
("Other Beverage Supplies", "Outras bebidas", "cost"),
|
||||
("Other Supplies", "Outros materiais", "cost"),
|
||||
("Paper and Plastics Disposables", "Descartáveis de papel e plástico", "cost"),
|
||||
("Spa Supplies", "Material de spa", "cost"),
|
||||
("Tobacco Supplies", "Tabaco", "cost"),
|
||||
("Wine Supplies", "Vinhos", "cost"),
|
||||
("Training", "Formação", "cost"),
|
||||
("Travel-Meals and Entertainment", "Viagens - refeições e entretenimento", "cost"),
|
||||
("Travel-Other", "Viagens - outros", "cost"),
|
||||
("Uniform Costs", "Fardamentos", "cost"),
|
||||
("Uniform Laundry", "Lavandaria de fardamentos", "cost"),
|
||||
("Internal Recharge", "Imputações internas", "cost"),
|
||||
]
|
||||
|
||||
SITES = ["Sagres", "Quinta", "Chiado", "Oriente"]
|
||||
@@ -0,0 +1,103 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from .calc import round2
|
||||
from .models import BudgetYear, Category, Department, Site, User, WorkflowStep
|
||||
from .permissions import allowed_actions, steps_of
|
||||
from .summary import LineView, YearView
|
||||
|
||||
|
||||
def money(value: Decimal | None) -> float | None:
|
||||
return None if value is None else float(round2(value))
|
||||
|
||||
|
||||
def pct(value: Decimal | None) -> float | None:
|
||||
return None if value is None else float(Decimal(value).quantize(Decimal("0.01")))
|
||||
|
||||
|
||||
def user_out(u: User) -> dict:
|
||||
return {"id": u.id, "email": u.email, "name": u.name, "role": u.role, "language": u.language,
|
||||
"active": u.active, "department_id": u.department_id,
|
||||
"department": u.department.name if u.department else None, "theme": u.theme or "system",
|
||||
"mfa_enabled": bool(u.mfa_enabled), "mfa_enabled_at": u.mfa_enabled_at.isoformat() if u.mfa_enabled_at else None,
|
||||
"mfa_recovery_left": len(u.mfa_recovery or []) if u.mfa_enabled else 0,
|
||||
# the timestamp changes the picture's URL, so browsers fetch a new one after a change
|
||||
"avatar_url": f"/api/users/{u.id}/avatar?v={int(u.avatar_updated_at.timestamp())}" if u.avatar_updated_at else None}
|
||||
|
||||
|
||||
def step_out(s: WorkflowStep) -> dict:
|
||||
return {"id": s.id, "position": s.position, "name": s.name, "approver_mode": s.approver_mode,
|
||||
"approver_ids": s.approver_ids or []}
|
||||
|
||||
|
||||
def department_out(d: Department) -> dict:
|
||||
return {"id": d.id, "name": d.name, "active": d.active, "steps": [step_out(s) for s in steps_of(d)]}
|
||||
|
||||
|
||||
def site_out(s: Site) -> dict:
|
||||
return {"id": s.id, "name": s.name, "active": s.active}
|
||||
|
||||
|
||||
def category_out(c: Category) -> dict:
|
||||
return {"id": c.id, "department_id": c.department_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}
|
||||
|
||||
|
||||
def totals_out(t) -> dict:
|
||||
return {"prior": money(t.prior), "base": money(t.base), "adjusted": money(t.adjusted),
|
||||
"change": money(t.change), "change_pct": pct(t.change_pct), "months": [money(m) for m in t.months]}
|
||||
|
||||
|
||||
def line_out(lv: LineView) -> dict:
|
||||
line, f = lv.line, lv.figures
|
||||
return {
|
||||
"id": line.id, "category_id": line.category_id, "site_id": line.site_id, "name": line.name,
|
||||
"description": line.description, "remarks": line.remarks,
|
||||
"prior_expr": line.prior_expr, "base_expr": line.base_expr,
|
||||
"adj_kind": line.adj_kind, "adj_value": float(line.adj_value or 0), "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,
|
||||
"prior": money(f.prior), "base": money(f.base), "factor": float(f.factor), "adjusted": money(f.adjusted),
|
||||
"change": money(f.change), "change_pct": pct(f.change_pct), "months": [money(m) for m in f.months],
|
||||
}
|
||||
|
||||
|
||||
def year_out(y: BudgetYear, view: YearView | None = None, user: User | None = None) -> dict:
|
||||
out = {
|
||||
"id": y.id, "year": y.year, "title": y.title, "default_rate": float(y.default_rate or 0),
|
||||
"department_id": y.department_id, "department": y.department.name,
|
||||
"status": y.status, "notes": y.notes, "current_step": y.current_step,
|
||||
"workflow": [step_out(s) for s in steps_of(y.department)],
|
||||
"submitted_by": y.submitted_by.name if y.submitted_by else None,
|
||||
"submitted_at": y.submitted_at.isoformat() if y.submitted_at else None,
|
||||
"approved_by": y.approved_by.name if y.approved_by else None,
|
||||
"approved_at": y.approved_at.isoformat() if y.approved_at else None,
|
||||
"source_file": y.source_file or "", "archived": bool(y.archived),
|
||||
"archived_at": y.archived_at.isoformat() if y.archived_at else None,
|
||||
"archived_by": y.archived_by.name if y.archived_by else None,
|
||||
"reopen_request": next((reopen_out(r) for r in y.reopen_requests if r.status == "pending"), None),
|
||||
}
|
||||
if user is not None:
|
||||
out["actions"] = allowed_actions(user, y)
|
||||
if view is not None:
|
||||
out["costs"] = totals_out(view.costs)
|
||||
out["revenue"] = totals_out(view.revenue)
|
||||
return out
|
||||
|
||||
|
||||
def reopen_out(r) -> dict:
|
||||
return {"id": r.id, "status": r.status, "reason": r.reason, "requested_by": r.requested_by.name if r.requested_by else None,
|
||||
"requested_at": r.requested_at.isoformat() if r.requested_at else None,
|
||||
"decided_by": r.decided_by.name if r.decided_by else None,
|
||||
"decided_at": r.decided_at.isoformat() if r.decided_at else None, "decision_note": r.decision_note}
|
||||
|
||||
|
||||
def year_detail(view: YearView, user: User | None = None) -> dict:
|
||||
out = year_out(view.year, view, user)
|
||||
out["reopen_requests"] = [reopen_out(r) for r in reversed(view.year.reopen_requests)]
|
||||
out["categories"] = [
|
||||
{"category": category_out(cv.category), "totals": totals_out(cv.totals),
|
||||
"lines": [line_out(lv) for lv in cv.lines]}
|
||||
for cv in view.categories
|
||||
]
|
||||
return out
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Groups a year's lines by category with computed figures; shared by the API and every report."""
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from .calc import LineFigures, Totals, line_figures
|
||||
from .models import BudgetYear, Category, CostLine
|
||||
|
||||
|
||||
@dataclass
|
||||
class LineView:
|
||||
line: CostLine
|
||||
figures: LineFigures
|
||||
|
||||
|
||||
@dataclass
|
||||
class CategoryView:
|
||||
category: Category
|
||||
lines: list[LineView] = field(default_factory=list)
|
||||
totals: Totals = field(default_factory=Totals)
|
||||
|
||||
def name(self, lang: str) -> str:
|
||||
return (self.category.name_pt if lang == "pt" else "") or self.category.name_en
|
||||
|
||||
|
||||
@dataclass
|
||||
class YearView:
|
||||
year: BudgetYear
|
||||
categories: list[CategoryView]
|
||||
revenue: Totals
|
||||
costs: Totals
|
||||
|
||||
def with_lines(self, section: str | None = None) -> list[CategoryView]:
|
||||
return [c for c in self.categories if c.lines and (section is None or c.category.section == section)]
|
||||
|
||||
|
||||
def build_year_view(db: Session, year: BudgetYear, site_id: int | None = None) -> YearView:
|
||||
categories = db.scalars(select(Category).where(Category.department_id == year.department_id)
|
||||
.order_by(Category.sort_order, Category.id)).all()
|
||||
lines = db.scalars(
|
||||
select(CostLine)
|
||||
.where(CostLine.year_id == year.id)
|
||||
.options(selectinload(CostLine.site), selectinload(CostLine.category))
|
||||
.order_by(CostLine.sort_order, CostLine.id)
|
||||
).all()
|
||||
views = {c.id: CategoryView(c) for c in categories}
|
||||
for line in lines:
|
||||
if site_id is not None and line.site_id != site_id:
|
||||
continue
|
||||
lv = LineView(line, line_figures(line, year.default_rate))
|
||||
cv = views.get(line.category_id)
|
||||
if cv is None: # a line whose category was moved to another department
|
||||
cv = views[line.category_id] = CategoryView(line.category)
|
||||
cv.lines.append(lv)
|
||||
cv.totals.add(lv.figures)
|
||||
revenue, costs = Totals(), Totals()
|
||||
shown = []
|
||||
for cv in views.values():
|
||||
if not cv.lines and not (cv.category.active and cv.category.show_when_empty):
|
||||
continue
|
||||
shown.append(cv)
|
||||
target = revenue if cv.category.section == "revenue" else costs
|
||||
for lv in cv.lines:
|
||||
target.add(lv.figures)
|
||||
return YearView(year, shown, revenue, costs)
|
||||
@@ -0,0 +1,316 @@
|
||||
"""Blank (or pre-filled) Excel template for a department's budget, and the import of a filled one."""
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Font, PatternFill
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.worksheet.datavalidation import DataValidation
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .audit import record
|
||||
from .calc import ExpressionError, evaluate
|
||||
from .models import Category, Department, Site, User
|
||||
|
||||
TEMPLATE_SHEETS = {"Lines", "Linhas"}
|
||||
HEADER_ROW = 8
|
||||
|
||||
TEXT = {
|
||||
"en": {
|
||||
"sheet": "Lines", "lists": "Lists", "help": "Instructions",
|
||||
"title": "Budget template · {dept}",
|
||||
"year": "Year", "rate": "Year rate (%)", "dept": "Department",
|
||||
"cols": ["Category", "Line", "Description", "Site", "Prior-year actual", "Base (without adjustment)",
|
||||
"Adjustment", "Adjustment value", "Reason for adjustment", "Remarks"],
|
||||
"months": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
|
||||
"split": "Split over months",
|
||||
"adj": {"year_rate": "Year rate", "percent": "Percent", "factor": "Factor", "none": "None"},
|
||||
"group": "Group",
|
||||
"instructions": [
|
||||
"How to fill in this template",
|
||||
"1. On the 'Lines' sheet, set the year and the year rate (for example 5.5 for 5.5%).",
|
||||
"2. Add one row per cost line, starting on row 9. Category and Line are required.",
|
||||
"3. Category and Site have drop-down lists. Leave Site empty for costs shared by the group.",
|
||||
"4. Prior-year actual and Base accept numbers or calculations such as =12*839.67.",
|
||||
"5. Adjustment: 'Year rate' applies the year rate to the base; 'Percent' uses the Adjustment value as a %;",
|
||||
" 'Factor' multiplies the base by the Adjustment value (e.g. 1.15); 'None' keeps the base.",
|
||||
"6. Fill in the months (Jan to Dec) only for lines with uneven monthly amounts; the year total is then",
|
||||
" the sum of the months and Base and Adjustment are ignored. Otherwise the year is split over 12.",
|
||||
" To split the adjusted amount equally over some months only, list them in the last column",
|
||||
" 'Split over months', for example 1-4 or Jan, Feb, Mar, Apr (leave Jan to Dec empty).",
|
||||
"7. In the app, go to Budgets > Import Excel, choose the department and upload this file.",
|
||||
],
|
||||
},
|
||||
"pt": {
|
||||
"sheet": "Linhas", "lists": "Listas", "help": "Instruções",
|
||||
"title": "Modelo de orçamento · {dept}",
|
||||
"year": "Ano", "rate": "Taxa do ano (%)", "dept": "Departamento",
|
||||
"cols": ["Categoria", "Linha", "Descrição", "Unidade", "Real do ano anterior", "Base (sem ajuste)",
|
||||
"Ajuste", "Valor do ajuste", "Motivo do ajuste", "Observações"],
|
||||
"months": ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"],
|
||||
"split": "Dividir pelos meses",
|
||||
"adj": {"year_rate": "Taxa do ano", "percent": "Percentagem", "factor": "Fator", "none": "Sem ajuste"},
|
||||
"group": "Grupo",
|
||||
"instructions": [
|
||||
"Como preencher este modelo",
|
||||
"1. Na folha 'Linhas', indique o ano e a taxa do ano (por exemplo 5,5 para 5,5%).",
|
||||
"2. Acrescente uma linha por custo, a partir da linha 9. Categoria e Linha são obrigatórias.",
|
||||
"3. Categoria e Unidade têm listas. Deixe a Unidade vazia para custos do grupo.",
|
||||
"4. Real do ano anterior e Base aceitam números ou cálculos como =12*839,67.",
|
||||
"5. Ajuste: 'Taxa do ano' aplica a taxa do ano à base; 'Percentagem' usa o Valor do ajuste como %;",
|
||||
" 'Fator' multiplica a base pelo Valor do ajuste (ex.: 1,15); 'Sem ajuste' mantém a base.",
|
||||
"6. Preencha os meses (Jan a Dez) só para linhas com valores mensais diferentes; o total do ano passa",
|
||||
" a ser a soma dos meses e a Base e o Ajuste são ignorados. Caso contrário o ano é dividido por 12.",
|
||||
" Para dividir o valor com ajuste em partes iguais só por alguns meses, indique-os na última coluna",
|
||||
" 'Dividir pelos meses', por exemplo 1-4 ou Jan, Fev, Mar, Abr (deixe Jan a Dez vazios).",
|
||||
"7. Na aplicação, vá a Orçamentos > Importar Excel, escolha o departamento e carregue este ficheiro.",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
MONTH_NAMES = {name.lower(): i for t in TEXT.values() for i, name in enumerate(t["months"], start=1)}
|
||||
MONTH_NAMES.update({"feb": 2, "fev": 2, "apr": 4, "abr": 4, "may": 5, "mai": 5, "aug": 8, "ago": 8,
|
||||
"sep": 9, "set": 9, "oct": 10, "out": 10, "dec": 12, "dez": 12})
|
||||
|
||||
|
||||
def _month(token: str) -> int | None:
|
||||
token = token.strip().lower().rstrip(".")
|
||||
if token.isdigit():
|
||||
return int(token) if 1 <= int(token) <= 12 else None
|
||||
return MONTH_NAMES.get(token[:3])
|
||||
|
||||
|
||||
def parse_months(value) -> list[int]:
|
||||
"""'1-4', '1,2,3,4', 'Jan-Abr', 'jan; fev; mar' -> [1, 2, 3, 4]. Unreadable text gives []."""
|
||||
if isinstance(value, (int, float)):
|
||||
value = str(int(value))
|
||||
out = set()
|
||||
for part in str(value).replace(";", ",").replace("/", ",").replace(" e ", ",").replace(" and ", ",").split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
if "-" in part or "–" in part:
|
||||
a, _, b = part.replace("–", "-").partition("-")
|
||||
start, end = _month(a), _month(b)
|
||||
if start is None or end is None or end < start:
|
||||
return []
|
||||
out.update(range(start, end + 1))
|
||||
else:
|
||||
m = _month(part)
|
||||
if m is None:
|
||||
return []
|
||||
out.add(m)
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def months_text(months: list[int]) -> str:
|
||||
"""[1, 2, 3, 4, 7] -> '1-4, 7'."""
|
||||
months, runs = sorted(months), []
|
||||
for m in months:
|
||||
if runs and m == runs[-1][1] + 1:
|
||||
runs[-1][1] = m
|
||||
else:
|
||||
runs.append([m, m])
|
||||
return ", ".join(str(a) if a == b else f"{a}-{b}" for a, b in runs)
|
||||
|
||||
|
||||
HEAD_FILL = PatternFill("solid", fgColor="1F3B63")
|
||||
INPUT_FILL = PatternFill("solid", fgColor="FFF7E0")
|
||||
|
||||
|
||||
def build_template(db: Session, department: Department, lang: str, year: int, rate: Decimal,
|
||||
lines: list | None = None) -> bytes:
|
||||
tx = TEXT.get(lang, TEXT["pt"])
|
||||
cats = db.scalars(select(Category).where(Category.department_id == department.id, Category.active)
|
||||
.order_by(Category.sort_order, Category.id)).all()
|
||||
sites = db.scalars(select(Site).where(Site.active).order_by(Site.name)).all()
|
||||
cat_name = (lambda c: c.name_pt or c.name_en) if lang == "pt" else (lambda c: c.name_en)
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = tx["sheet"]
|
||||
ws["A1"] = tx["title"].format(dept=department.name)
|
||||
ws["A1"].font = Font(bold=True, size=14)
|
||||
for row, (label, value) in enumerate(((tx["dept"], department.name), (tx["year"], year),
|
||||
(tx["rate"], float(rate * 100))), start=3):
|
||||
ws.cell(row, 1, label).font = Font(bold=True)
|
||||
c = ws.cell(row, 2, value)
|
||||
if row > 3:
|
||||
c.fill = INPUT_FILL
|
||||
ws["B5"].number_format = "0.00"
|
||||
headers = tx["cols"] + tx["months"] + [tx["split"]]
|
||||
for col, label in enumerate(headers, start=1):
|
||||
c = ws.cell(HEADER_ROW, col, label)
|
||||
c.font, c.fill = Font(bold=True, color="FFFFFF"), HEAD_FILL
|
||||
c.alignment = Alignment(wrap_text=True, vertical="center")
|
||||
ws.row_dimensions[HEADER_ROW].height = 32
|
||||
|
||||
lists = wb.create_sheet(tx["lists"])
|
||||
for i, c in enumerate(cats, start=1):
|
||||
lists.cell(i, 1, cat_name(c))
|
||||
for i, s in enumerate(sites, start=1):
|
||||
lists.cell(i, 2, s.name)
|
||||
for i, label in enumerate(tx["adj"].values(), start=1):
|
||||
lists.cell(i, 3, label)
|
||||
lists.sheet_state = "hidden"
|
||||
|
||||
last = HEADER_ROW + max(500, len(lines or []) + 200)
|
||||
rng = lambda col: f"{get_column_letter(col)}{HEADER_ROW + 1}:{get_column_letter(col)}{last}"
|
||||
for col, src, n in ((1, "A", len(cats)), (4, "B", len(sites)), (7, "C", 4)):
|
||||
if n:
|
||||
dv = DataValidation(type="list", formula1=f"'{tx['lists']}'!${src}$1:${src}${n}", allow_blank=True)
|
||||
dv.error, dv.showErrorMessage = "", col != 1 # unknown categories are allowed and created on import
|
||||
ws.add_data_validation(dv)
|
||||
dv.add(rng(col))
|
||||
|
||||
row = HEADER_ROW + 1
|
||||
for line in lines or []:
|
||||
f = line.figures
|
||||
ln = line.line
|
||||
values = [cat_name(ln.category), ln.name, ln.description, ln.site.name if ln.site else "",
|
||||
float(f.prior), float(f.base) if ln.monthly_kind != "manual" else None,
|
||||
tx["adj"][ln.adj_kind] if ln.monthly_kind != "manual" else None,
|
||||
float(ln.adj_value) if ln.adj_kind in ("percent", "factor") else None,
|
||||
ln.adj_reason, ln.remarks]
|
||||
if ln.monthly_kind == "manual":
|
||||
values += [float(m) for m in f.months]
|
||||
elif ln.monthly_kind == "selected" and ln.monthly_months:
|
||||
values += [None] * 12 + [months_text(ln.monthly_months)]
|
||||
for col, v in enumerate(values, start=1):
|
||||
if v not in (None, ""):
|
||||
ws.cell(row, col, v)
|
||||
row += 1
|
||||
for col in range(5, 7):
|
||||
for r in range(HEADER_ROW + 1, last + 1):
|
||||
ws.cell(r, col).number_format = "#,##0.00"
|
||||
for col, width in zip(range(1, 11), (34, 40, 30, 14, 18, 20, 16, 14, 30, 36)):
|
||||
ws.column_dimensions[get_column_letter(col)].width = width
|
||||
for col in range(11, 23):
|
||||
ws.column_dimensions[get_column_letter(col)].width = 11
|
||||
ws.column_dimensions[get_column_letter(23)].width = 22
|
||||
ws.freeze_panes = ws.cell(HEADER_ROW + 1, 3)
|
||||
|
||||
helpws = wb.create_sheet(tx["help"], 0)
|
||||
for i, text in enumerate(tx["instructions"], start=1):
|
||||
helpws.cell(i, 1, text).font = Font(bold=(i == 1), size=13 if i == 1 else 11)
|
||||
helpws.cell(len(tx["instructions"]) + 2, 1, f"{tx['dept']}: {department.name} · {datetime.now():%Y-%m-%d}").font = Font(color="888888")
|
||||
helpws.column_dimensions["A"].width = 110
|
||||
wb.active = 1
|
||||
|
||||
buf = BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _norm(value) -> str:
|
||||
return str(value or "").strip().lower()
|
||||
|
||||
|
||||
def _amount(value, warnings, where) -> tuple[str, Decimal]:
|
||||
if value is None or value == "":
|
||||
return "0", Decimal(0)
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value), Decimal(str(value))
|
||||
try:
|
||||
return str(value).strip().lstrip("="), evaluate(str(value))
|
||||
except ExpressionError:
|
||||
warnings.append(f"{where}: '{value}' is not a number, used 0")
|
||||
return "0", Decimal(0)
|
||||
|
||||
|
||||
def import_template(db: Session, wb, wb_formulas, user: User | None, department_id: int, year: int | None,
|
||||
replace: bool, filename: str = "", check_only: bool = False):
|
||||
from .importer import ImportErrorWithDetail, ImportResult, _category_index, prepare_year
|
||||
from .models import CostLine
|
||||
|
||||
ws = next(w for w in wb.worksheets if w.title in TEMPLATE_SHEETS)
|
||||
wsf = wb_formulas[ws.title]
|
||||
|
||||
def cell(r: int, c: int):
|
||||
# Excel stores the computed value of =4*300; files written by other tools may only hold the formula.
|
||||
v = ws.cell(r, c).value
|
||||
return wsf.cell(r, c).value if v is None else v
|
||||
labels = {**{_norm(t["year"]): "year" for t in TEXT.values()}, **{_norm(t["rate"]): "rate" for t in TEXT.values()}}
|
||||
header_row, meta = None, {}
|
||||
for r in range(1, 30):
|
||||
a = _norm(ws.cell(r, 1).value)
|
||||
if a in labels:
|
||||
meta[labels[a]] = ws.cell(r, 2).value
|
||||
if a in (_norm(t["cols"][0]) for t in TEXT.values()):
|
||||
header_row = r
|
||||
break
|
||||
if header_row is None:
|
||||
raise ImportErrorWithDetail("template_header_missing")
|
||||
|
||||
target_year = year or (int(meta["year"]) if isinstance(meta.get("year"), (int, float)) else None)
|
||||
if not target_year:
|
||||
raise ImportErrorWithDetail("year_not_found")
|
||||
rate_value = meta.get("rate")
|
||||
try:
|
||||
rate = evaluate(str(rate_value)) / 100 if rate_value not in (None, "") else Decimal(0)
|
||||
except ExpressionError:
|
||||
rate = Decimal(0)
|
||||
|
||||
result = ImportResult(year=target_year)
|
||||
if check_only:
|
||||
return result
|
||||
by = prepare_year(db, department_id, target_year, rate, replace, user, filename)
|
||||
cats = _category_index(db, department_id)
|
||||
sites = {_norm(s.name): s for s in db.scalars(select(Site))}
|
||||
adj_kinds = {_norm(label): kind for t in TEXT.values() for kind, label in t["adj"].items()}
|
||||
adj_kinds.update({"%": "percent", "factor ×": "factor", "fator ×": "factor"})
|
||||
group_words = {_norm(t["group"]) for t in TEXT.values()}
|
||||
next_order = (db.scalar(select(func.max(Category.sort_order)).where(Category.department_id == department_id)) or 0) + 10
|
||||
|
||||
order = 0
|
||||
for r in range(header_row + 1, ws.max_row + 1):
|
||||
row = [cell(r, c) for c in range(1, 24)]
|
||||
cat_label, name = str(row[0] or "").strip(), str(row[1] or "").strip()
|
||||
if not cat_label and not name:
|
||||
continue
|
||||
where = f"{ws.title} row {r}"
|
||||
if not cat_label or not name:
|
||||
result.warnings.append(f"{where}: category and line are required, row skipped")
|
||||
continue
|
||||
cat = cats.get(cat_label.lower())
|
||||
if cat is None:
|
||||
cat = Category(department_id=department_id, name_en=cat_label, name_pt=cat_label, section="cost",
|
||||
sort_order=next_order)
|
||||
next_order += 10
|
||||
db.add(cat)
|
||||
db.flush()
|
||||
cats[cat_label.lower()] = cat
|
||||
result.categories_created.append(cat_label)
|
||||
site = None
|
||||
if row[3] not in (None, "") and _norm(row[3]) not in group_words:
|
||||
site = sites.get(_norm(row[3]))
|
||||
if site is None:
|
||||
result.warnings.append(f"{where}: unknown site '{row[3]}', saved as group")
|
||||
prior_expr, prior = _amount(row[4], result.warnings, where)
|
||||
base_expr, base = _amount(row[5], result.warnings, where)
|
||||
kind = adj_kinds.get(_norm(row[6]), "year_rate") if row[6] not in (None, "") else "year_rate"
|
||||
_, adj_value = _amount(row[7], result.warnings, where)
|
||||
months = row[10:22]
|
||||
manual = any(m not in (None, "") for m in months)
|
||||
month_values = [float(_amount(m, result.warnings, where)[1]) for m in months] if manual else None
|
||||
selected = None
|
||||
if not manual and row[22] not in (None, ""):
|
||||
selected = parse_months(row[22])
|
||||
if not selected:
|
||||
result.warnings.append(f"{where}: could not read the months '{row[22]}', split over 12")
|
||||
order += 10
|
||||
db.add(CostLine(
|
||||
year_id=by.id, category_id=cat.id, site_id=site.id if site else None, name=name,
|
||||
description=str(row[2] or ""), remarks=str(row[9] or ""), prior_expr=prior_expr, prior_actual=prior,
|
||||
base_expr=base_expr, base_amount=base, adj_kind="none" if manual else kind, adj_value=adj_value,
|
||||
adj_reason=str(row[8] or ""), monthly_values=month_values, monthly_months=selected,
|
||||
monthly_kind="manual" if manual else "selected" if selected else "even",
|
||||
sort_order=order))
|
||||
result.lines += 1
|
||||
|
||||
record(db, user, "year", by.id, "import", f"Imported {result.lines} lines from template into {target_year}",
|
||||
year_id=by.id)
|
||||
db.commit()
|
||||
return result
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
python -m app.preupgrade
|
||||
alembic upgrade head
|
||||
python -m app.bootstrap
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips='*' --workers "${API_WORKERS:-2}"
|
||||
@@ -0,0 +1,13 @@
|
||||
fastapi==0.115.12
|
||||
uvicorn[standard]==0.34.2
|
||||
SQLAlchemy==2.0.40
|
||||
psycopg[binary]==3.2.6
|
||||
alembic==1.15.2
|
||||
openpyxl==3.1.5
|
||||
weasyprint==65.1
|
||||
Jinja2==3.1.6
|
||||
PyJWT==2.10.1
|
||||
bcrypt==4.3.0
|
||||
python-multipart==0.0.20
|
||||
cryptography==44.0.2
|
||||
segno==1.6.1
|
||||
@@ -0,0 +1,40 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
DB = Path(__file__).parent / "test.db"
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{DB}"
|
||||
os.environ["ADMIN_EMAIL"] = "admin@test.local"
|
||||
os.environ["ADMIN_PASSWORD"] = "admin-pass-123"
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from app.bootstrap import bootstrap # noqa: E402
|
||||
from app.db import Base, engine # noqa: E402
|
||||
from app.main import app # noqa: E402
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "budget_2027.xlsx"
|
||||
H = {"X-Requested-With": "budget-app"}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
Base.metadata.drop_all(engine)
|
||||
Base.metadata.create_all(engine)
|
||||
bootstrap()
|
||||
with TestClient(app) as c:
|
||||
r = c.post("/api/auth/login", json={"email": "admin@test.local", "password": "admin-pass-123"}, headers=H)
|
||||
assert r.status_code == 200
|
||||
c.headers.update(H)
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def imported(client):
|
||||
with FIXTURE.open("rb") as f:
|
||||
r = client.post("/api/years/import", files={"file": ("b.xlsx", f, "application/octet-stream")})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["year"]["id"]
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,113 @@
|
||||
from io import BytesIO
|
||||
|
||||
import openpyxl
|
||||
import pytest
|
||||
|
||||
# Category totals from the "Capex e Custos 2027 v4" PDF. Lines are rounded to cents before
|
||||
# summing, so a category can differ from the PDF by a cent or two.
|
||||
PDF_TOTALS = {
|
||||
"Contract Services": (600758.54, 615940.02),
|
||||
"Cost of Cell Phones": (96532.00, 111011.80),
|
||||
"Cost of Internet Services": (74160.00, 85284.00),
|
||||
"Cost of Local Calls": (5575.00, 6269.13),
|
||||
"Cost of Long Distance Calls": (11415.00, 12488.47),
|
||||
"Other Cost of Services": (176847.00, 185226.36),
|
||||
"Hardware": (37636.00, 83361.00),
|
||||
"Property Operation and Maintenance": (31283.00, 55015.83),
|
||||
"Other IT Equipment": (72566.00, 70747.24),
|
||||
"Travel-Other": (14400.00, 16880.00),
|
||||
}
|
||||
|
||||
|
||||
def test_requires_login():
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
assert TestClient(app).get("/api/years").status_code == 401
|
||||
|
||||
|
||||
def test_writes_need_header(client):
|
||||
r = client.post("/api/years", json={"year": 2030}, headers={"X-Requested-With": ""})
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_import_matches_pdf(client, imported):
|
||||
y = client.get(f"/api/years/{imported}").json()
|
||||
assert y["year"] == 2027 and y["default_rate"] == pytest.approx(0.055)
|
||||
got = {c["category"]["name_en"]: c["totals"] for c in y["categories"] if c["lines"]}
|
||||
for name, (prior, adjusted) in PDF_TOTALS.items():
|
||||
assert got[name]["prior"] == pytest.approx(prior, abs=0.005)
|
||||
assert got[name]["adjusted"] == pytest.approx(adjusted, abs=0.02)
|
||||
assert y["costs"]["prior"] == pytest.approx(1121172.54, abs=0.005)
|
||||
assert y["costs"]["adjusted"] == pytest.approx(1242223.85, abs=0.05)
|
||||
assert sum(len(c["lines"]) for c in y["categories"]) == 54
|
||||
hw = next(c for c in y["categories"] if c["category"]["name_en"] == "Hardware")
|
||||
oriente = next(l for l in hw["lines"] if "ORIENTE" in l["name"])
|
||||
assert oriente["adj_kind"] == "factor" and oriente["adj_value"] == pytest.approx(3.055)
|
||||
assert "Hardware price increase" in oriente["adj_reason"]
|
||||
protel = next(l for c in y["categories"] for l in c["lines"] if l["name"] == "Protel")
|
||||
assert protel["prior_expr"] == "5512*12"
|
||||
|
||||
|
||||
def test_line_crud_and_month_spread(client, imported):
|
||||
cats = client.get("/api/categories").json()
|
||||
training = next(c for c in cats if c["name_en"] == "Training")
|
||||
r = client.post(f"/api/years/{imported}/lines", json={
|
||||
"category_id": training["id"], "name": "Microsoft certification", "prior_expr": "1000",
|
||||
"base_expr": "=4*300", "adj_kind": "percent", "adj_value": 10})
|
||||
assert r.status_code == 200, r.text
|
||||
line_id = r.json()["id"]
|
||||
y = client.get(f"/api/years/{imported}").json()
|
||||
line = next(l for c in y["categories"] for l in c["lines"] if l["id"] == line_id)
|
||||
assert line["base"] == 1200 and line["adjusted"] == 1320 and sum(line["months"]) == pytest.approx(1320)
|
||||
assert client.put(f"/api/lines/{line_id}", json={"base_expr": "abc"}).status_code == 400
|
||||
r = client.put(f"/api/lines/{line_id}", json={"monthly_kind": "manual"})
|
||||
assert r.status_code == 200
|
||||
y = client.get(f"/api/years/{imported}").json()
|
||||
line = next(l for c in y["categories"] for l in c["lines"] if l["id"] == line_id)
|
||||
assert line["monthly_values"][0] == 110 and line["adjusted"] == pytest.approx(1320)
|
||||
assert client.delete(f"/api/lines/{line_id}").status_code == 200
|
||||
audit = client.get(f"/api/years/{imported}/audit").json()
|
||||
assert [a["action"] for a in audit[:3]] == ["delete", "update", "create"]
|
||||
|
||||
|
||||
def test_status_workflow_and_roles(client, imported):
|
||||
it = client.get("/api/departments").json()[0]["id"]
|
||||
r = client.post("/api/users", json={"email": "ed@test.local", "name": "Ed", "role": "editor",
|
||||
"password": "editor-pass-1", "department_id": it})
|
||||
assert r.status_code == 200
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
ed = TestClient(app)
|
||||
ed.headers.update({"X-Requested-With": "budget-app"})
|
||||
assert ed.post("/api/auth/login", json={"email": "ed@test.local", "password": "editor-pass-1"}).status_code == 200
|
||||
assert ed.get("/api/users").status_code == 403
|
||||
assert ed.post(f"/api/years/{imported}/status", json={"action": "submit"}).status_code == 200
|
||||
assert ed.post(f"/api/years/{imported}/status", json={"action": "approve"}).status_code == 403
|
||||
line = client.get(f"/api/years/{imported}").json()["categories"][1]["lines"][0]
|
||||
assert ed.put(f"/api/lines/{line['id']}", json={"name": "x"}).status_code == 409
|
||||
assert client.post(f"/api/years/{imported}/status", json={"action": "approve"}).json()["status"] == "approved"
|
||||
assert client.post(f"/api/years/{imported}/status", json={"action": "reopen"}).status_code == 400 # reason needed
|
||||
assert client.post(f"/api/years/{imported}/status", json={"action": "reopen", "comment": "new contract"}).json()["status"] == "draft"
|
||||
|
||||
|
||||
def test_rollover(client, imported):
|
||||
r = client.post("/api/years", json={"year": 2028, "copy_from_id": imported})
|
||||
assert r.status_code == 200
|
||||
y = client.get(f"/api/years/{r.json()['id']}").json()
|
||||
assert y["costs"]["prior"] == pytest.approx(1242223.85, abs=0.05)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["cost-report", "budget"])
|
||||
@pytest.mark.parametrize("lang", ["pt", "en"])
|
||||
def test_reports(client, imported, kind, lang):
|
||||
html = client.get(f"/api/years/{imported}/reports/{kind}.html?lang={lang}")
|
||||
assert html.status_code == 200
|
||||
assert ("<svg" if kind == "cost-report" else "<table") in html.text
|
||||
xlsx = client.get(f"/api/years/{imported}/reports/{kind}.xlsx?lang={lang}")
|
||||
assert xlsx.status_code == 200
|
||||
wb = openpyxl.load_workbook(BytesIO(xlsx.content))
|
||||
assert len(wb.sheetnames) >= 2
|
||||
pdf = client.get(f"/api/years/{imported}/reports/{kind}.pdf?lang={lang}")
|
||||
assert pdf.status_code == 200 and pdf.content[:4] == b"%PDF"
|
||||
@@ -0,0 +1,30 @@
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.calc import ExpressionError, evaluate, even_spread
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text,expected", [
|
||||
("=5512*12", "66144"),
|
||||
("3292.56+(12*839.67)+(12*148.19)", "15146.88"),
|
||||
("4 839,65", "4839.65"),
|
||||
("1.234,50", "1234.50"),
|
||||
("1,234.50", "1234.50"),
|
||||
("", "0"),
|
||||
("-10/4", "-2.5"),
|
||||
])
|
||||
def test_evaluate(text, expected):
|
||||
assert evaluate(text) == Decimal(expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["__import__('os')", "2**10", "1/0", "abc", "1;2"])
|
||||
def test_evaluate_rejects(text):
|
||||
with pytest.raises(ExpressionError):
|
||||
evaluate(text)
|
||||
|
||||
|
||||
def test_even_spread_adds_up():
|
||||
months = even_spread(Decimal("100.00"))
|
||||
assert sum(months) == Decimal("100.00")
|
||||
assert months[0] == Decimal("8.33") and months[-1] == Decimal("8.37")
|
||||
@@ -0,0 +1,127 @@
|
||||
from io import BytesIO
|
||||
|
||||
import openpyxl
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
H = {"X-Requested-With": "budget-app"}
|
||||
|
||||
|
||||
def login(email, password):
|
||||
c = TestClient(app)
|
||||
c.headers.update(H)
|
||||
assert c.post("/api/auth/login", json={"email": email, "password": password}).status_code == 200
|
||||
return c
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def org(client, imported):
|
||||
"""IT (with the imported 2027 budget) and F&B, one editor and one approver in each."""
|
||||
it = client.get("/api/departments").json()[0]
|
||||
fb = client.post("/api/departments", json={"name": "F&B", "categories_from": "defaults"}).json()
|
||||
users = {}
|
||||
for key, role, dept in (("it_ed", "editor", it), ("it_ap", "approver", it),
|
||||
("fb_ed", "editor", fb), ("fb_ap", "approver", fb)):
|
||||
r = client.post("/api/users", json={"email": f"{key}@t.local", "name": key, "role": role,
|
||||
"password": "password-123", "department_id": dept["id"]})
|
||||
assert r.status_code == 200, r.text
|
||||
users[key] = login(f"{key}@t.local", "password-123")
|
||||
return {"it": it, "fb": fb, "year": imported, **users}
|
||||
|
||||
|
||||
def test_categories_are_per_department(client, org):
|
||||
it_cats = client.get(f"/api/categories?department_id={org['it']['id']}").json()
|
||||
fb_cats = client.get(f"/api/categories?department_id={org['fb']['id']}").json()
|
||||
assert len(it_cats) == len(fb_cats) == 50
|
||||
assert not {c["id"] for c in it_cats} & {c["id"] for c in fb_cats}
|
||||
# a line in an IT budget cannot use an F&B category
|
||||
r = client.post(f"/api/years/{org['year']}/lines", json={"category_id": fb_cats[0]["id"], "name": "x"})
|
||||
assert r.status_code == 400 and r.json()["detail"] == "invalid_category"
|
||||
# the same year can exist once per department
|
||||
r = org["fb_ed"].post("/api/years", json={"year": 2027})
|
||||
assert r.status_code == 200 and r.json()["department"] == "F&B"
|
||||
|
||||
|
||||
def test_other_departments_can_only_view(org):
|
||||
fb = org["fb_ed"]
|
||||
y = fb.get(f"/api/years/{org['year']}").json()
|
||||
assert y["department"] == "IT" and y["actions"]["edit"] is False
|
||||
line = y["categories"][1]["lines"][0]
|
||||
assert fb.put(f"/api/lines/{line['id']}", json={"name": "hack"}).status_code == 403
|
||||
assert fb.post(f"/api/years/{org['year']}/status", json={"action": "submit"}).status_code == 403
|
||||
assert fb.get(f"/api/years/{org['year']}/reports/cost-report.xlsx").status_code == 200
|
||||
assert org["it_ed"].put(f"/api/lines/{line['id']}", json={"remarks": "ok"}).status_code == 200
|
||||
|
||||
|
||||
def test_custom_workflow(client, org):
|
||||
it = org["it"]["id"]
|
||||
users = {u["email"]: u["id"] for u in client.get("/api/users").json()}
|
||||
r = client.put(f"/api/departments/{it}/workflow", json={"steps": [
|
||||
{"name": "Department head", "approver_mode": "role"},
|
||||
{"name": "Finance", "approver_mode": "users", "approver_ids": [users["fb_ap@t.local"]]},
|
||||
]})
|
||||
assert r.status_code == 200 and [s["name"] for s in r.json()["steps"]] == ["Department head", "Finance"]
|
||||
y = org["year"]
|
||||
assert org["it_ed"].post(f"/api/years/{y}/status", json={"action": "submit"}).json()["current_step"] == 0
|
||||
# step 1: only IT approvers; the F&B approver is not on this step
|
||||
assert org["fb_ap"].post(f"/api/years/{y}/status", json={"action": "approve"}).status_code == 403
|
||||
assert org["it_ed"].post(f"/api/years/{y}/status", json={"action": "approve"}).status_code == 403
|
||||
r = org["it_ap"].post(f"/api/years/{y}/status", json={"action": "approve"})
|
||||
assert r.json()["status"] == "in_review" and r.json()["current_step"] == 1
|
||||
# step 2: only the named user
|
||||
assert org["it_ap"].post(f"/api/years/{y}/status", json={"action": "approve"}).status_code == 403
|
||||
r = org["fb_ap"].post(f"/api/years/{y}/status", json={"action": "approve"})
|
||||
assert r.json()["status"] == "approved"
|
||||
# the workflow cannot be changed while a budget is in review
|
||||
org["it_ap"].post(f"/api/years/{y}/status", json={"action": "reopen", "comment": "fix a line"})
|
||||
org["it_ed"].post(f"/api/years/{y}/status", json={"action": "submit"})
|
||||
assert client.put(f"/api/departments/{it}/workflow", json={"steps": []}).status_code == 409
|
||||
|
||||
|
||||
def test_template_roundtrip(client, org):
|
||||
fb = org["fb"]["id"]
|
||||
r = org["fb_ed"].get(f"/api/departments/{fb}/template.xlsx?lang=pt&year=2028&rate=3")
|
||||
assert r.status_code == 200
|
||||
wb = openpyxl.load_workbook(BytesIO(r.content))
|
||||
assert wb.sheetnames == ["Instruções", "Linhas", "Listas"]
|
||||
ws = wb["Linhas"]
|
||||
rows = [
|
||||
["Formação", "Curso de barista", "", "Chiado", 1000, "=4*300", "Percentagem", 10, "", "nota"],
|
||||
["Vinhos", "Carta de vinhos", "", "", 5000, 5000, "Fator", 1.2, "Preços", ""],
|
||||
["Receitas", "Eventos", "", "Grupo", 0, None, None, None, "", ""] + [100] * 12,
|
||||
["Nova categoria", "Algo novo", "", "Lua", 10, 10, "", None, "", ""],
|
||||
]
|
||||
for i, row in enumerate(rows):
|
||||
for j, v in enumerate(row):
|
||||
ws.cell(9 + i, j + 1, v)
|
||||
buf = BytesIO()
|
||||
wb.save(buf)
|
||||
r = org["fb_ed"].post("/api/years/import", files={"file": ("t.xlsx", buf.getvalue())},
|
||||
data={"department_id": str(fb)})
|
||||
assert r.status_code == 200, r.text
|
||||
res = r.json()
|
||||
assert res["lines"] == 4 and res["categories_created"] == ["Nova categoria"]
|
||||
assert any("Lua" in w for w in res["warnings"])
|
||||
y = client.get(f"/api/years/{res['year']['id']}").json()
|
||||
assert y["year"] == 2028 and y["default_rate"] == pytest.approx(0.03) and y["department"] == "F&B"
|
||||
lines = {l["name"]: l for c in y["categories"] for l in c["lines"]}
|
||||
assert lines["Curso de barista"]["adjusted"] == 1320
|
||||
assert lines["Carta de vinhos"]["adjusted"] == 6000
|
||||
assert lines["Eventos"]["adjusted"] == 1200 and lines["Eventos"]["monthly_kind"] == "manual"
|
||||
assert lines["Algo novo"]["adjusted"] == pytest.approx(10.3)
|
||||
# the IT editor cannot import into F&B
|
||||
r = org["it_ed"].post("/api/years/import", files={"file": ("t.xlsx", buf.getvalue())},
|
||||
data={"department_id": str(fb), "replace": "true"})
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_prefilled_template_reimports_same_totals(client, org):
|
||||
it = org["it"]["id"]
|
||||
r = client.get(f"/api/departments/{it}/template.xlsx?lang=en&from_year_id={org['year']}&year=2029&rate=5.5")
|
||||
res = client.post("/api/years/import", files={"file": ("t.xlsx", r.content)}, data={"department_id": str(it)}).json()
|
||||
assert res["lines"] == 54
|
||||
a = client.get(f"/api/years/{org['year']}").json()["costs"]
|
||||
b = client.get(f"/api/years/{res['year']['id']}").json()["costs"]
|
||||
assert a["adjusted"] == pytest.approx(b["adjusted"], abs=0.01) and a["prior"] == b["prior"]
|
||||
@@ -0,0 +1,191 @@
|
||||
import email
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import mailer
|
||||
from app.main import app
|
||||
|
||||
H = {"X-Requested-With": "budget-app"}
|
||||
|
||||
|
||||
class FakeSMTP:
|
||||
sent: list = []
|
||||
fail_login = False
|
||||
|
||||
def __init__(self, host, port, timeout=None, context=None):
|
||||
self.host, self.port = host, port
|
||||
|
||||
def ehlo(self):
|
||||
pass
|
||||
|
||||
def starttls(self, context=None):
|
||||
pass
|
||||
|
||||
def login(self, user, password):
|
||||
if FakeSMTP.fail_login or password != "smtp-secret":
|
||||
import smtplib
|
||||
raise smtplib.SMTPAuthenticationError(535, b"bad credentials")
|
||||
|
||||
def send_message(self, msg, from_addr=None, to_addrs=None):
|
||||
FakeSMTP.sent.append((from_addr, list(to_addrs), msg))
|
||||
return {}
|
||||
|
||||
def quit(self):
|
||||
pass
|
||||
|
||||
|
||||
class FakeIMAP:
|
||||
appended: list = []
|
||||
folders = [b'(\\HasNoChildren) "/" "INBOX"', b'(\\HasNoChildren \\Sent) "/" "Itens Enviados"',
|
||||
b'(\\HasNoChildren) "/" "Sent"']
|
||||
|
||||
def __init__(self, host, port, ssl_context=None, timeout=None):
|
||||
pass
|
||||
|
||||
def login(self, user, password):
|
||||
assert password == "smtp-secret"
|
||||
|
||||
def list(self):
|
||||
return "OK", list(FakeIMAP.folders)
|
||||
|
||||
def append(self, folder, flags, date, data):
|
||||
FakeIMAP.appended.append((folder, flags, data))
|
||||
return "OK", [b"APPEND completed"]
|
||||
|
||||
def logout(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_mail(monkeypatch):
|
||||
FakeSMTP.sent, FakeIMAP.appended, FakeSMTP.fail_login = [], [], False
|
||||
monkeypatch.setattr(mailer.smtplib, "SMTP", FakeSMTP)
|
||||
monkeypatch.setattr(mailer.smtplib, "SMTP_SSL", FakeSMTP)
|
||||
monkeypatch.setattr(mailer.imaplib, "IMAP4_SSL", FakeIMAP)
|
||||
monkeypatch.setattr(mailer.imaplib, "IMAP4", FakeIMAP)
|
||||
|
||||
|
||||
def configure(client, **extra):
|
||||
body = {"enabled": True, "smtp_host": "mail.local", "smtp_port": 587, "smtp_security": "starttls",
|
||||
"smtp_username": "budget@local.test", "smtp_password": "smtp-secret",
|
||||
"from_address": "budget@local.test", "from_name": "Budget", "save_to_sent": True,
|
||||
"imap_host": "mail.local", "imap_port": 993, "imap_security": "ssl", **extra}
|
||||
r = client.put("/api/mail/settings", json=body)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def test_settings_hide_and_encrypt_passwords(client, fake_mail):
|
||||
out = configure(client)
|
||||
assert out["smtp_password_set"] and "smtp_password" not in out and out["ready"]
|
||||
from app.db import SessionLocal
|
||||
from app.models import MailSettings
|
||||
with SessionLocal() as db:
|
||||
stored = db.get(MailSettings, 1).smtp_password
|
||||
assert stored and "smtp-secret" not in stored and mailer.decrypt(stored) == "smtp-secret"
|
||||
# saving again without a password keeps it
|
||||
configure(client, smtp_password="")
|
||||
assert client.post("/api/mail/test", json={}).json() == {"smtp": "ok", "imap": "ok", "sent_folder": "Itens Enviados"}
|
||||
|
||||
|
||||
def test_send_report_saves_sent_copy_and_logs(client, imported, fake_mail):
|
||||
configure(client)
|
||||
r = client.post(f"/api/years/{imported}/email", json={
|
||||
"to": ["cfo@example.com; ops@example.com"], "cc": ["boss@example.com"], "subject": "Budget 2027",
|
||||
"message": "Segue o orçamento.", "lang": "pt", "copy_me": True,
|
||||
"reports": [{"kind": "cost-report", "fmt": "pdf"}, {"kind": "budget", "fmt": "xlsx"}]})
|
||||
assert r.status_code == 200, r.text
|
||||
log = r.json()
|
||||
assert log["status"] == "sent" and log["sent_copy"] == "saved" and log["sent_copy_detail"] == "Itens Enviados"
|
||||
assert log["to"] == ["cfo@example.com", "ops@example.com"]
|
||||
assert log["cc"] == ["boss@example.com", "admin@test.local"]
|
||||
|
||||
sender, rcpts, msg = FakeSMTP.sent[0]
|
||||
assert sender == "budget@local.test" and len(rcpts) == 4
|
||||
names = [p.get_filename() for p in msg.iter_attachments()]
|
||||
assert names == ["Budget_IT_2027.xlsx", "Capex_e_Custos_IT_2027.pdf"]
|
||||
folder, flags, data = FakeIMAP.appended[0]
|
||||
assert folder == '"Itens Enviados"' and flags == r"(\Seen)"
|
||||
copy = email.message_from_bytes(data)
|
||||
assert copy["Message-ID"] == msg["Message-ID"] and copy["Subject"] == "Budget 2027"
|
||||
|
||||
rows = client.get("/api/mail/log").json()
|
||||
assert rows["total"] == 1 and rows["rows"][0]["user"] == "Administrator"
|
||||
assert [a["name"] for a in rows["rows"][0]["attachments"]] == names
|
||||
assert "cfo@example.com" in client.get("/api/mail/log.csv").text
|
||||
acts = client.get("/api/activity?entity=mail").json()["rows"]
|
||||
assert acts[0]["action"] == "send" and "cfo@example.com" in acts[0]["summary"]
|
||||
|
||||
|
||||
def test_failed_send_is_logged(client, imported, fake_mail):
|
||||
configure(client)
|
||||
FakeSMTP.fail_login = True
|
||||
r = client.post(f"/api/years/{imported}/email", json={"to": ["a@example.com"],
|
||||
"reports": [{"kind": "budget", "fmt": "pdf"}]})
|
||||
assert r.status_code == 502 and r.json()["detail"] == "smtp_auth"
|
||||
row = client.get("/api/mail/log?status=failed").json()["rows"][0]
|
||||
assert row["error"].startswith("smtp_auth") and row["sent_copy"] == "off"
|
||||
assert not FakeIMAP.appended
|
||||
|
||||
|
||||
def test_sent_folder_found_by_name(fake_mail, monkeypatch):
|
||||
monkeypatch.setattr(FakeIMAP, "folders", [b'(\\HasNoChildren) "." "INBOX"', b'(\\HasNoChildren) "." "INBOX.Sent"'])
|
||||
assert mailer.find_sent_folder(FakeIMAP("h", 1), "") == "INBOX.Sent"
|
||||
with pytest.raises(mailer.MailError) as exc:
|
||||
mailer.find_sent_folder(FakeIMAP("h", 1), "Outbox")
|
||||
assert exc.value.code == "sent_folder_not_found"
|
||||
|
||||
|
||||
def test_mail_rules(client, imported, fake_mail):
|
||||
# not configured yet
|
||||
r = client.post(f"/api/years/{imported}/email", json={"to": ["a@example.com"],
|
||||
"reports": [{"kind": "budget", "fmt": "pdf"}]})
|
||||
assert r.status_code == 409
|
||||
configure(client)
|
||||
bad = client.post(f"/api/years/{imported}/email", json={"to": ["not-an-address"],
|
||||
"reports": [{"kind": "budget", "fmt": "pdf"}]})
|
||||
assert bad.status_code == 400 and bad.json()["detail"].startswith("invalid_address")
|
||||
assert client.post(f"/api/years/{imported}/email", json={"to": ["a@example.com"], "reports": []}).status_code == 400
|
||||
|
||||
client.post("/api/users", json={"email": "v@t.local", "name": "Viewer", "role": "viewer",
|
||||
"password": "password-123"})
|
||||
viewer = TestClient(app)
|
||||
viewer.headers.update(H)
|
||||
viewer.post("/api/auth/login", json={"email": "v@t.local", "password": "password-123"})
|
||||
# any signed-in user may email a report; only admins see the settings and the logs
|
||||
assert viewer.get("/api/mail/status").json()["enabled"] is True
|
||||
assert viewer.post(f"/api/years/{imported}/email", json={
|
||||
"to": ["a@example.com"], "reports": [{"kind": "budget", "fmt": "xlsx"}]}).status_code == 200
|
||||
for url in ("/api/mail/settings", "/api/mail/log", "/api/mail/log.csv", "/api/activity", "/api/activity.csv"):
|
||||
assert viewer.get(url).status_code == 403, url
|
||||
assert viewer.put("/api/mail/settings", json={"enabled": False}).status_code == 403
|
||||
|
||||
|
||||
def test_only_admin_deletes_budgets(client, imported):
|
||||
client.post("/api/users", json={"email": "ap@t.local", "name": "Approver", "role": "approver",
|
||||
"password": "password-123", "department_id": 1})
|
||||
ap = TestClient(app)
|
||||
ap.headers.update(H)
|
||||
ap.post("/api/auth/login", json={"email": "ap@t.local", "password": "password-123"})
|
||||
assert ap.get(f"/api/years/{imported}").json()["actions"]["delete"] is False
|
||||
assert ap.delete(f"/api/years/{imported}").status_code == 403
|
||||
# an admin can delete it even after approval
|
||||
assert client.post(f"/api/years/{imported}/status", json={"action": "submit"}).status_code == 200
|
||||
assert client.get(f"/api/years/{imported}").json()["actions"]["delete"] is True
|
||||
assert client.delete(f"/api/years/{imported}").status_code == 200
|
||||
assert client.get(f"/api/years/{imported}").status_code == 404
|
||||
row = client.get("/api/activity?action=delete&entity=year").json()["rows"][0]
|
||||
assert row["user"] == "Administrator" and "IT 2027" in row["summary"] and row["budget_exists"] is False
|
||||
|
||||
|
||||
def test_activity_log_records_who_does_what(client, imported):
|
||||
client.post("/api/auth/login", json={"email": "admin@test.local", "password": "wrong"})
|
||||
client.get(f"/api/years/{imported}/reports/budget.xlsx")
|
||||
data = client.get("/api/activity").json()
|
||||
actions = [(r["entity"], r["action"]) for r in data["rows"]]
|
||||
assert ("report", "download") in actions and ("user", "login_failed") in actions and ("year", "import") in actions
|
||||
assert data["rows"][0]["budget"] == "IT 2027" or data["rows"][0]["entity"] == "user"
|
||||
only = client.get("/api/activity?entity=report").json()
|
||||
assert only["total"] == 1 and only["rows"][0]["budget"] == "IT 2027"
|
||||
assert "budget.xlsx" in client.get("/api/activity.csv?entity=report").text
|
||||
@@ -0,0 +1,71 @@
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
|
||||
import openpyxl
|
||||
|
||||
from app.calc import selected_spread
|
||||
|
||||
|
||||
def test_selected_spread_in_cents():
|
||||
months = selected_spread(Decimal("1000.00"), [1, 2, 3])
|
||||
assert months[:3] == [Decimal("333.33"), Decimal("333.33"), Decimal("333.34")]
|
||||
assert sum(months) == Decimal("1000.00") and months[3:] == [0] * 9
|
||||
assert selected_spread(Decimal("120"), [4, 1, 3, 2, 2]) == [30, 30, 30, 30] + [0] * 8
|
||||
assert selected_spread(Decimal("12"), []) == [1] * 12
|
||||
|
||||
|
||||
def _first_line(client, year_id):
|
||||
return client.get(f"/api/years/{year_id}").json()["categories"][0]["lines"][0]
|
||||
|
||||
|
||||
def test_line_split_over_chosen_months(client, imported):
|
||||
line = _first_line(client, imported)
|
||||
r = client.put(f"/api/lines/{line['id']}", json={"monthly_kind": "selected", "monthly_months": [4, 1, 2, 3, 3]})
|
||||
assert r.status_code == 200, r.text
|
||||
after = _first_line(client, imported)
|
||||
assert after["monthly_kind"] == "selected" and after["monthly_months"] == [1, 2, 3, 4]
|
||||
assert after["adjusted"] == line["adjusted"] # the year total does not change, only when it is spent
|
||||
share = round(line["adjusted"] / 4, 2)
|
||||
assert after["months"][0] == share and after["months"][4:] == [0] * 8
|
||||
assert round(sum(after["months"]), 2) == line["adjusted"]
|
||||
|
||||
# the year's monthly totals follow; the monthly budget report shows the months
|
||||
year = client.get(f"/api/years/{imported}").json()
|
||||
assert year["costs"]["adjusted"] == client.get(f"/api/years/{imported}").json()["costs"]["adjusted"]
|
||||
assert client.get(f"/api/years/{imported}/reports/budget.xlsx").status_code == 200
|
||||
|
||||
assert client.put(f"/api/lines/{line['id']}", json={"monthly_kind": "selected", "monthly_months": []}).status_code == 400
|
||||
assert client.put(f"/api/lines/{line['id']}", json={"monthly_months": [13]}).status_code == 400
|
||||
|
||||
# rolling over to next year keeps the chosen months
|
||||
nxt = client.post("/api/years", json={"year": 2028, "copy_from_id": imported}).json()
|
||||
copied = _first_line(client, nxt["id"])
|
||||
assert copied["monthly_kind"] == "selected" and copied["monthly_months"] == [1, 2, 3, 4]
|
||||
|
||||
# back to 12 months
|
||||
client.put(f"/api/lines/{line['id']}", json={"monthly_kind": "even", "monthly_months": None})
|
||||
assert _first_line(client, imported)["months"][11] != 0
|
||||
|
||||
|
||||
def test_template_keeps_chosen_months(client, imported):
|
||||
line = _first_line(client, imported)
|
||||
client.put(f"/api/lines/{line['id']}", json={"monthly_kind": "selected", "monthly_months": [1, 2, 3, 4, 7]})
|
||||
dept = client.get("/api/departments").json()[0]["id"]
|
||||
r = client.get(f"/api/departments/{dept}/template.xlsx?lang=pt&from_year_id={imported}&year=2030")
|
||||
ws = openpyxl.load_workbook(BytesIO(r.content))["Linhas"]
|
||||
assert ws.cell(8, 23).value == "Dividir pelos meses"
|
||||
assert "1-4, 7" in [ws.cell(row, 23).value for row in range(9, 70)]
|
||||
# a hand-written month list on a row without typed months
|
||||
free = next(r for r in range(9, 70) if ws.cell(r, 2).value and ws.cell(r, 23).value is None
|
||||
and all(ws.cell(r, c).value is None for c in range(11, 23)))
|
||||
ws.cell(free, 23, "Set-Dez")
|
||||
buf = BytesIO()
|
||||
ws.parent.save(buf)
|
||||
res = client.post("/api/years/import", files={"file": ("t.xlsx", buf.getvalue())},
|
||||
data={"department_id": str(dept)}).json()
|
||||
lines = [ln for c in client.get(f"/api/years/{res['year']['id']}").json()["categories"] for ln in c["lines"]]
|
||||
picked = [ln["monthly_months"] for ln in lines if ln["monthly_kind"] == "selected"]
|
||||
assert [1, 2, 3, 4, 7] in picked and [9, 10, 11, 12] in picked
|
||||
a = client.get(f"/api/years/{imported}").json()["costs"]
|
||||
b = client.get(f"/api/years/{res['year']['id']}").json()["costs"]
|
||||
assert abs(a["adjusted"] - b["adjusted"]) < 0.01
|
||||
@@ -0,0 +1,127 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from tests.test_mail import fake_mail # noqa: F401 (fixture)
|
||||
|
||||
H = {"X-Requested-With": "budget-app"}
|
||||
|
||||
|
||||
def user(client, email, role, dept=1):
|
||||
client.post("/api/users", json={"email": email, "name": email.split("@")[0], "role": role,
|
||||
"password": "password-123", "department_id": dept})
|
||||
c = TestClient(app)
|
||||
c.headers.update(H)
|
||||
assert c.post("/api/auth/login", json={"email": email, "password": "password-123"}).status_code == 200
|
||||
return c
|
||||
|
||||
|
||||
def approve(client, year_id):
|
||||
assert client.post(f"/api/years/{year_id}/status", json={"action": "submit"}).status_code == 200
|
||||
while client.get(f"/api/years/{year_id}").json()["status"] == "in_review":
|
||||
assert client.post(f"/api/years/{year_id}/status", json={"action": "approve"}).status_code == 200
|
||||
|
||||
|
||||
def test_reopen_request_needs_reason_and_approver(client, imported):
|
||||
viewer = user(client, "view@t.local", "viewer", dept=None)
|
||||
approver = user(client, "ap@t.local", "approver")
|
||||
approve(client, imported)
|
||||
y = viewer.get(f"/api/years/{imported}").json()
|
||||
assert y["actions"]["request_reopen"] and not y["actions"]["reopen"]
|
||||
assert viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": ""}).status_code == 400
|
||||
r = viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Contrato novo de telecomunicações"})
|
||||
assert r.status_code == 200 and r.json()["reopened"] is False
|
||||
assert viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": "again please"}).status_code == 409
|
||||
y = approver.get(f"/api/years/{imported}").json()
|
||||
assert y["status"] == "approved" and y["reopen_request"]["requested_by"] == "view"
|
||||
assert y["actions"]["decide_reopen"] and not viewer.get(f"/api/years/{imported}").json()["actions"]["decide_reopen"]
|
||||
rid = y["reopen_request"]["id"]
|
||||
assert viewer.post(f"/api/reopen-requests/{rid}/decide", json={"accept": True}).status_code == 403
|
||||
assert approver.post(f"/api/reopen-requests/{rid}/decide", json={"accept": False}).status_code == 400 # say why
|
||||
d = approver.post(f"/api/reopen-requests/{rid}/decide", json={"accept": False, "note": "Já aprovado pela direção"})
|
||||
assert d.json()["request"]["status"] == "declined" and d.json()["year"]["status"] == "approved"
|
||||
# a second request, accepted this time
|
||||
rid = viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Falta a linha de licenças"}).json()["request"]["id"]
|
||||
d = approver.post(f"/api/reopen-requests/{rid}/decide", json={"accept": True}).json()
|
||||
assert d["request"]["status"] == "accepted" and d["year"]["status"] == "draft"
|
||||
hist = client.get(f"/api/years/{imported}").json()["reopen_requests"]
|
||||
assert [h["status"] for h in hist] == ["accepted", "declined"]
|
||||
acts = [r["action"] for r in client.get("/api/activity?entity=year").json()["rows"]]
|
||||
assert {"reopen_requested", "reopen_declined", "reopen"} <= set(acts)
|
||||
|
||||
|
||||
def test_approver_reopens_directly_with_reason(client, imported):
|
||||
approver = user(client, "ap@t.local", "approver")
|
||||
approve(client, imported)
|
||||
r = approver.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Correção de valores"}).json()
|
||||
assert r["reopened"] is True and r["year"]["status"] == "draft"
|
||||
|
||||
|
||||
def test_download_and_reupload_archives_old_version(client, imported):
|
||||
# downloads keep the name of the file the budget was loaded from
|
||||
assert 'filename="b.xlsx"' in client.get(f"/api/years/{imported}/workbook.xlsx").headers["content-disposition"]
|
||||
fresh = client.post("/api/years", json={"year": 2031}).json()["id"]
|
||||
assert 'filename="Orcamento_IT_2031.xlsx"' in client.get(f"/api/years/{fresh}/workbook.xlsx?lang=pt").headers["content-disposition"]
|
||||
client.delete(f"/api/years/{fresh}")
|
||||
r = client.get(f"/api/years/{imported}/workbook.xlsx?lang=pt")
|
||||
files = {"file": ("Orcamento_IT_2027.xlsx", r.content)}
|
||||
check = client.post("/api/years/import/check", files=files, data={"department_id": "1"}).json()
|
||||
assert check["year"] == 2027 and check["existing"]["id"] == imported and check["existing"]["can_replace"]
|
||||
assert check["existing"]["archived_title"].startswith("Budget 2027 (")
|
||||
# without confirming, nothing is replaced
|
||||
assert client.post("/api/years/import", files=files, data={"department_id": "1"}).status_code == 409
|
||||
res = client.post("/api/years/import", files=files, data={"department_id": "1", "replace": "true"}).json()
|
||||
new_id = res["year"]["id"]
|
||||
assert new_id != imported and res["year"]["title"] == "Budget 2027" and res["year"]["source_file"] == "Orcamento_IT_2027.xlsx"
|
||||
old = client.get(f"/api/years/{imported}").json()
|
||||
assert old["archived"] and old["title"].startswith("Budget 2027 (") and old["title"] != "Budget 2027"
|
||||
assert not any(v for k, v in old["actions"].items() if k != "delete")
|
||||
line = old["categories"][0]["lines"][0]
|
||||
assert client.put(f"/api/lines/{line['id']}", json={"name": "x"}).status_code in (403, 409)
|
||||
assert client.post(f"/api/years/{imported}/status", json={"action": "submit"}).status_code in (403, 409)
|
||||
assert [y["id"] for y in client.get("/api/years").json()] == [new_id]
|
||||
assert [y["id"] for y in client.get("/api/years?archived=true").json()] == [imported]
|
||||
assert client.get(f"/api/years/{imported}/reports/budget.pdf").status_code == 200 # still viewable
|
||||
assert abs(old["costs"]["adjusted"] - client.get(f"/api/years/{new_id}").json()["costs"]["adjusted"]) < 0.01
|
||||
|
||||
# the same file again: the downloaded name is kept, and the previous upload gets the date in its file name
|
||||
again = client.get(f"/api/years/{new_id}/workbook.xlsx").content
|
||||
check = client.post("/api/years/import/check", files={"file": ("Orcamento_IT_2027.xlsx", again)},
|
||||
data={"department_id": "1"}).json()
|
||||
assert check["existing"]["same_file"] and check["existing"]["archived_file"].startswith("Orcamento_IT_2027_")
|
||||
client.post("/api/years/import", files={"file": ("Orcamento_IT_2027.xlsx", again)},
|
||||
data={"department_id": "1", "replace": "true"})
|
||||
names = [y["source_file"] for y in client.get("/api/years?archived=true").json()]
|
||||
assert any(n.startswith("Orcamento_IT_2027_2") and n.endswith(".xlsx") for n in names)
|
||||
|
||||
|
||||
def test_approved_budget_cannot_be_replaced(client, imported):
|
||||
data = client.get(f"/api/years/{imported}/workbook.xlsx").content
|
||||
approve(client, imported)
|
||||
check = client.post("/api/years/import/check", files={"file": ("b.xlsx", data)}, data={"department_id": "1"}).json()
|
||||
assert check["existing"]["can_replace"] is False
|
||||
r = client.post("/api/years/import", files={"file": ("b.xlsx", data)}, data={"department_id": "1", "replace": "true"})
|
||||
assert r.status_code == 409 and r.json()["detail"] == "year_not_draft"
|
||||
|
||||
|
||||
def test_approver_reopening_answers_pending_request(client, imported):
|
||||
viewer = user(client, "view@t.local", "viewer", dept=None)
|
||||
approve(client, imported)
|
||||
viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Preciso de mudar a linha X"})
|
||||
r = client.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Concordo, reabrir"}).json()
|
||||
assert r["reopened"] and r["request"]["status"] == "accepted" and r["request"]["requested_by"] == "view"
|
||||
assert len(client.get(f"/api/years/{imported}").json()["reopen_requests"]) == 1
|
||||
|
||||
|
||||
def test_reopen_request_emails_the_approvers(client, imported, fake_mail):
|
||||
from tests.test_mail import FakeSMTP, configure
|
||||
configure(client)
|
||||
user(client, "ap@t.local", "approver")
|
||||
viewer = user(client, "view@t.local", "viewer", dept=None)
|
||||
approve(client, imported)
|
||||
viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Licenças em falta"})
|
||||
_, rcpts, msg = FakeSMTP.sent[-1]
|
||||
assert set(rcpts) == {"admin@test.local", "ap@t.local"} and "Licenças em falta" in msg.get_content()
|
||||
rid = client.get(f"/api/years/{imported}").json()["reopen_request"]["id"]
|
||||
client.post(f"/api/reopen-requests/{rid}/decide", json={"accept": False, "note": "Fica para o próximo ano"})
|
||||
_, rcpts, msg = FakeSMTP.sent[-1]
|
||||
assert rcpts == ["view@t.local"] and "Fica para o próximo ano" in msg.get_content()
|
||||
@@ -0,0 +1,42 @@
|
||||
from tests.test_mail import configure, fake_mail # noqa: F401 (fixture)
|
||||
from tests.test_v5 import approve, user
|
||||
|
||||
|
||||
def test_upper_management_views_downloads_and_emails_every_department(client, imported, fake_mail):
|
||||
fb = client.post("/api/departments", json={"name": "F&B", "categories_from": "defaults"}).json()
|
||||
other = client.post("/api/years", json={"department_id": fb["id"], "year": 2027}).json()["id"]
|
||||
boss = user(client, "boss@t.local", "management", dept=None)
|
||||
assert boss.get("/api/auth/me").json()["role"] == "management"
|
||||
years = boss.get("/api/years").json()
|
||||
assert {y["department_id"] for y in years} == {1, fb["id"]}
|
||||
assert [y["id"] for y in boss.get(f"/api/years?department_id={fb['id']}").json()] == [other]
|
||||
for year_id in (imported, other):
|
||||
y = boss.get(f"/api/years/{year_id}").json()
|
||||
assert not any(y["actions"].values()), y["actions"]
|
||||
assert boss.get(f"/api/years/{year_id}/workbook.xlsx").status_code == 200
|
||||
assert boss.get(f"/api/years/{year_id}/reports/cost-report.pdf").status_code == 200
|
||||
configure(client)
|
||||
r = boss.post(f"/api/years/{other}/email", json={"to": ["cfo@example.com"], "reports": [{"kind": "cost-report", "fmt": "pdf"}]})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_upper_management_changes_nothing(client, imported):
|
||||
boss = user(client, "boss@t.local", "management", dept=1) # even with a department
|
||||
line = client.get(f"/api/years/{imported}").json()["categories"][0]["lines"][0]
|
||||
assert boss.put(f"/api/lines/{line['id']}", json={"name": "x"}).status_code == 403
|
||||
assert boss.post("/api/years", json={"year": 2032}).status_code == 403
|
||||
assert boss.post(f"/api/years/{imported}/status", json={"action": "submit"}).status_code == 403
|
||||
assert boss.delete(f"/api/years/{imported}").status_code == 403
|
||||
assert boss.post("/api/categories", json={"department_id": 1, "name_en": "X"}).status_code == 403
|
||||
assert boss.get("/api/users").status_code == 403 and boss.get("/api/activity").status_code == 403
|
||||
# never an approver, even when named in a workflow step
|
||||
me = boss.get("/api/auth/me").json()["id"]
|
||||
client.put("/api/departments/1/workflow", json={"steps": [{"name": "Board", "approver_mode": "users", "approver_ids": [me]}]})
|
||||
y = boss.get(f"/api/years/{imported}").json()
|
||||
assert not y["actions"]["approve"]
|
||||
# and does not ask for reopening: that is for the people who work on budgets
|
||||
client.put("/api/departments/1/workflow", json={"steps": []})
|
||||
approve(client, imported)
|
||||
y = boss.get(f"/api/years/{imported}").json()
|
||||
assert y["status"] == "approved" and not y["actions"]["request_reopen"]
|
||||
assert boss.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Quero rever"}).status_code == 403
|
||||
@@ -0,0 +1,105 @@
|
||||
import time
|
||||
from io import BytesIO
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
from app import mfa
|
||||
from app.main import app
|
||||
from tests.test_v5 import H, user
|
||||
|
||||
|
||||
def login(email, password, code=None):
|
||||
c = TestClient(app)
|
||||
c.headers.update(H)
|
||||
body = {"email": email, "password": password}
|
||||
if code is not None:
|
||||
body["code"] = code
|
||||
return c, c.post("/api/auth/login", json=body)
|
||||
|
||||
|
||||
def now_code(secret, offset=0):
|
||||
return mfa.code_at(secret, int(time.time() // mfa.STEP) + offset)
|
||||
|
||||
|
||||
def test_rfc6238_vectors():
|
||||
import base64
|
||||
s = base64.b32encode(b"12345678901234567890").decode().rstrip("=")
|
||||
assert mfa.code_at(s, 59 // 30) == "287082" and mfa.code_at(s, 1111111109 // 30) == "081804"
|
||||
|
||||
|
||||
def test_mfa_set_up_sign_in_recovery_and_reset(client):
|
||||
ana = user(client, "ana@t.local", "editor")
|
||||
setup = ana.post("/api/auth/mfa/setup").json()
|
||||
assert setup["uri"].startswith("otpauth://totp/") and "<svg" in setup["qr_svg"]
|
||||
secret = setup["secret"]
|
||||
# not on until a code is confirmed
|
||||
assert ana.get("/api/auth/me").json()["mfa_enabled"] is False
|
||||
assert ana.post("/api/auth/mfa/enable", json={"code": "000000" if now_code(secret) != "000000" else "111111"}).status_code == 400
|
||||
r = ana.post("/api/auth/mfa/enable", json={"code": now_code(secret)}).json()
|
||||
codes = r["recovery_codes"]
|
||||
assert r["user"]["mfa_enabled"] and len(codes) == 8 and r["user"]["mfa_recovery_left"] == 8
|
||||
listed = {u["email"]: u["mfa_enabled"] for u in client.get("/api/users").json()}
|
||||
assert listed == {"admin@test.local": False, "ana@t.local": True}
|
||||
|
||||
# sign-in now asks for the code; no session without it
|
||||
c, res = login("ana@t.local", "password-123")
|
||||
assert res.status_code == 200 and res.json() == {"mfa_required": True}
|
||||
assert c.get("/api/auth/me").status_code == 401
|
||||
_, res = login("ana@t.local", "password-123", "123456" if now_code(secret, 1) != "123456" else "654321")
|
||||
assert res.status_code == 401 and res.json()["detail"] == "invalid_mfa_code"
|
||||
# the code from the enable step was used already; the next one works once
|
||||
c, res = login("ana@t.local", "password-123", now_code(secret, 1))
|
||||
assert res.status_code == 200 and res.json()["email"] == "ana@t.local" and c.get("/api/auth/me").status_code == 200
|
||||
_, res = login("ana@t.local", "password-123", now_code(secret, 1))
|
||||
assert res.status_code == 401 # replay refused
|
||||
# a recovery code works once
|
||||
c, res = login("ana@t.local", "password-123", codes[0].upper())
|
||||
assert res.status_code == 200 and res.json()["mfa_recovery_left"] == 7
|
||||
assert login("ana@t.local", "password-123", codes[0])[1].status_code == 401
|
||||
|
||||
# an administrator resets it (lost phone)
|
||||
me = c.get("/api/auth/me").json()["id"]
|
||||
assert ana.post(f"/api/users/{me}/mfa/reset").status_code == 403
|
||||
assert client.post(f"/api/users/{me}/mfa/reset").json()["mfa_enabled"] is False
|
||||
assert login("ana@t.local", "password-123")[1].json()["email"] == "ana@t.local"
|
||||
acts = {r["action"] for r in client.get("/api/activity?entity=user").json()["rows"]}
|
||||
assert {"mfa_enabled", "mfa_failed", "mfa_recovery_used", "mfa_reset"} <= acts
|
||||
|
||||
|
||||
def test_mfa_locks_after_wrong_codes_and_turns_off_with_password_and_code(client):
|
||||
ana = user(client, "ana@t.local", "viewer")
|
||||
secret = ana.post("/api/auth/mfa/setup").json()["secret"]
|
||||
ana.post("/api/auth/mfa/enable", json={"code": now_code(secret)})
|
||||
for _ in range(mfa.MAX_FAILED - 1):
|
||||
assert login("ana@t.local", "password-123", "999999x")[1].status_code == 401
|
||||
assert login("ana@t.local", "password-123", "999999x")[1].status_code == 429
|
||||
assert login("ana@t.local", "password-123", now_code(secret, 1))[1].status_code == 429 # locked for a while
|
||||
assert ana.post("/api/auth/mfa/disable", json={"password": "wrong", "code": ""}).status_code == 400
|
||||
from app.db import SessionLocal
|
||||
from app.models import User
|
||||
with SessionLocal() as db:
|
||||
u = db.query(User).filter_by(email="ana@t.local").one()
|
||||
u.mfa_locked_until = None
|
||||
db.commit()
|
||||
assert ana.post("/api/auth/mfa/disable", json={"password": "password-123", "code": now_code(secret, 1)}).json()["mfa_enabled"] is False
|
||||
|
||||
|
||||
def test_avatar_upload_resize_and_remove(client):
|
||||
ana = user(client, "ana@t.local", "viewer")
|
||||
buf = BytesIO()
|
||||
Image.new("RGB", (900, 600), (200, 30, 30)).save(buf, "PNG")
|
||||
assert ana.put("/api/auth/me/avatar", files={"file": ("x.txt", b"not a picture")}).status_code == 400
|
||||
me = ana.put("/api/auth/me/avatar", files={"file": ("me.png", buf.getvalue(), "image/png")}).json()
|
||||
assert me["avatar_url"].startswith(f"/api/users/{me['id']}/avatar?v=")
|
||||
pic = client.get(me["avatar_url"]) # other users see it in lists
|
||||
assert pic.status_code == 200 and pic.headers["content-type"] == "image/jpeg"
|
||||
assert Image.open(BytesIO(pic.content)).size == (256, 256)
|
||||
assert ana.delete("/api/auth/me/avatar").json()["avatar_url"] is None
|
||||
assert client.get(f"/api/users/{me['id']}/avatar").status_code == 404
|
||||
|
||||
|
||||
def test_theme_is_saved(client):
|
||||
assert client.get("/api/auth/me").json()["theme"] == "system"
|
||||
assert client.put("/api/auth/me", json={"theme": "dark"}).json()["theme"] == "dark"
|
||||
assert client.put("/api/auth/me", json={"theme": "pink"}).json()["theme"] == "dark"
|
||||
Reference in New Issue
Block a user