From d70d0e4239d85d9ebdb2c667fea12d4ff7113003 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Thu, 3 Sep 2026 17:59:01 +0200 Subject: [PATCH 01/12] Anade sistema de estilos de formulario reutilizable Los formularios usaban form.as_p (salida generica de Django) o bucles manuales for field in form repetidos en cada plantilla, sin estilos propios: inputs/selects/textareas sin padding, borde ni foco visibles, y cada plantilla resolvia los errores/ayuda de campo a su manera. Cambios: - base.css: nuevas clases .app-form, .form-field (label + control en columna), estilos de input/select/textarea (incluye estado :focus con el color de foco del tema), .form-field-checkbox para campos booleanos y .form-help para el help_text de los campos. - Nuevo parcial expenses/_form_fields.html: itera los campos del formulario y renderiza label, control, help_text y errores de forma consistente. Incluye un caso especial para el campo "tags" de gastos (CheckboxSelectMultiple), que se pinta como lista de chips en vez de checkboxes sueltos. Este commit solo anade el estilo y el parcial; las plantillas que lo adoptan van en el siguiente commit. --- .../expenses/static/expenses/css/base.css | 49 +++++++++++++++++++ .../templates/expenses/_form_fields.html | 30 ++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 expenses_manager/expenses/templates/expenses/_form_fields.html diff --git a/expenses_manager/expenses/static/expenses/css/base.css b/expenses_manager/expenses/static/expenses/css/base.css index 41e7f7d..8672703 100644 --- a/expenses_manager/expenses/static/expenses/css/base.css +++ b/expenses_manager/expenses/static/expenses/css/base.css @@ -694,10 +694,59 @@ tr:hover { /* Forms */ /* ========================= */ +.app-form { + display: flex; + flex-direction: column; + gap: 1rem; + max-width: 480px; +} + .form-field { + display: flex; + flex-direction: column; + gap: 0.35rem; margin-bottom: 1rem; } +.form-field label { + font-weight: 600; + font-size: 0.9rem; +} + +.form-field input:not([type="checkbox"]):not([type="radio"]), +.form-field select, +.form-field textarea { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: 6px; + background-color: var(--color-surface); + color: var(--color-text); + font: inherit; +} + +.form-field input:focus, +.form-field select:focus, +.form-field textarea:focus { + outline: 2px solid var(--color-focus-ring); + outline-offset: 1px; + border-color: var(--color-primary); +} + +.form-field-checkbox { + flex-direction: row; + align-items: center; +} + +.form-field-checkbox input[type="checkbox"] { + accent-color: var(--color-primary); +} + +.form-help { + font-size: 0.8rem; + color: var(--color-text-muted); +} + .form-errors { color: var(--color-danger-accent); font-size: 0.85rem; diff --git a/expenses_manager/expenses/templates/expenses/_form_fields.html b/expenses_manager/expenses/templates/expenses/_form_fields.html new file mode 100644 index 0000000..d4a3cd3 --- /dev/null +++ b/expenses_manager/expenses/templates/expenses/_form_fields.html @@ -0,0 +1,30 @@ +{% for field in form %} +
+ {% if field.name == "tags" %} + {{ field.label_tag }} +
+ {% for checkbox in field %} + + {% endfor %} +
+ {% else %} + {{ field.label_tag }} + {{ field }} + {% endif %} + + {% if field.help_text %} + {{ field.help_text }} + {% endif %} + + {% if field.errors %} +
{{ field.errors }}
+ {% endif %} +
+{% endfor %} + +{% if form.non_field_errors %} +
{{ form.non_field_errors }}
+{% endif %} From 581eb202c79874ec055f8aa83179e6edaf3babdc Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Thu, 3 Sep 2026 17:59:28 +0200 Subject: [PATCH 02/12] Migra formularios de gastos/ingresos/cuentas/etc al parcial comun Sustituye {{ form.as_p }} (o el bucle for field in form repetido a mano en expense_form.html) por {% include "expenses/_form_fields.html" %} y anade class="app-form" al
en: - categories/form.html, categories/list.html (alta de categoria) - expenses/account_form.html - expenses/expense_form.html - expenses/income_form.html - expenses/tag_form.html - fuel/create.html - goals/form.html - registration/login.html De paso: - categories/list.html: se corrige indentacion de la tabla y las etiquetas de "Editar"/"Eliminar" (estaban dentro de una celda sin problema funcional, solo desalineadas) y se anade una vacia para la columna de acciones. - goals/form.html: el bloque title y el

tenian "Nuevo objetivo" fijo aunque la vista goal_edit ya pasa title="Editar objetivo" en el contexto; ahora la plantilla usa {{ title }}, asi que el formulario de edicion deja de decir "Nuevo objetivo" por error. No se toca la logica de las vistas ni los formularios en forms.py. --- .../expenses/templates/categories/form.html | 4 +-- .../expenses/templates/categories/list.html | 35 ++++++++++--------- .../templates/expenses/account_form.html | 4 +-- .../templates/expenses/expense_form.html | 32 +++-------------- .../templates/expenses/income_form.html | 4 +-- .../expenses/templates/expenses/tag_form.html | 4 +-- .../expenses/templates/fuel/create.html | 4 +-- .../expenses/templates/goals/form.html | 10 +++--- .../templates/registration/login.html | 4 +-- 9 files changed, 39 insertions(+), 62 deletions(-) diff --git a/expenses_manager/expenses/templates/categories/form.html b/expenses_manager/expenses/templates/categories/form.html index 61a27b6..581fff7 100644 --- a/expenses_manager/expenses/templates/categories/form.html +++ b/expenses_manager/expenses/templates/categories/form.html @@ -7,9 +7,9 @@ {% block content %}

Editar categoría

- + {% csrf_token %} - {{ form.as_p }} + {% include "expenses/_form_fields.html" %}
Cancelar diff --git a/expenses_manager/expenses/templates/categories/list.html b/expenses_manager/expenses/templates/categories/list.html index e587118..81d918a 100644 --- a/expenses_manager/expenses/templates/categories/list.html +++ b/expenses_manager/expenses/templates/categories/list.html @@ -8,26 +8,27 @@

Mis categorías

-

Nueva categoría

- +

Nueva categoría

+ {% csrf_token %} - {{ form.as_p }} - + {% include "expenses/_form_fields.html" %} +

Listado

- - - - - - - - - {% for category in categories %} +
CategoríaCategoría padre
+ + + + + + + + + {% for category in categories %} @@ -36,9 +37,9 @@ Eliminar - {% endfor %} - -
CategoríaCategoría padre
{{ category.name }} {% if category.parent %}{{ category.parent.name }}{% endif %}
+ {% endfor %} + +
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/expenses_manager/expenses/templates/expenses/account_form.html b/expenses_manager/expenses/templates/expenses/account_form.html index e017818..92fb5f3 100644 --- a/expenses_manager/expenses/templates/expenses/account_form.html +++ b/expenses_manager/expenses/templates/expenses/account_form.html @@ -10,9 +10,9 @@ {% endif %} -
+ {% csrf_token %} - {{ form.as_p }} + {% include "expenses/_form_fields.html" %}
diff --git a/expenses_manager/expenses/templates/expenses/expense_form.html b/expenses_manager/expenses/templates/expenses/expense_form.html index a925943..f900d70 100644 --- a/expenses_manager/expenses/templates/expenses/expense_form.html +++ b/expenses_manager/expenses/templates/expenses/expense_form.html @@ -7,7 +7,7 @@ Nuevo gasto {% endif %} {% endblock %} - + {% block content %}

{% if form.instance.pk %} @@ -17,34 +17,10 @@ {% endif %}

- + {% csrf_token %} - - {% for field in form %} - {% if field.name == "tags" %} -
- {{ field.label_tag }} -
- {% for checkbox in field %} - - {% endfor %} -
- {% if field.errors %} -
{{ field.errors }}
- {% endif %} -
- {% else %} -

- {{ field.label_tag }} {{ field }} - {% if field.errors %} - {{ field.errors }} - {% endif %} -

- {% endif %} - {% endfor %} + + {% include "expenses/_form_fields.html" %}
Volver diff --git a/expenses_manager/expenses/templates/fuel/create.html b/expenses_manager/expenses/templates/fuel/create.html index 58a36cf..77cdb60 100644 --- a/expenses_manager/expenses/templates/fuel/create.html +++ b/expenses_manager/expenses/templates/fuel/create.html @@ -17,10 +17,10 @@ {% endif %} - + {% csrf_token %} {% if next %}{% endif %} - {{ form.as_p }} + {% include "expenses/_form_fields.html" %}
From 94308cbfe8b552fa2891f75745cda612927a72b1 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Thu, 3 Sep 2026 17:59:53 +0200 Subject: [PATCH 03/12] Corrige registration/password_change_*.html: extendian "base.html", que no existe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ambas plantillas hacian {% extends "base.html" %}, pero en este proyecto no existe ninguna plantilla llamada asi a nivel raiz: el layout de la app se llama "expenses/base.html" (esta dentro de expenses/templates/expenses/, no de expenses/templates/). Con APP_DIRS=True y DIRS=[] en settings.py, Django busca "base.html" tal cual en la carpeta templates/ de cada app instalada y no lo encuentra en ninguna, asi que un {% extends %} a esa ruta lanza TemplateDoesNotExist en cuanto se intenta renderizar la plantilla. Esto llevaba tiempo sin detectarse porque, hasta el commit de INSTALLED_APPS de esta misma tanda, django.contrib.admin sombreaba estas dos plantillas con las suyas propias (ver ese commit): la plantilla rota de la app nunca llegaba a cargarse, asi que el error nunca saltaba. Al arreglar el orden de INSTALLED_APPS estas plantillas pasan a usarse de verdad, así que había que arreglarlas ahora sí en el mismo movimiento. Cambios: - Ambas extienden "expenses/base.html". - Se migran al parcial expenses/_form_fields.html y a class="app-form", igual que el resto de formularios (ver commit anterior). - Se anade un enlace de "Volver" a {% url 'home' %} en las dos, y un {% block title %} (heredaban el titulo generico "Expenses manager"). --- .../registration/password_change_done.html | 13 ++++++++++--- .../registration/password_change_form.html | 16 +++++++++++----- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/expenses_manager/expenses/templates/registration/password_change_done.html b/expenses_manager/expenses/templates/registration/password_change_done.html index 6e67296..3411f85 100644 --- a/expenses_manager/expenses/templates/registration/password_change_done.html +++ b/expenses_manager/expenses/templates/registration/password_change_done.html @@ -1,6 +1,13 @@ -{% extends "base.html" %} +{% extends "expenses/base.html" %} + +{% block title %}Contraseña actualizada{% endblock %} {% block content %} -

Contraseña actualizada correctamente

- Volver +

Contraseña actualizada

+ +

Tu contraseña se ha cambiado correctamente.

+ + {% endblock %} \ No newline at end of file diff --git a/expenses_manager/expenses/templates/registration/password_change_form.html b/expenses_manager/expenses/templates/registration/password_change_form.html index 0145f5a..52494c6 100644 --- a/expenses_manager/expenses/templates/registration/password_change_form.html +++ b/expenses_manager/expenses/templates/registration/password_change_form.html @@ -1,10 +1,16 @@ -{% extends "base.html" %} +{% extends "expenses/base.html" %} + +{% block title %}Cambiar contraseña{% endblock %} {% block content %} -

Cambiar contraseña

-
+

Cambiar contraseña

+ + {% csrf_token %} - {{ form.as_p }} - + {% include "expenses/_form_fields.html" %} +
+ + Volver +
{% endblock %} \ No newline at end of file From 5852941136a895f85a1ef16178a85071537e821a Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Thu, 3 Sep 2026 18:00:22 +0200 Subject: [PATCH 04/12] Mueve 'expenses' antes que 'django.contrib.admin' en INSTALLED_APPS Sintoma: /accounts/password_change/ renderizaba la pantalla del admin de Django (cabecera "Administracion de Django", breadcrumb "Inicio > Cambio de contrasena") en vez de la plantilla de la app. Causa: con APP_DIRS=True (settings.py) y sin DIRS explicito, Django busca plantillas recorriendo INSTALLED_APPS en orden y usa la PRIMERA coincidencia por ruta relativa. django.contrib.admin trae sus propias registration/password_change_form.html y registration/password_change_done.html. Como 'django.contrib.admin' aparecia antes que 'expenses' en la lista, sus plantillas ganaban siempre a las de nuestra app, que ni siquiera llegaban a evaluarse. Verificado con el shell de Django (get_template(...).origin.name): Antes del cambio: registration/password_change_form.html -> .../django/contrib/admin/templates/registration/password_change_form.html registration/password_change_done.html -> .../django/contrib/admin/templates/registration/password_change_done.html Despues del cambio: registration/password_change_form.html -> .../expenses/templates/registration/password_change_form.html registration/password_change_done.html -> .../expenses/templates/registration/password_change_done.html Esto tambien explica por que el bug de las plantillas rotas (extendian "base.html", que no existe; ver commit anterior) llevaba tanto tiempo sin dar error: al estar sombreadas, esas plantillas nunca se renderizaban, asi que su TemplateDoesNotExist nunca saltaba. Y explica por que el login SI funcionaba pese a la colision de app: el admin publica su login en "admin/login.html", no en "registration/login.html", asi que ahi no habia conflicto. Efecto secundario esperado y comprobado (no es una regresion, pero hay que saberlo): /admin/password_change/, que Django resuelve tambien por la ruta "registration/password_change_form.html" cuando un admin cambia su propia contrasena, ahora renderiza con el layout completo de la app (menu de navegacion, etc.) en vez del look propio del admin. Probado con un superusuario contra /admin/, /admin/login/, /admin/password_change/ y /admin/password_change/done/: los cuatro responden 200 y el formulario funciona (AdminPasswordChangeForm hereda los mismos campos old_password/new_password1/new_password2 de PasswordChangeForm, asi que el parcial generico de campos los pinta sin problema). El resto del admin (listados, cambio de contrasena de otros usuarios, etc.) no usa ninguna plantilla bajo registration/ ni admin/ que la app sobrescriba, asi que no le afecta. No se reordena alfabeticamente esta lista en el futuro sin tener en cuenta esto: hay un comentario en el propio settings.py explicandolo. --- expenses_manager/expenses_manager/settings.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/expenses_manager/expenses_manager/settings.py b/expenses_manager/expenses_manager/settings.py index b85bcb2..8561b56 100644 --- a/expenses_manager/expenses_manager/settings.py +++ b/expenses_manager/expenses_manager/settings.py @@ -61,14 +61,19 @@ if not DEBUG: # Application definition +# 'expenses' va antes que 'django.contrib.admin' a proposito: el cargador de +# plantillas por aplicacion recorre esta lista en orden y se queda con la +# primera coincidencia. Si el admin va antes, sus plantillas de registration/ +# sombrean a las nuestras (paso con password_change_form.html y +# password_change_done.html). No reordenar alfabeticamente. INSTALLED_APPS = [ + 'expenses', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', - 'expenses', ] MIDDLEWARE = [ From b04a8db080e3bdb79bb63fa8c0af3dbbaf217189 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Thu, 3 Sep 2026 18:01:01 +0200 Subject: [PATCH 05/12] Amplia el dashboard de Home: KPIs, saldos, comparativa y movimientos home() (views.py) y expenses/home.html pasan de mostrar tres numeros sueltos y un listado plano de ultimos gastos a un panel mas completo: - Saldo total y desglose por cuenta (account_balances/total_balance), calculado con dos queries agregadas (values().annotate(Sum(...))) en vez de N+1 llamadas a account.current_balance() en un bucle. - Comparativa del gasto del mes en curso contra el mes anterior (prev_total/diff_amount/diff_pct), con aviso de que el mes en curso esta incompleto y la comparacion no es equivalente. - Avisos (has_alerts) cuando hay objetivos excedidos o cuentas en negativo, con enlace directo a objetivos/cuentas. - "Ultimos movimientos" mezcla gastos e ingresos (antes solo se veian los ultimos gastos) ordenados por fecha, con icono/color segun tipo. - Se reutiliza una unica query de Goal tanto para el widget de objetivos (show_on_home) como para las alertas de excedidos (is_exceeded()), en vez de dos queries separadas. base.css: nuevo bloque "/* Home widgets */" con las clases que usa la plantilla (.home-section, .kpi-grid/.kpi-card, .balance-*, .movements-list/.movement-*, .amount-positive/.amount-negative, .comparison), incluyendo un ajuste responsive que oculta la cuenta en movil (<480px). No relacionado con los formularios de los commits anteriores; se incluye aqui porque estaba pendiente de subir en la misma rama. --- .../expenses/static/expenses/css/base.css | 109 ++++++++++++ .../expenses/templates/expenses/home.html | 167 ++++++++++++++---- expenses_manager/expenses/views.py | 128 +++++++++++--- 3 files changed, 345 insertions(+), 59 deletions(-) diff --git a/expenses_manager/expenses/static/expenses/css/base.css b/expenses_manager/expenses/static/expenses/css/base.css index 8672703..a704d0b 100644 --- a/expenses_manager/expenses/static/expenses/css/base.css +++ b/expenses_manager/expenses/static/expenses/css/base.css @@ -909,6 +909,115 @@ tr:hover { color: var(--color-text-muted); } +/* ========================= */ +/* Home widgets */ +/* ========================= */ + +.home-section { + margin-bottom: 2.5rem; +} + +.home-section > h2 { + margin-bottom: 0.75rem; +} + +.section-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1.25rem; +} + +.balance-total { + font-size: 2rem; + font-weight: 600; + color: var(--color-success-accent); + margin: 0 0 1rem; +} + +.balance-total.negative, +.balance-amount.negative { + color: var(--color-danger-accent); +} + +.balance-breakdown { + list-style: none; + margin: 0; + padding: 0; + max-width: 420px; +} + +.balance-breakdown li { + display: flex; + justify-content: space-between; + padding: 0.4rem 0; + border-bottom: 1px solid var(--color-border); +} + +.balance-amount { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.movements-list { + list-style: none; + margin: 0; + padding: 0; +} + +.movement { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0; + border-bottom: 1px solid var(--color-border); +} + +.movement-date { + flex-shrink: 0; + font-size: 0.85rem; + color: var(--color-text-muted); + font-variant-numeric: tabular-nums; +} + +.movement-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.movement-account { + flex-shrink: 0; + font-size: 0.85rem; + color: var(--color-text-muted); +} + +.movement-amount { + flex-shrink: 0; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.amount-positive { + color: var(--color-success-accent); +} + +.amount-negative { + color: var(--color-danger-accent); +} + +.comparison { + margin-top: 1rem; +} + +@media (max-width: 480px) { + .movement-account { + display: none; + } +} + /* ========================= */ /* Responsive */ /* ========================= */ diff --git a/expenses_manager/expenses/templates/expenses/home.html b/expenses_manager/expenses/templates/expenses/home.html index 835415b..06ff6ca 100644 --- a/expenses_manager/expenses/templates/expenses/home.html +++ b/expenses_manager/expenses/templates/expenses/home.html @@ -8,29 +8,111 @@

Home

-
-

Resumen del mes

-

Total: {{ kpi_total|floatformat:2 }}

-

Gastos: {{ kpi_count }}

-

Categorías: {{ kpi_categories }}

-
+ {% if has_alerts %} +
+ {% for goal in exceeded_goals %} +
+ Presupuesto {{ goal.name }} excedido: + {{ goal.progress|floatformat:2 }}€ de {{ goal.target_amount|floatformat:2 }}€. + Ver objetivos +
+ {% endfor %} + {% for item in negative_accounts %} +
+ La cuenta {{ item.account.name }} está en negativo: + {{ item.balance|floatformat:2 }}€. + Ver cuentas +
+ {% endfor %} +
+ {% endif %} -
-

Últimos gastos

-
    - {% for expense in last_expenses %} +
    +

    Saldo total

    +

    + {{ total_balance|floatformat:2 }}€ +

    + +
      + {% for item in account_balances %}
    • - {{ expense.date }} - - {{ expense.category.name }} - - {{ expense.amount }} + + + {{ item.balance|floatformat:2 }}€ +
    • {% empty %} -
    • No hay gastos
    • +
    • No tienes cuentas activas.
    • {% endfor %}
    -
    +
    +

    Resumen del mes

    + +
    +
    + Gastado este mes + {{ kpi_total|floatformat:2 }}€ +
    +
    + Nº de gastos + {{ kpi_count }} +
    +
    + Categorías usadas + {{ kpi_categories }} +
    +
    + +

    + {% if diff_pct is None %} + Sin gastos el mes pasado para comparar. + {% else %} + Mes anterior: {{ prev_total|floatformat:2 }}€ — + {% if diff_amount > 0 %} + + +{{ diff_amount|floatformat:2 }}€ ({{ diff_pct|floatformat:1 }}%) + + {% elif diff_amount < 0 %} + + {{ diff_amount|floatformat:2 }}€ ({{ diff_pct|floatformat:1 }}%) + + {% else %} + sin variación + {% endif %} + {% endif %} +
    + + El mes en curso está incompleto: la comparación no es equivalente + hasta que termine. + +

    +
    + +
    +

    Últimos movimientos

    +
      + {% for mov in movements %} +
    • + {{ mov.date }} + {{ mov.label }} + + + {% if mov.kind == 'income' %}+{% else %}−{% endif %}{{ mov.amount|floatformat:2 }}€ + +
    • + {% empty %} +
    • Todavía no hay movimientos.
    • + {% endfor %} +
    + +
    + +

    Últimos meses

    @@ -43,7 +125,7 @@ -
    -

    Objetivos

    - {% if goals %} -
    - {% for goal in goals %} -
    - {{ goal.name }} - {% if goal.is_exceeded %} - Excedido - {% endif %} +
    +

    Objetivos

    + {% if goals %} +
    + {% for goal in goals %} +
    + {{ goal.name }} + {% if goal.is_exceeded %} + Excedido + {% endif %} -
    -
    +
    +
    +
    + + + {{ goal.progress|floatformat:1 }}€ / {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%) +
    - - - {{ goal.progress|floatformat:1 }}€ / {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%) - -
    - {% endfor %} -
    - {% else %} -

    No tienes objetivos aún.

    - {% endif %} + {% endfor %} +
    + {% else %} +

    No tienes objetivos aún.

    + {% endif %} +
    {% endblock %} diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index 65cd142..6f8e710 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -1,6 +1,7 @@ import logging import calendar from datetime import date, datetime +from decimal import Decimal from django.contrib import messages from .models import Account, Category, Expense, FuelEntry, Tag, Income, Goal from .forms import ( @@ -58,21 +59,108 @@ def sub_months(year, month, n): @login_required def home(request): + today = date.today() expenses = Expense.objects.filter(owner=request.user) - # Last expenses - last_expenses = expenses.select_related("category").order_by("-date")[:5] - - # Simple KPIs (current month) - today = date.today() + # ---- KPIs del mes en curso ---- month_expenses = expenses.filter(date__year=today.year, date__month=today.month) - - kpi_total = month_expenses.aggregate(total=Sum("amount"))["total"] or 0 - + kpi_total = month_expenses.aggregate(total=Sum("amount"))["total"] or Decimal("0") kpi_count = month_expenses.count() - kpi_categories = month_expenses.values("category").distinct().count() + # ---- Comparativa con el mes anterior ---- + prev_year, prev_month = sub_months(today.year, today.month, 1) + prev_total = ( + expenses.filter(date__year=prev_year, date__month=prev_month).aggregate( + total=Sum("amount") + )["total"] + or Decimal("0") + ) + diff_amount = kpi_total - prev_total + # None cuando no hay mes anterior con gastos (evita división por cero) + diff_pct = None + if prev_total: + diff_pct = (diff_amount / prev_total) * 100 + + # ---- Saldos de cuentas ---- + # Dos queries agregadas en lugar de N+1 (nada de account.current_balance() en bucle). + accounts = list(Account.objects.filter(owner=request.user, active=True)) + account_ids = [a.id for a in accounts] + + expense_totals = { + row["account_id"]: row["total"] + for row in Expense.objects.filter(account_id__in=account_ids) + .values("account_id") + .annotate(total=Sum("amount")) + } + income_totals = { + row["account_id"]: row["total"] + for row in Income.objects.filter(account_id__in=account_ids) + .values("account_id") + .annotate(total=Sum("amount")) + } + + account_balances = [] + negative_accounts = [] + total_balance = Decimal("0") + for acc in accounts: + balance = ( + acc.initial_balance + + (income_totals.get(acc.id) or Decimal("0")) + - (expense_totals.get(acc.id) or Decimal("0")) + ) + total_balance += balance + account_balances.append({"account": acc, "balance": balance}) + if balance < 0: + negative_accounts.append({"account": acc, "balance": balance}) + + # ---- Objetivos ---- + # Una sola query reutilizada para "goals" (home) y "exceeded_goals" (alertas). + # is_exceeded() solo aplica a kind == budget, así que pago y ahorro nunca la disparan. + all_goals = list( + Goal.objects.filter(owner=request.user).select_related("category", "account") + ) + goals = [g for g in all_goals if g.show_on_home] + exceeded_goals = [g for g in all_goals if g.is_exceeded()] + + has_alerts = bool(exceeded_goals or negative_accounts) + + # ---- Últimos movimientos (gastos + ingresos mezclados) ---- + recent_expenses = expenses.select_related("category", "account").order_by( + "-date", "-id" + )[:8] + recent_incomes = ( + Income.objects.filter(owner=request.user) + .select_related("account") + .order_by("-date", "-id")[:8] + ) + + movements = sorted( + [ + { + "kind": "expense", + "date": e.date, + "label": e.category.name, + "account": e.account.name, + "amount": e.amount, + } + for e in recent_expenses + ] + + [ + { + "kind": "income", + "date": i.date, + "label": i.name, + "account": i.account.name, + "amount": i.amount, + } + for i in recent_incomes + ], + key=lambda m: m["date"], + reverse=True, + )[:8] + + # ---- Mini-gráfico de 6 meses (sin cambios) ---- six_months = [] for i in range(5, -1, -1): y, m = sub_months(today.year, today.month, i) @@ -86,28 +174,28 @@ def home(request): ] or 0 ) + mini_data.append({"label": f"{m}/{y}", "total": float(total)}) - mini_data.append( - { - "label": f"{m}/{y}", - "total": float(total), - } - ) - - goals = Goal.objects.filter(owner=request.user, show_on_home=True) - return render( request, "expenses/home.html", { "active_menu": "home", - "last_expenses": last_expenses, "kpi_total": kpi_total, "kpi_count": kpi_count, "kpi_categories": kpi_categories, + "prev_total": prev_total, + "diff_amount": diff_amount, + "diff_pct": diff_pct, + "total_balance": total_balance, + "account_balances": account_balances, + "negative_accounts": negative_accounts, + "goals": goals, + "exceeded_goals": exceeded_goals, + "has_alerts": has_alerts, + "movements": movements, "mini_chart_labels": [x["label"] for x in mini_data], "mini_chart_data": [x["total"] for x in mini_data], - "goals": goals, }, ) From e65b15ccf8bf7bca3417ebc5bacf6d471b58bdae Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 9 Sep 2026 15:34:37 +0200 Subject: [PATCH 06/12] Extrae helpers de vistas y rehace los listados de cuentas y categorias Saca de home() dos ayudantes reutilizables: - _account_balances(): calcula el saldo de cada cuenta con dos queries agregadas en lugar de llamar a account.current_balance() en bucle, que disparaba un N+1. - _category_tree(): aplana las categorias en preorden con su profundidad, para poder indentarlas en la plantilla. Con ellos, account_list pasa a mostrar el saldo calculado de cada cuenta (antes usaba current_balance() desde la plantilla) y category_list muestra un arbol indentado en vez de una columna "Categoria padre" que obligaba a reconstruir la jerarquia mentalmente. Co-Authored-By: Claude Opus 5 --- .../expenses/static/expenses/css/base.css | 7 ++ .../expenses/templates/categories/list.html | 16 ++-- .../templates/expenses/account_list.html | 18 ++-- .../expenses/tests/test_accounts.py | 15 ++++ .../expenses/tests/test_categories.py | 17 ++++ expenses_manager/expenses/views.py | 84 ++++++++++++------- 6 files changed, 114 insertions(+), 43 deletions(-) diff --git a/expenses_manager/expenses/static/expenses/css/base.css b/expenses_manager/expenses/static/expenses/css/base.css index a704d0b..48588b1 100644 --- a/expenses_manager/expenses/static/expenses/css/base.css +++ b/expenses_manager/expenses/static/expenses/css/base.css @@ -638,6 +638,12 @@ tr:hover { margin-top: 0.5rem; } +.cat-depth-0 { padding-left: 0; } +.cat-depth-1 { padding-left: 1.5rem; } +.cat-depth-2 { padding-left: 3rem; } +.cat-depth-3 { padding-left: 4.5rem; } +.cat-depth-4 { padding-left: 6rem; } + .pagination { margin-top: 1rem; } @@ -926,6 +932,7 @@ tr:hover { flex-wrap: wrap; gap: 0.5rem; margin-top: 1.25rem; + margin-bottom: 1.25rem; } .balance-total { diff --git a/expenses_manager/expenses/templates/categories/list.html b/expenses_manager/expenses/templates/categories/list.html index 81d918a..0a40561 100644 --- a/expenses_manager/expenses/templates/categories/list.html +++ b/expenses_manager/expenses/templates/categories/list.html @@ -23,18 +23,22 @@ Categoría - Categoría padre - {% for category in categories %} + {% for row in category_rows %} - {{ category.name }} - {% if category.parent %}{{ category.parent.name }}{% endif %} + {{ row.category.name }} - Editar - Eliminar + Editar + Eliminar + + + {% empty %} + + +

    No hay categorías

    {% endfor %} diff --git a/expenses_manager/expenses/templates/expenses/account_list.html b/expenses_manager/expenses/templates/expenses/account_list.html index c869f28..c94dbc3 100644 --- a/expenses_manager/expenses/templates/expenses/account_list.html +++ b/expenses_manager/expenses/templates/expenses/account_list.html @@ -18,22 +18,22 @@ - {% for account in accounts %} + {% for row in account_rows %} - {{ account.name }} - {{ account.initial_balance }} - {{ account.current_balance|floatformat:2 }} + {{ row.account.name }} + {{ row.account.initial_balance }} + {{ row.balance|floatformat:2 }} - {% if account.active %} + {% if row.account.active %} Activa {% else %} Inactiva {% endif %} - Editar - {% if account.active %} - Eliminar + Editar + {% if row.account.active %} + Eliminar {% endif %} @@ -48,4 +48,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/expenses_manager/expenses/tests/test_accounts.py b/expenses_manager/expenses/tests/test_accounts.py index d025ad6..c784bc6 100644 --- a/expenses_manager/expenses/tests/test_accounts.py +++ b/expenses_manager/expenses/tests/test_accounts.py @@ -1,6 +1,7 @@ import pytest from datetime import date from decimal import Decimal +from django.urls import reverse from expenses.models import Account, Expense, Income, Category pytestmark = pytest.mark.django_db @@ -84,3 +85,17 @@ def test_balance_until_only_counts_entries_on_or_before_date(user, category): Income.objects.create(owner=user, account=acc, name="Later", amount=Decimal("50"), date=date(2024, 1, 20)) assert acc.balance_until(date(2024, 1, 15)) == Decimal("-25") + + +def test_account_list_shows_all_accounts_with_computed_balances(auth_client, user, category, django_assert_max_num_queries): + active = Account.objects.create(owner=user, name="Activa", initial_balance=Decimal("100")) + Account.objects.create(owner=user, name="Inactiva", initial_balance=Decimal("50"), active=False) + Income.objects.create(owner=user, account=active, name="Nomina", amount=Decimal("20"), date=date.today()) + Expense.objects.create(owner=user, account=active, category=category, amount=Decimal("5"), date=date.today()) + + with django_assert_max_num_queries(10): + response = auth_client.get(reverse('account_list')) + + assert response.status_code == 200 + rows = {row["account"].name: row["balance"] for row in response.context["account_rows"]} + assert rows == {"Activa": Decimal("115"), "Inactiva": Decimal("50")} diff --git a/expenses_manager/expenses/tests/test_categories.py b/expenses_manager/expenses/tests/test_categories.py index a8622b0..0d6a6c2 100644 --- a/expenses_manager/expenses/tests/test_categories.py +++ b/expenses_manager/expenses/tests/test_categories.py @@ -99,3 +99,20 @@ def test_category_delete_without_expenses_succeeds(auth_client, user): assert response.status_code == 302 assert not Category.objects.filter(pk=cat.pk).exists() + + +def test_category_list_view_returns_tree_ordered_with_depth(auth_client, user): + root_a = Category.objects.create(name="A raiz", owner=user) + Category.objects.create(name="B raiz", owner=user) + child = Category.objects.create(name="Hijo", owner=user, parent=root_a) + Category.objects.create(name="Nieto", owner=user, parent=child) + + response = auth_client.get(reverse('category_list')) + + rows = [(row["category"].name, row["depth"]) for row in response.context["category_rows"]] + assert rows == [ + ("A raiz", 0), + ("Hijo", 1), + ("Nieto", 2), + ("B raiz", 0), + ] diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index 6f8e710..f297875 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -57,34 +57,29 @@ def sub_months(year, month, n): return year, month -@login_required -def home(request): - today = date.today() - expenses = Expense.objects.filter(owner=request.user) +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) - # ---- KPIs del mes en curso ---- - month_expenses = expenses.filter(date__year=today.year, date__month=today.month) - kpi_total = month_expenses.aggregate(total=Sum("amount"))["total"] or Decimal("0") - kpi_count = month_expenses.count() - kpi_categories = month_expenses.values("category").distinct().count() + rows = [] - # ---- 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 + def walk(parent_id, depth): + for category in by_parent.get(parent_id, []): + rows.append({"category": category, "depth": depth}) + walk(category.id, depth + 1) - # ---- Saldos de cuentas ---- - # Dos queries agregadas en lugar de N+1 (nada de account.current_balance() en bucle). - accounts = list(Account.objects.filter(owner=request.user, active=True)) + 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 = { @@ -114,6 +109,38 @@ def home(request): if balance < 0: negative_accounts.append({"account": acc, "balance": balance}) + return account_balances, negative_accounts, total_balance + + +@login_required +def home(request): + today = date.today() + expenses = Expense.objects.filter(owner=request.user) + + # ---- KPIs del mes en curso ---- + month_expenses = expenses.filter(date__year=today.year, date__month=today.month) + kpi_total = month_expenses.aggregate(total=Sum("amount"))["total"] or Decimal("0") + kpi_count = month_expenses.count() + kpi_categories = month_expenses.values("category").distinct().count() + + # ---- Comparativa con el mes anterior ---- + prev_year, prev_month = sub_months(today.year, today.month, 1) + prev_total = ( + expenses.filter(date__year=prev_year, date__month=prev_month).aggregate( + total=Sum("amount") + )["total"] + or Decimal("0") + ) + diff_amount = kpi_total - prev_total + # None cuando no hay mes anterior con gastos (evita división por cero) + diff_pct = None + if prev_total: + diff_pct = (diff_amount / prev_total) * 100 + + # ---- Saldos de cuentas ---- + accounts = list(Account.objects.filter(owner=request.user, active=True)) + account_balances, negative_accounts, total_balance = _account_balances(accounts) + # ---- Objetivos ---- # Una sola query reutilizada para "goals" (home) y "exceeded_goals" (alertas). # is_exceeded() solo aplica a kind == budget, así que pago y ahorro nunca la disparan. @@ -669,11 +696,12 @@ def tag_delete(request, pk): @login_required def account_list(request): - accounts = Account.objects.filter(owner=request.user) + accounts = list(Account.objects.filter(owner=request.user).order_by("name")) + account_rows, _, _ = _account_balances(accounts) return render( request, "expenses/account_list.html", - {"active_menu": "accounts", "accounts": accounts}, + {"active_menu": "accounts", "account_rows": account_rows}, ) @@ -975,7 +1003,7 @@ def category_list(request): "categories/list.html", { "active_menu": "settings", - "categories": categories, + "category_rows": _category_tree(categories), "form": form, }, ) From 22563df48a0890d38fb827e73a453c33056dd63b Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 9 Sep 2026 15:36:02 +0200 Subject: [PATCH 07/12] Muestra las etiquetas en tabla con su numero de gastos El listado de etiquetas era una lista sin mas, sin forma de saber cuales estaban en uso. Pasa a tabla, en linea con el resto de listados, y anade una columna con el numero de gastos que usa cada etiqueta. El contador se calcula con annotate(Count("expenses")) en la vista, en vez de contar desde la plantilla, para no provocar una query por fila. Incluye tambien el estado vacio, que la lista no tenia. Co-Authored-By: Claude Opus 5 --- .../expenses/templates/expenses/tag_list.html | 43 +++++++++++++------ expenses_manager/expenses/tests/test_tags.py | 23 ++++++++++ expenses_manager/expenses/views.py | 8 +++- 3 files changed, 60 insertions(+), 14 deletions(-) create mode 100644 expenses_manager/expenses/tests/test_tags.py diff --git a/expenses_manager/expenses/templates/expenses/tag_list.html b/expenses_manager/expenses/templates/expenses/tag_list.html index 1291ade..163870f 100644 --- a/expenses_manager/expenses/templates/expenses/tag_list.html +++ b/expenses_manager/expenses/templates/expenses/tag_list.html @@ -5,15 +5,34 @@ ➕ Nueva etiqueta -
    - {% for tag in tags %} -
  • - {{ tag.name }} - Editar - Eliminar -
  • - {% empty %} -
  • No hay etiquetas
  • - {% endfor %} -
-{% endblock %} \ No newline at end of file +
+ + + + + + + + + + {% for tag in tags %} + + + + + + {% empty %} + + + + {% endfor %} + +
NombreGastos
{{ tag.name }}{{ tag.expense_count }} + Editar + Eliminar +
+

No hay etiquetas

+ Añade la primera +
+
+{% endblock %} diff --git a/expenses_manager/expenses/tests/test_tags.py b/expenses_manager/expenses/tests/test_tags.py new file mode 100644 index 0000000..f915286 --- /dev/null +++ b/expenses_manager/expenses/tests/test_tags.py @@ -0,0 +1,23 @@ +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} diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index f297875..a24ffc9 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -15,7 +15,7 @@ from .forms import ( ) 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.contrib.auth.decorators import login_required @@ -635,7 +635,11 @@ def dashboard(request): @login_required def tag_list(request): - tags = Tag.objects.filter(owner=request.user) + tags = ( + Tag.objects.filter(owner=request.user) + .annotate(expense_count=Count("expenses")) + .order_by("name") + ) return render( request, "expenses/tag_list.html", {"active_menu": "settings", "tags": tags} From e814bc2d030326533653d6868e6d839773497698 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 9 Sep 2026 16:16:58 +0200 Subject: [PATCH 08/12] Anade filtro por tipo en objetivos y unifica la barra de progreso El listado de objetivos mezclaba pagos, presupuestos y ahorros sin forma de separarlos. Anade un filtro por tipo, con el mismo patron que los filtros de gastos, y ordena los objetivos por nombre. Un valor de "kind" que no este en KIND_CHOICES se ignora en vez de romper. De paso, la barra de progreso pasa a usar goal.progress_state en lugar de decidir el color en la plantilla con un if sobre el porcentaje. La logica ya vivia en el modelo, que ademas distingue presupuestos (donde acercarse al 100% es un aviso) del resto; la plantilla del dashboard seguia con las clases antiguas low/medium/high, que se retiran del CSS. Incluye estado vacio y thead/tbody en la tabla, que le faltaban. Co-Authored-By: Claude Opus 5 --- .../expenses/static/expenses/css/base.css | 20 ++- .../templates/expenses/dashboard.html | 6 +- .../expenses/templates/goals/list.html | 121 +++++++++++------- expenses_manager/expenses/tests/test_goals.py | 21 +++ expenses_manager/expenses/views.py | 10 ++ 5 files changed, 123 insertions(+), 55 deletions(-) diff --git a/expenses_manager/expenses/static/expenses/css/base.css b/expenses_manager/expenses/static/expenses/css/base.css index 48588b1..fb214c5 100644 --- a/expenses_manager/expenses/static/expenses/css/base.css +++ b/expenses_manager/expenses/static/expenses/css/base.css @@ -367,8 +367,23 @@ button[type="submit"]:hover, .filters-main { display: flex; flex-wrap: wrap; - gap: 0.5rem; align-items: center; + gap: 0.5rem; +} + +.filters-main select { + padding: 0.5rem 0.7rem; + font-family: inherit; + font-size: 0.95rem; + color: var(--color-text); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 6px; +} + +.filters-main select:focus { + outline: 2px solid var(--color-focus-ring); + outline-offset: 1px; } .filters-advanced { @@ -826,17 +841,14 @@ tr:hover { transition: width 0.3s ease-in-out; } -.progress-fill.low, .progress-fill.danger { background-color: var(--color-danger-accent); } -.progress-fill.medium, .progress-fill.warning { background-color: var(--color-warning-accent); } -.progress-fill.high, .progress-fill.complete { background-color: var(--color-success-accent); } diff --git a/expenses_manager/expenses/templates/expenses/dashboard.html b/expenses_manager/expenses/templates/expenses/dashboard.html index 49df5a5..f9ff28b 100644 --- a/expenses_manager/expenses/templates/expenses/dashboard.html +++ b/expenses_manager/expenses/templates/expenses/dashboard.html @@ -428,11 +428,7 @@ aria-valuenow="{{ goal.percentage|unlocalize }}" aria-label="Progreso de {{ goal.name }}" aria-valuetext="{{ goal.progress|floatformat:1 }}€ de {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%)"> -
diff --git a/expenses_manager/expenses/templates/goals/list.html b/expenses_manager/expenses/templates/goals/list.html index 9806361..66a4ee8 100644 --- a/expenses_manager/expenses/templates/goals/list.html +++ b/expenses_manager/expenses/templates/goals/list.html @@ -9,57 +9,86 @@ {% block content %}

Objetivos

-➕ Nuevo objetivo + + +
+
+ + + + + Limpiar +
+
- - - - - - - - {% for goal in goals %} - - - - + + + + + + + + + {% for goal in goals %} + + + + + - - - {% endfor %} + + + {% empty %} + + + + {% endfor %} +
NombreTipoProgreso
{{ goal.name }} - {{ goal.get_kind_display }} - {% if goal.kind == "budget" %} - ({{ goal.get_period_display|lower }}) - {% endif %} - -
- - {{ goal.progress|floatformat:1 }}€ / {{ goal.target_amount|floatformat:1 }}€ - -
-
-
- - {{ goal.percentage|floatformat:1 }}% - - {% if goal.is_exceeded %} - Excedido +
NombreTipoProgreso
{{ goal.name }} + {{ goal.get_kind_display }} + {% if goal.kind == "budget" %} + ({{ goal.get_period_display|lower }}) {% endif %} - - +
+ + {{ goal.progress|floatformat:1 }}€ / {{ goal.target_amount|floatformat:1 }}€ + +
+
+
+ + {{ goal.percentage|floatformat:1 }}% + + {% if goal.is_exceeded %} + Excedido + {% endif %} +
+
- Editar - Eliminar -
+ Editar + Eliminar +
+

No hay objetivos

+ Añade el primero +
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/expenses_manager/expenses/tests/test_goals.py b/expenses_manager/expenses/tests/test_goals.py index e7717ae..2b3066e 100644 --- a/expenses_manager/expenses/tests/test_goals.py +++ b/expenses_manager/expenses/tests/test_goals.py @@ -1,6 +1,7 @@ import pytest from datetime import date from decimal import Decimal +from django.urls import reverse from expenses.models import Account, Category, Expense, Goal from expenses.forms import GoalForm @@ -194,3 +195,23 @@ def test_goalform_valid_payment_saves_goal(user, category): goal.save() assert Goal.objects.filter(pk=goal.pk, name="Ahorro viaje", kind=Goal.KIND_PAYMENT, category=category).exists() + + +def test_goal_list_filters_by_kind(auth_client, user, category): + Goal.objects.create(owner=user, name="Pago", target_amount=Decimal("100"), kind=Goal.KIND_PAYMENT, category=category) + Goal.objects.create(owner=user, name="Presupuesto", target_amount=Decimal("200"), kind=Goal.KIND_BUDGET, category=category, period=Goal.PERIOD_MONTH) + + response = auth_client.get(reverse('goal_list'), {"kind": Goal.KIND_BUDGET}) + + assert [g.name for g in response.context["goals"]] == ["Presupuesto"] + assert response.context["selected_kind"] == Goal.KIND_BUDGET + + +def test_goal_list_ignores_invalid_kind_param(auth_client, user, category): + Goal.objects.create(owner=user, name="Pago", target_amount=Decimal("100"), kind=Goal.KIND_PAYMENT, category=category) + + response = auth_client.get(reverse('goal_list'), {"kind": "bogus"}) + + assert response.status_code == 200 + assert [g.name for g in response.context["goals"]] == ["Pago"] + assert response.context["selected_kind"] == "" diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index a24ffc9..906a9a1 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -1071,11 +1071,21 @@ def category_delete(request, pk): def goal_list(request): goals = Goal.objects.filter(owner=request.user) + selected_kind = request.GET.get("kind") or "" + if selected_kind in dict(Goal.KIND_CHOICES): + goals = goals.filter(kind=selected_kind) + else: + selected_kind = "" + + goals = goals.order_by("name") + return render( request, "goals/list.html", { "goals": goals, + "kind_choices": Goal.KIND_CHOICES, + "selected_kind": selected_kind, "active_menu": "settings", }, ) From f135797d31b6145e2d26dbc2498e8857d6c73dc4 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 9 Sep 2026 16:18:52 +0200 Subject: [PATCH 09/12] Ordena etiquetas, categorias, cuentas y objetivos ignorando mayusculas Tag era el unico de los tres modelos con nombre sin ordering en su Meta, asi que las etiquetas salian en orden de insercion. Al anadirlo aparecio el problema de fondo: con ordering = ["name"], SQLite ordena por valor binario y en ASCII todas las mayusculas van antes que todas las minusculas, de modo que salia AA, MK, ZZ, mk, ms en vez de AA, MK, mk, ms, ZZ. Importa mas de lo que parece porque en produccion la base de datos es PostgreSQL, que ordena segun la configuracion regional. El orden pasa a calcularse en la consulta con Lower("name") en vez de depender de la colacion del motor, asi que local y NAS coinciden. Los tres Meta usan ahora ordering = [Lower("name")]. Django admite expresiones ahi, pero no se propaga solo a todos los sitios: habia tres sitios con orden explicito que lo pisaban y tambien se corrigen. - tag_list y account_list tenian .order_by("name") - goal_list ordenaba igual - _category_tree reordena los hermanos en memoria, y Python compara por code point igual que SQLite, asi que el arbol tampoco quedaba bien Las dos migraciones son AlterModelOptions, sin cambios de esquema, pero hay que desplegarlas. Lo que no resuelve: los acentos. Lower() normaliza mayusculas y minusculas, pero como se ordenan "N", "a" o "u" sigue dependiendo de la colacion de cada motor. Co-Authored-By: Claude Opus 5 --- .../migrations/0011_alter_tag_options.py | 17 ++++++++++++ ...options_alter_category_options_and_more.py | 26 +++++++++++++++++++ expenses_manager/expenses/models.py | 7 ++--- expenses_manager/expenses/tests/test_tags.py | 9 +++++++ expenses_manager/expenses/views.py | 10 +++---- 5 files changed, 61 insertions(+), 8 deletions(-) create mode 100644 expenses_manager/expenses/migrations/0011_alter_tag_options.py create mode 100644 expenses_manager/expenses/migrations/0012_alter_account_options_alter_category_options_and_more.py diff --git a/expenses_manager/expenses/migrations/0011_alter_tag_options.py b/expenses_manager/expenses/migrations/0011_alter_tag_options.py new file mode 100644 index 0000000..70c4aa6 --- /dev/null +++ b/expenses_manager/expenses/migrations/0011_alter_tag_options.py @@ -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',)}, + ), + ] diff --git a/expenses_manager/expenses/migrations/0012_alter_account_options_alter_category_options_and_more.py b/expenses_manager/expenses/migrations/0012_alter_account_options_alter_category_options_and_more.py new file mode 100644 index 0000000..f113ab4 --- /dev/null +++ b/expenses_manager/expenses/migrations/0012_alter_account_options_alter_category_options_and_more.py @@ -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')]}, + ), + ] diff --git a/expenses_manager/expenses/models.py b/expenses_manager/expenses/models.py index 50b1181..e6dc33b 100644 --- a/expenses_manager/expenses/models.py +++ b/expenses_manager/expenses/models.py @@ -5,7 +5,7 @@ from django.conf import settings from django.db.models import Sum from functools import cached_property from django.utils.text import slugify -from django.db.models.functions import ExtractMonth +from django.db.models.functions import ExtractMonth, Lower class Category(models.Model): @@ -29,7 +29,7 @@ class Category(models.Model): class Meta: unique_together = ("name", "parent", "owner", "slug") verbose_name_plural = "categories" - ordering = ["name"] + ordering = [Lower("name")] def __str__(self): return self.name @@ -71,7 +71,7 @@ class Account(models.Model): created_at = models.DateTimeField(auto_now_add=True) class Meta: - ordering = ["name"] + ordering = [Lower("name")] def current_balance(self): expenses_total = self.expenses.aggregate(total=Sum("amount"))[ @@ -193,6 +193,7 @@ class Tag(models.Model): class Meta: unique_together = ("name", "owner") + ordering = [Lower("name")] def __str__(self): return self.name diff --git a/expenses_manager/expenses/tests/test_tags.py b/expenses_manager/expenses/tests/test_tags.py index f915286..2bcdd29 100644 --- a/expenses_manager/expenses/tests/test_tags.py +++ b/expenses_manager/expenses/tests/test_tags.py @@ -21,3 +21,12 @@ def test_tag_list_annotates_expense_usage_count(auth_client, user, account, cate 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"] diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index 906a9a1..4f73831 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -16,7 +16,7 @@ from .forms import ( from django.core.paginator import Paginator from django.db.models import Sum, Count, ProtectedError -from django.db.models.functions import ExtractMonth, ExtractYear, ExtractDay +from django.db.models.functions import ExtractMonth, ExtractYear, ExtractDay, Lower from django.contrib.auth.decorators import login_required from django.utils.http import url_has_allowed_host_and_scheme @@ -64,7 +64,7 @@ def _category_tree(categories): 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) + children.sort(key=lambda c: c.name.lower()) rows = [] @@ -638,7 +638,7 @@ def tag_list(request): tags = ( Tag.objects.filter(owner=request.user) .annotate(expense_count=Count("expenses")) - .order_by("name") + .order_by(Lower("name")) ) return render( @@ -700,7 +700,7 @@ def tag_delete(request, pk): @login_required def account_list(request): - accounts = list(Account.objects.filter(owner=request.user).order_by("name")) + accounts = list(Account.objects.filter(owner=request.user).order_by(Lower("name"))) account_rows, _, _ = _account_balances(accounts) return render( request, @@ -1077,7 +1077,7 @@ def goal_list(request): else: selected_kind = "" - goals = goals.order_by("name") + goals = goals.order_by(Lower("name")) return render( request, From f154127f9ef5bcef717f6e52e1c0801eb9494c02 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 9 Sep 2026 16:19:30 +0200 Subject: [PATCH 10/12] Anade panel de ayuda en el formulario de objetivos El formulario tiene tres tipos de objetivo y campos que solo aplican a algunos, sin nada en pantalla que lo explique: no habia forma de saber que "Account" solo cuenta para ahorro o que "Period" solo cuenta para presupuesto. Anade un desplegable, cerrado por defecto, que describe para que sirve cada tipo y que hace cada campo. Usa
/ nativo en vez de JavaScript, que es accesible por teclado de serie y ya es el patron de los filtros avanzados de gastos. El texto esta contrastado con Goal._period_start(), Goal.progress y Goal.progress_state(), y los nombres de campo son los que el formulario muestra de verdad. Esas etiquetas salen hoy en ingles porque GoalForm no define labels y no hay i18n configurada; el panel usa las reales para que coincida con lo que se ve, no las traducidas. Co-Authored-By: Claude Opus 5 --- .../expenses/static/expenses/css/base.css | 33 +++++++++++ .../expenses/templates/goals/form.html | 58 +++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/expenses_manager/expenses/static/expenses/css/base.css b/expenses_manager/expenses/static/expenses/css/base.css index fb214c5..e827586 100644 --- a/expenses_manager/expenses/static/expenses/css/base.css +++ b/expenses_manager/expenses/static/expenses/css/base.css @@ -1108,3 +1108,36 @@ tr:hover { height: 260px; } } + +.info-panel { + margin-bottom: 1.5rem; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-surface-alt); +} + +.info-panel summary { + padding: 0.75rem 1rem; + cursor: pointer; + font-weight: 500; +} + +.info-panel-content { + padding: 0 1rem 1rem; +} + +.info-panel-content h2 { + margin: 1rem 0 0.25rem; + font-size: 1rem; +} + +.info-panel-content p, +.info-panel-content ul { + margin: 0.25rem 0; + font-size: 0.9rem; + color: var(--color-text-muted); +} + +.info-panel-content ul { + padding-left: 1.2rem; +} diff --git a/expenses_manager/expenses/templates/goals/form.html b/expenses_manager/expenses/templates/goals/form.html index 2c2b2e4..531e076 100644 --- a/expenses_manager/expenses/templates/goals/form.html +++ b/expenses_manager/expenses/templates/goals/form.html @@ -8,6 +8,64 @@ {{ title }} +
+ ¿Qué tipo de objetivo necesito? + +
+

Pago / deuda

+

+ 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. +

+ +

Presupuesto

+

+ 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. +

+ +

Ahorro

+

+ 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. +

+ +

Los demás campos

+
    +
  • + Category — solo para pago y presupuesto: es la + categoría cuyos gastos se cuentan. +
  • +
  • + Include subcategories — si se marca, también + cuentan los gastos de las categorías hijas. +
  • +
  • + Account — solo para ahorro: la cuenta cuyo saldo + mide el progreso. +
  • +
  • + Start date — desde cuándo se cuentan los gastos + en un pago. En un presupuesto no se usa: manda el reinicio del + periodo. +
  • +
  • + Period — solo para presupuesto: cada cuánto + vuelve el progreso a cero. +
  • +
  • + Show on home — si el objetivo aparece en la + pantalla de inicio. +
  • +
+
+
+
{% csrf_token %} {% include "expenses/_form_fields.html" %} From 814487a39041b937a171d1fe5de3de1257eb7f23 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 9 Sep 2026 16:21:35 +0200 Subject: [PATCH 11/12] Anade columna con el numero de gastos de cada categoria El listado de categorias no daba ninguna pista de cuales estan en uso. Anade la columna con el mismo criterio que el de etiquetas: annotate en la vista para no disparar una query por fila. Cuenta solo los gastos directos de cada categoria, no los de sus descendientes. Si el numero incluyera a los hijos, un padre sin gastos propios mostraria una cifra que no corresponde a ninguna fila suya y que no cuadraria con lo que se ve al filtrar por esa categoria. Con Category.Meta.ordering = [Lower("name")], Django anade la expresion al GROUP BY del annotate. Verificado que no multiplica filas ni fusiona categorias cuyo nombre solo difiere en mayusculas. Co-Authored-By: Claude Opus 5 --- .../expenses/templates/categories/list.html | 4 +++- .../expenses/tests/test_categories.py | 23 +++++++++++++++++++ expenses_manager/expenses/views.py | 6 ++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/expenses_manager/expenses/templates/categories/list.html b/expenses_manager/expenses/templates/categories/list.html index 0a40561..224af55 100644 --- a/expenses_manager/expenses/templates/categories/list.html +++ b/expenses_manager/expenses/templates/categories/list.html @@ -23,6 +23,7 @@ Categoría + Gastos @@ -30,6 +31,7 @@ {% for row in category_rows %} {{ row.category.name }} + {{ row.category.expense_count }} Editar Eliminar @@ -37,7 +39,7 @@ {% empty %} - +

No hay categorías

diff --git a/expenses_manager/expenses/tests/test_categories.py b/expenses_manager/expenses/tests/test_categories.py index 0d6a6c2..6c9bf74 100644 --- a/expenses_manager/expenses/tests/test_categories.py +++ b/expenses_manager/expenses/tests/test_categories.py @@ -116,3 +116,26 @@ def test_category_list_view_returns_tree_ordered_with_depth(auth_client, user): ("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} diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index 4f73831..34d77ac 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -990,7 +990,11 @@ def fuel_delete(request, pk): @login_required def category_list(request): - categories = Category.objects.filter(owner=request.user) + categories = list( + Category.objects.filter(owner=request.user) + .annotate(expense_count=Count("expenses")) + .select_related("parent") + ) if request.method == "POST": form = CategoryForm(request.POST, user=request.user) From 4eb120927b0f72ab5b198718c5a065e327ad5edd Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 9 Sep 2026 16:22:08 +0200 Subject: [PATCH 12/12] Anade CLAUDE.md y el informe de analisis del proyecto CLAUDE.md documenta la estructura de directorios (que tiene tres niveles llamados expenses_manager y despista), los comandos habituales, las variables de entorno y las decisiones de auth, para no tener que deducirlo de settings.py cada vez. ANALISIS_code.md es un informe de solo lectura sobre UX, deuda tecnica y mantenibilidad, ordenado por impacto. Sirve de lista de trabajo pendiente; varios de sus puntos ya estan resueltos en los commits anteriores. Co-Authored-By: Claude Opus 5 --- ANALISIS_code.md | 181 +++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 90 +++++++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 ANALISIS_code.md create mode 100644 CLAUDE.md diff --git a/ANALISIS_code.md b/ANALISIS_code.md new file mode 100644 index 0000000..bbd1c25 --- /dev/null +++ b/ANALISIS_code.md @@ -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 `` 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 `