Compare commits
4 commits
496f44cf8c
...
0ba7a19972
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ba7a19972 | ||
|
|
4d9b64d760 | ||
|
|
0c533d62ed | ||
|
|
032c35c75e |
12 changed files with 436 additions and 21 deletions
47
src/quartermaster/groups.py
Normal file
47
src/quartermaster/groups.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
|
||||||
|
from quartermaster.models import Section
|
||||||
|
|
||||||
|
|
||||||
|
class Group(str, enum.Enum):
|
||||||
|
income = "income"
|
||||||
|
committed = "committed"
|
||||||
|
savings = "savings"
|
||||||
|
flexible = "flexible"
|
||||||
|
|
||||||
|
|
||||||
|
GROUP_LABELS: dict[Group, str] = {
|
||||||
|
Group.income: "Income",
|
||||||
|
Group.committed: "Committed",
|
||||||
|
Group.savings: "Savings",
|
||||||
|
Group.flexible: "Flexible",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
GROUP_OF_SECTION: dict[Section, Group] = {
|
||||||
|
Section.income: Group.income,
|
||||||
|
Section.fixed_bill: Group.committed,
|
||||||
|
Section.debt_minimum: Group.committed,
|
||||||
|
Section.sinking_fund: Group.savings,
|
||||||
|
Section.food: Group.flexible,
|
||||||
|
Section.subscription: Group.flexible,
|
||||||
|
Section.other: Group.flexible,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
GROUP_DEFAULT_OPEN: dict[Group, bool] = {
|
||||||
|
Group.income: True,
|
||||||
|
Group.committed: False,
|
||||||
|
Group.savings: False,
|
||||||
|
Group.flexible: True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sections_in_group(group: Group) -> list[Section]:
|
||||||
|
return [s for s in Section if GROUP_OF_SECTION[s] == group]
|
||||||
|
|
||||||
|
|
||||||
|
def group_order() -> list[Group]:
|
||||||
|
return [Group.income, Group.committed, Group.savings, Group.flexible]
|
||||||
|
|
@ -24,6 +24,7 @@ class Section(str, enum.Enum):
|
||||||
food = "food"
|
food = "food"
|
||||||
subscription = "subscription"
|
subscription = "subscription"
|
||||||
other = "other"
|
other = "other"
|
||||||
|
sinking_fund = "sinking_fund"
|
||||||
|
|
||||||
|
|
||||||
SECTION_LABELS: dict[Section, str] = {
|
SECTION_LABELS: dict[Section, str] = {
|
||||||
|
|
@ -33,6 +34,7 @@ SECTION_LABELS: dict[Section, str] = {
|
||||||
Section.food: "Food and Essentials",
|
Section.food: "Food and Essentials",
|
||||||
Section.subscription: "Subscriptions",
|
Section.subscription: "Subscriptions",
|
||||||
Section.other: "Other",
|
Section.other: "Other",
|
||||||
|
Section.sinking_fund: "Sinking Funds",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,15 @@ from enum import Enum
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from quartermaster.groups import (
|
||||||
|
GROUP_DEFAULT_OPEN,
|
||||||
|
GROUP_LABELS,
|
||||||
|
Group,
|
||||||
|
group_order,
|
||||||
|
sections_in_group,
|
||||||
|
)
|
||||||
from quartermaster.models import (
|
from quartermaster.models import (
|
||||||
|
SECTION_LABELS,
|
||||||
DebtTarget,
|
DebtTarget,
|
||||||
Entry,
|
Entry,
|
||||||
Month,
|
Month,
|
||||||
|
|
@ -48,6 +56,16 @@ class ZeroAmounts:
|
||||||
applied: Decimal
|
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:
|
def valid_year_month(year_month: str) -> bool:
|
||||||
return bool(YEAR_MONTH_RE.match(year_month))
|
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:
|
def section_view(month: Month, section: Section, label: str) -> MonthSectionView:
|
||||||
entries = [e for e in month.entries if e.section == section]
|
entries = [e for e in month.entries if e.section == section]
|
||||||
entries.sort(key=lambda e: e.id)
|
entries.sort(key=lambda e: e.id)
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,14 @@ def _render_zero(request: Request, db: Session) -> HTMLResponse:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_group_totals(request: Request, db: Session) -> HTMLResponse:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"partials/budget_group_totals.html",
|
||||||
|
{"groups": service.budget_group_views(db)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _append_oob(base: HTMLResponse, *extras: HTMLResponse) -> HTMLResponse:
|
def _append_oob(base: HTMLResponse, *extras: HTMLResponse) -> HTMLResponse:
|
||||||
body = base.body.decode() + "".join(e.body.decode() for e in extras)
|
body = base.body.decode() + "".join(e.body.decode() for e in extras)
|
||||||
return HTMLResponse(body)
|
return HTMLResponse(body)
|
||||||
|
|
@ -73,7 +81,7 @@ def _append_oob(base: HTMLResponse, *extras: HTMLResponse) -> HTMLResponse:
|
||||||
|
|
||||||
@router.get("/", response_class=HTMLResponse)
|
@router.get("/", response_class=HTMLResponse)
|
||||||
def index(request: Request, db: Session = Depends(get_session)) -> HTMLResponse:
|
def index(request: Request, db: Session = Depends(get_session)) -> HTMLResponse:
|
||||||
sections = [_section_view(db, s) for s in Section]
|
groups = service.budget_group_views(db)
|
||||||
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()
|
current_ym = month_service.current_year_month()
|
||||||
|
|
@ -82,7 +90,7 @@ def index(request: Request, db: Session = Depends(get_session)) -> HTMLResponse:
|
||||||
request,
|
request,
|
||||||
"index.html",
|
"index.html",
|
||||||
{
|
{
|
||||||
"sections": sections,
|
"groups": groups,
|
||||||
"target": target,
|
"target": target,
|
||||||
"debt_minimums": debt_minimums,
|
"debt_minimums": debt_minimums,
|
||||||
"current_year_month": current_ym,
|
"current_year_month": current_ym,
|
||||||
|
|
@ -107,7 +115,10 @@ def create_entry(
|
||||||
parsed = _parse_amount(amount)
|
parsed = _parse_amount(amount)
|
||||||
service.add_entry(db, section, clean_name, parsed)
|
service.add_entry(db, section, clean_name, parsed)
|
||||||
response = _render_section(request, db, section)
|
response = _render_section(request, db, section)
|
||||||
extras: list[HTMLResponse] = [_render_zero(request, db)]
|
extras: list[HTMLResponse] = [
|
||||||
|
_render_zero(request, db),
|
||||||
|
_render_group_totals(request, db),
|
||||||
|
]
|
||||||
if section == Section.debt_minimum:
|
if section == Section.debt_minimum:
|
||||||
extras.append(_render_target(request, db))
|
extras.append(_render_target(request, db))
|
||||||
return _append_oob(response, *extras)
|
return _append_oob(response, *extras)
|
||||||
|
|
@ -123,7 +134,10 @@ def remove_entry(
|
||||||
if entry is None:
|
if entry is None:
|
||||||
raise HTTPException(status_code=404, detail="entry not found")
|
raise HTTPException(status_code=404, detail="entry not found")
|
||||||
response = _render_section(request, db, entry.section)
|
response = _render_section(request, db, entry.section)
|
||||||
extras: list[HTMLResponse] = [_render_zero(request, db)]
|
extras: list[HTMLResponse] = [
|
||||||
|
_render_zero(request, db),
|
||||||
|
_render_group_totals(request, db),
|
||||||
|
]
|
||||||
if entry.section == Section.debt_minimum:
|
if entry.section == Section.debt_minimum:
|
||||||
extras.append(_render_target(request, db))
|
extras.append(_render_target(request, db))
|
||||||
return _append_oob(response, *extras)
|
return _append_oob(response, *extras)
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,14 @@ def _render_zero(request: Request, month: Month) -> HTMLResponse:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def _append_oob(base: HTMLResponse, *extras: HTMLResponse) -> HTMLResponse:
|
||||||
body = base.body.decode() + "".join(e.body.decode() for e in extras)
|
body = base.body.decode() + "".join(e.body.decode() for e in extras)
|
||||||
return HTMLResponse(body)
|
return HTMLResponse(body)
|
||||||
|
|
@ -120,7 +128,7 @@ def view_month(
|
||||||
"prev_year_month": prev_ym,
|
"prev_year_month": prev_ym,
|
||||||
"next_year_month": next_ym,
|
"next_year_month": next_ym,
|
||||||
"all_months": all_months,
|
"all_months": all_months,
|
||||||
"sections": _section_views(month),
|
"groups": month_service.month_group_views(month),
|
||||||
"target": month_service.get_month_target(db, month),
|
"target": month_service.get_month_target(db, month),
|
||||||
"debt_minimums": sorted(
|
"debt_minimums": sorted(
|
||||||
(e for e in month.entries if e.section == Section.debt_minimum),
|
(e for e in month.entries if e.section == Section.debt_minimum),
|
||||||
|
|
@ -169,6 +177,7 @@ def add_month_entry(
|
||||||
return _append_oob(
|
return _append_oob(
|
||||||
_render_section(request, month, section),
|
_render_section(request, month, section),
|
||||||
_render_zero(request, month),
|
_render_zero(request, month),
|
||||||
|
_render_group_totals(request, month),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -184,7 +193,10 @@ def delete_month_entry(
|
||||||
if section is None:
|
if section is None:
|
||||||
raise HTTPException(status_code=404, detail="entry not found")
|
raise HTTPException(status_code=404, detail="entry not found")
|
||||||
db.refresh(month)
|
db.refresh(month)
|
||||||
extras: list[HTMLResponse] = [_render_zero(request, month)]
|
extras: list[HTMLResponse] = [
|
||||||
|
_render_zero(request, month),
|
||||||
|
_render_group_totals(request, month),
|
||||||
|
]
|
||||||
if section == Section.debt_minimum:
|
if section == Section.debt_minimum:
|
||||||
extras.append(_render_target(request, db, month))
|
extras.append(_render_target(request, db, month))
|
||||||
return _append_oob(_render_section(request, month, section), *extras)
|
return _append_oob(_render_section(request, month, section), *extras)
|
||||||
|
|
@ -222,6 +234,7 @@ def update_month_entry(
|
||||||
return _append_oob(
|
return _append_oob(
|
||||||
_render_section(request, month, updated.section),
|
_render_section(request, month, updated.section),
|
||||||
_render_zero(request, month),
|
_render_zero(request, month),
|
||||||
|
_render_group_totals(request, month),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,14 @@ from decimal import Decimal
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
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:
|
def zero_tone(value: Decimal) -> str:
|
||||||
|
|
@ -23,6 +30,15 @@ class SectionView:
|
||||||
total: Decimal
|
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]:
|
def list_entries(db: Session, section: Section) -> list[Entry]:
|
||||||
stmt = select(Entry).where(Entry.section == section).order_by(Entry.id)
|
stmt = select(Entry).where(Entry.section == section).order_by(Entry.id)
|
||||||
return list(db.scalars(stmt))
|
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"))
|
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:
|
def budget_zero(db: Session) -> Decimal:
|
||||||
stmt = select(Entry)
|
stmt = select(Entry)
|
||||||
total_income = Decimal("0")
|
total_income = Decimal("0")
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,70 @@ button[type=submit] {
|
||||||
border-bottom-style: dashed;
|
border-bottom-style: dashed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Section groups --------------------------------------------------------- */
|
||||||
|
|
||||||
|
details.group {
|
||||||
|
margin-top: 1rem;
|
||||||
|
border-top: 2px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
details.group:last-of-type {
|
||||||
|
border-bottom: 2px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
details.group > summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.group > summary::-webkit-details-marker { display: none; }
|
||||||
|
|
||||||
|
details.group > summary .chevron {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.8rem;
|
||||||
|
transition: transform 0.12s ease-out;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
details.group > summary .chevron::before {
|
||||||
|
content: "\25B6"; /* black right-pointing triangle */
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.group[open] > summary .chevron {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
details.group > summary .group-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.group > summary .group-total {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.group > .section {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.group > .section:last-child {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Zero Amount widget ----------------------------------------------------- */
|
/* Zero Amount widget ----------------------------------------------------- */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,20 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
{% include "partials/budget_zero.html" %}
|
{% include "partials/budget_zero.html" %}
|
||||||
{% for section in sections %}
|
{% for g in groups %}
|
||||||
{% if section.section.value == 'debt_minimum' %}
|
<details class="group" id="group-{{ g.group.value }}"{% if g.default_open %} open{% endif %}>
|
||||||
|
<summary class="group-header">
|
||||||
|
<span class="chevron" aria-hidden="true"></span>
|
||||||
|
<span class="group-name">{{ g.label }}</span>
|
||||||
|
<span class="group-total" id="group-total-{{ g.group.value }}">${{ '%.2f' | format(g.total) }}</span>
|
||||||
|
</summary>
|
||||||
|
{% for section in g.sections %}
|
||||||
{% include "partials/section.html" %}
|
{% include "partials/section.html" %}
|
||||||
|
{% if g.group.value == 'committed' and section.section.value == 'debt_minimum' %}
|
||||||
{% include "partials/target_card.html" %}
|
{% include "partials/target_card.html" %}
|
||||||
{% else %}
|
|
||||||
{% include "partials/section.html" %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</details>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,24 @@
|
||||||
<div class="budget">
|
<div class="budget">
|
||||||
{% include "partials/month_nav.html" %}
|
{% include "partials/month_nav.html" %}
|
||||||
{% include "partials/month_zero.html" %}
|
{% include "partials/month_zero.html" %}
|
||||||
{% for section in sections %}
|
{% for g in groups %}
|
||||||
{% if section.section.value == 'debt_minimum' %}
|
<details class="group" id="group-{{ g.group.value }}"{% if g.default_open %} open{% endif %}>
|
||||||
|
<summary class="group-header">
|
||||||
|
<span class="chevron" aria-hidden="true"></span>
|
||||||
|
<span class="group-name">{{ g.label }}</span>
|
||||||
|
<span class="group-total" id="group-total-{{ g.group.value }}">
|
||||||
|
<span class="applied">${{ '%.2f' | format(g.total_applied) }}</span>
|
||||||
|
<span class="divider">/</span>
|
||||||
|
<span class="planned">${{ '%.2f' | format(g.total_planned) }}</span>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
{% for section in g.sections %}
|
||||||
{% include "partials/month_section.html" %}
|
{% include "partials/month_section.html" %}
|
||||||
|
{% if g.group.value == 'committed' and section.section.value == 'debt_minimum' %}
|
||||||
{% include "partials/month_target.html" %}
|
{% include "partials/month_target.html" %}
|
||||||
{% else %}
|
|
||||||
{% include "partials/month_section.html" %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</details>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{% for g in groups -%}
|
||||||
|
<span class="group-total" id="group-total-{{ g.group.value }}" hx-swap-oob="outerHTML">${{ '%.2f' | format(g.total) }}</span>
|
||||||
|
{% endfor -%}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{% for g in groups -%}
|
||||||
|
<span class="group-total" id="group-total-{{ g.group.value }}" hx-swap-oob="outerHTML">
|
||||||
|
<span class="applied">${{ '%.2f' | format(g.total_applied) }}</span>
|
||||||
|
<span class="divider">/</span>
|
||||||
|
<span class="planned">${{ '%.2f' | format(g.total_planned) }}</span>
|
||||||
|
</span>
|
||||||
|
{% endfor -%}
|
||||||
164
tests/test_groups.py
Normal file
164
tests/test_groups.py
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from quartermaster import month_service, service
|
||||||
|
from quartermaster.groups import (
|
||||||
|
GROUP_DEFAULT_OPEN,
|
||||||
|
GROUP_OF_SECTION,
|
||||||
|
Group,
|
||||||
|
group_order,
|
||||||
|
sections_in_group,
|
||||||
|
)
|
||||||
|
from quartermaster.models import Section
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_section_maps_to_a_group():
|
||||||
|
for s in Section:
|
||||||
|
assert s in GROUP_OF_SECTION, f"{s} is not assigned to a group"
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_order_is_stable():
|
||||||
|
assert group_order() == [
|
||||||
|
Group.income,
|
||||||
|
Group.committed,
|
||||||
|
Group.savings,
|
||||||
|
Group.flexible,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sinking_fund_is_in_savings_group():
|
||||||
|
assert GROUP_OF_SECTION[Section.sinking_fund] == Group.savings
|
||||||
|
assert Section.sinking_fund in sections_in_group(Group.savings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_defaults_match_spec():
|
||||||
|
assert GROUP_DEFAULT_OPEN[Group.income] is True
|
||||||
|
assert GROUP_DEFAULT_OPEN[Group.committed] is False
|
||||||
|
assert GROUP_DEFAULT_OPEN[Group.savings] is False
|
||||||
|
assert GROUP_DEFAULT_OPEN[Group.flexible] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_committed_group_contains_fixed_and_debt_minimum():
|
||||||
|
committed_sections = sections_in_group(Group.committed)
|
||||||
|
assert Section.fixed_bill in committed_sections
|
||||||
|
assert Section.debt_minimum in committed_sections
|
||||||
|
# debt_target is a pointer, not a section; not part of the enum
|
||||||
|
assert len(committed_sections) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_group_views_totals(db):
|
||||||
|
service.add_entry(db, Section.income, "Paycheck", Decimal("2500.00"))
|
||||||
|
service.add_entry(db, Section.fixed_bill, "Rent", Decimal("1200.00"))
|
||||||
|
service.add_entry(db, Section.debt_minimum, "Card A", Decimal("40.00"))
|
||||||
|
service.add_entry(db, Section.sinking_fund, "Emergency", Decimal("300.00"))
|
||||||
|
service.add_entry(db, Section.food, "Groceries", Decimal("500.00"))
|
||||||
|
views = {v.group: v for v in service.budget_group_views(db)}
|
||||||
|
assert views[Group.income].total == Decimal("2500.00")
|
||||||
|
assert views[Group.committed].total == Decimal("1240.00")
|
||||||
|
assert views[Group.savings].total == Decimal("300.00")
|
||||||
|
assert views[Group.flexible].total == Decimal("500.00")
|
||||||
|
|
||||||
|
|
||||||
|
def test_month_group_views_planned_and_applied(db):
|
||||||
|
service.add_entry(db, Section.income, "Paycheck", Decimal("2500.00"))
|
||||||
|
service.add_entry(db, Section.fixed_bill, "Rent", Decimal("1200.00"))
|
||||||
|
service.add_entry(db, Section.sinking_fund, "Emergency", Decimal("300.00"))
|
||||||
|
month = month_service.create_month(db, "2026-04")
|
||||||
|
# seed applied values
|
||||||
|
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")
|
||||||
|
)
|
||||||
|
db.refresh(month)
|
||||||
|
views = {v.group: v for v in month_service.month_group_views(month)}
|
||||||
|
assert views[Group.income].total_planned == Decimal("2500.00")
|
||||||
|
assert views[Group.income].total_applied == Decimal("0.00")
|
||||||
|
assert views[Group.committed].total_planned == Decimal("1200.00")
|
||||||
|
assert views[Group.committed].total_applied == Decimal("1200.00")
|
||||||
|
assert views[Group.savings].total_planned == Decimal("300.00")
|
||||||
|
assert views[Group.savings].total_applied == Decimal("0.00")
|
||||||
|
assert views[Group.flexible].total_planned == Decimal("0.00")
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_page_renders_details_groups(client):
|
||||||
|
response = client.get("/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
# four <details class="group"> blocks
|
||||||
|
assert response.text.count('class="group"') >= 4
|
||||||
|
# default open state: income and flexible open, committed and savings closed
|
||||||
|
assert 'id="group-income"' in response.text
|
||||||
|
assert 'id="group-committed"' in response.text
|
||||||
|
assert 'id="group-savings"' in response.text
|
||||||
|
assert 'id="group-flexible"' in response.text
|
||||||
|
# income has " open" in its details tag; committed does not
|
||||||
|
income_tag_start = response.text.index('id="group-income"')
|
||||||
|
income_chunk = response.text[income_tag_start - 80 : income_tag_start + 80]
|
||||||
|
assert " open" in income_chunk
|
||||||
|
committed_tag_start = response.text.index('id="group-committed"')
|
||||||
|
committed_chunk = response.text[
|
||||||
|
committed_tag_start - 80 : committed_tag_start + 80
|
||||||
|
]
|
||||||
|
assert " open" not in committed_chunk
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_entry_returns_group_total_oob(client):
|
||||||
|
response = client.post(
|
||||||
|
"/sections/income/entries",
|
||||||
|
data={"name": "Paycheck", "amount": "2500.00"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'id="group-total-income"' in response.text
|
||||||
|
assert 'hx-swap-oob="outerHTML"' in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_sinking_fund_entry_ends_up_in_savings_group_total(client):
|
||||||
|
response = client.post(
|
||||||
|
"/sections/sinking_fund/entries",
|
||||||
|
data={"name": "Emergency", "amount": "500.00"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
# OOB response contains group-total-savings with 500
|
||||||
|
assert 'id="group-total-savings"' in response.text
|
||||||
|
assert "$500.00" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_month_page_renders_group_headers_with_paired_totals(client):
|
||||||
|
client.post(
|
||||||
|
"/sections/income/entries",
|
||||||
|
data={"name": "Paycheck", "amount": "2500.00"},
|
||||||
|
)
|
||||||
|
client.post("/month/2026-04/create")
|
||||||
|
response = client.get("/month/2026-04")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'id="group-committed"' in response.text
|
||||||
|
assert 'id="group-total-income"' in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_month_mutation_returns_group_total_oob(client):
|
||||||
|
client.post(
|
||||||
|
"/sections/income/entries",
|
||||||
|
data={"name": "Paycheck", "amount": "2500.00"},
|
||||||
|
)
|
||||||
|
client.post("/month/2026-04/create")
|
||||||
|
response = client.post(
|
||||||
|
"/month/2026-04/entries/1", data={"applied": "2500.00"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'id="group-total-income"' in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_sinking_fund_section_shows_on_budget_page(client):
|
||||||
|
response = client.get("/")
|
||||||
|
assert "Sinking Funds" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_sinking_fund_section_shows_on_month_page(client):
|
||||||
|
client.post(
|
||||||
|
"/sections/sinking_fund/entries",
|
||||||
|
data={"name": "Emergency", "amount": "500.00"},
|
||||||
|
)
|
||||||
|
client.post("/month/2026-04/create")
|
||||||
|
response = client.get("/month/2026-04")
|
||||||
|
assert "Sinking Funds" in response.text
|
||||||
|
assert "Emergency" in response.text
|
||||||
Loading…
Reference in a new issue