From 488e370a0447f8a7e5ea09de0519bf7d352094aa Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Tue, 22 Sep 2026 08:52:13 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20redirecci=C3=B3n=20del=20check=20is=5Ft?= =?UTF-8?q?ransfer=20al=20formulario=20de=20traspaso?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- expenses_manager/expenses/forms.py | 16 ++ .../static/expenses/js/transfer_redirect.js | 51 +++++++ .../templates/expenses/expense_form.html | 9 +- .../templates/expenses/income_form.html | 11 +- expenses_manager/expenses/views.py | 140 +++++++++++++++++- 5 files changed, 218 insertions(+), 9 deletions(-) create mode 100644 expenses_manager/expenses/static/expenses/js/transfer_redirect.js diff --git a/expenses_manager/expenses/forms.py b/expenses_manager/expenses/forms.py index 0221bca..d5f6ae9 100644 --- a/expenses_manager/expenses/forms.py +++ b/expenses_manager/expenses/forms.py @@ -11,6 +11,19 @@ from .models import ( ) +def _hide_is_transfer_when_editing(form): + """Quita la casilla de traspaso al editar un movimiento que ya existe. + + Al crear, la casilla es un atajo: lleva al formulario de traspaso, que es + quien crea las dos patas. Al editar no significa nada, porque convertir un + gasto suelto en traspaso no es editarlo (habría que inventarse la otra + cuenta), y marcarla solo dejaría el movimiento marcado pero sin pareja: justo + el estado a medias que Transfer viene a evitar. + """ + if form.instance.pk: + form.fields.pop("is_transfer", None) + + class ExpenseForm(forms.ModelForm): def __init__(self, *args, **kwargs): @@ -27,6 +40,8 @@ class ExpenseForm(forms.ModelForm): ) self.fields["date"].input_formats = ["%Y-%m-%d"] + _hide_is_transfer_when_editing(self) + class Meta: model = Expense fields = [ @@ -70,6 +85,7 @@ class IncomeForm(forms.ModelForm): owner=user, active=True, ) + _hide_is_transfer_when_editing(self) class TransferForm(forms.ModelForm): diff --git a/expenses_manager/expenses/static/expenses/js/transfer_redirect.js b/expenses_manager/expenses/static/expenses/js/transfer_redirect.js new file mode 100644 index 0000000..cde8ee7 --- /dev/null +++ b/expenses_manager/expenses/static/expenses/js/transfer_redirect.js @@ -0,0 +1,51 @@ +// Al marcar la casilla de traspaso en el formulario de gasto o de ingreso, salta +// al formulario de traspaso llevándose fecha, importe y cuenta. El movimiento no +// se guarda: un traspaso son dos patas y las crea Transfer.sync(). +// +// Esto es solo un atajo para no obligar a enviar el formulario: si el JS no corre, +// la vista hace la misma redirección al recibir el POST, así que el +// comportamiento no depende de este fichero. +document.addEventListener('DOMContentLoaded', function () { + var form = document.querySelector('[data-transfer-url]'); + + if (!form) { + return; + } + + var checkbox = form.querySelector('#id_is_transfer'); + + if (!checkbox) { + return; // editando no se pinta la casilla + } + + // Qué cuenta es la del formulario en el traspaso: origen si es un gasto, + // destino si es un ingreso. Lo decide la plantilla, no este fichero. + var accountParam = form.dataset.transferAccountParam; + + checkbox.addEventListener('change', function () { + if (!checkbox.checked) { + return; + } + + var params = new URLSearchParams(); + + ['date', 'amount'].forEach(function (name) { + var field = form.querySelector('[name="' + name + '"]'); + + if (field && field.value) { + params.set(name, field.value); + } + }); + + var account = form.querySelector('[name="account"]'); + + if (account && account.value) { + params.set(accountParam, account.value); + } + + var query = params.toString(); + window.location.href = query + ? form.dataset.transferUrl + '?' + query + : form.dataset.transferUrl; + }); +}); diff --git a/expenses_manager/expenses/templates/expenses/expense_form.html b/expenses_manager/expenses/templates/expenses/expense_form.html index f900d70..3a4ecd4 100644 --- a/expenses_manager/expenses/templates/expenses/expense_form.html +++ b/expenses_manager/expenses/templates/expenses/expense_form.html @@ -1,4 +1,5 @@ {% extends "expenses/base.html" %} +{% load static %} {% block title %} {% if form.instance.pk %} @@ -17,7 +18,9 @@ {% endif %} -
+ {% csrf_token %} {% include "expenses/_form_fields.html" %} @@ -34,3 +37,7 @@
{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/expenses_manager/expenses/templates/expenses/income_form.html b/expenses_manager/expenses/templates/expenses/income_form.html index 9992000..76ac36d 100644 --- a/expenses_manager/expenses/templates/expenses/income_form.html +++ b/expenses_manager/expenses/templates/expenses/income_form.html @@ -1,4 +1,5 @@ {% extends "expenses/base.html" %} +{% load static %} {% block title %} {% if form.instance.pk %} Editar ingreso @@ -16,7 +17,9 @@ {% endif %} -
+ {% csrf_token %} {% include "expenses/_form_fields.html" %}
@@ -30,4 +33,8 @@ Volver
-{% endblock %} \ No newline at end of file +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index 7d69014..5e2a7c0 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -1,6 +1,7 @@ import logging import calendar from datetime import date, datetime, timedelta +from urllib.parse import urlencode from decimal import Decimal from django.contrib import messages from .models import ( @@ -37,6 +38,7 @@ from django.db.models.functions import ( from django.contrib.auth.decorators import login_required from django.utils.http import url_has_allowed_host_and_scheme from django.shortcuts import get_object_or_404, render, redirect +from django.urls import reverse logger = logging.getLogger(__name__) @@ -47,11 +49,6 @@ def _get_int(value): return None -# Umbral del eje del gráfico con rango libre: dos meses es donde un gráfico -# diario deja de leerse, así que por encima se agrupa por mes. -RANGE_DAY_CHART_MAX_DAYS = 62 - - def _get_date(value): """Parsea una fecha AAAA-MM-DD de la URL. None si falta o no es válida.""" try: @@ -60,6 +57,25 @@ def _get_date(value): return None +def _get_decimal(value): + """Parsea un importe de la URL. None si falta o no es un número usable. + + Se descartan NaN e Infinity aparte: Decimal los acepta pero luego revientan + en cuanto se comparan o se guardan. + """ + try: + number = Decimal(value) + except (TypeError, ValueError, ArithmeticError): + return None + + return number if number.is_finite() else None + + +# Umbral del eje del gráfico con rango libre: dos meses es donde un gráfico +# diario deja de leerse, así que por encima se agrupa por mes. +RANGE_DAY_CHART_MAX_DAYS = 62 + + def _resolve_date_range(request): """Lee el rango de fechas libre de la URL. @@ -138,6 +154,88 @@ def _analysis_expenses(user): return Expense.objects.filter(owner=user).exclude(is_transfer=True) +def _reject_transfer_leg(request, movement): + """Corta la edición o el borrado suelto de una pata de traspaso. + + Devuelve un redirect a la edición del traspaso, o None si el movimiento es + normal y se puede tocar. Las patas las mantiene cuadradas Transfer.sync(), + así que tocar una por separado descuadraría la otra sin que nada lo avise: + es justo el problema que el modelo Transfer viene a resolver. + + Los listados ya no enseñan esos enlaces, pero la URL sigue ahí a mano. + """ + transfer = movement.transfer + + if transfer is None: + return None + + messages.error( + request, + "Este movimiento forma parte de un traspaso. Edítalo o bórralo desde el " + "traspaso para que las dos cuentas sigan cuadradas.", + ) + return redirect("transfer_edit", pk=transfer.pk) + + +def _transfer_create_url(data, account_param): + """URL de "nuevo traspaso" arrastrando lo que ya se hubiera escrito. + + `data` es el POST de un formulario de gasto o de ingreso y `account_param` el + papel que juega su cuenta en el traspaso: "from_account" si viene de un gasto + (el dinero sale de ahí) y "to_account" si viene de un ingreso. + + Lo que no se pueda interpretar se deja fuera: el formulario de traspaso saldrá + con ese campo vacío, que es mejor que un error por un dato a medio escribir. + """ + params = {} + + if (date_value := _get_date(data.get("date"))) is not None: + params["date"] = date_value.isoformat() + + if (amount := _get_decimal(data.get("amount"))) is not None: + params["amount"] = str(amount) + + if (account_id := _get_int(data.get("account"))) is not None: + params[account_param] = account_id + + url = reverse("transfer_create") + return f"{url}?{urlencode(params)}" if params else url + + +def _transfer_initial(request): + """Datos de arranque del formulario de traspaso leídos de la URL. + + Los manda el formulario de gasto o de ingreso cuando el usuario marca la + casilla de traspaso. Nada de esto es de fiar (es la URL): lo que no se pueda + interpretar se ignora y el campo sale vacío. + """ + initial = {} + + if (date_value := _get_date(request.GET.get("date"))) is not None: + initial["date"] = date_value + + if (amount := _get_decimal(request.GET.get("amount"))) is not None: + initial["amount"] = amount + + # Las cuentas se comprueban contra las del usuario: un id ajeno o de una + # cuenta desactivada no se preselecciona aunque venga en la URL. + wanted = { + field: account_id + for field in ("from_account", "to_account") + if (account_id := _get_int(request.GET.get(field))) is not None + } + + if wanted: + own = set( + Account.objects.filter( + owner=request.user, active=True, pk__in=wanted.values() + ).values_list("pk", flat=True) + ) + initial.update({f: pk for f, pk in wanted.items() if pk in own}) + + return initial + + def _account_balances(accounts): """Calcula el saldo de cada cuenta con dos queries agregadas (nada de account.current_balance() en bucle, que dispararía N+1). @@ -497,6 +595,13 @@ def expense_list(request): @login_required def expense_create(request): if request.method == "POST": + # Marcado como traspaso: esto no se guarda aquí. Un traspaso son dos patas + # que crea Transfer.sync(), así que se reconduce al formulario de traspaso + # con lo que ya hubiera escrito. Se mira el POST en bruto y antes de + # validar porque el gasto puede estar a medias y da igual: no se crea. + if request.POST.get("is_transfer"): + return redirect(_transfer_create_url(request.POST, "from_account")) + form = ExpenseForm(request.POST, user=request.user) if form.is_valid(): expense = form.save(commit=False) @@ -524,6 +629,10 @@ def expense_edit(request, pk): owner=request.user, ) + blocked = _reject_transfer_leg(request, expense) + if blocked: + return blocked + if request.method == "POST": form = ExpenseForm(request.POST or None, instance=expense, user=request.user) @@ -552,6 +661,10 @@ def expense_delete(request, pk): owner=request.user, ) + blocked = _reject_transfer_leg(request, expense) + if blocked: + return blocked + if request.method == "POST": expense.delete() messages.success(request, "Gasto eliminado correctamente.") @@ -981,6 +1094,11 @@ def account_delete(request, pk): @login_required def income_create(request): if request.method == "POST": + # Igual que en expense_create, pero la cuenta es el destino: el dinero + # entra en ella. + if request.POST.get("is_transfer"): + return redirect(_transfer_create_url(request.POST, "to_account")) + form = IncomeForm(request.POST, user=request.user) if form.is_valid(): income = form.save(commit=False) @@ -1080,6 +1198,10 @@ def income_list(request): def income_edit(request, pk): income = get_object_or_404(Income, pk=pk, owner=request.user) + blocked = _reject_transfer_leg(request, income) + if blocked: + return blocked + if request.method == "POST": form = IncomeForm(request.POST, instance=income, user=request.user) if form.is_valid(): @@ -1098,6 +1220,10 @@ def income_edit(request, pk): def income_delete(request, pk): income = get_object_or_404(Income, pk=pk, owner=request.user) + blocked = _reject_transfer_leg(request, income) + if blocked: + return blocked + if request.method == "POST": income.delete() messages.success(request, "Ingreso eliminado.") @@ -1484,7 +1610,9 @@ def transfer_create(request): messages.success(request, "Traspaso creado.") return redirect("transfer_list") else: - form = TransferForm(user=request.user) + # La URL puede traer fecha, importe y cuenta del formulario de gasto o de + # ingreso del que se venga. + form = TransferForm(user=request.user, initial=_transfer_initial(request)) return render( request,