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