Add budget block allocation screen
This commit is contained in:
parent
e64421b3df
commit
d9b0ce4287
@ -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.
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
<th>Saldo inicial</th>
|
||||
<th>Saldo actual</th>
|
||||
<th>Activa</th>
|
||||
<th>Ahorro</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -30,6 +31,11 @@
|
||||
<span class="badge badge-inactive">Inactiva</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if row.account.is_savings %}
|
||||
<span class="badge badge-neutral">De ahorro</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="table-actions">
|
||||
<a href="{% url 'account_edit' row.account.id %}">Editar</a>
|
||||
{% if row.account.active %}
|
||||
@ -39,7 +45,7 @@
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="empty-state">
|
||||
<td colspan="6" class="empty-state">
|
||||
<p>No hay cuentas</p>
|
||||
<a href="{% url 'account_create' %}">Añade la primera</a>
|
||||
</td>
|
||||
|
||||
@ -43,6 +43,7 @@
|
||||
<a href="{% url 'category_list' %}">Categorías</a>
|
||||
<a href="{% url 'tag_list' %}">Etiquetas</a>
|
||||
<a href="{% url 'goal_list' %}">Objetivos</a>
|
||||
<a href="{% url 'budget_blocks' %}">Reparto por bloques</a>
|
||||
<a href="{% url 'export_index' %}">Exportar datos</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,76 @@
|
||||
{% extends "expenses/base.html" %}
|
||||
|
||||
{% block title %}Reparto por bloques{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Reparto por bloques</h1>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
{# Arranca con el reparto guardado; el script de abajo lo recalcula al teclear. #}
|
||||
<p id="budgetTotals" aria-live="polite">Asignado: {{ assigned|floatformat:"-2" }} %. Sin asignar: {{ unassigned|floatformat:"-2" }} %.</p>
|
||||
|
||||
{% if proposal %}
|
||||
<p class="muted">
|
||||
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 %}
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="muted">No hay ingresos en los últimos 12 meses completos, así que no se puede calcular una propuesta.</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'budget_blocks' %}" class="app-form" id="budgetForm">
|
||||
{% csrf_token %}
|
||||
{% include "expenses/_form_fields.html" %}
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Guardar</button>
|
||||
{% if proposal %}
|
||||
<a class="btn btn-secondary" href="{% url 'budget_blocks' %}?propuesta=1">Usar valores calculados</a>
|
||||
{% endif %}
|
||||
<a class="btn btn-secondary" href="{% url 'settings_index' %}">Volver</a>
|
||||
</div>
|
||||
{% if proposal_total > 100 %}
|
||||
<p class="form-errors">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.</p>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
<script>
|
||||
// Suma en vivo de los cinco bloques. Es solo comodidad: quien decide si
|
||||
// se puede guardar es la validación del servidor.
|
||||
(function () {
|
||||
const form = document.getElementById('budgetForm');
|
||||
const totals = document.getElementById('budgetTotals');
|
||||
const names = [{% for field in form %}"{{ field.html_name }}",{% endfor %}];
|
||||
|
||||
function format(cents) {
|
||||
return (cents / 100).toLocaleString('es-ES', { maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function update() {
|
||||
// En céntimos enteros y redondeados: sin el redondeo,
|
||||
// 18,1 × 100 + 81,9 × 100 da 10000,000000000002 y avisaría
|
||||
// de que pasa de 100.
|
||||
let cents = 0;
|
||||
names.forEach(function (name) {
|
||||
const value = parseFloat(form.elements[name].value);
|
||||
if (!isNaN(value)) {
|
||||
cents += Math.round(value * 100);
|
||||
}
|
||||
});
|
||||
|
||||
if (cents > 10000) {
|
||||
totals.textContent = 'Asignado: ' + format(cents) + ' %. Pasa de 100 por '
|
||||
+ format(cents - 10000) + ' %: así no se podrá guardar.';
|
||||
totals.className = 'form-errors';
|
||||
} else {
|
||||
totals.textContent = 'Asignado: ' + format(cents) + ' %. Sin asignar: '
|
||||
+ format(10000 - cents) + ' %.';
|
||||
totals.className = '';
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener('input', update);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -25,6 +25,11 @@
|
||||
<p>Define y controla tus metas de gasto.</p>
|
||||
</a>
|
||||
|
||||
<a class="settings-card" href="{% url 'budget_blocks' %}">
|
||||
<h2>Reparto por bloques</h2>
|
||||
<p>Decide qué parte de tus ingresos va a cada tipo de gasto y al ahorro.</p>
|
||||
</a>
|
||||
|
||||
<a class="settings-card" href="{% url 'export_index' %}">
|
||||
<h2>Exportar datos</h2>
|
||||
<p>Descarga tus gastos, ingresos o traspasos en CSV.</p>
|
||||
|
||||
317
expenses_manager/expenses/tests/test_budget_blocks.py
Normal file
317
expenses_manager/expenses/tests/test_budget_blocks.py
Normal file
@ -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()
|
||||
@ -33,6 +33,7 @@ urlpatterns = [
|
||||
path('categories/<int:pk>/edit/', views.category_edit, name='category_edit'),
|
||||
path('categories/<int:pk>/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'),
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user