Compare commits

...

6 Commits

Author SHA1 Message Date
9e43af1bb3 Merge pull request 'Modificación de gráficos' (#43) from dev into main
All checks were successful
Tests / CI / Run tests passed: 342
expenses_manager/pipeline/head This commit looks good
Reviewed-on: #43
2026-10-18 14:02:05 +00:00
1fe9ae31d7 Merge branch 'main' into dev
All checks were successful
Tests / CI / Run tests passed: 342
expenses_manager/pipeline/head This commit looks good
2026-10-18 14:01:49 +00:00
9207580f8a Add expense type to category form and list
All checks were successful
Tests / CI / Run tests passed: 342
expenses_manager/pipeline/head This commit looks good
2026-10-14 20:08:38 +02:00
dec08b6192 Add expense type classification to categories 2026-10-14 20:08:35 +02:00
c058bc2cf1 Merge branch 'dev' of https://gitea.kuijper.es/jkuijperm/expenses_manager into dev
All checks were successful
Tests / CI / Run tests passed: 333
expenses_manager/pipeline/head This commit looks good
2026-10-14 08:53:23 +02:00
0666e17a25 Add smoke test for all views and context key tests 2026-10-14 08:53:16 +02:00
7 changed files with 394 additions and 7 deletions

View File

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

View File

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

View File

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

View File

@ -23,6 +23,7 @@
<thead>
<tr>
<th>Categoría</th>
<th>Tipo de gasto</th>
<th>Gastos</th>
<th></th>
</tr>
@ -36,6 +37,13 @@
<span class="badge badge-neutral">Del sistema</span>
{% endif %}
</td>
<td>
{% if row.expense_type_inherited %}
<span class="muted">{{ row.expense_type_label }}{% if row.expense_type_label %} (heredado){% endif %}</span>
{% else %}
{{ row.expense_type_label }}
{% endif %}
</td>
<td>{{ row.category.expense_count }}</td>
<td class="table-actions">
<a href="{% url 'category_edit' row.category.id %}">Editar</a>
@ -46,7 +54,7 @@
</tr>
{% empty %}
<tr>
<td colspan="3" class="empty-state">
<td colspan="4" class="empty-state">
<p>No hay categorías</p>
</td>
</tr>

View File

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

View File

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

View File

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