Añadidas tandas: 4, 4b y 7 #33
@ -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 {
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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")}
|
||||
|
||||
@ -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),
|
||||
]
|
||||
|
||||
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user