Compare commits
No commits in common. "e99af6f03fb3d65fd5372fa99ffbbfcdae47d88c" and "6cdc7f662f157e9f75ee8bef95ba4965cd4ce0d1" have entirely different histories.
e99af6f03f
...
6cdc7f662f
3
.gitignore
vendored
3
.gitignore
vendored
@ -165,5 +165,4 @@ staticfiles/
|
|||||||
*.bak
|
*.bak
|
||||||
|
|
||||||
# graphify
|
# graphify
|
||||||
graphify-out/
|
graphify-out/
|
||||||
.gstack/
|
|
||||||
@ -560,37 +560,6 @@ button[type="submit"]:hover,
|
|||||||
min-width: 280px;
|
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 {
|
.chart-box {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 320px;
|
height: 320px;
|
||||||
@ -648,7 +617,6 @@ button[type="submit"]:hover,
|
|||||||
.comparison {
|
.comparison {
|
||||||
background: var(--color-surface-alt);
|
background: var(--color-surface-alt);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin-top: 1rem;
|
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
@ -1099,6 +1067,10 @@ tr:hover {
|
|||||||
color: var(--color-danger-accent);
|
color: var(--color-danger-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comparison {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.movement-account {
|
.movement-account {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@ -13,15 +13,6 @@
|
|||||||
tickColor: styles.getPropertyValue('--color-text-muted').trim() || '#6b6560',
|
tickColor: styles.getPropertyValue('--color-text-muted').trim() || '#6b6560',
|
||||||
primaryColor: styles.getPropertyValue('--color-primary').trim() || '#0f766e',
|
primaryColor: styles.getPropertyValue('--color-primary').trim() || '#0f766e',
|
||||||
primaryFill: styles.getPropertyValue('--chart-fill-color').trim() || 'rgba(15, 118, 110, 0.2)',
|
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']
|
palette: ['#ff6384', '#36a2eb', '#cc65fe', '#ffce56', '#4bc0c0', '#ffa1b5']
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -66,17 +57,6 @@
|
|||||||
var registeredCharts = [];
|
var registeredCharts = [];
|
||||||
|
|
||||||
function registerThemedChart(chart, applyColors) {
|
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 });
|
registeredCharts.push({ chart: chart, applyColors: applyColors });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -55,51 +55,49 @@
|
|||||||
<br>
|
<br>
|
||||||
|
|
||||||
<form method="get" action="{% url 'dashboard' %}" id="filtered_form">
|
<form method="get" action="{% url 'dashboard' %}" id="filtered_form">
|
||||||
<div class="filters-main">
|
{% if period %}
|
||||||
{% if period %}
|
<input type="hidden" name="period" value="{{ period }}" id="hiddenPeriod">
|
||||||
<input type="hidden" name="period" value="{{ period }}" id="hiddenPeriod">
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<label class="sr-only" for="account">Cuenta</label>
|
<label class="sr-only" for="account">Cuenta</label>
|
||||||
<select name="account" id="account">
|
<select name="account" id="account">
|
||||||
<option value="">Todas las cuentas</option>
|
<option value="">Todas las cuentas</option>
|
||||||
{% for acc in accounts %}
|
{% for acc in accounts %}
|
||||||
<option value="{{ acc.id }}" {% if selected_account == acc.id %}selected{% endif %}>
|
<option value="{{ acc.id }}" {% if selected_account == acc.id %}selected{% endif %}>
|
||||||
{{ acc.name }}
|
{{ acc.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{% include "expenses/_date_range.html" %}
|
||||||
|
|
||||||
|
{% if not period %}
|
||||||
|
<label class="sr-only" for="yearSelect">Año</label>
|
||||||
|
<select name="year" id="yearSelect">
|
||||||
|
{% for y in year_list %}
|
||||||
|
<option value="{{ y }}" {% if y == selected_year %}selected{% endif %}>
|
||||||
|
{{ y }}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{% include "expenses/_date_range.html" %}
|
<label class="sr-only" for="monthSelect">Mes</label>
|
||||||
|
<select name="month" id="monthSelect">
|
||||||
|
<option value="">Todo el año</option>
|
||||||
|
{% for m in months %}
|
||||||
|
<option value="{{ m }}"{% if m == selected_month %}selected{% endif %}>
|
||||||
|
{{ m }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if not period %}
|
<label class="checkbox">
|
||||||
<label class="sr-only" for="yearSelect">Año</label>
|
<input type="checkbox" name="compare" value="1" {% if compare_enabled %}checked{% endif %}>
|
||||||
<select name="year" id="yearSelect">
|
Comparar periodo anterior
|
||||||
{% for y in year_list %}
|
</label>
|
||||||
<option value="{{ y }}" {% if y == selected_year %}selected{% endif %}>
|
|
||||||
{{ y }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label class="sr-only" for="monthSelect">Mes</label>
|
<button type="submit" class="btn btn-primary">Aplicar Filtros</button>
|
||||||
<select name="month" id="monthSelect">
|
|
||||||
<option value="">Todo el año</option>
|
|
||||||
{% for m in months %}
|
|
||||||
<option value="{{ m }}"{% if m == selected_month %}selected{% endif %}>
|
|
||||||
{{ m }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<label class="checkbox">
|
|
||||||
<input type="checkbox" name="compare" value="1" {% if compare_enabled %}checked{% endif %}>
|
|
||||||
Comparar periodo anterior
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Aplicar Filtros</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if range_active %}
|
{% if range_active %}
|
||||||
@ -200,35 +198,14 @@
|
|||||||
|
|
||||||
<div class="charts-container">
|
<div class="charts-container">
|
||||||
<section>
|
<section>
|
||||||
<div class="chart-header">
|
<h2>Evolución de Gastos ({% if chart_type == 'day' %}Por día {% else %} Por Meses{% endif %})</h2>
|
||||||
<h2>Evolución de Gastos ({% if chart_type == 'day' %}Por día {% else %} Por Meses{% endif %})</h2>
|
|
||||||
</div>
|
|
||||||
<div class="chart-box">
|
<div class="chart-box">
|
||||||
<canvas id="mainChart"></canvas>
|
<canvas id="mainChart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="chart-header">
|
<h2>Distribución por Categorías</h2>
|
||||||
<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">
|
<div class="chart-box">
|
||||||
<canvas id="categoryChart"></canvas>
|
<canvas id="categoryChart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
@ -356,187 +333,6 @@
|
|||||||
applyCartesianColors(chart, colors);
|
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"];
|
const allMonths = ["Ene", "Feb", "Mar", "Abr", "May", "Jun", "Jul", "Ago", "Sep", "Oct", "Nov", "Dic"];
|
||||||
|
|
||||||
{% for acc in accounts_charts %}
|
{% for acc in accounts_charts %}
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from datetime import date, timedelta
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
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
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@ -172,264 +172,6 @@ def test_dashboard_compare_fills_difference_and_trend(auth_client, user, account
|
|||||||
assert rows[category.name]['difference'] == 10.0
|
assert rows[category.name]['difference'] == 10.0
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_comparison_is_ordered_by_absolute_difference(
|
|
||||||
auth_client, user, account
|
|
||||||
):
|
|
||||||
# Las categorías se juntan con un set, y el orden de un set no es estable
|
|
||||||
# entre ejecuciones: sin ordenar, la tabla sale distinta en cada recarga.
|
|
||||||
moved_little = Category.objects.create(owner=user, name="Poco")
|
|
||||||
moved_a_lot = Category.objects.create(owner=user, name="Mucho")
|
|
||||||
|
|
||||||
for cat, previous, current in (
|
|
||||||
(moved_little, "10", "15"),
|
|
||||||
(moved_a_lot, "10", "90"),
|
|
||||||
):
|
|
||||||
Expense.objects.create(
|
|
||||||
owner=user, account=account, category=cat,
|
|
||||||
amount=Decimal(previous), date=date(2024, 2, 15),
|
|
||||||
)
|
|
||||||
Expense.objects.create(
|
|
||||||
owner=user, account=account, category=cat,
|
|
||||||
amount=Decimal(current), date=date(2024, 3, 10),
|
|
||||||
)
|
|
||||||
|
|
||||||
response = auth_client.get(
|
|
||||||
reverse('dashboard'),
|
|
||||||
{'year': 2024, 'month': 3, 'compare': '1'},
|
|
||||||
)
|
|
||||||
|
|
||||||
order = [row['category'] for row in response.context['category_comparison']]
|
|
||||||
assert order == ["Mucho", "Poco"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_comparison_breaks_ties_by_category_name(
|
|
||||||
auth_client, user, account
|
|
||||||
):
|
|
||||||
# Misma diferencia en las tres: sin desempate explícito por nombre
|
|
||||||
# seguirían bailando entre recargas aunque el orden principal sea estable.
|
|
||||||
for name in ("Zeta", "Alfa", "Media"):
|
|
||||||
cat = Category.objects.create(owner=user, name=name)
|
|
||||||
Expense.objects.create(
|
|
||||||
owner=user, account=account, category=cat,
|
|
||||||
amount=Decimal("10"), date=date(2024, 2, 15),
|
|
||||||
)
|
|
||||||
Expense.objects.create(
|
|
||||||
owner=user, account=account, category=cat,
|
|
||||||
amount=Decimal("20"), date=date(2024, 3, 10),
|
|
||||||
)
|
|
||||||
|
|
||||||
response = auth_client.get(
|
|
||||||
reverse('dashboard'),
|
|
||||||
{'year': 2024, 'month': 3, 'compare': '1'},
|
|
||||||
)
|
|
||||||
|
|
||||||
order = [row['category'] for row in response.context['category_comparison']]
|
|
||||||
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):
|
def test_dashboard_chart_type_is_day_when_a_month_is_selected(auth_client):
|
||||||
response = auth_client.get(reverse('dashboard'), {'year': 2024, 'month': 3})
|
response = auth_client.get(reverse('dashboard'), {'year': 2024, 'month': 3})
|
||||||
|
|
||||||
|
|||||||
@ -143,16 +143,6 @@ def _category_tree(categories):
|
|||||||
return rows
|
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):
|
def _analysis_expenses(user):
|
||||||
"""Gastos para análisis: excluye los traspasos entre cuentas propias.
|
"""Gastos para análisis: excluye los traspasos entre cuentas propias.
|
||||||
|
|
||||||
@ -164,64 +154,7 @@ def _analysis_expenses(user):
|
|||||||
monthly_balance, balance_until, monthly_net): ahí los traspasos SÍ cuentan,
|
monthly_balance, balance_until, monthly_net): ahí los traspasos SÍ cuentan,
|
||||||
y filtrarlos descuadraría todos los saldos.
|
y filtrarlos descuadraría todos los saldos.
|
||||||
"""
|
"""
|
||||||
return _without_transfers(Expense.objects.filter(owner=user))
|
return Expense.objects.filter(owner=user).exclude(is_transfer=True)
|
||||||
|
|
||||||
|
|
||||||
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):
|
def _reject_transfer_leg(request, movement):
|
||||||
@ -787,27 +720,16 @@ def dashboard(request):
|
|||||||
if account_id:
|
if account_id:
|
||||||
expenses = expenses.filter(account_id=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:
|
if range_active:
|
||||||
expenses_filtered = expenses
|
expenses_filtered = expenses
|
||||||
incomes_filtered = incomes
|
|
||||||
if date_from:
|
if date_from:
|
||||||
expenses_filtered = expenses_filtered.filter(date__gte=date_from)
|
expenses_filtered = expenses_filtered.filter(date__gte=date_from)
|
||||||
incomes_filtered = incomes_filtered.filter(date__gte=date_from)
|
|
||||||
if date_to:
|
if date_to:
|
||||||
expenses_filtered = expenses_filtered.filter(date__lte=date_to)
|
expenses_filtered = expenses_filtered.filter(date__lte=date_to)
|
||||||
incomes_filtered = incomes_filtered.filter(date__lte=date_to)
|
|
||||||
else:
|
else:
|
||||||
expenses_filtered = expenses.filter(date__year=selected_year)
|
expenses_filtered = expenses.filter(date__year=selected_year)
|
||||||
incomes_filtered = incomes.filter(date__year=selected_year)
|
|
||||||
if selected_month:
|
if selected_month:
|
||||||
expenses_filtered = expenses_filtered.filter(date__month=selected_month)
|
expenses_filtered = expenses_filtered.filter(date__month=selected_month)
|
||||||
incomes_filtered = incomes_filtered.filter(date__month=selected_month)
|
|
||||||
|
|
||||||
|
|
||||||
# Basic KPIs
|
# Basic KPIs
|
||||||
@ -838,35 +760,20 @@ def dashboard(request):
|
|||||||
})
|
})
|
||||||
|
|
||||||
#Graphic
|
#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
|
daily_average = 0
|
||||||
projected_end_of_month = 0
|
projected_end_of_month = 0
|
||||||
if range_active:
|
if range_active:
|
||||||
# Con rango libre el eje no lo decide el mes sino la longitud del rango.
|
# 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
|
# Si falta un extremo se acota para poder medirlo: por abajo con el gasto
|
||||||
# movimiento más antiguo del conjunto, por arriba con hoy.
|
# más antiguo del conjunto, por arriba con hoy.
|
||||||
span_from = date_from
|
span_from = date_from or expenses_filtered.aggregate(first=Min("date"))["first"]
|
||||||
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
|
span_to = date_to or today
|
||||||
|
|
||||||
if span_from is None:
|
if span_from is None:
|
||||||
# Sin movimientos y sin fecha inicial no hay longitud que medir.
|
# Sin gastos y sin fecha inicial no hay longitud que medir.
|
||||||
axis_keys = []
|
|
||||||
chart_labels = []
|
chart_labels = []
|
||||||
|
chart_totals = []
|
||||||
chart_type = "month"
|
chart_type = "month"
|
||||||
bucket = "year_month"
|
|
||||||
else:
|
else:
|
||||||
span_days = (span_to - span_from).days + 1
|
span_days = (span_to - span_from).days + 1
|
||||||
daily_average = total_amount / span_days if span_days > 0 else 0
|
daily_average = total_amount / span_days if span_days > 0 else 0
|
||||||
@ -874,49 +781,71 @@ def dashboard(request):
|
|||||||
# un rango arbitrario no significa nada.
|
# un rango arbitrario no significa nada.
|
||||||
|
|
||||||
if span_days <= RANGE_DAY_CHART_MAX_DAYS:
|
if span_days <= RANGE_DAY_CHART_MAX_DAYS:
|
||||||
axis_keys = [span_from + timedelta(days=i) for i in range(span_days)]
|
# Agrupar por el propio campo, no con TruncDate: "date" ya es un
|
||||||
chart_labels = [d.strftime("%d/%m") for d in axis_keys]
|
# 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]
|
||||||
chart_type = "day"
|
chart_type = "day"
|
||||||
bucket = "date"
|
|
||||||
else:
|
else:
|
||||||
axis_keys = []
|
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 = []
|
||||||
cursor = (span_from.year, span_from.month)
|
cursor = (span_from.year, span_from.month)
|
||||||
last = (span_to.year, span_to.month)
|
last = (span_to.year, span_to.month)
|
||||||
while cursor <= last:
|
while cursor <= last:
|
||||||
axis_keys.append(cursor)
|
axis.append(cursor)
|
||||||
y, m = cursor
|
y, m = cursor
|
||||||
cursor = (y + 1, 1) if m == 12 else (y, m + 1)
|
cursor = (y + 1, 1) if m == 12 else (y, m + 1)
|
||||||
|
|
||||||
chart_labels = [f"{m:02d}/{y}" for y, m in axis_keys]
|
chart_labels = [f"{m:02d}/{y}" for y, m in axis]
|
||||||
|
chart_totals = [month_totals.get(key, 0) for key in axis]
|
||||||
chart_type = "month"
|
chart_type = "month"
|
||||||
bucket = "year_month"
|
|
||||||
elif selected_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]
|
num_days = calendar.monthrange(selected_year, selected_month)[1]
|
||||||
if selected_year == today.year and selected_month == today.month:
|
if selected_year == today.year and selected_month == today.month:
|
||||||
days_passed = today.day
|
days_passed = today.day
|
||||||
else:
|
else:
|
||||||
days_passed = calendar.monthrange(selected_year, selected_month)[1]
|
days_passed = calendar.monthrange(selected_year, selected_month)[1]
|
||||||
|
|
||||||
daily_average = total_amount / days_passed if days_passed > 0 else 0
|
daily_average = total_amount / days_passed if days_passed > 0 else 0
|
||||||
total_days_in_month = calendar.monthrange(selected_year, selected_month)[1]
|
total_days_in_month = calendar.monthrange(selected_year, selected_month)[1]
|
||||||
projected_end_of_month = daily_average * total_days_in_month
|
projected_end_of_month = daily_average * total_days_in_month
|
||||||
|
|
||||||
axis_keys = list(range(1, num_days + 1))
|
chart_labels = list(range(1, num_days + 1))
|
||||||
chart_labels = axis_keys
|
chart_totals = [day_totals.get(d, 0) for d in chart_labels]
|
||||||
chart_type = "day"
|
chart_type = "day"
|
||||||
bucket = "day_of_month"
|
|
||||||
else:
|
else:
|
||||||
axis_keys = list(range(1, 13))
|
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}
|
||||||
chart_labels = ["Ene", "Feb", "Mar", "Abr", "May", "Jun", "Jul", "Ago", "Sep", "Oct", "Nov", "Dic"]
|
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"
|
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 = (
|
year_list = (
|
||||||
Expense.objects.filter(owner=request.user)
|
Expense.objects.filter(owner=request.user)
|
||||||
@ -971,14 +900,6 @@ def dashboard(request):
|
|||||||
"difference": cur_t - prev_t,
|
"difference": cur_t - prev_t,
|
||||||
"difference_abs": abs(cur_t - prev_t)
|
"difference_abs": abs(cur_t - prev_t)
|
||||||
})
|
})
|
||||||
|
|
||||||
# El orden de un set de Python no es estable entre ejecuciones, así que
|
|
||||||
# la tabla salía distinta en cada recarga. Primero lo que más se ha
|
|
||||||
# movido, y el nombre como desempate explícito: sin él, dos categorías
|
|
||||||
# con la misma diferencia seguirían bailando.
|
|
||||||
category_comparison.sort(
|
|
||||||
key=lambda row: (-row["difference_abs"], row["category"])
|
|
||||||
)
|
|
||||||
|
|
||||||
# Anual evolution by accounts
|
# Anual evolution by accounts
|
||||||
accounts_charts = _build_account_charts(
|
accounts_charts = _build_account_charts(
|
||||||
@ -1002,9 +923,6 @@ def dashboard(request):
|
|||||||
"by_category_chart": by_category_chart,
|
"by_category_chart": by_category_chart,
|
||||||
"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
|
|
||||||
# sola vez: el neto y los tres modos se resuelven en cliente.
|
|
||||||
"chart_income_data": chart_income_totals,
|
|
||||||
"chart_type": chart_type,
|
"chart_type": chart_type,
|
||||||
"year_list": year_list,
|
"year_list": year_list,
|
||||||
"months": list(range(1, 13)),
|
"months": list(range(1, 13)),
|
||||||
@ -1240,7 +1158,7 @@ def income_list(request):
|
|||||||
|
|
||||||
# La tabla sigue listando los traspasos, pero el total mide ingreso real y
|
# 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.
|
# los excluye. Por eso la etiqueta dice "sin traspasos" en la plantilla.
|
||||||
totals = _without_transfers(incomes).aggregate(
|
totals = incomes.exclude(is_transfer=True).aggregate(
|
||||||
total=Sum("amount"), count=Count("id")
|
total=Sum("amount"), count=Count("id")
|
||||||
)
|
)
|
||||||
total_amount = totals["total"] or 0
|
total_amount = totals["total"] or 0
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user