Unzip budget-app v7 into repo root
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user