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(