Pon el listado de ingresos al nivel del resto

El listado de ingresos era el unico que seguia volcando la tabla entera sin
filtros, sin totales y sin paginacion: con unos cuantos anos de datos la
pagina se vuelve inmanejable y no hay forma de responder "cuanto ingrese en
marzo".

Se reutiliza lo que ya existe en el listado de gastos en vez de inventar nada:
los mismos filtros de ano, mes y cuenta, el mismo rango de fechas libre con la
misma precedencia y el mismo aviso, la misma paginacion de diez por pagina y
el mismo query_params para que los enlaces de pagina no pierdan los filtros.

Los ingresos marcados como traspaso se siguen listando, porque son movimientos
reales de la cuenta y hay que poder verlos y corregirlos, pero el total los
excluye: de ahi la etiqueta "Total (sin traspasos)".

El estado vacio distingue entre no tener ingresos y que los filtros no
devuelvan nada, para no invitar a crear un ingreso cuando el problema es el
filtro. El colspan de esa fila es 5, que son las columnas de esta tabla.

select_related("account") mantiene el numero de consultas plano: sin el, cada
fila pedia su cuenta por separado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
JKuijperM 2026-09-16 08:33:12 +02:00
parent 50fd58daa7
commit e48512d2c8
3 changed files with 318 additions and 8 deletions

View File

@ -3,8 +3,72 @@
{% block content %} {% block content %}
<h1>Mis ingresos</h1> <h1>Mis ingresos</h1>
<form method="get" class="filters">
<div class="filters-main">
<label class="sr-only" for="filterYear">Año</label>
<select name="year" id="filterYear">
<option value="">Año</option>
{% for y in year_list %}
<option value="{{ y }}"
{% if selected_year == y %}selected{% endif %}
>
{{ y }}
</option>
{% endfor %}
</select>
<label class="sr-only" for="filterMonth">Mes</label>
<select name="month" id="filterMonth">
<option value="">Mes</option>
{% for m in months %}
<option value="{{ m }}"
{% if selected_month == m %}selected{% endif %}
>
{{ m }}
</option>
{% endfor %}
</select>
<label class="sr-only" for="filterAccount">Cuenta</label>
<select name="account" id="filterAccount">
<option value="">Cuenta</option>
{% for acc in accounts %}
<option value="{{ acc.id }}"
{% if selected_account == acc.id %}selected{% endif %}
>
{{ acc.name }}
</option>
{% endfor %}
</select>
{% include "expenses/_date_range.html" %}
<button type="submit" class="btn btn-primary">Filtrar</button>
<a href="{% url 'income_list' %}" class="btn btn-secondary">Limpiar</a>
</div>
{% if range_active %}
<p class="range-notice">
Mostrando el rango de fechas seleccionado; los filtros de año y mes no
se aplican.
<a href="{% url 'income_list' %}">Quitar el rango</a>
</p>
{% endif %}
</form>
<br>
<a class="btn" href="{% url 'income_create' %}"> Nuevo ingreso</a> <a class="btn" href="{% url 'income_create' %}"> Nuevo ingreso</a>
<br>
<br>
<section>
<strong>Total (sin traspasos):</strong> {{ kpi_total|floatformat:2 }}€ |
<strong>Ingresos:</strong> {{ kpi_count }}
</section>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
@ -17,10 +81,15 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for income in incomes %} {% for income in page_obj %}
<tr> <tr>
<td>{{ income.name }}</td> <td>
<td>{{ income.account }}</td> {{ income.name }}
{% if income.is_transfer %}
<span class="badge badge-neutral">Traspaso</span>
{% endif %}
</td>
<td>{{ income.account.name }}</td>
<td>{{ income.amount|floatformat:2 }}</td> <td>{{ income.amount|floatformat:2 }}</td>
<td>{{ income.date }}</td> <td>{{ income.date }}</td>
<td class="table-actions"> <td class="table-actions">
@ -31,8 +100,13 @@
{% empty %} {% empty %}
<tr> <tr>
<td colspan="5" class="empty-state"> <td colspan="5" class="empty-state">
<p>No hay ingresos</p> {% if filters_active %}
<a href="{% url 'income_create' %}">Añade el primero</a> <p>No hay ingresos que coincidan con los filtros</p>
<a href="{% url 'income_list' %}">Quitar los filtros</a>
{% else %}
<p>No hay ingresos</p>
<a href="{% url 'income_create' %}">Añade el primero</a>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -40,4 +114,20 @@
</table> </table>
</div> </div>
<nav class="pagination" aria-label="Paginación de ingresos">
<span class="step-links">
{% if page_obj.has_previous %}
<a href="?page=1{% if query_params %}&{{ query_params }}{% endif %}">&laquo; Primero</a>
<a href="?page={{ page_obj.previous_page_number }}{% if query_params %}&{{ query_params }}{% endif %}">Anterior</a>
{% endif %}
<span>Página {{ page_obj.number }} de {{ page_obj.paginator.num_pages }}</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}{% if query_params %}&{{ query_params }}{% endif %}">Siguiente</a>
<a href="?page={{ page_obj.paginator.num_pages }}{% if query_params %}&{{ query_params }}{% endif %}">Último &raquo;</a>
{% endif %}
</span>
</nav>
{% endblock %} {% endblock %}

View File

@ -1,9 +1,25 @@
import pytest import pytest
from datetime import date from datetime import date
from decimal import Decimal
from django.test.utils import CaptureQueriesContext
from django.db import connection
from django.urls import reverse
from expenses.models import Income, Account from expenses.models import Income, Account
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
def make_income(user, account, amount, on, name="Ingreso", is_transfer=False):
return Income.objects.create(
owner=user,
account=account,
name=name,
amount=Decimal(amount),
date=on,
is_transfer=is_transfer,
)
def test_income_increases_account_balance(user): def test_income_increases_account_balance(user):
general_account = Account.objects.create(name='General', owner=user, initial_balance=1000, active=True) general_account = Account.objects.create(name='General', owner=user, initial_balance=1000, active=True)
@ -16,3 +32,140 @@ def test_income_increases_account_balance(user):
) )
assert general_account.current_balance() == 1500 assert general_account.current_balance() == 1500
# --------------------------------------------------------------------------
# Listado: filtros, paginación y totales
# --------------------------------------------------------------------------
def test_income_list_filters_by_year(auth_client, user, account):
old = make_income(user, account, "10", date(2023, 5, 10))
new = make_income(user, account, "20", date(2024, 5, 10))
response = auth_client.get(reverse("income_list"), {"year": 2024})
assert list(response.context["page_obj"]) == [new]
assert old not in list(response.context["page_obj"])
def test_income_list_filters_by_month(auth_client, user, account):
march = make_income(user, account, "10", date(2024, 3, 10))
make_income(user, account, "20", date(2024, 5, 10))
response = auth_client.get(reverse("income_list"), {"year": 2024, "month": 3})
assert list(response.context["page_obj"]) == [march]
def test_income_list_filters_by_account(auth_client, user, account):
other = Account.objects.create(
owner=user, name="Otra", initial_balance=Decimal("0"), active=True
)
make_income(user, account, "10", date(2024, 3, 10))
from_other = make_income(user, other, "20", date(2024, 3, 11))
response = auth_client.get(reverse("income_list"), {"account": other.id})
assert list(response.context["page_obj"]) == [from_other]
def test_income_list_filters_by_range(auth_client, user, account):
inside = make_income(user, account, "10", date(2024, 3, 15))
make_income(user, account, "20", date(2024, 4, 5))
response = auth_client.get(
reverse("income_list"),
{"date_from": "2024-03-01", "date_to": "2024-03-31"},
)
assert response.context["range_active"] is True
assert list(response.context["page_obj"]) == [inside]
def test_income_list_range_overrides_year(auth_client, user, account):
in_2023 = make_income(user, account, "10", date(2023, 5, 10))
make_income(user, account, "20", date(2024, 5, 10))
response = auth_client.get(
reverse("income_list"),
{"year": 2024, "date_from": "2023-01-01", "date_to": "2023-12-31"},
)
assert list(response.context["page_obj"]) == [in_2023]
def test_income_list_paginates_by_ten(auth_client, user, account):
for day in range(1, 15):
make_income(user, account, "10", date(2024, 3, day))
response = auth_client.get(reverse("income_list"))
assert len(response.context["page_obj"]) == 10
assert response.context["page_obj"].paginator.num_pages == 2
second = auth_client.get(reverse("income_list"), {"page": 2})
assert len(second.context["page_obj"]) == 4
def test_income_list_pagination_links_keep_the_filters(auth_client, user, account):
for day in range(1, 15):
make_income(user, account, "10", date(2024, 3, day))
make_income(user, account, "99", date(2023, 1, 1))
response = auth_client.get(reverse("income_list"), {"year": 2024})
# query_params alimenta los enlaces de paginación: sin él, pasar de página
# perdería el filtro y aparecería el ingreso de 2023.
assert "year=2024" in response.context["query_params"]
assert "page" not in response.context["query_params"]
assert 'href="?page=2&year=2024"' in response.content.decode()
def test_income_list_total_excludes_transfers_but_lists_them(auth_client, user, account):
make_income(user, account, "100", date(2024, 3, 10))
transfer = make_income(
user, account, "400", date(2024, 3, 11), is_transfer=True
)
response = auth_client.get(reverse("income_list"))
assert response.context["kpi_total"] == Decimal("100")
assert response.context["kpi_count"] == 1
assert transfer in list(response.context["page_obj"])
def test_income_list_empty_state_when_filters_match_nothing(auth_client, user, account):
make_income(user, account, "10", date(2024, 3, 10))
response = auth_client.get(reverse("income_list"), {"year": 1999})
assert response.status_code == 200
assert list(response.context["page_obj"]) == []
assert response.context["filters_active"] is True
body = response.content.decode()
assert "No hay ingresos que coincidan con los filtros" in body
assert "Añade el primero" not in body
def test_income_list_empty_state_without_filters(auth_client):
response = auth_client.get(reverse("income_list"))
assert response.context["filters_active"] is False
assert "Añade el primero" in response.content.decode()
def test_income_list_query_count_does_not_grow_with_rows(auth_client, user, account):
"""select_related("account"): una consulta por página, no una por fila."""
for day in range(1, 4):
make_income(user, account, "10", date(2024, 3, day))
with CaptureQueriesContext(connection) as few:
auth_client.get(reverse("income_list"))
for day in range(4, 11):
make_income(user, account, "10", date(2024, 3, day))
with CaptureQueriesContext(connection) as many:
auth_client.get(reverse("income_list"))
assert len(many) == len(few)

View File

@ -979,12 +979,79 @@ def income_create(request):
@login_required @login_required
def income_list(request): def income_list(request):
incomes = Income.objects.filter(owner=request.user) incomes = Income.objects.filter(owner=request.user).select_related("account")
year_list = Income.objects.filter(owner=request.user).dates("date", "year")
months = list(range(1, 13))
# Filters
year = _get_int(request.GET.get("year"))
month = _get_int(request.GET.get("month"))
account_id = _get_int(request.GET.get("account"))
date_from, date_to = _resolve_date_range(request)
range_active = bool(date_from or date_to)
# Misma precedencia que en expense_list: si hay rango, manda el rango y los
# selectores de año y mes no se aplican (la plantilla lo avisa).
if range_active:
if date_from:
incomes = incomes.filter(date__gte=date_from)
if date_to:
incomes = incomes.filter(date__lte=date_to)
else:
if year:
incomes = incomes.filter(date__year=year)
if month:
incomes = incomes.filter(date__month=month)
if account_id:
incomes = incomes.filter(account_id=account_id)
incomes = incomes.order_by("-date")
# La tabla sigue listando los traspasos, pero el total mide ingreso real y
# los excluye. Por eso la etiqueta dice "sin traspasos" en la plantilla.
totals = incomes.exclude(is_transfer=True).aggregate(
total=Sum("amount"), count=Count("id")
)
total_amount = totals["total"] or 0
income_count = totals["count"]
# Pagination
paginator = Paginator(incomes, 10)
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
query_params = request.GET.copy()
query_params.pop("page", None)
filters_active = bool(
year or month or account_id or range_active
)
return render( return render(
request, request,
"expenses/income_list.html", "expenses/income_list.html",
{"active_menu": "incomes", "incomes": incomes}, {
"active_menu": "incomes",
"incomes": page_obj,
"page_obj": page_obj,
"year_list": [y.year for y in year_list],
"months": months,
"accounts": Account.objects.filter(owner=request.user),
"selected_year": year,
"selected_month": month,
"selected_account": account_id,
"date_from": date_from,
"date_to": date_to,
"range_active": range_active,
"kpi_total": total_amount,
"kpi_count": income_count,
"filters_active": filters_active,
"query_params": query_params.urlencode(),
},
) )