From f135797d31b6145e2d26dbc2498e8857d6c73dc4 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 9 Sep 2026 16:18:52 +0200 Subject: [PATCH] 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,