Añadidas tandas: 4, 4b y 7 #33

Merged
jkuijperm merged 13 commits from dev into main 2026-09-09 14:29:29 +00:00
6 changed files with 114 additions and 43 deletions
Showing only changes of commit e65b15ccf8 - Show all commits

View File

@ -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 {

View File

@ -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 %}

View File

@ -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>

View File

@ -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")}

View File

@ -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),
]

View File

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