Compare commits
No commits in common. "a33a3cdb5a929ef807660d33630b4e34fedf9e7e" and "d0c257da874f68f7f9529f9f0df86473aab55dcc" have entirely different histories.
a33a3cdb5a
...
d0c257da87
5
.gitignore
vendored
5
.gitignore
vendored
@ -162,7 +162,4 @@ cython_debug/
|
|||||||
#.idea/
|
#.idea/
|
||||||
|
|
||||||
staticfiles/
|
staticfiles/
|
||||||
*.bak
|
*.bak
|
||||||
|
|
||||||
# graphify
|
|
||||||
graphify-out/
|
|
||||||
@ -25,43 +25,7 @@ def _hide_is_transfer_when_editing(form):
|
|||||||
form.fields.pop("is_transfer", None)
|
form.fields.pop("is_transfer", None)
|
||||||
|
|
||||||
|
|
||||||
class _GoalLinksMixin:
|
class ExpenseForm(forms.ModelForm):
|
||||||
"""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):
|
def __init__(self, *args, **kwargs):
|
||||||
# Get user
|
# Get user
|
||||||
@ -76,7 +40,6 @@ class ExpenseForm(_GoalLinksMixin, forms.ModelForm):
|
|||||||
owner=user, active=True
|
owner=user, active=True
|
||||||
)
|
)
|
||||||
self.fields["date"].input_formats = ["%Y-%m-%d"]
|
self.fields["date"].input_formats = ["%Y-%m-%d"]
|
||||||
self.setup_goal_links(user)
|
|
||||||
|
|
||||||
_hide_is_transfer_when_editing(self)
|
_hide_is_transfer_when_editing(self)
|
||||||
|
|
||||||
@ -110,9 +73,7 @@ class AccountForm(forms.ModelForm):
|
|||||||
fields = ["name", "initial_balance", "active"]
|
fields = ["name", "initial_balance", "active"]
|
||||||
|
|
||||||
|
|
||||||
class IncomeForm(_GoalLinksMixin, forms.ModelForm):
|
class IncomeForm(forms.ModelForm):
|
||||||
goal_kinds = Goal.KINDS_LINKING_INCOMES
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Income
|
model = Income
|
||||||
fields = ["account", "name", "amount", "date", "is_transfer"]
|
fields = ["account", "name", "amount", "date", "is_transfer"]
|
||||||
@ -125,7 +86,6 @@ class IncomeForm(_GoalLinksMixin, forms.ModelForm):
|
|||||||
owner=user,
|
owner=user,
|
||||||
active=True,
|
active=True,
|
||||||
)
|
)
|
||||||
self.setup_goal_links(user)
|
|
||||||
_hide_is_transfer_when_editing(self)
|
_hide_is_transfer_when_editing(self)
|
||||||
|
|
||||||
|
|
||||||
@ -265,18 +225,6 @@ class GoalForm(forms.ModelForm):
|
|||||||
"Selecciona la cuenta donde se acumula el ahorro.",
|
"Selecciona la cuenta donde se acumula el ahorro.",
|
||||||
)
|
)
|
||||||
cleaned["category"] = None
|
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 kind == Goal.KIND_BUDGET:
|
||||||
if cleaned.get("period") == Goal.PERIOD_NONE:
|
if cleaned.get("period") == Goal.PERIOD_NONE:
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
# Generated by Django 5.2.10 on 2026-10-08 14:42
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('expenses', '0016_alter_transfer_expense_leg_alter_transfer_income_leg'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='goal',
|
|
||||||
name='linked_expenses',
|
|
||||||
field=models.ManyToManyField(blank=True, related_name='goals', to='expenses.expense', verbose_name='Gastos vinculados'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='goal',
|
|
||||||
name='linked_incomes',
|
|
||||||
field=models.ManyToManyField(blank=True, related_name='goals', to='expenses.income', verbose_name='Ingresos vinculados'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='goal',
|
|
||||||
name='kind',
|
|
||||||
field=models.CharField(choices=[('payment', 'Pago / deuda'), ('budget', 'Presupuesto'), ('saving', 'Ahorro'), ('collection', 'Cobro pendiente')], default='payment', max_length=10),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -3,7 +3,7 @@ from decimal import Decimal
|
|||||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models import Q, Sum
|
from django.db.models import Sum
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.db.models.functions import ExtractMonth, Lower
|
from django.db.models.functions import ExtractMonth, Lower
|
||||||
@ -551,47 +551,17 @@ class FuelEntry(models.Model):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _deduped_total(movements):
|
|
||||||
"""Suma importes sin que el JOIN del M2M cuente la misma fila dos veces.
|
|
||||||
|
|
||||||
Un aggregate() sobre un queryset que lleva el JOIN de la vinculación dentro
|
|
||||||
suma cada movimiento tantas veces como objetivos tenga vinculados, y
|
|
||||||
.distinct() no lo arregla: el DISTINCT se aplica a las filas del SELECT
|
|
||||||
final y en un aggregate no hay filas que deduplicar, porque el JOIN ya
|
|
||||||
multiplicó antes de sumar. Sum("amount", distinct=True) es todavía peor,
|
|
||||||
porque deduplica por el valor del importe: dos gastos distintos de 10 € se
|
|
||||||
sumarían como uno.
|
|
||||||
|
|
||||||
Así que primero los ids (ahí el DISTINCT sí vale) y luego la suma. El
|
|
||||||
`pk__in` con un queryset dentro es una subconsulta, no un segundo viaje.
|
|
||||||
"""
|
|
||||||
ids = movements.values("pk").distinct()
|
|
||||||
total = movements.model.objects.filter(pk__in=ids).aggregate(
|
|
||||||
total=Sum("amount")
|
|
||||||
)["total"]
|
|
||||||
|
|
||||||
return total or Decimal("0")
|
|
||||||
|
|
||||||
|
|
||||||
class Goal(models.Model):
|
class Goal(models.Model):
|
||||||
KIND_PAYMENT = "payment"
|
KIND_PAYMENT = "payment"
|
||||||
KIND_BUDGET = "budget"
|
KIND_BUDGET = "budget"
|
||||||
KIND_SAVING = "saving"
|
KIND_SAVING = "saving"
|
||||||
KIND_COLLECTION = "collection"
|
|
||||||
|
|
||||||
KIND_CHOICES = [
|
KIND_CHOICES = [
|
||||||
(KIND_PAYMENT, "Pago / deuda"),
|
(KIND_PAYMENT, "Pago / deuda"),
|
||||||
(KIND_BUDGET, "Presupuesto"),
|
(KIND_BUDGET, "Presupuesto"),
|
||||||
(KIND_SAVING, "Ahorro"),
|
(KIND_SAVING, "Ahorro"),
|
||||||
(KIND_COLLECTION, "Cobro pendiente"),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Qué objetivos admiten vincular cada tipo de movimiento. Las dos vías de
|
|
||||||
# marcado (el formulario del movimiento y la pantalla del objetivo) leen de
|
|
||||||
# aquí: si cada una lo decidiera por su cuenta, acabarían divergiendo.
|
|
||||||
KINDS_LINKING_EXPENSES = (KIND_PAYMENT, KIND_BUDGET)
|
|
||||||
KINDS_LINKING_INCOMES = (KIND_COLLECTION,)
|
|
||||||
|
|
||||||
PERIOD_NONE = "none"
|
PERIOD_NONE = "none"
|
||||||
PERIOD_MONTH = "month"
|
PERIOD_MONTH = "month"
|
||||||
PERIOD_YEAR = "year"
|
PERIOD_YEAR = "year"
|
||||||
@ -634,57 +604,6 @@ class Goal(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
show_on_home = models.BooleanField(default=False)
|
show_on_home = models.BooleanField(default=False)
|
||||||
|
|
||||||
# Movimientos sueltos vinculados a mano. Conviven con la selección por
|
|
||||||
# categoría, no la sustituyen: un objetivo puede tirar de categoría, de
|
|
||||||
# movimientos sueltos, o de los dos a la vez.
|
|
||||||
#
|
|
||||||
# blank=True y no null: un M2M no admite null. Borrar el objetivo limpia la
|
|
||||||
# tabla intermedia y no toca los movimientos, que es lo que se quiere.
|
|
||||||
linked_expenses = models.ManyToManyField(
|
|
||||||
"Expense", blank=True, related_name="goals", verbose_name="Gastos vinculados"
|
|
||||||
)
|
|
||||||
linked_incomes = models.ManyToManyField(
|
|
||||||
"Income", blank=True, related_name="goals", verbose_name="Ingresos vinculados"
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def links_incomes(self):
|
|
||||||
"""Si este objetivo se mide con ingresos en vez de con gastos."""
|
|
||||||
return self.kind in self.KINDS_LINKING_INCOMES
|
|
||||||
|
|
||||||
@property
|
|
||||||
def links(self):
|
|
||||||
"""El M2M que le toca a este objetivo, para no repetir el if por ahí."""
|
|
||||||
return self.linked_incomes if self.links_incomes else self.linked_expenses
|
|
||||||
|
|
||||||
@property
|
|
||||||
def accepts_links(self):
|
|
||||||
"""Si vincularle movimientos significa algo.
|
|
||||||
|
|
||||||
El ahorro no: se mide con el saldo de su cuenta, así que un gasto
|
|
||||||
vinculado ahí quedaría guardado y no contaría en el progreso.
|
|
||||||
"""
|
|
||||||
return self.kind in self.KINDS_LINKING_EXPENSES + self.KINDS_LINKING_INCOMES
|
|
||||||
|
|
||||||
def candidate_movements(self):
|
|
||||||
"""Movimientos que este objetivo puede vincular.
|
|
||||||
|
|
||||||
Único sitio donde se decide qué es vinculable; lo usan las dos vías de
|
|
||||||
marcado. Fuera las patas de un traspaso: mover dinero entre cuentas
|
|
||||||
propias no es ni un pago ni un cobro. La comisión sí entra, porque es
|
|
||||||
gasto real (va con is_transfer=False) y cuenta en los análisis.
|
|
||||||
"""
|
|
||||||
model = Income if self.links_incomes else Expense
|
|
||||||
movements = model.objects.filter(owner_id=self.owner_id).exclude(
|
|
||||||
is_transfer=True
|
|
||||||
)
|
|
||||||
|
|
||||||
start = self._period_start()
|
|
||||||
if start:
|
|
||||||
movements = movements.filter(date__gte=start)
|
|
||||||
|
|
||||||
return movements
|
|
||||||
|
|
||||||
def _period_start(self):
|
def _period_start(self):
|
||||||
"""Fecha desde la que se cuenta el progreso, o None si es todo."""
|
"""Fecha desde la que se cuenta el progreso, o None si es todo."""
|
||||||
if self.kind == self.KIND_BUDGET:
|
if self.kind == self.KIND_BUDGET:
|
||||||
@ -713,46 +632,25 @@ class Goal(models.Model):
|
|||||||
# así que no filtres is_transfer en esta rama.
|
# así que no filtres is_transfer en esta rama.
|
||||||
return self.account.current_balance() if self.account else Decimal("0")
|
return self.account.current_balance() if self.account else Decimal("0")
|
||||||
|
|
||||||
if self.kind == self.KIND_COLLECTION:
|
if not self.category:
|
||||||
# Un cobro pendiente mide lo que te han devuelto, así que suma los
|
return Decimal("0")
|
||||||
# ingresos vinculados a mano. Aquí no hay selección por categoría
|
|
||||||
# que valga: Income no tiene categoría, que es justo el motivo de
|
|
||||||
# que exista la vinculación.
|
|
||||||
incomes = Income.objects.filter(goals=self, owner_id=self.owner_id).exclude(
|
|
||||||
is_transfer=True
|
|
||||||
)
|
|
||||||
|
|
||||||
return _deduped_total(self._since_start(incomes))
|
if self.include_subcategories:
|
||||||
|
category_ids = self.category.descendant_ids()
|
||||||
category_ids = []
|
else:
|
||||||
# Por _id y no por el objeto: mirar self.category traería la categoría
|
category_ids = [self.category_id]
|
||||||
# de la base de datos, una consulta por objetivo al pintar una lista.
|
|
||||||
if self.category_id:
|
|
||||||
if self.include_subcategories:
|
|
||||||
category_ids = self.category.descendant_ids()
|
|
||||||
else:
|
|
||||||
category_ids = [self.category_id]
|
|
||||||
|
|
||||||
# Un solo queryset con un OR, y no dos sumas por separado: un gasto que
|
|
||||||
# esté en la categoría del objetivo Y además vinculado a mano se
|
|
||||||
# contaría dos veces. No daría error, daría un número mal.
|
|
||||||
scope = Q(goals=self)
|
|
||||||
if category_ids:
|
|
||||||
scope |= Q(category_id__in=category_ids)
|
|
||||||
|
|
||||||
# Pago y presupuesto miden gasto real, así que los traspasos entre
|
# Pago y presupuesto miden gasto real, así que los traspasos entre
|
||||||
# cuentas propias no cuentan. La rama de ahorro de arriba es la opuesta.
|
# cuentas propias no cuentan. La rama de ahorro de arriba es la opuesta.
|
||||||
expenses = Expense.objects.filter(scope, owner_id=self.owner_id).exclude(
|
expenses = Expense.objects.filter(
|
||||||
is_transfer=True
|
owner=self.owner, category_id__in=category_ids
|
||||||
)
|
).exclude(is_transfer=True)
|
||||||
|
|
||||||
return _deduped_total(self._since_start(expenses))
|
|
||||||
|
|
||||||
def _since_start(self, movements):
|
|
||||||
"""Acota al periodo del objetivo. El cobro pendiente lo necesita igual."""
|
|
||||||
start = self._period_start()
|
start = self._period_start()
|
||||||
|
if start:
|
||||||
|
expenses = expenses.filter(date__gte=start)
|
||||||
|
|
||||||
return movements.filter(date__gte=start) if start else movements
|
return expenses.aggregate(total=Sum("amount"))["total"] or Decimal("0")
|
||||||
|
|
||||||
def percentage(self):
|
def percentage(self):
|
||||||
if not self.target_amount:
|
if not self.target_amount:
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{% for field in form %}
|
{% for field in form %}
|
||||||
<div class="form-field {% if field.field.widget.input_type == 'checkbox' %}form-field-checkbox{% endif %}">
|
<div class="form-field {% if field.field.widget.input_type == 'checkbox' %}form-field-checkbox{% endif %}">
|
||||||
{% if field.name == "tags" or field.name == "goals" %}
|
{% if field.name == "tags" %}
|
||||||
{{ field.label_tag }}
|
{{ field.label_tag }}
|
||||||
<div class="tag-chip-list">
|
<div class="tag-chip-list">
|
||||||
{% for checkbox in field %}
|
{% for checkbox in field %}
|
||||||
|
|||||||
@ -25,8 +25,7 @@
|
|||||||
Para ponerte un límite de gasto que no quieres superar. El progreso
|
Para ponerte un límite de gasto que no quieres superar. El progreso
|
||||||
se reinicia cada mes o cada año, según el periodo que elijas.
|
se reinicia cada mes o cada año, según el periodo que elijas.
|
||||||
Aquí acercarse al 100% es un aviso, no un logro: la barra pasa a
|
Aquí acercarse al 100% es un aviso, no un logro: la barra pasa a
|
||||||
naranja al llegar al 80% y a rojo si te pasas del límite. Cuenta
|
naranja al llegar al 80% y a rojo si te pasas del límite.
|
||||||
los gastos de su categoría y además los que le vincules a mano.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Ahorro</h2>
|
<h2>Ahorro</h2>
|
||||||
@ -36,46 +35,6 @@
|
|||||||
categoría.
|
categoría.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Cobro pendiente</h2>
|
|
||||||
<p>
|
|
||||||
El inverso del de pago: para dinero que te tienen que devolver y vas
|
|
||||||
cobrando a plazos. El progreso es la suma de los ingresos que le
|
|
||||||
vincules desde la fecha de inicio, y llegar al 100% es haberlo
|
|
||||||
cobrado todo. No lleva categoría ni cuenta, porque los ingresos no
|
|
||||||
tienen categoría: se mide solo con los ingresos que marques.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2>Vincular movimientos</h2>
|
|
||||||
<p>
|
|
||||||
Además de contar una categoría entera, un objetivo puede contar
|
|
||||||
gastos o ingresos concretos. Se marcan de dos maneras: con el campo
|
|
||||||
<strong>Objetivos</strong> del formulario del gasto o del ingreso, o
|
|
||||||
desde <strong>Vincular</strong> en la lista de objetivos, que permite
|
|
||||||
marcar varios de golpe.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Lo admiten tres tipos, y cada uno vincula lo que mide:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Pago / deuda</strong> y <strong>presupuesto</strong> — gastos.</li>
|
|
||||||
<li><strong>Cobro pendiente</strong> — ingresos.</li>
|
|
||||||
<li>
|
|
||||||
<strong>Ahorro</strong> — no admite vinculación: se mide con el
|
|
||||||
saldo de su cuenta, así que un gasto marcado ahí no contaría.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p>
|
|
||||||
La categoría y las vinculaciones conviven: un presupuesto de ocio
|
|
||||||
puede contar su categoría y además un gasto suelto de otra que
|
|
||||||
quieras que compute. Un gasto que esté en la categoría y también
|
|
||||||
vinculado a mano cuenta una sola vez.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Los dos movimientos de un traspaso no se pueden vincular (mover
|
|
||||||
dinero entre tus cuentas no es un gasto ni un cobro), pero su
|
|
||||||
comisión sí, porque es gasto real.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2>Los demás campos</h2>
|
<h2>Los demás campos</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
@ -92,8 +51,8 @@
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Start date</strong> — desde cuándo se cuentan los gastos
|
<strong>Start date</strong> — desde cuándo se cuentan los gastos
|
||||||
en un pago, o los ingresos en un cobro pendiente. En un
|
en un pago. En un presupuesto no se usa: manda el reinicio del
|
||||||
presupuesto no se usa: manda el reinicio del periodo.
|
periodo.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Period</strong> — solo para presupuesto: cada cuánto
|
<strong>Period</strong> — solo para presupuesto: cada cuánto
|
||||||
|
|||||||
@ -1,102 +0,0 @@
|
|||||||
{% extends "expenses/base.html" %}
|
|
||||||
|
|
||||||
{% block title %}
|
|
||||||
Vincular movimientos
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<h1>Vincular movimientos</h1>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Objetivo: <strong>{{ goal.name }}</strong> ({{ goal.get_kind_display|lower }}).
|
|
||||||
{% if goal.links_incomes %}
|
|
||||||
Marca los ingresos que cuentan como cobrados.
|
|
||||||
{% else %}
|
|
||||||
Marca los gastos que cuentan en este objetivo.
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Vinculado ahora mismo: <strong>{{ linked_total|floatformat:2 }}€</strong>
|
|
||||||
de {{ goal.target_amount|floatformat:2 }}€.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{% if goal.start_date %}
|
|
||||||
<p><small>Solo se listan movimientos desde el {{ goal.start_date|date:"d/m/Y" }}.</small></p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form method="post" class="app-form">
|
|
||||||
{% csrf_token %}
|
|
||||||
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th></th>
|
|
||||||
<th>Fecha</th>
|
|
||||||
<th>Importe</th>
|
|
||||||
<th>Cuenta</th>
|
|
||||||
<th>Concepto</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for movement in page_obj %}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
{# shown viaja con cada fila de la página: así al guardar solo #}
|
|
||||||
{# se toca lo que había aquí y no lo marcado en otras páginas. #}
|
|
||||||
<input type="hidden" name="shown" value="{{ movement.pk }}">
|
|
||||||
<input type="checkbox"
|
|
||||||
name="movements"
|
|
||||||
value="{{ movement.pk }}"
|
|
||||||
id="movement{{ movement.pk }}"
|
|
||||||
{% if movement.pk in linked_ids %}checked{% endif %}>
|
|
||||||
<label class="sr-only" for="movement{{ movement.pk }}">
|
|
||||||
Vincular movimiento del {{ movement.date|date:"d/m/Y" }}
|
|
||||||
</label>
|
|
||||||
</td>
|
|
||||||
<td>{{ movement.date|date:"d/m/Y" }}</td>
|
|
||||||
<td>{{ movement.amount|floatformat:2 }}€</td>
|
|
||||||
<td>{{ movement.account.name }}</td>
|
|
||||||
<td>
|
|
||||||
{% if goal.links_incomes %}
|
|
||||||
{{ movement.name }}
|
|
||||||
{% else %}
|
|
||||||
{{ movement.description|default:movement.category.name }}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% empty %}
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" class="empty-state">
|
|
||||||
<p>No hay movimientos que vincular</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit" class="btn btn-primary">Guardar vinculaciones</button>
|
|
||||||
<a class="btn btn-secondary" href="{% url 'goal_list' %}">Volver</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<nav class="pagination" aria-label="Paginación de movimientos">
|
|
||||||
<span class="step-links">
|
|
||||||
{% if page_obj.has_previous %}
|
|
||||||
<a href="?page=1">« Primero</a>
|
|
||||||
<a href="?page={{ page_obj.previous_page_number }}">Anterior</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<span>Página {{ page_obj.number }} de {{ page_obj.paginator.num_pages }}</span>
|
|
||||||
|
|
||||||
{% if page_obj.has_next %}
|
|
||||||
<a href="?page={{ page_obj.next_page_number }}">Siguiente</a>
|
|
||||||
<a href="?page={{ page_obj.paginator.num_pages }}">Último »</a>
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@ -76,9 +76,6 @@
|
|||||||
|
|
||||||
<td class="table-actions">
|
<td class="table-actions">
|
||||||
<a href="{% url 'goal_edit' goal.id %}">Editar</a>
|
<a href="{% url 'goal_edit' goal.id %}">Editar</a>
|
||||||
{% if goal.accepts_links %}
|
|
||||||
<a href="{% url 'goal_links' goal.id %}">Vincular</a>
|
|
||||||
{% endif %}
|
|
||||||
<a href="{% url 'goal_delete' goal.id %}" class="danger">Eliminar</a>
|
<a href="{% url 'goal_delete' goal.id %}" class="danger">Eliminar</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -1,749 +0,0 @@
|
|||||||
"""Vinculación de movimientos a objetivos y el tipo "cobro pendiente".
|
|
||||||
|
|
||||||
El test central es el del doble conteo: con la selección por categoría y la
|
|
||||||
vinculación a mano conviviendo, un gasto que esté en las dos tiene que contar
|
|
||||||
una sola vez. No da error si se cuenta dos veces, da un número mal, que es peor.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from django.db import connection
|
|
||||||
from django.test.utils import CaptureQueriesContext
|
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
from expenses.forms import ExpenseForm, GoalForm, IncomeForm
|
|
||||||
from expenses.models import Account, Category, Expense, Goal, Income, Transfer
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def savings(user):
|
|
||||||
return Account.objects.create(
|
|
||||||
owner=user, name="Ahorro", initial_balance=Decimal("0"), active=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def make_payment_goal(user, category=None, target="1000", start=date(2024, 1, 1)):
|
|
||||||
return Goal.objects.create(
|
|
||||||
owner=user,
|
|
||||||
name="Pago",
|
|
||||||
kind=Goal.KIND_PAYMENT,
|
|
||||||
category=category,
|
|
||||||
include_subcategories=False,
|
|
||||||
start_date=start,
|
|
||||||
target_amount=Decimal(target),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def make_budget_goal(user, category, target="100", period=Goal.PERIOD_MONTH):
|
|
||||||
return Goal.objects.create(
|
|
||||||
owner=user,
|
|
||||||
name="Presupuesto",
|
|
||||||
kind=Goal.KIND_BUDGET,
|
|
||||||
category=category,
|
|
||||||
include_subcategories=False,
|
|
||||||
period=period,
|
|
||||||
target_amount=Decimal(target),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def make_collection_goal(user, target="1000", start=date(2024, 1, 1)):
|
|
||||||
return Goal.objects.create(
|
|
||||||
owner=user,
|
|
||||||
name="Cobro",
|
|
||||||
kind=Goal.KIND_COLLECTION,
|
|
||||||
start_date=start,
|
|
||||||
target_amount=Decimal(target),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def make_expense(user, account, category, amount="10", on=date(2024, 5, 11)):
|
|
||||||
return Expense.objects.create(
|
|
||||||
owner=user,
|
|
||||||
account=account,
|
|
||||||
category=category,
|
|
||||||
amount=Decimal(amount),
|
|
||||||
date=on,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def make_income(user, account, amount="10", on=date(2024, 5, 11), name="Devolución"):
|
|
||||||
return Income.objects.create(
|
|
||||||
owner=user, account=account, name=name, amount=Decimal(amount), date=on
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# El doble conteo: el test central de la fase
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_an_expense_in_the_category_and_linked_by_hand_counts_once(
|
|
||||||
user, account, category
|
|
||||||
):
|
|
||||||
expense = make_expense(user, account, category, amount="100")
|
|
||||||
goal = make_payment_goal(user, category=category)
|
|
||||||
|
|
||||||
goal.linked_expenses.add(expense)
|
|
||||||
|
|
||||||
assert goal.progress == Decimal("100")
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_category_and_the_links_add_up_without_overlapping(
|
|
||||||
user, account, category
|
|
||||||
):
|
|
||||||
other_category = Category.objects.create(owner=user, name="Otra")
|
|
||||||
in_category = make_expense(user, account, category, amount="100")
|
|
||||||
linked_outside = make_expense(user, account, other_category, amount="30")
|
|
||||||
goal = make_payment_goal(user, category=category)
|
|
||||||
|
|
||||||
# El de la categoría también vinculado a mano, para que el solape exista.
|
|
||||||
goal.linked_expenses.add(in_category, linked_outside)
|
|
||||||
|
|
||||||
assert goal.progress == Decimal("130")
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_linked_expense_counted_in_several_goals_is_not_inflated(
|
|
||||||
user, account, category
|
|
||||||
):
|
|
||||||
expense = make_expense(user, account, category, amount="100")
|
|
||||||
goal = make_payment_goal(user, category=category)
|
|
||||||
other_goal = make_payment_goal(user, category=category)
|
|
||||||
|
|
||||||
# El JOIN del M2M devuelve una fila por objetivo vinculado: si se suma sin
|
|
||||||
# deduplicar, este gasto cuenta tantas veces como objetivos tenga.
|
|
||||||
goal.linked_expenses.add(expense)
|
|
||||||
other_goal.linked_expenses.add(expense)
|
|
||||||
|
|
||||||
assert goal.progress == Decimal("100")
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_goal_with_links_and_no_category_still_measures(user, account, category):
|
|
||||||
expense = make_expense(user, account, category, amount="40")
|
|
||||||
goal = make_payment_goal(user, category=None)
|
|
||||||
|
|
||||||
goal.linked_expenses.add(expense)
|
|
||||||
|
|
||||||
assert goal.progress == Decimal("40")
|
|
||||||
|
|
||||||
|
|
||||||
def test_links_respect_the_start_date(user, account, category):
|
|
||||||
old = make_expense(user, account, category, amount="99", on=date(2023, 5, 1))
|
|
||||||
goal = make_payment_goal(user, category=None, start=date(2024, 1, 1))
|
|
||||||
|
|
||||||
goal.linked_expenses.add(old)
|
|
||||||
|
|
||||||
assert goal.progress == Decimal("0")
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# Presupuesto: el caso con más riesgo de duplicar, porque siempre tiene
|
|
||||||
# categoría y además admite vinculaciones
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_budget_counts_a_linked_expense_of_its_own_category_once(
|
|
||||||
user, account, category
|
|
||||||
):
|
|
||||||
# Un presupuesto siempre lleva categoría, así que el solape con la
|
|
||||||
# vinculación a mano es el caso normal, no el raro.
|
|
||||||
expense = make_expense(user, account, category, amount="30", on=date.today())
|
|
||||||
goal = make_budget_goal(user, category, target="100")
|
|
||||||
|
|
||||||
goal.linked_expenses.add(expense)
|
|
||||||
|
|
||||||
assert goal.progress == Decimal("30")
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_budget_adds_a_linked_expense_from_another_category(
|
|
||||||
user, account, category
|
|
||||||
):
|
|
||||||
other_category = Category.objects.create(owner=user, name="Ocio")
|
|
||||||
make_expense(user, account, category, amount="30", on=date.today())
|
|
||||||
outside = make_expense(user, account, other_category, amount="20", on=date.today())
|
|
||||||
goal = make_budget_goal(user, category, target="100")
|
|
||||||
|
|
||||||
# El caso que motiva vincular en un presupuesto: un gasto de otra categoría
|
|
||||||
# que quieres que compute aquí.
|
|
||||||
goal.linked_expenses.add(outside)
|
|
||||||
|
|
||||||
assert goal.progress == Decimal("50")
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_budget_blown_by_a_linked_expense_is_still_flagged(user, account, category):
|
|
||||||
other_category = Category.objects.create(owner=user, name="Ocio")
|
|
||||||
outside = make_expense(user, account, other_category, amount="120", on=date.today())
|
|
||||||
goal = make_budget_goal(user, category, target="100")
|
|
||||||
|
|
||||||
goal.linked_expenses.add(outside)
|
|
||||||
|
|
||||||
assert goal.progress == Decimal("120")
|
|
||||||
assert goal.is_exceeded() is True
|
|
||||||
assert goal.progress_state() == "danger"
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_budget_accepts_links(user, category):
|
|
||||||
assert make_budget_goal(user, category).accepts_links is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_only_the_budget_inverts_the_criterion(user, account, category):
|
|
||||||
# Pasado del objetivo: el presupuesto lo marca como excedido, el pago lo
|
|
||||||
# considera conseguido. Solo el presupuesto invierte el criterio.
|
|
||||||
over_budget = make_budget_goal(user, category, target="100")
|
|
||||||
over_budget.linked_expenses.add(
|
|
||||||
make_expense(user, account, category, amount="120", on=date.today())
|
|
||||||
)
|
|
||||||
over_payment = make_payment_goal(user, category=None, target="100")
|
|
||||||
over_payment.linked_expenses.add(
|
|
||||||
make_expense(user, account, category, amount="120")
|
|
||||||
)
|
|
||||||
|
|
||||||
assert over_budget.is_exceeded() is True
|
|
||||||
assert over_budget.progress_state() == "danger"
|
|
||||||
assert over_payment.is_exceeded() is False
|
|
||||||
assert over_payment.progress_state() == "complete"
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_budget_at_eighty_percent_warns(user, account, category):
|
|
||||||
goal = make_budget_goal(user, category, target="100")
|
|
||||||
goal.linked_expenses.add(
|
|
||||||
make_expense(user, account, category, amount="80", on=date.today())
|
|
||||||
)
|
|
||||||
|
|
||||||
assert goal.progress_state() == "warning"
|
|
||||||
assert goal.is_exceeded() is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_linked_expense_outside_the_budget_period_does_not_count(
|
|
||||||
user, account, category
|
|
||||||
):
|
|
||||||
other_category = Category.objects.create(owner=user, name="Ocio")
|
|
||||||
old = make_expense(
|
|
||||||
user, account, other_category, amount="50", on=date(2020, 1, 15)
|
|
||||||
)
|
|
||||||
goal = make_budget_goal(user, category, target="100")
|
|
||||||
|
|
||||||
# El presupuesto se reinicia cada mes: vincular algo viejo no lo resucita.
|
|
||||||
goal.linked_expenses.add(old)
|
|
||||||
|
|
||||||
assert goal.progress == Decimal("0")
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# Cobro pendiente
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_collection_goal_sums_its_linked_incomes(user, account):
|
|
||||||
income = make_income(user, account, amount="250")
|
|
||||||
goal = make_collection_goal(user)
|
|
||||||
|
|
||||||
goal.linked_incomes.add(income)
|
|
||||||
|
|
||||||
assert goal.progress == Decimal("250")
|
|
||||||
|
|
||||||
|
|
||||||
def test_an_income_outside_the_collection_goal_does_not_count(user, account):
|
|
||||||
make_income(user, account, amount="250")
|
|
||||||
goal = make_collection_goal(user)
|
|
||||||
|
|
||||||
assert goal.progress == Decimal("0")
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_collection_goal_respects_the_start_date(user, account):
|
|
||||||
old = make_income(user, account, amount="250", on=date(2023, 1, 1))
|
|
||||||
goal = make_collection_goal(user, start=date(2024, 1, 1))
|
|
||||||
|
|
||||||
goal.linked_incomes.add(old)
|
|
||||||
|
|
||||||
assert goal.progress == Decimal("0")
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_collection_goal_at_half_way_is_not_flagged(user, account):
|
|
||||||
goal = make_collection_goal(user, target="100")
|
|
||||||
goal.linked_incomes.add(make_income(user, account, amount="50"))
|
|
||||||
|
|
||||||
assert goal.percentage() == Decimal("50")
|
|
||||||
# Acercarse al 100% es bueno aquí, como en pago y ahorro: solo el
|
|
||||||
# presupuesto invierte el criterio.
|
|
||||||
assert goal.progress_state() == "ok"
|
|
||||||
assert goal.is_exceeded() is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_fully_collected_goal_is_complete(user, account):
|
|
||||||
goal = make_collection_goal(user, target="100")
|
|
||||||
goal.linked_incomes.add(make_income(user, account, amount="100"))
|
|
||||||
|
|
||||||
assert goal.progress_state() == "complete"
|
|
||||||
assert goal.is_exceeded() is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_new_kind_is_offered_in_the_goal_list_filter(auth_client, user):
|
|
||||||
make_collection_goal(user)
|
|
||||||
make_payment_goal(user)
|
|
||||||
|
|
||||||
response = auth_client.get(reverse("goal_list"), {"kind": Goal.KIND_COLLECTION})
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert [g.kind for g in response.context["goals"]] == [Goal.KIND_COLLECTION]
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_collection_goal_needs_a_start_date(user):
|
|
||||||
form = GoalForm(
|
|
||||||
{
|
|
||||||
"name": "Cobro",
|
|
||||||
"kind": Goal.KIND_COLLECTION,
|
|
||||||
"target_amount": "100",
|
|
||||||
"period": Goal.PERIOD_NONE,
|
|
||||||
},
|
|
||||||
user=user,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert not form.is_valid()
|
|
||||||
assert "start_date" in form.errors
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_collection_goal_needs_neither_category_nor_account(user, category):
|
|
||||||
form = GoalForm(
|
|
||||||
{
|
|
||||||
"name": "Cobro",
|
|
||||||
"kind": Goal.KIND_COLLECTION,
|
|
||||||
"target_amount": "100",
|
|
||||||
"start_date": "2024-01-01",
|
|
||||||
"period": Goal.PERIOD_NONE,
|
|
||||||
},
|
|
||||||
user=user,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert form.is_valid(), form.errors
|
|
||||||
goal = form.save(commit=False)
|
|
||||||
assert goal.category is None
|
|
||||||
assert goal.account is None
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# Qué es vinculable: las patas del traspaso no, la comisión sí
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_transfer_leg_is_not_a_candidate(user, account, savings, category):
|
|
||||||
transfer = Transfer(
|
|
||||||
owner=user,
|
|
||||||
date=date(2024, 5, 11),
|
|
||||||
amount=Decimal("400"),
|
|
||||||
from_account=account,
|
|
||||||
to_account=savings,
|
|
||||||
)
|
|
||||||
transfer.sync()
|
|
||||||
goal = make_payment_goal(user, category=category)
|
|
||||||
|
|
||||||
candidates = list(goal.candidate_movements())
|
|
||||||
|
|
||||||
# Mover dinero entre cuentas propias no es ni un pago ni un cobro.
|
|
||||||
assert transfer.expense_leg not in candidates
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_income_leg_is_not_a_candidate_for_a_collection_goal(
|
|
||||||
user, account, savings
|
|
||||||
):
|
|
||||||
transfer = Transfer(
|
|
||||||
owner=user,
|
|
||||||
date=date(2024, 5, 11),
|
|
||||||
amount=Decimal("400"),
|
|
||||||
from_account=account,
|
|
||||||
to_account=savings,
|
|
||||||
)
|
|
||||||
transfer.sync()
|
|
||||||
goal = make_collection_goal(user)
|
|
||||||
|
|
||||||
assert transfer.income_leg not in list(goal.candidate_movements())
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_transfer_fee_is_a_candidate(user, account, savings, category):
|
|
||||||
transfer = Transfer(
|
|
||||||
owner=user,
|
|
||||||
date=date(2024, 5, 11),
|
|
||||||
amount=Decimal("400"),
|
|
||||||
from_account=account,
|
|
||||||
to_account=savings,
|
|
||||||
fee_amount=Decimal("1.50"),
|
|
||||||
)
|
|
||||||
transfer.sync()
|
|
||||||
goal = make_payment_goal(user, category=category)
|
|
||||||
|
|
||||||
# La comisión es gasto real: va con is_transfer=False y cuenta en análisis.
|
|
||||||
assert transfer.fee_expense in list(goal.candidate_movements())
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_saving_goal_does_not_offer_linking(user, savings):
|
|
||||||
goal = Goal.objects.create(
|
|
||||||
owner=user,
|
|
||||||
name="Ahorro",
|
|
||||||
kind=Goal.KIND_SAVING,
|
|
||||||
account=savings,
|
|
||||||
target_amount=Decimal("100"),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert goal.accepts_links is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_linking_screen_turns_away_a_saving_goal(auth_client, user, savings):
|
|
||||||
goal = Goal.objects.create(
|
|
||||||
owner=user,
|
|
||||||
name="Ahorro",
|
|
||||||
kind=Goal.KIND_SAVING,
|
|
||||||
account=savings,
|
|
||||||
target_amount=Decimal("100"),
|
|
||||||
)
|
|
||||||
|
|
||||||
response = auth_client.get(reverse("goal_links", args=[goal.pk]))
|
|
||||||
|
|
||||||
assert response.status_code == 302
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# Vía 1: el formulario del movimiento
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_expense_form_only_offers_goals_that_take_expenses(
|
|
||||||
user, savings, category
|
|
||||||
):
|
|
||||||
payment = make_payment_goal(user, category=category)
|
|
||||||
budget = make_budget_goal(user, category)
|
|
||||||
collection = make_collection_goal(user)
|
|
||||||
saving = Goal.objects.create(
|
|
||||||
owner=user,
|
|
||||||
name="Ahorro",
|
|
||||||
kind=Goal.KIND_SAVING,
|
|
||||||
account=savings,
|
|
||||||
target_amount=Decimal("100"),
|
|
||||||
)
|
|
||||||
|
|
||||||
offered = list(ExpenseForm(user=user).fields["goals"].queryset)
|
|
||||||
|
|
||||||
assert payment in offered
|
|
||||||
assert budget in offered
|
|
||||||
assert collection not in offered
|
|
||||||
assert saving not in offered
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_goal_list_offers_linking_on_a_budget(auth_client, user, category):
|
|
||||||
goal = make_budget_goal(user, category)
|
|
||||||
|
|
||||||
response = auth_client.get(reverse("goal_list"))
|
|
||||||
|
|
||||||
assert reverse("goal_links", args=[goal.pk]) in response.content.decode()
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_income_form_only_offers_collection_goals(user, category):
|
|
||||||
collection = make_collection_goal(user)
|
|
||||||
payment = make_payment_goal(user, category=category)
|
|
||||||
|
|
||||||
offered = list(IncomeForm(user=user).fields["goals"].queryset)
|
|
||||||
|
|
||||||
assert collection in offered
|
|
||||||
assert payment not in offered
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_expense_form_does_not_offer_another_users_goals(
|
|
||||||
user, category, django_user_model
|
|
||||||
):
|
|
||||||
intruder = django_user_model.objects.create_user(username="otro", password="x")
|
|
||||||
theirs = make_payment_goal(intruder, category=None)
|
|
||||||
|
|
||||||
assert theirs not in list(ExpenseForm(user=user).fields["goals"].queryset)
|
|
||||||
|
|
||||||
|
|
||||||
def test_creating_an_expense_links_it_to_the_chosen_goal(
|
|
||||||
auth_client, user, account, category
|
|
||||||
):
|
|
||||||
goal = make_payment_goal(user, category=None)
|
|
||||||
|
|
||||||
response = auth_client.post(
|
|
||||||
reverse("expense_create"),
|
|
||||||
{
|
|
||||||
"date": "2024-05-11",
|
|
||||||
"amount": "75",
|
|
||||||
"category": category.pk,
|
|
||||||
"account": account.pk,
|
|
||||||
"description": "Compra",
|
|
||||||
"goals": [goal.pk],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 302
|
|
||||||
assert goal.progress == Decimal("75")
|
|
||||||
|
|
||||||
|
|
||||||
def test_creating_an_income_links_it_to_the_chosen_goal(auth_client, user, account):
|
|
||||||
goal = make_collection_goal(user)
|
|
||||||
|
|
||||||
response = auth_client.post(
|
|
||||||
reverse("income_create"),
|
|
||||||
{
|
|
||||||
"date": "2024-05-11",
|
|
||||||
"amount": "75",
|
|
||||||
"account": account.pk,
|
|
||||||
"name": "Devolución",
|
|
||||||
"goals": [goal.pk],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 302
|
|
||||||
assert goal.progress == Decimal("75")
|
|
||||||
|
|
||||||
|
|
||||||
def test_editing_an_expense_can_unlink_it(auth_client, user, account, category):
|
|
||||||
expense = make_expense(user, account, category, amount="75")
|
|
||||||
goal = make_payment_goal(user, category=None)
|
|
||||||
goal.linked_expenses.add(expense)
|
|
||||||
|
|
||||||
auth_client.post(
|
|
||||||
reverse("expense_edit", args=[expense.pk]),
|
|
||||||
{
|
|
||||||
"date": "2024-05-11",
|
|
||||||
"amount": "75",
|
|
||||||
"category": category.pk,
|
|
||||||
"account": account.pk,
|
|
||||||
"description": "",
|
|
||||||
# Sin "goals": se queda sin vincular.
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert goal.linked_expenses.count() == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_expense_form_shows_the_current_links_when_editing(
|
|
||||||
user, account, category
|
|
||||||
):
|
|
||||||
expense = make_expense(user, account, category)
|
|
||||||
goal = make_payment_goal(user, category=None)
|
|
||||||
goal.linked_expenses.add(expense)
|
|
||||||
|
|
||||||
form = ExpenseForm(instance=expense, user=user)
|
|
||||||
|
|
||||||
assert form.initial["goals"] == [goal.pk]
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# Vía 2: la pantalla del objetivo
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_linking_screen_lists_the_candidates(auth_client, user, account, category):
|
|
||||||
expense = make_expense(user, account, category)
|
|
||||||
goal = make_payment_goal(user, category=None)
|
|
||||||
|
|
||||||
response = auth_client.get(reverse("goal_links", args=[goal.pk]))
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert expense in list(response.context["page_obj"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_linking_from_the_goal_screen_saves(auth_client, user, account, category):
|
|
||||||
expense = make_expense(user, account, category, amount="60")
|
|
||||||
goal = make_payment_goal(user, category=None)
|
|
||||||
|
|
||||||
auth_client.post(
|
|
||||||
reverse("goal_links", args=[goal.pk]),
|
|
||||||
{"shown": [expense.pk], "movements": [expense.pk]},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert list(goal.linked_expenses.all()) == [expense]
|
|
||||||
|
|
||||||
|
|
||||||
def test_both_ways_of_linking_end_up_the_same(auth_client, user, account, category):
|
|
||||||
first = make_expense(user, account, category, amount="60")
|
|
||||||
second = make_expense(user, account, category, amount="60")
|
|
||||||
by_screen = make_payment_goal(user, category=None)
|
|
||||||
by_form = make_payment_goal(user, category=None)
|
|
||||||
|
|
||||||
auth_client.post(
|
|
||||||
reverse("goal_links", args=[by_screen.pk]),
|
|
||||||
{"shown": [first.pk], "movements": [first.pk]},
|
|
||||||
)
|
|
||||||
auth_client.post(
|
|
||||||
reverse("expense_edit", args=[second.pk]),
|
|
||||||
{
|
|
||||||
"date": second.date.isoformat(),
|
|
||||||
"amount": "60",
|
|
||||||
"category": category.pk,
|
|
||||||
"account": account.pk,
|
|
||||||
"description": "",
|
|
||||||
"goals": [by_form.pk],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert by_screen.progress == by_form.progress == Decimal("60")
|
|
||||||
|
|
||||||
|
|
||||||
def test_unchecking_on_the_goal_screen_unlinks(auth_client, user, account, category):
|
|
||||||
expense = make_expense(user, account, category)
|
|
||||||
goal = make_payment_goal(user, category=None)
|
|
||||||
goal.linked_expenses.add(expense)
|
|
||||||
|
|
||||||
auth_client.post(
|
|
||||||
reverse("goal_links", args=[goal.pk]),
|
|
||||||
{"shown": [expense.pk]},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert goal.linked_expenses.count() == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_get_on_the_linking_screen_saves_nothing(
|
|
||||||
auth_client, user, account, category
|
|
||||||
):
|
|
||||||
expense = make_expense(user, account, category)
|
|
||||||
goal = make_payment_goal(user, category=None)
|
|
||||||
|
|
||||||
auth_client.get(
|
|
||||||
reverse("goal_links", args=[goal.pk]),
|
|
||||||
{"shown": [expense.pk], "movements": [expense.pk]},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert goal.linked_expenses.count() == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_saving_one_page_leaves_the_other_pages_alone(
|
|
||||||
auth_client, user, account, category
|
|
||||||
):
|
|
||||||
# La pantalla pagina de 10 en 10: un set() a secas con lo marcado en una
|
|
||||||
# página desvincularía todo lo de las demás.
|
|
||||||
expenses = [
|
|
||||||
make_expense(user, account, category, on=date(2024, 5, day))
|
|
||||||
for day in range(1, 15)
|
|
||||||
]
|
|
||||||
goal = make_payment_goal(user, category=None)
|
|
||||||
goal.linked_expenses.set(expenses)
|
|
||||||
|
|
||||||
first_page = list(auth_client.get(reverse("goal_links", args=[goal.pk])).context["page_obj"])
|
|
||||||
# Se reenvía la primera página sin ninguna marca: solo esos se desvinculan.
|
|
||||||
auth_client.post(
|
|
||||||
reverse("goal_links", args=[goal.pk]),
|
|
||||||
{"shown": [m.pk for m in first_page]},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert goal.linked_expenses.count() == len(expenses) - len(first_page)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# Aislamiento por usuario
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_another_users_movement_cannot_be_linked_by_post(
|
|
||||||
auth_client, user, django_user_model
|
|
||||||
):
|
|
||||||
intruder = django_user_model.objects.create_user(username="otro", password="x")
|
|
||||||
their_account = Account.objects.create(
|
|
||||||
owner=intruder, name="Ajena", initial_balance=Decimal("0"), active=True
|
|
||||||
)
|
|
||||||
their_category = Category.objects.create(owner=intruder, name="Ajena")
|
|
||||||
theirs = make_expense(intruder, their_account, their_category, amount="500")
|
|
||||||
goal = make_payment_goal(user, category=None)
|
|
||||||
|
|
||||||
response = auth_client.post(
|
|
||||||
reverse("goal_links", args=[goal.pk]),
|
|
||||||
{"shown": [theirs.pk], "movements": [theirs.pk]},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 302
|
|
||||||
assert goal.linked_expenses.count() == 0
|
|
||||||
assert goal.progress == Decimal("0")
|
|
||||||
|
|
||||||
|
|
||||||
def test_another_users_goal_cannot_be_opened(auth_client, django_user_model):
|
|
||||||
intruder = django_user_model.objects.create_user(username="otro", password="x")
|
|
||||||
theirs = make_payment_goal(intruder, category=None)
|
|
||||||
|
|
||||||
response = auth_client.get(reverse("goal_links", args=[theirs.pk]))
|
|
||||||
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
def test_another_users_goal_cannot_be_chosen_from_the_expense_form(
|
|
||||||
auth_client, user, account, category, django_user_model
|
|
||||||
):
|
|
||||||
intruder = django_user_model.objects.create_user(username="otro", password="x")
|
|
||||||
theirs = make_payment_goal(intruder, category=None)
|
|
||||||
|
|
||||||
auth_client.post(
|
|
||||||
reverse("expense_create"),
|
|
||||||
{
|
|
||||||
"date": "2024-05-11",
|
|
||||||
"amount": "75",
|
|
||||||
"category": category.pk,
|
|
||||||
"account": account.pk,
|
|
||||||
"description": "Compra",
|
|
||||||
"goals": [theirs.pk],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert theirs.linked_expenses.count() == 0
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# Borrado y rendimiento
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_deleting_a_goal_keeps_the_linked_movements(user, account, category):
|
|
||||||
expense = make_expense(user, account, category)
|
|
||||||
goal = make_payment_goal(user, category=None)
|
|
||||||
goal.linked_expenses.add(expense)
|
|
||||||
|
|
||||||
goal.delete()
|
|
||||||
|
|
||||||
# Se limpia la tabla intermedia y el gasto sigue ahí.
|
|
||||||
expense.refresh_from_db()
|
|
||||||
assert Expense.objects.filter(pk=expense.pk).exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_deleting_a_linked_movement_leaves_the_goal_alone(user, account, category):
|
|
||||||
expense = make_expense(user, account, category)
|
|
||||||
goal = make_payment_goal(user, category=None)
|
|
||||||
goal.linked_expenses.add(expense)
|
|
||||||
|
|
||||||
expense.delete()
|
|
||||||
goal.refresh_from_db()
|
|
||||||
|
|
||||||
assert goal.linked_expenses.count() == 0
|
|
||||||
assert goal.progress == Decimal("0")
|
|
||||||
|
|
||||||
|
|
||||||
def _queries_painting(auth_client, user, account, category, goals, links_each):
|
|
||||||
"""Consultas que cuesta pintar goal_list con esos objetivos y vinculaciones."""
|
|
||||||
Goal.objects.filter(owner=user).delete()
|
|
||||||
for _ in range(goals):
|
|
||||||
goal = make_payment_goal(user, category=None)
|
|
||||||
goal.linked_expenses.set(
|
|
||||||
[make_expense(user, account, category) for _ in range(links_each)]
|
|
||||||
)
|
|
||||||
|
|
||||||
with CaptureQueriesContext(connection) as captured:
|
|
||||||
assert auth_client.get(reverse("goal_list")).status_code == 200
|
|
||||||
|
|
||||||
return len(captured)
|
|
||||||
|
|
||||||
|
|
||||||
def test_linked_movements_do_not_cost_a_query_each(
|
|
||||||
auth_client, user, account, category
|
|
||||||
):
|
|
||||||
# El N+1 que reaparece con el M2M: una consulta por movimiento vinculado al
|
|
||||||
# pintar la lista. Triplicar las vinculaciones no debe mover el número.
|
|
||||||
few = _queries_painting(auth_client, user, account, category, 2, 1)
|
|
||||||
many = _queries_painting(auth_client, user, account, category, 2, 3)
|
|
||||||
|
|
||||||
assert few == many
|
|
||||||
|
|
||||||
|
|
||||||
def test_each_extra_goal_costs_a_single_query(auth_client, user, account, category):
|
|
||||||
# Cada objetivo cuesta el agregado de su progress(), y nada más.
|
|
||||||
two = _queries_painting(auth_client, user, account, category, 2, 2)
|
|
||||||
four = _queries_painting(auth_client, user, account, category, 4, 2)
|
|
||||||
|
|
||||||
assert four - two == 2
|
|
||||||
@ -39,6 +39,5 @@ urlpatterns = [
|
|||||||
path('goals/new/', views.goal_create, name='goal_create'),
|
path('goals/new/', views.goal_create, name='goal_create'),
|
||||||
path('goals/<int:pk>/edit/', views.goal_edit, name='goal_edit'),
|
path('goals/<int:pk>/edit/', views.goal_edit, name='goal_edit'),
|
||||||
path('goals/<int:pk>/delete/', views.goal_delete, name='goal_delete'),
|
path('goals/<int:pk>/delete/', views.goal_delete, name='goal_delete'),
|
||||||
path('goals/<int:pk>/links/', views.goal_links, name='goal_links'),
|
|
||||||
path('password_help', TemplateView.as_view(template_name="registration/password_help.html"), name="password_help"),
|
path('password_help', TemplateView.as_view(template_name="registration/password_help.html"), name="password_help"),
|
||||||
]
|
]
|
||||||
@ -1107,9 +1107,6 @@ def income_create(request):
|
|||||||
income = form.save(commit=False)
|
income = form.save(commit=False)
|
||||||
income.owner = request.user
|
income.owner = request.user
|
||||||
income.save()
|
income.save()
|
||||||
# Hace falta por el campo de objetivos: sin esto la vinculación
|
|
||||||
# hecha al crear se perdería sin avisar.
|
|
||||||
form.save_m2m()
|
|
||||||
messages.success(request, "Ingreso creado correctamente.")
|
messages.success(request, "Ingreso creado correctamente.")
|
||||||
return redirect("income_list")
|
return redirect("income_list")
|
||||||
else:
|
else:
|
||||||
@ -1489,9 +1486,7 @@ def category_delete(request, pk):
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def goal_list(request):
|
def goal_list(request):
|
||||||
goals = Goal.objects.filter(owner=request.user).select_related(
|
goals = Goal.objects.filter(owner=request.user)
|
||||||
"category", "account"
|
|
||||||
)
|
|
||||||
|
|
||||||
selected_kind = request.GET.get("kind") or ""
|
selected_kind = request.GET.get("kind") or ""
|
||||||
if selected_kind in dict(Goal.KIND_CHOICES):
|
if selected_kind in dict(Goal.KIND_CHOICES):
|
||||||
@ -1583,64 +1578,6 @@ def goal_delete(request, pk):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
def goal_links(request, pk):
|
|
||||||
"""Vincular movimientos a un objetivo de golpe, desde su ficha.
|
|
||||||
|
|
||||||
La otra vía de marcado es el campo "Objetivos" del formulario del
|
|
||||||
movimiento. Las dos escriben en la misma tabla y las dos preguntan a
|
|
||||||
Goal.candidate_movements() qué es vinculable.
|
|
||||||
"""
|
|
||||||
goal = get_object_or_404(Goal, pk=pk, owner=request.user)
|
|
||||||
|
|
||||||
if not goal.accepts_links:
|
|
||||||
messages.error(
|
|
||||||
request,
|
|
||||||
"Un objetivo de ahorro se mide con el saldo de su cuenta, así que no "
|
|
||||||
"se le vinculan movimientos.",
|
|
||||||
)
|
|
||||||
return redirect("goal_list")
|
|
||||||
|
|
||||||
candidates = goal.candidate_movements()
|
|
||||||
|
|
||||||
if request.method == "POST":
|
|
||||||
shown = {int(v) for v in request.POST.getlist("shown") if v.isdigit()}
|
|
||||||
checked = {int(v) for v in request.POST.getlist("movements") if v.isdigit()}
|
|
||||||
|
|
||||||
# Se parte de lo que de verdad podía marcarse en la página enviada: así
|
|
||||||
# un id de otro usuario o de un movimiento no vinculable se cae solo.
|
|
||||||
allowed = set(
|
|
||||||
candidates.filter(pk__in=shown).values_list("pk", flat=True)
|
|
||||||
)
|
|
||||||
# Solo se toca lo de esa página: con paginación, un set() a secas
|
|
||||||
# desvincularía lo marcado en las demás.
|
|
||||||
goal.links.add(*(checked & allowed))
|
|
||||||
goal.links.remove(*(allowed - checked))
|
|
||||||
|
|
||||||
messages.success(request, "Movimientos vinculados actualizados.")
|
|
||||||
return redirect("goal_links", pk=goal.pk)
|
|
||||||
|
|
||||||
movements = candidates.select_related("account").order_by("-date", "-id")
|
|
||||||
paginator = Paginator(movements, 10)
|
|
||||||
page_obj = paginator.get_page(request.GET.get("page"))
|
|
||||||
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"goals/links.html",
|
|
||||||
{
|
|
||||||
"active_menu": "settings",
|
|
||||||
"goal": goal,
|
|
||||||
"page_obj": page_obj,
|
|
||||||
"linked_ids": set(goal.links.values_list("pk", flat=True)),
|
|
||||||
# Solo lo vinculado a mano, que es lo que se está tocando aquí; el
|
|
||||||
# progreso del objetivo puede llevar además los gastos de su
|
|
||||||
# categoría.
|
|
||||||
"linked_total": goal.links.aggregate(total=Sum("amount"))["total"]
|
|
||||||
or Decimal("0"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def settings_index(request):
|
def settings_index(request):
|
||||||
return render(request, "settings/index.html", {"active_menu": "settings"})
|
return render(request, "settings/index.html", {"active_menu": "settings"})
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user