feat(db): add Month, MonthEntry, and MonthDebtTarget models with migration
A month is a snapshot of the budget. MonthEntry holds the copied planned amount plus applied and origin_name/origin_planned so the UI can mark edited rows. source_entry_id links back to the budget but is nullable with ON DELETE SET NULL, so deleting a budget row after snapshot leaves the month intact. MonthDebtTarget is one row per month via CASCADE from month. Refs #3 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7d205d2853
commit
b63daa958b
2 changed files with 153 additions and 0 deletions
73
alembic/versions/03ebe3c07262_add_month_snapshot_tables.py
Normal file
73
alembic/versions/03ebe3c07262_add_month_snapshot_tables.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
"""add month snapshot tables
|
||||||
|
|
||||||
|
Revision ID: 03ebe3c07262
|
||||||
|
Revises: f1ccdc4bc1bf
|
||||||
|
Create Date: 2026-04-17 11:33:45.853510
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '03ebe3c07262'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'f1ccdc4bc1bf'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('month',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('year_month', sa.String(length=7), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('year_month')
|
||||||
|
)
|
||||||
|
op.create_table('month_entry',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('month_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('section', sa.Enum('income', 'fixed_bill', 'debt_minimum', 'food', 'subscription', 'other', name='section', native_enum=False, length=32), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('planned', sa.Numeric(precision=10, scale=2), nullable=False),
|
||||||
|
sa.Column('applied', sa.Numeric(precision=10, scale=2), server_default='0.00', nullable=False),
|
||||||
|
sa.Column('origin_name', sa.String(length=128), nullable=True),
|
||||||
|
sa.Column('origin_planned', sa.Numeric(precision=10, scale=2), nullable=True),
|
||||||
|
sa.Column('source_entry_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['month_id'], ['month.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['source_entry_id'], ['entry.id'], ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('month_id', 'id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('month_entry', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_month_entry_month_id'), ['month_id'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_month_entry_section'), ['section'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('month_debt_target',
|
||||||
|
sa.Column('month_id', sa.Integer(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('month_entry_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['month_entry_id'], ['month_entry.id'], ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['month_id'], ['month.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('month_id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('month_debt_target')
|
||||||
|
with op.batch_alter_table('month_entry', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_month_entry_section'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_month_entry_month_id'))
|
||||||
|
|
||||||
|
op.drop_table('month_entry')
|
||||||
|
op.drop_table('month')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
@ -11,6 +11,7 @@ from sqlalchemy import (
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Numeric,
|
Numeric,
|
||||||
String,
|
String,
|
||||||
|
UniqueConstraint,
|
||||||
func,
|
func,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
@ -75,3 +76,82 @@ class DebtTarget(Base):
|
||||||
)
|
)
|
||||||
|
|
||||||
entry: Mapped[Entry | None] = relationship(Entry, lazy="joined")
|
entry: Mapped[Entry | None] = relationship(Entry, lazy="joined")
|
||||||
|
|
||||||
|
|
||||||
|
class Month(Base):
|
||||||
|
__tablename__ = "month"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
year_month: Mapped[str] = mapped_column(String(7), nullable=False, unique=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
entries: Mapped[list["MonthEntry"]] = relationship(
|
||||||
|
back_populates="month", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
target: Mapped["MonthDebtTarget | None"] = relationship(
|
||||||
|
back_populates="month",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
uselist=False,
|
||||||
|
lazy="joined",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MonthEntry(Base):
|
||||||
|
__tablename__ = "month_entry"
|
||||||
|
__table_args__ = (UniqueConstraint("month_id", "id"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
month_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("month.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
section: Mapped[Section] = mapped_column(
|
||||||
|
Enum(Section, native_enum=False, length=32), nullable=False, index=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
planned: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
|
||||||
|
applied: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(10, 2), nullable=False, default=Decimal("0.00"),
|
||||||
|
server_default="0.00",
|
||||||
|
)
|
||||||
|
origin_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
origin_planned: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(10, 2), nullable=True
|
||||||
|
)
|
||||||
|
source_entry_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("entry.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
month: Mapped[Month] = relationship(back_populates="entries")
|
||||||
|
|
||||||
|
|
||||||
|
class MonthDebtTarget(Base):
|
||||||
|
__tablename__ = "month_debt_target"
|
||||||
|
|
||||||
|
month_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("month.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
autoincrement=False,
|
||||||
|
)
|
||||||
|
month_entry_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("month_entry.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
month: Mapped[Month] = relationship(back_populates="target")
|
||||||
|
entry: Mapped[MonthEntry | None] = relationship(MonthEntry, lazy="joined")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue