Compare commits
3 Commits
3c313aa4e4
...
20fb21721a
| Author | SHA1 | Date | |
|---|---|---|---|
| 20fb21721a | |||
| 3eb36a1226 | |||
| 27703bd47c |
3
.gitignore
vendored
3
.gitignore
vendored
@ -165,4 +165,5 @@ staticfiles/
|
||||
*.bak
|
||||
|
||||
# graphify
|
||||
graphify-out/
|
||||
graphify-out/
|
||||
.gstack/
|
||||
|
||||
@ -560,6 +560,37 @@ button[type="submit"]:hover,
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
/* 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
|
||||
también en las tarjetas que no lo tienen, así que los dos ejes 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. */
|
||||
.chart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
min-height: 2.5rem;
|
||||
margin: 1.5rem 0 0.75rem;
|
||||
}
|
||||
|
||||
/* Sin esto el margen del h2 se sumaría al de la cabecera y el min-height
|
||||
dejaría de mandar. */
|
||||
.chart-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* El alto del control se fija por debajo del min-height de la cabecera. Así la
|
||||
cabecera mide siempre el min-height en las dos tarjetas y la alineación no
|
||||
depende de cuánto mida un <select> en cada navegador, que es justo lo que
|
||||
haría que cuadrara en uno y no en otro. */
|
||||
.chart-header select {
|
||||
height: 2.25rem;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.chart-box {
|
||||
position: relative;
|
||||
height: 320px;
|
||||
|
||||
@ -13,6 +13,15 @@
|
||||
tickColor: styles.getPropertyValue('--color-text-muted').trim() || '#6b6560',
|
||||
primaryColor: styles.getPropertyValue('--color-primary').trim() || '#0f766e',
|
||||
primaryFill: styles.getPropertyValue('--chart-fill-color').trim() || 'rgba(15, 118, 110, 0.2)',
|
||||
// Verde ingreso / rojo gasto, los mismos acentos que usan los
|
||||
// importes de las tablas, para que el gráfico no invente un código
|
||||
// de color propio.
|
||||
successColor: styles.getPropertyValue('--color-success-accent').trim() || '#1e7e34',
|
||||
dangerColor: styles.getPropertyValue('--color-danger-accent').trim() || '#b91c1c',
|
||||
// Para aros y contornos que tienen que leerse encima de otra serie.
|
||||
// Cambia con el tema, así que el punto se recorta igual en claro
|
||||
// que en oscuro.
|
||||
surfaceColor: styles.getPropertyValue('--color-surface').trim() || '#ffffff',
|
||||
palette: ['#ff6384', '#36a2eb', '#cc65fe', '#ffce56', '#4bc0c0', '#ffa1b5']
|
||||
};
|
||||
}
|
||||
@ -57,6 +66,17 @@
|
||||
var registeredCharts = [];
|
||||
|
||||
function registerThemedChart(chart, applyColors) {
|
||||
// Un gráfico que se recrea (destroy() + new Chart, que es lo que exige
|
||||
// cambiar de tipo) vuelve a pasar por aquí. El registro solo acumulaba,
|
||||
// así que la entrada vieja se quedaba apuntando a un gráfico destruido y
|
||||
// refreshThemedCharts() petaba al llamarle update(), dejando sin tema a
|
||||
// todos los gráficos registrados por detrás. Se descartan las entradas
|
||||
// del mismo canvas y las de gráficos ya destruidos: destroy() deja
|
||||
// chart.canvas en null.
|
||||
registeredCharts = registeredCharts.filter(function (entry) {
|
||||
return entry.chart.canvas && entry.chart.canvas !== chart.canvas;
|
||||
});
|
||||
|
||||
registeredCharts.push({ chart: chart, applyColors: applyColors });
|
||||
}
|
||||
|
||||
|
||||
@ -200,14 +200,35 @@
|
||||
|
||||
<div class="charts-container">
|
||||
<section>
|
||||
<h2>Evolución de Gastos ({% if chart_type == 'day' %}Por día {% else %} Por Meses{% endif %})</h2>
|
||||
<div class="chart-header">
|
||||
<h2>Evolución de Gastos ({% if chart_type == 'day' %}Por día {% else %} Por Meses{% endif %})</h2>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<canvas id="mainChart"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Distribución por Categorías</h2>
|
||||
<div class="chart-header">
|
||||
<h2>Ingresos vs gastos ({% if chart_type == 'day' %}Por día{% else %}Por meses{% endif %})</h2>
|
||||
<div class="filters-main">
|
||||
<label class="sr-only" for="balanceChartMode">Modo del gráfico</label>
|
||||
<select id="balanceChartMode">
|
||||
<option value="bars">Barras agrupadas</option>
|
||||
<option value="lines">Dos líneas</option>
|
||||
<option value="net">Barras + neto</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<canvas id="balanceChart"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="chart-header">
|
||||
<h2>Distribución por Categorías</h2>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<canvas id="categoryChart"></canvas>
|
||||
</div>
|
||||
@ -335,6 +356,187 @@
|
||||
applyCartesianColors(chart, colors);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Ingresos vs gastos: tres modos, todos en cliente
|
||||
// ---------------------------------------------------------------
|
||||
(function () {
|
||||
const incomeData = {{ chart_income_data|safe }};
|
||||
const expenseData = data;
|
||||
// El neto se resta aquí: las dos series vienen del servidor una sola
|
||||
// vez y cambiar de modo no le pide nada.
|
||||
const netData = incomeData.map(function (value, i) {
|
||||
return value - (expenseData[i] || 0);
|
||||
});
|
||||
|
||||
const canvas = document.getElementById('balanceChart');
|
||||
const modeSelect = document.getElementById('balanceChartMode');
|
||||
const STORAGE_KEY = 'dashboardBalanceChartMode';
|
||||
const MODES = ['bars', 'lines', 'net'];
|
||||
let balanceChart = null;
|
||||
|
||||
function storedMode() {
|
||||
// localStorage puede fallar (modo privado, cookies bloqueadas):
|
||||
// si no se puede leer, se asume el modo por defecto.
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
return MODES.indexOf(saved) !== -1 ? saved : 'bars';
|
||||
} catch (e) {
|
||||
return 'bars';
|
||||
}
|
||||
}
|
||||
|
||||
function rememberMode(mode) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, mode);
|
||||
} catch (e) {
|
||||
// Sin persistencia, pero el gráfico ya ha cambiado.
|
||||
}
|
||||
}
|
||||
|
||||
// Chart.js dibuja los datasets ordenados por `order` y recorriéndolos
|
||||
// del revés, así que el de `order` MÁS BAJO se pinta ÚLTIMO y queda
|
||||
// encima. El orden del array no manda: sin order explícito la línea
|
||||
// del neto se quedaba por detrás de las barras.
|
||||
const ORDER_NET = 0;
|
||||
const ORDER_INCOMES = 1;
|
||||
const ORDER_EXPENSES = 2;
|
||||
|
||||
function datasetsFor(mode, colors) {
|
||||
const incomes = {
|
||||
label: 'Ingresos (€)',
|
||||
data: incomeData,
|
||||
backgroundColor: colors.successColor,
|
||||
borderColor: colors.successColor,
|
||||
borderWidth: 1,
|
||||
order: ORDER_INCOMES
|
||||
};
|
||||
const expenses = {
|
||||
label: 'Gastos (€)',
|
||||
data: expenseData,
|
||||
backgroundColor: colors.dangerColor,
|
||||
borderColor: colors.dangerColor,
|
||||
borderWidth: 1,
|
||||
order: ORDER_EXPENSES
|
||||
};
|
||||
|
||||
if (mode === 'lines') {
|
||||
incomes.tension = 0.3;
|
||||
expenses.tension = 0.3;
|
||||
incomes.fill = false;
|
||||
expenses.fill = false;
|
||||
return [incomes, expenses];
|
||||
}
|
||||
|
||||
if (mode === 'net') {
|
||||
return [incomes, expenses, {
|
||||
label: 'Neto (€)',
|
||||
data: netData,
|
||||
type: 'line',
|
||||
order: ORDER_NET,
|
||||
borderColor: colors.primaryColor,
|
||||
backgroundColor: colors.primaryColor,
|
||||
borderWidth: 3,
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
// Puntos con aro del color del fondo: así se recortan
|
||||
// sobre las barras en vez de diluirse en ellas.
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
pointBackgroundColor: colors.primaryColor,
|
||||
pointBorderColor: colors.surfaceColor,
|
||||
pointBorderWidth: 2
|
||||
}];
|
||||
}
|
||||
|
||||
return [incomes, expenses];
|
||||
}
|
||||
|
||||
function applyBalanceColors(chart, colors) {
|
||||
chart.data.datasets.forEach(function (dataset) {
|
||||
if (dataset.label === 'Ingresos (€)') {
|
||||
dataset.backgroundColor = colors.successColor;
|
||||
dataset.borderColor = colors.successColor;
|
||||
} else if (dataset.label === 'Gastos (€)') {
|
||||
dataset.backgroundColor = colors.dangerColor;
|
||||
dataset.borderColor = colors.dangerColor;
|
||||
} else {
|
||||
dataset.backgroundColor = colors.primaryColor;
|
||||
dataset.borderColor = colors.primaryColor;
|
||||
// El aro del punto va del color del fondo, que cambia
|
||||
// con el tema: sin esto se queda con el color anterior
|
||||
// y el punto deja de recortarse.
|
||||
dataset.pointBackgroundColor = colors.primaryColor;
|
||||
dataset.pointBorderColor = colors.surfaceColor;
|
||||
}
|
||||
});
|
||||
applyCartesianColors(chart, colors);
|
||||
}
|
||||
|
||||
function render(mode) {
|
||||
const colors = getChartColors();
|
||||
|
||||
// Cambiar de modo obliga a destruir y crear de nuevo. Mutar
|
||||
// chart.config.type o chart.options.scales sobre un gráfico ya
|
||||
// creado es lo que provocaba "t.startsWith is not a function".
|
||||
if (balanceChart) {
|
||||
balanceChart.destroy();
|
||||
}
|
||||
|
||||
balanceChart = new Chart(canvas, {
|
||||
type: mode === 'lines' ? 'line' : 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: datasetsFor(mode, colors)
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: colors.gridColor },
|
||||
ticks: { color: colors.tickColor }
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: colors.gridColor },
|
||||
ticks: { color: colors.tickColor }
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: colors.tickColor,
|
||||
// `order` también ordena la leyenda, y con el
|
||||
// neto en 0 saldría listado primero. Se fija
|
||||
// por índice del dataset para que se lea
|
||||
// Ingresos, Gastos y el neto al final, que es
|
||||
// el derivado. Explícito y no por defecto:
|
||||
// así no depende de cómo ordene Chart.js.
|
||||
sort: function (a, b) {
|
||||
return a.datasetIndex - b.datasetIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Imprescindible tras recrear: si no se vuelve a registrar, el
|
||||
// gráfico queda fuera del tema y el modo oscuro deja de
|
||||
// aplicársele en cuanto el usuario cambia de modo.
|
||||
registerThemedChart(balanceChart, applyBalanceColors);
|
||||
}
|
||||
|
||||
const initialMode = storedMode();
|
||||
modeSelect.value = initialMode;
|
||||
render(initialMode);
|
||||
|
||||
modeSelect.addEventListener('change', function () {
|
||||
rememberMode(modeSelect.value);
|
||||
render(modeSelect.value);
|
||||
});
|
||||
})();
|
||||
|
||||
const allMonths = ["Ene", "Feb", "Mar", "Abr", "May", "Jun", "Jul", "Ago", "Sep", "Oct", "Nov", "Dic"];
|
||||
|
||||
{% for acc in accounts_charts %}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import pytest
|
||||
from datetime import date
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from django.urls import reverse
|
||||
from expenses.models import Account, Expense, Category, Income
|
||||
from expenses.models import Account, Expense, Category, Income, Transfer
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@ -227,6 +227,209 @@ def test_dashboard_comparison_breaks_ties_by_category_name(
|
||||
assert order == ["Alfa", "Media", "Zeta"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Serie de ingresos contra gastos
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_transfer(user, from_account, to_account, amount="400", on=None, fee=None):
|
||||
transfer = Transfer(
|
||||
owner=user,
|
||||
date=on or date(2024, 3, 10),
|
||||
amount=Decimal(amount),
|
||||
from_account=from_account,
|
||||
to_account=to_account,
|
||||
fee_amount=Decimal(fee) if fee is not None else None,
|
||||
)
|
||||
transfer.sync()
|
||||
return transfer
|
||||
|
||||
|
||||
def test_dashboard_sends_both_series_on_the_same_axis(
|
||||
auth_client, user, account, category
|
||||
):
|
||||
Expense.objects.create(
|
||||
owner=user, account=account, category=category,
|
||||
amount=Decimal("30"), date=date(2024, 5, 10),
|
||||
)
|
||||
Income.objects.create(
|
||||
owner=user, account=account, name="Nómina",
|
||||
amount=Decimal("1500"), date=date(2024, 3, 10),
|
||||
)
|
||||
|
||||
response = auth_client.get(reverse('dashboard'), {'year': 2024})
|
||||
|
||||
expenses = response.context['chart_data']
|
||||
incomes = response.context['chart_income_data']
|
||||
# Mismo eje para las dos: si cada una montara el suyo, se desalinearían.
|
||||
assert len(incomes) == len(response.context['chart_labels']) == len(expenses)
|
||||
assert incomes[2] == 1500.0 # marzo
|
||||
assert expenses[4] == 30.0 # mayo
|
||||
assert incomes[4] == 0 # mayo no tuvo ingresos: cero, no hueco
|
||||
|
||||
|
||||
def test_dashboard_income_series_is_zero_filled_without_movements(auth_client):
|
||||
response = auth_client.get(reverse('dashboard'), {'year': 2024})
|
||||
|
||||
assert response.context['chart_income_data'] == [0] * 12
|
||||
|
||||
|
||||
def test_dashboard_income_series_is_zero_filled_in_a_month_without_movements(
|
||||
auth_client
|
||||
):
|
||||
response = auth_client.get(reverse('dashboard'), {'year': 2024, 'month': 3})
|
||||
|
||||
assert response.context['chart_income_data'] == [0] * 31
|
||||
|
||||
|
||||
def test_dashboard_both_series_ignore_a_transfer(user, auth_client, account):
|
||||
other = Account.objects.create(
|
||||
owner=user, name="Ahorro", initial_balance=Decimal("0"), active=True
|
||||
)
|
||||
_make_transfer(user, account, other, amount="400")
|
||||
|
||||
response = auth_client.get(reverse('dashboard'), {'year': 2024})
|
||||
|
||||
# Mandarse dinero a una cuenta propia no es ingreso ni gasto: si se
|
||||
# excluyera solo de los gastos, el gráfico daría un pico de ingresos cada
|
||||
# vez que el usuario se traspasa la nómina.
|
||||
assert sum(response.context['chart_income_data']) == 0
|
||||
assert sum(response.context['chart_data']) == 0
|
||||
|
||||
|
||||
def test_dashboard_counts_the_transfer_fee_as_an_expense(user, auth_client, 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})
|
||||
|
||||
# La comisión va con is_transfer=False: sale del patrimonio de verdad.
|
||||
assert sum(response.context['chart_data']) == 1.5
|
||||
assert sum(response.context['chart_income_data']) == 0
|
||||
|
||||
|
||||
def test_dashboard_income_series_follows_the_day_axis_in_a_short_range(
|
||||
auth_client, user, account
|
||||
):
|
||||
Income.objects.create(
|
||||
owner=user, account=account, name="Nómina",
|
||||
amount=Decimal("1500"), date=date(2024, 3, 5),
|
||||
)
|
||||
|
||||
response = auth_client.get(
|
||||
reverse('dashboard'),
|
||||
{'date_from': '2024-03-01', 'date_to': '2024-03-30'}, # 30 días
|
||||
)
|
||||
|
||||
assert response.context['chart_type'] == 'day'
|
||||
incomes = response.context['chart_income_data']
|
||||
assert len(incomes) == 30
|
||||
assert incomes[4] == 1500.0
|
||||
assert incomes[0] == 0
|
||||
|
||||
|
||||
def test_dashboard_income_series_follows_the_month_axis_in_a_long_range(
|
||||
auth_client, user, account
|
||||
):
|
||||
Income.objects.create(
|
||||
owner=user, account=account, name="Nómina",
|
||||
amount=Decimal("1500"), date=date(2024, 2, 5),
|
||||
)
|
||||
|
||||
response = auth_client.get(
|
||||
reverse('dashboard'),
|
||||
{'date_from': '2024-01-01', 'date_to': '2024-04-29'}, # 120 días
|
||||
)
|
||||
|
||||
assert response.context['chart_type'] == 'month'
|
||||
incomes = response.context['chart_income_data']
|
||||
assert len(incomes) == 4
|
||||
assert incomes[1] == 1500.0
|
||||
|
||||
|
||||
def test_dashboard_income_series_matches_the_62_day_threshold(
|
||||
auth_client, user, account
|
||||
):
|
||||
start = date(2024, 3, 1)
|
||||
at_limit = auth_client.get(
|
||||
reverse('dashboard'),
|
||||
{'date_from': start.isoformat(), 'date_to': (start + timedelta(days=61)).isoformat()},
|
||||
)
|
||||
over_limit = auth_client.get(
|
||||
reverse('dashboard'),
|
||||
{'date_from': start.isoformat(), 'date_to': (start + timedelta(days=62)).isoformat()},
|
||||
)
|
||||
|
||||
# La serie de ingresos usa el mismo umbral que la de gastos, no el suyo.
|
||||
assert at_limit.context['chart_type'] == 'day'
|
||||
assert len(at_limit.context['chart_income_data']) == 62
|
||||
assert over_limit.context['chart_type'] == 'month'
|
||||
assert len(over_limit.context['chart_income_data']) == 3 # mar, abr, may
|
||||
|
||||
|
||||
def test_dashboard_open_range_axis_covers_older_incomes(auth_client, user, account, category):
|
||||
Income.objects.create(
|
||||
owner=user, account=account, name="Nómina",
|
||||
amount=Decimal("1500"), date=date(2024, 3, 1),
|
||||
)
|
||||
Expense.objects.create(
|
||||
owner=user, account=account, category=category,
|
||||
amount=Decimal("30"), date=date(2024, 3, 20),
|
||||
)
|
||||
|
||||
response = auth_client.get(reverse('dashboard'), {'date_to': '2024-03-25'})
|
||||
|
||||
# Sin date_from el eje se acota con el movimiento más antiguo de las dos
|
||||
# series: midiendo solo el gasto más viejo, ese ingreso se caería del eje.
|
||||
assert response.context['chart_labels'][0] == "01/03"
|
||||
assert response.context['chart_income_data'][0] == 1500.0
|
||||
|
||||
|
||||
def test_dashboard_income_series_does_not_leak_between_users(
|
||||
auth_client, user, account, django_user_model
|
||||
):
|
||||
intruder = django_user_model.objects.create_user(username="otro", password="x")
|
||||
their_account = Account.objects.create(
|
||||
owner=intruder, name="Ajena", initial_balance=Decimal("0"), active=True
|
||||
)
|
||||
Income.objects.create(
|
||||
owner=intruder, account=their_account, name="Ajena",
|
||||
amount=Decimal("999"), date=date(2024, 3, 10),
|
||||
)
|
||||
Income.objects.create(
|
||||
owner=user, account=account, name="Mía",
|
||||
amount=Decimal("100"), date=date(2024, 3, 10),
|
||||
)
|
||||
|
||||
response = auth_client.get(reverse('dashboard'), {'year': 2024})
|
||||
|
||||
assert sum(response.context['chart_income_data']) == 100.0
|
||||
|
||||
|
||||
def test_dashboard_income_series_respects_the_account_filter(
|
||||
auth_client, user, account
|
||||
):
|
||||
other = Account.objects.create(
|
||||
owner=user, name="Otra", initial_balance=Decimal("0"), active=True
|
||||
)
|
||||
Income.objects.create(
|
||||
owner=user, account=account, name="Aquí",
|
||||
amount=Decimal("100"), date=date(2024, 3, 10),
|
||||
)
|
||||
Income.objects.create(
|
||||
owner=user, account=other, name="Allí",
|
||||
amount=Decimal("500"), date=date(2024, 3, 10),
|
||||
)
|
||||
|
||||
response = auth_client.get(
|
||||
reverse('dashboard'), {'year': 2024, 'account': account.id}
|
||||
)
|
||||
|
||||
assert sum(response.context['chart_income_data']) == 100.0
|
||||
|
||||
|
||||
def test_dashboard_chart_type_is_day_when_a_month_is_selected(auth_client):
|
||||
response = auth_client.get(reverse('dashboard'), {'year': 2024, 'month': 3})
|
||||
|
||||
|
||||
@ -143,6 +143,16 @@ def _category_tree(categories):
|
||||
return rows
|
||||
|
||||
|
||||
def _without_transfers(movements):
|
||||
"""Quita las patas de traspaso de un queryset de gastos o de ingresos.
|
||||
|
||||
Único sitio donde se decide qué cuenta como movimiento real en los análisis.
|
||||
Si cada pantalla lo filtrara por su cuenta acabaría habiendo dos criterios,
|
||||
y bastaría tocar uno para que los números dejaran de cuadrar entre sí.
|
||||
"""
|
||||
return movements.exclude(is_transfer=True)
|
||||
|
||||
|
||||
def _analysis_expenses(user):
|
||||
"""Gastos para análisis: excluye los traspasos entre cuentas propias.
|
||||
|
||||
@ -154,7 +164,64 @@ def _analysis_expenses(user):
|
||||
monthly_balance, balance_until, monthly_net): ahí los traspasos SÍ cuentan,
|
||||
y filtrarlos descuadraría todos los saldos.
|
||||
"""
|
||||
return Expense.objects.filter(owner=user).exclude(is_transfer=True)
|
||||
return _without_transfers(Expense.objects.filter(owner=user))
|
||||
|
||||
|
||||
def _analysis_incomes(user):
|
||||
"""Ingresos para análisis: el espejo de _analysis_expenses.
|
||||
|
||||
Hace falta porque si los traspasos se excluyeran solo del lado de los
|
||||
gastos, el gráfico de ingresos contra gastos daría un pico de ingresos cada
|
||||
vez que el usuario se traspasa la nómina a otra cuenta propia.
|
||||
|
||||
La misma advertencia que en los gastos: esto no vale para saldos.
|
||||
"""
|
||||
return _without_transfers(Income.objects.filter(owner=user))
|
||||
|
||||
|
||||
def _bucket_totals(movements, bucket):
|
||||
"""Suma importes agrupados según el eje del gráfico del dashboard.
|
||||
|
||||
`bucket` lo decide quien construye el eje, y las dos series del gráfico de
|
||||
ingresos contra gastos lo comparten: si cada una se agrupara por su cuenta,
|
||||
los ejes divergirían en cuanto una tuviera un hueco que la otra no.
|
||||
|
||||
Son dos consultas separadas (una por serie) que se cruzan después en Python.
|
||||
No se puede juntar en un annotate() de dos Sum sobre el mismo queryset: el
|
||||
JOIN multiplica filas e infla los totales.
|
||||
"""
|
||||
if bucket == "date":
|
||||
# Agrupar por el propio campo, no con TruncDate: "date" ya es un
|
||||
# DateField y TruncDate le aplicaría una conversión de zona horaria
|
||||
# que en SQLite falla si no está la base de datos tz.
|
||||
rows = movements.values("date").annotate(total=Sum("amount"))
|
||||
return {row["date"]: float(row["total"]) for row in rows}
|
||||
|
||||
if bucket == "year_month":
|
||||
rows = (
|
||||
movements.annotate(bucket=TruncMonth("date"))
|
||||
.values("bucket")
|
||||
.annotate(total=Sum("amount"))
|
||||
)
|
||||
return {
|
||||
(row["bucket"].year, row["bucket"].month): float(row["total"])
|
||||
for row in rows
|
||||
}
|
||||
|
||||
if bucket == "day_of_month":
|
||||
rows = (
|
||||
movements.annotate(day=ExtractDay("date"))
|
||||
.values("day")
|
||||
.annotate(total=Sum("amount"))
|
||||
)
|
||||
return {row["day"]: float(row["total"]) for row in rows}
|
||||
|
||||
rows = (
|
||||
movements.annotate(month=ExtractMonth("date"))
|
||||
.values("month")
|
||||
.annotate(total=Sum("amount"))
|
||||
)
|
||||
return {row["month"]: float(row["total"]) for row in rows}
|
||||
|
||||
|
||||
def _reject_transfer_leg(request, movement):
|
||||
@ -720,16 +787,27 @@ def dashboard(request):
|
||||
if account_id:
|
||||
expenses = expenses.filter(account_id=account_id)
|
||||
|
||||
# Los ingresos van en paralelo y con los mismos filtros: son la otra serie
|
||||
# del gráfico de ingresos contra gastos.
|
||||
incomes = _analysis_incomes(request.user)
|
||||
if account_id:
|
||||
incomes = incomes.filter(account_id=account_id)
|
||||
|
||||
if range_active:
|
||||
expenses_filtered = expenses
|
||||
incomes_filtered = incomes
|
||||
if date_from:
|
||||
expenses_filtered = expenses_filtered.filter(date__gte=date_from)
|
||||
incomes_filtered = incomes_filtered.filter(date__gte=date_from)
|
||||
if date_to:
|
||||
expenses_filtered = expenses_filtered.filter(date__lte=date_to)
|
||||
incomes_filtered = incomes_filtered.filter(date__lte=date_to)
|
||||
else:
|
||||
expenses_filtered = expenses.filter(date__year=selected_year)
|
||||
incomes_filtered = incomes.filter(date__year=selected_year)
|
||||
if selected_month:
|
||||
expenses_filtered = expenses_filtered.filter(date__month=selected_month)
|
||||
incomes_filtered = incomes_filtered.filter(date__month=selected_month)
|
||||
|
||||
|
||||
# Basic KPIs
|
||||
@ -760,20 +838,35 @@ def dashboard(request):
|
||||
})
|
||||
|
||||
#Graphic
|
||||
# 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.
|
||||
# Si cada serie montara su propio eje, dos huecos distintos las
|
||||
# desalinearían y el gráfico mentiría.
|
||||
daily_average = 0
|
||||
projected_end_of_month = 0
|
||||
if range_active:
|
||||
# Con rango libre el eje no lo decide el mes sino la longitud del rango.
|
||||
# Si falta un extremo se acota para poder medirlo: por abajo con el gasto
|
||||
# más antiguo del conjunto, por arriba con hoy.
|
||||
span_from = date_from or expenses_filtered.aggregate(first=Min("date"))["first"]
|
||||
# Si falta un extremo se acota para poder medirlo: por abajo con el
|
||||
# movimiento más antiguo del conjunto, por arriba con hoy.
|
||||
span_from = date_from
|
||||
if span_from is None:
|
||||
# Se mira el más antiguo de LAS DOS series: acotando solo por el
|
||||
# gasto más viejo, los ingresos anteriores se caerían del eje sin
|
||||
# que nada lo avisara.
|
||||
firsts = [
|
||||
expenses_filtered.aggregate(first=Min("date"))["first"],
|
||||
incomes_filtered.aggregate(first=Min("date"))["first"],
|
||||
]
|
||||
firsts = [first for first in firsts if first is not None]
|
||||
span_from = min(firsts) if firsts else None
|
||||
span_to = date_to or today
|
||||
|
||||
if span_from is None:
|
||||
# Sin gastos y sin fecha inicial no hay longitud que medir.
|
||||
# Sin movimientos y sin fecha inicial no hay longitud que medir.
|
||||
axis_keys = []
|
||||
chart_labels = []
|
||||
chart_totals = []
|
||||
chart_type = "month"
|
||||
bucket = "year_month"
|
||||
else:
|
||||
span_days = (span_to - span_from).days + 1
|
||||
daily_average = total_amount / span_days if span_days > 0 else 0
|
||||
@ -781,71 +874,49 @@ def dashboard(request):
|
||||
# un rango arbitrario no significa nada.
|
||||
|
||||
if span_days <= RANGE_DAY_CHART_MAX_DAYS:
|
||||
# Agrupar por el propio campo, no con TruncDate: "date" ya es un
|
||||
# DateField y TruncDate le aplicaría una conversión de zona
|
||||
# horaria que en SQLite falla si no está la base de datos tz.
|
||||
by_day_qs = expenses_filtered.values("date").annotate(
|
||||
total=Sum("amount")
|
||||
)
|
||||
day_totals = {row["date"]: float(row["total"]) for row in by_day_qs}
|
||||
|
||||
axis = [span_from + timedelta(days=i) for i in range(span_days)]
|
||||
chart_labels = [d.strftime("%d/%m") for d in axis]
|
||||
chart_totals = [day_totals.get(d, 0) for d in axis]
|
||||
axis_keys = [span_from + timedelta(days=i) for i in range(span_days)]
|
||||
chart_labels = [d.strftime("%d/%m") for d in axis_keys]
|
||||
chart_type = "day"
|
||||
bucket = "date"
|
||||
else:
|
||||
by_month_qs = (
|
||||
expenses_filtered.annotate(bucket=TruncMonth("date"))
|
||||
.values("bucket")
|
||||
.annotate(total=Sum("amount"))
|
||||
)
|
||||
month_totals = {
|
||||
(row["bucket"].year, row["bucket"].month): float(row["total"])
|
||||
for row in by_month_qs
|
||||
}
|
||||
|
||||
axis = []
|
||||
axis_keys = []
|
||||
cursor = (span_from.year, span_from.month)
|
||||
last = (span_to.year, span_to.month)
|
||||
while cursor <= last:
|
||||
axis.append(cursor)
|
||||
axis_keys.append(cursor)
|
||||
y, m = cursor
|
||||
cursor = (y + 1, 1) if m == 12 else (y, m + 1)
|
||||
|
||||
chart_labels = [f"{m:02d}/{y}" for y, m in axis]
|
||||
chart_totals = [month_totals.get(key, 0) for key in axis]
|
||||
chart_labels = [f"{m:02d}/{y}" for y, m in axis_keys]
|
||||
chart_type = "month"
|
||||
bucket = "year_month"
|
||||
elif selected_month:
|
||||
by_day_qs = (
|
||||
expenses_filtered.annotate(day=ExtractDay("date"))
|
||||
.values("day")
|
||||
.annotate(total=Sum("amount"))
|
||||
)
|
||||
day_totals = {row["day"]: float(row["total"]) for row in by_day_qs}
|
||||
|
||||
num_days = calendar.monthrange(selected_year, selected_month)[1]
|
||||
if selected_year == today.year and selected_month == today.month:
|
||||
days_passed = today.day
|
||||
else:
|
||||
days_passed = calendar.monthrange(selected_year, selected_month)[1]
|
||||
|
||||
|
||||
daily_average = total_amount / days_passed if days_passed > 0 else 0
|
||||
total_days_in_month = calendar.monthrange(selected_year, selected_month)[1]
|
||||
projected_end_of_month = daily_average * total_days_in_month
|
||||
|
||||
chart_labels = list(range(1, num_days + 1))
|
||||
chart_totals = [day_totals.get(d, 0) for d in chart_labels]
|
||||
|
||||
axis_keys = list(range(1, num_days + 1))
|
||||
chart_labels = axis_keys
|
||||
chart_type = "day"
|
||||
bucket = "day_of_month"
|
||||
else:
|
||||
by_month_qs = (
|
||||
expenses_filtered.annotate(month=ExtractMonth("date"))
|
||||
.values("month")
|
||||
.annotate(total=Sum("amount"))
|
||||
)
|
||||
month_totals = {row["month"]: float(row["total"]) for row in by_month_qs}
|
||||
axis_keys = list(range(1, 13))
|
||||
chart_labels = ["Ene", "Feb", "Mar", "Abr", "May", "Jun", "Jul", "Ago", "Sep", "Oct", "Nov", "Dic"]
|
||||
chart_totals = [month_totals.get(m, 0) for m in range(1, 13)]
|
||||
chart_type = "month"
|
||||
bucket = "month_of_year"
|
||||
|
||||
# Los ceros son obligatorios: un día o un mes sin movimientos tiene que
|
||||
# salir con 0, no desaparecer del eje.
|
||||
expense_by_bucket = _bucket_totals(expenses_filtered, bucket)
|
||||
income_by_bucket = _bucket_totals(incomes_filtered, bucket)
|
||||
chart_totals = [expense_by_bucket.get(key, 0) for key in axis_keys]
|
||||
chart_income_totals = [income_by_bucket.get(key, 0) for key in axis_keys]
|
||||
|
||||
year_list = (
|
||||
Expense.objects.filter(owner=request.user)
|
||||
@ -931,6 +1002,9 @@ def dashboard(request):
|
||||
"by_category_chart": by_category_chart,
|
||||
"chart_labels": chart_labels,
|
||||
"chart_data": chart_totals,
|
||||
# La otra serie del gráfico de ingresos contra gastos. Se manda una
|
||||
# sola vez: el neto y los tres modos se resuelven en cliente.
|
||||
"chart_income_data": chart_income_totals,
|
||||
"chart_type": chart_type,
|
||||
"year_list": year_list,
|
||||
"months": list(range(1, 13)),
|
||||
@ -1166,7 +1240,7 @@ def income_list(request):
|
||||
|
||||
# La tabla sigue listando los traspasos, pero el total mide ingreso real y
|
||||
# los excluye. Por eso la etiqueta dice "sin traspasos" en la plantilla.
|
||||
totals = incomes.exclude(is_transfer=True).aggregate(
|
||||
totals = _without_transfers(incomes).aggregate(
|
||||
total=Sum("amount"), count=Count("id")
|
||||
)
|
||||
total_amount = totals["total"] or 0
|
||||
|
||||
Loading…
Reference in New Issue
Block a user