Compare commits
2 Commits
c058bc2cf1
...
9207580f8a
| Author | SHA1 | Date | |
|---|---|---|---|
| 9207580f8a | |||
| dec08b6192 |
@ -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")
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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(
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 == ""
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user