expenses_manager/expenses_manager/expenses/forms.py
JKuijperM 9207580f8a
All checks were successful
Tests / CI / Run tests passed: 342
expenses_manager/pipeline/head This commit looks good
Add expense type to category form and list
2026-10-14 20:08:38 +02:00

351 lines
11 KiB
Python

from django import forms
from .csv_export import DEFAULT_DIALECT
from .models import (
Expense,
Category,
Income,
Tag,
Account,
FuelEntry,
Goal,
Transfer,
)
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 _GoalLinksMixin:
"""Campo "Objetivos" en el formulario de un movimiento.
`goals` es el lado inverso de Goal.linked_expenses / linked_incomes, y un
ModelForm no guarda relaciones inversas: hay que poblarlo al abrir y
escribirlo a mano al guardar. Lo comparten gastos e ingresos para que qué
objetivo admite cada movimiento se decida en un solo sitio
(Goal.KINDS_LINKING_*, en el modelo).
"""
goal_kinds = ()
def setup_goal_links(self, user):
self.fields["goals"] = forms.ModelMultipleChoiceField(
queryset=Goal.objects.filter(owner=user, kind__in=self.goal_kinds),
required=False,
label="Objetivos",
widget=forms.CheckboxSelectMultiple(),
help_text="Este movimiento cuenta en los objetivos que marques.",
)
if self.instance.pk:
self.initial["goals"] = list(
self.instance.goals.values_list("pk", flat=True)
)
def _save_m2m(self):
super()._save_m2m()
# Si el formulario se construyó sin usuario no hay campo, y entonces no
# se toca nada: un set() a ciegas borraría las vinculaciones que ya haya.
if "goals" in self.cleaned_data:
self.instance.goals.set(self.cleaned_data["goals"])
class ExpenseForm(_GoalLinksMixin, forms.ModelForm):
goal_kinds = Goal.KINDS_LINKING_EXPENSES
def __init__(self, *args, **kwargs):
# Get user
user = kwargs.pop("user", None)
super().__init__(*args, **kwargs)
if user:
self.fields["category"].queryset = Category.objects.filter(owner=user)
self.fields["tags"].queryset = Tag.objects.filter(owner=user)
self.fields["account"].queryset = Account.objects.filter(
owner=user, active=True
)
self.fields["date"].input_formats = ["%Y-%m-%d"]
self.setup_goal_links(user)
_hide_is_transfer_when_editing(self)
class Meta:
model = Expense
fields = [
"date",
"amount",
"description",
"category",
"account",
"tags",
"is_transfer",
]
widgets = {
"date": forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"}),
"tags": forms.CheckboxSelectMultiple(),
}
class TagForm(forms.ModelForm):
class Meta:
model = Tag
fields = ["name"]
class AccountForm(forms.ModelForm):
class Meta:
model = Account
fields = ["name", "initial_balance", "active"]
class IncomeForm(_GoalLinksMixin, forms.ModelForm):
goal_kinds = Goal.KINDS_LINKING_INCOMES
class Meta:
model = Income
fields = ["account", "name", "amount", "date", "is_transfer"]
widgets = {"date": forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"})}
def __init__(self, *args, **kwargs):
user = kwargs.pop("user")
super().__init__(*args, **kwargs)
self.fields["account"].queryset = Account.objects.filter(
owner=user,
active=True,
)
self.setup_goal_links(user)
_hide_is_transfer_when_editing(self)
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):
odometer = forms.DecimalField(label="Kilómetros actuales")
liters = forms.DecimalField(max_digits=8, decimal_places=2, label="Litros")
class Meta:
model = Expense
fields = ["date", "amount", "account"]
widgets = {
"date": forms.DateInput(format="%Y-%m-%d", attrs={"type":"date"}),
}
def __init__(self, *args, **kwargs):
user = kwargs.pop("user")
super().__init__(*args, **kwargs)
if user:
self.fields["account"].queryset = user.accounts.filter(active=True)
if self.instance.pk and hasattr(self.instance, "fuel_data"):
fuel = self.instance.fuel_data
self.fields["odometer"].initial = fuel.odometer
self.fields["liters"].initial = fuel.liters
class CategoryForm(forms.ModelForm):
class Meta:
model = Category
fields = ["name", "parent", "expense_type"]
def __init__(self, *args, **kwargs):
user = kwargs.pop("user")
super().__init__(*args, **kwargs)
if user:
queryset = Category.objects.filter(owner=user)
if self.instance.pk:
queryset = queryset.exclude(
pk__in=self.instance.descendant_ids()
)
self.fields["parent"].queryset = queryset
def clean_parent(self):
parent = self.cleaned_data.get("parent")
if parent and self.instance.pk:
if parent.pk in self.instance.descendant_ids():
raise forms.ValidationError(
"Una categoría no puede ser su propio padre ni depender "
"de una de sus subcategorías."
)
return parent
class GoalForm(forms.ModelForm):
class Meta:
model = Goal
fields = [
"name",
"kind",
"target_amount",
"category",
"include_subcategories",
"account",
"start_date",
"period",
"show_on_home",
]
widgets = {
"start_date": forms.DateInput(
format="%Y-%m-%d", attrs={"type": "date"}
),
}
def __init__(self, *args, **kwargs):
user = kwargs.pop('user')
super().__init__(*args, **kwargs)
self.fields['category'].queryset = user.categories.all()
self.fields['account'].queryset = user.accounts.filter(active=True)
self.fields['category'].required = False
self.fields['account'].required = False
self.fields['start_date'].input_formats = ["%Y-%m-%d"]
def clean(self):
cleaned = super().clean()
kind = cleaned.get("kind")
if kind in (Goal.KIND_PAYMENT, Goal.KIND_BUDGET):
if not cleaned.get('category'):
self.add_error(
"category",
"Selecciona una categoría para este tipo de objetivo.",
)
cleaned["account"] = None
if kind == Goal.KIND_SAVING:
if not cleaned.get("account"):
self.add_error(
"account",
"Selecciona la cuenta donde se acumula el ahorro.",
)
cleaned["category"] = None
if kind == Goal.KIND_COLLECTION:
# Un cobro pendiente se mide solo con los ingresos que se le
# vinculen, así que no lleva categoría ni cuenta; la fecha de inicio
# sí, porque es la que acota desde cuándo cuentan.
if not cleaned.get("start_date"):
self.add_error(
"start_date",
"Indica desde cuándo se cuentan los cobros.",
)
cleaned["category"] = None
cleaned["account"] = None
if kind == Goal.KIND_BUDGET:
if cleaned.get("period") == Goal.PERIOD_NONE:
self.add_error(
"period",
"Un presupuesto necesita un periodo: mensual o anual",
)
else:
cleaned["period"] = Goal.PERIOD_NONE
return cleaned
class ExportForm(forms.Form):
"""Filtros de la exportación a CSV. Valida la pantalla y la descarga.
Los filtros son a propósito solo rango de fechas y cuenta: la gracia de un
CSV es filtrar fuera, y duplicar aquí la fila de filtros avanzados de
expense_list serían dos UIs que divergen.
"""
KIND_CHOICES = [
("expenses", "Gastos"),
("incomes", "Ingresos"),
("transfers", "Traspasos"),
]
FMT_CHOICES = [
("excel", "Excel español — separador ; y decimales con coma"),
("std", "CSV estándar — separador , y decimales con punto"),
]
kind = forms.ChoiceField(choices=KIND_CHOICES, label="Qué exportar")
date_from = forms.DateField(
required=False,
label="Desde",
widget=forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"}),
)
date_to = forms.DateField(
required=False,
label="Hasta",
widget=forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"}),
)
account = forms.ModelChoiceField(
queryset=Account.objects.none(),
required=False,
label="Cuenta",
empty_label="Todas las cuentas",
)
fmt = forms.ChoiceField(
choices=FMT_CHOICES, label="Formato", initial=DEFAULT_DIALECT
)
def __init__(self, *args, **kwargs):
user = kwargs.pop("user")
super().__init__(*args, **kwargs)
# Activas e inactivas: se exporta histórico, y una cuenta cerrada tiene
# movimientos que interesan.
self.fields["account"].queryset = Account.objects.filter(owner=user)
self.fields["date_from"].input_formats = ["%Y-%m-%d"]
self.fields["date_to"].input_formats = ["%Y-%m-%d"]
def clean(self):
cleaned = super().clean()
date_from, date_to = cleaned.get("date_from"), cleaned.get("date_to")
# En un listado un rango invertido se ignora; aquí no, porque el
# resultado sería un fichero vacío descargado sin explicación.
if date_from and date_to and date_from > date_to:
self.add_error("date_to", "La fecha final es anterior a la inicial.")
return cleaned