Compare commits
5 commits
b2d16120d2
...
2d7ce333ea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d7ce333ea | ||
|
|
9da7205f77 | ||
|
|
647bf257f4 | ||
|
|
c6d4d65fe6 | ||
|
|
b63daa958b |
16 changed files with 1262 additions and 25 deletions
54
README.md
54
README.md
|
|
@ -2,19 +2,22 @@
|
||||||
|
|
||||||
Household budget tracker. FastAPI + HTMX frontend, SQLite backend.
|
Household budget tracker. FastAPI + HTMX frontend, SQLite backend.
|
||||||
|
|
||||||
## Sections
|
## Pages
|
||||||
|
|
||||||
The budget page shows one card per section. Every section accepts a name and
|
* `/` budget configuration. One section per category (Incomes, Fixed Amount
|
||||||
amount per entry and displays a running total, except *Primary Debt Target*,
|
Bills, Debt Minimums, Primary Debt Target, Food and Essentials,
|
||||||
which is a pointer to one of the *Debt Minimums* rows.
|
Subscriptions, Other). Every section accepts name + amount entries and
|
||||||
|
shows a running total. The Primary Debt Target is a pointer to a Debt
|
||||||
|
Minimums row.
|
||||||
|
* `/month/YYYY-MM` monthly view. Snapshots the budget at creation time and
|
||||||
|
tracks an `applied` amount per entry alongside the planned amount. Each
|
||||||
|
row is annotated when its name or planned value has been edited away
|
||||||
|
from the snapshot, or when the row was added after creation. Per-month
|
||||||
|
debt target is independent of the budget's target after snapshot.
|
||||||
|
|
||||||
* Incomes
|
Navigate between months with the prev / next buttons or the dropdown
|
||||||
* Fixed Amount Bills
|
picker. A "This month" link on `/` jumps to the current `YYYY-MM`; if it
|
||||||
* Debt Minimums
|
has not been created yet, you land on the create flow.
|
||||||
* Primary Debt Target (pointer)
|
|
||||||
* Food and Essentials
|
|
||||||
* Subscriptions
|
|
||||||
* Other
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|
@ -51,19 +54,22 @@ Tests run against an in-memory SQLite database; no migration step needed.
|
||||||
|
|
||||||
```
|
```
|
||||||
src/quartermaster/
|
src/quartermaster/
|
||||||
main.py FastAPI app factory
|
main.py FastAPI app factory
|
||||||
routes.py HTTP handlers, HTMX partials
|
routes.py Budget configuration HTTP handlers
|
||||||
service.py Queries, totals, target logic
|
routes_month.py Monthly view HTTP handlers
|
||||||
models.py SQLAlchemy models and Section enum
|
service.py Budget queries, totals, target logic
|
||||||
db.py Engine, session, PRAGMA foreign_keys=ON
|
month_service.py Snapshot, deviation, per-month CRUD
|
||||||
config.py DB URL resolution
|
models.py SQLAlchemy models and Section enum
|
||||||
templates/ Jinja2 templates (base, index, partials)
|
db.py Engine, session, PRAGMA foreign_keys=ON
|
||||||
static/ CSS
|
config.py DB URL resolution
|
||||||
alembic/ Migrations
|
templates/ Jinja2 templates (base, index, month, partials)
|
||||||
tests/ pytest suite
|
static/ CSS
|
||||||
|
alembic/ Migrations
|
||||||
|
tests/ pytest suite
|
||||||
```
|
```
|
||||||
|
|
||||||
## Scope
|
## Deferred
|
||||||
|
|
||||||
Single-month budget. Multi-month support is planned for a later milestone
|
A transaction log that rolls up into `applied` on a per-entry per-month
|
||||||
and is intentionally not modelled yet.
|
basis is deferred. Once implemented it may replace the hand-edited applied
|
||||||
|
field.
|
||||||
|
|
|
||||||
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 ###
|
||||||
|
|
@ -6,6 +6,7 @@ from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from quartermaster.routes import router
|
from quartermaster.routes import router
|
||||||
|
from quartermaster.routes_month import router as month_router
|
||||||
|
|
||||||
STATIC_DIR = Path(__file__).parent / "static"
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
|
|
||||||
|
|
@ -14,6 +15,7 @@ def create_app() -> FastAPI:
|
||||||
app = FastAPI(title="Quartermaster", version="0.1.0")
|
app = FastAPI(title="Quartermaster", version="0.1.0")
|
||||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||||
app.include_router(router)
|
app.include_router(router)
|
||||||
|
app.include_router(month_router)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
228
src/quartermaster/month_service.py
Normal file
228
src/quartermaster/month_service.py
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from quartermaster.models import (
|
||||||
|
DebtTarget,
|
||||||
|
Entry,
|
||||||
|
Month,
|
||||||
|
MonthDebtTarget,
|
||||||
|
MonthEntry,
|
||||||
|
Section,
|
||||||
|
)
|
||||||
|
|
||||||
|
YEAR_MONTH_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
|
||||||
|
|
||||||
|
|
||||||
|
class DeviationState(str, Enum):
|
||||||
|
unchanged = "unchanged"
|
||||||
|
edited = "edited"
|
||||||
|
new_in_month = "new_in_month"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MonthRow:
|
||||||
|
entry: MonthEntry
|
||||||
|
state: DeviationState
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MonthSectionView:
|
||||||
|
section: Section
|
||||||
|
label: str
|
||||||
|
rows: list[MonthRow]
|
||||||
|
total_planned: Decimal
|
||||||
|
total_applied: Decimal
|
||||||
|
|
||||||
|
|
||||||
|
def valid_year_month(year_month: str) -> bool:
|
||||||
|
return bool(YEAR_MONTH_RE.match(year_month))
|
||||||
|
|
||||||
|
|
||||||
|
def current_year_month() -> str:
|
||||||
|
today = date.today()
|
||||||
|
return f"{today.year:04d}-{today.month:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def shift_year_month(year_month: str, delta: int) -> str:
|
||||||
|
year, month = (int(part) for part in year_month.split("-"))
|
||||||
|
index = (year * 12 + (month - 1)) + delta
|
||||||
|
new_year, new_month0 = divmod(index, 12)
|
||||||
|
return f"{new_year:04d}-{new_month0 + 1:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_month(db: Session, year_month: str) -> Month | None:
|
||||||
|
stmt = select(Month).where(Month.year_month == year_month)
|
||||||
|
return db.scalar(stmt)
|
||||||
|
|
||||||
|
|
||||||
|
def list_months(db: Session) -> list[Month]:
|
||||||
|
stmt = select(Month).order_by(Month.year_month)
|
||||||
|
return list(db.scalars(stmt))
|
||||||
|
|
||||||
|
|
||||||
|
def create_month(db: Session, year_month: str) -> Month:
|
||||||
|
if not valid_year_month(year_month):
|
||||||
|
raise ValueError("year_month must be formatted as YYYY-MM")
|
||||||
|
existing = get_month(db, year_month)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
month = Month(year_month=year_month)
|
||||||
|
db.add(month)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
budget_entries = list(db.scalars(select(Entry).order_by(Entry.id)))
|
||||||
|
source_to_month_entry: dict[int, MonthEntry] = {}
|
||||||
|
for e in budget_entries:
|
||||||
|
month_entry = MonthEntry(
|
||||||
|
month_id=month.id,
|
||||||
|
section=e.section,
|
||||||
|
name=e.name,
|
||||||
|
planned=e.amount,
|
||||||
|
applied=Decimal("0.00"),
|
||||||
|
origin_name=e.name,
|
||||||
|
origin_planned=e.amount,
|
||||||
|
source_entry_id=e.id,
|
||||||
|
)
|
||||||
|
db.add(month_entry)
|
||||||
|
source_to_month_entry[e.id] = month_entry
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
budget_target = db.get(DebtTarget, 1)
|
||||||
|
target_entry_id: int | None = None
|
||||||
|
if budget_target is not None and budget_target.debt_minimum_id is not None:
|
||||||
|
mapped = source_to_month_entry.get(budget_target.debt_minimum_id)
|
||||||
|
if mapped is not None:
|
||||||
|
target_entry_id = mapped.id
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
MonthDebtTarget(month_id=month.id, month_entry_id=target_entry_id)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(month)
|
||||||
|
return month
|
||||||
|
|
||||||
|
|
||||||
|
def deviation_state(entry: MonthEntry) -> DeviationState:
|
||||||
|
if entry.origin_name is None or entry.origin_planned is None:
|
||||||
|
return DeviationState.new_in_month
|
||||||
|
if entry.name != entry.origin_name or entry.planned != entry.origin_planned:
|
||||||
|
return DeviationState.edited
|
||||||
|
return DeviationState.unchanged
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(entries: list[MonthEntry]) -> list[MonthRow]:
|
||||||
|
return [MonthRow(entry=e, state=deviation_state(e)) for e in entries]
|
||||||
|
|
||||||
|
|
||||||
|
def section_view(month: Month, section: Section, label: str) -> MonthSectionView:
|
||||||
|
entries = [e for e in month.entries if e.section == section]
|
||||||
|
entries.sort(key=lambda e: e.id)
|
||||||
|
rows = _rows(entries)
|
||||||
|
total_planned = sum((e.planned for e in entries), Decimal("0"))
|
||||||
|
total_applied = sum((e.applied for e in entries), Decimal("0"))
|
||||||
|
return MonthSectionView(
|
||||||
|
section=section,
|
||||||
|
label=label,
|
||||||
|
rows=rows,
|
||||||
|
total_planned=total_planned,
|
||||||
|
total_applied=total_applied,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def add_month_entry(
|
||||||
|
db: Session,
|
||||||
|
month: Month,
|
||||||
|
section: Section,
|
||||||
|
name: str,
|
||||||
|
planned: Decimal,
|
||||||
|
) -> MonthEntry:
|
||||||
|
entry = MonthEntry(
|
||||||
|
month_id=month.id,
|
||||||
|
section=section,
|
||||||
|
name=name.strip(),
|
||||||
|
planned=planned,
|
||||||
|
applied=Decimal("0.00"),
|
||||||
|
origin_name=None,
|
||||||
|
origin_planned=None,
|
||||||
|
source_entry_id=None,
|
||||||
|
)
|
||||||
|
db.add(entry)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(entry)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def get_month_entry(db: Session, month: Month, entry_id: int) -> MonthEntry | None:
|
||||||
|
entry = db.get(MonthEntry, entry_id)
|
||||||
|
if entry is None or entry.month_id != month.id:
|
||||||
|
return None
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def delete_month_entry(db: Session, month: Month, entry_id: int) -> Section | None:
|
||||||
|
entry = get_month_entry(db, month, entry_id)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
section = entry.section
|
||||||
|
db.delete(entry)
|
||||||
|
db.commit()
|
||||||
|
return section
|
||||||
|
|
||||||
|
|
||||||
|
def update_month_entry(
|
||||||
|
db: Session,
|
||||||
|
month: Month,
|
||||||
|
entry_id: int,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
planned: Decimal | None = None,
|
||||||
|
applied: Decimal | None = None,
|
||||||
|
) -> MonthEntry | None:
|
||||||
|
entry = get_month_entry(db, month, entry_id)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
if name is not None:
|
||||||
|
entry.name = name.strip()
|
||||||
|
if planned is not None:
|
||||||
|
entry.planned = planned
|
||||||
|
if applied is not None:
|
||||||
|
entry.applied = applied
|
||||||
|
db.commit()
|
||||||
|
db.refresh(entry)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def get_month_target(db: Session, month: Month) -> MonthDebtTarget:
|
||||||
|
if month.target is not None:
|
||||||
|
return month.target
|
||||||
|
target = MonthDebtTarget(month_id=month.id, month_entry_id=None)
|
||||||
|
db.add(target)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(target)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def set_month_target(
|
||||||
|
db: Session, month: Month, month_entry_id: int | None
|
||||||
|
) -> MonthDebtTarget:
|
||||||
|
target = get_month_target(db, month)
|
||||||
|
if month_entry_id is not None:
|
||||||
|
candidate = get_month_entry(db, month, month_entry_id)
|
||||||
|
if candidate is None or candidate.section != Section.debt_minimum:
|
||||||
|
raise ValueError(
|
||||||
|
"month_entry_id must reference a debt minimum entry in this month"
|
||||||
|
)
|
||||||
|
target.month_entry_id = month_entry_id
|
||||||
|
db.commit()
|
||||||
|
db.refresh(target)
|
||||||
|
return target
|
||||||
|
|
@ -8,7 +8,7 @@ from fastapi.responses import HTMLResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from quartermaster import service
|
from quartermaster import month_service, service
|
||||||
from quartermaster.db import get_session
|
from quartermaster.db import get_session
|
||||||
from quartermaster.models import SECTION_LABELS, Section
|
from quartermaster.models import SECTION_LABELS, Section
|
||||||
|
|
||||||
|
|
@ -62,6 +62,7 @@ def index(request: Request, db: Session = Depends(get_session)) -> HTMLResponse:
|
||||||
sections = [_section_view(db, s) for s in Section]
|
sections = [_section_view(db, s) for s in Section]
|
||||||
target = service.get_debt_target(db)
|
target = service.get_debt_target(db)
|
||||||
debt_minimums = service.list_entries(db, Section.debt_minimum)
|
debt_minimums = service.list_entries(db, Section.debt_minimum)
|
||||||
|
current_ym = month_service.current_year_month()
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"index.html",
|
"index.html",
|
||||||
|
|
@ -69,6 +70,8 @@ def index(request: Request, db: Session = Depends(get_session)) -> HTMLResponse:
|
||||||
"sections": sections,
|
"sections": sections,
|
||||||
"target": target,
|
"target": target,
|
||||||
"debt_minimums": debt_minimums,
|
"debt_minimums": debt_minimums,
|
||||||
|
"current_year_month": current_ym,
|
||||||
|
"all_months": month_service.list_months(db),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
224
src/quartermaster/routes_month.py
Normal file
224
src/quartermaster/routes_month.py
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||||
|
from fastapi.responses import HTMLResponse, Response
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from quartermaster import month_service, service
|
||||||
|
from quartermaster.db import get_session
|
||||||
|
from quartermaster.models import SECTION_LABELS, Month, Section
|
||||||
|
from quartermaster.routes import templates
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/month/{year_month}", tags=["month"])
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_amount(raw: str, *, allow_zero: bool = True) -> Decimal:
|
||||||
|
try:
|
||||||
|
amount = Decimal(raw.strip())
|
||||||
|
except (InvalidOperation, AttributeError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="amount must be numeric") from exc
|
||||||
|
if amount < 0:
|
||||||
|
raise HTTPException(status_code=400, detail="amount must be non-negative")
|
||||||
|
if amount == 0 and not allow_zero:
|
||||||
|
raise HTTPException(status_code=400, detail="amount must be positive")
|
||||||
|
return amount.quantize(Decimal("0.01"))
|
||||||
|
|
||||||
|
|
||||||
|
def _require_year_month(year_month: str) -> str:
|
||||||
|
if not month_service.valid_year_month(year_month):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail="year_month must be formatted as YYYY-MM"
|
||||||
|
)
|
||||||
|
return year_month
|
||||||
|
|
||||||
|
|
||||||
|
def _require_month(db: Session, year_month: str) -> Month:
|
||||||
|
_require_year_month(year_month)
|
||||||
|
month = month_service.get_month(db, year_month)
|
||||||
|
if month is None:
|
||||||
|
raise HTTPException(status_code=404, detail="month has not been created yet")
|
||||||
|
return month
|
||||||
|
|
||||||
|
|
||||||
|
def _section_views(month: Month) -> list[month_service.MonthSectionView]:
|
||||||
|
return [
|
||||||
|
month_service.section_view(month, s, SECTION_LABELS[s]) for s in Section
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _render_section(
|
||||||
|
request: Request, month: Month, section: Section
|
||||||
|
) -> HTMLResponse:
|
||||||
|
view = month_service.section_view(month, section, SECTION_LABELS[section])
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"partials/month_section.html",
|
||||||
|
{"month": month, "section": view},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_target(request: Request, db: Session, month: Month) -> HTMLResponse:
|
||||||
|
target = month_service.get_month_target(db, month)
|
||||||
|
debt_minimums = [e for e in month.entries if e.section == Section.debt_minimum]
|
||||||
|
debt_minimums.sort(key=lambda e: e.id)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"partials/month_target.html",
|
||||||
|
{"month": month, "target": target, "debt_minimums": debt_minimums},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_class=HTMLResponse)
|
||||||
|
def view_month(
|
||||||
|
year_month: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
_require_year_month(year_month)
|
||||||
|
month = month_service.get_month(db, year_month)
|
||||||
|
prev_ym = month_service.shift_year_month(year_month, -1)
|
||||||
|
next_ym = month_service.shift_year_month(year_month, 1)
|
||||||
|
all_months = month_service.list_months(db)
|
||||||
|
if month is None:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"month_create.html",
|
||||||
|
{
|
||||||
|
"year_month": year_month,
|
||||||
|
"prev_year_month": prev_ym,
|
||||||
|
"next_year_month": next_ym,
|
||||||
|
"all_months": all_months,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"month.html",
|
||||||
|
{
|
||||||
|
"month": month,
|
||||||
|
"year_month": year_month,
|
||||||
|
"prev_year_month": prev_ym,
|
||||||
|
"next_year_month": next_ym,
|
||||||
|
"all_months": all_months,
|
||||||
|
"sections": _section_views(month),
|
||||||
|
"target": month_service.get_month_target(db, month),
|
||||||
|
"debt_minimums": sorted(
|
||||||
|
(e for e in month.entries if e.section == Section.debt_minimum),
|
||||||
|
key=lambda e: e.id,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/create")
|
||||||
|
def create_month(
|
||||||
|
year_month: str,
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
) -> Response:
|
||||||
|
_require_year_month(year_month)
|
||||||
|
try:
|
||||||
|
month_service.create_month(db, year_month)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return Response(
|
||||||
|
status_code=204,
|
||||||
|
headers={"HX-Redirect": f"/month/{year_month}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sections/{section}/entries", response_class=HTMLResponse)
|
||||||
|
def add_month_entry(
|
||||||
|
year_month: str,
|
||||||
|
section: Section,
|
||||||
|
request: Request,
|
||||||
|
name: str = Form(...),
|
||||||
|
planned: str = Form(...),
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
month = _require_month(db, year_month)
|
||||||
|
clean_name = name.strip()
|
||||||
|
if not clean_name:
|
||||||
|
raise HTTPException(status_code=400, detail="name is required")
|
||||||
|
month_service.add_month_entry(
|
||||||
|
db, month, section, clean_name, _parse_amount(planned)
|
||||||
|
)
|
||||||
|
db.refresh(month)
|
||||||
|
return _render_section(request, month, section)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/entries/{entry_id}", response_class=HTMLResponse)
|
||||||
|
def delete_month_entry(
|
||||||
|
year_month: str,
|
||||||
|
entry_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
month = _require_month(db, year_month)
|
||||||
|
section = month_service.delete_month_entry(db, month, entry_id)
|
||||||
|
if section is None:
|
||||||
|
raise HTTPException(status_code=404, detail="entry not found")
|
||||||
|
db.refresh(month)
|
||||||
|
response = _render_section(request, month, section)
|
||||||
|
if section == Section.debt_minimum:
|
||||||
|
target_html = _render_target(request, db, month).body.decode()
|
||||||
|
response = HTMLResponse(response.body.decode() + target_html)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/entries/{entry_id}", response_class=HTMLResponse)
|
||||||
|
def update_month_entry(
|
||||||
|
year_month: str,
|
||||||
|
entry_id: int,
|
||||||
|
request: Request,
|
||||||
|
name: str | None = Form(None),
|
||||||
|
planned: str | None = Form(None),
|
||||||
|
applied: str | None = Form(None),
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
month = _require_month(db, year_month)
|
||||||
|
clean_name: str | None = None
|
||||||
|
if name is not None:
|
||||||
|
clean_name = name.strip()
|
||||||
|
if not clean_name:
|
||||||
|
raise HTTPException(status_code=400, detail="name must not be empty")
|
||||||
|
parsed_planned = _parse_amount(planned) if planned is not None else None
|
||||||
|
parsed_applied = _parse_amount(applied) if applied is not None else None
|
||||||
|
updated = month_service.update_month_entry(
|
||||||
|
db,
|
||||||
|
month,
|
||||||
|
entry_id,
|
||||||
|
name=clean_name,
|
||||||
|
planned=parsed_planned,
|
||||||
|
applied=parsed_applied,
|
||||||
|
)
|
||||||
|
if updated is None:
|
||||||
|
raise HTTPException(status_code=404, detail="entry not found")
|
||||||
|
db.refresh(month)
|
||||||
|
return _render_section(request, month, updated.section)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/target", response_class=HTMLResponse)
|
||||||
|
def update_month_target(
|
||||||
|
year_month: str,
|
||||||
|
request: Request,
|
||||||
|
month_entry_id: str = Form(""),
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
month = _require_month(db, year_month)
|
||||||
|
raw = month_entry_id.strip()
|
||||||
|
target_id: int | None
|
||||||
|
if raw == "":
|
||||||
|
target_id = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
target_id = int(raw)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="month_entry_id must be an integer"
|
||||||
|
) from exc
|
||||||
|
try:
|
||||||
|
month_service.set_month_target(db, month, target_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return _render_target(request, db, month)
|
||||||
|
|
@ -156,3 +156,124 @@ button[type=submit] {
|
||||||
.target-section .section-header {
|
.target-section .section-header {
|
||||||
border-bottom-style: dashed;
|
border-bottom-style: dashed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Monthly view ----------------------------------------------------------- */
|
||||||
|
|
||||||
|
.month-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
margin-top: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-nav .month-label {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-nav .nav-link {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-nav .nav-link:hover {
|
||||||
|
background: #f0ece0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-nav .spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-nav .month-picker {
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total .divider {
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0 0.25rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total .planned {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
table.month-entries thead th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 500;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
table.month-entries th.col-planned,
|
||||||
|
table.month-entries th.col-applied {
|
||||||
|
text-align: right;
|
||||||
|
width: 8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.month-entries th.col-actions {
|
||||||
|
width: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.month-entries td.entry-name input[name="name"] {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.month-entries td.entry-amount input {
|
||||||
|
width: 100%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.entry.state-edited {
|
||||||
|
background: #fff7e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.entry.state-new_in_month {
|
||||||
|
background: #e8f2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
padding: 0.05rem 0.4rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: lowercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-edited {
|
||||||
|
background: #f4d07a;
|
||||||
|
color: #5a3f00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-new {
|
||||||
|
background: #9cc5ef;
|
||||||
|
color: #123a66;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-add-form {
|
||||||
|
grid-template-columns: 1fr 9rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-missing-body {
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-create-form {
|
||||||
|
padding: 0.25rem 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,23 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="budget">
|
<div class="budget">
|
||||||
|
<nav class="month-nav budget-nav">
|
||||||
|
<span class="month-label">Budget configuration</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<a class="nav-link" href="/month/{{ current_year_month }}">This month ({{ current_year_month }})</a>
|
||||||
|
{% if all_months %}
|
||||||
|
<select
|
||||||
|
class="month-picker"
|
||||||
|
onchange="if(this.value){window.location=this.value}"
|
||||||
|
aria-label="Jump to month"
|
||||||
|
>
|
||||||
|
<option value="">Jump to...</option>
|
||||||
|
{% for m in all_months %}
|
||||||
|
<option value="/month/{{ m.year_month }}">{{ m.year_month }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
{% for section in sections %}
|
{% for section in sections %}
|
||||||
{% if section.section.value == 'debt_minimum' %}
|
{% if section.section.value == 'debt_minimum' %}
|
||||||
{% include "partials/section.html" %}
|
{% include "partials/section.html" %}
|
||||||
|
|
|
||||||
14
src/quartermaster/templates/month.html
Normal file
14
src/quartermaster/templates/month.html
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="budget">
|
||||||
|
{% include "partials/month_nav.html" %}
|
||||||
|
{% for section in sections %}
|
||||||
|
{% if section.section.value == 'debt_minimum' %}
|
||||||
|
{% include "partials/month_section.html" %}
|
||||||
|
{% include "partials/month_target.html" %}
|
||||||
|
{% else %}
|
||||||
|
{% include "partials/month_section.html" %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
23
src/quartermaster/templates/month_create.html
Normal file
23
src/quartermaster/templates/month_create.html
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="budget">
|
||||||
|
{% include "partials/month_nav.html" %}
|
||||||
|
<section class="section month-missing">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>No snapshot yet</h2>
|
||||||
|
</div>
|
||||||
|
<p class="month-missing-body">
|
||||||
|
This month has not been created. Creating it will snapshot the current
|
||||||
|
budget: every entry, its planned amount, and the current Primary Debt
|
||||||
|
Target. Applied amounts start at $0.00.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
hx-post="/month/{{ year_month }}/create"
|
||||||
|
hx-swap="none"
|
||||||
|
class="month-create-form"
|
||||||
|
>
|
||||||
|
<button type="submit">Create {{ year_month }}</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
22
src/quartermaster/templates/partials/month_nav.html
Normal file
22
src/quartermaster/templates/partials/month_nav.html
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<nav class="month-nav">
|
||||||
|
<a class="nav-link" href="/month/{{ prev_year_month }}" aria-label="Previous month">← {{ prev_year_month }}</a>
|
||||||
|
<span class="month-label">{{ year_month }}</span>
|
||||||
|
<a class="nav-link" href="/month/{{ next_year_month }}" aria-label="Next month">{{ next_year_month }} →</a>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
{% if all_months %}
|
||||||
|
<select
|
||||||
|
class="month-picker"
|
||||||
|
onchange="if(this.value){window.location=this.value}"
|
||||||
|
aria-label="Jump to month"
|
||||||
|
>
|
||||||
|
<option value="">Jump to...</option>
|
||||||
|
{% for m in all_months %}
|
||||||
|
<option
|
||||||
|
value="/month/{{ m.year_month }}"
|
||||||
|
{% if m.year_month == year_month %}selected{% endif %}
|
||||||
|
>{{ m.year_month }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
|
<a class="nav-link" href="/">Budget config</a>
|
||||||
|
</nav>
|
||||||
91
src/quartermaster/templates/partials/month_section.html
Normal file
91
src/quartermaster/templates/partials/month_section.html
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<section class="section" id="section-{{ section.section.value }}">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>{{ section.label }}</h2>
|
||||||
|
<span class="total" data-testid="total-{{ section.section.value }}">
|
||||||
|
<span class="applied">${{ '%.2f' | format(section.total_applied) }}</span>
|
||||||
|
<span class="divider">/</span>
|
||||||
|
<span class="planned">${{ '%.2f' | format(section.total_planned) }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<table class="entries month-entries">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-name">Name</th>
|
||||||
|
<th class="col-planned">Planned</th>
|
||||||
|
<th class="col-applied">Applied</th>
|
||||||
|
<th class="col-actions"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in section.rows %}
|
||||||
|
<tr class="entry state-{{ row.state.value }}" data-entry-id="{{ row.entry.id }}">
|
||||||
|
<td class="entry-name">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
value="{{ row.entry.name }}"
|
||||||
|
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-target="#section-{{ section.section.value }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
{% if row.state.value == 'edited' %}
|
||||||
|
<span class="tag tag-edited">modified</span>
|
||||||
|
{% elif row.state.value == 'new_in_month' %}
|
||||||
|
<span class="tag tag-new">new this month</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="entry-amount">
|
||||||
|
<input
|
||||||
|
type="number" step="0.01" min="0"
|
||||||
|
name="planned"
|
||||||
|
value="{{ '%.2f' | format(row.entry.planned) }}"
|
||||||
|
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-target="#section-{{ section.section.value }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td class="entry-amount">
|
||||||
|
<input
|
||||||
|
type="number" step="0.01" min="0"
|
||||||
|
name="applied"
|
||||||
|
value="{{ '%.2f' | format(row.entry.applied) }}"
|
||||||
|
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-target="#section-{{ section.section.value }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td class="entry-actions">
|
||||||
|
<button
|
||||||
|
class="delete"
|
||||||
|
type="button"
|
||||||
|
hx-delete="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
||||||
|
hx-target="#section-{{ section.section.value }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
aria-label="Delete {{ row.entry.name }}"
|
||||||
|
>×</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr class="empty"><td colspan="4">No entries.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
<tr class="add-row">
|
||||||
|
<td colspan="4">
|
||||||
|
<form
|
||||||
|
class="add-form month-add-form"
|
||||||
|
hx-post="/month/{{ month.year_month }}/sections/{{ section.section.value }}/entries"
|
||||||
|
hx-target="#section-{{ section.section.value }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-on::after-request="if(event.detail.successful) this.reset()"
|
||||||
|
>
|
||||||
|
<input type="text" name="name" placeholder="Name" required>
|
||||||
|
<input type="number" name="planned" step="0.01" min="0" placeholder="Planned" required>
|
||||||
|
<button type="submit">Add</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
46
src/quartermaster/templates/partials/month_target.html
Normal file
46
src/quartermaster/templates/partials/month_target.html
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
<section class="section target-section" id="section-debt_target" hx-swap-oob="outerHTML">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Primary Debt Target</h2>
|
||||||
|
{% if target.entry %}
|
||||||
|
<span class="total">
|
||||||
|
<span class="applied">${{ '%.2f' | format(target.entry.applied) }}</span>
|
||||||
|
<span class="divider">/</span>
|
||||||
|
<span class="planned">${{ '%.2f' | format(target.entry.planned) }}</span>
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="total empty">$0.00</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<table class="entries">
|
||||||
|
<tbody>
|
||||||
|
<tr class="entry">
|
||||||
|
<td class="entry-name">
|
||||||
|
{% if target.entry %}{{ target.entry.name }}{% else %}<span class="muted">No target selected.</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="entry-amount"></td>
|
||||||
|
<td class="entry-actions"></td>
|
||||||
|
</tr>
|
||||||
|
<tr class="add-row">
|
||||||
|
<td colspan="3">
|
||||||
|
<form
|
||||||
|
class="target-form"
|
||||||
|
hx-post="/month/{{ month.year_month }}/target"
|
||||||
|
hx-target="#section-debt_target"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<select name="month_entry_id">
|
||||||
|
<option value="">(none)</option>
|
||||||
|
{% for dm in debt_minimums %}
|
||||||
|
<option
|
||||||
|
value="{{ dm.id }}"
|
||||||
|
{% if target.month_entry_id == dm.id %}selected{% endif %}
|
||||||
|
>{{ dm.name }}: ${{ '%.2f' | format(dm.planned) }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit">Set</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
139
tests/test_month_routes.py
Normal file
139
tests/test_month_routes.py
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_budget_via_api(client):
|
||||||
|
client.post(
|
||||||
|
"/sections/income/entries",
|
||||||
|
data={"name": "Paycheck", "amount": "2500.00"},
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
"/sections/fixed_bill/entries",
|
||||||
|
data={"name": "Rent", "amount": "1200.00"},
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
"/sections/debt_minimum/entries",
|
||||||
|
data={"name": "Card A", "amount": "40.00"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_month_renders_create_flow(client):
|
||||||
|
response = client.get("/month/2026-04")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "No snapshot yet" in response.text
|
||||||
|
assert 'hx-post="/month/2026-04/create"' in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_month_redirects_via_htmx(client):
|
||||||
|
_seed_budget_via_api(client)
|
||||||
|
response = client.post("/month/2026-04/create")
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert response.headers.get("hx-redirect") == "/month/2026-04"
|
||||||
|
|
||||||
|
|
||||||
|
def test_created_month_renders_snapshot(client):
|
||||||
|
_seed_budget_via_api(client)
|
||||||
|
client.post("/month/2026-04/create")
|
||||||
|
response = client.get("/month/2026-04")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Paycheck" in response.text
|
||||||
|
assert "Rent" in response.text
|
||||||
|
assert "Card A" in response.text
|
||||||
|
# totals: applied / planned
|
||||||
|
assert "$2500.00" in response.text
|
||||||
|
assert "$1200.00" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_applied_update_returns_section_partial(client):
|
||||||
|
_seed_budget_via_api(client)
|
||||||
|
client.post("/month/2026-04/create")
|
||||||
|
# fetch month page, identify rent month_entry id = 2 (after income id 1)
|
||||||
|
response = client.post(
|
||||||
|
"/month/2026-04/entries/2", data={"applied": "1200.00"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
# rent is in fixed_bill section; total_applied should now include 1200
|
||||||
|
assert 'id="section-fixed_bill"' in response.text
|
||||||
|
# two $1200.00 occurrences: total_applied and planned (same amount)
|
||||||
|
assert response.text.count("$1200.00") >= 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_name_edit_flips_to_modified(client):
|
||||||
|
_seed_budget_via_api(client)
|
||||||
|
client.post("/month/2026-04/create")
|
||||||
|
response = client.post(
|
||||||
|
"/month/2026-04/entries/2", data={"name": "Rent (April)"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "state-edited" in response.text
|
||||||
|
assert 'class="tag tag-edited">modified' in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_new_entry_within_month_is_marked_new(client):
|
||||||
|
_seed_budget_via_api(client)
|
||||||
|
client.post("/month/2026-04/create")
|
||||||
|
response = client.post(
|
||||||
|
"/month/2026-04/sections/other/entries",
|
||||||
|
data={"name": "Gift", "planned": "50.00"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "state-new_in_month" in response.text
|
||||||
|
assert "new this month" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_month_entry(client):
|
||||||
|
_seed_budget_via_api(client)
|
||||||
|
client.post("/month/2026-04/create")
|
||||||
|
response = client.delete("/month/2026-04/entries/2")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Rent" not in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_month_debt_minimum_updates_target(client):
|
||||||
|
_seed_budget_via_api(client)
|
||||||
|
client.post("/month/2026-04/create")
|
||||||
|
# Card A is entry id 3 in the month snapshot
|
||||||
|
response = client.delete("/month/2026-04/entries/3")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "section-debt_target" in response.text
|
||||||
|
assert "No target selected" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_month_target(client):
|
||||||
|
_seed_budget_via_api(client)
|
||||||
|
client.post(
|
||||||
|
"/sections/debt_minimum/entries",
|
||||||
|
data={"name": "Card B", "amount": "60.00"},
|
||||||
|
)
|
||||||
|
client.post("/month/2026-04/create")
|
||||||
|
# Card B is month_entry id 4
|
||||||
|
response = client.post(
|
||||||
|
"/month/2026-04/target", data={"month_entry_id": "4"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Card B" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_month_target_isolated_between_months(client):
|
||||||
|
_seed_budget_via_api(client)
|
||||||
|
client.post(
|
||||||
|
"/sections/debt_minimum/entries",
|
||||||
|
data={"name": "Card B", "amount": "60.00"},
|
||||||
|
)
|
||||||
|
client.post("/month/2026-04/create")
|
||||||
|
client.post("/month/2026-05/create")
|
||||||
|
# Change April target to Card B (month_entry id 4 within April)
|
||||||
|
client.post("/month/2026-04/target", data={"month_entry_id": "4"})
|
||||||
|
may_page = client.get("/month/2026-05")
|
||||||
|
# May still points at Card A (copied from budget)
|
||||||
|
assert "Card A" in may_page.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_year_month_returns_404(client):
|
||||||
|
response = client.get("/month/2026-13")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_page_shows_month_nav(client):
|
||||||
|
response = client.get("/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "This month" in response.text
|
||||||
148
tests/test_month_service.py
Normal file
148
tests/test_month_service.py
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from quartermaster import month_service, service
|
||||||
|
from quartermaster.models import Section
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_budget(db):
|
||||||
|
income = service.add_entry(db, Section.income, "Paycheck", Decimal("2500.00"))
|
||||||
|
rent = service.add_entry(
|
||||||
|
db, Section.fixed_bill, "Rent", Decimal("1200.00")
|
||||||
|
)
|
||||||
|
card_a = service.add_entry(
|
||||||
|
db, Section.debt_minimum, "Card A", Decimal("40.00")
|
||||||
|
)
|
||||||
|
card_b = service.add_entry(
|
||||||
|
db, Section.debt_minimum, "Card B", Decimal("60.00")
|
||||||
|
)
|
||||||
|
service.set_debt_target(db, card_a.id)
|
||||||
|
return income, rent, card_a, card_b
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_year_month():
|
||||||
|
assert month_service.valid_year_month("2026-04")
|
||||||
|
assert month_service.valid_year_month("2026-12")
|
||||||
|
assert not month_service.valid_year_month("2026-00")
|
||||||
|
assert not month_service.valid_year_month("2026-13")
|
||||||
|
assert not month_service.valid_year_month("26-04")
|
||||||
|
assert not month_service.valid_year_month("2026/04")
|
||||||
|
|
||||||
|
|
||||||
|
def test_shift_year_month_rolls_years():
|
||||||
|
assert month_service.shift_year_month("2026-12", 1) == "2027-01"
|
||||||
|
assert month_service.shift_year_month("2026-01", -1) == "2025-12"
|
||||||
|
assert month_service.shift_year_month("2026-04", 0) == "2026-04"
|
||||||
|
assert month_service.shift_year_month("2026-04", 12) == "2027-04"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_month_snapshots_budget(db):
|
||||||
|
income, rent, card_a, card_b = _seed_budget(db)
|
||||||
|
month = month_service.create_month(db, "2026-04")
|
||||||
|
assert month.year_month == "2026-04"
|
||||||
|
assert len(month.entries) == 4
|
||||||
|
by_source = {e.source_entry_id: e for e in month.entries}
|
||||||
|
assert by_source[income.id].planned == Decimal("2500.00")
|
||||||
|
assert by_source[income.id].origin_name == "Paycheck"
|
||||||
|
assert by_source[income.id].origin_planned == Decimal("2500.00")
|
||||||
|
assert by_source[income.id].applied == Decimal("0.00")
|
||||||
|
assert by_source[rent.id].section == Section.fixed_bill
|
||||||
|
assert month.target is not None
|
||||||
|
assert month.target.entry is not None
|
||||||
|
assert month.target.entry.source_entry_id == card_a.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_month_idempotent(db):
|
||||||
|
_seed_budget(db)
|
||||||
|
first = month_service.create_month(db, "2026-04")
|
||||||
|
second = month_service.create_month(db, "2026-04")
|
||||||
|
assert first.id == second.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_month_rejects_bad_year_month(db):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
month_service.create_month(db, "2026-00")
|
||||||
|
|
||||||
|
|
||||||
|
def test_deviation_state_reflects_edits(db):
|
||||||
|
_seed_budget(db)
|
||||||
|
month = month_service.create_month(db, "2026-04")
|
||||||
|
rent = next(e for e in month.entries if e.origin_name == "Rent")
|
||||||
|
assert month_service.deviation_state(rent) == month_service.DeviationState.unchanged
|
||||||
|
|
||||||
|
month_service.update_month_entry(
|
||||||
|
db, month, rent.id, planned=Decimal("1300.00")
|
||||||
|
)
|
||||||
|
rent = next(e for e in month.entries if e.id == rent.id)
|
||||||
|
assert month_service.deviation_state(rent) == month_service.DeviationState.edited
|
||||||
|
|
||||||
|
|
||||||
|
def test_added_row_is_new_in_month(db):
|
||||||
|
_seed_budget(db)
|
||||||
|
month = month_service.create_month(db, "2026-04")
|
||||||
|
extra = month_service.add_month_entry(
|
||||||
|
db, month, Section.other, "Surprise", Decimal("20.00")
|
||||||
|
)
|
||||||
|
assert extra.origin_name is None
|
||||||
|
assert month_service.deviation_state(extra) == (
|
||||||
|
month_service.DeviationState.new_in_month
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_applied_update_changes_totals(db):
|
||||||
|
_seed_budget(db)
|
||||||
|
month = month_service.create_month(db, "2026-04")
|
||||||
|
rent = next(e for e in month.entries if e.origin_name == "Rent")
|
||||||
|
month_service.update_month_entry(
|
||||||
|
db, month, rent.id, applied=Decimal("1200.00")
|
||||||
|
)
|
||||||
|
view = month_service.section_view(month, Section.fixed_bill, "Fixed")
|
||||||
|
assert view.total_applied == Decimal("1200.00")
|
||||||
|
assert view.total_planned == Decimal("1200.00")
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_month_target_is_isolated(db):
|
||||||
|
_seed_budget(db)
|
||||||
|
april = month_service.create_month(db, "2026-04")
|
||||||
|
may = month_service.create_month(db, "2026-05")
|
||||||
|
april_b = next(
|
||||||
|
e for e in april.entries
|
||||||
|
if e.section == Section.debt_minimum and e.origin_name == "Card B"
|
||||||
|
)
|
||||||
|
month_service.set_month_target(db, april, april_b.id)
|
||||||
|
april_target = month_service.get_month_target(db, april)
|
||||||
|
may_target = month_service.get_month_target(db, may)
|
||||||
|
assert april_target.entry is not None
|
||||||
|
assert april_target.entry.origin_name == "Card B"
|
||||||
|
assert may_target.entry is not None
|
||||||
|
assert may_target.entry.origin_name == "Card A"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_month_entry_returns_section(db):
|
||||||
|
_seed_budget(db)
|
||||||
|
month = month_service.create_month(db, "2026-04")
|
||||||
|
rent = next(e for e in month.entries if e.origin_name == "Rent")
|
||||||
|
result = month_service.delete_month_entry(db, month, rent.id)
|
||||||
|
assert result == Section.fixed_bill
|
||||||
|
db.refresh(month)
|
||||||
|
remaining = [
|
||||||
|
e for e in month.entries if e.section == Section.fixed_bill
|
||||||
|
]
|
||||||
|
assert remaining == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_entry_deleted_after_snapshot_leaves_month_intact(db):
|
||||||
|
_seed_budget(db)
|
||||||
|
month = month_service.create_month(db, "2026-04")
|
||||||
|
# remove budget-side rent; month-side rent should still render unchanged
|
||||||
|
budget_rent = service.list_entries(db, Section.fixed_bill)[0]
|
||||||
|
service.delete_entry(db, budget_rent.id)
|
||||||
|
db.expire_all()
|
||||||
|
month_rent = next(e for e in month.entries if e.origin_name == "Rent")
|
||||||
|
assert month_rent.source_entry_id is None
|
||||||
|
assert month_service.deviation_state(month_rent) == (
|
||||||
|
month_service.DeviationState.unchanged
|
||||||
|
)
|
||||||
Loading…
Reference in a new issue