Add linking UI for goals from movement form and goal page

This commit is contained in:
JKuijperM 2026-10-13 13:37:47 +02:00
parent 4167338a6a
commit a5f1c7b590
7 changed files with 269 additions and 7 deletions

View File

@ -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:

View File

@ -1,6 +1,6 @@
{% for field in form %}
<div class="form-field {% if field.field.widget.input_type == 'checkbox' %}form-field-checkbox{% endif %}">
{% if field.name == "tags" %}
{% if field.name == "tags" or field.name == "goals" %}
{{ field.label_tag }}
<div class="tag-chip-list">
{% for checkbox in field %}

View File

@ -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.
</p>
<h2>Ahorro</h2>
@ -35,6 +36,46 @@
categoría.
</p>
<h2>Cobro pendiente</h2>
<p>
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.
</p>
<h2>Vincular movimientos</h2>
<p>
Además de contar una categoría entera, un objetivo puede contar
gastos o ingresos concretos. Se marcan de dos maneras: con el campo
<strong>Objetivos</strong> del formulario del gasto o del ingreso, o
desde <strong>Vincular</strong> en la lista de objetivos, que permite
marcar varios de golpe.
</p>
<p>
Lo admiten tres tipos, y cada uno vincula lo que mide:
</p>
<ul>
<li><strong>Pago / deuda</strong> y <strong>presupuesto</strong> — gastos.</li>
<li><strong>Cobro pendiente</strong> — ingresos.</li>
<li>
<strong>Ahorro</strong> — no admite vinculación: se mide con el
saldo de su cuenta, así que un gasto marcado ahí no contaría.
</li>
</ul>
<p>
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.
</p>
<p>
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.
</p>
<h2>Los demás campos</h2>
<ul>
<li>
@ -51,8 +92,8 @@
</li>
<li>
<strong>Start date</strong> — 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.
</li>
<li>
<strong>Period</strong> — solo para presupuesto: cada cuánto

View File

@ -0,0 +1,102 @@
{% extends "expenses/base.html" %}
{% block title %}
Vincular movimientos
{% endblock %}
{% block content %}
<h1>Vincular movimientos</h1>
<p>
Objetivo: <strong>{{ goal.name }}</strong> ({{ 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 %}
</p>
<p>
Vinculado ahora mismo: <strong>{{ linked_total|floatformat:2 }}€</strong>
de {{ goal.target_amount|floatformat:2 }}€.
</p>
{% if goal.start_date %}
<p><small>Solo se listan movimientos desde el {{ goal.start_date|date:"d/m/Y" }}.</small></p>
{% endif %}
<form method="post" class="app-form">
{% csrf_token %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th></th>
<th>Fecha</th>
<th>Importe</th>
<th>Cuenta</th>
<th>Concepto</th>
</tr>
</thead>
<tbody>
{% for movement in page_obj %}
<tr>
<td>
{# 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. #}
<input type="hidden" name="shown" value="{{ movement.pk }}">
<input type="checkbox"
name="movements"
value="{{ movement.pk }}"
id="movement{{ movement.pk }}"
{% if movement.pk in linked_ids %}checked{% endif %}>
<label class="sr-only" for="movement{{ movement.pk }}">
Vincular movimiento del {{ movement.date|date:"d/m/Y" }}
</label>
</td>
<td>{{ movement.date|date:"d/m/Y" }}</td>
<td>{{ movement.amount|floatformat:2 }}€</td>
<td>{{ movement.account.name }}</td>
<td>
{% if goal.links_incomes %}
{{ movement.name }}
{% else %}
{{ movement.description|default:movement.category.name }}
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="empty-state">
<p>No hay movimientos que vincular</p>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Guardar vinculaciones</button>
<a class="btn btn-secondary" href="{% url 'goal_list' %}">Volver</a>
</div>
</form>
<nav class="pagination" aria-label="Paginación de movimientos">
<span class="step-links">
{% if page_obj.has_previous %}
<a href="?page=1">&laquo; Primero</a>
<a href="?page={{ page_obj.previous_page_number }}">Anterior</a>
{% endif %}
<span>Página {{ page_obj.number }} de {{ page_obj.paginator.num_pages }}</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">Siguiente</a>
<a href="?page={{ page_obj.paginator.num_pages }}">Último &raquo;</a>
{% endif %}
</span>
</nav>
{% endblock %}

View File

@ -76,6 +76,9 @@
<td class="table-actions">
<a href="{% url 'goal_edit' goal.id %}">Editar</a>
{% if goal.accepts_links %}
<a href="{% url 'goal_links' goal.id %}">Vincular</a>
{% endif %}
<a href="{% url 'goal_delete' goal.id %}" class="danger">Eliminar</a>
</td>
</tr>

View File

@ -39,5 +39,6 @@ urlpatterns = [
path('goals/new/', views.goal_create, name='goal_create'),
path('goals/<int:pk>/edit/', views.goal_edit, name='goal_edit'),
path('goals/<int:pk>/delete/', views.goal_delete, name='goal_delete'),
path('goals/<int:pk>/links/', views.goal_links, name='goal_links'),
path('password_help', TemplateView.as_view(template_name="registration/password_help.html"), name="password_help"),
]

View File

@ -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"})