diff --git a/alembic/versions/03ebe3c07262_add_month_snapshot_tables.py b/alembic/versions/03ebe3c07262_add_month_snapshot_tables.py new file mode 100644 index 0000000..d2c226f --- /dev/null +++ b/alembic/versions/03ebe3c07262_add_month_snapshot_tables.py @@ -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 ### diff --git a/src/quartermaster/models.py b/src/quartermaster/models.py index 6e155f2..8fb87c1 100644 --- a/src/quartermaster/models.py +++ b/src/quartermaster/models.py @@ -11,6 +11,7 @@ from sqlalchemy import ( ForeignKey, Numeric, String, + UniqueConstraint, func, ) from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship @@ -75,3 +76,82 @@ class DebtTarget(Base): ) 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")