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:
parent
b04a8db080
commit
e65b15ccf8
@ -638,6 +638,12 @@ tr:hover {
|
|||||||
margin-top: 0.5rem;
|
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 {
|
.pagination {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
@ -926,6 +932,7 @@ tr:hover {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.balance-total {
|
.balance-total {
|
||||||
|
|||||||
@ -23,18 +23,22 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Categoría</th>
|
<th>Categoría</th>
|
||||||
<th>Categoría padre</th>
|
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for category in categories %}
|
{% for row in category_rows %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ category.name }}</td>
|
<td class="cat-depth-{{ row.depth }}">{{ row.category.name }}</td>
|
||||||
<td>{% if category.parent %}{{ category.parent.name }}{% endif %}</td>
|
|
||||||
<td class="table-actions">
|
<td class="table-actions">
|
||||||
<a href="{% url 'category_edit' category.id %}">Editar</a>
|
<a href="{% url 'category_edit' row.category.id %}">Editar</a>
|
||||||
<a href="{% url 'category_delete' category.id %}" class="danger">Eliminar</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@ -18,22 +18,22 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for account in accounts %}
|
{% for row in account_rows %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ account.name }}</td>
|
<td>{{ row.account.name }}</td>
|
||||||
<td>{{ account.initial_balance }}</td>
|
<td>{{ row.account.initial_balance }}</td>
|
||||||
<td>{{ account.current_balance|floatformat:2 }}</td>
|
<td>{{ row.balance|floatformat:2 }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if account.active %}
|
{% if row.account.active %}
|
||||||
<span class="badge badge-active">Activa</span>
|
<span class="badge badge-active">Activa</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge badge-inactive">Inactiva</span>
|
<span class="badge badge-inactive">Inactiva</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="table-actions">
|
<td class="table-actions">
|
||||||
<a href="{% url 'account_edit' account.id %}">Editar</a>
|
<a href="{% url 'account_edit' row.account.id %}">Editar</a>
|
||||||
{% if account.active %}
|
{% if row.account.active %}
|
||||||
<a href="{% url 'account_delete' account.id %}" class="danger">Eliminar</a>
|
<a href="{% url 'account_delete' row.account.id %}" class="danger">Eliminar</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -48,4 +48,4 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from django.urls import reverse
|
||||||
from expenses.models import Account, Expense, Income, Category
|
from expenses.models import Account, Expense, Income, Category
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
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))
|
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")
|
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")}
|
||||||
|
|||||||
@ -99,3 +99,20 @@ def test_category_delete_without_expenses_succeeds(auth_client, user):
|
|||||||
|
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
assert not Category.objects.filter(pk=cat.pk).exists()
|
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),
|
||||||
|
]
|
||||||
|
|||||||
@ -57,34 +57,29 @@ def sub_months(year, month, n):
|
|||||||
return year, month
|
return year, month
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
def _category_tree(categories):
|
||||||
def home(request):
|
"""Aplana las categorías en preorden (padres antes que hijos, cada nivel
|
||||||
today = date.today()
|
ordenado por nombre) junto con su profundidad, para poder indentarlas."""
|
||||||
expenses = Expense.objects.filter(owner=request.user)
|
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 ----
|
rows = []
|
||||||
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 ----
|
def walk(parent_id, depth):
|
||||||
prev_year, prev_month = sub_months(today.year, today.month, 1)
|
for category in by_parent.get(parent_id, []):
|
||||||
prev_total = (
|
rows.append({"category": category, "depth": depth})
|
||||||
expenses.filter(date__year=prev_year, date__month=prev_month).aggregate(
|
walk(category.id, depth + 1)
|
||||||
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 ----
|
walk(None, 0)
|
||||||
# Dos queries agregadas en lugar de N+1 (nada de account.current_balance() en bucle).
|
return rows
|
||||||
accounts = list(Account.objects.filter(owner=request.user, active=True))
|
|
||||||
|
|
||||||
|
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]
|
account_ids = [a.id for a in accounts]
|
||||||
|
|
||||||
expense_totals = {
|
expense_totals = {
|
||||||
@ -114,6 +109,38 @@ def home(request):
|
|||||||
if balance < 0:
|
if balance < 0:
|
||||||
negative_accounts.append({"account": acc, "balance": balance})
|
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 ----
|
# ---- Objetivos ----
|
||||||
# Una sola query reutilizada para "goals" (home) y "exceeded_goals" (alertas).
|
# 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.
|
# 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
|
@login_required
|
||||||
def account_list(request):
|
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(
|
return render(
|
||||||
request,
|
request,
|
||||||
"expenses/account_list.html",
|
"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",
|
"categories/list.html",
|
||||||
{
|
{
|
||||||
"active_menu": "settings",
|
"active_menu": "settings",
|
||||||
"categories": categories,
|
"category_rows": _category_tree(categories),
|
||||||
"form": form,
|
"form": form,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user