diff --git a/expenses_manager/expenses/forms.py b/expenses_manager/expenses/forms.py index 01cdaff..6f012d6 100644 --- a/expenses_manager/expenses/forms.py +++ b/expenses_manager/expenses/forms.py @@ -93,35 +93,19 @@ class CategoryForm(forms.ModelForm): if user: queryset = Category.objects.filter(owner=user) - + if self.instance.pk: queryset = queryset.exclude( - pk__in=self._self_and_descendant_ids(self.instance) + pk__in=self.instance.descendant_ids() ) - + self.fields["parent"].queryset = queryset - - @staticmethod - def _self_and_descendant_ids(category): - ids = [category.pk] - pending = [category.pk] - - while pending: - children = list( - Category.objects.filter(parent_id__in=pending) - .exclude(pk__in=ids) - .values_list("pk", flat=True) - ) - ids.extend(children) - pending = children - - return ids - + def clean_parent(self): parent = self.cleaned_data.get("parent") if parent and self.instance.pk: - if parent.pk in self._self_and_descendant_ids(self.instance): + if parent.pk in self.instance.descendant_ids(): raise forms.ValidationError( "Una categoría no puede ser su propio padre ni depender " "de una de sus subcategorías." @@ -133,12 +117,61 @@ class CategoryForm(forms.ModelForm): class GoalForm(forms.ModelForm): class Meta: model = Goal - fields = ['name', 'target_amount', 'category', "show_on_home"] + fields = [ + 'name', + 'kind', + 'target_amount', + 'category', + "include_subcategories", + "account", + "start_date", + "period", + "show_on_home" + ] + widgets = { + "start_date": forms.DateInput( + format="%Y-%m-%d", attrs={"type": "date"} + ), + } def __init__(self, *args, **kwargs): user = kwargs.pop('user') super().__init__(*args, **kwargs) - self.fields['category'].queryset = ( - user.categories.all() - ) \ No newline at end of file + self.fields['category'].queryset = user.categories.all() + self.fields['account'].queryset = user.accounts.filter(active=True) + + self.fields['category'].required = False + self.fields['account'].required = False + self.fields['start_date'].input_formats = ["%Y-%m-%d"] + + def clean(self): + cleaned = super().clean() + kind = cleaned.get("kind") + + if kind in (Goal.KIND_PAYMENT, Goal.KIND_BUDGET): + if not cleaned.get('category'): + self.add_error( + "category", + "Selecciona una categoría para este tipo de objetivo.", + ) + cleaned["account"] = None + + if kind == Goal.KIND_SAVING: + if not cleaned.get("account"): + self.add_error( + "account", + "Selecciona la cuenta donde se acumula el ahorro.", + ) + cleaned["category"] = None + + if kind == Goal.KIND_BUDGET: + if cleaned.get("period") == Goal.PERIOD_NONE: + self.add_error( + "period", + "Un presupuesto necesita un periodo: mensual o anual", + ) + else: + cleaned["period"] = Goal.PERIOD_NONE + + return cleaned \ No newline at end of file diff --git a/expenses_manager/expenses/migrations/0010_goal_account_goal_include_subcategories_goal_kind_and_more.py b/expenses_manager/expenses/migrations/0010_goal_account_goal_include_subcategories_goal_kind_and_more.py new file mode 100644 index 0000000..29402d0 --- /dev/null +++ b/expenses_manager/expenses/migrations/0010_goal_account_goal_include_subcategories_goal_kind_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 5.2.10 on 2026-07-23 14:23 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('expenses', '0009_goal_show_on_home'), + ] + + operations = [ + migrations.AddField( + model_name='goal', + name='account', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='goals', to='expenses.account'), + ), + migrations.AddField( + model_name='goal', + name='include_subcategories', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='goal', + name='kind', + field=models.CharField(choices=[('payment', 'Pago / deuda'), ('budget', 'Presupuesto'), ('saving', 'Ahorro')], default='payment', max_length=10), + ), + migrations.AddField( + model_name='goal', + name='period', + field=models.CharField(choices=[('none', 'Sin reinicio'), ('month', 'Mensual'), ('year', 'Anual')], default='none', max_length=6), + ), + migrations.AddField( + model_name='goal', + name='start_date', + field=models.DateField(blank=True, null=True), + ), + migrations.AlterField( + model_name='goal', + name='category', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='expenses.category'), + ), + ] diff --git a/expenses_manager/expenses/models.py b/expenses_manager/expenses/models.py index 33b7b57..3f8c995 100644 --- a/expenses_manager/expenses/models.py +++ b/expenses_manager/expenses/models.py @@ -41,6 +41,22 @@ class Category(models.Model): self.slug = slugify(self.name) super().save(*args, **kwargs) + def descendant_ids(self, include_self=True): + """IDs de esta categoría y de toda su descendencia.""" + seen = {self.pk} + pending = [self.pk] + + while pending: + children = list( + Category.objects.filter(parent_id__in=pending) + .exclude(pk__in=seen) + .values_list("pk", flat=True) + ) + seen.update(children) + pending = children + + return list(seen) if include_self else [i for i in seen if i != self.pk] + class Account(models.Model): owner = models.ForeignKey( @@ -264,41 +280,119 @@ class FuelEntry(models.Model): class Goal(models.Model): + KIND_PAYMENT = "payment" + KIND_BUDGET = "budget" + KIND_SAVING = "saving" + + KIND_CHOICES = [ + (KIND_PAYMENT, "Pago / deuda"), + (KIND_BUDGET, "Presupuesto"), + (KIND_SAVING, "Ahorro"), + ] + + PERIOD_NONE = "none" + PERIOD_MONTH = "month" + PERIOD_YEAR = "year" + + PERIOD_CHOICES = [ + (PERIOD_NONE, "Sin reinicio"), + (PERIOD_MONTH, "Mensual"), + (PERIOD_YEAR, "Anual"), + ] + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) name = models.CharField(max_length=100) target_amount = models.DecimalField(max_digits=12, decimal_places=2) - category = models.ForeignKey("Category", on_delete=models.CASCADE) + + kind = models.CharField( + max_length=10, choices=KIND_CHOICES, default=KIND_PAYMENT + ) + + # Para pago y presupuesto + category = models.ForeignKey( + "Category", on_delete=models.CASCADE, null=True, blank=True + ) + include_subcategories = models.BooleanField(default=True) + + # Para ahorro (de momento se mide con el saldo de la cuenta) + account = models.ForeignKey( + "Account", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="goals", + ) + + # Desde cuándo cuenta (pago) / cada cuánto se reinicia (presupuesto) + start_date = models.DateField(null=True, blank=True) + period = models.CharField( + max_length=6, choices=PERIOD_CHOICES, default=PERIOD_NONE + ) + created_at = models.DateTimeField(auto_now_add=True) show_on_home = models.BooleanField(default=False) + def _period_start(self): + """Fecha desde la que se cuenta el progreso, o None si es todo.""" + if self.kind == self.KIND_BUDGET: + today = date.today() + if self.period == self.PERIOD_MONTH: + return date(today.year, today.month, 1) + if self.period == self.PERIOD_YEAR: + return date(today.year, 1, 1) + + return self.start_date + def progress(self): - """ - Calculate the accumulated spending for the goal category. - This method returns the sum of all expenses of the owner in the goal's category. + if self.kind == self.KIND_SAVING: + # Provisional: saldo de la cuenta asociada. Cuando exista el + # módulo de inversiones, esta rama es lo único que hay que tocar. + return self.account.current_balance() if self.account else Decimal("0") - Returns: - Decimal: Total amount spent in the goal category, or Decimal('0') when there is no spending. + if not self.category: + return Decimal("0") - """ - total = Expense.objects.filter( - owner=self.owner, - category=self.category, - ).aggregate(total=Sum("amount"))["total"] + if self.include_subcategories: + category_ids = self.category.descendant_ids() + else: + category_ids = [self.category_id] - return total or 0 + expenses = Expense.objects.filter( + owner=self.owner, category_id__in=category_ids + ) + + start = self._period_start() + if start: + expenses = expenses.filter(date__gte=start) + + return expenses.aggregate(total=Sum("amount"))["total"] or Decimal("0") def percentage(self): - """ - Calculate the completion percentage of the goal. - This method returns how much of the target amount has been reached as a percentage. - - Returns: - Decimal: Percentage of the goal that has been reached, or Decimal('0') when the target amount is zero. - - """ - if self.target_amount == 0: + if not self.target_amount: return 0 return (self.progress() / self.target_amount) * 100 + def bar_width(self): + return min(float(self.percentage()), 100) + + def progress_state(self): + pct = self.percentage() + + if self.kind == self.KIND_BUDGET: + if pct > 100: + return "danger" + if pct >= 80: + return "warning" + return "ok" + + return "complete" if pct >= 100 else "ok" + + def is_exceeded(self): + """True si es un presupuesto que ya se ha pasado del límite.""" + return ( + self.kind == self.KIND_BUDGET + and self.progress() > self.target_amount + ) + def __str__(self): return self.name diff --git a/expenses_manager/expenses/static/expenses/css/base.css b/expenses_manager/expenses/static/expenses/css/base.css index 60255bc..5e47648 100644 --- a/expenses_manager/expenses/static/expenses/css/base.css +++ b/expenses_manager/expenses/static/expenses/css/base.css @@ -518,4 +518,17 @@ tbody tr:hover { background-color: #e6f4ea; border-color: #1e7e34; color: #1e7e34; +} + +.progress-fill.ok { + background-color: #1e88e5; +} +.progress-fill.complete { + background-color: #1e7e34; +} +.progress-fill.warning { + background-color: #f9a825; +} +.progress-fill.danger { + background-color: #b71c1c; } \ No newline at end of file diff --git a/expenses_manager/expenses/templates/goals/list.html b/expenses_manager/expenses/templates/goals/list.html index c544323..861d6a4 100644 --- a/expenses_manager/expenses/templates/goals/list.html +++ b/expenses_manager/expenses/templates/goals/list.html @@ -14,6 +14,7 @@
| Nombre | +Tipo | Progreso | |
|---|---|---|---|
| {{ goal.name }} | ++ {{ goal.get_kind_display }} + {% if goal.kind == "budget" %} + ({{ goal.get_period_display|lower }}) + {% endif %} + |
{{ goal.progress|floatformat:1 }}€ / {{ goal.target_amount|floatformat:1 }}€
- {{ goal.percentage|floatformat:1}}%
+ {{ goal.percentage|floatformat:1 }}%
+ {% if goal.is_exceeded %}
+ Excedido
+ {% endif %}
|