From d9b0ce428713ddda1e09fabea2e011c7ce329c5d Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 21 Oct 2026 18:41:05 +0200 Subject: [PATCH] Add budget block allocation screen --- expenses_manager/expenses/forms.py | 54 ++- .../templates/expenses/account_list.html | 8 +- .../expenses/templates/expenses/base.html | 1 + .../templates/settings/budget_blocks.html | 76 +++++ .../expenses/templates/settings/index.html | 5 + .../expenses/tests/test_budget_blocks.py | 317 ++++++++++++++++++ expenses_manager/expenses/urls.py | 1 + expenses_manager/expenses/views.py | 122 ++++++- 8 files changed, 581 insertions(+), 3 deletions(-) create mode 100644 expenses_manager/expenses/templates/settings/budget_blocks.html create mode 100644 expenses_manager/expenses/tests/test_budget_blocks.py diff --git a/expenses_manager/expenses/forms.py b/expenses_manager/expenses/forms.py index a8394a9..3a92d17 100644 --- a/expenses_manager/expenses/forms.py +++ b/expenses_manager/expenses/forms.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from django import forms from .csv_export import DEFAULT_DIALECT from .models import ( @@ -9,6 +11,7 @@ from .models import ( FuelEntry, Goal, Transfer, + BudgetBlock, ) @@ -107,7 +110,7 @@ class AccountForm(forms.ModelForm): class Meta: model = Account - fields = ["name", "initial_balance", "active"] + fields = ["name", "initial_balance", "active", "is_savings"] class IncomeForm(_GoalLinksMixin, forms.ModelForm): @@ -290,6 +293,55 @@ class GoalForm(forms.ModelForm): return cleaned +class BudgetBlockForm(forms.Form): + """El reparto del ingreso por bloques: un campo por cada uno de los cinco. + + La suma no puede pasar de 100, pero no se obliga a que llegue: lo que falte + es "sin asignar", que es información útil y no un error. + """ + + def __init__(self, *args, user, **kwargs): + self.user = user + saved = { + row.block: row.percentage + for row in BudgetBlock.objects.filter(owner=user) + } + kwargs.setdefault( + "initial", + # Un bloque sin fila guardada vale 0. + {block: saved.get(block, Decimal("0")) for block, _ in BudgetBlock.BLOCK_CHOICES}, + ) + super().__init__(*args, **kwargs) + + for block, label in BudgetBlock.BLOCK_CHOICES: + self.fields[block] = forms.DecimalField( + label=f"{label} (%)", + min_value=0, + max_value=100, + max_digits=5, + decimal_places=2, + ) + + def clean(self): + cleaned = super().clean() + values = [cleaned.get(block) for block, _ in BudgetBlock.BLOCK_CHOICES] + # Si algún campo ya falló, su error basta: la suma saldría con huecos. + if None not in values and sum(values) > 100: + raise forms.ValidationError( + "Los bloques suman %s %%; entre todos no pueden pasar del 100 %%." + % sum(values) + ) + return cleaned + + def save(self): + for block, _ in BudgetBlock.BLOCK_CHOICES: + BudgetBlock.objects.update_or_create( + owner=self.user, + block=block, + defaults={"percentage": self.cleaned_data[block]}, + ) + + class ExportForm(forms.Form): """Filtros de la exportación a CSV. Valida la pantalla y la descarga. diff --git a/expenses_manager/expenses/templates/expenses/account_list.html b/expenses_manager/expenses/templates/expenses/account_list.html index c94dbc3..8a9af1e 100644 --- a/expenses_manager/expenses/templates/expenses/account_list.html +++ b/expenses_manager/expenses/templates/expenses/account_list.html @@ -14,6 +14,7 @@ Saldo inicial Saldo actual Activa + Ahorro @@ -30,6 +31,11 @@ Inactiva {% endif %} + + {% if row.account.is_savings %} + De ahorro + {% endif %} + Editar {% if row.account.active %} @@ -39,7 +45,7 @@ {% empty %} - +

No hay cuentas

Añade la primera diff --git a/expenses_manager/expenses/templates/expenses/base.html b/expenses_manager/expenses/templates/expenses/base.html index 6c24746..23367a0 100644 --- a/expenses_manager/expenses/templates/expenses/base.html +++ b/expenses_manager/expenses/templates/expenses/base.html @@ -43,6 +43,7 @@ Categorías Etiquetas Objetivos + Reparto por bloques Exportar datos diff --git a/expenses_manager/expenses/templates/settings/budget_blocks.html b/expenses_manager/expenses/templates/settings/budget_blocks.html new file mode 100644 index 0000000..d99a2f9 --- /dev/null +++ b/expenses_manager/expenses/templates/settings/budget_blocks.html @@ -0,0 +1,76 @@ +{% extends "expenses/base.html" %} + +{% block title %}Reparto por bloques{% endblock %} + +{% block content %} +

Reparto por bloques

+ +

Qué porcentaje de tus ingresos debería ir a cada bloque. Los bloques de gasto son los tipos que asignas a tus categorías; el ahorro son los traspasos a tus cuentas de ahorro.

+ + {# Arranca con el reparto guardado; el script de abajo lo recalcula al teclear. #} +

Asignado: {{ assigned|floatformat:"-2" }} %. Sin asignar: {{ unassigned|floatformat:"-2" }} %.

+ + {% if proposal %} +

+ Junto a cada bloque tienes lo que fue de verdad del {{ proposal.date_from|date:"d/m/Y" }} al {{ proposal.date_to|date:"d/m/Y" }}, sobre tus ingresos de ese periodo. + {% if proposal.unclassified %}El {{ proposal.unclassified }} % se fue en gastos sin tipo, que no cuentan en ningún bloque.{% endif %} +

+ {% else %} +

No hay ingresos en los últimos 12 meses completos, así que no se puede calcular una propuesta.

+ {% endif %} + +
+ {% csrf_token %} + {% include "expenses/_form_fields.html" %} + +
+ + {% if proposal %} + Usar valores calculados + {% endif %} + Volver +
+ {% if proposal_total > 100 %} +

Los valores calculados suman un {{ proposal_total }} %: en ese periodo gastaste más de lo que ingresaste. Puedes cargarlos, pero tendrás que bajar algún bloque hasta que la suma no pase de 100 para poder guardar.

+ {% endif %} +
+ + +{% endblock %} diff --git a/expenses_manager/expenses/templates/settings/index.html b/expenses_manager/expenses/templates/settings/index.html index 29eaf4e..1202c67 100644 --- a/expenses_manager/expenses/templates/settings/index.html +++ b/expenses_manager/expenses/templates/settings/index.html @@ -25,6 +25,11 @@

Define y controla tus metas de gasto.

+ +

Reparto por bloques

+

Decide qué parte de tus ingresos va a cada tipo de gasto y al ahorro.

+
+

Exportar datos

Descarga tus gastos, ingresos o traspasos en CSV.

diff --git a/expenses_manager/expenses/tests/test_budget_blocks.py b/expenses_manager/expenses/tests/test_budget_blocks.py new file mode 100644 index 0000000..04616b0 --- /dev/null +++ b/expenses_manager/expenses/tests/test_budget_blocks.py @@ -0,0 +1,317 @@ +import pytest +from datetime import date, timedelta +from decimal import Decimal + +from django.db import connection +from django.test.utils import CaptureQueriesContext +from django.urls import reverse + +from expenses.forms import BudgetBlockForm +from expenses.models import Account, BudgetBlock, Category, Expense, Income, Transfer +from expenses.views import _budget_block_proposal, _savings_total + +pytestmark = pytest.mark.django_db + +# Con este "hoy" la propuesta mira del 01/07/2023 al 30/06/2024. +TODAY = date(2024, 7, 15) +WINDOW = (date(2023, 7, 1), date(2024, 7, 1)) + + +def _account(user, name, is_savings=False): + return Account.objects.create( + owner=user, name=name, initial_balance=Decimal("0"), active=True, + is_savings=is_savings, + ) + + +def _transfer(user, from_account, to_account, amount, on=date(2024, 3, 10)): + transfer = Transfer( + owner=user, date=on, amount=Decimal(amount), + from_account=from_account, to_account=to_account, + ) + transfer.sync() + return transfer + + +def _expense(user, account, category, amount, on=date(2024, 3, 1)): + return Expense.objects.create( + owner=user, account=account, category=category, + amount=Decimal(amount), date=on, + ) + + +def _block_data(**values): + data = {block: "0" for block, _ in BudgetBlock.BLOCK_CHOICES} + data.update(values) + return data + + +# -------------------------------------------------------------------------- +# Ahorro: los tres casos de traspaso +# -------------------------------------------------------------------------- + +def test_transfer_into_a_savings_account_counts_as_saving(user): + _transfer(user, _account(user, "Nómina"), _account(user, "Hucha", is_savings=True), "300") + + assert _savings_total(user, *WINDOW) == Decimal("300") + + +def test_transfer_out_of_a_savings_account_subtracts(user): + checking = _account(user, "Nómina") + savings = _account(user, "Hucha", is_savings=True) + _transfer(user, checking, savings, "300") + _transfer(user, savings, checking, "120") + + # Sacar dinero del colchón es desahorro, no ahorro. + assert _savings_total(user, *WINDOW) == Decimal("180") + + +def test_transfer_between_two_savings_accounts_does_not_count(user): + piggy = _account(user, "Hucha", is_savings=True) + deposit = _account(user, "Depósito", is_savings=True) + _transfer(user, piggy, deposit, "1000") + _transfer(user, deposit, piggy, "250") + + # Mover dinero de una hucha a otra no es ahorro nuevo, en ningún sentido. + assert _savings_total(user, *WINDOW) == Decimal("0") + + +# -------------------------------------------------------------------------- +# Validación de la suma +# -------------------------------------------------------------------------- + +def test_form_accepts_exactly_100(user): + form = BudgetBlockForm(_block_data(fixed_essential="50", saving="50"), user=user) + + assert form.is_valid(), form.errors + + +def test_form_rejects_more_than_100(user): + form = BudgetBlockForm(_block_data(fixed_essential="51", saving="50"), user=user) + + assert not form.is_valid() + assert "no pueden pasar del 100" in str(form.non_field_errors()) + + +def test_saving_80_leaves_20_unassigned(auth_client, user): + response = auth_client.post( + reverse("budget_blocks"), + _block_data(fixed_essential="50", variable_essential="20", saving="10"), + follow=True, + ) + + assert BudgetBlock.objects.filter(owner=user).count() == 5 + assert response.context["assigned"] == Decimal("80") + assert response.context["unassigned"] == Decimal("20") + assert "Sin asignar: 20 %" in response.content.decode() + + +def test_blocks_without_rows_are_zero_and_all_five_are_shown(auth_client, user): + BudgetBlock.objects.create(owner=user, block="saving", percentage=Decimal("15")) + + response = auth_client.get(reverse("budget_blocks")) + + form = response.context["form"] + assert list(form.fields) == [block for block, _ in BudgetBlock.BLOCK_CHOICES] + assert form.initial == { + "fixed_essential": Decimal("0"), + "fixed_discretionary": Decimal("0"), + "variable_essential": Decimal("0"), + "variable_discretionary": Decimal("0"), + "saving": Decimal("15"), + } + content = response.content.decode() + for block, _ in BudgetBlock.BLOCK_CHOICES: + assert 'name="%s"' % block in content + + +# -------------------------------------------------------------------------- +# Propuesta a partir de los datos reales +# -------------------------------------------------------------------------- + +def _known_data(user): + checking = _account(user, "Nómina") + savings = _account(user, "Hucha", is_savings=True) + Income.objects.create( + owner=user, account=checking, name="Nómina", + amount=Decimal("2000"), date=date(2024, 3, 5), + ) + home = Category.objects.create( + name="Casa", owner=user, expense_type=Category.EXPENSE_TYPE_FIXED_ESSENTIAL + ) + # Hija sin tipo propio: hereda "Fijo esencial" de Casa. + power = Category.objects.create(name="Luz", owner=user, parent=home) + fun = Category.objects.create( + name="Ocio", owner=user, expense_type=Category.EXPENSE_TYPE_VARIABLE_DISCRETIONARY + ) + loose = Category.objects.create(name="Varios", owner=user) + _expense(user, checking, home, "500") + _expense(user, checking, power, "100") + _expense(user, checking, fun, "200") + _expense(user, checking, loose, "100") + _transfer(user, checking, savings, "300") + # Fuera de la ventana de 12 meses completos: no cuentan. + _expense(user, checking, home, "999", on=date(2024, 7, 2)) + _expense(user, checking, home, "999", on=date(2023, 6, 30)) + return checking, savings + + +def test_proposal_percentages_with_known_data(user): + _known_data(user) + + proposal = _budget_block_proposal(user, TODAY) + + assert proposal["date_from"] == date(2023, 7, 1) + assert proposal["date_to"] == date(2024, 6, 30) + assert proposal["blocks"] == { + "fixed_essential": Decimal("30.0"), # (500 + 100) / 2000 + "fixed_discretionary": Decimal("0.0"), + "variable_essential": Decimal("0.0"), + "variable_discretionary": Decimal("10.0"), # 200 / 2000 + "saving": Decimal("15.0"), # 300 / 2000 + } + assert proposal["unclassified"] == Decimal("5.0") + + +def test_proposal_uses_inherited_expense_type(user): + checking = _account(user, "Nómina") + Income.objects.create( + owner=user, account=checking, name="Nómina", + amount=Decimal("1000"), date=date(2024, 3, 5), + ) + parent = Category.objects.create( + name="Coche", owner=user, expense_type=Category.EXPENSE_TYPE_VARIABLE_ESSENTIAL + ) + child = Category.objects.create(name="Gasolina", owner=user, parent=parent) + _expense(user, checking, child, "80") + + proposal = _budget_block_proposal(user, TODAY) + + assert proposal["blocks"]["variable_essential"] == Decimal("8.0") + assert proposal["unclassified"] == Decimal("0.0") + + +def test_proposal_is_none_without_income(user): + assert _budget_block_proposal(user, TODAY) is None + + +def test_proposal_query_count_does_not_grow(user): + checking, _ = _known_data(user) + + with CaptureQueriesContext(connection) as small: + _budget_block_proposal(user, TODAY) + + parent = Category.objects.get(name="Casa") + for i in range(10): + parent = Category.objects.create(name=f"Nivel {i}", owner=user, parent=parent) + _expense(user, checking, parent, "1") + + with CaptureQueriesContext(connection) as large: + _budget_block_proposal(user, TODAY) + + assert len(large.captured_queries) == len(small.captured_queries) == 4 + + +def test_use_proposal_prefills_without_saving(auth_client, user): + # La vista usa el "hoy" real: los datos van al mes pasado, que siempre cae + # dentro de los 12 meses completos. + last_month = date.today().replace(day=1) - timedelta(days=1) + checking = _account(user, "Nómina") + Income.objects.create( + owner=user, account=checking, name="Nómina", + amount=Decimal("1000"), date=last_month, + ) + _transfer(user, checking, _account(user, "Hucha", is_savings=True), "250", on=last_month) + + response = auth_client.get(reverse("budget_blocks"), {"propuesta": "1"}) + + form = response.context["form"] + assert form.initial["saving"] == Decimal("25.0") + # Con coma decimal, como el resto de números de la interfaz. + assert "Calculado: 25,0 %" in form.fields["saving"].help_text + assert not BudgetBlock.objects.filter(owner=user).exists() + + +def _last_month_income_and_expense(user, income, expense): + # La vista usa el "hoy" real: el mes pasado siempre cae dentro de los 12 + # meses completos de la propuesta. + last_month = date.today().replace(day=1) - timedelta(days=1) + checking = _account(user, "Nómina") + Income.objects.create( + owner=user, account=checking, name="Nómina", + amount=Decimal(income), date=last_month, + ) + rent = Category.objects.create( + name="Alquiler", owner=user, expense_type=Category.EXPENSE_TYPE_FIXED_ESSENTIAL + ) + _expense(user, checking, rent, expense, on=last_month) + + +def test_proposal_over_100_is_flagged_next_to_the_button(auth_client, user): + # Más gasto que ingreso: Fijo esencial sale al 120 %. + _last_month_income_and_expense(user, income="1000", expense="1200") + + response = auth_client.get(reverse("budget_blocks")) + + assert response.context["proposal_total"] == Decimal("120.0") + assert "suman un 120,0 %" in response.content.decode() + + +def test_proposal_up_to_100_has_no_warning(auth_client, user): + _last_month_income_and_expense(user, income="1000", expense="600") + + response = auth_client.get(reverse("budget_blocks")) + + assert response.context["proposal_total"] == Decimal("60.0") + assert "suman un" not in response.content.decode() + + +# -------------------------------------------------------------------------- +# Aislamiento y login +# -------------------------------------------------------------------------- + +def test_budget_of_another_user_is_neither_shown_nor_edited(auth_client, user, django_user_model): + other = django_user_model.objects.create_user(username="otro", password="x-pass-123") + BudgetBlock.objects.create(owner=other, block="saving", percentage=Decimal("40")) + + response = auth_client.get(reverse("budget_blocks")) + assert response.context["form"].initial["saving"] == Decimal("0") + assert response.context["assigned"] == Decimal("0") + + auth_client.post(reverse("budget_blocks"), _block_data(saving="25")) + + assert BudgetBlock.objects.get(owner=other, block="saving").percentage == Decimal("40") + assert BudgetBlock.objects.get(owner=user, block="saving").percentage == Decimal("25") + + +def test_budget_screen_requires_login(client): + response = client.get(reverse("budget_blocks")) + + assert response.status_code == 302 + assert reverse("login") in response["Location"] + + +def test_budget_screen_is_linked_from_hub_and_menu(auth_client): + # Son dos sitios independientes: el hub de Configuración y el desplegable + # del menú. Con la exportación se puso solo uno y desde el menú no se + # llegaba. + hub = auth_client.get(reverse("settings_index")).content.decode() + menu = auth_client.get(reverse("home")).content.decode() + link = 'href="%s"' % reverse("budget_blocks") + + assert link in hub + assert link in menu + + +# -------------------------------------------------------------------------- +# Campo de cuenta de ahorro +# -------------------------------------------------------------------------- + +def test_account_form_saves_savings_flag_and_list_shows_it(auth_client, user): + auth_client.post( + reverse("account_create"), + {"name": "Hucha", "initial_balance": "0", "active": "on", "is_savings": "on"}, + ) + + assert Account.objects.get(owner=user, name="Hucha").is_savings is True + assert "De ahorro" in auth_client.get(reverse("account_list")).content.decode() diff --git a/expenses_manager/expenses/urls.py b/expenses_manager/expenses/urls.py index 5ce7ce6..e9ae973 100644 --- a/expenses_manager/expenses/urls.py +++ b/expenses_manager/expenses/urls.py @@ -33,6 +33,7 @@ urlpatterns = [ path('categories//edit/', views.category_edit, name='category_edit'), path('categories//delete/', views.category_delete, name='category_delete'), path('settings/', views.settings_index, name='settings_index'), + path('budget/', views.budget_blocks, name='budget_blocks'), path('export/', views.export_index, name='export_index'), path('export/download/', views.export_download, name='export_download'), path('goals/', views.goal_list, name='goal_list'), diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index 1191a02..1785598 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -13,6 +13,7 @@ from .models import ( Income, Goal, Transfer, + BudgetBlock, ) from .forms import ( ExpenseForm, @@ -24,11 +25,12 @@ from .forms import ( GoalForm, TransferForm, ExportForm, + BudgetBlockForm, ) from .csv_export import DIALECTS, EXPORTS, csv_filename, stream_csv from django.core.paginator import Paginator -from django.db.models import Sum, Count, Min, ProtectedError +from django.db.models import Sum, Count, Min, ProtectedError, Q from django.db.models.functions import ( ExtractMonth, ExtractYear, @@ -39,6 +41,7 @@ from django.db.models.functions import ( from django.contrib.auth.decorators import login_required from django.http import HttpResponseBadRequest, StreamingHttpResponse +from django.utils.formats import number_format from django.utils.http import url_has_allowed_host_and_scheme from django.shortcuts import get_object_or_404, render, redirect from django.urls import reverse @@ -185,6 +188,75 @@ def _expense_type_breakdown(user, expenses): return rows, classified_count +def _savings_total(user, date_from, date_to): + """Ahorro neto del periodo, medido por los traspasos entre cuentas. + + No todos los traspasos cuentan igual: + - de una cuenta normal a una de ahorro: suma, es ahorrar; + - de una de ahorro a una normal: resta, es desahorro, y sacar dinero del + colchón no puede contar como ahorrar; + - entre dos de ahorro: ni suma ni resta. Mover dinero de una hucha a otra + no es ahorro nuevo, y contarlo inflaría el bloque cada vez que se + reorganizan cuentas. + + Solo cuenta el importe del traspaso; la comisión ya es un gasto normal. + Una sola consulta. `date_to` es exclusivo.""" + totals = Transfer.objects.filter( + owner=user, date__gte=date_from, date__lt=date_to + ).aggregate( + saved=Sum( + "amount", + filter=Q(to_account__is_savings=True, from_account__is_savings=False), + ), + withdrawn=Sum( + "amount", + filter=Q(from_account__is_savings=True, to_account__is_savings=False), + ), + ) + return (totals["saved"] or Decimal("0")) - (totals["withdrawn"] or Decimal("0")) + + +def _budget_block_proposal(user, today): + """Reparto propuesto a partir de los últimos 12 meses completos. + + Cada bloque es su parte del ingreso real del periodo, sin traspasos. Como + es la misma división para los dos lados, el bloque sobre el ingreso medio + mensual es lo mismo que el bloque sobre el ingreso total, así que da igual + si hay 12 meses de datos o menos. + + Devuelve None si no hubo ingresos: sin denominador no hay porcentaje. + Son cuatro consultas fijas (dos de _expense_type_breakdown, ingresos y + traspasos), crezca lo que crezca el número de categorías o de gastos.""" + date_to = today.replace(day=1) + date_from = date(*sub_months(date_to.year, date_to.month, 12), 1) + + income = _analysis_incomes(user).filter( + date__gte=date_from, date__lt=date_to + ).aggregate(total=Sum("amount"))["total"] + if not income: + return None + + expenses = _analysis_expenses(user).filter(date__gte=date_from, date__lt=date_to) + breakdown, _ = _expense_type_breakdown(user, expenses) + totals = {row["expense_type"]: row["total"] for row in breakdown} + totals[BudgetBlock.BLOCK_SAVING] = _savings_total(user, date_from, date_to) + + def pct(amount): + # Un desahorro neto daría un porcentaje negativo, que el formulario no + # admite: la propuesta se queda en 0 para ese bloque. + return max(amount / income * 100, Decimal("0")).quantize(Decimal("0.1")) + + return { + "date_from": date_from, + # El último día incluido, para enseñarlo en la pantalla. + "date_to": date_to - timedelta(days=1), + "blocks": {block: pct(totals[block]) for block, _ in BudgetBlock.BLOCK_CHOICES}, + # El gasto sin clasificar no va a ningún bloque. Se enseña aparte para + # que se entienda por qué la propuesta no llega al 100 %. + "unclassified": pct(totals[""]), + } + + def _without_transfers(movements): """Quita las patas de traspaso de un queryset de gastos o de ingresos. @@ -1782,6 +1854,54 @@ def settings_index(request): return render(request, "settings/index.html", {"active_menu": "settings"}) +@login_required +def budget_blocks(request): + proposal = _budget_block_proposal(request.user, date.today()) + + if request.method == "POST": + form = BudgetBlockForm(request.POST, user=request.user) + if form.is_valid(): + form.save() + messages.success(request, "Reparto guardado.") + return redirect("budget_blocks") + elif proposal and request.GET.get("propuesta"): + # "Usar valores calculados" solo precarga el formulario: no se guarda + # nada hasta que el usuario pulsa Guardar. + form = BudgetBlockForm(user=request.user, initial=proposal["blocks"]) + else: + form = BudgetBlockForm(user=request.user) + + proposal_total = None + if proposal: + for block, value in proposal["blocks"].items(): + # number_format para que salga con coma decimal, como en las + # plantillas; con %s quedaría "25.0" al lado de "25,0". + form.fields[block].help_text = "Calculado: %s %%" % number_format(value) + # Con más gasto que ingreso en el periodo la propuesta pasa de 100 y, + # aplicada tal cual, la rechazaría la validación al guardar: la + # plantilla lo avisa junto al botón. + proposal_total = sum(proposal["blocks"].values()) + + # Lo que queda sin asignar con el reparto guardado, no con lo que se esté + # tecleando: es lo que vale de verdad mientras no se guarde. + assigned = BudgetBlock.objects.filter(owner=request.user).aggregate( + total=Sum("percentage") + )["total"] or Decimal("0") + + return render( + request, + "settings/budget_blocks.html", + { + "active_menu": "settings", + "form": form, + "proposal": proposal, + "proposal_total": proposal_total, + "assigned": assigned, + "unassigned": Decimal("100") - assigned, + }, + ) + + @login_required def transfer_list(request): transfers = Transfer.objects.filter(owner=request.user).select_related(