Compare commits

..

14 Commits

Author SHA1 Message Date
6c6fbbd82c Merge pull request 'Añadidas tandas: 4, 4b y 7' (#33) from dev into main
Reviewed-on: #33
2026-09-09 14:29:27 +00:00
e5aa66369a Merge branch 'main' into dev 2026-09-09 14:29:19 +00:00
4eb120927b Anade CLAUDE.md y el informe de analisis del proyecto
CLAUDE.md documenta la estructura de directorios (que tiene tres niveles
llamados expenses_manager y despista), los comandos habituales, las
variables de entorno y las decisiones de auth, para no tener que deducirlo
de settings.py cada vez.

ANALISIS_code.md es un informe de solo lectura sobre UX, deuda tecnica y
mantenibilidad, ordenado por impacto. Sirve de lista de trabajo pendiente;
varios de sus puntos ya estan resueltos en los commits anteriores.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:22:08 +02:00
814487a390 Anade columna con el numero de gastos de cada categoria
El listado de categorias no daba ninguna pista de cuales estan en uso.
Anade la columna con el mismo criterio que el de etiquetas: annotate en la
vista para no disparar una query por fila.

Cuenta solo los gastos directos de cada categoria, no los de sus
descendientes. Si el numero incluyera a los hijos, un padre sin gastos
propios mostraria una cifra que no corresponde a ninguna fila suya y que no
cuadraria con lo que se ve al filtrar por esa categoria.

Con Category.Meta.ordering = [Lower("name")], Django anade la expresion al
GROUP BY del annotate. Verificado que no multiplica filas ni fusiona
categorias cuyo nombre solo difiere en mayusculas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:21:35 +02:00
f154127f9e Anade panel de ayuda en el formulario de objetivos
El formulario tiene tres tipos de objetivo y campos que solo aplican a
algunos, sin nada en pantalla que lo explique: no habia forma de saber que
"Account" solo cuenta para ahorro o que "Period" solo cuenta para
presupuesto.

Anade un desplegable, cerrado por defecto, que describe para que sirve cada
tipo y que hace cada campo. Usa <details>/<summary> nativo en vez de
JavaScript, que es accesible por teclado de serie y ya es el patron de los
filtros avanzados de gastos.

El texto esta contrastado con Goal._period_start(), Goal.progress y
Goal.progress_state(), y los nombres de campo son los que el formulario
muestra de verdad. Esas etiquetas salen hoy en ingles porque GoalForm no
define labels y no hay i18n configurada; el panel usa las reales para que
coincida con lo que se ve, no las traducidas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:19:30 +02:00
f135797d31 Ordena etiquetas, categorias, cuentas y objetivos ignorando mayusculas
Tag era el unico de los tres modelos con nombre sin ordering en su Meta, asi
que las etiquetas salian en orden de insercion. Al anadirlo aparecio el
problema de fondo: con ordering = ["name"], SQLite ordena por valor binario y
en ASCII todas las mayusculas van antes que todas las minusculas, de modo que
salia AA, MK, ZZ, mk, ms en vez de AA, MK, mk, ms, ZZ.

Importa mas de lo que parece porque en produccion la base de datos es
PostgreSQL, que ordena segun la configuracion regional. El orden pasa a
calcularse en la consulta con Lower("name") en vez de depender de la colacion
del motor, asi que local y NAS coinciden.

Los tres Meta usan ahora ordering = [Lower("name")]. Django admite
expresiones ahi, pero no se propaga solo a todos los sitios: habia tres
sitios con orden explicito que lo pisaban y tambien se corrigen.

- tag_list y account_list tenian .order_by("name")
- goal_list ordenaba igual
- _category_tree reordena los hermanos en memoria, y Python compara por code
  point igual que SQLite, asi que el arbol tampoco quedaba bien

Las dos migraciones son AlterModelOptions, sin cambios de esquema, pero hay
que desplegarlas.

Lo que no resuelve: los acentos. Lower() normaliza mayusculas y minusculas,
pero como se ordenan "N", "a" o "u" sigue dependiendo de la colacion de cada
motor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:18:52 +02:00
e814bc2d03 Anade filtro por tipo en objetivos y unifica la barra de progreso
El listado de objetivos mezclaba pagos, presupuestos y ahorros sin forma de
separarlos. Anade un filtro por tipo, con el mismo patron que los filtros de
gastos, y ordena los objetivos por nombre. Un valor de "kind" que no este en
KIND_CHOICES se ignora en vez de romper.

De paso, la barra de progreso pasa a usar goal.progress_state en lugar de
decidir el color en la plantilla con un if sobre el porcentaje. La logica ya
vivia en el modelo, que ademas distingue presupuestos (donde acercarse al
100% es un aviso) del resto; la plantilla del dashboard seguia con las clases
antiguas low/medium/high, que se retiran del CSS.

Incluye estado vacio y thead/tbody en la tabla, que le faltaban.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:16:58 +02:00
22563df48a Muestra las etiquetas en tabla con su numero de gastos
El listado de etiquetas era una lista sin mas, sin forma de saber cuales
estaban en uso. Pasa a tabla, en linea con el resto de listados, y anade
una columna con el numero de gastos que usa cada etiqueta.

El contador se calcula con annotate(Count("expenses")) en la vista, en vez
de contar desde la plantilla, para no provocar una query por fila.

Incluye tambien el estado vacio, que la lista no tenia.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 15:36:02 +02:00
e65b15ccf8 Extrae helpers de vistas y rehace los listados de cuentas y categorias
Saca de home() dos ayudantes reutilizables:

- _account_balances(): calcula el saldo de cada cuenta con dos queries
  agregadas en lugar de llamar a account.current_balance() en bucle,
  que disparaba un N+1.
- _category_tree(): aplana las categorias en preorden con su profundidad,
  para poder indentarlas en la plantilla.

Con ellos, account_list pasa a mostrar el saldo calculado de cada cuenta
(antes usaba current_balance() desde la plantilla) y category_list muestra
un arbol indentado en vez de una columna "Categoria padre" que obligaba a
reconstruir la jerarquia mentalmente.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 15:34:37 +02:00
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
29 changed files with 1209 additions and 220 deletions

181
ANALISIS_code.md Normal file
View File

@ -0,0 +1,181 @@
# Análisis de mejoras — Expenses Manager
Informe de solo lectura (no se ha modificado ni commiteado nada). Cubre UX/visual, técnico y mantenibilidad, ordenado por impacto dentro de cada bloque. No incluye bugs funcionales.
> **Nota**: durante el análisis han aparecido dos hallazgos que sí son bugs funcionales (fuera del alcance de este informe, para tu otra lista):
> - `expenses/templates/expenses/income_confirm_delete.html:10` muestra `{{ expense.date }}` en vez de `{{ income.date }}` (esa variable no existe en el contexto de esa vista, así que la fecha sale vacía). Parece un resto de copiar `expense_confirm_delete.html`.
> - `urls.py:36` apunta a `registration/password_help.html`, plantilla que no existe en `templates/registration/` (solo están `password_change_form.html` y `password_change_done.html`). Esa URL rompería con un `TemplateDoesNotExist`.
---
## 1. Visual / UX
### 1.1 Diseño no responsive: falta viewport y media queries (Alto impacto / Esfuerzo medio)
**Qué**: no hay ninguna etiqueta `<meta name="viewport">` en `base.html` ni `base_auth.html`, y `base.css` (539 líneas) no tiene ni un solo `@media`. Las tablas (`expense_list.html`, `dashboard.html` tablas de comparación, `fuel/list.html` con 7 columnas) se renderizan sin envoltorio de scroll horizontal.
**Dónde**: `expenses/templates/expenses/base.html`, `base_auth.html`, `expenses/static/expenses/css/base.css`.
**Por qué importa**: en móvil el layout se renderiza a tamaño escritorio y se ve reducido/roto; las tablas anchas (fuel, comparación del dashboard) van a desbordar o comprimir columnas ilegibles.
**Esfuerzo**: medio — añadir el meta viewport es trivial, pero meter breakpoints reales y envolver tablas en `overflow-x:auto` toca varias plantillas.
### 1.2 Plantillas de confirmación de borrado divergentes (Alto impacto / Esfuerzo medio)
**Qué**: las 7 páginas de confirmar-borrado (`expense_confirm_delete.html`, `tag_confirm_delete.html`, `account_confirm_delete.html`, `income_confirm_delete.html`, `categories/confirm_delete.html`, `fuel/confirm_delete.html`, `goals/confirm_delete.html`) son casi idénticas pero cada una tiene su propia mezcla de etiqueta de botón ("Eliminar" vs "Sí, eliminar"), clase CSS (`btn` vs `btn danger`, `btn secondary` vs `btn` a secas) y nivel de encabezado (`h1` vs `h2`).
**Dónde**: las 7 plantillas listadas arriba.
**Por qué importa**: la misma acción destructiva se presenta visualmente distinta según la sección; y como ya demuestra el bug de `income_confirm_delete.html`, copiar-pegar estas plantillas sin unificarlas es justo lo que genera ese tipo de errores.
**Esfuerzo**: medio — unificarlas en un partial (`{% include %}`) con variables (`object_label`, `cancel_url`) o migrar a `DeleteView` con una plantilla genérica.
### 1.3 Colores hardcodeados y sin variables CSS (Alto impacto / Esfuerzo medio)
**Qué**: `base.css` no define ninguna custom property (`--variable`); los mismos conceptos semánticos (verde éxito, rojo error/peligro) están definidos con 3-4 tonos de hex ligeramente distintos en sitios distintos (`#166534`/`#1e7e34` para verde; `#b91c1c`/`#991b1b`/`#b71c1c` para rojo). Además, `dashboard.html` usa hex literales inline (`#d9534f`/`#5cb85c`, líneas 167, 191, 239) que no coinciden con los que ya existen en `base.css` para el mismo significado ("gasto sube/baja").
**Dónde**: `expenses/static/expenses/css/base.css`, `expenses/templates/expenses/dashboard.html` (16 `style="..."` inline, concentrados en este archivo).
**Por qué importa**: es la brecha de coherencia visual más clara del proyecto — mismo significado, colores distintos según la página; y dificulta cualquier cambio de paleta futuro (hay que buscar y reemplazar en vez de cambiar una variable).
**Esfuerzo**: medio — introducir `:root { --color-success: ...; --color-danger: ... }` y sustituir tanto en `base.css` como en los inline styles de `dashboard.html`.
### 1.4 Menú desplegable "Configuraciones" inaccesible por teclado (Medio impacto / Esfuerzo bajo)
**Qué**: el dropdown de navegación (`base.html:24-32`) se abre solo con `:hover` en CSS (`base.css:382-384`), sin `role`, `aria-expanded` ni gestor de teclado/foco.
**Dónde**: `expenses/templates/expenses/base.html:24-32`, `base.css:382-384`.
**Por qué importa**: un usuario que navega solo con teclado no puede llegar nunca a Categorías/Etiquetas/Objetivos — no es solo "buena práctica de accesibilidad", es una ruta completamente bloqueada.
**Esfuerzo**: bajo — añadir `aria-expanded`, un pequeño script de toggle por click/Enter, y `tabindex` en el toggle.
### 1.5 Selects de filtro sin `<label>` (Medio impacto / Esfuerzo bajo)
**Qué**: los selects de año/mes/cuenta en `expense_list.html` (líneas 14, 25, 36) y `dashboard.html` (líneas 56, 66, 74) no tienen `<label>` asociado, mientras que el filtro de "Categoría" en la misma página sí lo tiene.
**Dónde**: `expenses/templates/expenses/expense_list.html`, `dashboard.html`.
**Por qué importa**: lectores de pantalla no anuncian qué controla cada select; inconsistente incluso dentro de la misma página.
**Esfuerzo**: bajo — añadir `<label>` a cada select.
### 1.6 Barras de progreso sin semántica ARIA (Bajo-medio impacto / Esfuerzo bajo)
**Qué**: las barras de progreso de objetivos (`goals/list.html:36-39`, `home.html:75-78`, `dashboard.html:379-386`) son `div`s anidados sin `role="progressbar"` ni `aria-valuenow/min/max`.
**Dónde**: las 3 plantillas citadas.
**Por qué importa**: la información de progreso es puramente visual, invisible para tecnología asistiva.
**Esfuerzo**: bajo.
### 1.7 Clases CSS referenciadas en plantillas que no existen en `base.css` (Bajo-medio impacto / Esfuerzo bajo)
**Qué**: `.auth-container`, `.pagination`/`.step-links`, `.form-errors`/`.form-field`, `.advanced-content`/`.tags-filters`, `.btn-secondary` (con guion, distinto de `.btn.secondary` que sí existe) se usan en plantillas pero no tienen regla CSS correspondiente.
**Dónde**: `base_auth.html:10`, `expense_list.html:48,60,75,151-152`, `expense_form.html:25,36,43`, `categories/confirm_delete.html:13,19,26`.
**Por qué importa**: probablemente la paginación y los mensajes de error de formulario se están viendo sin ningún estilo — vale la pena revisar si es intencional o un olvido.
**Esfuerzo**: bajo.
### 1.8 Patrones de UI distintos para datos estructuralmente iguales (Bajo-medio impacto / Esfuerzo bajo-medio)
**Qué**: `tag_list.html` renderiza como `<ul>/<li>`, mientras `categories/list.html` usa `<table>` para el mismo tipo de contenido ("nombre + acciones"). Además, crear una categoría se hace con un formulario embebido en la página de listado, mientras que cuentas/etiquetas/ingresos usan una página "nueva" separada.
**Dónde**: `expenses/templates/expenses/tag_list.html`, `categories/list.html`.
**Por qué importa**: dos patrones de UX distintos para la misma tarea ("añadir un elemento") sin razón aparente.
**Esfuerzo**: bajo-medio.
### 1.9 Jerarquía de encabezados inconsistente (Bajo impacto / Esfuerzo bajo)
**Qué**: la mayoría de páginas usa `<h1>` para el título, pero `categories/confirm_delete.html`, `categories/form.html` y `fuel/confirm_delete.html` usan `<h2>` sin ningún `<h1>` en la página; `dashboard.html` no tiene `<h1>` y empieza directamente en `<h2>`.
**Dónde**: plantillas citadas.
**Por qué importa**: rompe la jerarquía semántica que usan lectores de pantalla para navegar por secciones.
**Esfuerzo**: bajo.
---
## 2. Técnico / Código
### 2.1 N+1 en el dashboard por cuenta (Alto impacto / Esfuerzo medio)
**Qué**: `kpi_balance = sum(account.current_balance() for account in accounts)` (línea 317) ejecuta 2 queries por cuenta; más abajo, el bucle que construye los gráficos anuales por cuenta (líneas 445-470) llama a `monthly_balance()` (4 queries) y `current_balance()` otra vez (2 queries) por cada cuenta. Con 5 cuentas son ~40 queries solo para calcular saldos.
**Dónde**: `expenses/views.py`, función `dashboard` (líneas 288-514).
**Por qué importa**: es la página más visitada probablemente, y el coste crece linealmente con el número de cuentas del usuario.
**Esfuerzo**: medio — requiere consolidar en queries anotadas agrupadas por cuenta o cachear los resultados dentro de la misma petición.
### 2.2 `Goal.progress()` recalculado 3-5 veces por objetivo sin memoizar (Alto impacto / Esfuerzo bajo)
**Qué**: `percentage()`, `progress_state()`, `bar_width()` e `is_exceeded()` llaman todos a `progress()` (que hace una query de agregación), sin ningún cacheo. Las plantillas (`goals/list.html`, `home.html`, `dashboard.html`) llaman a varios de estos métodos por objetivo en el mismo render, así que con 10 objetivos son del orden de 50 queries que podrían ser 10.
**Dónde**: `expenses/models.py`, clase `Goal` (líneas 360-409); consumido en `templates/goals/list.html`, `templates/expenses/home.html`, `templates/expenses/dashboard.html`.
**Por qué importa**: es la relación coste/beneficio más favorable de todo el informe — un cambio pequeño y de bajo riesgo con impacto de rendimiento alto.
**Esfuerzo**: bajo — memoizar `progress()` con `functools.cached_property` o un atributo cacheado en la instancia.
### 2.3 Duplicación sistemática en `views.py`: ownership lookup, create/edit, delete (Alto impacto / Esfuerzo alto)
**Qué**: el patrón `get_object_or_404(Model, pk=pk, owner=request.user)` se repite 15 veces en 14 vistas; el bloque `if POST: form.is_valid()... else: form = Form(...)` se repite en 13 pares crear/editar; las 7 vistas de borrado comparten la misma forma (fetch → si POST borra+mensaje+redirect → si no, renderiza confirmación), con solo 3 excepciones reales (`account_delete` hace soft-delete, `category_delete` captura `ProtectedError`, `fuel_delete`/`fuel_edit` usan `_redirect_back` y lógica de `next`).
**Dónde**: `expenses/views.py` (todas las vistas de tags/cuentas/ingresos/categorías/objetivos/fuel).
**Por qué importa**: mucho código para mantener sincronizado; cada vista nueva copia el mismo patrón a mano, con riesgo de que alguna se olvide de filtrar por `owner` (justo lo que CLAUDE.md pide evitar).
**Esfuerzo**: alto — migrar a `ListView`/`CreateView`/`UpdateView`/`DeleteView` con un mixin de scoping por owner reduciría el archivo en unas 300-400 líneas, pero implica tocar 20+ vistas, sus URLs y validar que las plantillas sigan recibiendo el mismo contexto.
### 2.4 Patrón `user` duplicado 5 veces en `forms.py`, con inconsistencia `pop` (Medio impacto / Esfuerzo bajo)
**Qué**: `ExpenseForm`, `IncomeForm`, `FuelEntryForm`, `CategoryForm` y `GoalForm` repiten el mismo `__init__` que extrae `user` de `kwargs` y filtra querysets, sin una clase base común. Además, `ExpenseForm` usa `kwargs.pop("user", None)` (con default) mientras las otras 4 usan `kwargs.pop("user")` (lanza `KeyError` si alguien olvida pasar `user=`).
**Dónde**: `expenses/forms.py` (líneas 7-19, 56-62, 76-86, 94-106, 141-150).
**Por qué importa**: 5 copias del mismo código, y una inconsistencia que puede provocar un error confuso (`KeyError` en vez de un mensaje claro) si se instancia un formulario sin pasar `user`.
**Esfuerzo**: bajo — una clase base `OwnerScopedForm` con un método hook (`scope_querysets(user)`) resuelve ambos problemas de una vez.
### 2.5 `select_related`/`prefetch_related` casi ausentes (Medio impacto / Esfuerzo bajo-medio)
**Qué**: solo se usan en 2 de ~37 vistas (`home` y `fuel_list`, y este último solo para `expense`, no evita el N+1 de `km_since_previous()` que sigue haciendo una query por cada repostaje al iterar en `fuel_list`, líneas 785-789).
**Dónde**: `expenses/views.py`.
**Por qué importa**: cualquier vista que muestre listas con relaciones (categoría, cuenta, tags) es candidata a N+1 silencioso.
**Esfuerzo**: bajo-medio — revisar listado por listado y añadir `select_related`/`prefetch_related` donde corresponda.
### 2.6 `category_list` mezcla listado y creación, inconsistente con el resto de modelos (Bajo-medio impacto / Esfuerzo bajo)
**Qué**: es la única vista donde crear un objeto ocurre dentro de la misma función/URL que el listado (no existe `category_create`), mientras que cuentas/etiquetas/ingresos/objetivos tienen una vista y URL de creación separada.
**Dónde**: `expenses/views.py` (líneas 871-893), `expenses/urls.py`.
**Por qué importa**: inconsistencia estructural que puede confundir al añadir una nueva funcionalidad similar (¿sigo el patrón de categorías o el de todo lo demás?).
**Esfuerzo**: bajo — separar en `category_create` + URL propia, o documentar que es intencional.
### 2.7 Query de `Tag` duplicada en `expense_list` (Bajo impacto / Esfuerzo bajo)
**Qué**: `Tag.objects.filter(owner=request.user)` se ejecuta una vez para construir `tags_with_state` (línea 168) y otra vez para el contexto (línea 206), en vez de reutilizar la misma lista.
**Dónde**: `expenses/views.py`, función `expense_list`.
**Por qué importa**: query redundante, fácil de eliminar.
**Esfuerzo**: bajo.
### 2.8 Organización de carpetas de plantillas inconsistente (Bajo impacto / Esfuerzo bajo)
**Qué**: `categories/`, `fuel/` y `goals/` tienen su propia carpeta de plantillas, pero cuentas/etiquetas/ingresos/gastos están todos mezclados en `templates/expenses/` junto con `dashboard.html`/`home.html`.
**Dónde**: `expenses/templates/`.
**Por qué importa**: no hay un criterio claro; dificulta encontrar una plantilla si no se conoce ya la excepción histórica.
**Esfuerzo**: bajo — mover plantillas a carpetas por función, actualizando las rutas en las vistas.
### 2.9 Import muerto en `models.py` (Bajo impacto / Esfuerzo muy bajo)
**Qué**: `from django.db.models.fields import related` (línea 6) no se usa en ningún sitio del archivo.
**Dónde**: `expenses/models.py:6`.
**Por qué importa**: limpieza trivial, cero riesgo.
**Esfuerzo**: muy bajo.
---
## 3. Mantenibilidad
### 3.1 CLAUDE.md desactualizado respecto al código actual (Alto impacto / Esfuerzo bajo)
**Qué**: el documento afirma que `Goal.progress()` agrega "across all time, not just the current period" — ya no es cierto para objetivos de tipo `budget` con periodo mensual/anual (el modelo actual sí reinicia por periodo). También menciona un diccionario `MONTHS` en `views.py` que no existe (solo hay una lista inline `["Ene", "Feb", ...]` dentro de `dashboard`). Y describe `DATABASE_URL` como variable de entorno cuando `settings.py` en realidad lee `DB_ENGINE`/`DB_NAME`/`DB_USER`/etc. por separado.
**Dónde**: `expenses_manager/CLAUDE.md` (raíz del repo).
**Por qué importa**: es el primer documento que se lee (por ti dentro de 6 meses o por cualquier asistente/colaborador); si describe un comportamiento que ya cambió, genera confusión activa en vez de ayudar.
**Esfuerzo**: bajo — actualizar esos 3 puntos concretos.
### 3.2 README.md no corresponde al proyecto real (Alto impacto / Esfuerzo bajo-medio)
**Qué**: el README (91 líneas) instruye a ejecutar `python app.py` y visitar `localhost:5000` (estilo Flask), y documenta un `Dockerfile`/`docker build`/`docker run` que no existen en el repo. También menciona una variable `DATABASE_URL=sqlite:///expenses.db` que el proyecto no usa.
**Dónde**: `README.md` (raíz del repo).
**Por qué importa**: es la primera impresión para cualquier colaborador nuevo, y ahora mismo activamente engaña en vez de guiar — probablemente boilerplate nunca adaptado al proyecto real.
**Esfuerzo**: bajo-medio — reescribir con los pasos reales (`manage.py runserver`, variables de entorno correctas, sin Docker si no existe).
### 3.3 `views.py` con ~1036 líneas / 37 funciones cubriendo 8 áreas (Alto impacto / Esfuerzo medio-alto)
**Qué**: todas las vistas de gastos, dashboard, tags, cuentas, ingresos, fuel, categorías y objetivos viven en un único archivo, sin más separación que líneas en blanco.
**Dónde**: `expenses/views.py`.
**Por qué importa**: a partir de ~600 líneas o 15-20 vistas ya cuesta encontrar algo sin buscar por texto; a 1036 líneas es claramente el punto de dolor del proyecto. Los puntos de corte por funcionalidad ya son evidentes en el propio código.
**Esfuerzo**: medio-alto — dividir en un paquete `views/` (`dashboard.py`, `expenses.py`, `goals.py`, `fuel.py`, `accounts.py`, `income.py`, `tags.py`, `categories.py`, `_utils.py`), actualizando `urls.py` en consecuencia. Se puede hacer de forma incremental, un módulo a la vez, sin romper nada.
### 3.4 Función `dashboard` sobrecargada de responsabilidades (Alto impacto / Esfuerzo medio-alto)
**Qué**: 226 líneas que mezclan 5 responsabilidades (resolución de periodo, selección de cuenta/KPI, agregación por categoría/día/mes, modo comparación, gráficos anuales por cuenta), con un diccionario de contexto de 35 claves construido inline, dos variables de nombre parecido (`expenses` vs `expenses_filtered`) que conviven en todo el ámbito de la función, y un `except Exception` silencioso (líneas 447-455) que sustituye cualquier error por datos vacíos, ocultando posibles problemas reales de datos.
**Dónde**: `expenses/views.py`, función `dashboard` (líneas 288-514).
**Por qué importa**: es la función más difícil de seguir del proyecto; modificar cualquier KPI implica leer las 226 líneas para no chocar con otra parte.
**Esfuerzo**: medio-alto — extraer en funciones privadas nombradas (`_resolve_period`, `_build_comparison`, `_build_account_charts`) y agrupar el contexto en sub-diccionarios (`kpis`, `comparison`, `charts`).
### 3.5 `Goal.progress()`/`_period_start()` conceptualmente sobrecargado (Medio impacto / Esfuerzo bajo)
**Qué**: en 30 líneas se resuelven 3 comportamientos distintos según `kind` (ahorro = saldo de cuenta; pago = gasto acumulado desde `start_date` sin reinicio; presupuesto = gasto del periodo actual), con "caídas" silenciosas (si `kind` no es `budget`, `_period_start()` simplemente devuelve `self.start_date`, que puede ser `None`) y sin docstring que explique el reparto de responsabilidades.
**Dónde**: `expenses/models.py`, líneas 349-409.
**Por qué importa**: fácil de malinterpretar sin cruzar mentalmente `KIND_CHOICES`/`PERIOD_CHOICES` y la validación de `GoalForm.clean()`.
**Esfuerzo**: bajo — añadir un docstring explicando los 3 casos, o separar en métodos privados por `kind` (`_progress_saving()`, `_progress_payment()`, `_progress_budget()`).
### 3.6 `gunicorn` declarado en `requirements.txt` pero no instalado en el venv (Medio impacto / Esfuerzo bajo)
**Qué**: mismo patrón que se encontró antes con `whitenoise` (ya resuelto). `gunicorn==25.1.0` está en `requirements.txt` pero no existe en `venv/Lib/site-packages`, y no hay `Procfile` ni `Dockerfile` que muestre dónde se usaría realmente. `pytest-cov>=4.0` es además la única dependencia sin pin exacto en un archivo que fija versión exacta en todo lo demás.
**Dónde**: `requirements.txt` (raíz del proyecto Django).
**Por qué importa**: mismo tipo de deriva silenciosa que causó el fallo de `whitenoise`; vale la pena revisar todas las dependencias de una vez.
**Esfuerzo**: bajo — `pip install -r requirements.txt` y confirmar cuáles hacen falta realmente en dev vs producción.
### 3.7 Comentarios en dos idiomas sin criterio (Bajo-medio impacto / Esfuerzo bajo)
**Qué**: los comentarios de `models.py` están en español (`# Para pago y presupuesto`) y los de `views.py` en inglés (`# Time presets`, `# Comparison`), sin ninguna razón aparente para la diferencia entre archivos.
**Dónde**: `expenses/models.py`, `expenses/views.py`.
**Por qué importa**: obliga a cambiar de idioma mentalmente al leer ambos archivos seguidos; sería bueno fijar una convención (aunque sea "comentarios en español, identificadores en inglés", que es lo que ya se hace en el resto del proyecto).
**Esfuerzo**: bajo — no urge reescribir todo, pero conviene aplicar la convención elegida a partir de ahora.
### 3.8 No existe `.env.example` (Medio impacto / Esfuerzo bajo)
**Qué**: solo existe el `.env` real; un colaborador nuevo tiene que deducir las variables necesarias (`SECRET_KEY`, `DEBUG`, `ALLOWED_HOSTS`, `CSRF_TRUSTED_ORIGINS`, `DB_ENGINE`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`) leyendo `settings.py` directamente, ya que ni el README ni CLAUDE.md las listan completas.
**Dónde**: raíz del proyecto Django.
**Por qué importa**: fricción de onboarding evitable con un archivo de 10 líneas.
**Esfuerzo**: bajo.
### 3.9 Restos menores de limpieza (Bajo impacto / Esfuerzo muy bajo)
**Qué**: comentario de linter obsoleto `# sourcery skip: assign-if-exp, merge-else-if-into-elif` en `views.py:240`; mezcla de comillas simples/dobles en `GoalForm.Meta.fields` (`forms.py:125-134`); `verbose_name_plural = "categories"` en inglés dentro de un modelo con todo lo demás en español (afecta solo al admin de Django).
**Dónde**: `expenses/views.py:240`, `expenses/forms.py:125-134`, `expenses/models.py:31`.
**Por qué importa**: ruido cosmético, cero riesgo, fácil de arrastrar sin darse cuenta a nuevo código.
**Esfuerzo**: muy bajo.

90
CLAUDE.md Normal file
View File

@ -0,0 +1,90 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Repository layout
This git repo has a nested directory structure: the Django project root is `expenses_manager/` (contains `manage.py`), and inside that lives the `expenses_manager/` settings package plus the `expenses` app. So paths look like:
```
expenses_manager/ <- repo root
expenses_manager/ <- Django project root (manage.py here)
expenses_manager/ <- settings package (settings.py, urls.py, wsgi/asgi)
expenses/ <- the single Django app with all models/views/forms
pytest.ini
requirements.txt
Jenkinsfile
```
All commands below assume you're in `expenses_manager/expenses_manager/` (the directory with `manage.py`).
## Commands
```bash
# Install deps (a conda env or any virtualenv works)
pip install -r requirements.txt
# Run the dev server
python manage.py runserver
# Run all tests (pytest, not manage.py test)
pytest
# Run a single test file / test
pytest expenses/tests/test_dashboard.py
pytest expenses/tests/test_goals.py::test_budget_is_exceeded
# Run with coverage (what CI/Jenkins runs)
pytest --cov
# Migrations
python manage.py makemigrations
python manage.py migrate
# Seed demo data (guarded: only runs with DEBUG=True)
python manage.py seed_demo
```
Tests use `pytest-django`; `pytest.ini` sets `DJANGO_SETTINGS_MODULE=expenses_manager.settings`. There is a shared `expenses/tests/conftest.py` with fixtures (`user`, `auth_client`, `account`, `category`). Tests needing DB access must be marked `@pytest.mark.django_db` (or set `pytestmark = pytest.mark.django_db` at module level).
## Configuration & environment
Settings are driven entirely by environment variables read from a `.env` file (via `python-dotenv`) next to `manage.py`. A single `settings.py` serves both local dev and production; behaviour switches on `DEBUG`:
- `SECRET_KEY` — required when `DEBUG=False` (the app raises `ImproperlyConfigured` and refuses to start if missing). With `DEBUG=True` it falls back to an insecure dev key.
- `DEBUG` — defaults to `False`. When `True`, SQLite is used by default and the production hardening block (secure cookies, SSL redirect, proxy header) is skipped.
- Database is selected by `DB_ENGINE`: `postgresql` uses Postgres with `DB_NAME`/`DB_USER`/`DB_PASSWORD`/`DB_HOST`/`DB_PORT`; any other value (or unset) falls back to SQLite. **There is no `DATABASE_URL`** — the DB config uses these separate variables.
- `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` are comma-separated env vars.
See `.env.example` for the full list. CI (Jenkins) sets `DEBUG=True` and a throwaway `SECRET_KEY` in its `environment` block so tests run against SQLite without the hardening block.
## Architecture
This is a single-app Django project (`expenses`) — there is no REST API or frontend build step; views render server-side Django templates directly. All views live in one `expenses/views.py`, forms in one `expenses/forms.py`, models in one `expenses/models.py`.
**Ownership model**: every domain model (`Category`, `Account`, `Tag`, `Expense`, `Income`, `Goal`) has an `owner` FK to `settings.AUTH_USER_MODEL`. Every view filters querysets by `owner=request.user` — there is no shared/global data between users. When adding a new view or form, follow this same pattern (filter querysets by owner, scope `get_object_or_404` by owner too) to avoid leaking data across accounts.
**Core models** (`expenses/models.py`):
- `Account` — holds `initial_balance` and computes running balances. `current_balance()` sums all incomes/expenses against the initial balance. `monthly_balance(year)` builds a running-balance series per month for charts, and patches the current month's entry with the live `current_balance()` so today's number is always exact. `balance_until(date)` and `monthly_net(year)` support other reporting views (both use aggregated queries, not month-by-month loops). Accounts are soft-deleted (`active=False`), never physically removed, to preserve historical balances; inactive accounts are filtered out of form querysets but shown dimmed in the account list.
- `Expense` — belongs to an `Account` and a `Category` (both `PROTECT` on delete, so you can't delete a Category/Account that has expenses), optionally tagged with `Tag` (M2M).
- `Income` — same shape as Expense but simpler (no category/tags).
- `FuelEntry` — a `OneToOneField` extension of `Expense` for tracking car fuel fill-ups (odometer, liters); `fuel_create`/`fuel_edit`/`fuel_delete` views create/update/delete the paired `Expense` + `FuelEntry` together (delete cascades via the OneToOne), auto-assigning a "Gasolina" category scoped to the owner via `get_or_create`. `FuelEntryForm` is a `ModelForm` of `Expense` plus the `FuelEntry`-specific fields.
- `Goal` — a target with a `kind` field (`payment`, `budget`, or `saving`):
- `payment` (debt/repayment) and `budget` aggregate expenses in a `Category` (optionally including subcategories via `include_subcategories` + `Category.descendant_ids()`).
- `budget` resets per `period` (`month`/`year`): only the current period counts, and `is_exceeded()` flags going over.
- `saving` measures progress via the associated `Account`'s balance (provisional — this is the branch to change when an investments module is added).
- `progress` is a **`cached_property`** (accessed as `goal.progress`, no parentheses), memoized per instance because `percentage()`/`bar_width()`/`progress_state()`/`is_exceeded()` all derive from it. `progress()` is scoped by `_period_start()`, not aggregated across all time.
- `Category` — supports self-referential `parent` for subcategories; `descendant_ids()` returns a category plus all its descendants. Forms must scope the `parent` queryset by `owner` and, when editing, exclude the category itself and its descendants to prevent cycles (there was a past bug where parent categories leaked across users, and another where `clean_parent` silently dropped the value — see git history). Full CRUD exists (`category_edit`/`category_delete`); deletion catches `ProtectedError` when the category has expenses.
**Views/forms convention**: forms that need to scope choice fields by user (`category`, `account`, `tags`, `parent`) accept a `user` kwarg in `__init__` and filter querysets there, rather than doing it in the view. Views pass `user=request.user` when instantiating these forms both on GET and POST. Delete views require POST and render a confirmation template on GET (pattern established across `tag_delete`, `goal_delete`, etc.).
**Dashboard view** (`expenses/views.py:dashboard`) is the most complex view: it supports period presets (`this_month`/`last_month`/`this_year`), per-account filtering, and an optional prior-period comparison (`compare=1`) that diffs current vs. previous period totals overall and by category. It also builds per-account annual balance chart data via `Account.monthly_balance()`. Note: this view is large (~200+ lines) and is a known refactor target.
**Templates**: organized by feature under `expenses/templates/``expenses/` (expense/account/income/tag views + dashboard/home), plus separate `categories/`, `fuel/`, `goals/`, `settings/`, `registration/` subfolders. Template naming is inconsistent by folder: files under `expenses/templates/expenses/` use a resource prefix (`account_list.html`, `tag_confirm_delete.html`), while `categories/`, `goals/`, `fuel/` use unprefixed names (`list.html`, `form.html`, `confirm_delete.html`) — check the target folder before naming a new template. UI strings are in Spanish; keep that consistent when touching UI code.
**Auth**: uses Django's built-in `django.contrib.auth` views/urls (login/logout/password change) mounted at `/accounts/`. The app's own financial-account CRUD lives under `/finance-accounts/` (renamed from `/accounts/` to avoid colliding with the auth namespace). There is no password-reset-by-email flow by design: resets are done by the admin via `manage.py changepassword`, and a static `registration/password_help.html` page (route `password-help/`) tells users to contact the admin. `LOGIN_URL`/`LOGIN_REDIRECT_URL`/`LOGOUT_REDIRECT_URL` are set in settings. All app views are decorated with `@login_required`.
## Workflow
- Work happens on the **`dev`** branch; **`main`** only receives tested merges. Jenkins runs the test suite on `main`.
- Migrations are generated in development and committed; never run `makemigrations` in production (it would create migrations not in git). Only `migrate` runs on the NAS.

View File

@ -0,0 +1,17 @@
# Generated by Django 5.2.10 on 2026-09-09 11:54
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('expenses', '0010_goal_account_goal_include_subcategories_goal_kind_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='tag',
options={'ordering': ('name',)},
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 5.2.10 on 2026-09-09 12:59
import django.db.models.functions.text
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('expenses', '0011_alter_tag_options'),
]
operations = [
migrations.AlterModelOptions(
name='account',
options={'ordering': [django.db.models.functions.text.Lower('name')]},
),
migrations.AlterModelOptions(
name='category',
options={'ordering': [django.db.models.functions.text.Lower('name')], 'verbose_name_plural': 'categories'},
),
migrations.AlterModelOptions(
name='tag',
options={'ordering': [django.db.models.functions.text.Lower('name')]},
),
]

View File

@ -5,7 +5,7 @@ from django.conf import settings
from django.db.models import Sum
from functools import cached_property
from django.utils.text import slugify
from django.db.models.functions import ExtractMonth
from django.db.models.functions import ExtractMonth, Lower
class Category(models.Model):
@ -29,7 +29,7 @@ class Category(models.Model):
class Meta:
unique_together = ("name", "parent", "owner", "slug")
verbose_name_plural = "categories"
ordering = ["name"]
ordering = [Lower("name")]
def __str__(self):
return self.name
@ -71,7 +71,7 @@ class Account(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["name"]
ordering = [Lower("name")]
def current_balance(self):
expenses_total = self.expenses.aggregate(total=Sum("amount"))[
@ -193,6 +193,7 @@ class Tag(models.Model):
class Meta:
unique_together = ("name", "owner")
ordering = [Lower("name")]
def __str__(self):
return self.name

View File

@ -367,8 +367,23 @@ button[type="submit"]:hover,
.filters-main {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
gap: 0.5rem;
}
.filters-main select {
padding: 0.5rem 0.7rem;
font-family: inherit;
font-size: 0.95rem;
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 6px;
}
.filters-main select:focus {
outline: 2px solid var(--color-focus-ring);
outline-offset: 1px;
}
.filters-advanced {
@ -638,6 +653,12 @@ tr:hover {
margin-top: 0.5rem;
}
.cat-depth-0 { padding-left: 0; }
.cat-depth-1 { padding-left: 1.5rem; }
.cat-depth-2 { padding-left: 3rem; }
.cat-depth-3 { padding-left: 4.5rem; }
.cat-depth-4 { padding-left: 6rem; }
.pagination {
margin-top: 1rem;
}
@ -694,10 +715,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;
@ -771,17 +841,14 @@ tr:hover {
transition: width 0.3s ease-in-out;
}
.progress-fill.low,
.progress-fill.danger {
background-color: var(--color-danger-accent);
}
.progress-fill.medium,
.progress-fill.warning {
background-color: var(--color-warning-accent);
}
.progress-fill.high,
.progress-fill.complete {
background-color: var(--color-success-accent);
}
@ -860,6 +927,116 @@ 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;
margin-bottom: 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 */
/* ========================= */
@ -931,3 +1108,36 @@ tr:hover {
height: 260px;
}
}
.info-panel {
margin-bottom: 1.5rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface-alt);
}
.info-panel summary {
padding: 0.75rem 1rem;
cursor: pointer;
font-weight: 500;
}
.info-panel-content {
padding: 0 1rem 1rem;
}
.info-panel-content h2 {
margin: 1rem 0 0.25rem;
font-size: 1rem;
}
.info-panel-content p,
.info-panel-content ul {
margin: 0.25rem 0;
font-size: 0.9rem;
color: var(--color-text-muted);
}
.info-panel-content ul {
padding-left: 1.2rem;
}

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

@ -9,9 +9,9 @@
<h1>Mis categorías</h1>
<h3>Nueva categoría</h3>
<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">Crear</button>
</form>
@ -23,17 +23,24 @@
<thead>
<tr>
<th>Categoría</th>
<th>Categoría padre</th>
<th>Gastos</th>
<th></th>
</tr>
</thead>
<tbody>
{% for category in categories %}
{% for row in category_rows %}
<tr>
<td>{{ category.name }}</td>
<td>{% if category.parent %}{{ category.parent.name }}{% endif %}</td>
<td class="cat-depth-{{ row.depth }}">{{ row.category.name }}</td>
<td>{{ row.category.expense_count }}</td>
<td class="table-actions">
<a href="{% url 'category_edit' category.id %}">Editar</a>
<a href="{% url 'category_delete' category.id %}" class="danger">Eliminar</a>
<a href="{% url 'category_edit' row.category.id %}">Editar</a>
<a href="{% url 'category_delete' row.category.id %}" class="danger">Eliminar</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="empty-state">
<p>No hay categorías</p>
</td>
</tr>
{% endfor %}

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

@ -18,22 +18,22 @@
</tr>
</thead>
<tbody>
{% for account in accounts %}
{% for row in account_rows %}
<tr>
<td>{{ account.name }}</td>
<td>{{ account.initial_balance }}</td>
<td>{{ account.current_balance|floatformat:2 }}</td>
<td>{{ row.account.name }}</td>
<td>{{ row.account.initial_balance }}</td>
<td>{{ row.balance|floatformat:2 }}</td>
<td>
{% if account.active %}
{% if row.account.active %}
<span class="badge badge-active">Activa</span>
{% else %}
<span class="badge badge-inactive">Inactiva</span>
{% endif %}
</td>
<td class="table-actions">
<a href="{% url 'account_edit' account.id %}">Editar</a>
{% if account.active %}
<a href="{% url 'account_delete' account.id %}" class="danger">Eliminar</a>
<a href="{% url 'account_edit' row.account.id %}">Editar</a>
{% if row.account.active %}
<a href="{% url 'account_delete' row.account.id %}" class="danger">Eliminar</a>
{% endif %}
</td>
</tr>

View File

@ -428,11 +428,7 @@
aria-valuenow="{{ goal.percentage|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
{% if goal.percentage < 50 %} low
{% elif goal.percentage < 80 %} medium
{% else %} high
{% endif %}"
<div class="progress-fill {{ goal.progress_state }}"
style="width: {{ goal.bar_width|unlocalize }}%"></div>
</div>

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,10 +161,10 @@
applyCartesianColors(chart, colors);
});
</script>
</section>
<h3>Objetivos</h3>
<section class="home-section">
<h2>Objetivos</h2>
{% if goals %}
<div class="goals-widget">
{% for goal in goals %}
@ -92,7 +174,13 @@
<span class="badge badge-inactive">Excedido</span>
{% endif %}
<div class="progress-bar">
<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>
@ -104,7 +192,8 @@
{% endfor %}
</div>
{% else %}
<p>No tienes objetivos aún.</p>
<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

@ -5,15 +5,34 @@
<a class="btn" href="{% url 'tag_create' %}"> Nueva etiqueta</a>
<ul>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Nombre</th>
<th>Gastos</th>
<th></th>
</tr>
</thead>
<tbody>
{% for tag in tags %}
<li class="table-actions">
{{ tag.name }}
<tr>
<td>{{ tag.name }}</td>
<td>{{ tag.expense_count }}</td>
<td class="table-actions">
<a href="{% url 'tag_edit' tag.id %}">Editar</a>
<a href="{% url 'tag_delete' tag.id %}" class="danger">Eliminar</a>
</li>
</td>
</tr>
{% empty %}
<li>No hay etiquetas</li>
<tr>
<td colspan="3" class="empty-state">
<p>No hay etiquetas</p>
<a href="{% url 'tag_create' %}">Añade la primera</a>
</td>
</tr>
{% endfor %}
</ul>
</tbody>
</table>
</div>
{% endblock %}

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,74 @@
{% extends "expenses/base.html" %}
{% block title %}
Nuevo objetivo
{{ title }}
{% endblock %}
{% block content %}
<h1>
Nuevo objetivo
{{ title }}
</h1>
<form method="post">
<details class="info-panel">
<summary>¿Qué tipo de objetivo necesito?</summary>
<div class="info-panel-content">
<h2>Pago / deuda</h2>
<p>
Para algo que quieres terminar de pagar: devolver un préstamo,
costear un viaje a plazos, reunir el importe de una compra grande.
El progreso es la suma de los gastos de la categoría elegida desde
la fecha de inicio, y llegar al 100% es haberlo conseguido.
</p>
<h2>Presupuesto</h2>
<p>
Para ponerte un límite de gasto que no quieres superar. El progreso
se reinicia cada mes o cada año, según el periodo que elijas.
Aquí acercarse al 100% es un aviso, no un logro: la barra pasa a
naranja al llegar al 80% y a rojo si te pasas del límite.
</p>
<h2>Ahorro</h2>
<p>
Para acumular dinero hasta una cantidad. Se mide con el saldo de la
cuenta que asocies, así que necesita una cuenta en vez de una
categoría.
</p>
<h2>Los demás campos</h2>
<ul>
<li>
<strong>Category</strong> — solo para pago y presupuesto: es la
categoría cuyos gastos se cuentan.
</li>
<li>
<strong>Include subcategories</strong> — si se marca, también
cuentan los gastos de las categorías hijas.
</li>
<li>
<strong>Account</strong> — solo para ahorro: la cuenta cuyo saldo
mide el progreso.
</li>
<li>
<strong>Start date</strong> — desde cuándo se cuentan los gastos
en un pago. En un presupuesto no se usa: manda el reinicio del
periodo.
</li>
<li>
<strong>Period</strong> — solo para presupuesto: cada cuánto
vuelve el progreso a cero.
</li>
<li>
<strong>Show on home</strong> — si el objetivo aparece en la
pantalla de inicio.
</li>
</ul>
</div>
</details>
<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

@ -9,17 +9,38 @@
{% block content %}
<h1>Objetivos</h1>
<div class="section-actions">
<a class="btn btn-primary" href="{% url 'goal_create' %}"> Nuevo objetivo</a>
</div>
<form method="get" class="filters">
<div class="filters-main">
<label class="sr-only" for="filterKind">Tipo</label>
<select name="kind" id="filterKind" onchange="this.form.submit()">
<option value="">Todos los tipos</option>
{% for value, label in kind_choices %}
<option value="{{ value }}" {% if selected_kind == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary">Filtrar</button>
<a href="{% url 'goal_list' %}" class="btn btn-secondary">Limpiar</a>
</div>
</form>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Nombre</th>
<th>Tipo</th>
<th>Progreso</th>
<th></th>
</tr>
</thead>
<tbody>
{% for goal in goals %}
<tr>
<td>{{ goal.name }}</td>
@ -53,12 +74,20 @@
</div>
</td>
<td>
<td class="table-actions">
<a href="{% url 'goal_edit' goal.id %}">Editar</a>
<a href="{% url 'goal_delete' goal.id %}" class="danger">Eliminar</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="empty-state">
<p>No hay objetivos</p>
<a href="{% url 'goal_create' %}">Añade el primero</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>

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 pytest
from datetime import date
from decimal import Decimal
from django.urls import reverse
from expenses.models import Account, Expense, Income, Category
pytestmark = pytest.mark.django_db
@ -84,3 +85,17 @@ def test_balance_until_only_counts_entries_on_or_before_date(user, category):
Income.objects.create(owner=user, account=acc, name="Later", amount=Decimal("50"), date=date(2024, 1, 20))
assert acc.balance_until(date(2024, 1, 15)) == Decimal("-25")
def test_account_list_shows_all_accounts_with_computed_balances(auth_client, user, category, django_assert_max_num_queries):
active = Account.objects.create(owner=user, name="Activa", initial_balance=Decimal("100"))
Account.objects.create(owner=user, name="Inactiva", initial_balance=Decimal("50"), active=False)
Income.objects.create(owner=user, account=active, name="Nomina", amount=Decimal("20"), date=date.today())
Expense.objects.create(owner=user, account=active, category=category, amount=Decimal("5"), date=date.today())
with django_assert_max_num_queries(10):
response = auth_client.get(reverse('account_list'))
assert response.status_code == 200
rows = {row["account"].name: row["balance"] for row in response.context["account_rows"]}
assert rows == {"Activa": Decimal("115"), "Inactiva": Decimal("50")}

View File

@ -99,3 +99,43 @@ def test_category_delete_without_expenses_succeeds(auth_client, user):
assert response.status_code == 302
assert not Category.objects.filter(pk=cat.pk).exists()
def test_category_list_view_returns_tree_ordered_with_depth(auth_client, user):
root_a = Category.objects.create(name="A raiz", owner=user)
Category.objects.create(name="B raiz", owner=user)
child = Category.objects.create(name="Hijo", owner=user, parent=root_a)
Category.objects.create(name="Nieto", owner=user, parent=child)
response = auth_client.get(reverse('category_list'))
rows = [(row["category"].name, row["depth"]) for row in response.context["category_rows"]]
assert rows == [
("A raiz", 0),
("Hijo", 1),
("Nieto", 2),
("B raiz", 0),
]
def test_category_list_counts_only_direct_expenses(auth_client, user, account):
parent = Category.objects.create(name="Padre", owner=user)
child = Category.objects.create(name="Hija", owner=user, parent=parent)
Expense.objects.create(
owner=user, account=account, category=parent,
amount=Decimal("10"), date=date.today(),
)
for _ in range(2):
Expense.objects.create(
owner=user, account=account, category=child,
amount=Decimal("5"), date=date.today(),
)
response = auth_client.get(reverse('category_list'))
counts = {
row["category"].name: row["category"].expense_count
for row in response.context["category_rows"]
}
assert counts == {"Padre": 1, "Hija": 2}

View File

@ -1,6 +1,7 @@
import pytest
from datetime import date
from decimal import Decimal
from django.urls import reverse
from expenses.models import Account, Category, Expense, Goal
from expenses.forms import GoalForm
@ -194,3 +195,23 @@ def test_goalform_valid_payment_saves_goal(user, category):
goal.save()
assert Goal.objects.filter(pk=goal.pk, name="Ahorro viaje", kind=Goal.KIND_PAYMENT, category=category).exists()
def test_goal_list_filters_by_kind(auth_client, user, category):
Goal.objects.create(owner=user, name="Pago", target_amount=Decimal("100"), kind=Goal.KIND_PAYMENT, category=category)
Goal.objects.create(owner=user, name="Presupuesto", target_amount=Decimal("200"), kind=Goal.KIND_BUDGET, category=category, period=Goal.PERIOD_MONTH)
response = auth_client.get(reverse('goal_list'), {"kind": Goal.KIND_BUDGET})
assert [g.name for g in response.context["goals"]] == ["Presupuesto"]
assert response.context["selected_kind"] == Goal.KIND_BUDGET
def test_goal_list_ignores_invalid_kind_param(auth_client, user, category):
Goal.objects.create(owner=user, name="Pago", target_amount=Decimal("100"), kind=Goal.KIND_PAYMENT, category=category)
response = auth_client.get(reverse('goal_list'), {"kind": "bogus"})
assert response.status_code == 200
assert [g.name for g in response.context["goals"]] == ["Pago"]
assert response.context["selected_kind"] == ""

View File

@ -0,0 +1,32 @@
import pytest
from datetime import date
from decimal import Decimal
from django.urls import reverse
from expenses.models import Expense, Tag
pytestmark = pytest.mark.django_db
def test_tag_list_annotates_expense_usage_count(auth_client, user, account, category):
used = Tag.objects.create(owner=user, name="Usada")
unused = Tag.objects.create(owner=user, name="Sin usar")
for _ in range(2):
expense = Expense.objects.create(
owner=user, account=account, category=category, amount=Decimal("10"), date=date.today()
)
expense.tags.add(used)
response = auth_client.get(reverse('tag_list'))
counts = {tag.name: tag.expense_count for tag in response.context["tags"]}
assert counts == {"Usada": 2, "Sin usar": 0}
def test_tag_ordering_ignores_case(user):
for name in ["ZZ", "AA", "mk"]:
Tag.objects.create(owner=user, name=name)
names = list(Tag.objects.filter(owner=user).values_list("name", flat=True))
assert names == ["AA", "mk", "ZZ"]

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 (
@ -14,8 +15,8 @@ from .forms import (
)
from django.core.paginator import Paginator
from django.db.models import Sum, ProtectedError
from django.db.models.functions import ExtractMonth, ExtractYear, ExtractDay
from django.db.models import Sum, Count, ProtectedError
from django.db.models.functions import ExtractMonth, ExtractYear, ExtractDay, Lower
from django.contrib.auth.decorators import login_required
from django.utils.http import url_has_allowed_host_and_scheme
@ -56,23 +57,137 @@ def sub_months(year, month, n):
return year, month
def _category_tree(categories):
"""Aplana las categorías en preorden (padres antes que hijos, cada nivel
ordenado por nombre) junto con su profundidad, para poder indentarlas."""
by_parent = {}
for category in categories:
by_parent.setdefault(category.parent_id, []).append(category)
for children in by_parent.values():
children.sort(key=lambda c: c.name.lower())
rows = []
def walk(parent_id, depth):
for category in by_parent.get(parent_id, []):
rows.append({"category": category, "depth": depth})
walk(category.id, depth + 1)
walk(None, 0)
return rows
def _account_balances(accounts):
"""Calcula el saldo de cada cuenta con dos queries agregadas (nada de
account.current_balance() en bucle, que dispararía N+1)."""
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})
return account_balances, negative_accounts, total_balance
@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 ----
accounts = list(Account.objects.filter(owner=request.user, active=True))
account_balances, negative_accounts, total_balance = _account_balances(accounts)
# ---- 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 +201,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,
},
)
@ -520,7 +635,11 @@ def dashboard(request):
@login_required
def tag_list(request):
tags = Tag.objects.filter(owner=request.user)
tags = (
Tag.objects.filter(owner=request.user)
.annotate(expense_count=Count("expenses"))
.order_by(Lower("name"))
)
return render(
request, "expenses/tag_list.html", {"active_menu": "settings", "tags": tags}
@ -581,11 +700,12 @@ def tag_delete(request, pk):
@login_required
def account_list(request):
accounts = Account.objects.filter(owner=request.user)
accounts = list(Account.objects.filter(owner=request.user).order_by(Lower("name")))
account_rows, _, _ = _account_balances(accounts)
return render(
request,
"expenses/account_list.html",
{"active_menu": "accounts", "accounts": accounts},
{"active_menu": "accounts", "account_rows": account_rows},
)
@ -870,7 +990,11 @@ def fuel_delete(request, pk):
@login_required
def category_list(request):
categories = Category.objects.filter(owner=request.user)
categories = list(
Category.objects.filter(owner=request.user)
.annotate(expense_count=Count("expenses"))
.select_related("parent")
)
if request.method == "POST":
form = CategoryForm(request.POST, user=request.user)
@ -887,7 +1011,7 @@ def category_list(request):
"categories/list.html",
{
"active_menu": "settings",
"categories": categories,
"category_rows": _category_tree(categories),
"form": form,
},
)
@ -951,11 +1075,21 @@ def category_delete(request, pk):
def goal_list(request):
goals = Goal.objects.filter(owner=request.user)
selected_kind = request.GET.get("kind") or ""
if selected_kind in dict(Goal.KIND_CHOICES):
goals = goals.filter(kind=selected_kind)
else:
selected_kind = ""
goals = goals.order_by(Lower("name"))
return render(
request,
"goals/list.html",
{
"goals": goals,
"kind_choices": Goal.KIND_CHOICES,
"selected_kind": selected_kind,
"active_menu": "settings",
},
)

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 = [