Compare commits

..

No commits in common. "4eb120927b0f72ab5b198718c5a065e327ad5edd" and "b04a8db080e3bdb79bb63fa8c0af3dbbaf217189" have entirely different histories.

17 changed files with 118 additions and 747 deletions

View File

@ -1,181 +0,0 @@
# 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.

View File

@ -1,90 +0,0 @@
# 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

@ -1,17 +0,0 @@
# 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

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

View File

@ -367,23 +367,8 @@ button[type="submit"]:hover,
.filters-main { .filters-main {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center;
gap: 0.5rem; gap: 0.5rem;
} align-items: center;
.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 { .filters-advanced {
@ -653,12 +638,6 @@ tr:hover {
margin-top: 0.5rem; 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 { .pagination {
margin-top: 1rem; margin-top: 1rem;
} }
@ -841,14 +820,17 @@ tr:hover {
transition: width 0.3s ease-in-out; transition: width 0.3s ease-in-out;
} }
.progress-fill.low,
.progress-fill.danger { .progress-fill.danger {
background-color: var(--color-danger-accent); background-color: var(--color-danger-accent);
} }
.progress-fill.medium,
.progress-fill.warning { .progress-fill.warning {
background-color: var(--color-warning-accent); background-color: var(--color-warning-accent);
} }
.progress-fill.high,
.progress-fill.complete { .progress-fill.complete {
background-color: var(--color-success-accent); background-color: var(--color-success-accent);
} }
@ -944,7 +926,6 @@ tr:hover {
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
margin-top: 1.25rem; margin-top: 1.25rem;
margin-bottom: 1.25rem;
} }
.balance-total { .balance-total {
@ -1108,36 +1089,3 @@ tr:hover {
height: 260px; 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

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

View File

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

View File

@ -428,7 +428,11 @@
aria-valuenow="{{ goal.percentage|unlocalize }}" aria-valuenow="{{ goal.percentage|unlocalize }}"
aria-label="Progreso de {{ goal.name }}" aria-label="Progreso de {{ goal.name }}"
aria-valuetext="{{ goal.progress|floatformat:1 }}€ de {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%)"> aria-valuetext="{{ goal.progress|floatformat:1 }}€ de {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%)">
<div class="progress-fill {{ goal.progress_state }}" <div class="progress-fill
{% if goal.percentage < 50 %} low
{% elif goal.percentage < 80 %} medium
{% else %} high
{% endif %}"
style="width: {{ goal.bar_width|unlocalize }}%"></div> style="width: {{ goal.bar_width|unlocalize }}%"></div>
</div> </div>

View File

@ -5,34 +5,15 @@
<a class="btn" href="{% url 'tag_create' %}"> Nueva etiqueta</a> <a class="btn" href="{% url 'tag_create' %}"> Nueva etiqueta</a>
<div class="table-wrap"> <ul>
<table> {% for tag in tags %}
<thead> <li class="table-actions">
<tr> {{ tag.name }}
<th>Nombre</th> <a href="{% url 'tag_edit' tag.id %}">Editar</a>
<th>Gastos</th> <a href="{% url 'tag_delete' tag.id %}" class="danger">Eliminar</a>
<th></th> </li>
</tr> {% empty %}
</thead> <li>No hay etiquetas</li>
<tbody> {% endfor %}
{% for tag in tags %} </ul>
<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>
</td>
</tr>
{% empty %}
<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 %}
</tbody>
</table>
</div>
{% endblock %} {% endblock %}

View File

@ -8,64 +8,6 @@
{{ title }} {{ title }}
</h1> </h1>
<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"> <form method="post" class="app-form">
{% csrf_token %} {% csrf_token %}
{% include "expenses/_form_fields.html" %} {% include "expenses/_form_fields.html" %}

View File

@ -9,85 +9,56 @@
{% block content %} {% block content %}
<h1>Objetivos</h1> <h1>Objetivos</h1>
<div class="section-actions"> <a class="btn btn-primary" href="{% url 'goal_create' %}"> Nuevo objetivo</a>
<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"> <div class="table-wrap">
<table> <table>
<thead> <tr>
<tr> <th>Nombre</th>
<th>Nombre</th> <th>Tipo</th>
<th>Tipo</th> <th>Progreso</th>
<th>Progreso</th> <th></th>
<th></th> </tr>
</tr>
</thead>
<tbody>
{% for goal in goals %}
<tr>
<td>{{ goal.name }}</td>
<td>
{{ goal.get_kind_display }}
{% if goal.kind == "budget" %}
<small>({{ goal.get_period_display|lower }})</small>
{% endif %}
</td>
<td>
<div class="progress-container">
<span class="progress-label">
{{ goal.progress|floatformat:1 }}€ / {{ goal.target_amount|floatformat:1 }}€
</span>
<div class="progress-bar"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
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 {{ goal.progress_state }}"
style="width: {{ goal.bar_width|unlocalize }}%"></div>
</div>
<span class="progress-label">
{{ goal.percentage|floatformat:1 }}%
</span>
{% if goal.is_exceeded %}
<span class="badge badge-inactive">Excedido</span>
{% endif %}
</div>
</td>
<td class="table-actions"> {% for goal in goals %}
<a href="{% url 'goal_edit' goal.id %}">Editar</a> <tr>
<a href="{% url 'goal_delete' goal.id %}" class="danger">Eliminar</a> <td>{{ goal.name }}</td>
</td> <td>
</tr> {{ goal.get_kind_display }}
{% empty %} {% if goal.kind == "budget" %}
<tr> <small>({{ goal.get_period_display|lower }})</small>
<td colspan="4" class="empty-state"> {% endif %}
<p>No hay objetivos</p> </td>
<a href="{% url 'goal_create' %}">Añade el primero</a> <td>
</td> <div class="progress-container">
</tr> <span class="progress-label">
{% endfor %} {{ goal.progress|floatformat:1 }}€ / {{ goal.target_amount|floatformat:1 }}€
</tbody> </span>
<div class="progress-bar"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
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 {{ goal.progress_state }}"
style="width: {{ goal.bar_width|unlocalize }}%"></div>
</div>
<span class="progress-label">
{{ goal.percentage|floatformat:1 }}%
</span>
{% if goal.is_exceeded %}
<span class="badge badge-inactive">Excedido</span>
{% endif %}
</div>
</td>
<td>
<a href="{% url 'goal_edit' goal.id %}">Editar</a>
<a href="{% url 'goal_delete' goal.id %}" class="danger">Eliminar</a>
</td>
</tr>
{% endfor %}
</table> </table>
</div> </div>

View File

@ -1,7 +1,6 @@
import pytest import pytest
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from django.urls import reverse
from expenses.models import Account, Expense, Income, Category from expenses.models import Account, Expense, Income, Category
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@ -85,17 +84,3 @@ 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)) 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") 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,43 +99,3 @@ def test_category_delete_without_expenses_succeeds(auth_client, user):
assert response.status_code == 302 assert response.status_code == 302
assert not Category.objects.filter(pk=cat.pk).exists() 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,7 +1,6 @@
import pytest import pytest
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from django.urls import reverse
from expenses.models import Account, Category, Expense, Goal from expenses.models import Account, Category, Expense, Goal
from expenses.forms import GoalForm from expenses.forms import GoalForm
@ -195,23 +194,3 @@ def test_goalform_valid_payment_saves_goal(user, category):
goal.save() goal.save()
assert Goal.objects.filter(pk=goal.pk, name="Ahorro viaje", kind=Goal.KIND_PAYMENT, category=category).exists() 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

@ -1,32 +0,0 @@
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

@ -15,8 +15,8 @@ from .forms import (
) )
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db.models import Sum, Count, ProtectedError from django.db.models import Sum, ProtectedError
from django.db.models.functions import ExtractMonth, ExtractYear, ExtractDay, Lower from django.db.models.functions import ExtractMonth, ExtractYear, ExtractDay
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.utils.http import url_has_allowed_host_and_scheme from django.utils.http import url_has_allowed_host_and_scheme
@ -57,29 +57,34 @@ def sub_months(year, month, n):
return year, month return year, month
def _category_tree(categories): @login_required
"""Aplana las categorías en preorden (padres antes que hijos, cada nivel def home(request):
ordenado por nombre) junto con su profundidad, para poder indentarlas.""" today = date.today()
by_parent = {} expenses = Expense.objects.filter(owner=request.user)
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 = [] # ---- 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 Decimal("0")
kpi_count = month_expenses.count()
kpi_categories = month_expenses.values("category").distinct().count()
def walk(parent_id, depth): # ---- Comparativa con el mes anterior ----
for category in by_parent.get(parent_id, []): prev_year, prev_month = sub_months(today.year, today.month, 1)
rows.append({"category": category, "depth": depth}) prev_total = (
walk(category.id, depth + 1) 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
walk(None, 0) # ---- Saldos de cuentas ----
return rows # 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))
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] account_ids = [a.id for a in accounts]
expense_totals = { expense_totals = {
@ -109,38 +114,6 @@ def _account_balances(accounts):
if balance < 0: if balance < 0:
negative_accounts.append({"account": acc, "balance": balance}) 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)
# ---- 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 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 ---- # ---- Objetivos ----
# Una sola query reutilizada para "goals" (home) y "exceeded_goals" (alertas). # 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. # is_exceeded() solo aplica a kind == budget, así que pago y ahorro nunca la disparan.
@ -635,11 +608,7 @@ def dashboard(request):
@login_required @login_required
def tag_list(request): def tag_list(request):
tags = ( tags = Tag.objects.filter(owner=request.user)
Tag.objects.filter(owner=request.user)
.annotate(expense_count=Count("expenses"))
.order_by(Lower("name"))
)
return render( return render(
request, "expenses/tag_list.html", {"active_menu": "settings", "tags": tags} request, "expenses/tag_list.html", {"active_menu": "settings", "tags": tags}
@ -700,12 +669,11 @@ def tag_delete(request, pk):
@login_required @login_required
def account_list(request): def account_list(request):
accounts = list(Account.objects.filter(owner=request.user).order_by(Lower("name"))) accounts = Account.objects.filter(owner=request.user)
account_rows, _, _ = _account_balances(accounts)
return render( return render(
request, request,
"expenses/account_list.html", "expenses/account_list.html",
{"active_menu": "accounts", "account_rows": account_rows}, {"active_menu": "accounts", "accounts": accounts},
) )
@ -990,11 +958,7 @@ def fuel_delete(request, pk):
@login_required @login_required
def category_list(request): def category_list(request):
categories = list( categories = Category.objects.filter(owner=request.user)
Category.objects.filter(owner=request.user)
.annotate(expense_count=Count("expenses"))
.select_related("parent")
)
if request.method == "POST": if request.method == "POST":
form = CategoryForm(request.POST, user=request.user) form = CategoryForm(request.POST, user=request.user)
@ -1011,7 +975,7 @@ def category_list(request):
"categories/list.html", "categories/list.html",
{ {
"active_menu": "settings", "active_menu": "settings",
"category_rows": _category_tree(categories), "categories": categories,
"form": form, "form": form,
}, },
) )
@ -1075,21 +1039,11 @@ def category_delete(request, pk):
def goal_list(request): def goal_list(request):
goals = Goal.objects.filter(owner=request.user) 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( return render(
request, request,
"goals/list.html", "goals/list.html",
{ {
"goals": goals, "goals": goals,
"kind_choices": Goal.KIND_CHOICES,
"selected_kind": selected_kind,
"active_menu": "settings", "active_menu": "settings",
}, },
) )