Compare commits
No commits in common. "3b99bceeabe73461d9116f3c00e764e984d1cd26" and "41831990f0f7209b22180fc4e8d6139873023d6f" have entirely different histories.
3b99bceeab
...
41831990f0
@ -600,31 +600,6 @@ class FuelEntry(models.Model):
|
|||||||
return None
|
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):
|
def _deduped_total(movements):
|
||||||
"""Suma importes sin que el JOIN del M2M cuente la misma fila dos veces.
|
"""Suma importes sin que el JOIN del M2M cuente la misma fila dos veces.
|
||||||
|
|
||||||
@ -834,10 +809,19 @@ class Goal(models.Model):
|
|||||||
return (self.progress / self.target_amount) * 100
|
return (self.progress / self.target_amount) * 100
|
||||||
|
|
||||||
def bar_width(self):
|
def bar_width(self):
|
||||||
return bar_width(self.percentage())
|
return min(float(self.percentage()), 100)
|
||||||
|
|
||||||
def progress_state(self):
|
def progress_state(self):
|
||||||
return progress_state(self.percentage(), is_limit=self.kind == self.KIND_BUDGET)
|
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"
|
||||||
|
|
||||||
def is_exceeded(self):
|
def is_exceeded(self):
|
||||||
"""True si es un presupuesto que ya se ha pasado del límite."""
|
"""True si es un presupuesto que ya se ha pasado del límite."""
|
||||||
|
|||||||
@ -112,17 +112,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</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 -->
|
<!-- KPIs -->
|
||||||
<!-- ========================= -->
|
<!-- ========================= -->
|
||||||
@ -779,7 +768,7 @@
|
|||||||
role="progressbar"
|
role="progressbar"
|
||||||
aria-valuemin="0"
|
aria-valuemin="0"
|
||||||
aria-valuemax="100"
|
aria-valuemax="100"
|
||||||
aria-valuenow="{{ goal.bar_width|unlocalize }}"
|
aria-valuenow="{{ goal.percentage|unlocalize }}"
|
||||||
aria-label="Progreso de {{ goal.name }}"
|
aria-label="Progreso de {{ goal.name }}"
|
||||||
aria-valuetext="{{ goal.progress|floatformat:1 }}€ de {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%)">
|
aria-valuetext="{{ goal.progress|floatformat:1 }}€ de {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%)">
|
||||||
<div class="progress-fill {{ goal.progress_state }}"
|
<div class="progress-fill {{ goal.progress_state }}"
|
||||||
|
|||||||
@ -59,7 +59,7 @@
|
|||||||
role="progressbar"
|
role="progressbar"
|
||||||
aria-valuemin="0"
|
aria-valuemin="0"
|
||||||
aria-valuemax="100"
|
aria-valuemax="100"
|
||||||
aria-valuenow="{{ goal.bar_width|unlocalize }}"
|
aria-valuenow="{{ goal.percentage|unlocalize }}"
|
||||||
aria-label="Progreso de {{ goal.name }}"
|
aria-label="Progreso de {{ goal.name }}"
|
||||||
aria-valuetext="{{ goal.progress|floatformat:1 }}€ de {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%)">
|
aria-valuetext="{{ goal.progress|floatformat:1 }}€ de {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%)">
|
||||||
<div class="progress-fill {{ goal.progress_state }}"
|
<div class="progress-fill {{ goal.progress_state }}"
|
||||||
|
|||||||
@ -5,8 +5,6 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Reparto por bloques</h1>
|
<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>
|
<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. #}
|
{# Arranca con el reparto guardado; el script de abajo lo recalcula al teclear. #}
|
||||||
|
|||||||
@ -1,100 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@ -1,365 +0,0 @@
|
|||||||
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,4 +1,3 @@
|
|||||||
import re
|
|
||||||
import pytest
|
import pytest
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@ -216,33 +215,3 @@ def test_goal_list_ignores_invalid_kind_param(auth_client, user, category):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert [g.name for g in response.context["goals"]] == ["Pago"]
|
assert [g.name for g in response.context["goals"]] == ["Pago"]
|
||||||
assert response.context["selected_kind"] == ""
|
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,8 +122,6 @@ DASHBOARD_CONTEXT_KEYS = {
|
|||||||
# Objetivos y listado reciente
|
# Objetivos y listado reciente
|
||||||
"goals",
|
"goals",
|
||||||
"recent_expenses",
|
"recent_expenses",
|
||||||
# Bloques del reparto que se pasan en el mes en curso
|
|
||||||
"budget_overruns",
|
|
||||||
# Medias y proyección
|
# Medias y proyección
|
||||||
"daily_average",
|
"daily_average",
|
||||||
"projected_end_of_month",
|
"projected_end_of_month",
|
||||||
|
|||||||
@ -34,7 +34,6 @@ urlpatterns = [
|
|||||||
path('categories/<int:pk>/delete/', views.category_delete, name='category_delete'),
|
path('categories/<int:pk>/delete/', views.category_delete, name='category_delete'),
|
||||||
path('settings/', views.settings_index, name='settings_index'),
|
path('settings/', views.settings_index, name='settings_index'),
|
||||||
path('budget/', views.budget_blocks, name='budget_blocks'),
|
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/', views.export_index, name='export_index'),
|
||||||
path('export/download/', views.export_download, name='export_download'),
|
path('export/download/', views.export_download, name='export_download'),
|
||||||
path('goals/', views.goal_list, name='goal_list'),
|
path('goals/', views.goal_list, name='goal_list'),
|
||||||
|
|||||||
@ -14,8 +14,6 @@ from .models import (
|
|||||||
Goal,
|
Goal,
|
||||||
Transfer,
|
Transfer,
|
||||||
BudgetBlock,
|
BudgetBlock,
|
||||||
bar_width,
|
|
||||||
progress_state,
|
|
||||||
)
|
)
|
||||||
from .forms import (
|
from .forms import (
|
||||||
ExpenseForm,
|
ExpenseForm,
|
||||||
@ -259,80 +257,6 @@ 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):
|
def _without_transfers(movements):
|
||||||
"""Quita las patas de traspaso de un queryset de gastos o de ingresos.
|
"""Quita las patas de traspaso de un queryset de gastos o de ingresos.
|
||||||
|
|
||||||
@ -1191,18 +1115,6 @@ def dashboard(request):
|
|||||||
.order_by('-date', '-id')[:7]
|
.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
|
# Send the data to the dashboard
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
@ -1246,8 +1158,6 @@ def dashboard(request):
|
|||||||
"period": period,
|
"period": period,
|
||||||
"goals": goals,
|
"goals": goals,
|
||||||
"recent_expenses": recent_expenses,
|
"recent_expenses": recent_expenses,
|
||||||
"budget_overruns": budget_overruns,
|
|
||||||
"budget_month": budget_month,
|
|
||||||
"daily_average": daily_average,
|
"daily_average": daily_average,
|
||||||
"projected_end_of_month": projected_end_of_month,
|
"projected_end_of_month": projected_end_of_month,
|
||||||
},
|
},
|
||||||
@ -1944,39 +1854,6 @@ def settings_index(request):
|
|||||||
return render(request, "settings/index.html", {"active_menu": "settings"})
|
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
|
@login_required
|
||||||
def budget_blocks(request):
|
def budget_blocks(request):
|
||||||
proposal = _budget_block_proposal(request.user, date.today())
|
proposal = _budget_block_proposal(request.user, date.today())
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user