Compare commits

...

19 Commits

Author SHA1 Message Date
bfe96e76f2 Merge pull request 'Modificaciones en filtrado y creada opción para marcar que un gasto/ingreso es un traspaso de cuentas' (#34) from dev into main
Reviewed-on: #34
2026-09-16 07:50:17 +00:00
f3ac2526ce Merge branch 'main' into dev 2026-09-16 07:50:07 +00:00
8d3aaf1110 Distingue el estado vacio filtrado del listado sin datos
La tabla de gastos mostraba "No hay gastos / Anade el primero" tanto cuando el
usuario no tiene ningun gasto como cuando sus filtros no devuelven nada. En el
segundo caso el mensaje miente y ademas empuja a crear un gasto que no hace
falta.

Ahora, con cualquier filtro puesto (ano, mes, categoria, etiquetas, cuenta o
rango), el mensaje dice que no hay gastos que coincidan y ofrece quitarlos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:33:20 +02:00
e48512d2c8 Pon el listado de ingresos al nivel del resto
El listado de ingresos era el unico que seguia volcando la tabla entera sin
filtros, sin totales y sin paginacion: con unos cuantos anos de datos la
pagina se vuelve inmanejable y no hay forma de responder "cuanto ingrese en
marzo".

Se reutiliza lo que ya existe en el listado de gastos en vez de inventar nada:
los mismos filtros de ano, mes y cuenta, el mismo rango de fechas libre con la
misma precedencia y el mismo aviso, la misma paginacion de diez por pagina y
el mismo query_params para que los enlaces de pagina no pierdan los filtros.

Los ingresos marcados como traspaso se siguen listando, porque son movimientos
reales de la cuenta y hay que poder verlos y corregirlos, pero el total los
excluye: de ahi la etiqueta "Total (sin traspasos)".

El estado vacio distingue entre no tener ingresos y que los filtros no
devuelvan nada, para no invitar a crear un ingreso cuando el problema es el
filtro. El colspan de esa fila es 5, que son las columnas de esta tabla.

select_related("account") mantiene el numero de consultas plano: sin el, cada
fila pedia su cuenta por separado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:33:12 +02:00
50fd58daa7 Anade tests del rango de fechas
Cubren las reglas que no se ven leyendo la vista: que los extremos son
inclusivos, que cada fecha puede ir sola, que un rango invertido o una fecha
ilegible se ignoran en vez de vaciar el listado, y que el rango tiene
precedencia sobre los selectores de ano y mes.

En el dashboard se comprueba ademas que la comparativa queda desactivada y
avisada cuando hay rango, que el eje del grafico cambia de dia a mes al pasar
el umbral, y que los huecos del eje salen a cero en lugar de desaparecer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:32:52 +02:00
836f3e8524 Aplica el rango de fechas al dashboard
Un rango que solo funciona en el listado obliga a cambiar de pantalla para
responder "cuanto gaste en ese viaje". El dashboard acepta ahora los mismos
date_from y date_to, con la misma precedencia sobre ano y mes.

Dos decisiones que no son obvias:

La comparativa se desactiva cuando hay rango. Un rango arbitrario no tiene un
"periodo anterior" evidente, y elegir uno por el usuario daria un porcentaje
que parece significativo sin serlo. Se distingue entre pedirla y poder
aplicarla (compare_requested / compare_enabled) para poder avisar de que se
ha ignorado en vez de desmarcar la casilla en silencio.

El eje del grafico lo decide la longitud del rango, no el mes: por debajo de
62 dias se agrupa por dia y por encima por mes, que es donde un grafico
diario deja de leerse. El eje se rellena con ceros en los huecos, igual que
ya hacia el resto, para que un periodo sin gastos no colapse la escala. La
agrupacion diaria usa el propio campo date y no TruncDate, porque date ya es
un DateField y TruncDate le aplicaria una conversion de zona horaria que en
SQLite revienta si no esta instalada la base de datos de husos.

La tarjeta de proyeccion a fin de mes se oculta con rango activo: proyectar a
fin de mes desde un periodo arbitrario no significa nada.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:32:43 +02:00
c395c638e9 Anade un rango de fechas libre al listado de gastos
Los selectores de ano y mes solo saben expresar periodos naturales completos.
"Del 15 de marzo al 3 de abril" o "desde que empece el trabajo nuevo" no se
pueden pedir, y son justo las preguntas que uno le hace a un gestor de gastos.

Se anaden dos parametros date_from y date_to, opcionales por separado: "desde
el 1 de marzo" sin fecha final es un filtro perfectamente valido. Si el rango
viene invertido se ignoran las dos fechas, porque devolver una lista vacia sin
explicacion es peor que descartar un filtro mal puesto.

Cuando hay rango, manda el rango y los selectores de ano y mes no se aplican:
combinar ambos criterios da intersecciones que el usuario no ha pedido y no
puede ver. La plantilla lo avisa con un mensaje y un enlace para quitarlo, en
vez de dejar los selectores mostrando un periodo que no es el que se esta
viendo.

Los campos de fecha comparten estilo con el resto de filtros, incluido el
anillo de foco, para que no desentonen con los selectores de al lado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:32:11 +02:00
bb5ade4cc9 Quita un div de cierre sobrante en el listado de repostajes
La plantilla cerraba un div que nunca se habia abierto, asi que el
</div> del .table-wrap acababa cerrando el contenedor de la pagina y todo lo
que viniera despues quedaba fuera de su sitio. Ahora las aperturas y los
cierres cuadran.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:31:31 +02:00
2c05318e0b Anade tests de los traspasos
El campo is_transfer tiene dos comportamientos opuestos y uno de ellos falla
en silencio: si el filtro se cuela en un calculo de saldo, los numeros siguen
apareciendo, solo que mal. Los tests se organizan en dos bloques que cubren
justo esa frontera.

- Analisis: el traspaso no cuenta en los KPIs de home ni del dashboard, ni en
  el total del listado, ni en la comparativa de periodos, ni en el progreso de
  los objetivos de pago y presupuesto.
- Saldo: el traspaso SI cuenta en current_balance, monthly_balance,
  balance_until, monthly_net y en el objetivo de ahorro.

Tambien se cubre que el listado de gastos siga mostrando el traspaso aunque su
total lo descuente, que es la parte que un filtro puesto de mas romperia.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:31:23 +02:00
2d7cd8cb68 Permite marcar un traspaso y distinguirlo en la interfaz
Sin esto el campo is_transfer no es alcanzable: no aparece en los formularios
de gasto ni de ingreso, asi que nadie puede marcar un movimiento como
traspaso.

Ademas, un movimiento que no suma al total tiene que verse distinto o el
usuario no entendera por que sus numeros no cuadran con la tabla. Se anade un
distintivo "Traspaso" en el listado de gastos, en los ultimos movimientos de
home y en los gastos recientes del dashboard, que son los tres sitios donde
se listan movimientos sin agregarlos.

El distintivo usa una variante nueva, .badge-neutral, en vez de reutilizar
.badge-inactive: un traspaso es informacion, no un estado degradado, y
pintarlo con el color de error o de cuenta inactiva daria a entender que algo
va mal. Las variables que usa (--color-chip-bg y --color-text-muted) ya
existen en los dos temas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:31:11 +02:00
e2b756bcc7 Excluye los traspasos de los calculos de analisis
Un traspaso mueve dinero entre cuentas propias: suma al saldo de cada cuenta
pero no es gasto ni ingreso real. Las agregaciones de analisis (KPIs, graficos
de home y dashboard, comparativa de periodos, totales del listado y objetivos
de pago y presupuesto) pasan a excluirlo.

Hay dos comportamientos opuestos sobre el mismo campo y conviene no
confundirlos:

- Analisis: excluye los traspasos. De ahi el helper _analysis_expenses, que
  documenta en su docstring donde NO debe usarse.
- Saldo: los incluye. _account_balances, current_balance, monthly_balance,
  balance_until y monthly_net quedan intactos a proposito; filtrarlos ahi
  descuadraria todos los saldos de la aplicacion de forma silenciosa, que es
  mucho peor y menos evidente que inflar un KPI.

Por el mismo motivo la rama saving de Goal.progress tampoco filtra: mandar
dinero a la cuenta de ahorro es justo como se progresa en ese objetivo.

Los ultimos movimientos de home dejan de colgar de la consulta de KPIs y usan
la suya propia: son un listado, no un analisis, y un traspaso tiene que poder
verse ahi. Lo mismo en el listado de gastos, donde la tabla los sigue
mostrando y solo los totales los descuentan.

Los managers por defecto de Expense e Income no se tocan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:30:43 +02:00
8c0225a297 Anade el campo is_transfer a gastos e ingresos
Un traspaso entre cuentas propias se registra hoy como dos movimientos: un
gasto en la cuenta de origen y un ingreso en la de destino. Los saldos
cuadran, pero el gasto infla los analisis (KPIs, graficos, objetivos) con
dinero que nunca ha salido del patrimonio del usuario.

Marcar el movimiento hace falta en los dos modelos, no en uno solo. Una
"categoria de traspaso" cubriria unicamente la mitad de la operacion, porque
Income no tiene categoria; de ahi el booleano en ambos.

El campo solo se anade aqui: ninguna consulta lo usa todavia, asi que este
commit no cambia ningun numero de la aplicacion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:30:01 +02:00
b4508a13db Anade una escala tipografica para los encabezados
base.css nunca ha definido tamanos de encabezado, asi que los ponia el
navegador: 32px el h1, 24px el h2 y 18,7px el h3. En una vista densa de
datos como el dashboard eso compite con las propias cifras, y ademas dejaba
los tamanos a merced de que un encabezado cambiara de nivel.

Fija 1.625rem / 1.25rem / 1rem con font-weight 600. Los margenes van en rem
a proposito: los del navegador van en em, escalan con el tamano de fuente y
dejan huecos distintos entre secciones que deberian separarse igual.

Va en la zona de estilos base, antes de los componentes, para que las
reglas que ya ajustan encabezados concretos (.dashboard-context h1,
.settings-card h2, .home-section > h2, .info-panel-content h2) sigan
ganando por orden.

Afecta a toda la aplicacion. Lo que mas se nota es el titulo de pagina:
pierde 21px de aire por arriba, porque el margen superior del h1 pasa a 0 y
el padding de .content impedia que colapsara. Entre secciones de la home no
cambia nada, ahi manda el margin-bottom de 2.5rem de .home-section. Ningun
media query toca tamanos de fuente, asi que en movil la escala es la misma,
y el bloque no declara colores, asi que el tema oscuro no se entera.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:13:49 +02:00
3d47f8bd90 Usa un unico h1 por pagina en el resto de vistas
Cuatro paginas tenian su titulo en <h2> sin ningun <h1> encima: el
formulario de categoria, ajustes, el login y la ayuda de contrasena. Y el
listado de categorias si tenia <h1>, pero colgaba de el dos <h3>, saltandose
el nivel intermedio.

Sube los titulos de pagina a <h1> y los subtitulos al nivel que les toca:
las dos secciones del listado de categorias a <h2> y las tres tarjetas de
ajustes a <h2>. El selector .settings-card h3 pasa a h2 para seguirlas y
mantener su margin: 0 0 0.25rem, que es lo que evita que el titulo abra
hueco dentro de la tarjeta.

En login y ayuda de contrasena el <h1> ademas importa para el layout:
.auth-container tiene padding: 2rem, y el padding impide que colapse el
margen superior del primer hijo. Con la escala que viene despues, un <h2>
ahi sumaria su margen a ese padding y dejaria el titulo a 56px del borde;
el <h1> no lleva margen superior.

Verificado renderizando las 36 vistas: todas con un solo h1 y sin saltos
de nivel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:13:28 +02:00
beeda95d5d Corrige la jerarquia de encabezados del dashboard
El dashboard empezaba en <h2> y bajaba hasta <h4>, sin ningun <h1>. La
pagina no tenia titulo de documento y las secciones colgaban de un nivel
que no existia, asi que un lector de pantalla no podia recorrerla por
encabezados.

Sube cada nivel uno: el nombre de la cuenta (o "Todas las cuentas") pasa a
<h1>, las secciones a <h2> y el nombre de cada cuenta dentro de su tarjeta
de grafica a <h3>. El selector .dashboard-context h2 pasa a h1 para seguir
al elemento.

Solo cambian los niveles. Como base.css no definia tamanos de encabezado,
el aspecto lo daban los valores por defecto del navegador y esto si mueve
los tamanos; el commit de la escala tipografica los fija.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:13:03 +02:00
1cc13bae57 Borra JS muerto y la seccion comparativa duplicada del dashboard
El bloque <script> del formulario de periodo definia clearPeriodPreset y
tres consts, y no lo llamaba nadie: no hay ningun onchange ni listener que
lo use. Debajo habia ademas un segundo <script> entero comentado, de una
version anterior de los presets. Los ids hiddenPeriod, yearSelect y
monthSelect siguen en el formulario, que los usa para las etiquetas.

La seccion "Comparativa" del final mostraba la misma diferencia y el mismo
porcentaje que "Resumen comparativo" ya ensena arriba, y ambas salen bajo
el mismo {% if compare_enabled %}. Es un subconjunto estricto: la de arriba
anade los totales de los dos periodos. La de abajo ademas imprimia
kpi_difference_abs con un "+" condicional, asi que una bajada se veia como
"12,00 € (-13,3%)", sin signo en la cifra y con el porcentaje en negativo.
Se queda la de arriba, que imprime "-12,00 €".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:12:38 +02:00
f074005a7f Extrae helpers del dashboard y elimina el N+1 de saldos
La vista pasaba de 226 lineas y mezclaba tres cosas distintas: traducir los
parametros de la URL a un periodo, calcular los KPIs y montar las series de
las graficas por cuenta. Saca las dos que son autonomas a _resolve_period y
_build_account_charts y deja dashboard en 170.

De paso quita el N+1: la vista llamaba a current_balance() una vez por
cuenta para el KPI y otra vez por cuenta dentro del bucle de graficas, y
cada llamada son dos queries. Ahora los saldos salen de _account_balances,
que los calcula con dos agregados, y se reparten por un dict indexado por
id. Medido con CaptureQueriesContext: con 1/3/6 cuentas se pasa de 25/53/95
queries a 21/37/61.

accounts deja de ser un QuerySet y pasa a lista, porque ahora se recorre
mas de una vez, y la cuenta seleccionada se busca en memoria en vez de con
otra query.

Lo que sigue costando una query por cuenta es monthly_balance(), que no se
toca aqui.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:12:03 +02:00
b4c9f8507b Anade tests de regresion del dashboard
La vista dashboard concentra los presets de periodo, el filtro por cuenta,
la comparativa y las series de las graficas, y solo tenia cubierto el
filtro por ano. Antes de tocarla conviene fijar por escrito lo que hace
hoy, para que el refactor que viene no pueda cambiarlo sin que salte algo.

Cubre los tres presets (this_month, last_month, this_year), el filtro por
cuenta con su KPI de saldo, los valores por defecto de la comparativa
cuando no esta activada, la diferencia y la tendencia cuando si lo esta,
el chart_type segun se mire un mes o el ano entero, y el recorte a diez
categorias de la grafica de distribucion.

El test de la grafica de categorias comprueba tambien que by_category
sigue trayendo las doce: el recorte es solo de la grafica, no del listado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:58:31 +02:00
57d44dad8e Pasa chart_type al contexto del dashboard
La vista calculaba chart_type ("day" o "month") pero nunca lo metia en el
contexto, asi que la plantilla lo leia siempre vacio y el
{% if chart_type == 'day' %} del titulo de la grafica no se cumplia nunca.
El encabezado decia "Por Meses" tambien al mirar un mes suelto, cuando la
serie que se dibuja debajo es por dias.

Solo anade la clave que faltaba; el calculo ya estaba bien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:58:22 +02:00
20 changed files with 1415 additions and 157 deletions

View File

@ -27,6 +27,7 @@ class ExpenseForm(forms.ModelForm):
"category", "category",
"account", "account",
"tags", "tags",
"is_transfer",
] ]
widgets = { widgets = {
"date": forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"}), "date": forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"}),
@ -50,7 +51,7 @@ class AccountForm(forms.ModelForm):
class IncomeForm(forms.ModelForm): class IncomeForm(forms.ModelForm):
class Meta: class Meta:
model = Income model = Income
fields = ["account", "name", "amount", "date"] fields = ["account", "name", "amount", "date", "is_transfer"]
widgets = {"date": forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"})} widgets = {"date": forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"})}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):

View File

@ -0,0 +1,23 @@
# Generated by Django 5.2.10 on 2026-09-15 10:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('expenses', '0012_alter_account_options_alter_category_options_and_more'),
]
operations = [
migrations.AddField(
model_name='expense',
name='is_transfer',
field=models.BooleanField(default=False, help_text='Márcalo si este movimiento solo mueve dinero entre cuentas propias. No contará como gasto ni como ingreso en los análisis, pero sí afectará al saldo de la cuenta.', verbose_name='Es un traspaso entre cuentas'),
),
migrations.AddField(
model_name='income',
name='is_transfer',
field=models.BooleanField(default=False, help_text='Márcalo si este movimiento solo mueve dinero entre cuentas propias. No contará como gasto ni como ingreso en los análisis, pero sí afectará al saldo de la cuenta.', verbose_name='Es un traspaso entre cuentas'),
),
]

View File

@ -228,6 +228,16 @@ class Expense(models.Model):
related_name="expenses", related_name="expenses",
) )
is_transfer = models.BooleanField(
default=False,
verbose_name="Es un traspaso entre cuentas",
help_text=(
"Márcalo si este movimiento solo mueve dinero entre cuentas propias. "
"No contará como gasto ni como ingreso en los análisis, pero sí "
"afectará al saldo de la cuenta."
),
)
created_at = models.DateField(auto_now_add=True) created_at = models.DateField(auto_now_add=True)
class Meta: class Meta:
@ -246,6 +256,17 @@ class Income(models.Model):
name = models.CharField(max_length=150) name = models.CharField(max_length=150)
amount = models.DecimalField(max_digits=12, decimal_places=2) amount = models.DecimalField(max_digits=12, decimal_places=2)
date = models.DateField() date = models.DateField()
is_transfer = models.BooleanField(
default=False,
verbose_name="Es un traspaso entre cuentas",
help_text=(
"Márcalo si este movimiento solo mueve dinero entre cuentas propias. "
"No contará como gasto ni como ingreso en los análisis, pero sí "
"afectará al saldo de la cuenta."
),
)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
@ -370,6 +391,9 @@ class Goal(models.Model):
if self.kind == self.KIND_SAVING: if self.kind == self.KIND_SAVING:
# Provisional: saldo de la cuenta asociada. Cuando exista el # Provisional: saldo de la cuenta asociada. Cuando exista el
# módulo de inversiones, esta rama es lo único que hay que tocar. # módulo de inversiones, esta rama es lo único que hay que tocar.
# Ojo: aquí los traspasos SÍ cuentan. Mandar dinero a la cuenta de
# ahorro es precisamente cómo se progresa en un objetivo de ahorro,
# así que no filtres is_transfer en esta rama.
return self.account.current_balance() if self.account else Decimal("0") return self.account.current_balance() if self.account else Decimal("0")
if not self.category: if not self.category:
@ -380,9 +404,11 @@ class Goal(models.Model):
else: else:
category_ids = [self.category_id] category_ids = [self.category_id]
# Pago y presupuesto miden gasto real, así que los traspasos entre
# cuentas propias no cuentan. La rama de ahorro de arriba es la opuesta.
expenses = Expense.objects.filter( expenses = Expense.objects.filter(
owner=self.owner, category_id__in=category_ids owner=self.owner, category_id__in=category_ids
) ).exclude(is_transfer=True)
start = self._period_start() start = self._period_start()
if start: if start:

View File

@ -97,6 +97,31 @@ a.danger:visited,
color: var(--color-danger-accent); color: var(--color-danger-accent);
} }
/* Escala tipográfica. Los márgenes van en rem a propósito: los del navegador
van en em y escalan con el tamaño de fuente, dejando huecos irregulares
entre secciones. */
h1 {
font-size: 1.625rem;
font-weight: 600;
line-height: 1.2;
margin: 0 0 1rem;
}
h2 {
font-size: 1.25rem;
font-weight: 600;
line-height: 1.25;
margin: 1.5rem 0 0.75rem;
}
h3 {
font-size: 1rem;
font-weight: 600;
line-height: 1.3;
margin: 1rem 0 0.5rem;
}
/* ========================= */ /* ========================= */
/* Topbar / navigation */ /* Topbar / navigation */
/* ========================= */ /* ========================= */
@ -273,7 +298,7 @@ a.danger:visited,
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.dashboard-context h2 { .dashboard-context h1 {
margin: 0; margin: 0;
} }
@ -371,7 +396,8 @@ button[type="submit"]:hover,
gap: 0.5rem; gap: 0.5rem;
} }
.filters-main select { .filters-main select,
.filters-main input[type="date"] {
padding: 0.5rem 0.7rem; padding: 0.5rem 0.7rem;
font-family: inherit; font-family: inherit;
font-size: 0.95rem; font-size: 0.95rem;
@ -381,11 +407,18 @@ button[type="submit"]:hover,
border-radius: 6px; border-radius: 6px;
} }
.filters-main select:focus { .filters-main select:focus,
.filters-main input[type="date"]:focus {
outline: 2px solid var(--color-focus-ring); outline: 2px solid var(--color-focus-ring);
outline-offset: 1px; outline-offset: 1px;
} }
.range-notice {
margin-top: 0.5rem;
font-size: 0.9rem;
color: var(--color-text-muted);
}
.filters-advanced { .filters-advanced {
margin-top: 0.5rem; margin-top: 0.5rem;
} }
@ -707,6 +740,13 @@ tr:hover {
color: var(--color-danger-text); color: var(--color-danger-text);
} }
/* Etiqueta informativa, sin carga de error ni de éxito: marca un movimiento
como lo que es, no como un estado degradado. */
.badge-neutral {
background-color: var(--color-chip-bg);
color: var(--color-text-muted);
}
.row-inactive { .row-inactive {
opacity: 0.55; opacity: 0.55;
} }
@ -898,7 +938,7 @@ tr:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
} }
.settings-card h3 { .settings-card h2 {
margin: 0 0 0.25rem 0; margin: 0 0 0.25rem 0;
} }

View File

@ -5,7 +5,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<h2>Editar categoría</h2> <h1>Editar categoría</h1>
<form method="post" class="app-form"> <form method="post" class="app-form">
{% csrf_token %} {% csrf_token %}

View File

@ -8,7 +8,7 @@
<h1>Mis categorías</h1> <h1>Mis categorías</h1>
<h3>Nueva categoría</h3> <h2>Nueva categoría</h2>
<form method="post" class="app-form"> <form method="post" class="app-form">
{% csrf_token %} {% csrf_token %}
{% include "expenses/_form_fields.html" %} {% include "expenses/_form_fields.html" %}
@ -17,7 +17,7 @@
<hr> <hr>
<h3>Listado</h3> <h2>Listado</h2>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>

View File

@ -0,0 +1,12 @@
{% comment %}
Campos del rango de fechas libre. Se incluye dentro de un <form method="get">.
Espera date_from y date_to en el contexto.
{% endcomment %}
<label class="sr-only" for="dateFrom">Desde</label>
<input type="date" name="date_from" id="dateFrom"
value="{% if date_from %}{{ date_from|date:'Y-m-d' }}{% endif %}">
<label class="sr-only" for="dateTo">Hasta</label>
<input type="date" name="date_to" id="dateTo"
value="{% if date_to %}{{ date_to|date:'Y-m-d' }}{% endif %}">

View File

@ -7,16 +7,19 @@
{% block content %} {% block content %}
<section class="dashboard-context"> <section class="dashboard-context">
<h2> <h1>
{% if selected_account_obj %} {% if selected_account_obj %}
{{ selected_account_obj.name }} {{ selected_account_obj.name }}
{% else %} {% else %}
Todas las cuentas Todas las cuentas
{% endif %} {% endif %}
</h2> </h1>
<p class="muted"> <p class="muted">
{% if selected_month %} {% if range_active %}
Del {{ date_from|date:"d/m/Y"|default:"principio" }}
al {{ date_to|date:"d/m/Y"|default:"hoy" }}
{% elif selected_month %}
{{ selected_month }}/{{ selected_year}} {{ selected_month }}/{{ selected_year}}
{% else %} {% else %}
Año {{ selected_year }} Año {{ selected_year }}
@ -66,6 +69,8 @@
{% endfor %} {% endfor %}
</select> </select>
{% include "expenses/_date_range.html" %}
{% if not period %} {% if not period %}
<label class="sr-only" for="yearSelect">Año</label> <label class="sr-only" for="yearSelect">Año</label>
<select name="year" id="yearSelect"> <select name="year" id="yearSelect">
@ -94,35 +99,16 @@
<button type="submit" class="btn btn-primary">Aplicar Filtros</button> <button type="submit" class="btn btn-primary">Aplicar Filtros</button>
</form> </form>
{% if range_active %}
<p class="range-notice">
Mostrando del {{ date_from|date:"d/m/Y"|default:"principio" }} al
{{ date_to|date:"d/m/Y"|default:"hoy" }}. Los filtros de año, mes
{% if compare_suppressed %}y la comparativa {% endif %}no se aplican.
<a href="{% url 'dashboard' %}">Quitar el rango</a>
</p>
{% endif %}
</section> </section>
<script>
const yearSelect = document.getElementById('yearSelect');
const monthSelect = document.getElementById('monthSelect');
const hiddenPeriod = document.getElementById('hiddenPeriod');
function clearPeriodPreset() {
if (hiddenPeriod) {
hiddenPeriod.value = ""
}
document.querySelectorAll('.preset').forEach(btn => btn.classList.remove('active'));
}
</script>
<!-- <script>
document.querySelectorAll('.dashboard-presets a').forEach(link => {
link.addEventListener('click', e => {
e.preventDefault();
const period = link.dataset.period;
const isActive = link.classList.contains('active');
if (isActive) {
window.location.href = link.href;
} else {
window.location.href = `${link.href}?period=${period}`
}
});
});
</script> -->
<!-- ========================= --> <!-- ========================= -->
<!-- KPIs --> <!-- KPIs -->
@ -154,7 +140,7 @@
<span class="kpi-value">{{ daily_average|floatformat:2 }}€ / día</span> <span class="kpi-value">{{ daily_average|floatformat:2 }}€ / día</span>
</div> </div>
{% if selected_month == today.month and selected_year == today.year %} {% if selected_month == today.month and selected_year == today.year and not range_active %}
<div class="kpi-card" style="border-left: 4px solid var(--color-warning-accent);"> <div class="kpi-card" style="border-left: 4px solid var(--color-warning-accent);">
<span class="kpi-label">Proyección fin de mes</span> <span class="kpi-label">Proyección fin de mes</span>
<span class="kpi-value">{{ projected_end_of_month|floatformat:2 }}€</span> <span class="kpi-value">{{ projected_end_of_month|floatformat:2 }}€</span>
@ -165,7 +151,7 @@
{% if compare_enabled %} {% if compare_enabled %}
<section class="comparison"> <section class="comparison">
<h3>Resumen comparativo</h3> <h2>Resumen comparativo</h2>
<p> <p>
Gastos periodo actual: <strong>{{ kpi_total|floatformat:2 }} €</strong><br> Gastos periodo actual: <strong>{{ kpi_total|floatformat:2 }} €</strong><br>
Gastos periodo anterior: <strong>{{ kpi_previous_total|floatformat:2 }} €</strong><br> Gastos periodo anterior: <strong>{{ kpi_previous_total|floatformat:2 }} €</strong><br>
@ -178,7 +164,7 @@
</small> </small>
</p> </p>
<h3>Desglose de cambios por categoría</h3> <h2>Desglose de cambios por categoría</h2>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
@ -212,21 +198,21 @@
<div class="charts-container"> <div class="charts-container">
<section> <section>
<h3>Evolución de Gastos ({% if chart_type == 'day' %}Por día {% else %} Por Meses{% endif %})</h3> <h2>Evolución de Gastos ({% if chart_type == 'day' %}Por día {% else %} Por Meses{% endif %})</h2>
<div class="chart-box"> <div class="chart-box">
<canvas id="mainChart"></canvas> <canvas id="mainChart"></canvas>
</div> </div>
</section> </section>
<section> <section>
<h3>Distribución por Categorías</h3> <h2>Distribución por Categorías</h2>
<div class="chart-box"> <div class="chart-box">
<canvas id="categoryChart"></canvas> <canvas id="categoryChart"></canvas>
</div> </div>
</section> </section>
<section> <section>
<h3>Gastos Recientes</h3> <h2>Gastos Recientes</h2>
<div class="table-wrap"> <div class="table-wrap">
<table class="table"> <table class="table">
<thead> <thead>
@ -243,7 +229,12 @@
<tr> <tr>
<td>{{ exp.date|date:"d/m/Y" }}</td> <td>{{ exp.date|date:"d/m/Y" }}</td>
<td>{{ exp.account.name }}</td> <td>{{ exp.account.name }}</td>
<td>{{ exp.category.name }}</td> <td>
{{ exp.category.name }}
{% if exp.is_transfer %}
<span class="badge badge-neutral">Traspaso</span>
{% endif %}
</td>
<td>{{ exp.description|default:"-" }}</td> <td>{{ exp.description|default:"-" }}</td>
<td style="color: var(--color-danger-accent); font-weight: bold;">{{ exp.amount|floatformat:2 }}</td> <td style="color: var(--color-danger-accent); font-weight: bold;">{{ exp.amount|floatformat:2 }}</td>
</tr> </tr>
@ -290,11 +281,11 @@
}); });
</script> </script>
<h3>Evolución anual por cuenta ({{ selected_year }})</h3> <h2>Evolución anual por cuenta ({{ selected_year }})</h2>
<div class="dashboard-grid"> <div class="dashboard-grid">
{% for acc in accounts_charts %} {% for acc in accounts_charts %}
<div class="card card-chart"> <div class="card card-chart">
<h4>{{ acc.name }}</h4> <h3>{{ acc.name }}</h3>
<p><strong>Saldo actual:</strong> {{ acc.current_balance|floatformat:2 }}€</p> <p><strong>Saldo actual:</strong> {{ acc.current_balance|floatformat:2 }}€</p>
<div class="canvas-wrapper"> <div class="canvas-wrapper">
<canvas id="accountChart{{ acc.id }}"></canvas> <canvas id="accountChart{{ acc.id }}"></canvas>
@ -393,28 +384,11 @@
</script> </script>
{% if compare_enabled %}
<section class="comparison">
<h3>Comparativa</h3>
<p>
Diferencia:
<strong class="{% if kpi_trend == 'up' %}positive{% endif %}">
{% if kpi_trend == "up" %}+{% endif %}
{{ kpi_difference_abs|floatformat:2 }} €
</strong>
{% if kpi_percentage %}
({{ kpi_percentage|floatformat:1 }}%)
{% endif %}
</p>
</section>
{% endif %}
<!-- ========================= --> <!-- ========================= -->
<!-- Goals --> <!-- Goals -->
<!-- ========================= --> <!-- ========================= -->
<h3>Objetivos</h3> <h2>Objetivos</h2>
{% if goals %} {% if goals %}
<div class="goals-widget"> <div class="goals-widget">
{% for goal in goals %} {% for goal in goals %}

View File

@ -47,10 +47,20 @@
{% endfor %} {% endfor %}
</select> </select>
{% include "expenses/_date_range.html" %}
<button type="submit" class="btn btn-primary">Filtrar</button> <button type="submit" class="btn btn-primary">Filtrar</button>
<a href="{% url 'expense_list' %}" class="btn btn-secondary">Limpiar</a> <a href="{% url 'expense_list' %}" class="btn btn-secondary">Limpiar</a>
</div> </div>
{% if range_active %}
<p class="range-notice">
Mostrando el rango de fechas seleccionado; los filtros de año y mes no
se aplican.
<a href="{% url 'expense_list' %}">Quitar el rango</a>
</p>
{% endif %}
<br> <br>
<details class="filters-advanced" <details class="filters-advanced"
@ -96,7 +106,7 @@
<br> <br>
<section> <section>
<strong>Total:</strong> {{ kpi_total|floatformat:2 }}€ | <strong>Total (sin traspasos):</strong> {{ kpi_total|floatformat:2 }}€ |
<strong>Gastos:</strong> {{ kpi_count }} | <strong>Gastos:</strong> {{ kpi_count }} |
<strong>Categorías:</strong> {{ kpi_categories }} <strong>Categorías:</strong> {{ kpi_categories }}
</section> </section>
@ -117,7 +127,12 @@
{% for expense in page_obj %} {% for expense in page_obj %}
<tr> <tr>
<td>{{ expense.date }}</td> <td>{{ expense.date }}</td>
<td>{{ expense.category.name }}</td> <td>
{{ expense.category.name }}
{% if expense.is_transfer %}
<span class="badge badge-neutral">Traspaso</span>
{% endif %}
</td>
<td>{{ expense.amount }}</td> <td>{{ expense.amount }}</td>
<td>{{ expense.account.name }}</td> <td>{{ expense.account.name }}</td>
<td> <td>
@ -142,8 +157,13 @@
{% empty %} {% empty %}
<tr> <tr>
<td colspan="6" class="empty-state"> <td colspan="6" class="empty-state">
<p>No hay gastos</p> {% if filters_active %}
<a href="{% url 'expense_create' %}">Añade el primero</a> <p>No hay gastos que coincidan con los filtros</p>
<a href="{% url 'expense_list' %}">Quitar los filtros</a>
{% else %}
<p>No hay gastos</p>
<a href="{% url 'expense_create' %}">Añade el primero</a>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@ -96,7 +96,12 @@
{% for mov in movements %} {% for mov in movements %}
<li class="movement"> <li class="movement">
<span class="movement-date">{{ mov.date }}</span> <span class="movement-date">{{ mov.date }}</span>
<span class="movement-label">{{ mov.label }}</span> <span class="movement-label">
{{ mov.label }}
{% if mov.is_transfer %}
<span class="badge badge-neutral">Traspaso</span>
{% endif %}
</span>
<span class="movement-account muted">{{ mov.account }}</span> <span class="movement-account muted">{{ mov.account }}</span>
<span class="movement-amount {% if mov.kind == 'income' %}amount-positive{% else %}amount-negative{% endif %}"> <span class="movement-amount {% if mov.kind == 'income' %}amount-positive{% else %}amount-negative{% endif %}">
{% if mov.kind == 'income' %}+{% else %}{% endif %}{{ mov.amount|floatformat:2 }}€ {% if mov.kind == 'income' %}+{% else %}{% endif %}{{ mov.amount|floatformat:2 }}€

View File

@ -3,8 +3,72 @@
{% block content %} {% block content %}
<h1>Mis ingresos</h1> <h1>Mis ingresos</h1>
<form method="get" class="filters">
<div class="filters-main">
<label class="sr-only" for="filterYear">Año</label>
<select name="year" id="filterYear">
<option value="">Año</option>
{% for y in year_list %}
<option value="{{ y }}"
{% if selected_year == y %}selected{% endif %}
>
{{ y }}
</option>
{% endfor %}
</select>
<label class="sr-only" for="filterMonth">Mes</label>
<select name="month" id="filterMonth">
<option value="">Mes</option>
{% for m in months %}
<option value="{{ m }}"
{% if selected_month == m %}selected{% endif %}
>
{{ m }}
</option>
{% endfor %}
</select>
<label class="sr-only" for="filterAccount">Cuenta</label>
<select name="account" id="filterAccount">
<option value="">Cuenta</option>
{% for acc in accounts %}
<option value="{{ acc.id }}"
{% if selected_account == acc.id %}selected{% endif %}
>
{{ acc.name }}
</option>
{% endfor %}
</select>
{% include "expenses/_date_range.html" %}
<button type="submit" class="btn btn-primary">Filtrar</button>
<a href="{% url 'income_list' %}" class="btn btn-secondary">Limpiar</a>
</div>
{% if range_active %}
<p class="range-notice">
Mostrando el rango de fechas seleccionado; los filtros de año y mes no
se aplican.
<a href="{% url 'income_list' %}">Quitar el rango</a>
</p>
{% endif %}
</form>
<br>
<a class="btn" href="{% url 'income_create' %}"> Nuevo ingreso</a> <a class="btn" href="{% url 'income_create' %}"> Nuevo ingreso</a>
<br>
<br>
<section>
<strong>Total (sin traspasos):</strong> {{ kpi_total|floatformat:2 }}€ |
<strong>Ingresos:</strong> {{ kpi_count }}
</section>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
@ -17,10 +81,15 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for income in incomes %} {% for income in page_obj %}
<tr> <tr>
<td>{{ income.name }}</td> <td>
<td>{{ income.account }}</td> {{ income.name }}
{% if income.is_transfer %}
<span class="badge badge-neutral">Traspaso</span>
{% endif %}
</td>
<td>{{ income.account.name }}</td>
<td>{{ income.amount|floatformat:2 }}</td> <td>{{ income.amount|floatformat:2 }}</td>
<td>{{ income.date }}</td> <td>{{ income.date }}</td>
<td class="table-actions"> <td class="table-actions">
@ -31,8 +100,13 @@
{% empty %} {% empty %}
<tr> <tr>
<td colspan="5" class="empty-state"> <td colspan="5" class="empty-state">
<p>No hay ingresos</p> {% if filters_active %}
<a href="{% url 'income_create' %}">Añade el primero</a> <p>No hay ingresos que coincidan con los filtros</p>
<a href="{% url 'income_list' %}">Quitar los filtros</a>
{% else %}
<p>No hay ingresos</p>
<a href="{% url 'income_create' %}">Añade el primero</a>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -40,4 +114,20 @@
</table> </table>
</div> </div>
{% endblock %} <nav class="pagination" aria-label="Paginación de ingresos">
<span class="step-links">
{% if page_obj.has_previous %}
<a href="?page=1{% if query_params %}&{{ query_params }}{% endif %}">&laquo; Primero</a>
<a href="?page={{ page_obj.previous_page_number }}{% if query_params %}&{{ query_params }}{% endif %}">Anterior</a>
{% endif %}
<span>Página {{ page_obj.number }} de {{ page_obj.paginator.num_pages }}</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}{% if query_params %}&{{ query_params }}{% endif %}">Siguiente</a>
<a href="?page={{ page_obj.paginator.num_pages }}{% if query_params %}&{{ query_params }}{% endif %}">Último &raquo;</a>
{% endif %}
</span>
</nav>
{% endblock %}

View File

@ -10,8 +10,6 @@
<a class="btn" href="{% url 'fuel_create' %}"> Nuevo repostaje</a> <a class="btn" href="{% url 'fuel_create' %}"> Nuevo repostaje</a>
</div>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>

View File

@ -2,7 +2,7 @@
{% block content %} {% block content %}
<h2>Iniciar sesión</h2> <h1>Iniciar sesión</h1>
<form method="post" class="app-form"> <form method="post" class="app-form">
{% csrf_token %} {% csrf_token %}

View File

@ -2,7 +2,7 @@
{% block content %} {% block content %}
<h2>Recuperar contraseña</h2> <h1>Recuperar contraseña</h1>
<p> <p>
Esta aplicación actualmente no envía correos de recuperación automáticos. Esta aplicación actualmente no envía correos de recuperación automáticos.

View File

@ -5,23 +5,23 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<h2>Configuración</h2> <h1>Configuración</h1>
<p>Gestiona las categorías, etiquetas y objetivos de tu cuenta</p> <p>Gestiona las categorías, etiquetas y objetivos de tu cuenta</p>
<div class="settings-grid"> <div class="settings-grid">
<a class="settings-card" href="{% url 'category_list' %}"> <a class="settings-card" href="{% url 'category_list' %}">
<h3>Categorías</h3> <h2>Categorías</h2>
<p>Organiza tus gastos por tipo.</p> <p>Organiza tus gastos por tipo.</p>
</a> </a>
<a class="settings-card" href="{% url 'tag_list' %}"> <a class="settings-card" href="{% url 'tag_list' %}">
<h3>Etiquetas</h3> <h2>Etiquetas</h2>
<p>Añade etiquetas libres a tus gastos.</p> <p>Añade etiquetas libres a tus gastos.</p>
</a> </a>
<a class="settings-card" href="{% url 'goal_list' %}"> <a class="settings-card" href="{% url 'goal_list' %}">
<h3>Objetivos</h3> <h2>Objetivos</h2>
<p>Define y controla tus metas de gasto.</p> <p>Define y controla tus metas de gasto.</p>
</a> </a>
</div> </div>

View File

@ -2,7 +2,7 @@ import pytest
from datetime import date 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 Expense, Category from expenses.models import Account, Expense, Category, Income
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@ -80,3 +80,146 @@ def test_dashboard_filters_by_year(auth_client, user, account, category):
assert len(chart_data) == 12 assert len(chart_data) == 12
assert chart_data[4] == 20.0 # May = month 5 -> index 4 assert chart_data[4] == 20.0 # May = month 5 -> index 4
assert sum(chart_data) == 20.0 # only the 2024 expense contributes assert sum(chart_data) == 20.0 # only the 2024 expense contributes
def test_dashboard_preset_this_month(auth_client):
today = date.today()
response = auth_client.get(reverse('dashboard'), {'period': 'this_month'})
assert response.context['selected_year'] == today.year
assert response.context['selected_month'] == today.month
def test_dashboard_preset_last_month(auth_client):
today = date.today()
expected_month = today.month - 1 or 12
expected_year = today.year if today.month > 1 else today.year - 1
response = auth_client.get(reverse('dashboard'), {'period': 'last_month'})
assert response.context['selected_year'] == expected_year
assert response.context['selected_month'] == expected_month
def test_dashboard_preset_this_year_has_no_month(auth_client):
today = date.today()
response = auth_client.get(reverse('dashboard'), {'period': 'this_year'})
assert response.context['selected_year'] == today.year
assert response.context['selected_month'] is None
def test_dashboard_filters_by_account(auth_client, user, account, category):
other = Account.objects.create(
owner=user, name="Otra", initial_balance=Decimal("100"), active=True
)
Expense.objects.create(
owner=user, account=account, category=category,
amount=Decimal("10"), date=date(2024, 3, 1),
)
Expense.objects.create(
owner=user, account=other, category=category,
amount=Decimal("25"), date=date(2024, 3, 1),
)
response = auth_client.get(
reverse('dashboard'),
{'year': 2024, 'account': other.id},
)
assert response.context['kpi_total'] == Decimal('25')
assert response.context['kpi_count'] == 1
assert response.context['kpi_balance'] == Decimal('75') # 100 - 25
def test_dashboard_without_compare_keeps_comparison_defaults(auth_client):
response = auth_client.get(reverse('dashboard'), {'year': 2024})
assert response.context['compare_enabled'] is False
assert response.context['kpi_previous_total'] == 0
assert response.context['kpi_difference'] == 0
assert response.context['kpi_percentage'] == 0
assert response.context['kpi_trend'] == 'equal'
assert response.context['category_comparison'] == []
def test_dashboard_compare_fills_difference_and_trend(auth_client, user, account, category):
Expense.objects.create(
owner=user, account=account, category=category,
amount=Decimal("20"), date=date(2024, 2, 15),
)
Expense.objects.create(
owner=user, account=account, category=category,
amount=Decimal("30"), date=date(2024, 3, 10),
)
response = auth_client.get(
reverse('dashboard'),
{'year': 2024, 'month': 3, 'compare': '1'},
)
assert response.context['compare_enabled'] is True
assert response.context['kpi_previous_total'] == Decimal('20')
assert response.context['kpi_difference'] == Decimal('10')
assert response.context['kpi_trend'] == 'up'
assert float(response.context['kpi_percentage']) == 50.0
rows = {row['category']: row for row in response.context['category_comparison']}
assert rows[category.name]['current'] == 30.0
assert rows[category.name]['previous'] == 20.0
assert rows[category.name]['difference'] == 10.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})
assert response.context['chart_type'] == 'day'
def test_dashboard_chart_type_is_month_for_the_whole_year(auth_client):
response = auth_client.get(reverse('dashboard'), {'year': 2024})
assert response.context['chart_type'] == 'month'
def test_dashboard_category_chart_keeps_only_top_ten(auth_client, user, account):
for i in range(12):
cat = Category.objects.create(name=f"Cat {i:02d}", owner=user)
Expense.objects.create(
owner=user, account=account, category=cat,
amount=Decimal(str(i + 1)), date=date(2024, 6, 1),
)
response = auth_client.get(reverse('dashboard'), {'year': 2024})
assert len(response.context['by_category_chart']) == 10
assert len(list(response.context['by_category'])) == 12
def test_dashboard_kpi_balance_matches_sum_of_current_balances(auth_client, user, account, category):
savings = Account.objects.create(
owner=user, name="Ahorro", initial_balance=Decimal("500"), active=True
)
Account.objects.create(
owner=user, name="Cerrada", initial_balance=Decimal("999"), active=False
)
Expense.objects.create(
owner=user, account=account, category=category,
amount=Decimal("40"), date=date(2024, 4, 1),
)
Income.objects.create(
owner=user, account=savings, name="Nomina",
amount=Decimal("60"), date=date(2024, 4, 2),
)
response = auth_client.get(reverse('dashboard'), {'year': 2024})
expected = sum(
acc.current_balance()
for acc in Account.objects.filter(owner=user, active=True)
)
assert expected == Decimal('520') # (0 - 40) + (500 + 60), sin la inactiva
assert response.context['kpi_balance'] == expected

View File

@ -0,0 +1,271 @@
"""Rango de fechas libre (date_from / date_to) en expense_list y dashboard.
La regla que estructura todo: si hay rango activo, manda el rango y los
selectores de año y mes no se aplican. Un solo criterio de periodo a la vez.
"""
import pytest
from datetime import date, timedelta
from decimal import Decimal
from django.urls import reverse
from expenses.models import Expense
pytestmark = pytest.mark.django_db
def make_expense(user, account, category, amount, on):
return Expense.objects.create(
owner=user,
account=account,
category=category,
amount=Decimal(amount),
date=on,
)
# --------------------------------------------------------------------------
# expense_list
# --------------------------------------------------------------------------
def test_expense_list_filters_by_range(auth_client, user, account, category):
inside = make_expense(user, account, category, "10", date(2024, 3, 15))
before = make_expense(user, account, category, "20", date(2024, 2, 20))
after = make_expense(user, account, category, "30", date(2024, 4, 5))
response = auth_client.get(
reverse("expense_list"),
{"date_from": "2024-03-01", "date_to": "2024-03-31"},
)
rows = list(response.context["page_obj"])
assert rows == [inside]
assert before not in rows and after not in rows
assert response.context["range_active"] is True
assert response.context["kpi_total"] == Decimal("10")
def test_expense_list_open_ended_range_reaches_the_end(auth_client, user, account, category):
make_expense(user, account, category, "10", date(2024, 2, 20))
march = make_expense(user, account, category, "20", date(2024, 3, 15))
december = make_expense(user, account, category, "30", date(2024, 12, 31))
response = auth_client.get(reverse("expense_list"), {"date_from": "2024-03-01"})
rows = list(response.context["page_obj"])
assert set(rows) == {march, december}
def test_expense_list_open_ended_range_reaches_the_start(auth_client, user, account, category):
old = make_expense(user, account, category, "10", date(2020, 1, 1))
february = make_expense(user, account, category, "20", date(2024, 2, 20))
make_expense(user, account, category, "30", date(2024, 4, 5))
response = auth_client.get(reverse("expense_list"), {"date_to": "2024-03-01"})
rows = list(response.context["page_obj"])
assert set(rows) == {old, february}
def test_expense_list_inverted_range_is_ignored_entirely(auth_client, user, account, category):
"""Un rango invertido no debe devolver una lista vacía sin explicación."""
expenses = [
make_expense(user, account, category, "10", date(2024, 2, 20)),
make_expense(user, account, category, "20", date(2024, 3, 15)),
]
response = auth_client.get(
reverse("expense_list"),
{"date_from": "2024-12-01", "date_to": "2024-01-01"},
)
assert response.context["range_active"] is False
assert set(response.context["page_obj"]) == set(expenses)
def test_expense_list_malformed_date_is_ignored(auth_client, user, account, category):
expense = make_expense(user, account, category, "10", date(2024, 3, 15))
response = auth_client.get(reverse("expense_list"), {"date_from": "loquesea"})
assert response.status_code == 200
assert response.context["range_active"] is False
assert list(response.context["page_obj"]) == [expense]
def test_expense_list_range_overrides_year_and_month(auth_client, user, account, category):
"""El año viene en la URL pero no debe aplicarse: manda el rango."""
in_2023 = make_expense(user, account, category, "10", date(2023, 5, 10))
make_expense(user, account, category, "20", date(2024, 5, 10))
response = auth_client.get(
reverse("expense_list"),
{"year": 2024, "month": 5, "date_from": "2023-01-01", "date_to": "2023-12-31"},
)
assert list(response.context["page_obj"]) == [in_2023]
# --------------------------------------------------------------------------
# dashboard
# --------------------------------------------------------------------------
def test_dashboard_filters_by_range(auth_client, user, account, category):
make_expense(user, account, category, "10", date(2024, 3, 15))
make_expense(user, account, category, "20", date(2024, 4, 5))
response = auth_client.get(
reverse("dashboard"),
{"date_from": "2024-03-01", "date_to": "2024-03-31"},
)
assert response.context["range_active"] is True
assert response.context["kpi_total"] == Decimal("10")
assert response.context["kpi_count"] == 1
def test_dashboard_range_overrides_year(auth_client, user, account, category):
make_expense(user, account, category, "10", date(2023, 5, 10))
make_expense(user, account, category, "20", date(2024, 5, 10))
response = auth_client.get(
reverse("dashboard"),
{"year": 2024, "date_from": "2023-01-01", "date_to": "2023-12-31"},
)
assert response.context["kpi_total"] == Decimal("10")
def test_dashboard_inverted_range_falls_back_to_the_normal_period(
auth_client, user, account, category
):
make_expense(user, account, category, "20", date(2024, 5, 10))
response = auth_client.get(
reverse("dashboard"),
{"year": 2024, "date_from": "2024-12-01", "date_to": "2024-01-01"},
)
assert response.context["range_active"] is False
assert response.context["kpi_total"] == Decimal("20")
def test_dashboard_short_range_charts_by_day(auth_client, user, account, category):
make_expense(user, account, category, "10", 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"
labels = response.context["chart_labels"]
assert len(labels) == 30
assert labels[0] == "01/03"
assert labels[4] == "05/03"
# El eje es denso: el día con gasto lleva su importe, el resto va a cero.
assert response.context["chart_data"][4] == 10.0
assert response.context["chart_data"][0] == 0
def test_dashboard_long_range_charts_by_month(auth_client, user, account, category):
make_expense(user, account, category, "10", 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"
labels = response.context["chart_labels"]
assert labels == ["01/2024", "02/2024", "03/2024", "04/2024"]
assert response.context["chart_data"][1] == 10.0
def test_dashboard_range_threshold_is_62_days(auth_client, user, account, category):
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()},
)
assert at_limit.context["chart_type"] == "day" # 62 días justos
assert over_limit.context["chart_type"] == "month" # 63 días
def test_dashboard_range_disables_comparison(auth_client, user, account, category):
make_expense(user, account, category, "10", date(2024, 3, 15))
response = auth_client.get(
reverse("dashboard"),
{"compare": "1", "date_from": "2024-03-01", "date_to": "2024-03-31"},
)
assert response.context["compare_enabled"] is False
assert response.context["compare_suppressed"] is True
assert response.context["kpi_previous_total"] == 0
assert response.context["category_comparison"] == []
def test_dashboard_range_skips_the_end_of_month_projection(
auth_client, user, account, category
):
today = date.today()
make_expense(user, account, category, "10", today)
response = auth_client.get(
reverse("dashboard"),
{"date_from": today.replace(day=1).isoformat()},
)
assert response.context["projected_end_of_month"] == 0
def test_dashboard_range_daily_average_uses_the_range_length(
auth_client, user, account, category
):
make_expense(user, account, category, "100", date(2024, 3, 5))
response = auth_client.get(
reverse("dashboard"),
{"date_from": "2024-03-01", "date_to": "2024-03-10"}, # 10 días
)
assert response.context["daily_average"] == Decimal("10")
def test_dashboard_empty_open_range_does_not_break(auth_client, user):
"""Sin date_from y sin gastos no hay longitud que medir: eje mensual vacío."""
response = auth_client.get(reverse("dashboard"), {"date_to": "2024-03-31"})
assert response.status_code == 200
assert response.context["chart_type"] == "month"
assert response.context["chart_labels"] == []
# --------------------------------------------------------------------------
# Sin rango no cambia nada
# --------------------------------------------------------------------------
def test_dashboard_without_range_keeps_the_old_day_axis(auth_client, user, account, category):
make_expense(user, account, category, "10", date(2024, 3, 5))
response = auth_client.get(reverse("dashboard"), {"year": 2024, "month": 3})
assert response.context["range_active"] is False
assert response.context["chart_type"] == "day"
# Números de día, no fechas.
assert response.context["chart_labels"] == list(range(1, 32))
def test_dashboard_without_range_keeps_the_old_month_axis(auth_client, user, account, category):
make_expense(user, account, category, "10", date(2024, 3, 5))
response = auth_client.get(reverse("dashboard"), {"year": 2024})
assert response.context["chart_type"] == "month"
assert response.context["chart_labels"][0] == "Ene"
assert len(response.context["chart_labels"]) == 12

View File

@ -1,9 +1,25 @@
import pytest import pytest
from datetime import date from datetime import date
from decimal import Decimal
from django.test.utils import CaptureQueriesContext
from django.db import connection
from django.urls import reverse
from expenses.models import Income, Account from expenses.models import Income, Account
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
def make_income(user, account, amount, on, name="Ingreso", is_transfer=False):
return Income.objects.create(
owner=user,
account=account,
name=name,
amount=Decimal(amount),
date=on,
is_transfer=is_transfer,
)
def test_income_increases_account_balance(user): def test_income_increases_account_balance(user):
general_account = Account.objects.create(name='General', owner=user, initial_balance=1000, active=True) general_account = Account.objects.create(name='General', owner=user, initial_balance=1000, active=True)
@ -16,3 +32,140 @@ def test_income_increases_account_balance(user):
) )
assert general_account.current_balance() == 1500 assert general_account.current_balance() == 1500
# --------------------------------------------------------------------------
# Listado: filtros, paginación y totales
# --------------------------------------------------------------------------
def test_income_list_filters_by_year(auth_client, user, account):
old = make_income(user, account, "10", date(2023, 5, 10))
new = make_income(user, account, "20", date(2024, 5, 10))
response = auth_client.get(reverse("income_list"), {"year": 2024})
assert list(response.context["page_obj"]) == [new]
assert old not in list(response.context["page_obj"])
def test_income_list_filters_by_month(auth_client, user, account):
march = make_income(user, account, "10", date(2024, 3, 10))
make_income(user, account, "20", date(2024, 5, 10))
response = auth_client.get(reverse("income_list"), {"year": 2024, "month": 3})
assert list(response.context["page_obj"]) == [march]
def test_income_list_filters_by_account(auth_client, user, account):
other = Account.objects.create(
owner=user, name="Otra", initial_balance=Decimal("0"), active=True
)
make_income(user, account, "10", date(2024, 3, 10))
from_other = make_income(user, other, "20", date(2024, 3, 11))
response = auth_client.get(reverse("income_list"), {"account": other.id})
assert list(response.context["page_obj"]) == [from_other]
def test_income_list_filters_by_range(auth_client, user, account):
inside = make_income(user, account, "10", date(2024, 3, 15))
make_income(user, account, "20", date(2024, 4, 5))
response = auth_client.get(
reverse("income_list"),
{"date_from": "2024-03-01", "date_to": "2024-03-31"},
)
assert response.context["range_active"] is True
assert list(response.context["page_obj"]) == [inside]
def test_income_list_range_overrides_year(auth_client, user, account):
in_2023 = make_income(user, account, "10", date(2023, 5, 10))
make_income(user, account, "20", date(2024, 5, 10))
response = auth_client.get(
reverse("income_list"),
{"year": 2024, "date_from": "2023-01-01", "date_to": "2023-12-31"},
)
assert list(response.context["page_obj"]) == [in_2023]
def test_income_list_paginates_by_ten(auth_client, user, account):
for day in range(1, 15):
make_income(user, account, "10", date(2024, 3, day))
response = auth_client.get(reverse("income_list"))
assert len(response.context["page_obj"]) == 10
assert response.context["page_obj"].paginator.num_pages == 2
second = auth_client.get(reverse("income_list"), {"page": 2})
assert len(second.context["page_obj"]) == 4
def test_income_list_pagination_links_keep_the_filters(auth_client, user, account):
for day in range(1, 15):
make_income(user, account, "10", date(2024, 3, day))
make_income(user, account, "99", date(2023, 1, 1))
response = auth_client.get(reverse("income_list"), {"year": 2024})
# query_params alimenta los enlaces de paginación: sin él, pasar de página
# perdería el filtro y aparecería el ingreso de 2023.
assert "year=2024" in response.context["query_params"]
assert "page" not in response.context["query_params"]
assert 'href="?page=2&year=2024"' in response.content.decode()
def test_income_list_total_excludes_transfers_but_lists_them(auth_client, user, account):
make_income(user, account, "100", date(2024, 3, 10))
transfer = make_income(
user, account, "400", date(2024, 3, 11), is_transfer=True
)
response = auth_client.get(reverse("income_list"))
assert response.context["kpi_total"] == Decimal("100")
assert response.context["kpi_count"] == 1
assert transfer in list(response.context["page_obj"])
def test_income_list_empty_state_when_filters_match_nothing(auth_client, user, account):
make_income(user, account, "10", date(2024, 3, 10))
response = auth_client.get(reverse("income_list"), {"year": 1999})
assert response.status_code == 200
assert list(response.context["page_obj"]) == []
assert response.context["filters_active"] is True
body = response.content.decode()
assert "No hay ingresos que coincidan con los filtros" in body
assert "Añade el primero" not in body
def test_income_list_empty_state_without_filters(auth_client):
response = auth_client.get(reverse("income_list"))
assert response.context["filters_active"] is False
assert "Añade el primero" in response.content.decode()
def test_income_list_query_count_does_not_grow_with_rows(auth_client, user, account):
"""select_related("account"): una consulta por página, no una por fila."""
for day in range(1, 4):
make_income(user, account, "10", date(2024, 3, day))
with CaptureQueriesContext(connection) as few:
auth_client.get(reverse("income_list"))
for day in range(4, 11):
make_income(user, account, "10", date(2024, 3, day))
with CaptureQueriesContext(connection) as many:
auth_client.get(reverse("income_list"))
assert len(many) == len(few)

View File

@ -0,0 +1,242 @@
"""Traspasos entre cuentas (is_transfer).
Un traspaso no es gasto ni ingreso real, pero mueve dinero entre cuentas.
De ahí los dos bloques de este fichero, que comprueban lo contrario el uno del
otro: los análisis excluyen los traspasos, los saldos los incluyen.
"""
import pytest
from datetime import date
from decimal import Decimal
from django.urls import reverse
from expenses.models import Account, Category, Expense, Goal, Income
pytestmark = pytest.mark.django_db
@pytest.fixture
def savings(user):
return Account.objects.create(
owner=user, name="Ahorro", initial_balance=Decimal("0"), active=True
)
def make_expense(user, account, category, amount, on, is_transfer=False):
return Expense.objects.create(
owner=user,
account=account,
category=category,
amount=Decimal(amount),
date=on,
is_transfer=is_transfer,
)
def make_income(user, account, amount, on, name="Ingreso", is_transfer=False):
return Income.objects.create(
owner=user,
account=account,
name=name,
amount=Decimal(amount),
date=on,
is_transfer=is_transfer,
)
# --------------------------------------------------------------------------
# Análisis: los traspasos NO cuentan
# --------------------------------------------------------------------------
def test_dashboard_kpi_total_ignores_transfer(auth_client, user, account, category):
make_expense(user, account, category, "100", date(2024, 5, 10))
make_expense(user, account, category, "400", date(2024, 5, 11), is_transfer=True)
response = auth_client.get(reverse("dashboard"), {"year": 2024})
assert response.context["kpi_total"] == Decimal("100")
assert response.context["kpi_count"] == 1
def test_dashboard_by_category_ignores_transfer(auth_client, user, account, category):
other = Category.objects.create(owner=user, name="Traspasos")
make_expense(user, account, category, "100", date(2024, 5, 10))
make_expense(user, account, other, "400", date(2024, 5, 11), is_transfer=True)
response = auth_client.get(reverse("dashboard"), {"year": 2024})
rows = {row["category__name"]: row["total"] for row in response.context["by_category"]}
assert rows == {category.name: Decimal("100")}
def test_dashboard_comparison_ignores_transfer(auth_client, user, account, category):
make_expense(user, account, category, "30", date(2024, 3, 10))
make_expense(user, account, category, "20", date(2024, 2, 15))
make_expense(user, account, category, "500", date(2024, 2, 16), is_transfer=True)
response = auth_client.get(
reverse("dashboard"), {"year": 2024, "month": 3, "compare": "1"}
)
assert response.context["kpi_previous_total"] == Decimal("20")
assert response.context["kpi_difference"] == Decimal("10")
def test_home_kpi_total_ignores_transfer(auth_client, user, account, category):
today = date.today()
make_expense(user, account, category, "100", today)
make_expense(user, account, category, "400", today, is_transfer=True)
response = auth_client.get(reverse("home"))
assert response.context["kpi_total"] == Decimal("100")
assert response.context["kpi_count"] == 1
def test_home_previous_month_comparison_ignores_transfer(
auth_client, user, account, category
):
today = date.today()
# Día 1 para que reste un mes sin caer en un día inexistente.
first = date(today.year, today.month, 1)
prev = date(first.year - 1, 12, 1) if first.month == 1 else date(
first.year, first.month - 1, 1
)
make_expense(user, account, category, "10", prev)
make_expense(user, account, category, "900", prev, is_transfer=True)
response = auth_client.get(reverse("home"))
assert response.context["prev_total"] == Decimal("10")
def test_expense_list_total_ignores_transfer_but_row_is_listed(
auth_client, user, account, category
):
make_expense(user, account, category, "100", date(2024, 5, 10))
transfer = make_expense(
user, account, category, "400", date(2024, 5, 11), is_transfer=True
)
response = auth_client.get(reverse("expense_list"), {"year": 2024})
assert response.context["kpi_total"] == Decimal("100")
assert response.context["kpi_count"] == 1
# El traspaso sigue en la tabla aunque no sume en el total.
assert transfer in list(response.context["page_obj"])
def test_budget_goal_progress_ignores_transfer(user, account, category):
today = date.today()
make_expense(user, account, category, "50", today)
make_expense(user, account, category, "300", today, is_transfer=True)
goal = Goal.objects.create(
owner=user,
name="Presupuesto mensual",
target_amount=Decimal("200"),
kind=Goal.KIND_BUDGET,
category=category,
period=Goal.PERIOD_MONTH,
)
assert goal.progress == Decimal("50")
assert not goal.is_exceeded()
# --------------------------------------------------------------------------
# Saldos: los traspasos SÍ cuentan
# --------------------------------------------------------------------------
def test_transfer_expense_still_lowers_source_balance(user, account, category):
account.initial_balance = Decimal("1000")
account.save()
make_expense(user, account, category, "400", date(2024, 5, 11), is_transfer=True)
assert account.current_balance() == Decimal("600")
def test_transfer_income_still_raises_target_balance(user, savings):
make_income(user, savings, "400", date(2024, 5, 11), is_transfer=True)
assert savings.current_balance() == Decimal("400")
def test_transfer_keeps_total_balance_unchanged(auth_client, user, account, category, savings):
account.initial_balance = Decimal("1000")
account.save()
make_expense(user, account, category, "400", date(2024, 5, 11), is_transfer=True)
make_income(user, savings, "400", date(2024, 5, 11), is_transfer=True)
response = auth_client.get(reverse("home"))
# El dinero cambia de sitio, el patrimonio no se mueve.
assert response.context["total_balance"] == Decimal("1000")
def test_saving_goal_progress_counts_the_transfer(user, savings):
"""El punto más delicado: un traspaso a la cuenta de ahorro ES el progreso."""
make_income(user, savings, "400", date(2024, 5, 11), is_transfer=True)
goal = Goal.objects.create(
owner=user,
name="Fondo de emergencia",
target_amount=Decimal("1000"),
kind=Goal.KIND_SAVING,
account=savings,
)
assert goal.progress == Decimal("400")
def test_account_list_balance_counts_the_transfer(auth_client, user, account, category):
account.initial_balance = Decimal("1000")
account.save()
make_expense(user, account, category, "400", date(2024, 5, 11), is_transfer=True)
response = auth_client.get(reverse("account_list"))
rows = {row["account"].id: row["balance"] for row in response.context["account_rows"]}
assert rows[account.id] == Decimal("600")
# --------------------------------------------------------------------------
# Formularios
# --------------------------------------------------------------------------
def test_expense_form_can_mark_a_transfer(auth_client, user, account, category):
response = auth_client.post(
reverse("expense_create"),
{
"date": "2024-05-11",
"amount": "400",
"description": "",
"category": category.id,
"account": account.id,
"is_transfer": "on",
},
)
assert response.status_code == 302
assert Expense.objects.get(owner=user).is_transfer is True
def test_income_form_can_mark_a_transfer(auth_client, user, account):
response = auth_client.post(
reverse("income_create"),
{
"account": account.id,
"name": "Traspaso desde nómina",
"amount": "400",
"date": "2024-05-11",
"is_transfer": "on",
},
)
assert response.status_code == 302
assert Income.objects.get(owner=user).is_transfer is True
def test_expense_defaults_to_not_a_transfer(user, account, category):
expense = make_expense(user, account, category, "10", date(2024, 5, 11))
assert expense.is_transfer is False

View File

@ -1,6 +1,6 @@
import logging import logging
import calendar import calendar
from datetime import date, datetime from datetime import date, datetime, timedelta
from decimal import Decimal from decimal import Decimal
from django.contrib import messages from django.contrib import messages
from .models import Account, Category, Expense, FuelEntry, Tag, Income, Goal from .models import Account, Category, Expense, FuelEntry, Tag, Income, Goal
@ -15,8 +15,14 @@ from .forms import (
) )
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db.models import Sum, Count, ProtectedError from django.db.models import Sum, Count, Min, ProtectedError
from django.db.models.functions import ExtractMonth, ExtractYear, ExtractDay, Lower from django.db.models.functions import (
ExtractMonth,
ExtractYear,
ExtractDay,
Lower,
TruncMonth,
)
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.utils.http import url_has_allowed_host_and_scheme from django.utils.http import url_has_allowed_host_and_scheme
@ -31,6 +37,37 @@ def _get_int(value):
return None return None
# Umbral del eje del gráfico con rango libre: dos meses es donde un gráfico
# diario deja de leerse, así que por encima se agrupa por mes.
RANGE_DAY_CHART_MAX_DAYS = 62
def _get_date(value):
"""Parsea una fecha AAAA-MM-DD de la URL. None si falta o no es válida."""
try:
return datetime.strptime(value, "%Y-%m-%d").date()
except (TypeError, ValueError):
return None
def _resolve_date_range(request):
"""Lee el rango de fechas libre de la URL.
Devuelve (date_from, date_to); cualquiera de los dos puede ser None, porque
"desde el 1 de marzo" sin fecha final es un filtro válido.
Si el rango está invertido (desde > hasta) se ignoran LAS DOS fechas: devolver
una lista vacía sin explicación es peor que ignorar un filtro mal puesto.
"""
date_from = _get_date(request.GET.get("date_from"))
date_to = _get_date(request.GET.get("date_to"))
if date_from and date_to and date_from > date_to:
return None, None
return date_from, date_to
def _safe_next(request): def _safe_next(request):
next_url = request.POST.get("next") or request.GET.get("next") next_url = request.POST.get("next") or request.GET.get("next")
@ -77,9 +114,26 @@ def _category_tree(categories):
return rows return rows
def _analysis_expenses(user):
"""Gastos para análisis: excluye los traspasos entre cuentas propias.
Un traspaso mueve dinero entre cuentas del usuario, así que suma al saldo de
cada cuenta pero NO es gasto real. Usa este helper en KPIs, gráficos y
comparativas.
NO lo uses en cálculos de saldo (_account_balances, current_balance,
monthly_balance, balance_until, monthly_net): ahí los traspasos cuentan,
y filtrarlos descuadraría todos los saldos.
"""
return Expense.objects.filter(owner=user).exclude(is_transfer=True)
def _account_balances(accounts): def _account_balances(accounts):
"""Calcula el saldo de cada cuenta con dos queries agregadas (nada de """Calcula el saldo de cada cuenta con dos queries agregadas (nada de
account.current_balance() en bucle, que dispararía N+1).""" account.current_balance() en bucle, que dispararía N+1).
Los traspasos cuentan aquí a propósito: son movimientos reales de las
cuentas. No metas _analysis_expenses en esta función."""
account_ids = [a.id for a in accounts] account_ids = [a.id for a in accounts]
expense_totals = { expense_totals = {
@ -112,10 +166,73 @@ def _account_balances(accounts):
return account_balances, negative_accounts, total_balance return account_balances, negative_accounts, total_balance
def _resolve_period(request, today):
"""Traduce los parámetros de la URL en el periodo mirado por el dashboard.
Devuelve (period, selected_year, selected_month), donde selected_month es
None cuando se está mirando el año completo."""
period = request.GET.get("period", "")
if period == "this_month":
return period, today.year, today.month
if period == "last_month":
selected_year, selected_month = sub_months(today.year, today.month, 1)
return period, selected_year, selected_month
if period == "this_year":
return period, today.year, None
selected_year = _get_int(request.GET.get("year")) or today.year
selected_month = _get_int(request.GET.get("month")) or None
return period, selected_year, selected_month
def _build_account_charts(accounts, balance_by_account, selected_year, today):
"""Serie mensual de saldo de cada cuenta para las gráficas del dashboard.
En el año en curso la serie se corta en el mes actual y ese último punto
se sustituye por el saldo real de la cuenta, que llega ya calculado en
balance_by_account para no repetir current_balance() por cuenta."""
accounts_charts = []
for acc in accounts:
try:
monthly_data = acc.monthly_balance(selected_year)
m_balance = [float(row["balance"]) for row in monthly_data]
except Exception:
logger.exception(
"Error calculando monthly_balance para la cuenta %s (año %s)",
acc.id, selected_year
)
m_balance = [0] * 12
current_balance = balance_by_account[acc.id]
if selected_year == today.year:
current_month_index = today.month - 1
if current_month_index < len(m_balance):
m_balance[current_month_index] = float(current_balance)
m_balance = m_balance[:today.month]
accounts_charts.append({
"id": acc.id,
"name": acc.name,
"data": m_balance,
"current_balance": current_balance,
})
return accounts_charts
@login_required @login_required
def home(request): def home(request):
today = date.today() today = date.today()
expenses = Expense.objects.filter(owner=request.user) # Base de los KPIs, la comparativa y el mini-gráfico: sin traspasos.
# Los últimos movimientos usan su propia query, que sí los incluye.
expenses = _analysis_expenses(request.user)
# ---- KPIs del mes en curso ---- # ---- KPIs del mes en curso ----
month_expenses = expenses.filter(date__year=today.year, date__month=today.month) month_expenses = expenses.filter(date__year=today.year, date__month=today.month)
@ -153,9 +270,14 @@ def home(request):
has_alerts = bool(exceeded_goals or negative_accounts) has_alerts = bool(exceeded_goals or negative_accounts)
# ---- Últimos movimientos (gastos + ingresos mezclados) ---- # ---- Últimos movimientos (gastos + ingresos mezclados) ----
recent_expenses = expenses.select_related("category", "account").order_by( # Es un listado, no un análisis: los traspasos son movimientos reales de las
"-date", "-id" # cuentas y salen marcados con un distintivo, así que no se cuelga de
)[:8] # "expenses" (que ya los ha filtrado) sino de la query sin filtrar.
recent_expenses = (
Expense.objects.filter(owner=request.user)
.select_related("category", "account")
.order_by("-date", "-id")[:8]
)
recent_incomes = ( recent_incomes = (
Income.objects.filter(owner=request.user) Income.objects.filter(owner=request.user)
.select_related("account") .select_related("account")
@ -170,6 +292,7 @@ def home(request):
"label": e.category.name, "label": e.category.name,
"account": e.account.name, "account": e.account.name,
"amount": e.amount, "amount": e.amount,
"is_transfer": e.is_transfer,
} }
for e in recent_expenses for e in recent_expenses
] ]
@ -180,6 +303,7 @@ def home(request):
"label": i.name, "label": i.name,
"account": i.account.name, "account": i.account.name,
"amount": i.amount, "amount": i.amount,
"is_transfer": i.is_transfer,
} }
for i in recent_incomes for i in recent_incomes
], ],
@ -249,11 +373,21 @@ def expense_list(request):
if (t_id:= _get_int(t)) is not None if (t_id:= _get_int(t)) is not None
] ]
if year: date_from, date_to = _resolve_date_range(request)
expenses = expenses.filter(date__year=year) range_active = bool(date_from or date_to)
if month: # Un solo criterio de periodo a la vez: si hay rango, manda el rango y los
expenses = expenses.filter(date__month=month) # selectores de año y mes no se aplican (la plantilla lo avisa).
if range_active:
if date_from:
expenses = expenses.filter(date__gte=date_from)
if date_to:
expenses = expenses.filter(date__lte=date_to)
else:
if year:
expenses = expenses.filter(date__year=year)
if month:
expenses = expenses.filter(date__month=month)
if category: if category:
expenses = expenses.filter(category_id=category) expenses = expenses.filter(category_id=category)
@ -268,11 +402,16 @@ def expense_list(request):
expenses = expenses.order_by("-date") expenses = expenses.order_by("-date")
total_amount = expenses.aggregate(total=Sum("amount"))["total"] or 0 # La tabla sigue listando los traspasos (hay que poder verlos y corregirlos),
# pero el total y los contadores miden gasto real, así que los excluyen. Por
# eso la etiqueta del total dice "sin traspasos" en la plantilla.
analysis_expenses = expenses.exclude(is_transfer=True)
expense_count = expenses.count() total_amount = analysis_expenses.aggregate(total=Sum("amount"))["total"] or 0
category_count = expenses.values("category").distinct().count() expense_count = analysis_expenses.count()
category_count = analysis_expenses.values("category").distinct().count()
# Pagination # Pagination
paginator = Paginator(expenses, 10) paginator = Paginator(expenses, 10)
@ -306,6 +445,10 @@ def expense_list(request):
advanced_filters_open = bool(category or selected_tags) advanced_filters_open = bool(category or selected_tags)
filters_active = bool(
year or month or category or selected_tags or account_id or range_active
)
return render( return render(
request, request,
"expenses/expense_list.html", "expenses/expense_list.html",
@ -315,6 +458,9 @@ def expense_list(request):
"page_obj": page_obj, "page_obj": page_obj,
"selected_year": year, "selected_year": year,
"selected_month": month, "selected_month": month,
"date_from": date_from,
"date_to": date_to,
"range_active": range_active,
"selected_category": category, "selected_category": category,
"categories": categories, "categories": categories,
"year_list": [y.year for y in year_list], "year_list": [y.year for y in year_list],
@ -328,6 +474,7 @@ def expense_list(request):
"accounts": Account.objects.filter(owner=request.user), "accounts": Account.objects.filter(owner=request.user),
"selected_account": account_id, "selected_account": account_id,
"advanced_filters_open": advanced_filters_open, "advanced_filters_open": advanced_filters_open,
"filters_active": filters_active,
"query_params": query_params.urlencode(), "query_params": query_params.urlencode(),
}, },
) )
@ -407,43 +554,54 @@ def expense_delete(request, pk):
def dashboard(request): def dashboard(request):
today = date.today() today = date.today()
period = request.GET.get("period", "")
account_id = _get_int(request.GET.get("account")) account_id = _get_int(request.GET.get("account"))
compare_enabled = request.GET.get("compare") == "1" compare_requested = request.GET.get("compare") == "1"
# Time presets # Time presets
if period == "this_month": period, selected_year, selected_month = _resolve_period(request, today)
selected_year, selected_month = today.year, today.month
elif period == "last_month": # Rango de fechas libre. Si está activo manda sobre año y mes, y además
selected_year, selected_month = sub_months(today.year, today.month, 1) # desactiva la comparativa: no hay un "periodo anterior" obvio de un rango
elif period == "this_year": # arbitrario. compare_suppressed sirve para decírselo al usuario.
selected_year, selected_month = today.year, None date_from, date_to = _resolve_date_range(request)
else: range_active = bool(date_from or date_to)
selected_year = _get_int(request.GET.get("year")) or _get_int(today.year) compare_enabled = compare_requested and not range_active
selected_month = _get_int(request.GET.get("month")) compare_suppressed = compare_requested and range_active
selected_month = _get_int(selected_month) if selected_month else None
# Accounts # Accounts
accounts = Account.objects.filter(owner=request.user, active=True) accounts = list(Account.objects.filter(owner=request.user, active=True))
selected_account_obj = None selected_account_obj = None
if account_id: if account_id:
selected_account_obj = accounts.filter(id=account_id).first() selected_account_obj = next((a for a in accounts if a.id == account_id), None)
# Calculate the KPI # Calculate the KPI
account_balances, _, total_balance = _account_balances(accounts)
balance_by_account = {row["account"].id: row["balance"] for row in account_balances}
if selected_account_obj: if selected_account_obj:
kpi_balance = selected_account_obj.current_balance() kpi_balance = balance_by_account[selected_account_obj.id]
else: else:
kpi_balance = sum(account.current_balance() for account in accounts) kpi_balance = total_balance
# Filter by base expenses # Filter by base expenses
expenses = Expense.objects.filter(owner=request.user) # Todo lo que cuelga de aquí es análisis (KPIs, gráficos, comparativa), así
# que va sin traspasos. Los saldos de arriba sí los incluyen.
expenses = _analysis_expenses(request.user)
if account_id: if account_id:
expenses = expenses.filter(account_id=account_id) expenses = expenses.filter(account_id=account_id)
expenses_filtered = expenses.filter(date__year=selected_year) if range_active:
if selected_month: expenses_filtered = expenses
expenses_filtered = expenses_filtered.filter(date__month=selected_month) if date_from:
expenses_filtered = expenses_filtered.filter(date__gte=date_from)
if date_to:
expenses_filtered = expenses_filtered.filter(date__lte=date_to)
else:
expenses_filtered = expenses.filter(date__year=selected_year)
if selected_month:
expenses_filtered = expenses_filtered.filter(date__month=selected_month)
# Basic KPIs # Basic KPIs
total_amount = expenses_filtered.aggregate(total=Sum("amount"))["total"] or 0 total_amount = expenses_filtered.aggregate(total=Sum("amount"))["total"] or 0
expense_count = expenses_filtered.count() expense_count = expenses_filtered.count()
@ -474,7 +632,60 @@ def dashboard(request):
#Graphic #Graphic
daily_average = 0 daily_average = 0
projected_end_of_month = 0 projected_end_of_month = 0
if selected_month: 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"]
span_to = date_to or today
if span_from is None:
# Sin gastos y sin fecha inicial no hay longitud que medir.
chart_labels = []
chart_totals = []
chart_type = "month"
else:
span_days = (span_to - span_from).days + 1
daily_average = total_amount / span_days if span_days > 0 else 0
# projected_end_of_month se queda en 0: proyectar a fin de mes desde
# 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]
chart_type = "day"
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 = []
cursor = (span_from.year, span_from.month)
last = (span_to.year, span_to.month)
while cursor <= last:
axis.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_type = "month"
elif selected_month:
by_day_qs = ( by_day_qs = (
expenses_filtered.annotate(day=ExtractDay("date")) expenses_filtered.annotate(day=ExtractDay("date"))
.values("day") .values("day")
@ -522,7 +733,7 @@ def dashboard(request):
category_comparison = [] category_comparison = []
if compare_enabled: if compare_enabled:
previous_expenses = Expense.objects.filter(owner=request.user) previous_expenses = _analysis_expenses(request.user)
if account_id: if account_id:
previous_expenses = previous_expenses.filter(account_id=account_id) previous_expenses = previous_expenses.filter(account_id=account_id)
@ -561,33 +772,10 @@ def dashboard(request):
}) })
# Anual evolution by accounts # Anual evolution by accounts
accounts_charts = [] accounts_charts = _build_account_charts(
for acc in accounts: accounts, balance_by_account, selected_year, today
try: )
monthly_data = acc.monthly_balance(selected_year)
m_balance = [float(row["balance"]) for row in monthly_data]
except Exception:
logger.exception(
"Error calculando monthly_balance para la cuenta %s (año %s)",
acc.id, selected_year
)
m_balance = [0] * 12
if selected_year == today.year:
current_month_index = today.month - 1
if current_month_index < len(m_balance):
m_balance[current_month_index] = float(acc.current_balance())
m_balance = m_balance[:today.month]
accounts_charts.append({
"id": acc.id,
"name": acc.name,
"data": m_balance,
"current_balance": acc.current_balance(),
})
# Goals # Goals
goals = Goal.objects.filter(owner=request.user) goals = Goal.objects.filter(owner=request.user)
@ -605,10 +793,15 @@ 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,
"chart_type": chart_type,
"year_list": year_list, "year_list": year_list,
"months": list(range(1, 13)), "months": list(range(1, 13)),
"selected_year": selected_year, "selected_year": selected_year,
"selected_month": selected_month, "selected_month": selected_month,
"date_from": date_from,
"date_to": date_to,
"range_active": range_active,
"compare_suppressed": compare_suppressed,
"kpi_total": total_amount, "kpi_total": total_amount,
"kpi_count": expense_count, "kpi_count": expense_count,
"kpi_categories": category_count, "kpi_categories": category_count,
@ -791,12 +984,79 @@ def income_create(request):
@login_required @login_required
def income_list(request): def income_list(request):
incomes = Income.objects.filter(owner=request.user) incomes = Income.objects.filter(owner=request.user).select_related("account")
year_list = Income.objects.filter(owner=request.user).dates("date", "year")
months = list(range(1, 13))
# Filters
year = _get_int(request.GET.get("year"))
month = _get_int(request.GET.get("month"))
account_id = _get_int(request.GET.get("account"))
date_from, date_to = _resolve_date_range(request)
range_active = bool(date_from or date_to)
# Misma precedencia que en expense_list: si hay rango, manda el rango y los
# selectores de año y mes no se aplican (la plantilla lo avisa).
if range_active:
if date_from:
incomes = incomes.filter(date__gte=date_from)
if date_to:
incomes = incomes.filter(date__lte=date_to)
else:
if year:
incomes = incomes.filter(date__year=year)
if month:
incomes = incomes.filter(date__month=month)
if account_id:
incomes = incomes.filter(account_id=account_id)
incomes = incomes.order_by("-date")
# 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(
total=Sum("amount"), count=Count("id")
)
total_amount = totals["total"] or 0
income_count = totals["count"]
# Pagination
paginator = Paginator(incomes, 10)
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
query_params = request.GET.copy()
query_params.pop("page", None)
filters_active = bool(
year or month or account_id or range_active
)
return render( return render(
request, request,
"expenses/income_list.html", "expenses/income_list.html",
{"active_menu": "incomes", "incomes": incomes}, {
"active_menu": "incomes",
"incomes": page_obj,
"page_obj": page_obj,
"year_list": [y.year for y in year_list],
"months": months,
"accounts": Account.objects.filter(owner=request.user),
"selected_year": year,
"selected_month": month,
"selected_account": account_id,
"date_from": date_from,
"date_to": date_to,
"range_active": range_active,
"kpi_total": total_amount,
"kpi_count": income_count,
"filters_active": filters_active,
"query_params": query_params.urlencode(),
},
) )