Compare commits
4 Commits
41831990f0
...
3b99bceeab
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b99bceeab | |||
| b967d218b7 | |||
| e46a1e3668 | |||
| 7c6777091d |
@ -600,6 +600,31 @@ class FuelEntry(models.Model):
|
||||
return None
|
||||
|
||||
|
||||
def bar_width(pct):
|
||||
"""Ancho de una barra de progreso: el porcentaje, capado entre 0 y 100 para
|
||||
que no desborde al pasarse ni quede negativa con un ahorro neto negativo."""
|
||||
return max(min(float(pct), 100), 0)
|
||||
|
||||
|
||||
def progress_state(pct, is_limit):
|
||||
"""Clase de color de una barra de progreso según el porcentaje alcanzado.
|
||||
|
||||
`is_limit` decide el criterio. En un límite (un presupuesto, un bloque de
|
||||
gasto) pasarse es malo y acercarse ya avisa. En una meta (un ahorro) es al
|
||||
revés: llegar es el objetivo y pasarse no tiene nada de malo. Lo comparten
|
||||
Goal y la revisión del reparto por bloques, para que el mismo criterio no
|
||||
viva en dos sitios.
|
||||
"""
|
||||
if is_limit:
|
||||
if pct > 100:
|
||||
return "danger"
|
||||
if pct >= 80:
|
||||
return "warning"
|
||||
return "ok"
|
||||
|
||||
return "complete" if pct >= 100 else "ok"
|
||||
|
||||
|
||||
def _deduped_total(movements):
|
||||
"""Suma importes sin que el JOIN del M2M cuente la misma fila dos veces.
|
||||
|
||||
@ -809,19 +834,10 @@ class Goal(models.Model):
|
||||
return (self.progress / self.target_amount) * 100
|
||||
|
||||
def bar_width(self):
|
||||
return min(float(self.percentage()), 100)
|
||||
|
||||
return bar_width(self.percentage())
|
||||
|
||||
def progress_state(self):
|
||||
pct = self.percentage()
|
||||
|
||||
if self.kind == self.KIND_BUDGET:
|
||||
if pct > 100:
|
||||
return "danger"
|
||||
if pct >= 80:
|
||||
return "warning"
|
||||
return "ok"
|
||||
|
||||
return "complete" if pct >= 100 else "ok"
|
||||
return progress_state(self.percentage(), is_limit=self.kind == self.KIND_BUDGET)
|
||||
|
||||
def is_exceeded(self):
|
||||
"""True si es un presupuesto que ya se ha pasado del límite."""
|
||||
|
||||
@ -112,6 +112,17 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if budget_overruns %}
|
||||
<div class="messages">
|
||||
<div class="message warning">
|
||||
En el mes en curso ({{ budget_month|date:"F Y" }}, todas las cuentas, sin
|
||||
importar los filtros de arriba) se pasan del reparto:
|
||||
{% for row in budget_overruns %}<strong>{{ row.label }}</strong>, {{ row.real_amount|floatformat:2 }} €{% if row.progress is None %} sin nada asignado{% else %} de {{ row.defined_amount|floatformat:2 }} €{% endif %}{% if not forloop.last %}; {% endif %}{% endfor %}.
|
||||
<a href="{% url 'budget_review' %}">Ver la revisión</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ========================= -->
|
||||
<!-- KPIs -->
|
||||
<!-- ========================= -->
|
||||
@ -768,7 +779,7 @@
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="{{ goal.percentage|unlocalize }}"
|
||||
aria-valuenow="{{ goal.bar_width|unlocalize }}"
|
||||
aria-label="Progreso de {{ goal.name }}"
|
||||
aria-valuetext="{{ goal.progress|floatformat:1 }}€ de {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%)">
|
||||
<div class="progress-fill {{ goal.progress_state }}"
|
||||
|
||||
@ -59,7 +59,7 @@
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="{{ goal.percentage|unlocalize }}"
|
||||
aria-valuenow="{{ goal.bar_width|unlocalize }}"
|
||||
aria-label="Progreso de {{ goal.name }}"
|
||||
aria-valuetext="{{ goal.progress|floatformat:1 }}€ de {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%)">
|
||||
<div class="progress-fill {{ goal.progress_state }}"
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
{% block content %}
|
||||
<h1>Reparto por bloques</h1>
|
||||
|
||||
<p><a href="{% url 'budget_review' %}">Revisar cómo va el mes</a></p>
|
||||
|
||||
<p>Qué porcentaje de tus ingresos debería ir a cada bloque. Los bloques de gasto son los tipos que asignas a tus categorías; el ahorro son los traspasos a tus cuentas de ahorro.</p>
|
||||
|
||||
{# Arranca con el reparto guardado; el script de abajo lo recalcula al teclear. #}
|
||||
|
||||
100
expenses_manager/expenses/templates/settings/budget_review.html
Normal file
100
expenses_manager/expenses/templates/settings/budget_review.html
Normal file
@ -0,0 +1,100 @@
|
||||
{% extends "expenses/base.html" %}
|
||||
|
||||
{% load l10n %}
|
||||
|
||||
{% block title %}Revisión del reparto{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Revisión del reparto</h1>
|
||||
|
||||
<p>Lo que definiste para cada bloque frente a lo que fue de verdad, sobre los ingresos del mes. <a href="{% url 'budget_blocks' %}">Cambiar el reparto</a></p>
|
||||
|
||||
<form method="get" class="filters">
|
||||
<div class="filters-main">
|
||||
<label class="sr-only" for="reviewYear">Año</label>
|
||||
<select name="year" id="reviewYear">
|
||||
{% for y in year_list %}
|
||||
<option value="{{ y }}"{% if y == selected_year %} selected{% endif %}>{{ y }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label class="sr-only" for="reviewMonth">Mes</label>
|
||||
<select name="month" id="reviewMonth">
|
||||
{% for m in months %}
|
||||
<option value="{{ m.month }}"{% if m.month == selected_month %} selected{% endif %}>{{ m|date:"F" }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Ver mes</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if not review %}
|
||||
<p class="empty-state">{{ selected_date|date:"F Y"|capfirst }} está sin ingresos registrados, y sin ingresos no se puede repartir nada: cada bloque se mide como parte del ingreso del mes.</p>
|
||||
{% else %}
|
||||
<p>Ingresos de {{ selected_date|date:"F Y" }}: {{ review.income|floatformat:2 }} €.</p>
|
||||
|
||||
{% if not review.defined_any %}
|
||||
<div class="messages">
|
||||
<div class="message warning">
|
||||
Aún no has asignado ningún porcentaje, así que todo el gasto cuenta como exceso.
|
||||
<a href="{% url 'budget_blocks' %}">Define el reparto</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bloque</th>
|
||||
<th>Definido</th>
|
||||
<th>Real</th>
|
||||
<th>Real sobre definido</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in review.rows %}
|
||||
<tr>
|
||||
<td>{{ row.label }}</td>
|
||||
<td>{{ row.defined_pct|floatformat:"-2" }} % · {{ row.defined_amount|floatformat:2 }} €</td>
|
||||
<td>{{ row.real_pct|floatformat:1 }} % · {{ row.real_amount|floatformat:2 }} €</td>
|
||||
<td>
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar"
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="{{ row.bar_width|unlocalize }}"
|
||||
aria-label="{{ row.label }}: real sobre definido"
|
||||
aria-valuetext="{% if row.progress is None %}{{ row.real_amount|floatformat:2 }} € sin nada asignado{% else %}{{ row.real_amount|floatformat:2 }} € de {{ row.defined_amount|floatformat:2 }} € ({{ row.progress|floatformat:1 }} %){% endif %}">
|
||||
<div class="progress-fill {{ row.state }}"
|
||||
style="width: {{ row.bar_width|unlocalize }}%"></div>
|
||||
</div>
|
||||
<span class="progress-label">
|
||||
{% if row.progress is None %}Nada asignado{% else %}{{ row.progress|floatformat:1 }} %{% endif %}
|
||||
</span>
|
||||
{% if row.exceeded %}
|
||||
<span class="badge badge-inactive">Se pasa</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p>Sin asignar en el reparto: {{ review.unassigned.pct|floatformat:"-2" }} % · {{ review.unassigned.amount|floatformat:2 }} €.</p>
|
||||
|
||||
{% if review.unclassified.amount %}
|
||||
<div class="messages">
|
||||
<div class="message warning">
|
||||
Gasto sin clasificar: {{ review.unclassified.amount|floatformat:2 }} € ({{ review.unclassified.pct|floatformat:1 }} % del ingreso).
|
||||
No es un bloque, así que no cuenta en ninguno, y mientras sea alto la comparación es poco fiable.
|
||||
<a href="{% url 'category_list' %}">Clasifica sus categorías</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
365
expenses_manager/expenses/tests/test_budget_review.py
Normal file
365
expenses_manager/expenses/tests/test_budget_review.py
Normal file
@ -0,0 +1,365 @@
|
||||
import pytest
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from django.urls import reverse
|
||||
|
||||
from expenses.models import Account, BudgetBlock, Category, Expense, Income, Transfer
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
MARCH = {"year": 2024, "month": 3}
|
||||
|
||||
|
||||
def _account(user, name, is_savings=False):
|
||||
return Account.objects.create(
|
||||
owner=user, name=name, initial_balance=Decimal("0"), active=True,
|
||||
is_savings=is_savings,
|
||||
)
|
||||
|
||||
|
||||
def _category(user, name, expense_type=""):
|
||||
return Category.objects.create(name=name, owner=user, expense_type=expense_type)
|
||||
|
||||
|
||||
def _expense(user, account, category, amount, on=date(2024, 3, 10)):
|
||||
return Expense.objects.create(
|
||||
owner=user, account=account, category=category,
|
||||
amount=Decimal(amount), date=on,
|
||||
)
|
||||
|
||||
|
||||
def _income(user, account, amount, on=date(2024, 3, 1)):
|
||||
return Income.objects.create(
|
||||
owner=user, account=account, name="Nómina", amount=Decimal(amount), date=on,
|
||||
)
|
||||
|
||||
|
||||
def _transfer(user, from_account, to_account, amount, on=date(2024, 3, 20)):
|
||||
transfer = Transfer(
|
||||
owner=user, date=on, amount=Decimal(amount),
|
||||
from_account=from_account, to_account=to_account,
|
||||
)
|
||||
transfer.sync()
|
||||
return transfer
|
||||
|
||||
|
||||
def _define(user, **percentages):
|
||||
for block, value in percentages.items():
|
||||
BudgetBlock.objects.create(owner=user, block=block, percentage=Decimal(value))
|
||||
|
||||
|
||||
def _rows(response):
|
||||
return {row["block"]: row for row in response.context["review"]["rows"]}
|
||||
|
||||
|
||||
def _known_month(user):
|
||||
"""Marzo de 2024: 2000 € de ingreso y un gasto conocido en cada bloque."""
|
||||
checking = _account(user, "Nómina")
|
||||
savings = _account(user, "Hucha", is_savings=True)
|
||||
_income(user, checking, "2000")
|
||||
_define(
|
||||
user,
|
||||
fixed_essential="40", # 800 €
|
||||
fixed_discretionary="5", # 100 €
|
||||
variable_essential="20", # 400 €
|
||||
variable_discretionary="10", # 200 €
|
||||
saving="15", # 300 €; entre todos, 90 % y quedan 10 % sin asignar
|
||||
)
|
||||
_expense(user, checking, _category(user, "Alquiler", "fixed_essential"), "900")
|
||||
_expense(user, checking, _category(user, "Netflix", "fixed_discretionary"), "50")
|
||||
_expense(user, checking, _category(user, "Compra", "variable_essential"), "340")
|
||||
_expense(user, checking, _category(user, "Varios"), "100") # sin tipo
|
||||
_transfer(user, checking, savings, "400")
|
||||
# Fuera de marzo: no cuenta.
|
||||
_expense(user, checking, _category(user, "Abril", "fixed_essential"), "999", on=date(2024, 4, 1))
|
||||
return checking, savings
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Definido contra real
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_each_block_has_defined_and_real_percentage_and_amount(auth_client, user):
|
||||
_known_month(user)
|
||||
|
||||
response = auth_client.get(reverse("budget_review"), MARCH)
|
||||
|
||||
rows = _rows(response)
|
||||
assert list(rows) == [block for block, _ in BudgetBlock.BLOCK_CHOICES]
|
||||
expected = {
|
||||
# bloque: (definido %, definido €, real %, real €)
|
||||
"fixed_essential": ("40", "800", "45", "900"),
|
||||
"fixed_discretionary": ("5", "100", "2.5", "50"),
|
||||
"variable_essential": ("20", "400", "17", "340"),
|
||||
"variable_discretionary": ("10", "200", "0", "0"),
|
||||
"saving": ("15", "300", "20", "400"),
|
||||
}
|
||||
for block, (defined_pct, defined_amount, real_pct, real_amount) in expected.items():
|
||||
row = rows[block]
|
||||
assert row["defined_pct"] == Decimal(defined_pct), block
|
||||
assert row["defined_amount"] == Decimal(defined_amount), block
|
||||
assert row["real_pct"] == Decimal(real_pct), block
|
||||
assert row["real_amount"] == Decimal(real_amount), block
|
||||
assert response.context["review"]["income"] == Decimal("2000")
|
||||
|
||||
|
||||
def test_expense_blocks_follow_the_budget_criterion(auth_client, user):
|
||||
_known_month(user)
|
||||
|
||||
rows = _rows(auth_client.get(reverse("budget_review"), MARCH))
|
||||
|
||||
assert (rows["fixed_essential"]["state"], rows["fixed_essential"]["exceeded"]) == ("danger", True) # 112,5 %
|
||||
assert (rows["variable_essential"]["state"], rows["variable_essential"]["exceeded"]) == ("warning", False) # 85 %
|
||||
assert (rows["fixed_discretionary"]["state"], rows["fixed_discretionary"]["exceeded"]) == ("ok", False) # 50 %
|
||||
assert rows["fixed_essential"]["bar_width"] == 100
|
||||
|
||||
|
||||
def test_saving_above_defined_is_good(auth_client, user):
|
||||
_known_month(user) # 400 € ahorrados sobre 300 € previstos
|
||||
|
||||
saving = _rows(auth_client.get(reverse("budget_review"), MARCH))["saving"]
|
||||
|
||||
assert saving["state"] == "complete"
|
||||
assert saving["exceeded"] is False
|
||||
assert saving["bar_width"] == 100
|
||||
|
||||
|
||||
def test_saving_below_defined_is_not_complete(auth_client, user):
|
||||
checking = _account(user, "Nómina")
|
||||
_income(user, checking, "2000")
|
||||
_define(user, saving="15")
|
||||
_transfer(user, checking, _account(user, "Hucha", is_savings=True), "150")
|
||||
|
||||
saving = _rows(auth_client.get(reverse("budget_review"), MARCH))["saving"]
|
||||
|
||||
assert saving["state"] == "ok" # 50 % de lo previsto, sin llegar
|
||||
assert saving["exceeded"] is False
|
||||
assert saving["bar_width"] == 50
|
||||
|
||||
|
||||
def test_block_with_zero_defined_and_real_spending_is_a_total_excess(auth_client, user):
|
||||
checking = _account(user, "Nómina")
|
||||
_income(user, checking, "1000")
|
||||
_define(user, fixed_essential="50")
|
||||
_expense(user, checking, _category(user, "Caprichos", "variable_discretionary"), "80")
|
||||
|
||||
response = auth_client.get(reverse("budget_review"), MARCH)
|
||||
|
||||
row = _rows(response)["variable_discretionary"]
|
||||
assert row["defined_pct"] == Decimal("0")
|
||||
assert row["real_amount"] == Decimal("80")
|
||||
assert row["progress"] is None
|
||||
assert row["bar_width"] == 100
|
||||
assert row["state"] == "danger"
|
||||
assert row["exceeded"] is True
|
||||
assert 'aria-valuenow="100"' in response.content.decode()
|
||||
|
||||
|
||||
def test_progressbar_aria_uses_capped_width_without_locale_comma(auth_client, user):
|
||||
_known_month(user)
|
||||
|
||||
content = auth_client.get(reverse("budget_review"), MARCH).content.decode()
|
||||
|
||||
# Fijo prescindible: 50 € de 100 € → 50 %. Fijo esencial pasa del 100 % y
|
||||
# se capa: aria-valuenow nunca es 112,5.
|
||||
assert 'aria-valuenow="50' in content
|
||||
assert 'aria-valuenow="112' not in content
|
||||
assert 'aria-valuenow="100"' in content
|
||||
assert "112,5 %" in content # el real sí se dice, con coma, en el texto
|
||||
|
||||
|
||||
def test_unclassified_is_shown_apart_and_is_not_a_block(auth_client, user):
|
||||
_known_month(user)
|
||||
|
||||
response = auth_client.get(reverse("budget_review"), MARCH)
|
||||
|
||||
review = response.context["review"]
|
||||
assert "" not in _rows(response)
|
||||
assert review["unclassified"] == {"amount": Decimal("100"), "pct": Decimal("5")}
|
||||
assert "poco fiable" in response.content.decode()
|
||||
|
||||
|
||||
def test_unassigned_part_of_the_income(auth_client, user):
|
||||
_known_month(user)
|
||||
|
||||
review = auth_client.get(reverse("budget_review"), MARCH).context["review"]
|
||||
|
||||
assert review["unassigned"] == {"pct": Decimal("10"), "amount": Decimal("200")}
|
||||
|
||||
|
||||
def test_monthly_saving_respects_the_three_transfer_cases(auth_client, user):
|
||||
checking = _account(user, "Nómina")
|
||||
piggy = _account(user, "Hucha", is_savings=True)
|
||||
deposit = _account(user, "Depósito", is_savings=True)
|
||||
_income(user, checking, "1000")
|
||||
_transfer(user, checking, piggy, "300") # a ahorro: suma
|
||||
_transfer(user, piggy, checking, "100") # de ahorro: resta
|
||||
_transfer(user, piggy, deposit, "500") # entre ahorros: no cuenta
|
||||
|
||||
saving = _rows(auth_client.get(reverse("budget_review"), MARCH))["saving"]
|
||||
|
||||
assert saving["real_amount"] == Decimal("200")
|
||||
|
||||
|
||||
def test_month_without_income_shows_empty_state(auth_client, user):
|
||||
checking = _account(user, "Nómina")
|
||||
_define(user, fixed_essential="50")
|
||||
_expense(user, checking, _category(user, "Alquiler", "fixed_essential"), "700")
|
||||
|
||||
response = auth_client.get(reverse("budget_review"), MARCH)
|
||||
|
||||
assert response.context["review"] is None
|
||||
content = response.content.decode()
|
||||
assert "sin ingresos registrados" in content
|
||||
assert 'role="progressbar"' not in content
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Selector de mes
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_defaults_to_the_current_month(auth_client):
|
||||
response = auth_client.get(reverse("budget_review"))
|
||||
|
||||
today = date.today()
|
||||
assert (response.context["selected_year"], response.context["selected_month"]) == (today.year, today.month)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("params", [
|
||||
{"year": "abc", "month": "3"},
|
||||
{"year": "2024", "month": "13"},
|
||||
{"year": "2024", "month": "0"},
|
||||
{"year": "0", "month": "3"},
|
||||
{"year": "2024"},
|
||||
])
|
||||
def test_invalid_month_in_url_falls_back_to_current_month(auth_client, params):
|
||||
response = auth_client.get(reverse("budget_review"), params)
|
||||
|
||||
today = date.today()
|
||||
assert response.status_code == 200
|
||||
assert (response.context["selected_year"], response.context["selected_month"]) == (today.year, today.month)
|
||||
|
||||
|
||||
def test_month_selector_has_labels(auth_client):
|
||||
content = auth_client.get(reverse("budget_review")).content.decode()
|
||||
|
||||
assert '<label class="sr-only" for="reviewYear">' in content
|
||||
assert '<label class="sr-only" for="reviewMonth">' in content
|
||||
|
||||
|
||||
def test_definition_and_review_link_to_each_other(auth_client):
|
||||
review = auth_client.get(reverse("budget_review")).content.decode()
|
||||
definition = auth_client.get(reverse("budget_blocks")).content.decode()
|
||||
|
||||
assert 'href="%s"' % reverse("budget_blocks") in review
|
||||
assert 'href="%s"' % reverse("budget_review") in definition
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Resumen del dashboard
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _current_month_overrun(user):
|
||||
"""Mes en curso: Fijo esencial al 50 % (500 €) y se gastan 700 €."""
|
||||
today = date.today()
|
||||
checking = _account(user, "Nómina")
|
||||
_income(user, checking, "1000", on=today.replace(day=1))
|
||||
_define(user, fixed_essential="50", variable_essential="30")
|
||||
_expense(user, checking, _category(user, "Alquiler", "fixed_essential"), "700", on=today.replace(day=1))
|
||||
_expense(user, checking, _category(user, "Compra", "variable_essential"), "100", on=today.replace(day=1))
|
||||
return checking
|
||||
|
||||
|
||||
def test_dashboard_summary_lists_only_exceeded_blocks(auth_client, user):
|
||||
_current_month_overrun(user)
|
||||
|
||||
response = auth_client.get(reverse("dashboard"))
|
||||
|
||||
overruns = response.context["budget_overruns"]
|
||||
assert [row["block"] for row in overruns] == ["fixed_essential"]
|
||||
content = response.content.decode()
|
||||
assert "mes en curso" in content
|
||||
assert 'href="%s"' % reverse("budget_review") in content
|
||||
|
||||
|
||||
def test_dashboard_summary_says_when_a_block_had_nothing_assigned(auth_client, user):
|
||||
checking = _current_month_overrun(user)
|
||||
_expense(user, checking, _category(user, "Netflix", "fixed_discretionary"), "13", on=date.today().replace(day=1))
|
||||
|
||||
content = auth_client.get(reverse("dashboard")).content.decode()
|
||||
|
||||
assert "13,00 € sin nada asignado" in content
|
||||
assert "de 0,00 €" not in content
|
||||
|
||||
|
||||
def test_dashboard_summary_hidden_when_nothing_is_exceeded(auth_client, user):
|
||||
today = date.today()
|
||||
checking = _account(user, "Nómina")
|
||||
_income(user, checking, "1000", on=today.replace(day=1))
|
||||
_define(user, fixed_essential="50")
|
||||
_expense(user, checking, _category(user, "Alquiler", "fixed_essential"), "400", on=today.replace(day=1))
|
||||
|
||||
response = auth_client.get(reverse("dashboard"))
|
||||
|
||||
assert response.context["budget_overruns"] == []
|
||||
assert "mes en curso" not in response.content.decode()
|
||||
|
||||
|
||||
def test_dashboard_summary_hidden_without_a_defined_budget(auth_client, user):
|
||||
# Sin reparto definido, cada bloque con gasto sería un "exceso total": el
|
||||
# resumen avisaría de todo a quien nunca ha usado el reparto.
|
||||
today = date.today()
|
||||
checking = _account(user, "Nómina")
|
||||
_income(user, checking, "1000", on=today.replace(day=1))
|
||||
_expense(user, checking, _category(user, "Alquiler", "fixed_essential"), "400", on=today.replace(day=1))
|
||||
|
||||
assert auth_client.get(reverse("dashboard")).context["budget_overruns"] == []
|
||||
|
||||
|
||||
def _dashboard_queries(client):
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
client.get(reverse("dashboard"))
|
||||
return len(ctx.captured_queries)
|
||||
|
||||
|
||||
def test_dashboard_query_cost_of_the_summary(auth_client, user):
|
||||
checking = _current_month_overrun(user)
|
||||
with_summary = _dashboard_queries(auth_client)
|
||||
|
||||
BudgetBlock.objects.filter(owner=user).delete()
|
||||
without_budget = _dashboard_queries(auth_client)
|
||||
|
||||
# Sin reparto definido el resumen se corta tras leer los bloques. Con él,
|
||||
# suma ingresos del mes, gasto por categoría (2) y traspasos: 4 más.
|
||||
assert with_summary - without_budget == 4
|
||||
|
||||
_define(user, fixed_essential="50")
|
||||
for i in range(8):
|
||||
_expense(user, checking, _category(user, f"Extra {i}", "fixed_essential"), "1", on=date.today().replace(day=1))
|
||||
assert _dashboard_queries(auth_client) == with_summary
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Aislamiento y login
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_review_only_sees_own_data(auth_client, user, django_user_model):
|
||||
other = django_user_model.objects.create_user(username="otro", password="x-pass-123")
|
||||
_known_month(other)
|
||||
checking = _account(user, "Mía")
|
||||
_income(user, checking, "1000")
|
||||
|
||||
review = auth_client.get(reverse("budget_review"), MARCH).context["review"]
|
||||
|
||||
assert review["income"] == Decimal("1000")
|
||||
assert all(row["defined_pct"] == 0 and row["real_amount"] == 0 for row in review["rows"])
|
||||
|
||||
|
||||
def test_review_requires_login(client):
|
||||
response = client.get(reverse("budget_review"))
|
||||
|
||||
assert response.status_code == 302
|
||||
assert reverse("login") in response["Location"]
|
||||
@ -1,3 +1,4 @@
|
||||
import re
|
||||
import pytest
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
@ -215,3 +216,33 @@ def test_goal_list_ignores_invalid_kind_param(auth_client, user, category):
|
||||
assert response.status_code == 200
|
||||
assert [g.name for g in response.context["goals"]] == ["Pago"]
|
||||
assert response.context["selected_kind"] == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", ["goal_list", "dashboard", "home"])
|
||||
def test_progressbar_aria_valuenow_stays_within_valuemax_when_over_target(
|
||||
auth_client, user, account, category, route
|
||||
):
|
||||
# Presupuesto al 150 %. aria-valuemax es 100, así que aria-valuenow tiene
|
||||
# que ir capado (bar_width), no el porcentaje real: si no, el lector de
|
||||
# pantalla recibe un valor fuera de rango. El 150 % ya se dice en el texto.
|
||||
Goal.objects.create(
|
||||
owner=user, name="Presupuesto", kind=Goal.KIND_BUDGET, category=category,
|
||||
target_amount=Decimal("100"), period=Goal.PERIOD_MONTH, show_on_home=True,
|
||||
)
|
||||
Expense.objects.create(
|
||||
owner=user, account=account, category=category,
|
||||
amount=Decimal("150"), date=date.today(),
|
||||
)
|
||||
# Otro objetivo al 37,5 %: el capado de arriba da el entero 100, sin
|
||||
# decimales, así que solo este delata un |unlocalize que falte (saldría
|
||||
# "37,5", que no es un número válido para aria-valuenow).
|
||||
Goal.objects.create(
|
||||
owner=user, name="Pago", kind=Goal.KIND_PAYMENT, category=category,
|
||||
target_amount=Decimal("400"), show_on_home=True,
|
||||
)
|
||||
|
||||
content = auth_client.get(reverse(route)).content.decode()
|
||||
|
||||
values = re.findall(r'role="progressbar"[^>]*?aria-valuenow="([^"]*)"', content, re.S)
|
||||
assert len(values) == 2, "faltan barras de progreso en %s: %s" % (route, values)
|
||||
assert sorted(float(value) for value in values) == [37.5, 100], values
|
||||
|
||||
@ -122,6 +122,8 @@ DASHBOARD_CONTEXT_KEYS = {
|
||||
# Objetivos y listado reciente
|
||||
"goals",
|
||||
"recent_expenses",
|
||||
# Bloques del reparto que se pasan en el mes en curso
|
||||
"budget_overruns",
|
||||
# Medias y proyección
|
||||
"daily_average",
|
||||
"projected_end_of_month",
|
||||
|
||||
@ -34,6 +34,7 @@ urlpatterns = [
|
||||
path('categories/<int:pk>/delete/', views.category_delete, name='category_delete'),
|
||||
path('settings/', views.settings_index, name='settings_index'),
|
||||
path('budget/', views.budget_blocks, name='budget_blocks'),
|
||||
path('budget/review/', views.budget_review, name='budget_review'),
|
||||
path('export/', views.export_index, name='export_index'),
|
||||
path('export/download/', views.export_download, name='export_download'),
|
||||
path('goals/', views.goal_list, name='goal_list'),
|
||||
|
||||
@ -14,6 +14,8 @@ from .models import (
|
||||
Goal,
|
||||
Transfer,
|
||||
BudgetBlock,
|
||||
bar_width,
|
||||
progress_state,
|
||||
)
|
||||
from .forms import (
|
||||
ExpenseForm,
|
||||
@ -257,6 +259,80 @@ def _budget_block_proposal(user, today):
|
||||
}
|
||||
|
||||
|
||||
def _budget_review(user, year, month, only_if_defined=False):
|
||||
"""Definido contra real del reparto por bloques en un mes.
|
||||
|
||||
El denominador es el ingreso real del mes, sin traspasos. Devuelve None si
|
||||
no hubo ingresos: sin denominador no hay reparto que comparar. Con
|
||||
`only_if_defined` también devuelve None si el usuario no ha asignado nada
|
||||
a ningún bloque, y se corta tras esa primera consulta: el resumen del
|
||||
dashboard no debe pagar el cálculo, ni avisar de excesos, a quien no usa el
|
||||
reparto.
|
||||
|
||||
Son cinco consultas fijas: el reparto guardado, los ingresos, dos de
|
||||
_expense_type_breakdown y una de _savings_total."""
|
||||
defined = {
|
||||
row.block: row.percentage for row in BudgetBlock.objects.filter(owner=user)
|
||||
}
|
||||
if only_if_defined and not any(defined.values()):
|
||||
return None
|
||||
|
||||
date_from = date(year, month, 1)
|
||||
date_to = date(year + 1, 1, 1) if month == 12 else date(year, month + 1, 1)
|
||||
|
||||
income = _analysis_incomes(user).filter(
|
||||
date__gte=date_from, date__lt=date_to
|
||||
).aggregate(total=Sum("amount"))["total"] or Decimal("0")
|
||||
if income <= 0:
|
||||
return None
|
||||
|
||||
expenses = _analysis_expenses(user).filter(date__gte=date_from, date__lt=date_to)
|
||||
breakdown, _ = _expense_type_breakdown(user, expenses)
|
||||
real = {row["expense_type"]: row["total"] for row in breakdown}
|
||||
real[BudgetBlock.BLOCK_SAVING] = _savings_total(user, date_from, date_to)
|
||||
|
||||
rows = []
|
||||
for block, label in BudgetBlock.BLOCK_CHOICES:
|
||||
is_saving = block == BudgetBlock.BLOCK_SAVING
|
||||
defined_pct = defined.get(block, Decimal("0"))
|
||||
defined_amount = income * defined_pct / 100
|
||||
real_amount = real[block]
|
||||
|
||||
if defined_amount:
|
||||
progress = real_amount / defined_amount * 100
|
||||
elif real_amount > 0:
|
||||
# 0 % definido y gasto real: exceso total. El cociente no existe,
|
||||
# así que la barra va llena y en la plantilla se enseña el importe.
|
||||
progress = Decimal("Infinity")
|
||||
else:
|
||||
progress = Decimal("0")
|
||||
|
||||
rows.append({
|
||||
"block": block,
|
||||
"label": label,
|
||||
"defined_pct": defined_pct,
|
||||
"defined_amount": defined_amount,
|
||||
"real_pct": real_amount / income * 100,
|
||||
"real_amount": real_amount,
|
||||
"progress": None if progress.is_infinite() else progress,
|
||||
"bar_width": bar_width(progress),
|
||||
# El ahorro es una meta, no un límite: superarlo es lo que se busca.
|
||||
"state": progress_state(progress, is_limit=not is_saving),
|
||||
"exceeded": not is_saving and real_amount > defined_amount,
|
||||
})
|
||||
|
||||
unassigned_pct = 100 - sum(defined.values(), Decimal("0"))
|
||||
return {
|
||||
"income": income,
|
||||
"rows": rows,
|
||||
"defined_any": any(defined.values()),
|
||||
# No es un bloque: sin tipo resuelto no tiene objetivo con el que
|
||||
# compararse. Cuanto más pese, menos fiable es la comparación.
|
||||
"unclassified": {"amount": real[""], "pct": real[""] / income * 100},
|
||||
"unassigned": {"pct": unassigned_pct, "amount": income * unassigned_pct / 100},
|
||||
}
|
||||
|
||||
|
||||
def _without_transfers(movements):
|
||||
"""Quita las patas de traspaso de un queryset de gastos o de ingresos.
|
||||
|
||||
@ -1115,6 +1191,18 @@ def dashboard(request):
|
||||
.order_by('-date', '-id')[:7]
|
||||
)
|
||||
|
||||
# Siempre sobre el mes en curso y todas las cuentas, sin mirar los filtros
|
||||
# de arriba: la plantilla lo dice en el propio texto para que no parezca
|
||||
# que contradice al resto de la pantalla.
|
||||
budget_month = today.replace(day=1)
|
||||
current_review = _budget_review(
|
||||
request.user, today.year, today.month, only_if_defined=True
|
||||
)
|
||||
budget_overruns = (
|
||||
[row for row in current_review["rows"] if row["exceeded"]]
|
||||
if current_review else []
|
||||
)
|
||||
|
||||
# Send the data to the dashboard
|
||||
return render(
|
||||
request,
|
||||
@ -1158,6 +1246,8 @@ def dashboard(request):
|
||||
"period": period,
|
||||
"goals": goals,
|
||||
"recent_expenses": recent_expenses,
|
||||
"budget_overruns": budget_overruns,
|
||||
"budget_month": budget_month,
|
||||
"daily_average": daily_average,
|
||||
"projected_end_of_month": projected_end_of_month,
|
||||
},
|
||||
@ -1854,6 +1944,39 @@ def settings_index(request):
|
||||
return render(request, "settings/index.html", {"active_menu": "settings"})
|
||||
|
||||
|
||||
@login_required
|
||||
def budget_review(request):
|
||||
today = date.today()
|
||||
year = _get_int(request.GET.get("year"))
|
||||
month = _get_int(request.GET.get("month"))
|
||||
# Un mes inválido o a medias se ignora y se cae al mes en curso, igual
|
||||
# que _get_int con un número mal escrito: nada de 400.
|
||||
if not (year and 1 <= year <= 9999 and month and 1 <= month <= 12):
|
||||
year, month = today.year, today.month
|
||||
|
||||
years = {today.year} | {
|
||||
d.year
|
||||
for qs in (Expense.objects.filter(owner=request.user), Income.objects.filter(owner=request.user))
|
||||
for d in qs.dates("date", "year")
|
||||
}
|
||||
|
||||
return render(
|
||||
request,
|
||||
"settings/budget_review.html",
|
||||
{
|
||||
"active_menu": "settings",
|
||||
"review": _budget_review(request.user, year, month),
|
||||
"selected_year": year,
|
||||
"selected_month": month,
|
||||
"selected_date": date(year, month, 1),
|
||||
"year_list": sorted(years | {year}, reverse=True),
|
||||
# Fechas y no números para que la plantilla saque el nombre del
|
||||
# mes localizado con |date:"F".
|
||||
"months": [date(2000, m, 1) for m in range(1, 13)],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def budget_blocks(request):
|
||||
proposal = _budget_block_proposal(request.user, date.today())
|
||||
|
||||
Loading…
Reference in New Issue
Block a user