quartermaster/src/quartermaster/routes_month.py
archeious 4d9b64d760 feat(groups): render collapsible group details and OOB-swap subtotals
Budget and month pages now wrap sections in native <details> blocks
with summary rows showing the group name and subtotal. Income and
Flexible default open, Committed and Savings default closed so the
day-to-day editing targets are visible and the set-and-forget
commitments collapse out of the way. Primary Debt Target renders
inside the Committed group after Debt Minimums.

Every mutation appends a group-totals partial with OOB spans for
all four group subtotals so the header stays in sync without a
reload regardless of which section changed.

Refs #11

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 12:44:18 -06:00

264 lines
8.5 KiB
Python

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},
)
def _render_zero(request: Request, month: Month) -> HTMLResponse:
zero = month_service.month_zero(month)
return templates.TemplateResponse(
request,
"partials/month_zero.html",
{
"zero": zero,
"planned_tone": service.zero_tone(zero.planned),
"applied_tone": service.zero_tone(zero.applied),
},
)
def _render_group_totals(request: Request, month: Month) -> HTMLResponse:
return templates.TemplateResponse(
request,
"partials/month_group_totals.html",
{"groups": month_service.month_group_views(month)},
)
def _append_oob(base: HTMLResponse, *extras: HTMLResponse) -> HTMLResponse:
body = base.body.decode() + "".join(e.body.decode() for e in extras)
return HTMLResponse(body)
@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,
},
)
zero = month_service.month_zero(month)
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,
"groups": month_service.month_group_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,
),
"zero": zero,
"planned_tone": service.zero_tone(zero.planned),
"applied_tone": service.zero_tone(zero.applied),
},
)
@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 _append_oob(
_render_section(request, month, section),
_render_zero(request, month),
_render_group_totals(request, month),
)
@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)
extras: list[HTMLResponse] = [
_render_zero(request, month),
_render_group_totals(request, month),
]
if section == Section.debt_minimum:
extras.append(_render_target(request, db, month))
return _append_oob(_render_section(request, month, section), *extras)
@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 _append_oob(
_render_section(request, month, updated.section),
_render_zero(request, month),
_render_group_totals(request, month),
)
@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)