Extrae helpers de vistas y rehace los listados de cuentas y categorias

Saca de home() dos ayudantes reutilizables:

- _account_balances(): calcula el saldo de cada cuenta con dos queries
  agregadas en lugar de llamar a account.current_balance() en bucle,
  que disparaba un N+1.
- _category_tree(): aplana las categorias en preorden con su profundidad,
  para poder indentarlas en la plantilla.

Con ellos, account_list pasa a mostrar el saldo calculado de cada cuenta
(antes usaba current_balance() desde la plantilla) y category_list muestra
un arbol indentado en vez de una columna "Categoria padre" que obligaba a
reconstruir la jerarquia mentalmente.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
JKuijperM 2026-09-09 15:34:37 +02:00
parent b04a8db080
commit e65b15ccf8
6 changed files with 114 additions and 43 deletions

View File

@ -638,6 +638,12 @@ tr:hover {
margin-top: 0.5rem;
}
.cat-depth-0 { padding-left: 0; }
.cat-depth-1 { padding-left: 1.5rem; }
.cat-depth-2 { padding-left: 3rem; }
.cat-depth-3 { padding-left: 4.5rem; }
.cat-depth-4 { padding-left: 6rem; }
.pagination {
margin-top: 1rem;
}
@ -926,6 +932,7 @@ tr:hover {
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1.25rem;
margin-bottom: 1.25rem;
}
.balance-total {

View File

@ -23,18 +23,22 @@
<thead>
<tr>
<th>Categoría</th>
<th>Categoría padre</th>
<th></th>
</tr>
</thead>
<tbody>
{% for category in categories %}
{% for row in category_rows %}
<tr>
<td>{{ category.name }}</td>
<td>{% if category.parent %}{{ category.parent.name }}{% endif %}</td>
<td class="cat-depth-{{ row.depth }}">{{ row.category.name }}</td>
<td class="table-actions">
<a href="{% url 'category_edit' category.id %}">Editar</a>
<a href="{% url 'category_delete' category.id %}" class="danger">Eliminar</a>
<a href="{% url 'category_edit' row.category.id %}">Editar</a>
<a href="{% url 'category_delete' row.category.id %}" class="danger">Eliminar</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="2" class="empty-state">
<p>No hay categorías</p>
</td>
</tr>
{% endfor %}

View File

@ -18,22 +18,22 @@
</tr>
</thead>
<tbody>
{% for account in accounts %}
{% for row in account_rows %}
<tr>
<td>{{ account.name }}</td>
<td>{{ account.initial_balance }}</td>
<td>{{ account.current_balance|floatformat:2 }}</td>
<td>{{ row.account.name }}</td>
<td>{{ row.account.initial_balance }}</td>
<td>{{ row.balance|floatformat:2 }}</td>
<td>
{% if account.active %}
{% if row.account.active %}
<span class="badge badge-active">Activa</span>
{% else %}
<span class="badge badge-inactive">Inactiva</span>
{% endif %}
</td>
<td class="table-actions">
<a href="{% url 'account_edit' account.id %}">Editar</a>
{% if account.active %}
<a href="{% url 'account_delete' account.id %}" class="danger">Eliminar</a>
<a href="{% url 'account_edit' row.account.id %}">Editar</a>
{% if row.account.active %}
<a href="{% url 'account_delete' row.account.id %}" class="danger">Eliminar</a>
{% endif %}
</td>
</tr>

View File

@ -1,6 +1,7 @@
import pytest
from datetime import date
from decimal import Decimal
from django.urls import reverse
from expenses.models import Account, Expense, Income, Category
pytestmark = pytest.mark.django_db
@ -84,3 +85,17 @@ def test_balance_until_only_counts_entries_on_or_before_date(user, category):
Income.objects.create(owner=user, account=acc, name="Later", amount=Decimal("50"), date=date(2024, 1, 20))
assert acc.balance_until(date(2024, 1, 15)) == Decimal("-25")
def test_account_list_shows_all_accounts_with_computed_balances(auth_client, user, category, django_assert_max_num_queries):
active = Account.objects.create(owner=user, name="Activa", initial_balance=Decimal("100"))
Account.objects.create(owner=user, name="Inactiva", initial_balance=Decimal("50"), active=False)
Income.objects.create(owner=user, account=active, name="Nomina", amount=Decimal("20"), date=date.today())
Expense.objects.create(owner=user, account=active, category=category, amount=Decimal("5"), date=date.today())
with django_assert_max_num_queries(10):
response = auth_client.get(reverse('account_list'))
assert response.status_code == 200
rows = {row["account"].name: row["balance"] for row in response.context["account_rows"]}
assert rows == {"Activa": Decimal("115"), "Inactiva": Decimal("50")}

View File

@ -99,3 +99,20 @@ def test_category_delete_without_expenses_succeeds(auth_client, user):
assert response.status_code == 302
assert not Category.objects.filter(pk=cat.pk).exists()
def test_category_list_view_returns_tree_ordered_with_depth(auth_client, user):
root_a = Category.objects.create(name="A raiz", owner=user)
Category.objects.create(name="B raiz", owner=user)
child = Category.objects.create(name="Hijo", owner=user, parent=root_a)
Category.objects.create(name="Nieto", owner=user, parent=child)
response = auth_client.get(reverse('category_list'))
rows = [(row["category"].name, row["depth"]) for row in response.context["category_rows"]]
assert rows == [
("A raiz", 0),
("Hijo", 1),
("Nieto", 2),
("B raiz", 0),
]

View File

@ -57,34 +57,29 @@ def sub_months(year, month, n):
return year, month
@login_required
def home(request):
today = date.today()
expenses = Expense.objects.filter(owner=request.user)
def _category_tree(categories):
"""Aplana las categorías en preorden (padres antes que hijos, cada nivel
ordenado por nombre) junto con su profundidad, para poder indentarlas."""
by_parent = {}
for category in categories:
by_parent.setdefault(category.parent_id, []).append(category)
for children in by_parent.values():
children.sort(key=lambda c: c.name)
# ---- KPIs del mes en curso ----
month_expenses = expenses.filter(date__year=today.year, date__month=today.month)
kpi_total = month_expenses.aggregate(total=Sum("amount"))["total"] or Decimal("0")
kpi_count = month_expenses.count()
kpi_categories = month_expenses.values("category").distinct().count()
rows = []
# ---- Comparativa con el mes anterior ----
prev_year, prev_month = sub_months(today.year, today.month, 1)
prev_total = (
expenses.filter(date__year=prev_year, date__month=prev_month).aggregate(
total=Sum("amount")
)["total"]
or Decimal("0")
)
diff_amount = kpi_total - prev_total
# None cuando no hay mes anterior con gastos (evita división por cero)
diff_pct = None
if prev_total:
diff_pct = (diff_amount / prev_total) * 100
def walk(parent_id, depth):
for category in by_parent.get(parent_id, []):
rows.append({"category": category, "depth": depth})
walk(category.id, depth + 1)
# ---- Saldos de cuentas ----
# Dos queries agregadas en lugar de N+1 (nada de account.current_balance() en bucle).
accounts = list(Account.objects.filter(owner=request.user, active=True))
walk(None, 0)
return rows
def _account_balances(accounts):
"""Calcula el saldo de cada cuenta con dos queries agregadas (nada de
account.current_balance() en bucle, que dispararía N+1)."""
account_ids = [a.id for a in accounts]
expense_totals = {
@ -114,6 +109,38 @@ def home(request):
if balance < 0:
negative_accounts.append({"account": acc, "balance": balance})
return account_balances, negative_accounts, total_balance
@login_required
def home(request):
today = date.today()
expenses = Expense.objects.filter(owner=request.user)
# ---- KPIs del mes en curso ----
month_expenses = expenses.filter(date__year=today.year, date__month=today.month)
kpi_total = month_expenses.aggregate(total=Sum("amount"))["total"] or Decimal("0")
kpi_count = month_expenses.count()
kpi_categories = month_expenses.values("category").distinct().count()
# ---- Comparativa con el mes anterior ----
prev_year, prev_month = sub_months(today.year, today.month, 1)
prev_total = (
expenses.filter(date__year=prev_year, date__month=prev_month).aggregate(
total=Sum("amount")
)["total"]
or Decimal("0")
)
diff_amount = kpi_total - prev_total
# None cuando no hay mes anterior con gastos (evita división por cero)
diff_pct = None
if prev_total:
diff_pct = (diff_amount / prev_total) * 100
# ---- Saldos de cuentas ----
accounts = list(Account.objects.filter(owner=request.user, active=True))
account_balances, negative_accounts, total_balance = _account_balances(accounts)
# ---- Objetivos ----
# Una sola query reutilizada para "goals" (home) y "exceeded_goals" (alertas).
# is_exceeded() solo aplica a kind == budget, así que pago y ahorro nunca la disparan.
@ -669,11 +696,12 @@ def tag_delete(request, pk):
@login_required
def account_list(request):
accounts = Account.objects.filter(owner=request.user)
accounts = list(Account.objects.filter(owner=request.user).order_by("name"))
account_rows, _, _ = _account_balances(accounts)
return render(
request,
"expenses/account_list.html",
{"active_menu": "accounts", "accounts": accounts},
{"active_menu": "accounts", "account_rows": account_rows},
)
@ -975,7 +1003,7 @@ def category_list(request):
"categories/list.html",
{
"active_menu": "settings",
"categories": categories,
"category_rows": _category_tree(categories),
"form": form,
},
)