Configuración de automatización de CICD #35

Merged
jkuijperm merged 9 commits from dev into main 2026-09-29 16:11:55 +00:00
6 changed files with 329 additions and 6 deletions
Showing only changes of commit 7fd2f3361d - Show all commits

View File

@ -1,5 +1,14 @@
from django import forms from django import forms
from .models import Expense, Category, Income, Tag, Account, FuelEntry, Goal from .models import (
Expense,
Category,
Income,
Tag,
Account,
FuelEntry,
Goal,
Transfer,
)
class ExpenseForm(forms.ModelForm): class ExpenseForm(forms.ModelForm):
@ -63,6 +72,36 @@ class IncomeForm(forms.ModelForm):
) )
class TransferForm(forms.ModelForm):
"""Formulario del traspaso. Las patas no se editan aquí ni en ninguna parte:
las deriva Transfer.sync() de estos campos."""
class Meta:
model = Transfer
fields = ["date", "amount", "from_account", "to_account", "fee_amount"]
widgets = {"date": forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"})}
labels = {
"from_account": "Cuenta de origen",
"to_account": "Cuenta de destino",
"fee_amount": "Comisión",
}
help_texts = {
"fee_amount": (
"Déjalo vacío si el traspaso no tiene comisión. La comisión sí "
"cuenta como gasto real en el dashboard y en los presupuestos."
),
}
def __init__(self, *args, **kwargs):
user = kwargs.pop("user")
super().__init__(*args, **kwargs)
accounts = Account.objects.filter(owner=user, active=True)
self.fields["from_account"].queryset = accounts
self.fields["to_account"].queryset = accounts
self.fields["date"].input_formats = ["%Y-%m-%d"]
class FuelEntryForm(forms.ModelForm): class FuelEntryForm(forms.ModelForm):
odometer = forms.DecimalField(label="Kilómetros actuales") odometer = forms.DecimalField(label="Kilómetros actuales")
liters = forms.DecimalField(max_digits=8, decimal_places=2, label="Litros") liters = forms.DecimalField(max_digits=8, decimal_places=2, label="Litros")

View File

@ -0,0 +1,40 @@
# Generated by Django 5.2.10 on 2026-09-18 08:37
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('expenses', '0013_expense_is_transfer_income_is_transfer'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='category',
name='is_system',
field=models.BooleanField(default=False, help_text='Categoría creada por la app (traspasos, comisiones). No borrable.'),
),
migrations.CreateModel(
name='Transfer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('amount', models.DecimalField(decimal_places=2, max_digits=12)),
('fee_amount', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('expense_leg', models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfer_out', to='expenses.expense')),
('fee_expense', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfer_fee', to='expenses.expense')),
('from_account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transfers_out', to='expenses.account')),
('income_leg', models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfer_in', to='expenses.income')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfers', to=settings.AUTH_USER_MODEL)),
('to_account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transfers_in', to='expenses.account')),
],
options={
'ordering': ['-date'],
},
),
]

View File

@ -1,6 +1,7 @@
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from django.db import models from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import models, transaction
from django.conf import settings from django.conf import settings
from django.db.models import Sum from django.db.models import Sum
from functools import cached_property from functools import cached_property
@ -26,6 +27,11 @@ class Category(models.Model):
related_name="children", related_name="children",
) )
is_system = models.BooleanField(
default=False,
help_text="Categoría creada por la app (traspasos, comisiones). No borrable.",
)
class Meta: class Meta:
unique_together = ("name", "parent", "owner", "slug") unique_together = ("name", "parent", "owner", "slug")
verbose_name_plural = "categories" verbose_name_plural = "categories"
@ -246,6 +252,22 @@ class Expense(models.Model):
def __str__(self): def __str__(self):
return "{} - {}".format(self.date, self.amount) return "{} - {}".format(self.date, self.amount)
@property
def transfer(self):
"""El Transfer del que este gasto es una pata, o None si es normal.
Cubre las dos patas de gasto de un traspaso: la de salida
(transfer_out) y la comisión (transfer_fee). Un gasto que pertenece a un
traspaso no se edita ni se borra por separado: se hace desde el
traspaso, que es quien mantiene las tres patas cuadradas.
"""
for relation in ("transfer_out", "transfer_fee"):
try:
return getattr(self, relation)
except ObjectDoesNotExist:
continue
return None
class Income(models.Model): class Income(models.Model):
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
@ -275,6 +297,208 @@ class Income(models.Model):
def __str__(self): def __str__(self):
return f"{self.name} - {self.amount}" return f"{self.name} - {self.amount}"
@property
def transfer(self):
"""El Transfer del que este ingreso es la pata de entrada, o None."""
try:
return self.transfer_in
except ObjectDoesNotExist:
return None
def _system_category(user, slug, name):
"""Categoría de sistema del usuario (traspasos, comisiones), creándola si falta.
Se busca por slug y no por nombre porque el nombre lo puede cambiar el
usuario; el slug es el identificador estable.
"""
cat, _ = Category.objects.get_or_create(
slug=slug,
owner=user,
defaults={"name": name, "is_system": True},
)
return cat
class Transfer(models.Model):
"""Un traspaso entre dos cuentas propias.
No sustituye a los dos movimientos que ya registraban un traspaso: los
orquesta. Sigue habiendo un Expense en la cuenta origen y un Income en la
destino, los dos con is_transfer=True, pero ahora están vinculados aquí y
es este modelo quien los crea, los edita y los borra a la vez (sync() y
delete()).
Por eso los cuatro métodos de saldo de Account (current_balance,
monthly_balance, balance_until, monthly_net) no saben nada de Transfer y no
necesitan saberlo: el traspaso ya está dentro de sus sumas, porque sus dos
patas siguen siendo un Income y un Expense reales.
"""
TRANSFER_CATEGORY_SLUG = "traspaso"
FEE_CATEGORY_SLUG = "comision"
owner = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="transfers"
)
date = models.DateField()
# max_digits=12 frente a los 10 de Expense.amount: un traspaso de más de diez
# dígitos no cabría en su pata de gasto. Se deja así a propósito, porque
# igualarlo obliga a migrar Expense por un caso de 100 millones de euros.
amount = models.DecimalField(max_digits=12, decimal_places=2)
from_account = models.ForeignKey(
Account, on_delete=models.PROTECT, related_name="transfers_out"
)
to_account = models.ForeignKey(
Account, on_delete=models.PROTECT, related_name="transfers_in"
)
# Las dos patas y la comisión opcional. SET_NULL para que si algo las borra
# por fuera el Transfer no arrastre, pero el flujo normal las borra en cascada
# desde el propio Transfer (ver delete()).
expense_leg = models.OneToOneField(
Expense, on_delete=models.SET_NULL, null=True, related_name="transfer_out"
)
income_leg = models.OneToOneField(
Income, on_delete=models.SET_NULL, null=True, related_name="transfer_in"
)
fee_amount = models.DecimalField(
max_digits=10, decimal_places=2, null=True, blank=True
)
fee_expense = models.OneToOneField(
Expense,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="transfer_fee",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-date"]
def __str__(self):
return f"{self.date} - {self.amount} ({self.from_account} → {self.to_account})"
def clean(self):
if self.from_account_id and self.from_account_id == self.to_account_id:
raise ValidationError(
{"to_account": "El origen y el destino tienen que ser cuentas distintas."}
)
@transaction.atomic
def sync(self):
"""Guarda el traspaso y pone sus patas al día en la misma transacción.
Un solo método para crear y para editar: si no hay patas las crea, y si
las hay les propaga importe, fecha y cuentas (la categoría no, que el
usuario puede haberla recolocado). La comisión aparece o desaparece
según fee_amount.
Ojo con la comisión: va con is_transfer=False a propósito. Las dos patas
del movimiento solo mueven dinero entre cuentas propias y no son gasto
real, pero la comisión sale del patrimonio de verdad, así que es un gasto
normal y tiene que contar en el dashboard y en los presupuestos.
"""
if self.expense_leg_id:
leg = self.expense_leg
leg.date = self.date
leg.amount = self.amount
leg.account = self.from_account
leg.description = self._expense_description()
leg.save()
else:
self.expense_leg = Expense.objects.create(
owner=self.owner,
account=self.from_account,
category=_system_category(
self.owner, self.TRANSFER_CATEGORY_SLUG, "Traspaso"
),
amount=self.amount,
date=self.date,
description=self._expense_description(),
is_transfer=True,
)
if self.income_leg_id:
leg = self.income_leg
leg.date = self.date
leg.amount = self.amount
leg.account = self.to_account
leg.name = self._income_name()
leg.save()
else:
self.income_leg = Income.objects.create(
owner=self.owner,
account=self.to_account,
name=self._income_name(),
amount=self.amount,
date=self.date,
is_transfer=True,
)
# La comisión que sobra se borra después de guardar el Transfer, para no
# dejar ni un instante el FK apuntando a una fila que ya no existe.
stale_fee = None
if self.fee_amount:
if self.fee_expense_id:
fee = self.fee_expense
fee.date = self.date
fee.amount = self.fee_amount
fee.account = self.from_account
fee.save()
else:
self.fee_expense = Expense.objects.create(
owner=self.owner,
account=self.from_account,
category=_system_category(
self.owner, self.FEE_CATEGORY_SLUG, "Comisión"
),
amount=self.fee_amount,
date=self.date,
description=self._fee_description(),
is_transfer=False,
)
elif self.fee_expense_id:
stale_fee = self.fee_expense
self.fee_expense = None
self.save()
if stale_fee is not None:
stale_fee.delete()
return self
@transaction.atomic
def delete(self, *args, **kwargs):
"""Borra el traspaso y sus tres patas.
Las patas son SET_NULL, así que borrarlas no arrastraría el Transfer;
el arrastre va en este sentido, y va aquí y no en la vista para que
valga también desde el shell o los tests.
"""
legs = [self.expense_leg, self.income_leg, self.fee_expense]
result = super().delete(*args, **kwargs)
for leg in legs:
if leg is not None:
leg.delete()
return result
def _expense_description(self):
return f"Traspaso a {self.to_account.name}"
def _income_name(self):
return f"Traspaso desde {self.from_account.name}"
def _fee_description(self):
return f"Comisión del traspaso a {self.to_account.name}"
class FuelEntry(models.Model): class FuelEntry(models.Model):
expense = models.OneToOneField( expense = models.OneToOneField(

View File

@ -7,7 +7,12 @@
{% block delete_question %}¿Seguro que quieres eliminar la categoría <strong>{{ category.name }}</strong>?{% endblock %} {% block delete_question %}¿Seguro que quieres eliminar la categoría <strong>{{ category.name }}</strong>?{% endblock %}
{% block delete_warnings %} {% block delete_warnings %}
{% if expense_count %} {% if category.is_system %}
<p class="form-errors">
Esta categoría la usa la aplicación para clasificar los traspasos y sus
comisiones, así que no se puede eliminar.
</p>
{% elif expense_count %}
<p class="form-errors"> <p class="form-errors">
Esta categoría tiene {{ expense_count }} gasto{{ expense_count|pluralize }} asociado{{ expense_count|pluralize }} Esta categoría tiene {{ expense_count }} gasto{{ expense_count|pluralize }} asociado{{ expense_count|pluralize }}
y no se puede eliminar. Reasigna esos gastos a otra categoría primero. y no se puede eliminar. Reasigna esos gastos a otra categoría primero.

View File

@ -30,11 +30,18 @@
<tbody> <tbody>
{% for row in category_rows %} {% for row in category_rows %}
<tr> <tr>
<td class="cat-depth-{{ row.depth }}">{{ row.category.name }}</td> <td class="cat-depth-{{ row.depth }}">
{{ row.category.name }}
{% if row.category.is_system %}
<span class="badge badge-neutral">Del sistema</span>
{% endif %}
</td>
<td>{{ row.category.expense_count }}</td> <td>{{ row.category.expense_count }}</td>
<td class="table-actions"> <td class="table-actions">
<a href="{% url 'category_edit' row.category.id %}">Editar</a> <a href="{% url 'category_edit' row.category.id %}">Editar</a>
<a href="{% url 'category_delete' row.category.id %}" class="danger">Eliminar</a> {% if not row.category.is_system %}
<a href="{% url 'category_delete' row.category.id %}" class="danger">Eliminar</a>
{% endif %}
</td> </td>
</tr> </tr>
{% empty %} {% empty %}

View File

@ -1306,6 +1306,14 @@ def category_delete(request, pk):
category = get_object_or_404(Category, pk=pk, owner=request.user) category = get_object_or_404(Category, pk=pk, owner=request.user)
if request.method == "POST": if request.method == "POST":
if category.is_system:
messages.error(
request,
f"No se puede eliminar «{category.name}»; la usa la aplicación para "
"clasificar los traspasos y sus comisiones."
)
return redirect("category_list")
try: try:
category.delete() category.delete()
except ProtectedError: except ProtectedError:
@ -1314,7 +1322,7 @@ def category_delete(request, pk):
f"No se puede eliminar «{category.name}»; tiene gastos asociados. " f"No se puede eliminar «{category.name}»; tiene gastos asociados. "
"Reasigna esos gastos a otra categoría antes de borrarla." "Reasigna esos gastos a otra categoría antes de borrarla."
) )
else: else:
messages.success(request, "Categoría eliminada.") messages.success(request, "Categoría eliminada.")
return redirect("category_list") return redirect("category_list")