feat: redirección del check is_transfer al formulario de traspaso

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
JKuijperM 2026-09-22 08:52:13 +02:00
parent 0dd34ab5dc
commit 488e370a04
5 changed files with 218 additions and 9 deletions

View File

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

View File

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

View File

@ -1,4 +1,5 @@
{% extends "expenses/base.html" %}
{% load static %}
{% block title %}
{% if form.instance.pk %}
@ -17,7 +18,9 @@
{% endif %}
</h1>
<form method="post" class="app-form">
<form method="post" class="app-form"
data-transfer-url="{% url 'transfer_create' %}"
data-transfer-account-param="from_account">
{% csrf_token %}
{% include "expenses/_form_fields.html" %}
@ -34,3 +37,7 @@
</div>
</form>
{% endblock %}
{% block extra_js %}
<script src="{% static 'expenses/js/transfer_redirect.js' %}" defer></script>
{% endblock %}

View File

@ -1,4 +1,5 @@
{% extends "expenses/base.html" %}
{% load static %}
{% block title %}
{% if form.instance.pk %}
Editar ingreso
@ -16,7 +17,9 @@
{% endif %}
</h1>
<form method="post" class="app-form">
<form method="post" class="app-form"
data-transfer-url="{% url 'transfer_create' %}"
data-transfer-account-param="to_account">
{% csrf_token %}
{% include "expenses/_form_fields.html" %}
<div class="form-actions">
@ -30,4 +33,8 @@
<a class="btn btn-secondary" href="{% url 'income_list' %}">Volver</a>
</div>
</form>
{% endblock %}
{% endblock %}
{% block extra_js %}
<script src="{% static 'expenses/js/transfer_redirect.js' %}" defer></script>
{% endblock %}

View File

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