From 0666e17a2595d2c3b8bd8780f410d9c469f16c73 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 14 Oct 2026 08:53:16 +0200 Subject: [PATCH 1/3] Add smoke test for all views and context key tests --- expenses_manager/expenses/tests/test_smoke.py | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 expenses_manager/expenses/tests/test_smoke.py diff --git a/expenses_manager/expenses/tests/test_smoke.py b/expenses_manager/expenses/tests/test_smoke.py new file mode 100644 index 0000000..6fa49ae --- /dev/null +++ b/expenses_manager/expenses/tests/test_smoke.py @@ -0,0 +1,202 @@ +"""Red de seguridad: que todas las vistas rendericen y que el contexto no pierda claves. + +Dos agujeros que ni `manage.py check` ni el resto de la suite cubren: + +1. Un `{% url %}` mal escrito o una plantilla que falta solo revienta al pedir + la página. Es lo que pasó con `settings/index.html`, que estuvo roto sin que + nada avisara. +2. Un refactor que se lleva una clave del contexto por delante. Ya pasó dos + veces: `by_month` renombrado a `chart_data`, y `last_expenses` sustituido por + `movements`. Las claves que usan muchos tests están cubiertas de rebote; las + que se usan en un solo sitio se pierden en silencio. +""" + +import pytest +from django.urls import reverse + +from expenses import urls as expenses_urls + +pytestmark = pytest.mark.django_db + + +# -------------------------------------------------------------------------- +# 1. Smoke test de renderizado +# -------------------------------------------------------------------------- + +# Rutas que a propósito NO devuelven 200. Van una por línea con su motivo: la +# alternativa (aceptar cualquier 2xx/3xx) haría pasar el test aunque la vista +# estuviera rota, que es justo lo contrario de lo que se busca aquí. +NOT_RENDERABLE = { + # Endpoint de descarga, no página: valida ExportForm(request.GET) y sin + # `kind` ni `fmt` responde 400 por diseño. Su cobertura está en test_export. + "export_download", +} + + +def _argument_free_route_names(): + """Rutas del URLconf que se pueden pedir sin argumentos. + + Se saca del URLconf y no de una lista escrita a mano: así una vista nueva + entra en el smoke test el día que se añade, sin que nadie se acuerde. Una + lista a mano se queda desfasada y el test deja de servir en silencio. + """ + return [ + pattern.name + for pattern in expenses_urls.urlpatterns + if pattern.pattern.regex.groups == 0 + and pattern.name + and pattern.name not in NOT_RENDERABLE + ] + + +ROUTE_NAMES = _argument_free_route_names() + + +def test_the_route_list_is_not_empty(): + # Si el recorrido del URLconf se rompiera, el parametrize saldría vacío y + # los tests de abajo "pasarían" sin pedir una sola página. + assert len(ROUTE_NAMES) > 15 + + +@pytest.mark.parametrize("route_name", ROUTE_NAMES) +def test_every_view_renders(auth_client, route_name): + # Una ruta por caso para que el fallo diga qué vista se rompió. + response = auth_client.get(reverse(route_name)) + + assert response.status_code == 200 + + +@pytest.mark.parametrize("route_name", sorted(NOT_RENDERABLE)) +def test_the_excluded_routes_are_still_excluded_for_a_reason(auth_client, route_name): + # Si una excluida empieza a devolver 200, la exclusión sobra y hay que + # quitarla: así la lista no se queda con motivos caducados dentro. + response = auth_client.get(reverse(route_name)) + + assert response.status_code != 200 + + +# -------------------------------------------------------------------------- +# 2. Claves del contexto +# -------------------------------------------------------------------------- + +DASHBOARD_CONTEXT_KEYS = { + # Navegación y fecha de referencia + "active_menu", + "today", + # KPIs + "kpi_total", + "kpi_count", + "kpi_categories", + "kpi_balance", + # Comparativa con el periodo anterior + "compare_enabled", + "compare_suppressed", + "kpi_previous_total", + "kpi_difference", + "kpi_difference_abs", + "kpi_percentage", + "kpi_trend", + "category_comparison", + # Gráficos: eje compartido, serie de gastos, serie de ingresos y desglose + "chart_labels", + "chart_data", + "chart_income_data", + "chart_type", + "by_category", + "by_category_chart", + "accounts_charts", + # Filtros de periodo, cuenta y rango libre + "period", + "year_list", + "months", + "selected_year", + "selected_month", + "date_from", + "date_to", + "range_active", + "accounts", + "selected_account", + "selected_account_obj", + # Objetivos y listado reciente + "goals", + "recent_expenses", + # Medias y proyección + "daily_average", + "projected_end_of_month", +} + +HOME_CONTEXT_KEYS = { + "active_menu", + # KPIs del mes y su comparación con el anterior + "kpi_total", + "kpi_count", + "kpi_categories", + "prev_total", + "diff_amount", + "diff_pct", + # Saldos y alertas + "total_balance", + "account_balances", + "negative_accounts", + "has_alerts", + # Objetivos + "goals", + "exceeded_goals", + # Movimientos recientes y la mini gráfica + "movements", + "mini_chart_labels", + "mini_chart_data", +} + + +def _assert_context_has(response, expected_keys): + """Inclusión, nunca igualdad. + + Lo que hay que detectar es que falte una clave. Que aparezca una nueva es lo + normal al añadir una funcionalidad, así que endurecer esto a igualdad exacta + daría un test que falla cada vez que alguien amplía la vista, y acabaría + borrado por pesado. No lo cambies a ==. + """ + missing = sorted(key for key in expected_keys if key not in response.context) + + assert not missing, "faltan claves en el contexto: %s" % ", ".join(missing) + + +def test_dashboard_context_keeps_all_its_keys(auth_client): + response = _dashboard(auth_client) + + _assert_context_has(response, DASHBOARD_CONTEXT_KEYS) + + +def test_dashboard_context_keeps_its_keys_with_comparison_on(auth_client): + # La comparativa rellena claves que en el camino normal se quedan a cero. + response = _dashboard(auth_client, compare="1") + + _assert_context_has(response, DASHBOARD_CONTEXT_KEYS) + + +def test_dashboard_context_keeps_its_keys_with_a_free_range(auth_client): + # El rango libre entra por otra rama del eje, y es la que trae + # compara_suppressed y deja projected_end_of_month a cero. + response = _dashboard(auth_client, date_from="2024-03-01", date_to="2024-03-30") + + _assert_context_has(response, DASHBOARD_CONTEXT_KEYS) + + +def test_dashboard_context_keeps_its_keys_with_a_preset(auth_client): + response = _dashboard(auth_client, period="this_month") + + _assert_context_has(response, DASHBOARD_CONTEXT_KEYS) + + +def test_home_context_keeps_all_its_keys(auth_client): + response = auth_client.get(reverse("home")) + + _assert_context_has(response, HOME_CONTEXT_KEYS) + + +def _dashboard(client, **params): + response = client.get(reverse("dashboard"), params) + + assert response.status_code == 200 + return response From dec08b61928cd61bc44f7dc2f5c9d6f3d3486c79 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 14 Oct 2026 20:08:35 +0200 Subject: [PATCH 2/3] Add expense type classification to categories --- .../migrations/0018_category_expense_type.py | 18 +++++++++ expenses_manager/expenses/models.py | 40 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 expenses_manager/expenses/migrations/0018_category_expense_type.py diff --git a/expenses_manager/expenses/migrations/0018_category_expense_type.py b/expenses_manager/expenses/migrations/0018_category_expense_type.py new file mode 100644 index 0000000..f7bed73 --- /dev/null +++ b/expenses_manager/expenses/migrations/0018_category_expense_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.10 on 2026-10-14 16:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('expenses', '0017_goal_linked_expenses_goal_linked_incomes_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='category', + name='expense_type', + field=models.CharField(blank=True, choices=[('fixed_essential', 'Fijo esencial'), ('fixed_discretionary', 'Fijo prescindible'), ('variable_essential', 'Variable esencial'), ('variable_discretionary', 'Variable prescindible')], help_text='Si lo dejas vacío, hereda el de la categoría padre.', max_length=30, verbose_name='Tipo de gasto'), + ), + ] diff --git a/expenses_manager/expenses/models.py b/expenses_manager/expenses/models.py index 6ac2249..eb0ca44 100644 --- a/expenses_manager/expenses/models.py +++ b/expenses_manager/expenses/models.py @@ -10,6 +10,22 @@ from django.db.models.functions import ExtractMonth, Lower class Category(models.Model): + EXPENSE_TYPE_FIXED_ESSENTIAL = "fixed_essential" + EXPENSE_TYPE_FIXED_DISCRETIONARY = "fixed_discretionary" + EXPENSE_TYPE_VARIABLE_ESSENTIAL = "variable_essential" + EXPENSE_TYPE_VARIABLE_DISCRETIONARY = "variable_discretionary" + + EXPENSE_TYPE_CHOICES = [ + (EXPENSE_TYPE_FIXED_ESSENTIAL, "Fijo esencial"), + (EXPENSE_TYPE_FIXED_DISCRETIONARY, "Fijo prescindible"), + (EXPENSE_TYPE_VARIABLE_ESSENTIAL, "Variable esencial"), + (EXPENSE_TYPE_VARIABLE_DISCRETIONARY, "Variable prescindible"), + ] + + # Saltos máximos al heredar el tipo de gasto. Los ciclos los impide + # CategoryForm, pero uno colado por shell o migración no debe colgar nada. + EXPENSE_TYPE_MAX_DEPTH = 10 + name = models.CharField(max_length=100) slug = models.SlugField(blank=True) @@ -32,6 +48,14 @@ class Category(models.Model): help_text="Categoría creada por la app (traspasos, comisiones). No borrable.", ) + expense_type = models.CharField( + max_length=30, + choices=EXPENSE_TYPE_CHOICES, + blank=True, + verbose_name="Tipo de gasto", + help_text="Si lo dejas vacío, hereda el de la categoría padre.", + ) + class Meta: unique_together = ("name", "parent", "owner", "slug") verbose_name_plural = "categories" @@ -63,6 +87,22 @@ class Category(models.Model): return list(seen) if include_self else [i for i in seen if i != self.pk] + @property + def effective_expense_type(self): + """Tipo de gasto propio o, si está vacío, el del ancestro más cercano + que lo tenga. Cadena vacía si nadie en la cadena lo tiene. + + Cada salto lee self.parent: no lo uses por fila en un listado (N+1). + El listado de categorías lo resuelve en _category_tree.""" + category = self + for _ in range(self.EXPENSE_TYPE_MAX_DEPTH): + if category is None: + return "" + if category.expense_type: + return category.expense_type + category = category.parent + return "" + class Account(models.Model): owner = models.ForeignKey( From 9207580f8a30332ebfa26b7891179b67f5ded3da Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 14 Oct 2026 20:08:38 +0200 Subject: [PATCH 3/3] Add expense type to category form and list --- expenses_manager/expenses/forms.py | 2 +- .../expenses/templates/categories/list.html | 10 +- .../expenses/tests/test_categories.py | 109 ++++++++++++++++++ expenses_manager/expenses/views.py | 20 +++- 4 files changed, 134 insertions(+), 7 deletions(-) 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