Configuración de automatización de CICD #35
@ -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):
|
||||
|
||||
@ -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" %}
|
||||
{% 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 %}
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user