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:
parent
50fd58daa7
commit
e48512d2c8
@ -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">
|
||||||
|
{% if filters_active %}
|
||||||
|
<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>
|
<p>No hay ingresos</p>
|
||||||
<a href="{% url 'income_create' %}">Añade el primero</a>
|
<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 %}">« 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 »</a>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user