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:
parent
0dd34ab5dc
commit
488e370a04
@ -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):
|
class ExpenseForm(forms.ModelForm):
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@ -27,6 +40,8 @@ class ExpenseForm(forms.ModelForm):
|
|||||||
)
|
)
|
||||||
self.fields["date"].input_formats = ["%Y-%m-%d"]
|
self.fields["date"].input_formats = ["%Y-%m-%d"]
|
||||||
|
|
||||||
|
_hide_is_transfer_when_editing(self)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Expense
|
model = Expense
|
||||||
fields = [
|
fields = [
|
||||||
@ -70,6 +85,7 @@ class IncomeForm(forms.ModelForm):
|
|||||||
owner=user,
|
owner=user,
|
||||||
active=True,
|
active=True,
|
||||||
)
|
)
|
||||||
|
_hide_is_transfer_when_editing(self)
|
||||||
|
|
||||||
|
|
||||||
class TransferForm(forms.ModelForm):
|
class TransferForm(forms.ModelForm):
|
||||||
|
|||||||
@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,4 +1,5 @@
|
|||||||
{% extends "expenses/base.html" %}
|
{% extends "expenses/base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{% if form.instance.pk %}
|
{% if form.instance.pk %}
|
||||||
@ -17,7 +18,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</h1>
|
</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 %}
|
{% csrf_token %}
|
||||||
|
|
||||||
{% include "expenses/_form_fields.html" %}
|
{% include "expenses/_form_fields.html" %}
|
||||||
@ -34,3 +37,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="{% static 'expenses/js/transfer_redirect.js' %}" defer></script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
{% extends "expenses/base.html" %}
|
{% extends "expenses/base.html" %}
|
||||||
|
{% load static %}
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{% if form.instance.pk %}
|
{% if form.instance.pk %}
|
||||||
Editar ingreso
|
Editar ingreso
|
||||||
@ -16,7 +17,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</h1>
|
</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 %}
|
{% csrf_token %}
|
||||||
{% include "expenses/_form_fields.html" %}
|
{% include "expenses/_form_fields.html" %}
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
@ -30,4 +33,8 @@
|
|||||||
<a class="btn btn-secondary" href="{% url 'income_list' %}">Volver</a>
|
<a class="btn btn-secondary" href="{% url 'income_list' %}">Volver</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="{% static 'expenses/js/transfer_redirect.js' %}" defer></script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import calendar
|
import calendar
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
|
from urllib.parse import urlencode
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from .models import (
|
from .models import (
|
||||||
@ -37,6 +38,7 @@ from django.db.models.functions import (
|
|||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.utils.http import url_has_allowed_host_and_scheme
|
from django.utils.http import url_has_allowed_host_and_scheme
|
||||||
from django.shortcuts import get_object_or_404, render, redirect
|
from django.shortcuts import get_object_or_404, render, redirect
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -47,11 +49,6 @@ def _get_int(value):
|
|||||||
return None
|
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):
|
def _get_date(value):
|
||||||
"""Parsea una fecha AAAA-MM-DD de la URL. None si falta o no es válida."""
|
"""Parsea una fecha AAAA-MM-DD de la URL. None si falta o no es válida."""
|
||||||
try:
|
try:
|
||||||
@ -60,6 +57,25 @@ def _get_date(value):
|
|||||||
return None
|
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):
|
def _resolve_date_range(request):
|
||||||
"""Lee el rango de fechas libre de la URL.
|
"""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)
|
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):
|
def _account_balances(accounts):
|
||||||
"""Calcula el saldo de cada cuenta con dos queries agregadas (nada de
|
"""Calcula el saldo de cada cuenta con dos queries agregadas (nada de
|
||||||
account.current_balance() en bucle, que dispararía N+1).
|
account.current_balance() en bucle, que dispararía N+1).
|
||||||
@ -497,6 +595,13 @@ def expense_list(request):
|
|||||||
@login_required
|
@login_required
|
||||||
def expense_create(request):
|
def expense_create(request):
|
||||||
if request.method == "POST":
|
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)
|
form = ExpenseForm(request.POST, user=request.user)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
expense = form.save(commit=False)
|
expense = form.save(commit=False)
|
||||||
@ -524,6 +629,10 @@ def expense_edit(request, pk):
|
|||||||
owner=request.user,
|
owner=request.user,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
blocked = _reject_transfer_leg(request, expense)
|
||||||
|
if blocked:
|
||||||
|
return blocked
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = ExpenseForm(request.POST or None, instance=expense, user=request.user)
|
form = ExpenseForm(request.POST or None, instance=expense, user=request.user)
|
||||||
|
|
||||||
@ -552,6 +661,10 @@ def expense_delete(request, pk):
|
|||||||
owner=request.user,
|
owner=request.user,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
blocked = _reject_transfer_leg(request, expense)
|
||||||
|
if blocked:
|
||||||
|
return blocked
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
expense.delete()
|
expense.delete()
|
||||||
messages.success(request, "Gasto eliminado correctamente.")
|
messages.success(request, "Gasto eliminado correctamente.")
|
||||||
@ -981,6 +1094,11 @@ def account_delete(request, pk):
|
|||||||
@login_required
|
@login_required
|
||||||
def income_create(request):
|
def income_create(request):
|
||||||
if request.method == "POST":
|
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)
|
form = IncomeForm(request.POST, user=request.user)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
income = form.save(commit=False)
|
income = form.save(commit=False)
|
||||||
@ -1080,6 +1198,10 @@ def income_list(request):
|
|||||||
def income_edit(request, pk):
|
def income_edit(request, pk):
|
||||||
income = get_object_or_404(Income, pk=pk, owner=request.user)
|
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":
|
if request.method == "POST":
|
||||||
form = IncomeForm(request.POST, instance=income, user=request.user)
|
form = IncomeForm(request.POST, instance=income, user=request.user)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
@ -1098,6 +1220,10 @@ def income_edit(request, pk):
|
|||||||
def income_delete(request, pk):
|
def income_delete(request, pk):
|
||||||
income = get_object_or_404(Income, pk=pk, owner=request.user)
|
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":
|
if request.method == "POST":
|
||||||
income.delete()
|
income.delete()
|
||||||
messages.success(request, "Ingreso eliminado.")
|
messages.success(request, "Ingreso eliminado.")
|
||||||
@ -1484,7 +1610,9 @@ def transfer_create(request):
|
|||||||
messages.success(request, "Traspaso creado.")
|
messages.success(request, "Traspaso creado.")
|
||||||
return redirect("transfer_list")
|
return redirect("transfer_list")
|
||||||
else:
|
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(
|
return render(
|
||||||
request,
|
request,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user