Modificaciones en filtrado y creada opción para marcar que un gasto/ingreso es un traspaso de cuentas #34

Merged
jkuijperm merged 18 commits from dev into main 2026-09-16 07:50:18 +00:00
3 changed files with 318 additions and 8 deletions
Showing only changes of commit e48512d2c8 - Show all commits

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">
{% 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 %}">&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(),
},
) )