Add linking UI for goals from movement form and goal page
This commit is contained in:
parent
4167338a6a
commit
a5f1c7b590
@ -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:
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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
|
||||
|
||||
102
expenses_manager/expenses/templates/goals/links.html
Normal file
102
expenses_manager/expenses/templates/goals/links.html
Normal 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">« 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 »</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
{% endblock %}
|
||||
@ -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>
|
||||
|
||||
@ -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"),
|
||||
]
|
||||
@ -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"})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user