diff --git a/.gitignore b/.gitignore index 45878d3..841b644 100644 --- a/.gitignore +++ b/.gitignore @@ -162,4 +162,7 @@ cython_debug/ #.idea/ staticfiles/ -*.bak \ No newline at end of file +*.bak + +# graphify +graphify-out/ \ No newline at end of file diff --git a/expenses_manager/expenses/forms.py b/expenses_manager/expenses/forms.py index 0cfdb77..6442e32 100644 --- a/expenses_manager/expenses/forms.py +++ b/expenses_manager/expenses/forms.py @@ -25,7 +25,43 @@ def _hide_is_transfer_when_editing(form): form.fields.pop("is_transfer", None) -class ExpenseForm(forms.ModelForm): +class _GoalLinksMixin: + """Campo "Objetivos" en el formulario de un movimiento. + + `goals` es el lado inverso de Goal.linked_expenses / linked_incomes, y un + ModelForm no guarda relaciones inversas: hay que poblarlo al abrir y + escribirlo a mano al guardar. Lo comparten gastos e ingresos para que qué + objetivo admite cada movimiento se decida en un solo sitio + (Goal.KINDS_LINKING_*, en el modelo). + """ + + goal_kinds = () + + def setup_goal_links(self, user): + self.fields["goals"] = forms.ModelMultipleChoiceField( + queryset=Goal.objects.filter(owner=user, kind__in=self.goal_kinds), + required=False, + label="Objetivos", + widget=forms.CheckboxSelectMultiple(), + help_text="Este movimiento cuenta en los objetivos que marques.", + ) + + if self.instance.pk: + self.initial["goals"] = list( + self.instance.goals.values_list("pk", flat=True) + ) + + def _save_m2m(self): + super()._save_m2m() + + # Si el formulario se construyó sin usuario no hay campo, y entonces no + # se toca nada: un set() a ciegas borraría las vinculaciones que ya haya. + if "goals" in self.cleaned_data: + self.instance.goals.set(self.cleaned_data["goals"]) + + +class ExpenseForm(_GoalLinksMixin, forms.ModelForm): + goal_kinds = Goal.KINDS_LINKING_EXPENSES def __init__(self, *args, **kwargs): # Get user @@ -40,6 +76,7 @@ class ExpenseForm(forms.ModelForm): owner=user, active=True ) self.fields["date"].input_formats = ["%Y-%m-%d"] + self.setup_goal_links(user) _hide_is_transfer_when_editing(self) @@ -73,7 +110,9 @@ class AccountForm(forms.ModelForm): fields = ["name", "initial_balance", "active"] -class IncomeForm(forms.ModelForm): +class IncomeForm(_GoalLinksMixin, forms.ModelForm): + goal_kinds = Goal.KINDS_LINKING_INCOMES + class Meta: model = Income fields = ["account", "name", "amount", "date", "is_transfer"] @@ -86,6 +125,7 @@ class IncomeForm(forms.ModelForm): owner=user, active=True, ) + self.setup_goal_links(user) _hide_is_transfer_when_editing(self) @@ -225,6 +265,18 @@ class GoalForm(forms.ModelForm): "Selecciona la cuenta donde se acumula el ahorro.", ) cleaned["category"] = None + + if kind == Goal.KIND_COLLECTION: + # Un cobro pendiente se mide solo con los ingresos que se le + # vinculen, así que no lleva categoría ni cuenta; la fecha de inicio + # sí, porque es la que acota desde cuándo cuentan. + if not cleaned.get("start_date"): + self.add_error( + "start_date", + "Indica desde cuándo se cuentan los cobros.", + ) + cleaned["category"] = None + cleaned["account"] = None if kind == Goal.KIND_BUDGET: if cleaned.get("period") == Goal.PERIOD_NONE: diff --git a/expenses_manager/expenses/migrations/0017_goal_linked_expenses_goal_linked_incomes_and_more.py b/expenses_manager/expenses/migrations/0017_goal_linked_expenses_goal_linked_incomes_and_more.py new file mode 100644 index 0000000..4602438 --- /dev/null +++ b/expenses_manager/expenses/migrations/0017_goal_linked_expenses_goal_linked_incomes_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.10 on 2026-10-08 14:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('expenses', '0016_alter_transfer_expense_leg_alter_transfer_income_leg'), + ] + + operations = [ + migrations.AddField( + model_name='goal', + name='linked_expenses', + field=models.ManyToManyField(blank=True, related_name='goals', to='expenses.expense', verbose_name='Gastos vinculados'), + ), + migrations.AddField( + model_name='goal', + name='linked_incomes', + field=models.ManyToManyField(blank=True, related_name='goals', to='expenses.income', verbose_name='Ingresos vinculados'), + ), + migrations.AlterField( + model_name='goal', + name='kind', + field=models.CharField(choices=[('payment', 'Pago / deuda'), ('budget', 'Presupuesto'), ('saving', 'Ahorro'), ('collection', 'Cobro pendiente')], default='payment', max_length=10), + ), + ] diff --git a/expenses_manager/expenses/models.py b/expenses_manager/expenses/models.py index b6d763b..6ac2249 100644 --- a/expenses_manager/expenses/models.py +++ b/expenses_manager/expenses/models.py @@ -3,7 +3,7 @@ from decimal import Decimal from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db import models, transaction from django.conf import settings -from django.db.models import Sum +from django.db.models import Q, Sum from functools import cached_property from django.utils.text import slugify from django.db.models.functions import ExtractMonth, Lower @@ -551,17 +551,47 @@ class FuelEntry(models.Model): return None +def _deduped_total(movements): + """Suma importes sin que el JOIN del M2M cuente la misma fila dos veces. + + Un aggregate() sobre un queryset que lleva el JOIN de la vinculación dentro + suma cada movimiento tantas veces como objetivos tenga vinculados, y + .distinct() no lo arregla: el DISTINCT se aplica a las filas del SELECT + final y en un aggregate no hay filas que deduplicar, porque el JOIN ya + multiplicó antes de sumar. Sum("amount", distinct=True) es todavía peor, + porque deduplica por el valor del importe: dos gastos distintos de 10 € se + sumarían como uno. + + Así que primero los ids (ahí el DISTINCT sí vale) y luego la suma. El + `pk__in` con un queryset dentro es una subconsulta, no un segundo viaje. + """ + ids = movements.values("pk").distinct() + total = movements.model.objects.filter(pk__in=ids).aggregate( + total=Sum("amount") + )["total"] + + return total or Decimal("0") + + class Goal(models.Model): KIND_PAYMENT = "payment" KIND_BUDGET = "budget" KIND_SAVING = "saving" + KIND_COLLECTION = "collection" KIND_CHOICES = [ (KIND_PAYMENT, "Pago / deuda"), (KIND_BUDGET, "Presupuesto"), (KIND_SAVING, "Ahorro"), + (KIND_COLLECTION, "Cobro pendiente"), ] + # Qué objetivos admiten vincular cada tipo de movimiento. Las dos vías de + # marcado (el formulario del movimiento y la pantalla del objetivo) leen de + # aquí: si cada una lo decidiera por su cuenta, acabarían divergiendo. + KINDS_LINKING_EXPENSES = (KIND_PAYMENT, KIND_BUDGET) + KINDS_LINKING_INCOMES = (KIND_COLLECTION,) + PERIOD_NONE = "none" PERIOD_MONTH = "month" PERIOD_YEAR = "year" @@ -604,6 +634,57 @@ class Goal(models.Model): created_at = models.DateTimeField(auto_now_add=True) show_on_home = models.BooleanField(default=False) + # Movimientos sueltos vinculados a mano. Conviven con la selección por + # categoría, no la sustituyen: un objetivo puede tirar de categoría, de + # movimientos sueltos, o de los dos a la vez. + # + # blank=True y no null: un M2M no admite null. Borrar el objetivo limpia la + # tabla intermedia y no toca los movimientos, que es lo que se quiere. + linked_expenses = models.ManyToManyField( + "Expense", blank=True, related_name="goals", verbose_name="Gastos vinculados" + ) + linked_incomes = models.ManyToManyField( + "Income", blank=True, related_name="goals", verbose_name="Ingresos vinculados" + ) + + @property + def links_incomes(self): + """Si este objetivo se mide con ingresos en vez de con gastos.""" + return self.kind in self.KINDS_LINKING_INCOMES + + @property + def links(self): + """El M2M que le toca a este objetivo, para no repetir el if por ahí.""" + return self.linked_incomes if self.links_incomes else self.linked_expenses + + @property + def accepts_links(self): + """Si vincularle movimientos significa algo. + + El ahorro no: se mide con el saldo de su cuenta, así que un gasto + vinculado ahí quedaría guardado y no contaría en el progreso. + """ + return self.kind in self.KINDS_LINKING_EXPENSES + self.KINDS_LINKING_INCOMES + + def candidate_movements(self): + """Movimientos que este objetivo puede vincular. + + Único sitio donde se decide qué es vinculable; lo usan las dos vías de + marcado. Fuera las patas de un traspaso: mover dinero entre cuentas + propias no es ni un pago ni un cobro. La comisión sí entra, porque es + gasto real (va con is_transfer=False) y cuenta en los análisis. + """ + model = Income if self.links_incomes else Expense + movements = model.objects.filter(owner_id=self.owner_id).exclude( + is_transfer=True + ) + + start = self._period_start() + if start: + movements = movements.filter(date__gte=start) + + return movements + def _period_start(self): """Fecha desde la que se cuenta el progreso, o None si es todo.""" if self.kind == self.KIND_BUDGET: @@ -632,25 +713,46 @@ class Goal(models.Model): # así que no filtres is_transfer en esta rama. return self.account.current_balance() if self.account else Decimal("0") - if not self.category: - return Decimal("0") + if self.kind == self.KIND_COLLECTION: + # Un cobro pendiente mide lo que te han devuelto, así que suma los + # ingresos vinculados a mano. Aquí no hay selección por categoría + # que valga: Income no tiene categoría, que es justo el motivo de + # que exista la vinculación. + incomes = Income.objects.filter(goals=self, owner_id=self.owner_id).exclude( + is_transfer=True + ) - if self.include_subcategories: - category_ids = self.category.descendant_ids() - else: - category_ids = [self.category_id] + return _deduped_total(self._since_start(incomes)) + + category_ids = [] + # Por _id y no por el objeto: mirar self.category traería la categoría + # de la base de datos, una consulta por objetivo al pintar una lista. + if self.category_id: + if self.include_subcategories: + category_ids = self.category.descendant_ids() + else: + category_ids = [self.category_id] + + # Un solo queryset con un OR, y no dos sumas por separado: un gasto que + # esté en la categoría del objetivo Y además vinculado a mano se + # contaría dos veces. No daría error, daría un número mal. + scope = Q(goals=self) + if category_ids: + scope |= Q(category_id__in=category_ids) # Pago y presupuesto miden gasto real, así que los traspasos entre # cuentas propias no cuentan. La rama de ahorro de arriba es la opuesta. - expenses = Expense.objects.filter( - owner=self.owner, category_id__in=category_ids - ).exclude(is_transfer=True) + expenses = Expense.objects.filter(scope, owner_id=self.owner_id).exclude( + is_transfer=True + ) + return _deduped_total(self._since_start(expenses)) + + def _since_start(self, movements): + """Acota al periodo del objetivo. El cobro pendiente lo necesita igual.""" start = self._period_start() - if start: - expenses = expenses.filter(date__gte=start) - return expenses.aggregate(total=Sum("amount"))["total"] or Decimal("0") + return movements.filter(date__gte=start) if start else movements def percentage(self): if not self.target_amount: diff --git a/expenses_manager/expenses/templates/expenses/_form_fields.html b/expenses_manager/expenses/templates/expenses/_form_fields.html index d4a3cd3..68792d5 100644 --- a/expenses_manager/expenses/templates/expenses/_form_fields.html +++ b/expenses_manager/expenses/templates/expenses/_form_fields.html @@ -1,6 +1,6 @@ {% for field in form %}
- {% if field.name == "tags" %} + {% if field.name == "tags" or field.name == "goals" %} {{ field.label_tag }}
{% for checkbox in field %} diff --git a/expenses_manager/expenses/templates/goals/form.html b/expenses_manager/expenses/templates/goals/form.html index 531e076..11c5d62 100644 --- a/expenses_manager/expenses/templates/goals/form.html +++ b/expenses_manager/expenses/templates/goals/form.html @@ -25,7 +25,8 @@ Para ponerte un límite de gasto que no quieres superar. El progreso se reinicia cada mes o cada año, según el periodo que elijas. Aquí acercarse al 100% es un aviso, no un logro: la barra pasa a - naranja al llegar al 80% y a rojo si te pasas del límite. + naranja al llegar al 80% y a rojo si te pasas del límite. Cuenta + los gastos de su categoría y además los que le vincules a mano.

Ahorro

@@ -35,6 +36,46 @@ categoría.

+

Cobro pendiente

+

+ El inverso del de pago: para dinero que te tienen que devolver y vas + cobrando a plazos. El progreso es la suma de los ingresos que le + vincules desde la fecha de inicio, y llegar al 100% es haberlo + cobrado todo. No lleva categoría ni cuenta, porque los ingresos no + tienen categoría: se mide solo con los ingresos que marques. +

+ +

Vincular movimientos

+

+ Además de contar una categoría entera, un objetivo puede contar + gastos o ingresos concretos. Se marcan de dos maneras: con el campo + Objetivos del formulario del gasto o del ingreso, o + desde Vincular en la lista de objetivos, que permite + marcar varios de golpe. +

+

+ Lo admiten tres tipos, y cada uno vincula lo que mide: +

+ +

+ La categoría y las vinculaciones conviven: un presupuesto de ocio + puede contar su categoría y además un gasto suelto de otra que + quieras que compute. Un gasto que esté en la categoría y también + vinculado a mano cuenta una sola vez. +

+

+ Los dos movimientos de un traspaso no se pueden vincular (mover + dinero entre tus cuentas no es un gasto ni un cobro), pero su + comisión sí, porque es gasto real. +

+

Los demás campos