Compare commits
No commits in common. "aacbe3b8c2fe17ceeb37a5a3856bcf275bf951c7" and "1fe9ae31d795089cd6028d397dc079c151704a6c" have entirely different histories.
aacbe3b8c2
...
1fe9ae31d7
@ -1,5 +1,3 @@
|
|||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from .csv_export import DEFAULT_DIALECT
|
from .csv_export import DEFAULT_DIALECT
|
||||||
from .models import (
|
from .models import (
|
||||||
@ -11,7 +9,6 @@ from .models import (
|
|||||||
FuelEntry,
|
FuelEntry,
|
||||||
Goal,
|
Goal,
|
||||||
Transfer,
|
Transfer,
|
||||||
BudgetBlock,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -110,7 +107,7 @@ class AccountForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Account
|
model = Account
|
||||||
fields = ["name", "initial_balance", "active", "is_savings"]
|
fields = ["name", "initial_balance", "active"]
|
||||||
|
|
||||||
|
|
||||||
class IncomeForm(_GoalLinksMixin, forms.ModelForm):
|
class IncomeForm(_GoalLinksMixin, forms.ModelForm):
|
||||||
@ -293,55 +290,6 @@ class GoalForm(forms.ModelForm):
|
|||||||
return cleaned
|
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):
|
class ExportForm(forms.Form):
|
||||||
"""Filtros de la exportación a CSV. Valida la pantalla y la descarga.
|
"""Filtros de la exportación a CSV. Valida la pantalla y la descarga.
|
||||||
|
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
# Generated by Django 5.2.10 on 2026-10-18 14:51
|
|
||||||
|
|
||||||
import django.core.validators
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('expenses', '0018_category_expense_type'),
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='account',
|
|
||||||
name='is_savings',
|
|
||||||
field=models.BooleanField(default=False, help_text='Márcala si es una cuenta donde guardas dinero: los traspasos que entran en ella cuentan como ahorro en el reparto por bloques.', verbose_name='Cuenta de ahorro'),
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='BudgetBlock',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('block', models.CharField(choices=[('fixed_essential', 'Fijo esencial'), ('fixed_discretionary', 'Fijo prescindible'), ('variable_essential', 'Variable esencial'), ('variable_discretionary', 'Variable prescindible'), ('saving', 'Ahorro')], max_length=30)),
|
|
||||||
('percentage', models.DecimalField(decimal_places=2, default=0, max_digits=5, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)])),
|
|
||||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='budget_blocks', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'unique_together': {('owner', 'block')},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,7 +1,6 @@
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models import Q, Sum
|
from django.db.models import Q, Sum
|
||||||
@ -115,14 +114,6 @@ class Account(models.Model):
|
|||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100)
|
||||||
initial_balance = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
initial_balance = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||||
active = models.BooleanField(default=True)
|
active = models.BooleanField(default=True)
|
||||||
is_savings = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
verbose_name="Cuenta de ahorro",
|
|
||||||
help_text=(
|
|
||||||
"Márcala si es una cuenta donde guardas dinero: los traspasos que "
|
|
||||||
"entran en ella cuentan como ahorro en el reparto por bloques."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -832,35 +823,3 @@ class Goal(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class BudgetBlock(models.Model):
|
|
||||||
"""Qué porcentaje del ingreso debería ir a un bloque.
|
|
||||||
|
|
||||||
Los bloques son los cuatro tipos de gasto de Category más el ahorro: lo que
|
|
||||||
el usuario clasificó en sus categorías es lo que ve aquí, sin un tercer eje.
|
|
||||||
|
|
||||||
Un único reparto por usuario, sin versionar por fecha: un histórico obliga
|
|
||||||
a decidir qué reparto aplica a cada mes, y eso se añade el día que haga
|
|
||||||
falta. Un bloque sin fila vale 0.
|
|
||||||
"""
|
|
||||||
|
|
||||||
BLOCK_SAVING = "saving"
|
|
||||||
BLOCK_CHOICES = Category.EXPENSE_TYPE_CHOICES + [(BLOCK_SAVING, "Ahorro")]
|
|
||||||
|
|
||||||
owner = models.ForeignKey(
|
|
||||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="budget_blocks"
|
|
||||||
)
|
|
||||||
block = models.CharField(max_length=30, choices=BLOCK_CHOICES)
|
|
||||||
percentage = models.DecimalField(
|
|
||||||
max_digits=5,
|
|
||||||
decimal_places=2,
|
|
||||||
default=0,
|
|
||||||
validators=[MinValueValidator(0), MaxValueValidator(100)],
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
unique_together = ("owner", "block")
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.get_block_display()}: {self.percentage} %"
|
|
||||||
|
|||||||
@ -561,85 +561,36 @@ button[type="submit"]:hover,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Cabecera común de las tarjetas de gráfico: el título a la izquierda y, si lo
|
/* Cabecera común de las tarjetas de gráfico: el título a la izquierda y, si lo
|
||||||
lleva, su control a la derecha. Cuando el control no cabe junto al título,
|
lleva, su control a la derecha. El min-height reserva el alto del selector
|
||||||
baja a una segunda línea. Eso pasa a anchos de escritorio normales: el de
|
también en las tarjetas que no lo tienen, así que los dos ejes arrancan a la
|
||||||
tipo de gasto lleva dos grupos de controles y una tarjeta mide ~340 px con
|
misma altura por construcción y no por casualidad. Por eso no se compensa
|
||||||
cuatro por fila. El min-height reserva esas dos líneas (título de 2.25rem,
|
tocando el alto del canvas: eso cuadraría solo a un ancho concreto.
|
||||||
0.5rem de separación y controles de 2.25rem) en todas las tarjetas, tengan
|
|
||||||
controles o no. Así los canvas de una misma fila arrancan a la misma altura
|
|
||||||
por construcción y no por casualidad. Por eso no se compensa tocando el alto
|
|
||||||
del canvas: eso cuadraría solo a un ancho concreto.
|
|
||||||
Los márgenes son los que traía el h2, para no mover el ritmo vertical. */
|
Los márgenes son los que traía el h2, para no mover el ritmo vertical. */
|
||||||
.chart-header {
|
.chart-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
align-content: flex-start;
|
gap: 1rem;
|
||||||
gap: 0.5rem 1rem;
|
min-height: 2.5rem;
|
||||||
min-height: 5rem;
|
|
||||||
margin: 1.5rem 0 0.75rem;
|
margin: 1.5rem 0 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sin esto el margen del h2 se sumaría al de la cabecera y el min-height
|
/* Sin esto el margen del h2 se sumaría al de la cabecera y el min-height
|
||||||
dejaría de mandar. El alto mínimo iguala la línea del título a la de los
|
dejaría de mandar. */
|
||||||
controles: haya o no un control al lado, el título queda a la misma altura
|
|
||||||
en todas las tarjetas. */
|
|
||||||
.chart-header h2 {
|
.chart-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 2.25rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* El alto del control se fija igual que la línea del título. Así la cabecera
|
/* El alto del control se fija por debajo del min-height de la cabecera. Así la
|
||||||
mide siempre el min-height en todas las tarjetas y la alineación no depende
|
cabecera mide siempre el min-height en las dos tarjetas y la alineación no
|
||||||
de cuánto mida un <select> en cada navegador, que es justo lo que haría que
|
depende de cuánto mida un <select> en cada navegador, que es justo lo que
|
||||||
cuadrara en uno y no en otro. */
|
haría que cuadrara en uno y no en otro. */
|
||||||
.chart-header select {
|
.chart-header select {
|
||||||
height: 2.25rem;
|
height: 2.25rem;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Grupo de botones de icono que se excluyen entre sí (la forma del gráfico).
|
|
||||||
Mismo alto que el <select> de al lado; el pulsado lo marca aria-pressed. */
|
|
||||||
.icon-toggle {
|
|
||||||
display: inline-flex;
|
|
||||||
box-sizing: border-box;
|
|
||||||
height: 2.25rem;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-toggle button {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 2.25rem;
|
|
||||||
height: 100%;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
background: var(--color-surface);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-toggle button + button {
|
|
||||||
border-left: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-toggle button[aria-pressed="true"] {
|
|
||||||
color: var(--color-on-primary);
|
|
||||||
background: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-toggle button:focus-visible {
|
|
||||||
outline: 2px solid var(--color-focus-ring);
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-box {
|
.chart-box {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 320px;
|
height: 320px;
|
||||||
|
|||||||
@ -14,7 +14,6 @@
|
|||||||
<th>Saldo inicial</th>
|
<th>Saldo inicial</th>
|
||||||
<th>Saldo actual</th>
|
<th>Saldo actual</th>
|
||||||
<th>Activa</th>
|
<th>Activa</th>
|
||||||
<th>Ahorro</th>
|
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -31,11 +30,6 @@
|
|||||||
<span class="badge badge-inactive">Inactiva</span>
|
<span class="badge badge-inactive">Inactiva</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
{% if row.account.is_savings %}
|
|
||||||
<span class="badge badge-neutral">De ahorro</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="table-actions">
|
<td class="table-actions">
|
||||||
<a href="{% url 'account_edit' row.account.id %}">Editar</a>
|
<a href="{% url 'account_edit' row.account.id %}">Editar</a>
|
||||||
{% if row.account.active %}
|
{% if row.account.active %}
|
||||||
@ -45,7 +39,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" class="empty-state">
|
<td colspan="5" class="empty-state">
|
||||||
<p>No hay cuentas</p>
|
<p>No hay cuentas</p>
|
||||||
<a href="{% url 'account_create' %}">Añade la primera</a>
|
<a href="{% url 'account_create' %}">Añade la primera</a>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -43,7 +43,6 @@
|
|||||||
<a href="{% url 'category_list' %}">Categorías</a>
|
<a href="{% url 'category_list' %}">Categorías</a>
|
||||||
<a href="{% url 'tag_list' %}">Etiquetas</a>
|
<a href="{% url 'tag_list' %}">Etiquetas</a>
|
||||||
<a href="{% url 'goal_list' %}">Objetivos</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>
|
<a href="{% url 'export_index' %}">Exportar datos</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -234,59 +234,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
|
||||||
<div class="chart-header">
|
|
||||||
<h2>Distribución por tipo de gasto</h2>
|
|
||||||
{% if expense_type_classified_count >= 2 and kpi_total %}
|
|
||||||
<div class="filters-main">
|
|
||||||
<label class="sr-only" for="expenseTypeGrouping">Agrupación</label>
|
|
||||||
<select id="expenseTypeGrouping">
|
|
||||||
<option value="types">Cuatro tipos</option>
|
|
||||||
<option value="fixed">Fijo / variable</option>
|
|
||||||
<option value="essential">Esencial / prescindible</option>
|
|
||||||
</select>
|
|
||||||
<div class="icon-toggle" role="group" aria-label="Forma del gráfico">
|
|
||||||
<button type="button" data-shape="doughnut" aria-label="Donut" aria-pressed="true">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false">
|
|
||||||
<circle cx="12" cy="12" r="7" fill="none" stroke="currentColor" stroke-width="4"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button type="button" data-shape="bar" aria-label="Barras horizontales" aria-pressed="false">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false">
|
|
||||||
<rect x="3" y="4" width="16" height="4" fill="currentColor"/>
|
|
||||||
<rect x="3" y="10" width="10" height="4" fill="currentColor"/>
|
|
||||||
<rect x="3" y="16" width="6" height="4" fill="currentColor"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button type="button" data-shape="column" aria-label="Barras verticales" aria-pressed="false">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false">
|
|
||||||
<rect x="4" y="5" width="4" height="16" fill="currentColor"/>
|
|
||||||
<rect x="10" y="11" width="4" height="10" fill="currentColor"/>
|
|
||||||
<rect x="16" y="15" width="4" height="6" fill="currentColor"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if expense_type_classified_count < 2 %}
|
|
||||||
<div class="empty-state">
|
|
||||||
<p>Clasifica al menos dos categorías para ver este desglose</p>
|
|
||||||
<a href="{% url 'category_list' %}">Ir a categorías</a>
|
|
||||||
</div>
|
|
||||||
{% elif kpi_total %}
|
|
||||||
<div class="chart-box">
|
|
||||||
<canvas id="expenseTypeChart"></canvas>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="empty-state">No hay gastos en este periodo</p>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fuera de .charts-container: es una tabla, no tiene eje que alinear con
|
|
||||||
los gráficos y encajada entre dos canvas había que desplazarla para
|
|
||||||
ver sus columnas. -->
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Gastos Recientes</h2>
|
<h2>Gastos Recientes</h2>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
@ -321,6 +268,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="clear: both; margin-top: 2rem;"></div>
|
<div style="clear: both; margin-top: 2rem;"></div>
|
||||||
|
|
||||||
@ -354,119 +302,6 @@
|
|||||||
registerThemedChart(categoryChart, function (chart, colors) {
|
registerThemedChart(categoryChart, function (chart, colors) {
|
||||||
chart.options.plugins.legend.labels.color = colors.tickColor;
|
chart.options.plugins.legend.labels.color = colors.tickColor;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sin gasto en el periodo, o con menos de dos categorías clasificadas,
|
|
||||||
// la plantilla no pinta el canvas.
|
|
||||||
const expenseTypeCanvas = document.getElementById('expenseTypeChart');
|
|
||||||
if (expenseTypeCanvas) {
|
|
||||||
// Orden fijo del servidor: fijo esencial, fijo prescindible,
|
|
||||||
// variable esencial, variable prescindible y "Sin clasificar".
|
|
||||||
const typeLabels = [{% for row in expense_type_breakdown %}"{{ row.label }}",{% endfor %}];
|
|
||||||
const typeTotals = [{% for row in expense_type_breakdown %}{{ row.total|unlocalize }},{% endfor %}];
|
|
||||||
const t = typeTotals;
|
|
||||||
|
|
||||||
// Las agrupaciones de dos bloques se suman aquí con los cinco
|
|
||||||
// importes que ya llegan: cambiar de vista no pide nada al servidor.
|
|
||||||
// "Sin clasificar" se queda siempre aparte, nunca se reparte.
|
|
||||||
const GROUPINGS = {
|
|
||||||
types: { labels: typeLabels, data: typeTotals },
|
|
||||||
fixed: { labels: ['Fijo', 'Variable', typeLabels[4]], data: [t[0] + t[1], t[2] + t[3], t[4]] },
|
|
||||||
essential: { labels: ['Esencial', 'Prescindible', typeLabels[4]], data: [t[0] + t[2], t[1] + t[3], t[4]] }
|
|
||||||
};
|
|
||||||
|
|
||||||
const groupingSelect = document.getElementById('expenseTypeGrouping');
|
|
||||||
const shapeButtons = document.querySelectorAll('[data-shape]');
|
|
||||||
let grouping = 'types';
|
|
||||||
let shape = 'doughnut';
|
|
||||||
let expenseTypeChart = null;
|
|
||||||
|
|
||||||
// Un color por bloque y "Sin clasificar", que va el último, en gris.
|
|
||||||
function blockColors(count, colors) {
|
|
||||||
return colors.palette.slice(0, count - 1).concat(colors.tickColor);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyExpenseTypeColors(chart, colors) {
|
|
||||||
chart.options.plugins.legend.labels.color = colors.tickColor;
|
|
||||||
chart.data.datasets[0].backgroundColor = blockColors(chart.data.labels.length, colors);
|
|
||||||
if (shape !== 'doughnut') {
|
|
||||||
applyCartesianColors(chart, colors);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function expenseTypeConfig(colors) {
|
|
||||||
const group = GROUPINGS[grouping];
|
|
||||||
const legend = { labels: { color: colors.tickColor } };
|
|
||||||
const axis = {
|
|
||||||
grid: { color: colors.gridColor },
|
|
||||||
ticks: { color: colors.tickColor }
|
|
||||||
};
|
|
||||||
|
|
||||||
if (shape === 'doughnut') {
|
|
||||||
legend.position = 'right';
|
|
||||||
return {
|
|
||||||
type: 'doughnut',
|
|
||||||
data: {
|
|
||||||
labels: group.labels,
|
|
||||||
datasets: [{ data: group.data, backgroundColor: blockColors(group.labels.length, colors) }]
|
|
||||||
},
|
|
||||||
options: { plugins: { legend: legend } }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Barras horizontales ('bar') o verticales ('column'), con los
|
|
||||||
// importes en el eje de valores. Cada barra lleva su etiqueta,
|
|
||||||
// así que la leyenda sobra.
|
|
||||||
const valueAxis = shape === 'bar' ? 'x' : 'y';
|
|
||||||
const categoryAxis = shape === 'bar' ? 'y' : 'x';
|
|
||||||
const scales = {};
|
|
||||||
scales[valueAxis] = Object.assign({ beginAtZero: true }, axis);
|
|
||||||
scales[categoryAxis] = axis;
|
|
||||||
legend.display = false;
|
|
||||||
return {
|
|
||||||
type: 'bar',
|
|
||||||
data: {
|
|
||||||
labels: group.labels,
|
|
||||||
datasets: [{ data: group.data, backgroundColor: blockColors(group.labels.length, colors) }]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
indexAxis: categoryAxis,
|
|
||||||
scales: scales,
|
|
||||||
plugins: { legend: legend }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderExpenseTypeChart() {
|
|
||||||
const config = expenseTypeConfig(getChartColors());
|
|
||||||
config.options.responsive = true;
|
|
||||||
config.options.maintainAspectRatio = false;
|
|
||||||
|
|
||||||
// Cambiar de forma cambia el tipo de gráfico: hay que destruir y
|
|
||||||
// crear de nuevo, igual que en el de ingresos vs gastos.
|
|
||||||
if (expenseTypeChart) {
|
|
||||||
expenseTypeChart.destroy();
|
|
||||||
}
|
|
||||||
expenseTypeChart = new Chart(expenseTypeCanvas, config);
|
|
||||||
registerThemedChart(expenseTypeChart, applyExpenseTypeColors);
|
|
||||||
}
|
|
||||||
|
|
||||||
groupingSelect.addEventListener('change', function () {
|
|
||||||
grouping = groupingSelect.value;
|
|
||||||
renderExpenseTypeChart();
|
|
||||||
});
|
|
||||||
|
|
||||||
shapeButtons.forEach(function (button) {
|
|
||||||
button.addEventListener('click', function () {
|
|
||||||
shape = button.dataset.shape;
|
|
||||||
shapeButtons.forEach(function (other) {
|
|
||||||
other.setAttribute('aria-pressed', other === button ? 'true' : 'false');
|
|
||||||
});
|
|
||||||
renderExpenseTypeChart();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
renderExpenseTypeChart();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h2>Evolución anual por cuenta ({{ selected_year }})</h2>
|
<h2>Evolución anual por cuenta ({{ selected_year }})</h2>
|
||||||
|
|||||||
@ -1,76 +0,0 @@
|
|||||||
{% 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,11 +25,6 @@
|
|||||||
<p>Define y controla tus metas de gasto.</p>
|
<p>Define y controla tus metas de gasto.</p>
|
||||||
</a>
|
</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' %}">
|
<a class="settings-card" href="{% url 'export_index' %}">
|
||||||
<h2>Exportar datos</h2>
|
<h2>Exportar datos</h2>
|
||||||
<p>Descarga tus gastos, ingresos o traspasos en CSV.</p>
|
<p>Descarga tus gastos, ingresos o traspasos en CSV.</p>
|
||||||
|
|||||||
@ -1,317 +0,0 @@
|
|||||||
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()
|
|
||||||
@ -1,8 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from django.db import connection
|
|
||||||
from django.test.utils import CaptureQueriesContext
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from expenses.models import Account, Expense, Category, Income, Transfer
|
from expenses.models import Account, Expense, Category, Income, Transfer
|
||||||
|
|
||||||
@ -483,184 +481,3 @@ def test_dashboard_kpi_balance_matches_sum_of_current_balances(auth_client, user
|
|||||||
)
|
)
|
||||||
assert expected == Decimal('520') # (0 - 40) + (500 + 60), sin la inactiva
|
assert expected == Decimal('520') # (0 - 40) + (500 + 60), sin la inactiva
|
||||||
assert response.context['kpi_balance'] == expected
|
assert response.context['kpi_balance'] == expected
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# Desglose por tipo de gasto
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _breakdown(response):
|
|
||||||
return {row['label']: row['total'] for row in response.context['expense_type_breakdown']}
|
|
||||||
|
|
||||||
|
|
||||||
def test_expense_type_breakdown_groups_by_effective_type(auth_client, user, account):
|
|
||||||
home = Category.objects.create(
|
|
||||||
name='Casa', owner=user, expense_type=Category.EXPENSE_TYPE_FIXED_ESSENTIAL
|
|
||||||
)
|
|
||||||
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)
|
|
||||||
for category, amount in [(home, '500'), (power, '60'), (fun, '40'), (loose, '7')]:
|
|
||||||
Expense.objects.create(
|
|
||||||
owner=user, account=account, category=category,
|
|
||||||
amount=Decimal(amount), date=date(2024, 3, 1),
|
|
||||||
)
|
|
||||||
|
|
||||||
response = auth_client.get(reverse('dashboard'), {'year': 2024})
|
|
||||||
|
|
||||||
# La luz hereda "Fijo esencial" de Casa; Varios no tiene tipo en ninguna
|
|
||||||
# parte de la cadena y va a "Sin clasificar" en vez de desaparecer.
|
|
||||||
assert _breakdown(response) == {
|
|
||||||
'Fijo esencial': Decimal('560'),
|
|
||||||
'Fijo prescindible': Decimal('0'),
|
|
||||||
'Variable esencial': Decimal('0'),
|
|
||||||
'Variable prescindible': Decimal('40'),
|
|
||||||
'Sin clasificar': Decimal('7'),
|
|
||||||
}
|
|
||||||
assert sum(_breakdown(response).values()) == response.context['kpi_total']
|
|
||||||
assert 'id="expenseTypeChart"' in response.content.decode()
|
|
||||||
|
|
||||||
|
|
||||||
def test_expense_type_breakdown_follows_the_selected_period(auth_client, user, account, category):
|
|
||||||
Expense.objects.create(
|
|
||||||
owner=user, account=account, category=category,
|
|
||||||
amount=Decimal('10'), date=date(2024, 3, 1),
|
|
||||||
)
|
|
||||||
Expense.objects.create(
|
|
||||||
owner=user, account=account, category=category,
|
|
||||||
amount=Decimal('99'), date=date(2024, 4, 1),
|
|
||||||
)
|
|
||||||
|
|
||||||
response = auth_client.get(reverse('dashboard'), {'year': 2024, 'month': 3})
|
|
||||||
|
|
||||||
assert _breakdown(response)['Sin clasificar'] == Decimal('10')
|
|
||||||
|
|
||||||
|
|
||||||
def test_expense_type_breakdown_ignores_transfers(auth_client, user, account):
|
|
||||||
other = Account.objects.create(
|
|
||||||
owner=user, name="Ahorro", initial_balance=Decimal("0"), active=True
|
|
||||||
)
|
|
||||||
_make_transfer(user, account, other, amount="400", fee="1.50")
|
|
||||||
|
|
||||||
response = auth_client.get(reverse('dashboard'), {'year': 2024})
|
|
||||||
|
|
||||||
# Solo la comisión es gasto de verdad.
|
|
||||||
assert sum(_breakdown(response).values()) == Decimal('1.50')
|
|
||||||
|
|
||||||
|
|
||||||
def _classify_two_categories(user):
|
|
||||||
Category.objects.create(
|
|
||||||
name='Alquiler', owner=user, expense_type=Category.EXPENSE_TYPE_FIXED_ESSENTIAL
|
|
||||||
)
|
|
||||||
Category.objects.create(
|
|
||||||
name='Ocio', owner=user, expense_type=Category.EXPENSE_TYPE_VARIABLE_DISCRETIONARY
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_expense_type_breakdown_shows_empty_state_without_expenses(auth_client, user):
|
|
||||||
_classify_two_categories(user)
|
|
||||||
|
|
||||||
response = auth_client.get(reverse('dashboard'), {'year': 2024})
|
|
||||||
|
|
||||||
assert sum(_breakdown(response).values()) == 0
|
|
||||||
content = response.content.decode()
|
|
||||||
assert 'id="expenseTypeChart"' not in content
|
|
||||||
assert 'No hay gastos en este periodo' in content
|
|
||||||
assert 'Clasifica al menos dos categorías' not in content
|
|
||||||
|
|
||||||
|
|
||||||
def test_expense_type_breakdown_asks_to_classify_with_fewer_than_two(
|
|
||||||
auth_client, user, account
|
|
||||||
):
|
|
||||||
# Una sola categoría clasificada; la hija hereda pero no cuenta como
|
|
||||||
# clasificada, porque el usuario no la ha clasificado él.
|
|
||||||
parent = Category.objects.create(
|
|
||||||
name='Casa', owner=user, expense_type=Category.EXPENSE_TYPE_FIXED_ESSENTIAL
|
|
||||||
)
|
|
||||||
child = Category.objects.create(name='Luz', owner=user, parent=parent)
|
|
||||||
Expense.objects.create(
|
|
||||||
owner=user, account=account, category=child,
|
|
||||||
amount=Decimal('60'), date=date(2024, 3, 1),
|
|
||||||
)
|
|
||||||
|
|
||||||
response = auth_client.get(reverse('dashboard'), {'year': 2024})
|
|
||||||
|
|
||||||
assert response.context['expense_type_classified_count'] == 1
|
|
||||||
content = response.content.decode()
|
|
||||||
assert 'Clasifica al menos dos categorías' in content
|
|
||||||
assert 'href="%s"' % reverse('category_list') in content
|
|
||||||
assert 'id="expenseTypeChart"' not in content
|
|
||||||
assert 'id="expenseTypeGrouping"' not in content
|
|
||||||
assert 'No hay gastos en este periodo' not in content
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('classified, asks_to_classify', [(1, True), (2, False)])
|
|
||||||
def test_expense_type_breakdown_classification_threshold(
|
|
||||||
auth_client, user, account, classified, asks_to_classify
|
|
||||||
):
|
|
||||||
types = [Category.EXPENSE_TYPE_FIXED_ESSENTIAL, Category.EXPENSE_TYPE_VARIABLE_ESSENTIAL]
|
|
||||||
for i in range(classified):
|
|
||||||
category = Category.objects.create(name=f'Clasificada {i}', owner=user, expense_type=types[i])
|
|
||||||
Expense.objects.create(
|
|
||||||
owner=user, account=account, category=category,
|
|
||||||
amount=Decimal('10'), date=date(2024, 3, 1),
|
|
||||||
)
|
|
||||||
|
|
||||||
response = auth_client.get(reverse('dashboard'), {'year': 2024})
|
|
||||||
|
|
||||||
assert response.context['expense_type_classified_count'] == classified
|
|
||||||
content = response.content.decode()
|
|
||||||
assert ('Clasifica al menos dos categorías' in content) is asks_to_classify
|
|
||||||
assert ('id="expenseTypeChart"' in content) is not asks_to_classify
|
|
||||||
|
|
||||||
|
|
||||||
def test_expense_type_breakdown_renders_grouping_and_shape_controls(
|
|
||||||
auth_client, user, account
|
|
||||||
):
|
|
||||||
_classify_two_categories(user)
|
|
||||||
Expense.objects.create(
|
|
||||||
owner=user, account=account, category=Category.objects.get(name='Ocio'),
|
|
||||||
amount=Decimal('40'), date=date(2024, 3, 1),
|
|
||||||
)
|
|
||||||
|
|
||||||
response = auth_client.get(reverse('dashboard'), {'year': 2024})
|
|
||||||
|
|
||||||
content = response.content.decode()
|
|
||||||
assert 'id="expenseTypeGrouping"' in content
|
|
||||||
assert 'role="group" aria-label="Forma del gráfico"' in content
|
|
||||||
assert content.count('data-shape=') == 3
|
|
||||||
assert content.count('aria-pressed="true"') == 1
|
|
||||||
|
|
||||||
|
|
||||||
def _dashboard_queries(client):
|
|
||||||
with CaptureQueriesContext(connection) as ctx:
|
|
||||||
client.get(reverse('dashboard'), {'year': 2024})
|
|
||||||
return len(ctx.captured_queries)
|
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_query_count_does_not_grow_with_categories_and_expenses(
|
|
||||||
auth_client, user, account
|
|
||||||
):
|
|
||||||
root = Category.objects.create(
|
|
||||||
name='Raíz', owner=user, expense_type=Category.EXPENSE_TYPE_FIXED_ESSENTIAL
|
|
||||||
)
|
|
||||||
Expense.objects.create(
|
|
||||||
owner=user, account=account, category=root,
|
|
||||||
amount=Decimal('1'), date=date(2024, 3, 1),
|
|
||||||
)
|
|
||||||
baseline = _dashboard_queries(auth_client)
|
|
||||||
|
|
||||||
# Más categorías anidadas y más gastos que las 7 filas de recent_expenses:
|
|
||||||
# ni la herencia del desglose ni el listado reciente deben pedir una
|
|
||||||
# consulta por fila.
|
|
||||||
parent = root
|
|
||||||
for i in range(10):
|
|
||||||
parent = Category.objects.create(name=f'Nivel {i}', owner=user, parent=parent)
|
|
||||||
Expense.objects.create(
|
|
||||||
owner=user, account=account, category=parent,
|
|
||||||
amount=Decimal('1'), date=date(2024, 3, 1),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert _dashboard_queries(auth_client) == baseline
|
|
||||||
|
|||||||
@ -104,8 +104,6 @@ DASHBOARD_CONTEXT_KEYS = {
|
|||||||
"chart_type",
|
"chart_type",
|
||||||
"by_category",
|
"by_category",
|
||||||
"by_category_chart",
|
"by_category_chart",
|
||||||
"expense_type_breakdown",
|
|
||||||
"expense_type_classified_count",
|
|
||||||
"accounts_charts",
|
"accounts_charts",
|
||||||
# Filtros de periodo, cuenta y rango libre
|
# Filtros de periodo, cuenta y rango libre
|
||||||
"period",
|
"period",
|
||||||
|
|||||||
@ -33,7 +33,6 @@ urlpatterns = [
|
|||||||
path('categories/<int:pk>/edit/', views.category_edit, name='category_edit'),
|
path('categories/<int:pk>/edit/', views.category_edit, name='category_edit'),
|
||||||
path('categories/<int:pk>/delete/', views.category_delete, name='category_delete'),
|
path('categories/<int:pk>/delete/', views.category_delete, name='category_delete'),
|
||||||
path('settings/', views.settings_index, name='settings_index'),
|
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/', views.export_index, name='export_index'),
|
||||||
path('export/download/', views.export_download, name='export_download'),
|
path('export/download/', views.export_download, name='export_download'),
|
||||||
path('goals/', views.goal_list, name='goal_list'),
|
path('goals/', views.goal_list, name='goal_list'),
|
||||||
|
|||||||
@ -13,7 +13,6 @@ from .models import (
|
|||||||
Income,
|
Income,
|
||||||
Goal,
|
Goal,
|
||||||
Transfer,
|
Transfer,
|
||||||
BudgetBlock,
|
|
||||||
)
|
)
|
||||||
from .forms import (
|
from .forms import (
|
||||||
ExpenseForm,
|
ExpenseForm,
|
||||||
@ -25,12 +24,11 @@ from .forms import (
|
|||||||
GoalForm,
|
GoalForm,
|
||||||
TransferForm,
|
TransferForm,
|
||||||
ExportForm,
|
ExportForm,
|
||||||
BudgetBlockForm,
|
|
||||||
)
|
)
|
||||||
from .csv_export import DIALECTS, EXPORTS, csv_filename, stream_csv
|
from .csv_export import DIALECTS, EXPORTS, csv_filename, stream_csv
|
||||||
|
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.db.models import Sum, Count, Min, ProtectedError, Q
|
from django.db.models import Sum, Count, Min, ProtectedError
|
||||||
from django.db.models.functions import (
|
from django.db.models.functions import (
|
||||||
ExtractMonth,
|
ExtractMonth,
|
||||||
ExtractYear,
|
ExtractYear,
|
||||||
@ -41,7 +39,6 @@ from django.db.models.functions import (
|
|||||||
|
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.http import HttpResponseBadRequest, StreamingHttpResponse
|
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.utils.http import url_has_allowed_host_and_scheme
|
||||||
from django.shortcuts import get_object_or_404, render, redirect
|
from django.shortcuts import get_object_or_404, render, redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@ -147,7 +144,6 @@ def _category_tree(categories):
|
|||||||
rows.append({
|
rows.append({
|
||||||
"category": category,
|
"category": category,
|
||||||
"depth": depth,
|
"depth": depth,
|
||||||
"expense_type": expense_type,
|
|
||||||
"expense_type_label": labels.get(expense_type, ""),
|
"expense_type_label": labels.get(expense_type, ""),
|
||||||
"expense_type_inherited": not category.expense_type,
|
"expense_type_inherited": not category.expense_type,
|
||||||
})
|
})
|
||||||
@ -157,106 +153,6 @@ def _category_tree(categories):
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def _expense_type_breakdown(user, expenses):
|
|
||||||
"""Suma `expenses` por el tipo de gasto efectivo de su categoría.
|
|
||||||
|
|
||||||
Devuelve siempre las cinco filas (los cuatro tipos y "Sin clasificar" al
|
|
||||||
final), aunque alguna valga cero: así cada tipo conserva su color en el
|
|
||||||
gráfico. Lo que no tiene tipo, ni propio ni heredado, va a "Sin
|
|
||||||
clasificar" y nunca se descarta, para que la suma cuadre con el total.
|
|
||||||
|
|
||||||
Devuelve también cuántas categorías tienen tipo propio (sin contar las
|
|
||||||
que lo heredan), para avisar cuando hay demasiado poco clasificado.
|
|
||||||
|
|
||||||
Son dos consultas fijas: las categorías, de las que _category_tree saca el
|
|
||||||
tipo heredado, y los gastos agrupados por categoría."""
|
|
||||||
tree = _category_tree(Category.objects.filter(owner=user))
|
|
||||||
effective = {row["category"].id: row["expense_type"] for row in tree}
|
|
||||||
classified_count = sum(1 for row in tree if row["category"].expense_type)
|
|
||||||
|
|
||||||
labels = dict(Category.EXPENSE_TYPE_CHOICES)
|
|
||||||
labels[""] = "Sin clasificar"
|
|
||||||
totals = dict.fromkeys(labels, Decimal("0"))
|
|
||||||
|
|
||||||
for row in expenses.values("category").annotate(total=Sum("amount")):
|
|
||||||
totals[effective.get(row["category"], "")] += row["total"]
|
|
||||||
|
|
||||||
rows = [
|
|
||||||
{"expense_type": key, "label": labels[key], "total": total}
|
|
||||||
for key, total in totals.items()
|
|
||||||
]
|
|
||||||
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):
|
def _without_transfers(movements):
|
||||||
"""Quita las patas de traspaso de un queryset de gastos o de ingresos.
|
"""Quita las patas de traspaso de un queryset de gastos o de ingresos.
|
||||||
|
|
||||||
@ -951,12 +847,6 @@ def dashboard(request):
|
|||||||
"total": total_cat
|
"total": total_cat
|
||||||
})
|
})
|
||||||
|
|
||||||
# Sobre el mismo conjunto que los KPIs (periodo, cuenta y sin traspasos),
|
|
||||||
# así que sus cinco porciones suman kpi_total.
|
|
||||||
expense_type_breakdown, expense_type_classified_count = _expense_type_breakdown(
|
|
||||||
request.user, expenses_filtered
|
|
||||||
)
|
|
||||||
|
|
||||||
#Graphic
|
#Graphic
|
||||||
# El eje se construye una sola vez (claves + etiquetas + agrupación) y las
|
# El eje se construye una sola vez (claves + etiquetas + agrupación) y las
|
||||||
# dos series del gráfico de ingresos contra gastos se calculan contra él.
|
# dos series del gráfico de ingresos contra gastos se calculan contra él.
|
||||||
@ -1109,11 +999,7 @@ def dashboard(request):
|
|||||||
goals = Goal.objects.filter(owner=request.user)
|
goals = Goal.objects.filter(owner=request.user)
|
||||||
|
|
||||||
# Recent expenses
|
# Recent expenses
|
||||||
recent_expenses = (
|
recent_expenses = Expense.objects.filter(owner=request.user).order_by('-date', '-id')[:7]
|
||||||
Expense.objects.filter(owner=request.user)
|
|
||||||
.select_related("category", "account")
|
|
||||||
.order_by('-date', '-id')[:7]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Send the data to the dashboard
|
# Send the data to the dashboard
|
||||||
return render(
|
return render(
|
||||||
@ -1124,8 +1010,6 @@ def dashboard(request):
|
|||||||
"today": today,
|
"today": today,
|
||||||
"by_category": by_category,
|
"by_category": by_category,
|
||||||
"by_category_chart": by_category_chart,
|
"by_category_chart": by_category_chart,
|
||||||
"expense_type_breakdown": expense_type_breakdown,
|
|
||||||
"expense_type_classified_count": expense_type_classified_count,
|
|
||||||
"chart_labels": chart_labels,
|
"chart_labels": chart_labels,
|
||||||
"chart_data": chart_totals,
|
"chart_data": chart_totals,
|
||||||
# La otra serie del gráfico de ingresos contra gastos. Se manda una
|
# La otra serie del gráfico de ingresos contra gastos. Se manda una
|
||||||
@ -1854,54 +1738,6 @@ def settings_index(request):
|
|||||||
return render(request, "settings/index.html", {"active_menu": "settings"})
|
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
|
@login_required
|
||||||
def transfer_list(request):
|
def transfer_list(request):
|
||||||
transfers = Transfer.objects.filter(owner=request.user).select_related(
|
transfers = Transfer.objects.filter(owner=request.user).select_related(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user