feat(groups): group views with subtotals for budget and month

budget_group_views composes SectionViews into grouped dataclasses
with a combined total and the default open flag. month_group_views
does the same with planned and applied totals. Group order, labels,
and section-to-group mapping all come from the groups module.

Refs #11

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
archeious 2026-04-17 12:44:12 -06:00
parent 032c35c75e
commit 0c533d62ed
2 changed files with 84 additions and 1 deletions

View file

@ -9,7 +9,15 @@ from enum import Enum
from sqlalchemy import select
from sqlalchemy.orm import Session
from quartermaster.groups import (
GROUP_DEFAULT_OPEN,
GROUP_LABELS,
Group,
group_order,
sections_in_group,
)
from quartermaster.models import (
SECTION_LABELS,
DebtTarget,
Entry,
Month,
@ -48,6 +56,16 @@ class ZeroAmounts:
applied: Decimal
@dataclass(frozen=True)
class MonthGroupView:
group: Group
label: str
default_open: bool
sections: list[MonthSectionView]
total_planned: Decimal
total_applied: Decimal
def valid_year_month(year_month: str) -> bool:
return bool(YEAR_MONTH_RE.match(year_month))
@ -148,6 +166,28 @@ def month_zero(month: Month) -> ZeroAmounts:
)
def month_group_views(month: Month) -> list[MonthGroupView]:
views: list[MonthGroupView] = []
for group in group_order():
sections = [
section_view(month, s, SECTION_LABELS[s])
for s in sections_in_group(group)
]
planned = sum((sv.total_planned for sv in sections), Decimal("0"))
applied = sum((sv.total_applied for sv in sections), Decimal("0"))
views.append(
MonthGroupView(
group=group,
label=GROUP_LABELS[group],
default_open=GROUP_DEFAULT_OPEN[group],
sections=sections,
total_planned=planned,
total_applied=applied,
)
)
return views
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)

View file

@ -6,7 +6,14 @@ from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.orm import Session
from quartermaster.models import DebtTarget, Entry, Section
from quartermaster.groups import (
GROUP_DEFAULT_OPEN,
GROUP_LABELS,
Group,
group_order,
sections_in_group,
)
from quartermaster.models import SECTION_LABELS, DebtTarget, Entry, Section
def zero_tone(value: Decimal) -> str:
@ -23,6 +30,15 @@ class SectionView:
total: Decimal
@dataclass(frozen=True)
class BudgetGroupView:
group: Group
label: str
default_open: bool
sections: list[SectionView]
total: Decimal
def list_entries(db: Session, section: Section) -> list[Entry]:
stmt = select(Entry).where(Entry.section == section).order_by(Entry.id)
return list(db.scalars(stmt))
@ -32,6 +48,33 @@ def section_total(entries: list[Entry]) -> Decimal:
return sum((e.amount for e in entries), Decimal("0"))
def section_view(db: Session, section: Section) -> SectionView:
entries = list_entries(db, section)
return SectionView(
section=section,
label=SECTION_LABELS[section],
entries=entries,
total=section_total(entries),
)
def budget_group_views(db: Session) -> list[BudgetGroupView]:
views: list[BudgetGroupView] = []
for group in group_order():
sections = [section_view(db, s) for s in sections_in_group(group)]
total = sum((sv.total for sv in sections), Decimal("0"))
views.append(
BudgetGroupView(
group=group,
label=GROUP_LABELS[group],
default_open=GROUP_DEFAULT_OPEN[group],
sections=sections,
total=total,
)
)
return views
def budget_zero(db: Session) -> Decimal:
stmt = select(Entry)
total_income = Decimal("0")