From a5f1c7b59053288b3985ee8f4c904b1e5c3a04dc Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Tue, 13 Oct 2026 13:37:47 +0200 Subject: [PATCH] Add linking UI for goals from movement form and goal page --- expenses_manager/expenses/forms.py | 56 +++++++++- .../templates/expenses/_form_fields.html | 2 +- .../expenses/templates/goals/form.html | 47 +++++++- .../expenses/templates/goals/links.html | 102 ++++++++++++++++++ .../expenses/templates/goals/list.html | 3 + expenses_manager/expenses/urls.py | 1 + expenses_manager/expenses/views.py | 65 ++++++++++- 7 files changed, 269 insertions(+), 7 deletions(-) create mode 100644 expenses_manager/expenses/templates/goals/links.html 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/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: +

+
    +
  • Pago / deuda y presupuesto — gastos.
  • +
  • Cobro pendiente — ingresos.
  • +
  • + Ahorro — no admite vinculación: se mide con el + saldo de su cuenta, así que un gasto marcado ahí no contaría. +
  • +
+

+ 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

  • @@ -51,8 +92,8 @@
  • Start date — desde cuándo se cuentan los gastos - en un pago. En un presupuesto no se usa: manda el reinicio del - periodo. + en un pago, o los ingresos en un cobro pendiente. En un + presupuesto no se usa: manda el reinicio del periodo.
  • Period — solo para presupuesto: cada cuánto diff --git a/expenses_manager/expenses/templates/goals/links.html b/expenses_manager/expenses/templates/goals/links.html new file mode 100644 index 0000000..fe7d261 --- /dev/null +++ b/expenses_manager/expenses/templates/goals/links.html @@ -0,0 +1,102 @@ +{% extends "expenses/base.html" %} + +{% block title %} + Vincular movimientos +{% endblock %} + +{% block content %} +

    Vincular movimientos

    + +

    + Objetivo: {{ goal.name }} ({{ goal.get_kind_display|lower }}). + {% if goal.links_incomes %} + Marca los ingresos que cuentan como cobrados. + {% else %} + Marca los gastos que cuentan en este objetivo. + {% endif %} +

    + +

    + Vinculado ahora mismo: {{ linked_total|floatformat:2 }}€ + de {{ goal.target_amount|floatformat:2 }}€. +

    + +{% if goal.start_date %} +

    Solo se listan movimientos desde el {{ goal.start_date|date:"d/m/Y" }}.

    +{% endif %} + +
    + {% csrf_token %} + +
    + + + + + + + + + + + + {% for movement in page_obj %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
    FechaImporteCuentaConcepto
    + {# shown viaja con cada fila de la página: así al guardar solo #} + {# se toca lo que había aquí y no lo marcado en otras páginas. #} + + + + {{ movement.date|date:"d/m/Y" }}{{ movement.amount|floatformat:2 }}€{{ movement.account.name }} + {% if goal.links_incomes %} + {{ movement.name }} + {% else %} + {{ movement.description|default:movement.category.name }} + {% endif %} +
    +

    No hay movimientos que vincular

    +
    +
    + +
    + + Volver +
    +
    + + + +{% endblock %} diff --git a/expenses_manager/expenses/templates/goals/list.html b/expenses_manager/expenses/templates/goals/list.html index 66a4ee8..4f0386e 100644 --- a/expenses_manager/expenses/templates/goals/list.html +++ b/expenses_manager/expenses/templates/goals/list.html @@ -76,6 +76,9 @@ Editar + {% if goal.accepts_links %} + Vincular + {% endif %} Eliminar diff --git a/expenses_manager/expenses/urls.py b/expenses_manager/expenses/urls.py index 252b3d7..5ce7ce6 100644 --- a/expenses_manager/expenses/urls.py +++ b/expenses_manager/expenses/urls.py @@ -39,5 +39,6 @@ urlpatterns = [ path('goals/new/', views.goal_create, name='goal_create'), path('goals//edit/', views.goal_edit, name='goal_edit'), path('goals//delete/', views.goal_delete, name='goal_delete'), + path('goals//links/', views.goal_links, name='goal_links'), path('password_help', TemplateView.as_view(template_name="registration/password_help.html"), name="password_help"), ] \ No newline at end of file diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index 560d191..30fad4e 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -1107,6 +1107,9 @@ def income_create(request): income = form.save(commit=False) income.owner = request.user income.save() + # Hace falta por el campo de objetivos: sin esto la vinculación + # hecha al crear se perdería sin avisar. + form.save_m2m() messages.success(request, "Ingreso creado correctamente.") return redirect("income_list") else: @@ -1486,7 +1489,9 @@ def category_delete(request, pk): @login_required def goal_list(request): - goals = Goal.objects.filter(owner=request.user) + goals = Goal.objects.filter(owner=request.user).select_related( + "category", "account" + ) selected_kind = request.GET.get("kind") or "" if selected_kind in dict(Goal.KIND_CHOICES): @@ -1578,6 +1583,64 @@ def goal_delete(request, pk): ) +@login_required +def goal_links(request, pk): + """Vincular movimientos a un objetivo de golpe, desde su ficha. + + La otra vía de marcado es el campo "Objetivos" del formulario del + movimiento. Las dos escriben en la misma tabla y las dos preguntan a + Goal.candidate_movements() qué es vinculable. + """ + goal = get_object_or_404(Goal, pk=pk, owner=request.user) + + if not goal.accepts_links: + messages.error( + request, + "Un objetivo de ahorro se mide con el saldo de su cuenta, así que no " + "se le vinculan movimientos.", + ) + return redirect("goal_list") + + candidates = goal.candidate_movements() + + if request.method == "POST": + shown = {int(v) for v in request.POST.getlist("shown") if v.isdigit()} + checked = {int(v) for v in request.POST.getlist("movements") if v.isdigit()} + + # Se parte de lo que de verdad podía marcarse en la página enviada: así + # un id de otro usuario o de un movimiento no vinculable se cae solo. + allowed = set( + candidates.filter(pk__in=shown).values_list("pk", flat=True) + ) + # Solo se toca lo de esa página: con paginación, un set() a secas + # desvincularía lo marcado en las demás. + goal.links.add(*(checked & allowed)) + goal.links.remove(*(allowed - checked)) + + messages.success(request, "Movimientos vinculados actualizados.") + return redirect("goal_links", pk=goal.pk) + + movements = candidates.select_related("account").order_by("-date", "-id") + paginator = Paginator(movements, 10) + page_obj = paginator.get_page(request.GET.get("page")) + + return render( + request, + "goals/links.html", + { + "active_menu": "settings", + "goal": goal, + "page_obj": page_obj, + "linked_ids": set(goal.links.values_list("pk", flat=True)), + # Solo lo vinculado a mano, que es lo que se está tocando aquí; el + # progreso del objetivo puede llevar además los gastos de su + # categoría. + "linked_total": goal.links.aggregate(total=Sum("amount"))["total"] + or Decimal("0"), + }, + ) + + @login_required def settings_index(request): return render(request, "settings/index.html", {"active_menu": "settings"})