Modificaciones en filtrado y creada opción para marcar que un gasto/ingreso es un traspaso de cuentas #34
@ -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">
|
||||
{% 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 %}">« 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 %}
|
||||
@ -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)
|
||||
|
||||
@ -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(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user