Files
Budget_Tool_Martinhal/backend/app/models.py
T

257 lines
14 KiB
Python
Raw Normal View History

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