diff --git a/docs/developer/other-utilities.rst b/docs/developer/other-utilities.rst index 9f7eb682..8cd75e3b 100644 --- a/docs/developer/other-utilities.rst +++ b/docs/developer/other-utilities.rst @@ -27,6 +27,12 @@ Which use respectively ``AutoCreatedField``, ``AutoLastModifiedField`` from ``model_utils.fields`` (self-updating fields providing the creation date-time and the last modified date-time). +By default, querysets are ordered by newest creation time and then by +primary key, using ``("-created", "-pk")``. A subclass defining its own +``Meta`` class must inherit ``TimeStampedEditableModel.Meta`` to preserve +this ordering. Explicit model ordering and ``QuerySet.order_by()`` calls +override it. + REST API Utilities ------------------ diff --git a/openwisp_utils/base.py b/openwisp_utils/base.py index d22c4f3e..35394919 100644 --- a/openwisp_utils/base.py +++ b/openwisp_utils/base.py @@ -23,3 +23,4 @@ class TimeStampedEditableModel(UUIDModel): class Meta: abstract = True + ordering = ("-created", "-pk") diff --git a/openwisp_utils/metric_collection/migrations/0002_alter_consent_options.py b/openwisp_utils/metric_collection/migrations/0002_alter_consent_options.py new file mode 100644 index 00000000..484b6573 --- /dev/null +++ b/openwisp_utils/metric_collection/migrations/0002_alter_consent_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.13 on 2026-08-03 20:58 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("metric_collection", "0001_initial"), + ] + + operations = [ + migrations.AlterModelOptions( + name="consent", + options={"ordering": ("-created", "-pk")}, + ), + ] diff --git a/openwisp_utils/metric_collection/tests/test_models.py b/openwisp_utils/metric_collection/tests/test_models.py index 077f0f95..a1a42b31 100644 --- a/openwisp_utils/metric_collection/tests/test_models.py +++ b/openwisp_utils/metric_collection/tests/test_models.py @@ -1,5 +1,6 @@ -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from unittest.mock import patch +from uuid import UUID import requests from django.apps import apps @@ -22,6 +23,23 @@ class TestOpenwispVersion(TestCase): + def test_consent_ordering(self): + old = Consent.objects.create(id=UUID("00000000-0000-0000-0000-000000000001")) + tied = Consent.objects.create(id=UUID("00000000-0000-0000-0000-000000000002")) + newest = Consent.objects.create(id=UUID("00000000-0000-0000-0000-000000000003")) + created = datetime(2026, 1, 1, tzinfo=timezone.utc) + Consent.objects.filter(pk__in=[old.pk, tied.pk]).update(created=created) + Consent.objects.filter(pk=newest.pk).update(created=created + timedelta(days=1)) + self.assertEqual(Consent._meta.ordering, ("-created", "-pk")) + self.assertEqual( + list(Consent.objects.values_list("pk", flat=True)), + [newest.pk, tied.pk, old.pk], + ) + self.assertEqual( + list(Consent.objects.order_by("pk").values_list("pk", flat=True)), + [old.pk, tied.pk, newest.pk], + ) + def setUp(self): # The post_migrate signal creates the first OpenwispVersion object # and uses the actual modules installed in the Python environment. diff --git a/tests/test_project/migrations/0010_alter_book_options_alter_shelf_options.py b/tests/test_project/migrations/0010_alter_book_options_alter_shelf_options.py new file mode 100644 index 00000000..1ed5d3ac --- /dev/null +++ b/tests/test_project/migrations/0010_alter_book_options_alter_shelf_options.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.13 on 2026-08-03 20:58 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("test_project", "0009_shelf_writers"), + ] + + operations = [ + migrations.AlterModelOptions( + name="book", + options={"ordering": ("name", "pk")}, + ), + migrations.AlterModelOptions( + name="shelf", + options={"ordering": ("-created", "-pk")}, + ), + ] diff --git a/tests/test_project/models.py b/tests/test_project/models.py index 066d4c2a..8c380c2b 100644 --- a/tests/test_project/models.py +++ b/tests/test_project/models.py @@ -56,7 +56,7 @@ class Shelf(TimeStampedEditableModel): def __str__(self): return self.name - class Meta: + class Meta(TimeStampedEditableModel.Meta): abstract = False def clean(self): @@ -74,8 +74,9 @@ class Book(TimeStampedEditableModel): def __str__(self): return self.name - class Meta: + class Meta(TimeStampedEditableModel.Meta): abstract = False + ordering = ("name", "pk") class RadiusAccounting(models.Model): diff --git a/tests/test_project/tests/test_model.py b/tests/test_project/tests/test_model.py index eb140d7e..3f08bc57 100644 --- a/tests/test_project/tests/test_model.py +++ b/tests/test_project/tests/test_model.py @@ -1,4 +1,6 @@ +from datetime import datetime, timedelta, timezone from unittest.mock import patch +from uuid import UUID from django.core.exceptions import ValidationError from django.db import connection, models @@ -13,6 +15,60 @@ class TestModel(TestCase): TEST_KEY = "w1gwJxKaHcamUw62TQIPgYchwLKn3AA0" + def test_timestamped_model_ordering(self): + old_shelf = Shelf.objects.create( + id=UUID("00000000-0000-0000-0000-000000000001"), name="Old shelf" + ) + tied_shelf = Shelf.objects.create( + id=UUID("00000000-0000-0000-0000-000000000002"), name="Tied shelf" + ) + newest_shelf = Shelf.objects.create( + id=UUID("00000000-0000-0000-0000-000000000003"), name="Newest shelf" + ) + old_book = Book.objects.create( + id=UUID("00000000-0000-0000-0000-000000000004"), + name="Same book", + author="Author", + shelf=old_shelf, + ) + tied_book = Book.objects.create( + id=UUID("00000000-0000-0000-0000-000000000005"), + name="Same book", + author="Author", + shelf=tied_shelf, + ) + newest_book = Book.objects.create( + id=UUID("00000000-0000-0000-0000-000000000006"), + name="Other book", + author="Author", + shelf=newest_shelf, + ) + created = datetime(2026, 1, 1, tzinfo=timezone.utc) + Shelf.objects.filter(pk__in=[old_shelf.pk, tied_shelf.pk]).update( + created=created + ) + Shelf.objects.filter(pk=newest_shelf.pk).update( + created=created + timedelta(days=1) + ) + Book.objects.filter(pk__in=[old_book.pk, tied_book.pk]).update(created=created) + Book.objects.filter(pk=newest_book.pk).update( + created=created + timedelta(days=1) + ) + self.assertEqual(Shelf._meta.ordering, ("-created", "-pk")) + self.assertEqual(Book._meta.ordering, ("name", "pk")) + self.assertEqual( + list(Shelf.objects.values_list("pk", flat=True)), + [newest_shelf.pk, tied_shelf.pk, old_shelf.pk], + ) + self.assertEqual( + list(Book.objects.values_list("pk", flat=True)), + [newest_book.pk, old_book.pk, tied_book.pk], + ) + self.assertEqual( + list(Shelf.objects.order_by("name").values_list("pk", flat=True)), + [newest_shelf.pk, old_shelf.pk, tied_shelf.pk], + ) + def test_key_validator(self): p = Project.objects.create(name="test_project") p.key = "key/key" diff --git a/tests/test_project/tests/test_selenium.py b/tests/test_project/tests/test_selenium.py index 0650afa3..6ed9a9df 100644 --- a/tests/test_project/tests/test_selenium.py +++ b/tests/test_project/tests/test_selenium.py @@ -652,7 +652,10 @@ def test_autocomplete_shelf_filter(self): select_id = "id-shelf__id-dal-filter" filter_css_selector = f"#select2-{select_id}-container" filter_options = f'//*[@id="select2-{select_id}-results"]/li' - filter_option_xpath = f'//*[@id="select2-{select_id}-results"]/li[2]' + filter_option_xpath = ( + f'//*[@id="select2-{select_id}-results"]/li' + f'[contains(text(), "{factual_shelf.name}")]' + ) result_xpath = '//*[@id="result_list"]/tbody/tr/th/a[contains(text(), "{}")]' self.open(url)