diff --git a/expenses_manager/expenses/forms.py b/expenses_manager/expenses/forms.py index 6442e32..a8394a9 100644 --- a/expenses_manager/expenses/forms.py +++ b/expenses_manager/expenses/forms.py @@ -186,7 +186,7 @@ class FuelEntryForm(forms.ModelForm): class CategoryForm(forms.ModelForm): class Meta: model = Category - fields = ["name", "parent"] + fields = ["name", "parent", "expense_type"] def __init__(self, *args, **kwargs): user = kwargs.pop("user") diff --git a/expenses_manager/expenses/templates/categories/list.html b/expenses_manager/expenses/templates/categories/list.html index cb2ba81..ef53431 100644 --- a/expenses_manager/expenses/templates/categories/list.html +++ b/expenses_manager/expenses/templates/categories/list.html @@ -23,6 +23,7 @@ Categoría + Tipo de gasto Gastos @@ -36,6 +37,13 @@ Del sistema {% endif %} + + {% if row.expense_type_inherited %} + {{ row.expense_type_label }}{% if row.expense_type_label %} (heredado){% endif %} + {% else %} + {{ row.expense_type_label }} + {% endif %} + {{ row.category.expense_count }} Editar @@ -46,7 +54,7 @@ {% empty %} - +

No hay categorías

diff --git a/expenses_manager/expenses/tests/test_categories.py b/expenses_manager/expenses/tests/test_categories.py index 33a6329..70bdafd 100644 --- a/expenses_manager/expenses/tests/test_categories.py +++ b/expenses_manager/expenses/tests/test_categories.py @@ -2,6 +2,8 @@ import pytest from datetime import date from decimal import Decimal from django.contrib.messages import get_messages +from django.db import connection +from django.test.utils import CaptureQueriesContext from django.urls import reverse from django.utils.text import slugify from expenses.models import Category, Expense @@ -159,3 +161,110 @@ def test_category_list_counts_only_direct_expenses(auth_client, user, account): for row in response.context["category_rows"] } assert counts == {"Padre": 1, "Hija": 2} + + +FIXED = Category.EXPENSE_TYPE_FIXED_ESSENTIAL +VARIABLE = Category.EXPENSE_TYPE_VARIABLE_DISCRETIONARY + + +def test_effective_expense_type_returns_own_value(user): + cat = Category.objects.create(name="Alquiler", owner=user, expense_type=FIXED) + + assert cat.effective_expense_type == FIXED + + +def test_effective_expense_type_inherits_from_grandparent(user): + grandparent = Category.objects.create(name="Casa", owner=user, expense_type=FIXED) + parent = Category.objects.create(name="Suministros", owner=user, parent=grandparent) + child = Category.objects.create(name="Luz", owner=user, parent=parent) + + assert parent.effective_expense_type == FIXED + assert child.effective_expense_type == FIXED + + +def test_effective_expense_type_own_value_wins_over_parent(user): + parent = Category.objects.create(name="Casa", owner=user, expense_type=FIXED) + child = Category.objects.create(name="Decoración", owner=user, parent=parent, expense_type=VARIABLE) + + assert child.effective_expense_type == VARIABLE + + +def test_effective_expense_type_empty_without_value_in_chain(user): + orphan = Category.objects.create(name="Suelta", owner=user) + parent = Category.objects.create(name="Padre", owner=user) + child = Category.objects.create(name="Hija", owner=user, parent=parent) + + assert orphan.effective_expense_type == "" + assert child.effective_expense_type == "" + + +def test_effective_expense_type_stops_on_cycle(user): + a = Category.objects.create(name="A", owner=user) + b = Category.objects.create(name="B", owner=user, parent=a) + # Ciclo colado saltándose CategoryForm, como podría pasar desde el shell. + Category.objects.filter(pk=a.pk).update(parent=b) + a.refresh_from_db() + + assert a.effective_expense_type == "" + + +def test_category_list_shows_own_and_inherited_expense_type(auth_client, user): + parent = Category.objects.create(name="Casa", owner=user, expense_type=FIXED) + child = Category.objects.create(name="Luz", owner=user, parent=parent) + Category.objects.create(name="Nieto", owner=user, parent=child, expense_type=VARIABLE) + Category.objects.create(name="Suelta", owner=user) + + response = auth_client.get(reverse('category_list')) + + rows = { + row["category"].name: (row["expense_type_label"], row["expense_type_inherited"]) + for row in response.context["category_rows"] + } + assert rows == { + "Casa": ("Fijo esencial", False), + "Luz": ("Fijo esencial", True), + "Nieto": ("Variable prescindible", False), + "Suelta": ("", True), + } + assert "(heredado)" in response.content.decode() + + +def _category_list_queries(client): + with CaptureQueriesContext(connection) as ctx: + client.get(reverse('category_list')) + return len(ctx.captured_queries) + + +def test_category_list_query_count_does_not_grow_with_rows(auth_client, user): + root = Category.objects.create(name="Raíz", owner=user, expense_type=FIXED) + Category.objects.create(name="Hija", owner=user, parent=root) + baseline = _category_list_queries(auth_client) + + parent = root + for i in range(5): + parent = Category.objects.create(name=f"Nivel {i}", owner=user, parent=parent) + Category.objects.create(name=f"Hoja {i}", owner=user, parent=parent) + + assert _category_list_queries(auth_client) == baseline + + +def test_categoryform_saves_expense_type(user): + form = CategoryForm(data={'name': 'Netflix', 'expense_type': Category.EXPENSE_TYPE_FIXED_DISCRETIONARY}, user=user) + assert form.is_valid(), form.errors + cat = form.save(commit=False) + cat.owner = user + cat.save() + + cat.refresh_from_db() + assert cat.expense_type == Category.EXPENSE_TYPE_FIXED_DISCRETIONARY + + +def test_categoryform_leaves_expense_type_empty_when_not_chosen(user): + form = CategoryForm(data={'name': 'Varios', 'expense_type': ''}, user=user) + assert form.is_valid(), form.errors + cat = form.save(commit=False) + cat.owner = user + cat.save() + + cat.refresh_from_db() + assert cat.expense_type == "" diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index cdfd28b..28c251d 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -125,21 +125,31 @@ def sub_months(year, month, n): 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.""" + ordenado por nombre) junto con su profundidad, para poder indentarlas. + + También resuelve el tipo de gasto heredado bajando por el árbol ya + cargado, en vez de Category.effective_expense_type por fila (N+1).""" 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.lower()) + labels = dict(Category.EXPENSE_TYPE_CHOICES) rows = [] - def walk(parent_id, depth): + def walk(parent_id, depth, parent_type): for category in by_parent.get(parent_id, []): - rows.append({"category": category, "depth": depth}) - walk(category.id, depth + 1) + expense_type = category.expense_type or parent_type + rows.append({ + "category": category, + "depth": depth, + "expense_type_label": labels.get(expense_type, ""), + "expense_type_inherited": not category.expense_type, + }) + walk(category.id, depth + 1, expense_type) - walk(None, 0) + walk(None, 0, "") return rows