Merge pull request 'Añadidas tandas: 4, 4b y 7' (#33) from dev into main
Reviewed-on: #33
This commit is contained in:
commit
6c6fbbd82c
181
ANALISIS_code.md
Normal file
181
ANALISIS_code.md
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
# Análisis de mejoras — Expenses Manager
|
||||||
|
|
||||||
|
Informe de solo lectura (no se ha modificado ni commiteado nada). Cubre UX/visual, técnico y mantenibilidad, ordenado por impacto dentro de cada bloque. No incluye bugs funcionales.
|
||||||
|
|
||||||
|
> **Nota**: durante el análisis han aparecido dos hallazgos que sí son bugs funcionales (fuera del alcance de este informe, para tu otra lista):
|
||||||
|
> - `expenses/templates/expenses/income_confirm_delete.html:10` muestra `{{ expense.date }}` en vez de `{{ income.date }}` (esa variable no existe en el contexto de esa vista, así que la fecha sale vacía). Parece un resto de copiar `expense_confirm_delete.html`.
|
||||||
|
> - `urls.py:36` apunta a `registration/password_help.html`, plantilla que no existe en `templates/registration/` (solo están `password_change_form.html` y `password_change_done.html`). Esa URL rompería con un `TemplateDoesNotExist`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Visual / UX
|
||||||
|
|
||||||
|
### 1.1 Diseño no responsive: falta viewport y media queries (Alto impacto / Esfuerzo medio)
|
||||||
|
**Qué**: no hay ninguna etiqueta `<meta name="viewport">` en `base.html` ni `base_auth.html`, y `base.css` (539 líneas) no tiene ni un solo `@media`. Las tablas (`expense_list.html`, `dashboard.html` tablas de comparación, `fuel/list.html` con 7 columnas) se renderizan sin envoltorio de scroll horizontal.
|
||||||
|
**Dónde**: `expenses/templates/expenses/base.html`, `base_auth.html`, `expenses/static/expenses/css/base.css`.
|
||||||
|
**Por qué importa**: en móvil el layout se renderiza a tamaño escritorio y se ve reducido/roto; las tablas anchas (fuel, comparación del dashboard) van a desbordar o comprimir columnas ilegibles.
|
||||||
|
**Esfuerzo**: medio — añadir el meta viewport es trivial, pero meter breakpoints reales y envolver tablas en `overflow-x:auto` toca varias plantillas.
|
||||||
|
|
||||||
|
### 1.2 Plantillas de confirmación de borrado divergentes (Alto impacto / Esfuerzo medio)
|
||||||
|
**Qué**: las 7 páginas de confirmar-borrado (`expense_confirm_delete.html`, `tag_confirm_delete.html`, `account_confirm_delete.html`, `income_confirm_delete.html`, `categories/confirm_delete.html`, `fuel/confirm_delete.html`, `goals/confirm_delete.html`) son casi idénticas pero cada una tiene su propia mezcla de etiqueta de botón ("Eliminar" vs "Sí, eliminar"), clase CSS (`btn` vs `btn danger`, `btn secondary` vs `btn` a secas) y nivel de encabezado (`h1` vs `h2`).
|
||||||
|
**Dónde**: las 7 plantillas listadas arriba.
|
||||||
|
**Por qué importa**: la misma acción destructiva se presenta visualmente distinta según la sección; y como ya demuestra el bug de `income_confirm_delete.html`, copiar-pegar estas plantillas sin unificarlas es justo lo que genera ese tipo de errores.
|
||||||
|
**Esfuerzo**: medio — unificarlas en un partial (`{% include %}`) con variables (`object_label`, `cancel_url`) o migrar a `DeleteView` con una plantilla genérica.
|
||||||
|
|
||||||
|
### 1.3 Colores hardcodeados y sin variables CSS (Alto impacto / Esfuerzo medio)
|
||||||
|
**Qué**: `base.css` no define ninguna custom property (`--variable`); los mismos conceptos semánticos (verde éxito, rojo error/peligro) están definidos con 3-4 tonos de hex ligeramente distintos en sitios distintos (`#166534`/`#1e7e34` para verde; `#b91c1c`/`#991b1b`/`#b71c1c` para rojo). Además, `dashboard.html` usa hex literales inline (`#d9534f`/`#5cb85c`, líneas 167, 191, 239) que no coinciden con los que ya existen en `base.css` para el mismo significado ("gasto sube/baja").
|
||||||
|
**Dónde**: `expenses/static/expenses/css/base.css`, `expenses/templates/expenses/dashboard.html` (16 `style="..."` inline, concentrados en este archivo).
|
||||||
|
**Por qué importa**: es la brecha de coherencia visual más clara del proyecto — mismo significado, colores distintos según la página; y dificulta cualquier cambio de paleta futuro (hay que buscar y reemplazar en vez de cambiar una variable).
|
||||||
|
**Esfuerzo**: medio — introducir `:root { --color-success: ...; --color-danger: ... }` y sustituir tanto en `base.css` como en los inline styles de `dashboard.html`.
|
||||||
|
|
||||||
|
### 1.4 Menú desplegable "Configuraciones" inaccesible por teclado (Medio impacto / Esfuerzo bajo)
|
||||||
|
**Qué**: el dropdown de navegación (`base.html:24-32`) se abre solo con `:hover` en CSS (`base.css:382-384`), sin `role`, `aria-expanded` ni gestor de teclado/foco.
|
||||||
|
**Dónde**: `expenses/templates/expenses/base.html:24-32`, `base.css:382-384`.
|
||||||
|
**Por qué importa**: un usuario que navega solo con teclado no puede llegar nunca a Categorías/Etiquetas/Objetivos — no es solo "buena práctica de accesibilidad", es una ruta completamente bloqueada.
|
||||||
|
**Esfuerzo**: bajo — añadir `aria-expanded`, un pequeño script de toggle por click/Enter, y `tabindex` en el toggle.
|
||||||
|
|
||||||
|
### 1.5 Selects de filtro sin `<label>` (Medio impacto / Esfuerzo bajo)
|
||||||
|
**Qué**: los selects de año/mes/cuenta en `expense_list.html` (líneas 14, 25, 36) y `dashboard.html` (líneas 56, 66, 74) no tienen `<label>` asociado, mientras que el filtro de "Categoría" en la misma página sí lo tiene.
|
||||||
|
**Dónde**: `expenses/templates/expenses/expense_list.html`, `dashboard.html`.
|
||||||
|
**Por qué importa**: lectores de pantalla no anuncian qué controla cada select; inconsistente incluso dentro de la misma página.
|
||||||
|
**Esfuerzo**: bajo — añadir `<label>` a cada select.
|
||||||
|
|
||||||
|
### 1.6 Barras de progreso sin semántica ARIA (Bajo-medio impacto / Esfuerzo bajo)
|
||||||
|
**Qué**: las barras de progreso de objetivos (`goals/list.html:36-39`, `home.html:75-78`, `dashboard.html:379-386`) son `div`s anidados sin `role="progressbar"` ni `aria-valuenow/min/max`.
|
||||||
|
**Dónde**: las 3 plantillas citadas.
|
||||||
|
**Por qué importa**: la información de progreso es puramente visual, invisible para tecnología asistiva.
|
||||||
|
**Esfuerzo**: bajo.
|
||||||
|
|
||||||
|
### 1.7 Clases CSS referenciadas en plantillas que no existen en `base.css` (Bajo-medio impacto / Esfuerzo bajo)
|
||||||
|
**Qué**: `.auth-container`, `.pagination`/`.step-links`, `.form-errors`/`.form-field`, `.advanced-content`/`.tags-filters`, `.btn-secondary` (con guion, distinto de `.btn.secondary` que sí existe) se usan en plantillas pero no tienen regla CSS correspondiente.
|
||||||
|
**Dónde**: `base_auth.html:10`, `expense_list.html:48,60,75,151-152`, `expense_form.html:25,36,43`, `categories/confirm_delete.html:13,19,26`.
|
||||||
|
**Por qué importa**: probablemente la paginación y los mensajes de error de formulario se están viendo sin ningún estilo — vale la pena revisar si es intencional o un olvido.
|
||||||
|
**Esfuerzo**: bajo.
|
||||||
|
|
||||||
|
### 1.8 Patrones de UI distintos para datos estructuralmente iguales (Bajo-medio impacto / Esfuerzo bajo-medio)
|
||||||
|
**Qué**: `tag_list.html` renderiza como `<ul>/<li>`, mientras `categories/list.html` usa `<table>` para el mismo tipo de contenido ("nombre + acciones"). Además, crear una categoría se hace con un formulario embebido en la página de listado, mientras que cuentas/etiquetas/ingresos usan una página "nueva" separada.
|
||||||
|
**Dónde**: `expenses/templates/expenses/tag_list.html`, `categories/list.html`.
|
||||||
|
**Por qué importa**: dos patrones de UX distintos para la misma tarea ("añadir un elemento") sin razón aparente.
|
||||||
|
**Esfuerzo**: bajo-medio.
|
||||||
|
|
||||||
|
### 1.9 Jerarquía de encabezados inconsistente (Bajo impacto / Esfuerzo bajo)
|
||||||
|
**Qué**: la mayoría de páginas usa `<h1>` para el título, pero `categories/confirm_delete.html`, `categories/form.html` y `fuel/confirm_delete.html` usan `<h2>` sin ningún `<h1>` en la página; `dashboard.html` no tiene `<h1>` y empieza directamente en `<h2>`.
|
||||||
|
**Dónde**: plantillas citadas.
|
||||||
|
**Por qué importa**: rompe la jerarquía semántica que usan lectores de pantalla para navegar por secciones.
|
||||||
|
**Esfuerzo**: bajo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Técnico / Código
|
||||||
|
|
||||||
|
### 2.1 N+1 en el dashboard por cuenta (Alto impacto / Esfuerzo medio)
|
||||||
|
**Qué**: `kpi_balance = sum(account.current_balance() for account in accounts)` (línea 317) ejecuta 2 queries por cuenta; más abajo, el bucle que construye los gráficos anuales por cuenta (líneas 445-470) llama a `monthly_balance()` (4 queries) y `current_balance()` otra vez (2 queries) por cada cuenta. Con 5 cuentas son ~40 queries solo para calcular saldos.
|
||||||
|
**Dónde**: `expenses/views.py`, función `dashboard` (líneas 288-514).
|
||||||
|
**Por qué importa**: es la página más visitada probablemente, y el coste crece linealmente con el número de cuentas del usuario.
|
||||||
|
**Esfuerzo**: medio — requiere consolidar en queries anotadas agrupadas por cuenta o cachear los resultados dentro de la misma petición.
|
||||||
|
|
||||||
|
### 2.2 `Goal.progress()` recalculado 3-5 veces por objetivo sin memoizar (Alto impacto / Esfuerzo bajo)
|
||||||
|
**Qué**: `percentage()`, `progress_state()`, `bar_width()` e `is_exceeded()` llaman todos a `progress()` (que hace una query de agregación), sin ningún cacheo. Las plantillas (`goals/list.html`, `home.html`, `dashboard.html`) llaman a varios de estos métodos por objetivo en el mismo render, así que con 10 objetivos son del orden de 50 queries que podrían ser 10.
|
||||||
|
**Dónde**: `expenses/models.py`, clase `Goal` (líneas 360-409); consumido en `templates/goals/list.html`, `templates/expenses/home.html`, `templates/expenses/dashboard.html`.
|
||||||
|
**Por qué importa**: es la relación coste/beneficio más favorable de todo el informe — un cambio pequeño y de bajo riesgo con impacto de rendimiento alto.
|
||||||
|
**Esfuerzo**: bajo — memoizar `progress()` con `functools.cached_property` o un atributo cacheado en la instancia.
|
||||||
|
|
||||||
|
### 2.3 Duplicación sistemática en `views.py`: ownership lookup, create/edit, delete (Alto impacto / Esfuerzo alto)
|
||||||
|
**Qué**: el patrón `get_object_or_404(Model, pk=pk, owner=request.user)` se repite 15 veces en 14 vistas; el bloque `if POST: form.is_valid()... else: form = Form(...)` se repite en 13 pares crear/editar; las 7 vistas de borrado comparten la misma forma (fetch → si POST borra+mensaje+redirect → si no, renderiza confirmación), con solo 3 excepciones reales (`account_delete` hace soft-delete, `category_delete` captura `ProtectedError`, `fuel_delete`/`fuel_edit` usan `_redirect_back` y lógica de `next`).
|
||||||
|
**Dónde**: `expenses/views.py` (todas las vistas de tags/cuentas/ingresos/categorías/objetivos/fuel).
|
||||||
|
**Por qué importa**: mucho código para mantener sincronizado; cada vista nueva copia el mismo patrón a mano, con riesgo de que alguna se olvide de filtrar por `owner` (justo lo que CLAUDE.md pide evitar).
|
||||||
|
**Esfuerzo**: alto — migrar a `ListView`/`CreateView`/`UpdateView`/`DeleteView` con un mixin de scoping por owner reduciría el archivo en unas 300-400 líneas, pero implica tocar 20+ vistas, sus URLs y validar que las plantillas sigan recibiendo el mismo contexto.
|
||||||
|
|
||||||
|
### 2.4 Patrón `user` duplicado 5 veces en `forms.py`, con inconsistencia `pop` (Medio impacto / Esfuerzo bajo)
|
||||||
|
**Qué**: `ExpenseForm`, `IncomeForm`, `FuelEntryForm`, `CategoryForm` y `GoalForm` repiten el mismo `__init__` que extrae `user` de `kwargs` y filtra querysets, sin una clase base común. Además, `ExpenseForm` usa `kwargs.pop("user", None)` (con default) mientras las otras 4 usan `kwargs.pop("user")` (lanza `KeyError` si alguien olvida pasar `user=`).
|
||||||
|
**Dónde**: `expenses/forms.py` (líneas 7-19, 56-62, 76-86, 94-106, 141-150).
|
||||||
|
**Por qué importa**: 5 copias del mismo código, y una inconsistencia que puede provocar un error confuso (`KeyError` en vez de un mensaje claro) si se instancia un formulario sin pasar `user`.
|
||||||
|
**Esfuerzo**: bajo — una clase base `OwnerScopedForm` con un método hook (`scope_querysets(user)`) resuelve ambos problemas de una vez.
|
||||||
|
|
||||||
|
### 2.5 `select_related`/`prefetch_related` casi ausentes (Medio impacto / Esfuerzo bajo-medio)
|
||||||
|
**Qué**: solo se usan en 2 de ~37 vistas (`home` y `fuel_list`, y este último solo para `expense`, no evita el N+1 de `km_since_previous()` que sigue haciendo una query por cada repostaje al iterar en `fuel_list`, líneas 785-789).
|
||||||
|
**Dónde**: `expenses/views.py`.
|
||||||
|
**Por qué importa**: cualquier vista que muestre listas con relaciones (categoría, cuenta, tags) es candidata a N+1 silencioso.
|
||||||
|
**Esfuerzo**: bajo-medio — revisar listado por listado y añadir `select_related`/`prefetch_related` donde corresponda.
|
||||||
|
|
||||||
|
### 2.6 `category_list` mezcla listado y creación, inconsistente con el resto de modelos (Bajo-medio impacto / Esfuerzo bajo)
|
||||||
|
**Qué**: es la única vista donde crear un objeto ocurre dentro de la misma función/URL que el listado (no existe `category_create`), mientras que cuentas/etiquetas/ingresos/objetivos tienen una vista y URL de creación separada.
|
||||||
|
**Dónde**: `expenses/views.py` (líneas 871-893), `expenses/urls.py`.
|
||||||
|
**Por qué importa**: inconsistencia estructural que puede confundir al añadir una nueva funcionalidad similar (¿sigo el patrón de categorías o el de todo lo demás?).
|
||||||
|
**Esfuerzo**: bajo — separar en `category_create` + URL propia, o documentar que es intencional.
|
||||||
|
|
||||||
|
### 2.7 Query de `Tag` duplicada en `expense_list` (Bajo impacto / Esfuerzo bajo)
|
||||||
|
**Qué**: `Tag.objects.filter(owner=request.user)` se ejecuta una vez para construir `tags_with_state` (línea 168) y otra vez para el contexto (línea 206), en vez de reutilizar la misma lista.
|
||||||
|
**Dónde**: `expenses/views.py`, función `expense_list`.
|
||||||
|
**Por qué importa**: query redundante, fácil de eliminar.
|
||||||
|
**Esfuerzo**: bajo.
|
||||||
|
|
||||||
|
### 2.8 Organización de carpetas de plantillas inconsistente (Bajo impacto / Esfuerzo bajo)
|
||||||
|
**Qué**: `categories/`, `fuel/` y `goals/` tienen su propia carpeta de plantillas, pero cuentas/etiquetas/ingresos/gastos están todos mezclados en `templates/expenses/` junto con `dashboard.html`/`home.html`.
|
||||||
|
**Dónde**: `expenses/templates/`.
|
||||||
|
**Por qué importa**: no hay un criterio claro; dificulta encontrar una plantilla si no se conoce ya la excepción histórica.
|
||||||
|
**Esfuerzo**: bajo — mover plantillas a carpetas por función, actualizando las rutas en las vistas.
|
||||||
|
|
||||||
|
### 2.9 Import muerto en `models.py` (Bajo impacto / Esfuerzo muy bajo)
|
||||||
|
**Qué**: `from django.db.models.fields import related` (línea 6) no se usa en ningún sitio del archivo.
|
||||||
|
**Dónde**: `expenses/models.py:6`.
|
||||||
|
**Por qué importa**: limpieza trivial, cero riesgo.
|
||||||
|
**Esfuerzo**: muy bajo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Mantenibilidad
|
||||||
|
|
||||||
|
### 3.1 CLAUDE.md desactualizado respecto al código actual (Alto impacto / Esfuerzo bajo)
|
||||||
|
**Qué**: el documento afirma que `Goal.progress()` agrega "across all time, not just the current period" — ya no es cierto para objetivos de tipo `budget` con periodo mensual/anual (el modelo actual sí reinicia por periodo). También menciona un diccionario `MONTHS` en `views.py` que no existe (solo hay una lista inline `["Ene", "Feb", ...]` dentro de `dashboard`). Y describe `DATABASE_URL` como variable de entorno cuando `settings.py` en realidad lee `DB_ENGINE`/`DB_NAME`/`DB_USER`/etc. por separado.
|
||||||
|
**Dónde**: `expenses_manager/CLAUDE.md` (raíz del repo).
|
||||||
|
**Por qué importa**: es el primer documento que se lee (por ti dentro de 6 meses o por cualquier asistente/colaborador); si describe un comportamiento que ya cambió, genera confusión activa en vez de ayudar.
|
||||||
|
**Esfuerzo**: bajo — actualizar esos 3 puntos concretos.
|
||||||
|
|
||||||
|
### 3.2 README.md no corresponde al proyecto real (Alto impacto / Esfuerzo bajo-medio)
|
||||||
|
**Qué**: el README (91 líneas) instruye a ejecutar `python app.py` y visitar `localhost:5000` (estilo Flask), y documenta un `Dockerfile`/`docker build`/`docker run` que no existen en el repo. También menciona una variable `DATABASE_URL=sqlite:///expenses.db` que el proyecto no usa.
|
||||||
|
**Dónde**: `README.md` (raíz del repo).
|
||||||
|
**Por qué importa**: es la primera impresión para cualquier colaborador nuevo, y ahora mismo activamente engaña en vez de guiar — probablemente boilerplate nunca adaptado al proyecto real.
|
||||||
|
**Esfuerzo**: bajo-medio — reescribir con los pasos reales (`manage.py runserver`, variables de entorno correctas, sin Docker si no existe).
|
||||||
|
|
||||||
|
### 3.3 `views.py` con ~1036 líneas / 37 funciones cubriendo 8 áreas (Alto impacto / Esfuerzo medio-alto)
|
||||||
|
**Qué**: todas las vistas de gastos, dashboard, tags, cuentas, ingresos, fuel, categorías y objetivos viven en un único archivo, sin más separación que líneas en blanco.
|
||||||
|
**Dónde**: `expenses/views.py`.
|
||||||
|
**Por qué importa**: a partir de ~600 líneas o 15-20 vistas ya cuesta encontrar algo sin buscar por texto; a 1036 líneas es claramente el punto de dolor del proyecto. Los puntos de corte por funcionalidad ya son evidentes en el propio código.
|
||||||
|
**Esfuerzo**: medio-alto — dividir en un paquete `views/` (`dashboard.py`, `expenses.py`, `goals.py`, `fuel.py`, `accounts.py`, `income.py`, `tags.py`, `categories.py`, `_utils.py`), actualizando `urls.py` en consecuencia. Se puede hacer de forma incremental, un módulo a la vez, sin romper nada.
|
||||||
|
|
||||||
|
### 3.4 Función `dashboard` sobrecargada de responsabilidades (Alto impacto / Esfuerzo medio-alto)
|
||||||
|
**Qué**: 226 líneas que mezclan 5 responsabilidades (resolución de periodo, selección de cuenta/KPI, agregación por categoría/día/mes, modo comparación, gráficos anuales por cuenta), con un diccionario de contexto de 35 claves construido inline, dos variables de nombre parecido (`expenses` vs `expenses_filtered`) que conviven en todo el ámbito de la función, y un `except Exception` silencioso (líneas 447-455) que sustituye cualquier error por datos vacíos, ocultando posibles problemas reales de datos.
|
||||||
|
**Dónde**: `expenses/views.py`, función `dashboard` (líneas 288-514).
|
||||||
|
**Por qué importa**: es la función más difícil de seguir del proyecto; modificar cualquier KPI implica leer las 226 líneas para no chocar con otra parte.
|
||||||
|
**Esfuerzo**: medio-alto — extraer en funciones privadas nombradas (`_resolve_period`, `_build_comparison`, `_build_account_charts`) y agrupar el contexto en sub-diccionarios (`kpis`, `comparison`, `charts`).
|
||||||
|
|
||||||
|
### 3.5 `Goal.progress()`/`_period_start()` conceptualmente sobrecargado (Medio impacto / Esfuerzo bajo)
|
||||||
|
**Qué**: en 30 líneas se resuelven 3 comportamientos distintos según `kind` (ahorro = saldo de cuenta; pago = gasto acumulado desde `start_date` sin reinicio; presupuesto = gasto del periodo actual), con "caídas" silenciosas (si `kind` no es `budget`, `_period_start()` simplemente devuelve `self.start_date`, que puede ser `None`) y sin docstring que explique el reparto de responsabilidades.
|
||||||
|
**Dónde**: `expenses/models.py`, líneas 349-409.
|
||||||
|
**Por qué importa**: fácil de malinterpretar sin cruzar mentalmente `KIND_CHOICES`/`PERIOD_CHOICES` y la validación de `GoalForm.clean()`.
|
||||||
|
**Esfuerzo**: bajo — añadir un docstring explicando los 3 casos, o separar en métodos privados por `kind` (`_progress_saving()`, `_progress_payment()`, `_progress_budget()`).
|
||||||
|
|
||||||
|
### 3.6 `gunicorn` declarado en `requirements.txt` pero no instalado en el venv (Medio impacto / Esfuerzo bajo)
|
||||||
|
**Qué**: mismo patrón que se encontró antes con `whitenoise` (ya resuelto). `gunicorn==25.1.0` está en `requirements.txt` pero no existe en `venv/Lib/site-packages`, y no hay `Procfile` ni `Dockerfile` que muestre dónde se usaría realmente. `pytest-cov>=4.0` es además la única dependencia sin pin exacto en un archivo que fija versión exacta en todo lo demás.
|
||||||
|
**Dónde**: `requirements.txt` (raíz del proyecto Django).
|
||||||
|
**Por qué importa**: mismo tipo de deriva silenciosa que causó el fallo de `whitenoise`; vale la pena revisar todas las dependencias de una vez.
|
||||||
|
**Esfuerzo**: bajo — `pip install -r requirements.txt` y confirmar cuáles hacen falta realmente en dev vs producción.
|
||||||
|
|
||||||
|
### 3.7 Comentarios en dos idiomas sin criterio (Bajo-medio impacto / Esfuerzo bajo)
|
||||||
|
**Qué**: los comentarios de `models.py` están en español (`# Para pago y presupuesto`) y los de `views.py` en inglés (`# Time presets`, `# Comparison`), sin ninguna razón aparente para la diferencia entre archivos.
|
||||||
|
**Dónde**: `expenses/models.py`, `expenses/views.py`.
|
||||||
|
**Por qué importa**: obliga a cambiar de idioma mentalmente al leer ambos archivos seguidos; sería bueno fijar una convención (aunque sea "comentarios en español, identificadores en inglés", que es lo que ya se hace en el resto del proyecto).
|
||||||
|
**Esfuerzo**: bajo — no urge reescribir todo, pero conviene aplicar la convención elegida a partir de ahora.
|
||||||
|
|
||||||
|
### 3.8 No existe `.env.example` (Medio impacto / Esfuerzo bajo)
|
||||||
|
**Qué**: solo existe el `.env` real; un colaborador nuevo tiene que deducir las variables necesarias (`SECRET_KEY`, `DEBUG`, `ALLOWED_HOSTS`, `CSRF_TRUSTED_ORIGINS`, `DB_ENGINE`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`) leyendo `settings.py` directamente, ya que ni el README ni CLAUDE.md las listan completas.
|
||||||
|
**Dónde**: raíz del proyecto Django.
|
||||||
|
**Por qué importa**: fricción de onboarding evitable con un archivo de 10 líneas.
|
||||||
|
**Esfuerzo**: bajo.
|
||||||
|
|
||||||
|
### 3.9 Restos menores de limpieza (Bajo impacto / Esfuerzo muy bajo)
|
||||||
|
**Qué**: comentario de linter obsoleto `# sourcery skip: assign-if-exp, merge-else-if-into-elif` en `views.py:240`; mezcla de comillas simples/dobles en `GoalForm.Meta.fields` (`forms.py:125-134`); `verbose_name_plural = "categories"` en inglés dentro de un modelo con todo lo demás en español (afecta solo al admin de Django).
|
||||||
|
**Dónde**: `expenses/views.py:240`, `expenses/forms.py:125-134`, `expenses/models.py:31`.
|
||||||
|
**Por qué importa**: ruido cosmético, cero riesgo, fácil de arrastrar sin darse cuenta a nuevo código.
|
||||||
|
**Esfuerzo**: muy bajo.
|
||||||
90
CLAUDE.md
Normal file
90
CLAUDE.md
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
This git repo has a nested directory structure: the Django project root is `expenses_manager/` (contains `manage.py`), and inside that lives the `expenses_manager/` settings package plus the `expenses` app. So paths look like:
|
||||||
|
|
||||||
|
```
|
||||||
|
expenses_manager/ <- repo root
|
||||||
|
expenses_manager/ <- Django project root (manage.py here)
|
||||||
|
expenses_manager/ <- settings package (settings.py, urls.py, wsgi/asgi)
|
||||||
|
expenses/ <- the single Django app with all models/views/forms
|
||||||
|
pytest.ini
|
||||||
|
requirements.txt
|
||||||
|
Jenkinsfile
|
||||||
|
```
|
||||||
|
|
||||||
|
All commands below assume you're in `expenses_manager/expenses_manager/` (the directory with `manage.py`).
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install deps (a conda env or any virtualenv works)
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Run the dev server
|
||||||
|
python manage.py runserver
|
||||||
|
|
||||||
|
# Run all tests (pytest, not manage.py test)
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# Run a single test file / test
|
||||||
|
pytest expenses/tests/test_dashboard.py
|
||||||
|
pytest expenses/tests/test_goals.py::test_budget_is_exceeded
|
||||||
|
|
||||||
|
# Run with coverage (what CI/Jenkins runs)
|
||||||
|
pytest --cov
|
||||||
|
|
||||||
|
# Migrations
|
||||||
|
python manage.py makemigrations
|
||||||
|
python manage.py migrate
|
||||||
|
|
||||||
|
# Seed demo data (guarded: only runs with DEBUG=True)
|
||||||
|
python manage.py seed_demo
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests use `pytest-django`; `pytest.ini` sets `DJANGO_SETTINGS_MODULE=expenses_manager.settings`. There is a shared `expenses/tests/conftest.py` with fixtures (`user`, `auth_client`, `account`, `category`). Tests needing DB access must be marked `@pytest.mark.django_db` (or set `pytestmark = pytest.mark.django_db` at module level).
|
||||||
|
|
||||||
|
## Configuration & environment
|
||||||
|
|
||||||
|
Settings are driven entirely by environment variables read from a `.env` file (via `python-dotenv`) next to `manage.py`. A single `settings.py` serves both local dev and production; behaviour switches on `DEBUG`:
|
||||||
|
|
||||||
|
- `SECRET_KEY` — required when `DEBUG=False` (the app raises `ImproperlyConfigured` and refuses to start if missing). With `DEBUG=True` it falls back to an insecure dev key.
|
||||||
|
- `DEBUG` — defaults to `False`. When `True`, SQLite is used by default and the production hardening block (secure cookies, SSL redirect, proxy header) is skipped.
|
||||||
|
- Database is selected by `DB_ENGINE`: `postgresql` uses Postgres with `DB_NAME`/`DB_USER`/`DB_PASSWORD`/`DB_HOST`/`DB_PORT`; any other value (or unset) falls back to SQLite. **There is no `DATABASE_URL`** — the DB config uses these separate variables.
|
||||||
|
- `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` are comma-separated env vars.
|
||||||
|
|
||||||
|
See `.env.example` for the full list. CI (Jenkins) sets `DEBUG=True` and a throwaway `SECRET_KEY` in its `environment` block so tests run against SQLite without the hardening block.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
This is a single-app Django project (`expenses`) — there is no REST API or frontend build step; views render server-side Django templates directly. All views live in one `expenses/views.py`, forms in one `expenses/forms.py`, models in one `expenses/models.py`.
|
||||||
|
|
||||||
|
**Ownership model**: every domain model (`Category`, `Account`, `Tag`, `Expense`, `Income`, `Goal`) has an `owner` FK to `settings.AUTH_USER_MODEL`. Every view filters querysets by `owner=request.user` — there is no shared/global data between users. When adding a new view or form, follow this same pattern (filter querysets by owner, scope `get_object_or_404` by owner too) to avoid leaking data across accounts.
|
||||||
|
|
||||||
|
**Core models** (`expenses/models.py`):
|
||||||
|
- `Account` — holds `initial_balance` and computes running balances. `current_balance()` sums all incomes/expenses against the initial balance. `monthly_balance(year)` builds a running-balance series per month for charts, and patches the current month's entry with the live `current_balance()` so today's number is always exact. `balance_until(date)` and `monthly_net(year)` support other reporting views (both use aggregated queries, not month-by-month loops). Accounts are soft-deleted (`active=False`), never physically removed, to preserve historical balances; inactive accounts are filtered out of form querysets but shown dimmed in the account list.
|
||||||
|
- `Expense` — belongs to an `Account` and a `Category` (both `PROTECT` on delete, so you can't delete a Category/Account that has expenses), optionally tagged with `Tag` (M2M).
|
||||||
|
- `Income` — same shape as Expense but simpler (no category/tags).
|
||||||
|
- `FuelEntry` — a `OneToOneField` extension of `Expense` for tracking car fuel fill-ups (odometer, liters); `fuel_create`/`fuel_edit`/`fuel_delete` views create/update/delete the paired `Expense` + `FuelEntry` together (delete cascades via the OneToOne), auto-assigning a "Gasolina" category scoped to the owner via `get_or_create`. `FuelEntryForm` is a `ModelForm` of `Expense` plus the `FuelEntry`-specific fields.
|
||||||
|
- `Goal` — a target with a `kind` field (`payment`, `budget`, or `saving`):
|
||||||
|
- `payment` (debt/repayment) and `budget` aggregate expenses in a `Category` (optionally including subcategories via `include_subcategories` + `Category.descendant_ids()`).
|
||||||
|
- `budget` resets per `period` (`month`/`year`): only the current period counts, and `is_exceeded()` flags going over.
|
||||||
|
- `saving` measures progress via the associated `Account`'s balance (provisional — this is the branch to change when an investments module is added).
|
||||||
|
- `progress` is a **`cached_property`** (accessed as `goal.progress`, no parentheses), memoized per instance because `percentage()`/`bar_width()`/`progress_state()`/`is_exceeded()` all derive from it. `progress()` is scoped by `_period_start()`, not aggregated across all time.
|
||||||
|
- `Category` — supports self-referential `parent` for subcategories; `descendant_ids()` returns a category plus all its descendants. Forms must scope the `parent` queryset by `owner` and, when editing, exclude the category itself and its descendants to prevent cycles (there was a past bug where parent categories leaked across users, and another where `clean_parent` silently dropped the value — see git history). Full CRUD exists (`category_edit`/`category_delete`); deletion catches `ProtectedError` when the category has expenses.
|
||||||
|
|
||||||
|
**Views/forms convention**: forms that need to scope choice fields by user (`category`, `account`, `tags`, `parent`) accept a `user` kwarg in `__init__` and filter querysets there, rather than doing it in the view. Views pass `user=request.user` when instantiating these forms both on GET and POST. Delete views require POST and render a confirmation template on GET (pattern established across `tag_delete`, `goal_delete`, etc.).
|
||||||
|
|
||||||
|
**Dashboard view** (`expenses/views.py:dashboard`) is the most complex view: it supports period presets (`this_month`/`last_month`/`this_year`), per-account filtering, and an optional prior-period comparison (`compare=1`) that diffs current vs. previous period totals overall and by category. It also builds per-account annual balance chart data via `Account.monthly_balance()`. Note: this view is large (~200+ lines) and is a known refactor target.
|
||||||
|
|
||||||
|
**Templates**: organized by feature under `expenses/templates/` — `expenses/` (expense/account/income/tag views + dashboard/home), plus separate `categories/`, `fuel/`, `goals/`, `settings/`, `registration/` subfolders. Template naming is inconsistent by folder: files under `expenses/templates/expenses/` use a resource prefix (`account_list.html`, `tag_confirm_delete.html`), while `categories/`, `goals/`, `fuel/` use unprefixed names (`list.html`, `form.html`, `confirm_delete.html`) — check the target folder before naming a new template. UI strings are in Spanish; keep that consistent when touching UI code.
|
||||||
|
|
||||||
|
**Auth**: uses Django's built-in `django.contrib.auth` views/urls (login/logout/password change) mounted at `/accounts/`. The app's own financial-account CRUD lives under `/finance-accounts/` (renamed from `/accounts/` to avoid colliding with the auth namespace). There is no password-reset-by-email flow by design: resets are done by the admin via `manage.py changepassword`, and a static `registration/password_help.html` page (route `password-help/`) tells users to contact the admin. `LOGIN_URL`/`LOGIN_REDIRECT_URL`/`LOGOUT_REDIRECT_URL` are set in settings. All app views are decorated with `@login_required`.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
- Work happens on the **`dev`** branch; **`main`** only receives tested merges. Jenkins runs the test suite on `main`.
|
||||||
|
- Migrations are generated in development and committed; never run `makemigrations` in production (it would create migrations not in git). Only `migrate` runs on the NAS.
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.2.10 on 2026-09-09 11:54
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('expenses', '0010_goal_account_goal_include_subcategories_goal_kind_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='tag',
|
||||||
|
options={'ordering': ('name',)},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 5.2.10 on 2026-09-09 12:59
|
||||||
|
|
||||||
|
import django.db.models.functions.text
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('expenses', '0011_alter_tag_options'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='account',
|
||||||
|
options={'ordering': [django.db.models.functions.text.Lower('name')]},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='category',
|
||||||
|
options={'ordering': [django.db.models.functions.text.Lower('name')], 'verbose_name_plural': 'categories'},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='tag',
|
||||||
|
options={'ordering': [django.db.models.functions.text.Lower('name')]},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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
|
from django.db.models.functions import ExtractMonth, Lower
|
||||||
|
|
||||||
|
|
||||||
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 = ["name"]
|
ordering = [Lower("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 = ["name"]
|
ordering = [Lower("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,6 +193,7 @@ 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
|
||||||
|
|||||||
@ -367,8 +367,23 @@ button[type="submit"]:hover,
|
|||||||
.filters-main {
|
.filters-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-main select {
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-main select:focus {
|
||||||
|
outline: 2px solid var(--color-focus-ring);
|
||||||
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters-advanced {
|
.filters-advanced {
|
||||||
@ -638,6 +653,12 @@ 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;
|
||||||
}
|
}
|
||||||
@ -694,10 +715,59 @@ tr:hover {
|
|||||||
/* Forms */
|
/* Forms */
|
||||||
/* ========================= */
|
/* ========================= */
|
||||||
|
|
||||||
|
.app-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
.form-field {
|
.form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-field label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field input:not([type="checkbox"]):not([type="radio"]),
|
||||||
|
.form-field select,
|
||||||
|
.form-field textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field input:focus,
|
||||||
|
.form-field select:focus,
|
||||||
|
.form-field textarea:focus {
|
||||||
|
outline: 2px solid var(--color-focus-ring);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-checkbox {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-checkbox input[type="checkbox"] {
|
||||||
|
accent-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-help {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.form-errors {
|
.form-errors {
|
||||||
color: var(--color-danger-accent);
|
color: var(--color-danger-accent);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@ -771,17 +841,14 @@ 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);
|
||||||
}
|
}
|
||||||
@ -860,6 +927,116 @@ tr:hover {
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========================= */
|
||||||
|
/* Home widgets */
|
||||||
|
/* ========================= */
|
||||||
|
|
||||||
|
.home-section {
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-section > h2 {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-total {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-success-accent);
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-total.negative,
|
||||||
|
.balance-amount.negative {
|
||||||
|
color: var(--color-danger-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-breakdown {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-breakdown li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-amount {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movements-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-date {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-account {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-amount {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-positive {
|
||||||
|
color: var(--color-success-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-negative {
|
||||||
|
color: var(--color-danger-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.movement-account {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ========================= */
|
/* ========================= */
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
/* ========================= */
|
/* ========================= */
|
||||||
@ -931,3 +1108,36 @@ 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;
|
||||||
|
}
|
||||||
|
|||||||
@ -7,9 +7,9 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>Editar categoría</h2>
|
<h2>Editar categoría</h2>
|
||||||
|
|
||||||
<form method="post">
|
<form method="post" class="app-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.as_p }}
|
{% include "expenses/_form_fields.html" %}
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">Guardar</button>
|
<button type="submit" class="btn btn-primary">Guardar</button>
|
||||||
<a class="btn btn-secondary" href="{% url 'category_list' %}">Cancelar</a>
|
<a class="btn btn-secondary" href="{% url 'category_list' %}">Cancelar</a>
|
||||||
|
|||||||
@ -8,37 +8,44 @@
|
|||||||
|
|
||||||
<h1>Mis categorías</h1>
|
<h1>Mis categorías</h1>
|
||||||
|
|
||||||
<h3> Nueva categoría</h3>
|
<h3>Nueva categoría</h3>
|
||||||
<form method="post">
|
<form method="post" class="app-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.as_p }}
|
{% include "expenses/_form_fields.html" %}
|
||||||
<button type="submit" class="btn btn-primary">Crear</button>
|
<button type="submit" class="btn btn-primary">Crear</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<h3>Listado</h3>
|
<h3>Listado</h3>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
|
||||||
<th>Categoría</th>
|
|
||||||
<th>Categoría padre</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for category in categories %}
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ category.name }}</td>
|
<th>Categoría</th>
|
||||||
<td>{% if category.parent %}{{ category.parent.name }}{% endif %}</td>
|
<th>Gastos</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in category_rows %}
|
||||||
|
<tr>
|
||||||
|
<td class="cat-depth-{{ row.depth }}">{{ row.category.name }}</td>
|
||||||
|
<td>{{ row.category.expense_count }}</td>
|
||||||
<td class="table-actions">
|
<td class="table-actions">
|
||||||
<a href="{% url 'category_edit' category.id %}">Editar</a>
|
<a href="{% url 'category_edit' row.category.id %}">Editar</a>
|
||||||
<a href="{% url 'category_delete' category.id %}" class="danger">Eliminar</a>
|
<a href="{% url 'category_delete' row.category.id %}" class="danger">Eliminar</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% empty %}
|
||||||
</tbody>
|
<tr>
|
||||||
</table>
|
<td colspan="3" class="empty-state">
|
||||||
|
<p>No hay categorías</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
{% for field in form %}
|
||||||
|
<div class="form-field {% if field.field.widget.input_type == 'checkbox' %}form-field-checkbox{% endif %}">
|
||||||
|
{% if field.name == "tags" %}
|
||||||
|
{{ field.label_tag }}
|
||||||
|
<div class="tag-chip-list">
|
||||||
|
{% for checkbox in field %}
|
||||||
|
<label class="tag-chip">
|
||||||
|
{{ checkbox.tag }}
|
||||||
|
<span>{{ checkbox.choice_label }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{{ field.label_tag }}
|
||||||
|
{{ field }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if field.help_text %}
|
||||||
|
<small class="form-help">{{ field.help_text }}</small>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if field.errors %}
|
||||||
|
<div class="form-errors">{{ field.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="form-errors">{{ form.non_field_errors }}</div>
|
||||||
|
{% endif %}
|
||||||
@ -10,9 +10,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<form method="post">
|
<form method="post" class="app-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.as_p }}
|
{% include "expenses/_form_fields.html" %}
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">Guardar</button>
|
<button type="submit" class="btn btn-primary">Guardar</button>
|
||||||
|
|||||||
@ -18,22 +18,22 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for account in accounts %}
|
{% for row in account_rows %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ account.name }}</td>
|
<td>{{ row.account.name }}</td>
|
||||||
<td>{{ account.initial_balance }}</td>
|
<td>{{ row.account.initial_balance }}</td>
|
||||||
<td>{{ account.current_balance|floatformat:2 }}</td>
|
<td>{{ row.balance|floatformat:2 }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if account.active %}
|
{% if row.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' account.id %}">Editar</a>
|
<a href="{% url 'account_edit' row.account.id %}">Editar</a>
|
||||||
{% if account.active %}
|
{% if row.account.active %}
|
||||||
<a href="{% url 'account_delete' account.id %}" class="danger">Eliminar</a>
|
<a href="{% url 'account_delete' row.account.id %}" class="danger">Eliminar</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -48,4 +48,4 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -428,11 +428,7 @@
|
|||||||
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
|
<div class="progress-fill {{ goal.progress_state }}"
|
||||||
{% 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>
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
Nuevo gasto
|
Nuevo gasto
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>
|
<h1>
|
||||||
{% if form.instance.pk %}
|
{% if form.instance.pk %}
|
||||||
@ -17,34 +17,10 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<form method="post">
|
<form method="post" class="app-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
{% for field in form %}
|
{% include "expenses/_form_fields.html" %}
|
||||||
{% if field.name == "tags" %}
|
|
||||||
<div class="form-field">
|
|
||||||
{{ field.label_tag }}
|
|
||||||
<div class="tag-chip-list">
|
|
||||||
{% for checkbox in field %}
|
|
||||||
<label class="tag-chip">
|
|
||||||
{{ checkbox.tag }}
|
|
||||||
<span>{{ checkbox.choice_label }}</span>
|
|
||||||
</label>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% if field.errors %}
|
|
||||||
<div class="form-errors">{{ field.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p>
|
|
||||||
{{ field.label_tag }} {{ field }}
|
|
||||||
{% if field.errors %}
|
|
||||||
<span class="form-errors">{{ field.errors }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
|
|||||||
@ -8,29 +8,111 @@
|
|||||||
|
|
||||||
<h1>Home</h1>
|
<h1>Home</h1>
|
||||||
|
|
||||||
<section>
|
{% if has_alerts %}
|
||||||
<h2>Resumen del mes</h2>
|
<div class="messages">
|
||||||
<p>Total: {{ kpi_total|floatformat:2 }}</p>
|
{% for goal in exceeded_goals %}
|
||||||
<p>Gastos: {{ kpi_count }}</p>
|
<div class="message warning">
|
||||||
<p>Categorías: {{ kpi_categories }}</p>
|
Presupuesto <strong>{{ goal.name }}</strong> excedido:
|
||||||
</section>
|
{{ goal.progress|floatformat:2 }}€ de {{ goal.target_amount|floatformat:2 }}€.
|
||||||
|
<a href="{% url 'goal_list' %}">Ver objetivos</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% for item in negative_accounts %}
|
||||||
|
<div class="message error">
|
||||||
|
La cuenta <strong>{{ item.account.name }}</strong> está en negativo:
|
||||||
|
{{ item.balance|floatformat:2 }}€.
|
||||||
|
<a href="{% url 'account_list' %}">Ver cuentas</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<section>
|
<section class="home-section">
|
||||||
<h2>Últimos gastos</h2>
|
<h2>Saldo total</h2>
|
||||||
<ul>
|
<p class="balance-total {% if total_balance < 0 %}negative{% endif %}">
|
||||||
{% for expense in last_expenses %}
|
{{ total_balance|floatformat:2 }}€
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="balance-breakdown">
|
||||||
|
{% for item in account_balances %}
|
||||||
<li>
|
<li>
|
||||||
{{ expense.date }} -
|
<span class="balance-account">{{ item.account.name }}</span>
|
||||||
{{ expense.category.name }} -
|
<span class="balance-amount {% if item.balance < 0 %}negative{% endif %}">
|
||||||
{{ expense.amount }}
|
{{ item.balance|floatformat:2 }}€
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<li>No hay gastos</li>
|
<li class="muted">No tienes cuentas activas.</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section class="home-section">
|
||||||
|
<h2>Resumen del mes</h2>
|
||||||
|
|
||||||
|
<div class="kpi-grid">
|
||||||
|
<div class="kpi-card">
|
||||||
|
<span class="kpi-label">Gastado este mes</span>
|
||||||
|
<span class="kpi-value">{{ kpi_total|floatformat:2 }}€</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<span class="kpi-label">Nº de gastos</span>
|
||||||
|
<span class="kpi-value">{{ kpi_count }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<span class="kpi-label">Categorías usadas</span>
|
||||||
|
<span class="kpi-value">{{ kpi_categories }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="comparison">
|
||||||
|
{% if diff_pct is None %}
|
||||||
|
<span class="muted">Sin gastos el mes pasado para comparar.</span>
|
||||||
|
{% else %}
|
||||||
|
Mes anterior: {{ prev_total|floatformat:2 }}€ —
|
||||||
|
{% if diff_amount > 0 %}
|
||||||
|
<span class="amount-negative">
|
||||||
|
+{{ diff_amount|floatformat:2 }}€ ({{ diff_pct|floatformat:1 }}%)
|
||||||
|
</span>
|
||||||
|
{% elif diff_amount < 0 %}
|
||||||
|
<span class="amount-positive">
|
||||||
|
{{ diff_amount|floatformat:2 }}€ ({{ diff_pct|floatformat:1 }}%)
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
sin variación
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<br>
|
||||||
|
<small class="muted">
|
||||||
|
El mes en curso está incompleto: la comparación no es equivalente
|
||||||
|
hasta que termine.
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="home-section">
|
||||||
|
<h2>Últimos movimientos</h2>
|
||||||
|
<ul class="movements-list">
|
||||||
|
{% for mov in movements %}
|
||||||
|
<li class="movement">
|
||||||
|
<span class="movement-date">{{ mov.date }}</span>
|
||||||
|
<span class="movement-label">{{ mov.label }}</span>
|
||||||
|
<span class="movement-account muted">{{ mov.account }}</span>
|
||||||
|
<span class="movement-amount {% if mov.kind == 'income' %}amount-positive{% else %}amount-negative{% endif %}">
|
||||||
|
{% if mov.kind == 'income' %}+{% else %}−{% endif %}{{ mov.amount|floatformat:2 }}€
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{% empty %}
|
||||||
|
<li class="muted">Todavía no hay movimientos.</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<div class="section-actions">
|
||||||
|
<a class="btn btn-secondary" href="{% url 'expense_list' %}">Ver todos los gastos</a>
|
||||||
|
<a class="btn btn-secondary" href="{% url 'income_list' %}">Ver todos los ingresos</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="home-section">
|
||||||
<div class="dashboard-grid">
|
<div class="dashboard-grid">
|
||||||
<div class="card card-chart">
|
<div class="card card-chart">
|
||||||
<h2>Últimos meses</h2>
|
<h2>Últimos meses</h2>
|
||||||
@ -43,7 +125,7 @@
|
|||||||
<script>
|
<script>
|
||||||
const labels = {{ mini_chart_labels|safe }};
|
const labels = {{ mini_chart_labels|safe }};
|
||||||
const data = {{ mini_chart_data|safe }};
|
const data = {{ mini_chart_data|safe }};
|
||||||
|
|
||||||
const ctx = document.getElementById('miniChart');
|
const ctx = document.getElementById('miniChart');
|
||||||
const miniChartColors = getChartColors();
|
const miniChartColors = getChartColors();
|
||||||
|
|
||||||
@ -79,32 +161,39 @@
|
|||||||
applyCartesianColors(chart, colors);
|
applyCartesianColors(chart, colors);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<h3>Objetivos</h3>
|
<section class="home-section">
|
||||||
{% if goals %}
|
<h2>Objetivos</h2>
|
||||||
<div class="goals-widget">
|
{% if goals %}
|
||||||
{% for goal in goals %}
|
<div class="goals-widget">
|
||||||
<div class="goal-card">
|
{% for goal in goals %}
|
||||||
<strong>{{ goal.name }}</strong>
|
<div class="goal-card">
|
||||||
{% if goal.is_exceeded %}
|
<strong>{{ goal.name }}</strong>
|
||||||
<span class="badge badge-inactive">Excedido</span>
|
{% if goal.is_exceeded %}
|
||||||
{% endif %}
|
<span class="badge badge-inactive">Excedido</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="progress-bar">
|
<div class="progress-bar"
|
||||||
<div class="progress-fill {{ goal.progress_state }}"
|
role="progressbar"
|
||||||
style="width: {{ goal.bar_width|unlocalize }}%"></div>
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
aria-valuenow="{{ goal.bar_width|unlocalize }}"
|
||||||
|
aria-label="Progreso de {{ goal.name }}"
|
||||||
|
aria-valuetext="{{ goal.progress|floatformat:1 }}€ de {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%)">
|
||||||
|
<div class="progress-fill {{ goal.progress_state }}"
|
||||||
|
style="width: {{ goal.bar_width|unlocalize }}%"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="progress-label">
|
||||||
|
{{ goal.progress|floatformat:1 }}€ / {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%)
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
<span class="progress-label">
|
</div>
|
||||||
{{ goal.progress|floatformat:1 }}€ / {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%)
|
{% else %}
|
||||||
</span>
|
<p class="muted">No tienes objetivos aún.</p>
|
||||||
</div>
|
{% endif %}
|
||||||
{% endfor %}
|
</section>
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p>No tienes objetivos aún.</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -16,9 +16,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<form method="post">
|
<form method="post" class="app-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.as_p }}
|
{% include "expenses/_form_fields.html" %}
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
{% if form.instance.pk %}
|
{% if form.instance.pk %}
|
||||||
|
|||||||
@ -3,9 +3,9 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Etiqueta</h1>
|
<h1>Etiqueta</h1>
|
||||||
|
|
||||||
<form method="post">
|
<form method="post" class="app-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.as_p }}
|
{% include "expenses/_form_fields.html" %}
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">Guardar</button>
|
<button type="submit" class="btn btn-primary">Guardar</button>
|
||||||
<a class="btn btn-secondary" href="{% url 'tag_list' %}">Volver</a>
|
<a class="btn btn-secondary" href="{% url 'tag_list' %}">Volver</a>
|
||||||
|
|||||||
@ -5,15 +5,34 @@
|
|||||||
|
|
||||||
<a class="btn" href="{% url 'tag_create' %}">➕ Nueva etiqueta</a>
|
<a class="btn" href="{% url 'tag_create' %}">➕ Nueva etiqueta</a>
|
||||||
|
|
||||||
<ul>
|
<div class="table-wrap">
|
||||||
{% for tag in tags %}
|
<table>
|
||||||
<li class="table-actions">
|
<thead>
|
||||||
{{ tag.name }}
|
<tr>
|
||||||
<a href="{% url 'tag_edit' tag.id %}">Editar</a>
|
<th>Nombre</th>
|
||||||
<a href="{% url 'tag_delete' tag.id %}" class="danger">Eliminar</a>
|
<th>Gastos</th>
|
||||||
</li>
|
<th></th>
|
||||||
{% empty %}
|
</tr>
|
||||||
<li>No hay etiquetas</li>
|
</thead>
|
||||||
{% endfor %}
|
<tbody>
|
||||||
</ul>
|
{% for tag in tags %}
|
||||||
{% endblock %}
|
<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 %}
|
||||||
|
|||||||
@ -17,10 +17,10 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<form method="post">
|
<form method="post" class="app-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
|
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
|
||||||
{{ form.as_p }}
|
{% include "expenses/_form_fields.html" %}
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
{% if editing %}
|
{% if editing %}
|
||||||
|
|||||||
@ -1,16 +1,74 @@
|
|||||||
{% extends "expenses/base.html" %}
|
{% extends "expenses/base.html" %}
|
||||||
{% block title %}
|
{% block title %}
|
||||||
Nuevo objetivo
|
{{ title }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>
|
<h1>
|
||||||
Nuevo objetivo
|
{{ title }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<form method="post">
|
<details class="info-panel">
|
||||||
|
<summary>¿Qué tipo de objetivo necesito?</summary>
|
||||||
|
|
||||||
|
<div class="info-panel-content">
|
||||||
|
<h2>Pago / deuda</h2>
|
||||||
|
<p>
|
||||||
|
Para algo que quieres terminar de pagar: devolver un préstamo,
|
||||||
|
costear un viaje a plazos, reunir el importe de una compra grande.
|
||||||
|
El progreso es la suma de los gastos de la categoría elegida desde
|
||||||
|
la fecha de inicio, y llegar al 100% es haberlo conseguido.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Presupuesto</h2>
|
||||||
|
<p>
|
||||||
|
Para ponerte un límite de gasto que no quieres superar. El progreso
|
||||||
|
se reinicia cada mes o cada año, según el periodo que elijas.
|
||||||
|
Aquí acercarse al 100% es un aviso, no un logro: la barra pasa a
|
||||||
|
naranja al llegar al 80% y a rojo si te pasas del límite.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Ahorro</h2>
|
||||||
|
<p>
|
||||||
|
Para acumular dinero hasta una cantidad. Se mide con el saldo de la
|
||||||
|
cuenta que asocies, así que necesita una cuenta en vez de una
|
||||||
|
categoría.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Los demás campos</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Category</strong> — solo para pago y presupuesto: es la
|
||||||
|
categoría cuyos gastos se cuentan.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Include subcategories</strong> — si se marca, también
|
||||||
|
cuentan los gastos de las categorías hijas.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Account</strong> — solo para ahorro: la cuenta cuyo saldo
|
||||||
|
mide el progreso.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Start date</strong> — desde cuándo se cuentan los gastos
|
||||||
|
en un pago. En un presupuesto no se usa: manda el reinicio del
|
||||||
|
periodo.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Period</strong> — solo para presupuesto: cada cuánto
|
||||||
|
vuelve el progreso a cero.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Show on home</strong> — si el objetivo aparece en la
|
||||||
|
pantalla de inicio.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<form method="post" class="app-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.as_p }}
|
{% include "expenses/_form_fields.html" %}
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
Guardar
|
Guardar
|
||||||
|
|||||||
@ -9,57 +9,86 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Objetivos</h1>
|
<h1>Objetivos</h1>
|
||||||
|
|
||||||
<a class="btn btn-primary" href="{% url 'goal_create' %}">➕ Nuevo objetivo</a>
|
<div class="section-actions">
|
||||||
|
<a class="btn btn-primary" href="{% url 'goal_create' %}">➕ Nuevo objetivo</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="get" class="filters">
|
||||||
|
<div class="filters-main">
|
||||||
|
<label class="sr-only" for="filterKind">Tipo</label>
|
||||||
|
<select name="kind" id="filterKind" onchange="this.form.submit()">
|
||||||
|
<option value="">Todos los tipos</option>
|
||||||
|
{% for value, label in kind_choices %}
|
||||||
|
<option value="{{ value }}" {% if selected_kind == value %}selected{% endif %}>
|
||||||
|
{{ label }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Filtrar</button>
|
||||||
|
<a href="{% url 'goal_list' %}" class="btn btn-secondary">Limpiar</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<thead>
|
||||||
<th>Nombre</th>
|
<tr>
|
||||||
<th>Tipo</th>
|
<th>Nombre</th>
|
||||||
<th>Progreso</th>
|
<th>Tipo</th>
|
||||||
<th></th>
|
<th>Progreso</th>
|
||||||
</tr>
|
<th></th>
|
||||||
|
</tr>
|
||||||
{% for goal in goals %}
|
</thead>
|
||||||
<tr>
|
<tbody>
|
||||||
<td>{{ goal.name }}</td>
|
{% for goal in goals %}
|
||||||
<td>
|
<tr>
|
||||||
{{ goal.get_kind_display }}
|
<td>{{ goal.name }}</td>
|
||||||
{% if goal.kind == "budget" %}
|
<td>
|
||||||
<small>({{ goal.get_period_display|lower }})</small>
|
{{ goal.get_kind_display }}
|
||||||
{% endif %}
|
{% if goal.kind == "budget" %}
|
||||||
</td>
|
<small>({{ goal.get_period_display|lower }})</small>
|
||||||
<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 %}
|
{% endif %}
|
||||||
</div>
|
</td>
|
||||||
</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>
|
<td class="table-actions">
|
||||||
<a href="{% url 'goal_edit' goal.id %}">Editar</a>
|
<a href="{% url 'goal_edit' goal.id %}">Editar</a>
|
||||||
<a href="{% url 'goal_delete' goal.id %}" class="danger">Eliminar</a>
|
<a href="{% url 'goal_delete' goal.id %}" class="danger">Eliminar</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="empty-state">
|
||||||
|
<p>No hay objetivos</p>
|
||||||
|
<a href="{% url 'goal_create' %}">Añade el primero</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
<h2>Iniciar sesión</h2>
|
<h2>Iniciar sesión</h2>
|
||||||
|
|
||||||
<form method="post">
|
<form method="post" class="app-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.as_p }}
|
{% include "expenses/_form_fields.html" %}
|
||||||
<button type="submit" class="btn btn-primary">Entrar</button>
|
<button type="submit" class="btn btn-primary">Entrar</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,13 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "expenses/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Contraseña actualizada{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>Contraseña actualizada correctamente</h2>
|
<h1>Contraseña actualizada</h1>
|
||||||
<a href="/">Volver</a>
|
|
||||||
|
<p>Tu contraseña se ha cambiado correctamente.</p>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<a class="btn btn-primary" href="{% url 'home' %}">Volver al inicio</a>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -1,10 +1,16 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "expenses/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Cambiar contraseña{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>Cambiar contraseña</h2>
|
<h1>Cambiar contraseña</h1>
|
||||||
<form method="post">
|
|
||||||
|
<form method="post" class="app-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.as_p }}
|
{% include "expenses/_form_fields.html" %}
|
||||||
<button type="submit" class="btn btn-primary">Cambiar</button>
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Cambiar contraseña</button>
|
||||||
|
<a class="btn btn-secondary" href="{% url 'home' %}">Volver</a>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
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
|
||||||
@ -84,3 +85,17 @@ def test_balance_until_only_counts_entries_on_or_before_date(user, category):
|
|||||||
Income.objects.create(owner=user, account=acc, name="Later", amount=Decimal("50"), date=date(2024, 1, 20))
|
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")}
|
||||||
|
|||||||
@ -99,3 +99,43 @@ 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}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
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
|
||||||
|
|
||||||
@ -194,3 +195,23 @@ 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"] == ""
|
||||||
|
|||||||
32
expenses_manager/expenses/tests/test_tags.py
Normal file
32
expenses_manager/expenses/tests/test_tags.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import pytest
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.urls import reverse
|
||||||
|
from expenses.models import Expense, Tag
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_tag_list_annotates_expense_usage_count(auth_client, user, account, category):
|
||||||
|
used = Tag.objects.create(owner=user, name="Usada")
|
||||||
|
unused = Tag.objects.create(owner=user, name="Sin usar")
|
||||||
|
|
||||||
|
for _ in range(2):
|
||||||
|
expense = Expense.objects.create(
|
||||||
|
owner=user, account=account, category=category, amount=Decimal("10"), date=date.today()
|
||||||
|
)
|
||||||
|
expense.tags.add(used)
|
||||||
|
|
||||||
|
response = auth_client.get(reverse('tag_list'))
|
||||||
|
|
||||||
|
counts = {tag.name: tag.expense_count for tag in response.context["tags"]}
|
||||||
|
assert counts == {"Usada": 2, "Sin usar": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_tag_ordering_ignores_case(user):
|
||||||
|
for name in ["ZZ", "AA", "mk"]:
|
||||||
|
Tag.objects.create(owner=user, name=name)
|
||||||
|
|
||||||
|
names = list(Tag.objects.filter(owner=user).values_list("name", flat=True))
|
||||||
|
|
||||||
|
assert names == ["AA", "mk", "ZZ"]
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import calendar
|
import calendar
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from .models import Account, Category, Expense, FuelEntry, Tag, Income, Goal
|
from .models import Account, Category, Expense, FuelEntry, Tag, Income, Goal
|
||||||
from .forms import (
|
from .forms import (
|
||||||
@ -14,8 +15,8 @@ from .forms import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.db.models import Sum, ProtectedError
|
from django.db.models import Sum, Count, ProtectedError
|
||||||
from django.db.models.functions import ExtractMonth, ExtractYear, ExtractDay
|
from django.db.models.functions import ExtractMonth, ExtractYear, ExtractDay, Lower
|
||||||
|
|
||||||
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
|
||||||
@ -56,23 +57,137 @@ def sub_months(year, month, n):
|
|||||||
return year, month
|
return year, month
|
||||||
|
|
||||||
|
|
||||||
|
def _category_tree(categories):
|
||||||
|
"""Aplana las categorías en preorden (padres antes que hijos, cada nivel
|
||||||
|
ordenado por nombre) junto con su profundidad, para poder indentarlas."""
|
||||||
|
by_parent = {}
|
||||||
|
for category in categories:
|
||||||
|
by_parent.setdefault(category.parent_id, []).append(category)
|
||||||
|
for children in by_parent.values():
|
||||||
|
children.sort(key=lambda c: c.name.lower())
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
def walk(parent_id, depth):
|
||||||
|
for category in by_parent.get(parent_id, []):
|
||||||
|
rows.append({"category": category, "depth": depth})
|
||||||
|
walk(category.id, depth + 1)
|
||||||
|
|
||||||
|
walk(None, 0)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _account_balances(accounts):
|
||||||
|
"""Calcula el saldo de cada cuenta con dos queries agregadas (nada de
|
||||||
|
account.current_balance() en bucle, que dispararía N+1)."""
|
||||||
|
account_ids = [a.id for a in accounts]
|
||||||
|
|
||||||
|
expense_totals = {
|
||||||
|
row["account_id"]: row["total"]
|
||||||
|
for row in Expense.objects.filter(account_id__in=account_ids)
|
||||||
|
.values("account_id")
|
||||||
|
.annotate(total=Sum("amount"))
|
||||||
|
}
|
||||||
|
income_totals = {
|
||||||
|
row["account_id"]: row["total"]
|
||||||
|
for row in Income.objects.filter(account_id__in=account_ids)
|
||||||
|
.values("account_id")
|
||||||
|
.annotate(total=Sum("amount"))
|
||||||
|
}
|
||||||
|
|
||||||
|
account_balances = []
|
||||||
|
negative_accounts = []
|
||||||
|
total_balance = Decimal("0")
|
||||||
|
for acc in accounts:
|
||||||
|
balance = (
|
||||||
|
acc.initial_balance
|
||||||
|
+ (income_totals.get(acc.id) or Decimal("0"))
|
||||||
|
- (expense_totals.get(acc.id) or Decimal("0"))
|
||||||
|
)
|
||||||
|
total_balance += balance
|
||||||
|
account_balances.append({"account": acc, "balance": balance})
|
||||||
|
if balance < 0:
|
||||||
|
negative_accounts.append({"account": acc, "balance": balance})
|
||||||
|
|
||||||
|
return account_balances, negative_accounts, total_balance
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def home(request):
|
def home(request):
|
||||||
|
today = date.today()
|
||||||
expenses = Expense.objects.filter(owner=request.user)
|
expenses = Expense.objects.filter(owner=request.user)
|
||||||
|
|
||||||
# Last expenses
|
# ---- KPIs del mes en curso ----
|
||||||
last_expenses = expenses.select_related("category").order_by("-date")[:5]
|
|
||||||
|
|
||||||
# Simple KPIs (current month)
|
|
||||||
today = date.today()
|
|
||||||
month_expenses = expenses.filter(date__year=today.year, date__month=today.month)
|
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_total = month_expenses.aggregate(total=Sum("amount"))["total"] or 0
|
|
||||||
|
|
||||||
kpi_count = month_expenses.count()
|
kpi_count = month_expenses.count()
|
||||||
|
|
||||||
kpi_categories = month_expenses.values("category").distinct().count()
|
kpi_categories = month_expenses.values("category").distinct().count()
|
||||||
|
|
||||||
|
# ---- Comparativa con el mes anterior ----
|
||||||
|
prev_year, prev_month = sub_months(today.year, today.month, 1)
|
||||||
|
prev_total = (
|
||||||
|
expenses.filter(date__year=prev_year, date__month=prev_month).aggregate(
|
||||||
|
total=Sum("amount")
|
||||||
|
)["total"]
|
||||||
|
or Decimal("0")
|
||||||
|
)
|
||||||
|
diff_amount = kpi_total - prev_total
|
||||||
|
# None cuando no hay mes anterior con gastos (evita división por cero)
|
||||||
|
diff_pct = None
|
||||||
|
if prev_total:
|
||||||
|
diff_pct = (diff_amount / prev_total) * 100
|
||||||
|
|
||||||
|
# ---- Saldos de cuentas ----
|
||||||
|
accounts = list(Account.objects.filter(owner=request.user, active=True))
|
||||||
|
account_balances, negative_accounts, total_balance = _account_balances(accounts)
|
||||||
|
|
||||||
|
# ---- Objetivos ----
|
||||||
|
# Una sola query reutilizada para "goals" (home) y "exceeded_goals" (alertas).
|
||||||
|
# is_exceeded() solo aplica a kind == budget, así que pago y ahorro nunca la disparan.
|
||||||
|
all_goals = list(
|
||||||
|
Goal.objects.filter(owner=request.user).select_related("category", "account")
|
||||||
|
)
|
||||||
|
goals = [g for g in all_goals if g.show_on_home]
|
||||||
|
exceeded_goals = [g for g in all_goals if g.is_exceeded()]
|
||||||
|
|
||||||
|
has_alerts = bool(exceeded_goals or negative_accounts)
|
||||||
|
|
||||||
|
# ---- Últimos movimientos (gastos + ingresos mezclados) ----
|
||||||
|
recent_expenses = expenses.select_related("category", "account").order_by(
|
||||||
|
"-date", "-id"
|
||||||
|
)[:8]
|
||||||
|
recent_incomes = (
|
||||||
|
Income.objects.filter(owner=request.user)
|
||||||
|
.select_related("account")
|
||||||
|
.order_by("-date", "-id")[:8]
|
||||||
|
)
|
||||||
|
|
||||||
|
movements = sorted(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"kind": "expense",
|
||||||
|
"date": e.date,
|
||||||
|
"label": e.category.name,
|
||||||
|
"account": e.account.name,
|
||||||
|
"amount": e.amount,
|
||||||
|
}
|
||||||
|
for e in recent_expenses
|
||||||
|
]
|
||||||
|
+ [
|
||||||
|
{
|
||||||
|
"kind": "income",
|
||||||
|
"date": i.date,
|
||||||
|
"label": i.name,
|
||||||
|
"account": i.account.name,
|
||||||
|
"amount": i.amount,
|
||||||
|
}
|
||||||
|
for i in recent_incomes
|
||||||
|
],
|
||||||
|
key=lambda m: m["date"],
|
||||||
|
reverse=True,
|
||||||
|
)[:8]
|
||||||
|
|
||||||
|
# ---- Mini-gráfico de 6 meses (sin cambios) ----
|
||||||
six_months = []
|
six_months = []
|
||||||
for i in range(5, -1, -1):
|
for i in range(5, -1, -1):
|
||||||
y, m = sub_months(today.year, today.month, i)
|
y, m = sub_months(today.year, today.month, i)
|
||||||
@ -86,28 +201,28 @@ def home(request):
|
|||||||
]
|
]
|
||||||
or 0
|
or 0
|
||||||
)
|
)
|
||||||
|
mini_data.append({"label": f"{m}/{y}", "total": float(total)})
|
||||||
|
|
||||||
mini_data.append(
|
|
||||||
{
|
|
||||||
"label": f"{m}/{y}",
|
|
||||||
"total": float(total),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
goals = Goal.objects.filter(owner=request.user, show_on_home=True)
|
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"expenses/home.html",
|
"expenses/home.html",
|
||||||
{
|
{
|
||||||
"active_menu": "home",
|
"active_menu": "home",
|
||||||
"last_expenses": last_expenses,
|
|
||||||
"kpi_total": kpi_total,
|
"kpi_total": kpi_total,
|
||||||
"kpi_count": kpi_count,
|
"kpi_count": kpi_count,
|
||||||
"kpi_categories": kpi_categories,
|
"kpi_categories": kpi_categories,
|
||||||
|
"prev_total": prev_total,
|
||||||
|
"diff_amount": diff_amount,
|
||||||
|
"diff_pct": diff_pct,
|
||||||
|
"total_balance": total_balance,
|
||||||
|
"account_balances": account_balances,
|
||||||
|
"negative_accounts": negative_accounts,
|
||||||
|
"goals": goals,
|
||||||
|
"exceeded_goals": exceeded_goals,
|
||||||
|
"has_alerts": has_alerts,
|
||||||
|
"movements": movements,
|
||||||
"mini_chart_labels": [x["label"] for x in mini_data],
|
"mini_chart_labels": [x["label"] for x in mini_data],
|
||||||
"mini_chart_data": [x["total"] for x in mini_data],
|
"mini_chart_data": [x["total"] for x in mini_data],
|
||||||
"goals": goals,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -520,7 +635,11 @@ def dashboard(request):
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def tag_list(request):
|
def tag_list(request):
|
||||||
tags = Tag.objects.filter(owner=request.user)
|
tags = (
|
||||||
|
Tag.objects.filter(owner=request.user)
|
||||||
|
.annotate(expense_count=Count("expenses"))
|
||||||
|
.order_by(Lower("name"))
|
||||||
|
)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request, "expenses/tag_list.html", {"active_menu": "settings", "tags": tags}
|
request, "expenses/tag_list.html", {"active_menu": "settings", "tags": tags}
|
||||||
@ -581,11 +700,12 @@ def tag_delete(request, pk):
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def account_list(request):
|
def account_list(request):
|
||||||
accounts = Account.objects.filter(owner=request.user)
|
accounts = list(Account.objects.filter(owner=request.user).order_by(Lower("name")))
|
||||||
|
account_rows, _, _ = _account_balances(accounts)
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"expenses/account_list.html",
|
"expenses/account_list.html",
|
||||||
{"active_menu": "accounts", "accounts": accounts},
|
{"active_menu": "accounts", "account_rows": account_rows},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -870,7 +990,11 @@ def fuel_delete(request, pk):
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def category_list(request):
|
def category_list(request):
|
||||||
categories = Category.objects.filter(owner=request.user)
|
categories = list(
|
||||||
|
Category.objects.filter(owner=request.user)
|
||||||
|
.annotate(expense_count=Count("expenses"))
|
||||||
|
.select_related("parent")
|
||||||
|
)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = CategoryForm(request.POST, user=request.user)
|
form = CategoryForm(request.POST, user=request.user)
|
||||||
@ -887,7 +1011,7 @@ def category_list(request):
|
|||||||
"categories/list.html",
|
"categories/list.html",
|
||||||
{
|
{
|
||||||
"active_menu": "settings",
|
"active_menu": "settings",
|
||||||
"categories": categories,
|
"category_rows": _category_tree(categories),
|
||||||
"form": form,
|
"form": form,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -951,11 +1075,21 @@ 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",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@ -61,14 +61,19 @@ if not DEBUG:
|
|||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
|
# 'expenses' va antes que 'django.contrib.admin' a proposito: el cargador de
|
||||||
|
# plantillas por aplicacion recorre esta lista en orden y se queda con la
|
||||||
|
# primera coincidencia. Si el admin va antes, sus plantillas de registration/
|
||||||
|
# sombrean a las nuestras (paso con password_change_form.html y
|
||||||
|
# password_change_done.html). No reordenar alfabeticamente.
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
|
'expenses',
|
||||||
'django.contrib.admin',
|
'django.contrib.admin',
|
||||||
'django.contrib.auth',
|
'django.contrib.auth',
|
||||||
'django.contrib.contenttypes',
|
'django.contrib.contenttypes',
|
||||||
'django.contrib.sessions',
|
'django.contrib.sessions',
|
||||||
'django.contrib.messages',
|
'django.contrib.messages',
|
||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
'expenses',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user