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 %}
<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>
<br>
<br>
<section>
<strong>Total (sin traspasos):</strong> {{ kpi_total|floatformat:2 }}€ |
<strong>Ingresos:</strong> {{ kpi_count }}
</section>
<div class="table-wrap">
<table>
<thead>
@ -17,10 +81,15 @@
</tr>
</thead>
<tbody>
{% for income in incomes %}
{% for income in page_obj %}
<tr>
<td>{{ income.name }}</td>
<td>{{ income.account }}</td>
<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.date }}</td>
<td class="table-actions">
@ -31,8 +100,13 @@
{% empty %}
<tr>
<td colspan="5" class="empty-state">
<p>No hay ingresos</p>
<a href="{% url 'income_create' %}">Añade el primero</a>
{% 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>
<a href="{% url 'income_create' %}">Añade el primero</a>
{% endif %}
</td>
</tr>
{% endfor %}
@ -40,4 +114,20 @@
</table>
</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 %}

View File

@ -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)

View File

@ -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(),
},
)