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 <noreply@anthropic.com>
This commit is contained in:
JKuijperM 2026-09-09 16:18:52 +02:00
parent e814bc2d03
commit f135797d31
5 changed files with 61 additions and 8 deletions

View File

@ -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',)},
),
]

View File

@ -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')]},
),
]

View File

@ -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

View File

@ -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"]

View File

@ -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,