Compare commits

...

5 Commits

Author SHA1 Message Date
b04a8db080 Amplia el dashboard de Home: KPIs, saldos, comparativa y movimientos
home() (views.py) y expenses/home.html pasan de mostrar tres numeros
sueltos y un listado plano de ultimos gastos a un panel mas completo:

- Saldo total y desglose por cuenta (account_balances/total_balance),
  calculado con dos queries agregadas (values().annotate(Sum(...)))
  en vez de N+1 llamadas a account.current_balance() en un bucle.
- Comparativa del gasto del mes en curso contra el mes anterior
  (prev_total/diff_amount/diff_pct), con aviso de que el mes en curso
  esta incompleto y la comparacion no es equivalente.
- Avisos (has_alerts) cuando hay objetivos excedidos o cuentas en
  negativo, con enlace directo a objetivos/cuentas.
- "Ultimos movimientos" mezcla gastos e ingresos (antes solo se veian
  los ultimos gastos) ordenados por fecha, con icono/color segun tipo.
- Se reutiliza una unica query de Goal tanto para el widget de
  objetivos (show_on_home) como para las alertas de excedidos
  (is_exceeded()), en vez de dos queries separadas.

base.css: nuevo bloque "/* Home widgets */" con las clases que usa la
plantilla (.home-section, .kpi-grid/.kpi-card, .balance-*,
.movements-list/.movement-*, .amount-positive/.amount-negative,
.comparison), incluyendo un ajuste responsive que oculta la cuenta en
movil (<480px).

No relacionado con los formularios de los commits anteriores; se
incluye aqui porque estaba pendiente de subir en la misma rama.
2026-09-03 18:01:01 +02:00
5852941136 Mueve 'expenses' antes que 'django.contrib.admin' en INSTALLED_APPS
Sintoma: /accounts/password_change/ renderizaba la pantalla del admin
de Django (cabecera "Administracion de Django", breadcrumb "Inicio >
Cambio de contrasena") en vez de la plantilla de la app.

Causa: con APP_DIRS=True (settings.py) y sin DIRS explicito, Django
busca plantillas recorriendo INSTALLED_APPS en orden y usa la PRIMERA
coincidencia por ruta relativa. django.contrib.admin trae sus propias
registration/password_change_form.html y
registration/password_change_done.html. Como 'django.contrib.admin'
aparecia antes que 'expenses' en la lista, sus plantillas ganaban
siempre a las de nuestra app, que ni siquiera llegaban a evaluarse.

Verificado con el shell de Django (get_template(...).origin.name):

  Antes del cambio:
    registration/password_change_form.html -> .../django/contrib/admin/templates/registration/password_change_form.html
    registration/password_change_done.html -> .../django/contrib/admin/templates/registration/password_change_done.html

  Despues del cambio:
    registration/password_change_form.html -> .../expenses/templates/registration/password_change_form.html
    registration/password_change_done.html -> .../expenses/templates/registration/password_change_done.html

Esto tambien explica por que el bug de las plantillas rotas (extendian
"base.html", que no existe; ver commit anterior) llevaba tanto tiempo
sin dar error: al estar sombreadas, esas plantillas nunca se
renderizaban, asi que su TemplateDoesNotExist nunca saltaba. Y explica
por que el login SI funcionaba pese a la colision de app: el admin
publica su login en "admin/login.html", no en "registration/login.html",
asi que ahi no habia conflicto.

Efecto secundario esperado y comprobado (no es una regresion, pero hay
que saberlo): /admin/password_change/, que Django resuelve tambien por
la ruta "registration/password_change_form.html" cuando un admin
cambia su propia contrasena, ahora renderiza con el layout completo de
la app (menu de navegacion, etc.) en vez del look propio del admin.
Probado con un superusuario contra /admin/, /admin/login/,
/admin/password_change/ y /admin/password_change/done/: los cuatro
responden 200 y el formulario funciona (AdminPasswordChangeForm hereda
los mismos campos old_password/new_password1/new_password2 de
PasswordChangeForm, asi que el parcial generico de campos los pinta
sin problema). El resto del admin (listados, cambio de contrasena de
otros usuarios, etc.) no usa ninguna plantilla bajo registration/ ni
admin/ que la app sobrescriba, asi que no le afecta.

No se reordena alfabeticamente esta lista en el futuro sin tener en
cuenta esto: hay un comentario en el propio settings.py explicandolo.
2026-09-03 18:00:22 +02:00
94308cbfe8 Corrige registration/password_change_*.html: extendian "base.html", que no existe
Ambas plantillas hacian {% extends "base.html" %}, pero en este
proyecto no existe ninguna plantilla llamada asi a nivel raiz: el
layout de la app se llama "expenses/base.html" (esta dentro de
expenses/templates/expenses/, no de expenses/templates/). Con
APP_DIRS=True y DIRS=[] en settings.py, Django busca "base.html" tal
cual en la carpeta templates/ de cada app instalada y no lo encuentra
en ninguna, asi que un {% extends %} a esa ruta lanza
TemplateDoesNotExist en cuanto se intenta renderizar la plantilla.

Esto llevaba tiempo sin detectarse porque, hasta el commit de
INSTALLED_APPS de esta misma tanda, django.contrib.admin sombreaba
estas dos plantillas con las suyas propias (ver ese commit): la
plantilla rota de la app nunca llegaba a cargarse, asi que el error
nunca saltaba. Al arreglar el orden de INSTALLED_APPS estas plantillas
pasan a usarse de verdad, así que había que arreglarlas ahora sí en el
mismo movimiento.

Cambios:
- Ambas extienden "expenses/base.html".
- Se migran al parcial expenses/_form_fields.html y a class="app-form",
  igual que el resto de formularios (ver commit anterior).
- Se anade un enlace de "Volver" a {% url 'home' %} en las dos, y un
  {% block title %} (heredaban el titulo generico "Expenses manager").
2026-09-03 17:59:53 +02:00
581eb202c7 Migra formularios de gastos/ingresos/cuentas/etc al parcial comun
Sustituye {{ form.as_p }} (o el bucle for field in form repetido a
mano en expense_form.html) por {% include "expenses/_form_fields.html" %}
y anade class="app-form" al <form> en:

  - categories/form.html, categories/list.html (alta de categoria)
  - expenses/account_form.html
  - expenses/expense_form.html
  - expenses/income_form.html
  - expenses/tag_form.html
  - fuel/create.html
  - goals/form.html
  - registration/login.html

De paso:
- categories/list.html: se corrige indentacion de la tabla y las
  etiquetas <a> de "Editar"/"Eliminar" (estaban dentro de una celda
  sin problema funcional, solo desalineadas) y se anade una <th> vacia
  para la columna de acciones.
- goals/form.html: el bloque title y el <h1> tenian "Nuevo objetivo"
  fijo aunque la vista goal_edit ya pasa title="Editar objetivo" en el
  contexto; ahora la plantilla usa {{ title }}, asi que el formulario
  de edicion deja de decir "Nuevo objetivo" por error.

No se toca la logica de las vistas ni los formularios en forms.py.
2026-09-03 17:59:28 +02:00
d70d0e4239 Anade sistema de estilos de formulario reutilizable
Los formularios usaban form.as_p (salida generica de Django) o bucles
manuales for field in form repetidos en cada plantilla, sin estilos
propios: inputs/selects/textareas sin padding, borde ni foco visibles,
y cada plantilla resolvia los errores/ayuda de campo a su manera.

Cambios:
- base.css: nuevas clases .app-form, .form-field (label + control en
  columna), estilos de input/select/textarea (incluye estado :focus
  con el color de foco del tema), .form-field-checkbox para campos
  booleanos y .form-help para el help_text de los campos.
- Nuevo parcial expenses/_form_fields.html: itera los campos del
  formulario y renderiza label, control, help_text y errores de forma
  consistente. Incluye un caso especial para el campo "tags" de gastos
  (CheckboxSelectMultiple), que se pinta como lista de chips en vez de
  checkboxes sueltos.

Este commit solo anade el estilo y el parcial; las plantillas que lo
adoptan van en el siguiente commit.
2026-09-03 17:59:01 +02:00
16 changed files with 490 additions and 130 deletions

View File

@ -694,10 +694,59 @@ tr:hover {
/* Forms */
/* ========================= */
.app-form {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 480px;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 1rem;
}
.form-field label {
font-weight: 600;
font-size: 0.9rem;
}
.form-field input:not([type="checkbox"]):not([type="radio"]),
.form-field select,
.form-field textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background-color: var(--color-surface);
color: var(--color-text);
font: inherit;
}
.form-field input:focus,
.form-field select:focus,
.form-field textarea:focus {
outline: 2px solid var(--color-focus-ring);
outline-offset: 1px;
border-color: var(--color-primary);
}
.form-field-checkbox {
flex-direction: row;
align-items: center;
}
.form-field-checkbox input[type="checkbox"] {
accent-color: var(--color-primary);
}
.form-help {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.form-errors {
color: var(--color-danger-accent);
font-size: 0.85rem;
@ -860,6 +909,115 @@ tr:hover {
color: var(--color-text-muted);
}
/* ========================= */
/* Home widgets */
/* ========================= */
.home-section {
margin-bottom: 2.5rem;
}
.home-section > h2 {
margin-bottom: 0.75rem;
}
.section-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1.25rem;
}
.balance-total {
font-size: 2rem;
font-weight: 600;
color: var(--color-success-accent);
margin: 0 0 1rem;
}
.balance-total.negative,
.balance-amount.negative {
color: var(--color-danger-accent);
}
.balance-breakdown {
list-style: none;
margin: 0;
padding: 0;
max-width: 420px;
}
.balance-breakdown li {
display: flex;
justify-content: space-between;
padding: 0.4rem 0;
border-bottom: 1px solid var(--color-border);
}
.balance-amount {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.movements-list {
list-style: none;
margin: 0;
padding: 0;
}
.movement {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-border);
}
.movement-date {
flex-shrink: 0;
font-size: 0.85rem;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.movement-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.movement-account {
flex-shrink: 0;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.movement-amount {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.amount-positive {
color: var(--color-success-accent);
}
.amount-negative {
color: var(--color-danger-accent);
}
.comparison {
margin-top: 1rem;
}
@media (max-width: 480px) {
.movement-account {
display: none;
}
}
/* ========================= */
/* Responsive */
/* ========================= */

View File

@ -7,9 +7,9 @@
{% block content %}
<h2>Editar categoría</h2>
<form method="post">
<form method="post" class="app-form">
{% csrf_token %}
{{ form.as_p }}
{% include "expenses/_form_fields.html" %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">Guardar</button>
<a class="btn btn-secondary" href="{% url 'category_list' %}">Cancelar</a>

View File

@ -8,10 +8,10 @@
<h1>Mis categorías</h1>
<h3> Nueva categoría</h3>
<form method="post">
<h3>Nueva categoría</h3>
<form method="post" class="app-form">
{% csrf_token %}
{{ form.as_p }}
{% include "expenses/_form_fields.html" %}
<button type="submit" class="btn btn-primary">Crear</button>
</form>
@ -19,15 +19,16 @@
<h3>Listado</h3>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Categoría</th>
<th>Categoría padre</th>
</tr>
</thead>
<tbody>
{% for category in categories %}
<table>
<thead>
<tr>
<th>Categoría</th>
<th>Categoría padre</th>
<th></th>
</tr>
</thead>
<tbody>
{% for category in categories %}
<tr>
<td>{{ category.name }}</td>
<td>{% if category.parent %}{{ category.parent.name }}{% endif %}</td>
@ -36,9 +37,9 @@
<a href="{% url 'category_delete' category.id %}" class="danger">Eliminar</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -0,0 +1,30 @@
{% for field in form %}
<div class="form-field {% if field.field.widget.input_type == 'checkbox' %}form-field-checkbox{% endif %}">
{% if field.name == "tags" %}
{{ field.label_tag }}
<div class="tag-chip-list">
{% for checkbox in field %}
<label class="tag-chip">
{{ checkbox.tag }}
<span>{{ checkbox.choice_label }}</span>
</label>
{% endfor %}
</div>
{% else %}
{{ field.label_tag }}
{{ field }}
{% endif %}
{% if field.help_text %}
<small class="form-help">{{ field.help_text }}</small>
{% endif %}
{% if field.errors %}
<div class="form-errors">{{ field.errors }}</div>
{% endif %}
</div>
{% endfor %}
{% if form.non_field_errors %}
<div class="form-errors">{{ form.non_field_errors }}</div>
{% endif %}

View File

@ -10,9 +10,9 @@
{% endif %}
</h1>
<form method="post">
<form method="post" class="app-form">
{% csrf_token %}
{{ form.as_p }}
{% include "expenses/_form_fields.html" %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">Guardar</button>

View File

@ -17,34 +17,10 @@
{% endif %}
</h1>
<form method="post">
<form method="post" class="app-form">
{% csrf_token %}
{% for field in form %}
{% if field.name == "tags" %}
<div class="form-field">
{{ field.label_tag }}
<div class="tag-chip-list">
{% for checkbox in field %}
<label class="tag-chip">
{{ checkbox.tag }}
<span>{{ checkbox.choice_label }}</span>
</label>
{% endfor %}
</div>
{% if field.errors %}
<div class="form-errors">{{ field.errors }}</div>
{% endif %}
</div>
{% else %}
<p>
{{ field.label_tag }} {{ field }}
{% if field.errors %}
<span class="form-errors">{{ field.errors }}</span>
{% endif %}
</p>
{% endif %}
{% endfor %}
{% include "expenses/_form_fields.html" %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">

View File

@ -8,29 +8,111 @@
<h1>Home</h1>
<section>
<h2>Resumen del mes</h2>
<p>Total: {{ kpi_total|floatformat:2 }}</p>
<p>Gastos: {{ kpi_count }}</p>
<p>Categorías: {{ kpi_categories }}</p>
</section>
{% if has_alerts %}
<div class="messages">
{% for goal in exceeded_goals %}
<div class="message warning">
Presupuesto <strong>{{ goal.name }}</strong> excedido:
{{ goal.progress|floatformat:2 }}€ de {{ goal.target_amount|floatformat:2 }}€.
<a href="{% url 'goal_list' %}">Ver objetivos</a>
</div>
{% endfor %}
{% for item in negative_accounts %}
<div class="message error">
La cuenta <strong>{{ item.account.name }}</strong> está en negativo:
{{ item.balance|floatformat:2 }}€.
<a href="{% url 'account_list' %}">Ver cuentas</a>
</div>
{% endfor %}
</div>
{% endif %}
<section>
<h2>Últimos gastos</h2>
<ul>
{% for expense in last_expenses %}
<section class="home-section">
<h2>Saldo total</h2>
<p class="balance-total {% if total_balance < 0 %}negative{% endif %}">
{{ total_balance|floatformat:2 }}€
</p>
<ul class="balance-breakdown">
{% for item in account_balances %}
<li>
{{ expense.date }} -
{{ expense.category.name }} -
{{ expense.amount }}
<span class="balance-account">{{ item.account.name }}</span>
<span class="balance-amount {% if item.balance < 0 %}negative{% endif %}">
{{ item.balance|floatformat:2 }}€
</span>
</li>
{% empty %}
<li>No hay gastos</li>
<li class="muted">No tienes cuentas activas.</li>
{% endfor %}
</ul>
</section>
<section>
<section class="home-section">
<h2>Resumen del mes</h2>
<div class="kpi-grid">
<div class="kpi-card">
<span class="kpi-label">Gastado este mes</span>
<span class="kpi-value">{{ kpi_total|floatformat:2 }}€</span>
</div>
<div class="kpi-card">
<span class="kpi-label">Nº de gastos</span>
<span class="kpi-value">{{ kpi_count }}</span>
</div>
<div class="kpi-card">
<span class="kpi-label">Categorías usadas</span>
<span class="kpi-value">{{ kpi_categories }}</span>
</div>
</div>
<p class="comparison">
{% if diff_pct is None %}
<span class="muted">Sin gastos el mes pasado para comparar.</span>
{% else %}
Mes anterior: {{ prev_total|floatformat:2 }}€ —
{% if diff_amount > 0 %}
<span class="amount-negative">
+{{ diff_amount|floatformat:2 }}€ ({{ diff_pct|floatformat:1 }}%)
</span>
{% elif diff_amount < 0 %}
<span class="amount-positive">
{{ diff_amount|floatformat:2 }}€ ({{ diff_pct|floatformat:1 }}%)
</span>
{% else %}
sin variación
{% endif %}
{% endif %}
<br>
<small class="muted">
El mes en curso está incompleto: la comparación no es equivalente
hasta que termine.
</small>
</p>
</section>
<section class="home-section">
<h2>Últimos movimientos</h2>
<ul class="movements-list">
{% for mov in movements %}
<li class="movement">
<span class="movement-date">{{ mov.date }}</span>
<span class="movement-label">{{ mov.label }}</span>
<span class="movement-account muted">{{ mov.account }}</span>
<span class="movement-amount {% if mov.kind == 'income' %}amount-positive{% else %}amount-negative{% endif %}">
{% if mov.kind == 'income' %}+{% else %}{% endif %}{{ mov.amount|floatformat:2 }}€
</span>
</li>
{% empty %}
<li class="muted">Todavía no hay movimientos.</li>
{% endfor %}
</ul>
<div class="section-actions">
<a class="btn btn-secondary" href="{% url 'expense_list' %}">Ver todos los gastos</a>
<a class="btn btn-secondary" href="{% url 'income_list' %}">Ver todos los ingresos</a>
</div>
</section>
<section class="home-section">
<div class="dashboard-grid">
<div class="card card-chart">
<h2>Últimos meses</h2>
@ -79,32 +161,39 @@
applyCartesianColors(chart, colors);
});
</script>
</section>
<h3>Objetivos</h3>
{% if goals %}
<div class="goals-widget">
{% for goal in goals %}
<div class="goal-card">
<strong>{{ goal.name }}</strong>
{% if goal.is_exceeded %}
<span class="badge badge-inactive">Excedido</span>
{% endif %}
<section class="home-section">
<h2>Objetivos</h2>
{% if goals %}
<div class="goals-widget">
{% for goal in goals %}
<div class="goal-card">
<strong>{{ goal.name }}</strong>
{% if goal.is_exceeded %}
<span class="badge badge-inactive">Excedido</span>
{% endif %}
<div class="progress-bar">
<div class="progress-fill {{ goal.progress_state }}"
style="width: {{ goal.bar_width|unlocalize }}%"></div>
<div class="progress-bar"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="{{ goal.bar_width|unlocalize }}"
aria-label="Progreso de {{ goal.name }}"
aria-valuetext="{{ goal.progress|floatformat:1 }}€ de {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%)">
<div class="progress-fill {{ goal.progress_state }}"
style="width: {{ goal.bar_width|unlocalize }}%"></div>
</div>
<span class="progress-label">
{{ goal.progress|floatformat:1 }}€ / {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%)
</span>
</div>
<span class="progress-label">
{{ goal.progress|floatformat:1 }}€ / {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%)
</span>
</div>
{% endfor %}
</div>
{% else %}
<p>No tienes objetivos aún.</p>
{% endif %}
{% endfor %}
</div>
{% else %}
<p class="muted">No tienes objetivos aún.</p>
{% endif %}
</section>
{% endblock %}

View File

@ -16,9 +16,9 @@
{% endif %}
</h1>
<form method="post">
<form method="post" class="app-form">
{% csrf_token %}
{{ form.as_p }}
{% include "expenses/_form_fields.html" %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">
{% if form.instance.pk %}

View File

@ -3,9 +3,9 @@
{% block content %}
<h1>Etiqueta</h1>
<form method="post">
<form method="post" class="app-form">
{% csrf_token %}
{{ form.as_p }}
{% include "expenses/_form_fields.html" %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">Guardar</button>
<a class="btn btn-secondary" href="{% url 'tag_list' %}">Volver</a>

View File

@ -17,10 +17,10 @@
{% endif %}
</h1>
<form method="post">
<form method="post" class="app-form">
{% csrf_token %}
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
{{ form.as_p }}
{% include "expenses/_form_fields.html" %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">
{% if editing %}

View File

@ -1,16 +1,16 @@
{% extends "expenses/base.html" %}
{% block title %}
Nuevo objetivo
{{ title }}
{% endblock %}
{% block content %}
<h1>
Nuevo objetivo
{{ title }}
</h1>
<form method="post">
<form method="post" class="app-form">
{% csrf_token %}
{{ form.as_p }}
{% include "expenses/_form_fields.html" %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">
Guardar

View File

@ -4,9 +4,9 @@
<h2>Iniciar sesión</h2>
<form method="post">
<form method="post" class="app-form">
{% csrf_token %}
{{ form.as_p }}
{% include "expenses/_form_fields.html" %}
<button type="submit" class="btn btn-primary">Entrar</button>
</form>

View File

@ -1,6 +1,13 @@
{% extends "base.html" %}
{% extends "expenses/base.html" %}
{% block title %}Contraseña actualizada{% endblock %}
{% block content %}
<h2>Contraseña actualizada correctamente</h2>
<a href="/">Volver</a>
<h1>Contraseña actualizada</h1>
<p>Tu contraseña se ha cambiado correctamente.</p>
<div class="form-actions">
<a class="btn btn-primary" href="{% url 'home' %}">Volver al inicio</a>
</div>
{% endblock %}

View File

@ -1,10 +1,16 @@
{% extends "base.html" %}
{% extends "expenses/base.html" %}
{% block title %}Cambiar contraseña{% endblock %}
{% block content %}
<h2>Cambiar contraseña</h2>
<form method="post">
<h1>Cambiar contraseña</h1>
<form method="post" class="app-form">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-primary">Cambiar</button>
{% include "expenses/_form_fields.html" %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">Cambiar contraseña</button>
<a class="btn btn-secondary" href="{% url 'home' %}">Volver</a>
</div>
</form>
{% endblock %}

View File

@ -1,6 +1,7 @@
import logging
import calendar
from datetime import date, datetime
from decimal import Decimal
from django.contrib import messages
from .models import Account, Category, Expense, FuelEntry, Tag, Income, Goal
from .forms import (
@ -58,21 +59,108 @@ def sub_months(year, month, n):
@login_required
def home(request):
today = date.today()
expenses = Expense.objects.filter(owner=request.user)
# Last expenses
last_expenses = expenses.select_related("category").order_by("-date")[:5]
# Simple KPIs (current month)
today = date.today()
# ---- KPIs del mes en curso ----
month_expenses = expenses.filter(date__year=today.year, date__month=today.month)
kpi_total = month_expenses.aggregate(total=Sum("amount"))["total"] or 0
kpi_total = month_expenses.aggregate(total=Sum("amount"))["total"] or Decimal("0")
kpi_count = month_expenses.count()
kpi_categories = month_expenses.values("category").distinct().count()
# ---- Comparativa con el mes anterior ----
prev_year, prev_month = sub_months(today.year, today.month, 1)
prev_total = (
expenses.filter(date__year=prev_year, date__month=prev_month).aggregate(
total=Sum("amount")
)["total"]
or Decimal("0")
)
diff_amount = kpi_total - prev_total
# None cuando no hay mes anterior con gastos (evita división por cero)
diff_pct = None
if prev_total:
diff_pct = (diff_amount / prev_total) * 100
# ---- Saldos de cuentas ----
# Dos queries agregadas en lugar de N+1 (nada de account.current_balance() en bucle).
accounts = list(Account.objects.filter(owner=request.user, active=True))
account_ids = [a.id for a in accounts]
expense_totals = {
row["account_id"]: row["total"]
for row in Expense.objects.filter(account_id__in=account_ids)
.values("account_id")
.annotate(total=Sum("amount"))
}
income_totals = {
row["account_id"]: row["total"]
for row in Income.objects.filter(account_id__in=account_ids)
.values("account_id")
.annotate(total=Sum("amount"))
}
account_balances = []
negative_accounts = []
total_balance = Decimal("0")
for acc in accounts:
balance = (
acc.initial_balance
+ (income_totals.get(acc.id) or Decimal("0"))
- (expense_totals.get(acc.id) or Decimal("0"))
)
total_balance += balance
account_balances.append({"account": acc, "balance": balance})
if balance < 0:
negative_accounts.append({"account": acc, "balance": balance})
# ---- Objetivos ----
# Una sola query reutilizada para "goals" (home) y "exceeded_goals" (alertas).
# is_exceeded() solo aplica a kind == budget, así que pago y ahorro nunca la disparan.
all_goals = list(
Goal.objects.filter(owner=request.user).select_related("category", "account")
)
goals = [g for g in all_goals if g.show_on_home]
exceeded_goals = [g for g in all_goals if g.is_exceeded()]
has_alerts = bool(exceeded_goals or negative_accounts)
# ---- Últimos movimientos (gastos + ingresos mezclados) ----
recent_expenses = expenses.select_related("category", "account").order_by(
"-date", "-id"
)[:8]
recent_incomes = (
Income.objects.filter(owner=request.user)
.select_related("account")
.order_by("-date", "-id")[:8]
)
movements = sorted(
[
{
"kind": "expense",
"date": e.date,
"label": e.category.name,
"account": e.account.name,
"amount": e.amount,
}
for e in recent_expenses
]
+ [
{
"kind": "income",
"date": i.date,
"label": i.name,
"account": i.account.name,
"amount": i.amount,
}
for i in recent_incomes
],
key=lambda m: m["date"],
reverse=True,
)[:8]
# ---- Mini-gráfico de 6 meses (sin cambios) ----
six_months = []
for i in range(5, -1, -1):
y, m = sub_months(today.year, today.month, i)
@ -86,28 +174,28 @@ def home(request):
]
or 0
)
mini_data.append(
{
"label": f"{m}/{y}",
"total": float(total),
}
)
goals = Goal.objects.filter(owner=request.user, show_on_home=True)
mini_data.append({"label": f"{m}/{y}", "total": float(total)})
return render(
request,
"expenses/home.html",
{
"active_menu": "home",
"last_expenses": last_expenses,
"kpi_total": kpi_total,
"kpi_count": kpi_count,
"kpi_categories": kpi_categories,
"prev_total": prev_total,
"diff_amount": diff_amount,
"diff_pct": diff_pct,
"total_balance": total_balance,
"account_balances": account_balances,
"negative_accounts": negative_accounts,
"goals": goals,
"exceeded_goals": exceeded_goals,
"has_alerts": has_alerts,
"movements": movements,
"mini_chart_labels": [x["label"] for x in mini_data],
"mini_chart_data": [x["total"] for x in mini_data],
"goals": goals,
},
)

View File

@ -61,14 +61,19 @@ if not DEBUG:
# Application definition
# 'expenses' va antes que 'django.contrib.admin' a proposito: el cargador de
# plantillas por aplicacion recorre esta lista en orden y se queda con la
# primera coincidencia. Si el admin va antes, sus plantillas de registration/
# sombrean a las nuestras (paso con password_change_form.html y
# password_change_done.html). No reordenar alfabeticamente.
INSTALLED_APPS = [
'expenses',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'expenses',
]
MIDDLEWARE = [