Compare commits

...

4 Commits

Author SHA1 Message Date
3b99bceeab Add budget review screen and dashboard summary
All checks were successful
Tests / CI / Run tests passed: 399
expenses_manager/pipeline/head This commit looks good
2026-10-21 19:28:44 +02:00
b967d218b7 Add budget block review with monthly comparison 2026-10-21 19:28:40 +02:00
e46a1e3668 Add budget review screen and dashboard summary 2026-10-21 19:14:22 +02:00
7c6777091d Add budget block review with monthly comparison 2026-10-21 19:14:16 +02:00
10 changed files with 665 additions and 14 deletions

View File

@ -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."""

View File

@ -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 }}"

View File

@ -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 }}"

View File

@ -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. #}

View 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 %}

View 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"]

View File

@ -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

View File

@ -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",

View File

@ -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'),

View File

@ -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())