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:
archeious 2026-04-17 11:39:50 -06:00
parent 6986081ee4
commit 58faa9dfe9
2 changed files with 153 additions and 0 deletions

View 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 ###

View file

@ -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")