Compare commits

...

2 Commits

Author SHA1 Message Date
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
6 changed files with 192 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

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