Merge pull request 'Modificación y mejora de objetivos' (#41) from dev into main
All checks were successful
Tests / CI / Run tests passed: 293
expenses_manager/pipeline/head This commit looks good

Reviewed-on: #41
This commit is contained in:
jkuijperm 2026-10-13 13:36:11 +00:00
commit 6cdc7f662f
11 changed files with 1165 additions and 21 deletions

5
.gitignore vendored
View File

@ -162,4 +162,7 @@ cython_debug/
#.idea/
staticfiles/
*.bak
*.bak
# graphify
graphify-out/

View File

@ -25,7 +25,43 @@ def _hide_is_transfer_when_editing(form):
form.fields.pop("is_transfer", None)
class ExpenseForm(forms.ModelForm):
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
@ -40,6 +76,7 @@ class ExpenseForm(forms.ModelForm):
owner=user, active=True
)
self.fields["date"].input_formats = ["%Y-%m-%d"]
self.setup_goal_links(user)
_hide_is_transfer_when_editing(self)
@ -73,7 +110,9 @@ class AccountForm(forms.ModelForm):
fields = ["name", "initial_balance", "active"]
class IncomeForm(forms.ModelForm):
class IncomeForm(_GoalLinksMixin, forms.ModelForm):
goal_kinds = Goal.KINDS_LINKING_INCOMES
class Meta:
model = Income
fields = ["account", "name", "amount", "date", "is_transfer"]
@ -86,6 +125,7 @@ class IncomeForm(forms.ModelForm):
owner=user,
active=True,
)
self.setup_goal_links(user)
_hide_is_transfer_when_editing(self)
@ -225,6 +265,18 @@ class GoalForm(forms.ModelForm):
"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:

View File

@ -0,0 +1,28 @@
# 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),
),
]

View File

@ -3,7 +3,7 @@ from decimal import Decimal
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import models, transaction
from django.conf import settings
from django.db.models import Sum
from django.db.models import Q, Sum
from functools import cached_property
from django.utils.text import slugify
from django.db.models.functions import ExtractMonth, Lower
@ -551,17 +551,47 @@ class FuelEntry(models.Model):
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):
KIND_PAYMENT = "payment"
KIND_BUDGET = "budget"
KIND_SAVING = "saving"
KIND_COLLECTION = "collection"
KIND_CHOICES = [
(KIND_PAYMENT, "Pago / deuda"),
(KIND_BUDGET, "Presupuesto"),
(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_MONTH = "month"
PERIOD_YEAR = "year"
@ -604,6 +634,57 @@ class Goal(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
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):
"""Fecha desde la que se cuenta el progreso, o None si es todo."""
if self.kind == self.KIND_BUDGET:
@ -632,25 +713,46 @@ class Goal(models.Model):
# así que no filtres is_transfer en esta rama.
return self.account.current_balance() if self.account else Decimal("0")
if not self.category:
return Decimal("0")
if self.kind == self.KIND_COLLECTION:
# Un cobro pendiente mide lo que te han devuelto, así que suma los
# 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
)
if self.include_subcategories:
category_ids = self.category.descendant_ids()
else:
category_ids = [self.category_id]
return _deduped_total(self._since_start(incomes))
category_ids = []
# Por _id y no por el objeto: mirar self.category traería la categoría
# 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
# cuentas propias no cuentan. La rama de ahorro de arriba es la opuesta.
expenses = Expense.objects.filter(
owner=self.owner, category_id__in=category_ids
).exclude(is_transfer=True)
expenses = Expense.objects.filter(scope, owner_id=self.owner_id).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()
if start:
expenses = expenses.filter(date__gte=start)
return expenses.aggregate(total=Sum("amount"))["total"] or Decimal("0")
return movements.filter(date__gte=start) if start else movements
def percentage(self):
if not self.target_amount:

View File

@ -1,6 +1,6 @@
{% for field in form %}
<div class="form-field {% if field.field.widget.input_type == 'checkbox' %}form-field-checkbox{% endif %}">
{% if field.name == "tags" %}
{% if field.name == "tags" or field.name == "goals" %}
{{ field.label_tag }}
<div class="tag-chip-list">
{% for checkbox in field %}

View File

@ -25,7 +25,8 @@
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.
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.
naranja al llegar al 80% y a rojo si te pasas del límite. Cuenta
los gastos de su categoría y además los que le vincules a mano.
</p>
<h2>Ahorro</h2>
@ -35,6 +36,46 @@
categoría.
</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>
<ul>
<li>
@ -51,8 +92,8 @@
</li>
<li>
<strong>Start date</strong> — desde cuándo se cuentan los gastos
en un pago. En un presupuesto no se usa: manda el reinicio del
periodo.
en un pago, o los ingresos en un cobro pendiente. En un
presupuesto no se usa: manda el reinicio del periodo.
</li>
<li>
<strong>Period</strong> — solo para presupuesto: cada cuánto

View File

@ -0,0 +1,102 @@
{% 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">&laquo; 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 &raquo;</a>
{% endif %}
</span>
</nav>
{% endblock %}

View File

@ -76,6 +76,9 @@
<td class="table-actions">
<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>
</td>
</tr>

View File

@ -0,0 +1,749 @@
"""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

View File

@ -39,5 +39,6 @@ urlpatterns = [
path('goals/new/', views.goal_create, name='goal_create'),
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>/links/', views.goal_links, name='goal_links'),
path('password_help', TemplateView.as_view(template_name="registration/password_help.html"), name="password_help"),
]

View File

@ -1107,6 +1107,9 @@ def income_create(request):
income = form.save(commit=False)
income.owner = request.user
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.")
return redirect("income_list")
else:
@ -1486,7 +1489,9 @@ def category_delete(request, pk):
@login_required
def goal_list(request):
goals = Goal.objects.filter(owner=request.user)
goals = Goal.objects.filter(owner=request.user).select_related(
"category", "account"
)
selected_kind = request.GET.get("kind") or ""
if selected_kind in dict(Goal.KIND_CHOICES):
@ -1578,6 +1583,64 @@ 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
def settings_index(request):
return render(request, "settings/index.html", {"active_menu": "settings"})