Compare commits
No commits in common. "4eb120927b0f72ab5b198718c5a065e327ad5edd" and "b04a8db080e3bdb79bb63fa8c0af3dbbaf217189" have entirely different histories.
4eb120927b
...
b04a8db080
181
ANALISIS_code.md
181
ANALISIS_code.md
@ -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.
|
||||
90
CLAUDE.md
90
CLAUDE.md
@ -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.
|
||||
@ -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',)},
|
||||
),
|
||||
]
|
||||
@ -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')]},
|
||||
),
|
||||
]
|
||||
@ -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, Lower
|
||||
from django.db.models.functions import ExtractMonth
|
||||
|
||||
|
||||
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 = [Lower("name")]
|
||||
ordering = ["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 = [Lower("name")]
|
||||
ordering = ["name"]
|
||||
|
||||
def current_balance(self):
|
||||
expenses_total = self.expenses.aggregate(total=Sum("amount"))[
|
||||
@ -193,7 +193,6 @@ class Tag(models.Model):
|
||||
|
||||
class Meta:
|
||||
unique_together = ("name", "owner")
|
||||
ordering = [Lower("name")]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@ -367,23 +367,8 @@ button[type="submit"]:hover,
|
||||
.filters-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
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;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filters-advanced {
|
||||
@ -653,12 +638,6 @@ 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;
|
||||
}
|
||||
@ -841,14 +820,17 @@ 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);
|
||||
}
|
||||
@ -944,7 +926,6 @@ tr:hover {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.balance-total {
|
||||
@ -1108,36 +1089,3 @@ 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;
|
||||
}
|
||||
|
||||
@ -23,24 +23,18 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Categoría</th>
|
||||
<th>Gastos</th>
|
||||
<th>Categoría padre</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in category_rows %}
|
||||
{% for category in categories %}
|
||||
<tr>
|
||||
<td class="cat-depth-{{ row.depth }}">{{ row.category.name }}</td>
|
||||
<td>{{ row.category.expense_count }}</td>
|
||||
<td>{{ category.name }}</td>
|
||||
<td>{% if category.parent %}{{ category.parent.name }}{% endif %}</td>
|
||||
<td class="table-actions">
|
||||
<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>
|
||||
<a href="{% url 'category_edit' category.id %}">Editar</a>
|
||||
<a href="{% url 'category_delete' category.id %}" class="danger">Eliminar</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@ -18,22 +18,22 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in account_rows %}
|
||||
{% for account in accounts %}
|
||||
<tr>
|
||||
<td>{{ row.account.name }}</td>
|
||||
<td>{{ row.account.initial_balance }}</td>
|
||||
<td>{{ row.balance|floatformat:2 }}</td>
|
||||
<td>{{ account.name }}</td>
|
||||
<td>{{ account.initial_balance }}</td>
|
||||
<td>{{ account.current_balance|floatformat:2 }}</td>
|
||||
<td>
|
||||
{% if row.account.active %}
|
||||
{% if 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' row.account.id %}">Editar</a>
|
||||
{% if row.account.active %}
|
||||
<a href="{% url 'account_delete' row.account.id %}" class="danger">Eliminar</a>
|
||||
<a href="{% url 'account_edit' account.id %}">Editar</a>
|
||||
{% if account.active %}
|
||||
<a href="{% url 'account_delete' account.id %}" class="danger">Eliminar</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -428,7 +428,11 @@
|
||||
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 }}"
|
||||
<div class="progress-fill
|
||||
{% if goal.percentage < 50 %} low
|
||||
{% elif goal.percentage < 80 %} medium
|
||||
{% else %} high
|
||||
{% endif %}"
|
||||
style="width: {{ goal.bar_width|unlocalize }}%"></div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -5,34 +5,15 @@
|
||||
|
||||
<a class="btn" href="{% url 'tag_create' %}">➕ Nueva etiqueta</a>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nombre</th>
|
||||
<th>Gastos</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tag in tags %}
|
||||
<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>
|
||||
<ul>
|
||||
{% for tag in tags %}
|
||||
<li class="table-actions">
|
||||
{{ tag.name }}
|
||||
<a href="{% url 'tag_edit' tag.id %}">Editar</a>
|
||||
<a href="{% url 'tag_delete' tag.id %}" class="danger">Eliminar</a>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li>No hay etiquetas</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
@ -8,64 +8,6 @@
|
||||
{{ title }}
|
||||
</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">
|
||||
{% csrf_token %}
|
||||
{% include "expenses/_form_fields.html" %}
|
||||
|
||||
@ -9,85 +9,56 @@
|
||||
{% 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>
|
||||
<a class="btn btn-primary" href="{% url 'goal_create' %}">➕ Nuevo objetivo</a>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<tr>
|
||||
<th>Nombre</th>
|
||||
<th>Tipo</th>
|
||||
<th>Progreso</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
|
||||
<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>
|
||||
{% 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>
|
||||
<a href="{% url 'goal_edit' goal.id %}">Editar</a>
|
||||
<a href="{% url 'goal_delete' goal.id %}" class="danger">Eliminar</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
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
|
||||
@ -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))
|
||||
|
||||
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")}
|
||||
|
||||
@ -99,43 +99,3 @@ 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}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
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
|
||||
|
||||
@ -195,23 +194,3 @@ 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"] == ""
|
||||
|
||||
@ -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"]
|
||||
@ -15,8 +15,8 @@ from .forms import (
|
||||
)
|
||||
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Sum, Count, ProtectedError
|
||||
from django.db.models.functions import ExtractMonth, ExtractYear, ExtractDay, Lower
|
||||
from django.db.models import Sum, ProtectedError
|
||||
from django.db.models.functions import ExtractMonth, ExtractYear, ExtractDay
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
@ -57,29 +57,34 @@ 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())
|
||||
@login_required
|
||||
def home(request):
|
||||
today = date.today()
|
||||
expenses = Expense.objects.filter(owner=request.user)
|
||||
|
||||
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):
|
||||
for category in by_parent.get(parent_id, []):
|
||||
rows.append({"category": category, "depth": depth})
|
||||
walk(category.id, depth + 1)
|
||||
# ---- 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
|
||||
|
||||
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)."""
|
||||
# ---- Saldos de cuentas ----
|
||||
# Dos queries agregadas en lugar de N+1 (nada de account.current_balance() en bucle).
|
||||
accounts = list(Account.objects.filter(owner=request.user, active=True))
|
||||
account_ids = [a.id for a in accounts]
|
||||
|
||||
expense_totals = {
|
||||
@ -109,38 +114,6 @@ def _account_balances(accounts):
|
||||
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)
|
||||
|
||||
# ---- 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 ----
|
||||
# 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.
|
||||
@ -635,11 +608,7 @@ def dashboard(request):
|
||||
|
||||
@login_required
|
||||
def tag_list(request):
|
||||
tags = (
|
||||
Tag.objects.filter(owner=request.user)
|
||||
.annotate(expense_count=Count("expenses"))
|
||||
.order_by(Lower("name"))
|
||||
)
|
||||
tags = Tag.objects.filter(owner=request.user)
|
||||
|
||||
return render(
|
||||
request, "expenses/tag_list.html", {"active_menu": "settings", "tags": tags}
|
||||
@ -700,12 +669,11 @@ def tag_delete(request, pk):
|
||||
|
||||
@login_required
|
||||
def account_list(request):
|
||||
accounts = list(Account.objects.filter(owner=request.user).order_by(Lower("name")))
|
||||
account_rows, _, _ = _account_balances(accounts)
|
||||
accounts = Account.objects.filter(owner=request.user)
|
||||
return render(
|
||||
request,
|
||||
"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
|
||||
def category_list(request):
|
||||
categories = list(
|
||||
Category.objects.filter(owner=request.user)
|
||||
.annotate(expense_count=Count("expenses"))
|
||||
.select_related("parent")
|
||||
)
|
||||
categories = Category.objects.filter(owner=request.user)
|
||||
|
||||
if request.method == "POST":
|
||||
form = CategoryForm(request.POST, user=request.user)
|
||||
@ -1011,7 +975,7 @@ def category_list(request):
|
||||
"categories/list.html",
|
||||
{
|
||||
"active_menu": "settings",
|
||||
"category_rows": _category_tree(categories),
|
||||
"categories": categories,
|
||||
"form": form,
|
||||
},
|
||||
)
|
||||
@ -1075,21 +1039,11 @@ 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",
|
||||
},
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user