Flatten budget-app contents into repo root

This commit is contained in:
jpmvaz
2026-10-29 20:41:01 +00:00
parent c9fd26f1c7
commit 93f2696764
95 changed files with 184 additions and 187 deletions
+19
View File
@@ -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()
+22
View File
@@ -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"}
+121
View File
@@ -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.")
+69
View File
@@ -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)