diff --git a/expenses_manager/expenses/templates/expenses/income_list.html b/expenses_manager/expenses/templates/expenses/income_list.html index 7c9fb4d..56820bb 100644 --- a/expenses_manager/expenses/templates/expenses/income_list.html +++ b/expenses_manager/expenses/templates/expenses/income_list.html @@ -3,8 +3,72 @@ {% block content %}

Mis ingresos

+
+
+ + + + + + + + + + + {% include "expenses/_date_range.html" %} + + + Limpiar +
+ + {% if range_active %} +

+ Mostrando el rango de fechas seleccionado; los filtros de año y mes no + se aplican. + Quitar el rango +

+ {% endif %} +
+ +
+ ➕ Nuevo ingreso +
+
+ +
+ Total (sin traspasos): {{ kpi_total|floatformat:2 }}€ | + Ingresos: {{ kpi_count }} +
+
@@ -17,10 +81,15 @@ - {% for income in incomes %} + {% for income in page_obj %} - - + + {% endfor %} @@ -40,4 +114,20 @@
{{ income.name }}{{ income.account }} + {{ income.name }} + {% if income.is_transfer %} + Traspaso + {% endif %} + {{ income.account.name }} {{ income.amount|floatformat:2 }} {{ income.date }} @@ -31,8 +100,13 @@ {% empty %}
-

No hay ingresos

- Añade el primero + {% if filters_active %} +

No hay ingresos que coincidan con los filtros

+ Quitar los filtros + {% else %} +

No hay ingresos

+ Añade el primero + {% endif %}
-{% endblock %} \ No newline at end of file + + +{% endblock %} diff --git a/expenses_manager/expenses/tests/test_income.py b/expenses_manager/expenses/tests/test_income.py index e0e351d..136d180 100644 --- a/expenses_manager/expenses/tests/test_income.py +++ b/expenses_manager/expenses/tests/test_income.py @@ -1,9 +1,25 @@ import pytest 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 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): 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 + + +# -------------------------------------------------------------------------- +# 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) diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index 3bbc8d1..ef29c7b 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -979,12 +979,79 @@ def income_create(request): @login_required 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( request, "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(), + }, )