diff --git a/expenses_manager/expenses/static/expenses/css/base.css b/expenses_manager/expenses/static/expenses/css/base.css index a704d0b..48588b1 100644 --- a/expenses_manager/expenses/static/expenses/css/base.css +++ b/expenses_manager/expenses/static/expenses/css/base.css @@ -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 { diff --git a/expenses_manager/expenses/templates/categories/list.html b/expenses_manager/expenses/templates/categories/list.html index 81d918a..0a40561 100644 --- a/expenses_manager/expenses/templates/categories/list.html +++ b/expenses_manager/expenses/templates/categories/list.html @@ -23,18 +23,22 @@ Categoría - Categoría padre - {% for category in categories %} + {% for row in category_rows %} - {{ category.name }} - {% if category.parent %}{{ category.parent.name }}{% endif %} + {{ row.category.name }} - Editar - Eliminar + Editar + Eliminar + + + {% empty %} + + +

No hay categorías

{% endfor %} diff --git a/expenses_manager/expenses/templates/expenses/account_list.html b/expenses_manager/expenses/templates/expenses/account_list.html index c869f28..c94dbc3 100644 --- a/expenses_manager/expenses/templates/expenses/account_list.html +++ b/expenses_manager/expenses/templates/expenses/account_list.html @@ -18,22 +18,22 @@ - {% for account in accounts %} + {% for row in account_rows %} - {{ account.name }} - {{ account.initial_balance }} - {{ account.current_balance|floatformat:2 }} + {{ row.account.name }} + {{ row.account.initial_balance }} + {{ row.balance|floatformat:2 }} - {% if account.active %} + {% if row.account.active %} Activa {% else %} Inactiva {% endif %} - Editar - {% if account.active %} - Eliminar + Editar + {% if row.account.active %} + Eliminar {% endif %} @@ -48,4 +48,4 @@ -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/expenses_manager/expenses/tests/test_accounts.py b/expenses_manager/expenses/tests/test_accounts.py index d025ad6..c784bc6 100644 --- a/expenses_manager/expenses/tests/test_accounts.py +++ b/expenses_manager/expenses/tests/test_accounts.py @@ -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")} diff --git a/expenses_manager/expenses/tests/test_categories.py b/expenses_manager/expenses/tests/test_categories.py index a8622b0..0d6a6c2 100644 --- a/expenses_manager/expenses/tests/test_categories.py +++ b/expenses_manager/expenses/tests/test_categories.py @@ -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), + ] diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index 6f8e710..f297875 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -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, }, )