From 0c533d62edd6023d1dc5f0d2d72b0a67436a7efe Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 12:44:12 -0600 Subject: [PATCH] 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) --- src/quartermaster/month_service.py | 40 ++++++++++++++++++++++++++ src/quartermaster/service.py | 45 +++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/quartermaster/month_service.py b/src/quartermaster/month_service.py index c55e5bd..92e7b9f 100644 --- a/src/quartermaster/month_service.py +++ b/src/quartermaster/month_service.py @@ -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) diff --git a/src/quartermaster/service.py b/src/quartermaster/service.py index e6a97cc..7ed9fb7 100644 --- a/src/quartermaster/service.py +++ b/src/quartermaster/service.py @@ -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")