Add expense type breakdown chart to dashboard
This commit is contained in:
parent
9207580f8a
commit
40b0c7ed7b
@ -561,36 +561,85 @@ 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. El min-height reserva el alto del selector
|
lleva, su control a la derecha. Cuando el control no cabe junto al título,
|
||||||
también en las tarjetas que no lo tienen, así que los dos ejes arrancan a la
|
baja a una segunda línea. Eso pasa a anchos de escritorio normales: el de
|
||||||
misma altura por construcción y no por casualidad. Por eso no se compensa
|
tipo de gasto lleva dos grupos de controles y una tarjeta mide ~340 px con
|
||||||
tocando el alto del canvas: eso cuadraría solo a un ancho concreto.
|
cuatro por fila. El min-height reserva esas dos líneas (título de 2.25rem,
|
||||||
|
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;
|
||||||
gap: 1rem;
|
align-content: flex-start;
|
||||||
min-height: 2.5rem;
|
gap: 0.5rem 1rem;
|
||||||
|
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. */
|
dejaría de mandar. El alto mínimo iguala la línea del título a la de los
|
||||||
|
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 por debajo del min-height de la cabecera. Así la
|
/* El alto del control se fija igual que la línea del título. Así la cabecera
|
||||||
cabecera mide siempre el min-height en las dos tarjetas y la alineación no
|
mide siempre el min-height en todas las tarjetas y la alineación no depende
|
||||||
depende de cuánto mida un <select> en cada navegador, que es justo lo que
|
de cuánto mida un <select> en cada navegador, que es justo lo que haría que
|
||||||
haría que cuadrara en uno y no en otro. */
|
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;
|
||||||
|
|||||||
@ -235,7 +235,60 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Gastos Recientes</h2>
|
<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>
|
||||||
|
<h2>Gastos Recientes</h2>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
@ -267,8 +320,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="clear: both; margin-top: 2rem;"></div>
|
<div style="clear: both; margin-top: 2rem;"></div>
|
||||||
|
|
||||||
@ -302,6 +354,119 @@
|
|||||||
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,6 +1,8 @@
|
|||||||
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
|
||||||
|
|
||||||
@ -481,3 +483,184 @@ 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,6 +104,8 @@ 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",
|
||||||
|
|||||||
@ -144,6 +144,7 @@ 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,
|
||||||
})
|
})
|
||||||
@ -153,6 +154,37 @@ 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 _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.
|
||||||
|
|
||||||
@ -847,6 +879,12 @@ 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.
|
||||||
@ -999,7 +1037,11 @@ def dashboard(request):
|
|||||||
goals = Goal.objects.filter(owner=request.user)
|
goals = Goal.objects.filter(owner=request.user)
|
||||||
|
|
||||||
# Recent expenses
|
# Recent expenses
|
||||||
recent_expenses = Expense.objects.filter(owner=request.user).order_by('-date', '-id')[:7]
|
recent_expenses = (
|
||||||
|
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(
|
||||||
@ -1010,6 +1052,8 @@ 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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user