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>
264 lines
8.5 KiB
Python
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)
|