diff --git a/django/contrib/admin/views/main.py b/django/contrib/admin/views/main.py index b2d95828df..5bc3f736f1 100644 --- a/django/contrib/admin/views/main.py +++ b/django/contrib/admin/views/main.py @@ -367,8 +367,16 @@ class ChangeList: break ordering_fields.add(field.attname) else: - # No single total ordering field, try unique_together. - for field_names in self.lookup_opts.unique_together: + # No single total ordering field, try unique_together and total + # unique constraints. + constraint_field_names = ( + *self.lookup_opts.unique_together, + *( + constraint.fields + for constraint in self.lookup_opts.total_unique_constraints + ), + ) + for field_names in constraint_field_names: # Normalize attname references by using get_field(). fields = [self.lookup_opts.get_field(field_name) for field_name in field_names] # Composite unique constraints containing a nullable column diff --git a/tests/admin_changelist/tests.py b/tests/admin_changelist/tests.py index a4c220cb15..fa29886160 100644 --- a/tests/admin_changelist/tests.py +++ b/tests/admin_changelist/tests.py @@ -1058,6 +1058,98 @@ class ChangeListTests(TestCase): with self.subTest(ordering=ordering): self.assertEqual(change_list._get_deterministic_ordering(ordering), expected) + @isolate_apps('admin_changelist') + def test_total_ordering_optimization_meta_constraints(self): + class Related(models.Model): + unique_field = models.BooleanField(unique=True) + + class Meta: + ordering = ('unique_field',) + + class Model(models.Model): + field_1 = models.BooleanField() + field_2 = models.BooleanField() + field_3 = models.BooleanField() + field_4 = models.BooleanField() + field_5 = models.BooleanField() + field_6 = models.BooleanField() + nullable_1 = models.BooleanField(null=True) + nullable_2 = models.BooleanField(null=True) + related_1 = models.ForeignKey(Related, models.CASCADE) + related_2 = models.ForeignKey(Related, models.CASCADE) + related_3 = models.ForeignKey(Related, models.CASCADE) + related_4 = models.ForeignKey(Related, models.CASCADE) + + class Meta: + constraints = [ + *[ + models.UniqueConstraint(fields=fields, name=''.join(fields)) + for fields in ( + ['field_1'], + ['nullable_1'], + ['related_1'], + ['related_2_id'], + ['field_2', 'field_3'], + ['field_2', 'nullable_2'], + ['field_2', 'related_3'], + ['field_3', 'related_4_id'], + ) + ], + models.CheckConstraint(check=models.Q(id__gt=0), name='foo'), + models.UniqueConstraint( + fields=['field_5'], + condition=models.Q(id__gt=10), + name='total_ordering_1', + ), + models.UniqueConstraint( + fields=['field_6'], + condition=models.Q(), + name='total_ordering', + ), + ] + + class ModelAdmin(admin.ModelAdmin): + def get_queryset(self, request): + return Model.objects.none() + + request = self._mocked_authenticated_request('/', self.superuser) + site = admin.AdminSite(name='admin') + model_admin = ModelAdmin(Model, site) + change_list = model_admin.get_changelist_instance(request) + tests = ( + # Unique non-nullable field. + (['field_1'], ['field_1']), + # Unique nullable field. + (['nullable_1'], ['nullable_1', '-pk']), + # Related attname unique. + (['related_1_id'], ['related_1_id']), + (['related_2_id'], ['related_2_id']), + # Related ordering introspection is not implemented. + (['related_1'], ['related_1', '-pk']), + # Composite unique. + (['-field_2', 'field_3'], ['-field_2', 'field_3']), + # Composite unique nullable. + (['field_2', '-nullable_2'], ['field_2', '-nullable_2', '-pk']), + # Composite unique and nullable. + ( + ['field_2', '-nullable_2', 'field_3'], + ['field_2', '-nullable_2', 'field_3'], + ), + # Composite field and related field name. + (['field_2', '-related_3'], ['field_2', '-related_3', '-pk']), + (['field_3', 'related_4'], ['field_3', 'related_4', '-pk']), + # Composite field and related field attname. + (['field_2', 'related_3_id'], ['field_2', 'related_3_id']), + (['field_3', '-related_4_id'], ['field_3', '-related_4_id']), + # Partial unique constraint is ignored. + (['field_5'], ['field_5', '-pk']), + # Unique constraint with an empty condition. + (['field_6'], ['field_6']), + ) + for ordering, expected in tests: + with self.subTest(ordering=ordering): + self.assertEqual(change_list._get_deterministic_ordering(ordering), expected) + def test_dynamic_list_filter(self): """ Regression tests for ticket #17646: dynamic list_filter support.