- Repo-Struktur für Backend, Frontend, Dokumentation und Gitea-Workflows - FastAPI-Skeleton mit pydantic-settings und RFC-7807-artigem Fehlerformat - Vollständiges Datenmodell (15 Tabellen) als SQLAlchemy-2.0-Modelle - Alembic gegen die Async-Engine inklusive Erstmigration - Idempotenter Seed für den deutschen Kategoriebaum und Standardregeln - Endpunkte /api/health und /api/version - pytest-Infrastruktur mit transaktionsisolierten Fixtures Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
291 lines
18 KiB
Python
291 lines
18 KiB
Python
"""initial schema
|
|
|
|
Revision ID: 2bc4ab55dbe9
|
|
Revises:
|
|
Create Date: 2026-09-09 13:07:31.239925+02:00
|
|
"""
|
|
|
|
from collections.abc import Sequence
|
|
|
|
import sqlalchemy as sa
|
|
from alembic import op
|
|
from sqlalchemy.dialects import postgresql
|
|
revision: str = '2bc4ab55dbe9'
|
|
down_revision: str | None = None
|
|
branch_labels: str | Sequence[str] | None = None
|
|
depends_on: str | Sequence[str] | None = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
# ### commands auto generated by Alembic - please adjust! ###
|
|
op.create_table('account',
|
|
sa.Column('id', sa.Integer(), nullable=False),
|
|
sa.Column('name', sa.String(length=120), nullable=False),
|
|
sa.Column('type', sa.Enum('checking', 'credit_card', 'savings', 'cash', name='account_type'), nullable=False),
|
|
sa.Column('iban_last4', sa.String(length=4), nullable=True),
|
|
sa.Column('opening_balance', sa.Numeric(precision=12, scale=2), nullable=False),
|
|
sa.Column('opening_balance_date', sa.Date(), nullable=False),
|
|
sa.Column('color', sa.String(length=9), nullable=False),
|
|
sa.Column('icon', sa.String(length=64), nullable=False),
|
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
|
sa.Column('sort_order', sa.Integer(), nullable=False),
|
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_account')),
|
|
sa.UniqueConstraint('name', name='uq_account_name')
|
|
)
|
|
op.create_index('ix_account_is_active', 'account', ['is_active'], unique=False)
|
|
op.create_table('app_user',
|
|
sa.Column('id', sa.Integer(), nullable=False),
|
|
sa.Column('username', sa.String(length=120), nullable=False),
|
|
sa.Column('email', sa.String(length=255), nullable=True),
|
|
sa.Column('password_hash', sa.Text(), nullable=True),
|
|
sa.Column('must_change_password', sa.Boolean(), nullable=False),
|
|
sa.Column('external_subject', sa.String(length=255), nullable=True),
|
|
sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True),
|
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
|
sa.CheckConstraint('password_hash IS NOT NULL OR external_subject IS NOT NULL', name=op.f('ck_app_user_local_or_external_login')),
|
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_app_user')),
|
|
sa.UniqueConstraint('external_subject', name=op.f('uq_app_user_external_subject')),
|
|
sa.UniqueConstraint('username', name=op.f('uq_app_user_username'))
|
|
)
|
|
op.create_table('category',
|
|
sa.Column('id', sa.Integer(), nullable=False),
|
|
sa.Column('parent_id', sa.Integer(), nullable=True),
|
|
sa.Column('name', sa.String(length=120), nullable=False),
|
|
sa.Column('kind', sa.Enum('expense', 'income', name='entry_kind'), nullable=False),
|
|
sa.Column('color', sa.String(length=9), nullable=False),
|
|
sa.Column('icon', sa.String(length=64), nullable=False),
|
|
sa.Column('is_fixed_cost', sa.Boolean(), nullable=False),
|
|
sa.Column('sort_order', sa.Integer(), nullable=False),
|
|
sa.Column('is_archived', sa.Boolean(), nullable=False),
|
|
sa.ForeignKeyConstraint(['parent_id'], ['category.id'], name=op.f('fk_category_parent_id_category'), ondelete='RESTRICT'),
|
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_category')),
|
|
sa.UniqueConstraint('parent_id', 'name', name='uq_category_parent_id')
|
|
)
|
|
op.create_index('ix_category_kind', 'category', ['kind'], unique=False)
|
|
op.create_table('logo_asset',
|
|
sa.Column('id', sa.Integer(), nullable=False),
|
|
sa.Column('sha256', sa.String(length=64), nullable=False),
|
|
sa.Column('mime', sa.String(length=64), nullable=False),
|
|
sa.Column('width', sa.Integer(), nullable=True),
|
|
sa.Column('height', sa.Integer(), nullable=True),
|
|
sa.Column('file_path', sa.String(length=255), nullable=False),
|
|
sa.Column('source_url', sa.Text(), nullable=True),
|
|
sa.Column('fetched_at', sa.DateTime(timezone=True), nullable=False),
|
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_logo_asset')),
|
|
sa.UniqueConstraint('sha256', name=op.f('uq_logo_asset_sha256'))
|
|
)
|
|
op.create_table('notification_rule',
|
|
sa.Column('id', sa.Integer(), nullable=False),
|
|
sa.Column('type', sa.Enum('due_soon', 'notice_deadline', 'budget_exceeded', 'contract_renewal', name='notification_type'), nullable=False),
|
|
sa.Column('lead_days', sa.Integer(), nullable=False),
|
|
sa.Column('channel', sa.Enum('smtp', 'apprise', name='notification_channel'), nullable=False),
|
|
sa.Column('target', sa.Text(), nullable=True),
|
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_notification_rule'))
|
|
)
|
|
op.create_table('budget',
|
|
sa.Column('id', sa.Integer(), nullable=False),
|
|
sa.Column('category_id', sa.Integer(), nullable=False),
|
|
sa.Column('period_month', sa.Date(), nullable=False),
|
|
sa.Column('limit_amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
|
sa.Column('rollover', sa.Boolean(), nullable=False),
|
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
|
sa.CheckConstraint("date_trunc('month', period_month) = period_month", name=op.f('ck_budget_month_start')),
|
|
sa.ForeignKeyConstraint(['category_id'], ['category.id'], name=op.f('fk_budget_category_id_category'), ondelete='CASCADE'),
|
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_budget')),
|
|
sa.UniqueConstraint('category_id', 'period_month', name='uq_budget_category_id')
|
|
)
|
|
op.create_table('budget_template',
|
|
sa.Column('id', sa.Integer(), nullable=False),
|
|
sa.Column('category_id', sa.Integer(), nullable=False),
|
|
sa.Column('valid_from', sa.Date(), nullable=False),
|
|
sa.Column('valid_until', sa.Date(), nullable=True),
|
|
sa.Column('limit_amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
|
sa.Column('rollover', sa.Boolean(), nullable=False),
|
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
|
sa.CheckConstraint("date_trunc('month', valid_from) = valid_from", name=op.f('ck_budget_template_month_start')),
|
|
sa.ForeignKeyConstraint(['category_id'], ['category.id'], name=op.f('fk_budget_template_category_id_category'), ondelete='CASCADE'),
|
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_budget_template')),
|
|
sa.UniqueConstraint('category_id', 'valid_from', name='uq_budget_template_category_id')
|
|
)
|
|
op.create_table('merchant',
|
|
sa.Column('id', sa.Integer(), nullable=False),
|
|
sa.Column('name', sa.String(length=160), nullable=False),
|
|
sa.Column('normalized_name', sa.String(length=160), nullable=False),
|
|
sa.Column('domain', sa.String(length=255), nullable=True),
|
|
sa.Column('aliases', postgresql.ARRAY(sa.Text()), server_default='{}', nullable=False),
|
|
sa.Column('logo_asset_id', sa.Integer(), nullable=True),
|
|
sa.Column('brand_color', sa.String(length=9), nullable=True),
|
|
sa.Column('brand_color_dark', sa.String(length=9), nullable=True),
|
|
sa.Column('logo_source', sa.Enum('simple-icons', 'logodev', 'brandfetch', 'favicon', 'upload', 'generated', name='logo_source'), nullable=True),
|
|
sa.Column('logo_status', sa.Enum('pending', 'resolved', 'failed', 'manual', name='logo_status'), nullable=False),
|
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
|
sa.ForeignKeyConstraint(['logo_asset_id'], ['logo_asset.id'], name=op.f('fk_merchant_logo_asset_id_logo_asset'), ondelete='SET NULL'),
|
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_merchant')),
|
|
sa.UniqueConstraint('normalized_name', name=op.f('uq_merchant_normalized_name'))
|
|
)
|
|
op.create_index('ix_merchant_normalized_name', 'merchant', ['normalized_name'], unique=False)
|
|
op.create_table('notification_log',
|
|
sa.Column('id', sa.Integer(), nullable=False),
|
|
sa.Column('rule_id', sa.Integer(), nullable=False),
|
|
sa.Column('ref_type', sa.String(length=40), nullable=False),
|
|
sa.Column('ref_id', sa.String(length=80), nullable=False),
|
|
sa.Column('dedupe_day', sa.Date(), nullable=False),
|
|
sa.Column('sent_at', sa.DateTime(timezone=True), nullable=False),
|
|
sa.Column('status', sa.Enum('sent', 'failed', name='notification_status'), nullable=False),
|
|
sa.Column('error', sa.Text(), nullable=True),
|
|
sa.ForeignKeyConstraint(['rule_id'], ['notification_rule.id'], name=op.f('fk_notification_log_rule_id_notification_rule'), ondelete='CASCADE'),
|
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_notification_log')),
|
|
sa.UniqueConstraint('rule_id', 'ref_type', 'ref_id', 'dedupe_day', name='uq_notification_log_rule_id')
|
|
)
|
|
op.create_index('ix_notification_log_sent_at', 'notification_log', ['sent_at'], unique=False)
|
|
op.create_table('savings_goal',
|
|
sa.Column('id', sa.Integer(), nullable=False),
|
|
sa.Column('name', sa.String(length=160), nullable=False),
|
|
sa.Column('target_amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
|
sa.Column('target_date', sa.Date(), nullable=True),
|
|
sa.Column('current_amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
|
sa.Column('account_id', sa.Integer(), nullable=True),
|
|
sa.Column('monthly_contribution', sa.Numeric(precision=12, scale=2), nullable=True),
|
|
sa.Column('color', sa.String(length=9), nullable=False),
|
|
sa.Column('icon', sa.String(length=64), nullable=False),
|
|
sa.Column('is_archived', sa.Boolean(), nullable=False),
|
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
|
sa.ForeignKeyConstraint(['account_id'], ['account.id'], name=op.f('fk_savings_goal_account_id_account'), ondelete='SET NULL'),
|
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_savings_goal')),
|
|
sa.UniqueConstraint('name', name='uq_savings_goal_name')
|
|
)
|
|
op.create_table('recurrence',
|
|
sa.Column('id', sa.Integer(), nullable=False),
|
|
sa.Column('kind', sa.Enum('expense', 'income', name='entry_kind'), nullable=False),
|
|
sa.Column('title', sa.String(length=160), nullable=False),
|
|
sa.Column('merchant_id', sa.Integer(), nullable=True),
|
|
sa.Column('category_id', sa.Integer(), nullable=False),
|
|
sa.Column('account_id', sa.Integer(), nullable=False),
|
|
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
|
sa.Column('is_variable', sa.Boolean(), nullable=False),
|
|
sa.Column('currency', sa.String(length=3), nullable=False),
|
|
sa.Column('rrule', sa.Text(), nullable=False),
|
|
sa.Column('dtstart', sa.Date(), nullable=False),
|
|
sa.Column('until', sa.Date(), nullable=True),
|
|
sa.Column('business_day_shift', sa.Enum('none', 'next', 'previous', name='business_day_shift'), server_default='next', nullable=False),
|
|
sa.Column('holiday_region', sa.String(length=8), server_default='DE-NW', nullable=False),
|
|
sa.Column('installments_total', sa.Integer(), nullable=True),
|
|
sa.Column('principal_amount', sa.Numeric(precision=12, scale=2), nullable=True),
|
|
sa.Column('contract_start', sa.Date(), nullable=True),
|
|
sa.Column('contract_min_term_months', sa.Integer(), nullable=True),
|
|
sa.Column('contract_notice_period_days', sa.Integer(), nullable=True),
|
|
sa.Column('contract_auto_renew_months', sa.Integer(), nullable=True),
|
|
sa.Column('contract_cancelled_at', sa.Date(), nullable=True),
|
|
sa.Column('reserve_enabled', sa.Boolean(), nullable=False),
|
|
sa.Column('notes', sa.Text(), nullable=True),
|
|
sa.Column('tags', postgresql.ARRAY(sa.Text()), server_default='{}', nullable=False),
|
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
|
sa.CheckConstraint('installments_total IS NULL OR installments_total > 0', name=op.f('ck_recurrence_installments')),
|
|
sa.ForeignKeyConstraint(['account_id'], ['account.id'], name=op.f('fk_recurrence_account_id_account'), ondelete='RESTRICT'),
|
|
sa.ForeignKeyConstraint(['category_id'], ['category.id'], name=op.f('fk_recurrence_category_id_category'), ondelete='RESTRICT'),
|
|
sa.ForeignKeyConstraint(['merchant_id'], ['merchant.id'], name=op.f('fk_recurrence_merchant_id_merchant'), ondelete='SET NULL'),
|
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_recurrence'))
|
|
)
|
|
op.create_index('ix_recurrence_category_id', 'recurrence', ['category_id'], unique=False)
|
|
op.create_index('ix_recurrence_kind_active', 'recurrence', ['kind', 'is_active'], unique=False)
|
|
op.create_index('ix_recurrence_merchant_id', 'recurrence', ['merchant_id'], unique=False)
|
|
op.create_table('transaction',
|
|
sa.Column('id', sa.Integer(), nullable=False),
|
|
sa.Column('kind', sa.Enum('expense', 'income', name='entry_kind'), nullable=False),
|
|
sa.Column('title', sa.String(length=160), nullable=False),
|
|
sa.Column('merchant_id', sa.Integer(), nullable=True),
|
|
sa.Column('category_id', sa.Integer(), nullable=False),
|
|
sa.Column('account_id', sa.Integer(), nullable=False),
|
|
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
|
sa.Column('booking_date', sa.Date(), nullable=False),
|
|
sa.Column('note', sa.Text(), nullable=True),
|
|
sa.Column('tags', postgresql.ARRAY(sa.Text()), server_default='{}', nullable=False),
|
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
|
sa.ForeignKeyConstraint(['account_id'], ['account.id'], name=op.f('fk_transaction_account_id_account'), ondelete='RESTRICT'),
|
|
sa.ForeignKeyConstraint(['category_id'], ['category.id'], name=op.f('fk_transaction_category_id_category'), ondelete='RESTRICT'),
|
|
sa.ForeignKeyConstraint(['merchant_id'], ['merchant.id'], name=op.f('fk_transaction_merchant_id_merchant'), ondelete='SET NULL'),
|
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_transaction'))
|
|
)
|
|
op.create_index('ix_transaction_booking_date', 'transaction', ['booking_date'], unique=False)
|
|
op.create_index('ix_transaction_category_id', 'transaction', ['category_id'], unique=False)
|
|
op.create_table('amount_version',
|
|
sa.Column('id', sa.Integer(), nullable=False),
|
|
sa.Column('recurrence_id', sa.Integer(), nullable=False),
|
|
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
|
sa.Column('valid_from', sa.Date(), nullable=False),
|
|
sa.Column('note', sa.Text(), nullable=True),
|
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
|
sa.ForeignKeyConstraint(['recurrence_id'], ['recurrence.id'], name=op.f('fk_amount_version_recurrence_id_recurrence'), ondelete='CASCADE'),
|
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_amount_version')),
|
|
sa.UniqueConstraint('recurrence_id', 'valid_from', name='uq_amount_version_recurrence_id')
|
|
)
|
|
op.create_table('occurrence',
|
|
sa.Column('id', sa.Integer(), nullable=False),
|
|
sa.Column('recurrence_id', sa.Integer(), nullable=False),
|
|
sa.Column('occurrence_date', sa.Date(), nullable=False),
|
|
sa.Column('status', sa.Enum('planned', 'confirmed', 'skipped', name='occurrence_status'), nullable=False),
|
|
sa.Column('planned_amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
|
sa.Column('actual_amount', sa.Numeric(precision=12, scale=2), nullable=True),
|
|
sa.Column('actual_date', sa.Date(), nullable=True),
|
|
sa.Column('account_id', sa.Integer(), nullable=True),
|
|
sa.Column('note', sa.Text(), nullable=True),
|
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
|
sa.ForeignKeyConstraint(['account_id'], ['account.id'], name=op.f('fk_occurrence_account_id_account'), ondelete='RESTRICT'),
|
|
sa.ForeignKeyConstraint(['recurrence_id'], ['recurrence.id'], name=op.f('fk_occurrence_recurrence_id_recurrence'), ondelete='CASCADE'),
|
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_occurrence')),
|
|
sa.UniqueConstraint('recurrence_id', 'occurrence_date', name='uq_occurrence_recurrence_id')
|
|
)
|
|
op.create_index('ix_occurrence_date', 'occurrence', ['occurrence_date'], unique=False)
|
|
op.create_table('reserve_ledger',
|
|
sa.Column('id', sa.Integer(), nullable=False),
|
|
sa.Column('recurrence_id', sa.Integer(), nullable=False),
|
|
sa.Column('period_month', sa.Date(), nullable=False),
|
|
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
|
sa.Column('is_released', sa.Boolean(), nullable=False),
|
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
|
sa.CheckConstraint("date_trunc('month', period_month) = period_month", name=op.f('ck_reserve_ledger_month_start')),
|
|
sa.ForeignKeyConstraint(['recurrence_id'], ['recurrence.id'], name=op.f('fk_reserve_ledger_recurrence_id_recurrence'), ondelete='CASCADE'),
|
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_reserve_ledger')),
|
|
sa.UniqueConstraint('recurrence_id', 'period_month', name='uq_reserve_ledger_recurrence_id')
|
|
)
|
|
# ### end Alembic commands ###
|
|
|
|
|
|
def downgrade() -> None:
|
|
# ### commands auto generated by Alembic - please adjust! ###
|
|
op.drop_table('reserve_ledger')
|
|
op.drop_index('ix_occurrence_date', table_name='occurrence')
|
|
op.drop_table('occurrence')
|
|
op.drop_table('amount_version')
|
|
op.drop_index('ix_transaction_category_id', table_name='transaction')
|
|
op.drop_index('ix_transaction_booking_date', table_name='transaction')
|
|
op.drop_table('transaction')
|
|
op.drop_index('ix_recurrence_merchant_id', table_name='recurrence')
|
|
op.drop_index('ix_recurrence_kind_active', table_name='recurrence')
|
|
op.drop_index('ix_recurrence_category_id', table_name='recurrence')
|
|
op.drop_table('recurrence')
|
|
op.drop_table('savings_goal')
|
|
op.drop_index('ix_notification_log_sent_at', table_name='notification_log')
|
|
op.drop_table('notification_log')
|
|
op.drop_index('ix_merchant_normalized_name', table_name='merchant')
|
|
op.drop_table('merchant')
|
|
op.drop_table('budget_template')
|
|
op.drop_table('budget')
|
|
op.drop_table('notification_rule')
|
|
op.drop_table('logo_asset')
|
|
op.drop_index('ix_category_kind', table_name='category')
|
|
op.drop_table('category')
|
|
op.drop_table('app_user')
|
|
op.drop_index('ix_account_is_active', table_name='account')
|
|
op.drop_table('account')
|
|
# ### end Alembic commands ###
|