diff --git a/django/contrib/admin/filters.py b/django/contrib/admin/filters.py index d234d28e3c..f7c520a43a 100644 --- a/django/contrib/admin/filters.py +++ b/django/contrib/admin/filters.py @@ -176,8 +176,16 @@ class RelatedFieldListFilter(FieldListFilter): self.title = self.lookup_title self.empty_value_display = model_admin.get_empty_value_display() + @property + def include_empty_choice(self): + """ + Return True if a "(None)" choice should be included, which filters + out everything except empty relationships. + """ + return self.field.null or (self.field.is_relation and self.field.many_to_many) + def has_output(self): - if self.field.null: + if self.include_empty_choice: extra = 1 else: extra = 0 @@ -204,7 +212,7 @@ class RelatedFieldListFilter(FieldListFilter): }, [self.lookup_kwarg_isnull]), 'display': val, } - if self.field.null: + if self.include_empty_choice: yield { 'selected': bool(self.lookup_val_isnull), 'query_string': cl.get_query_string({ diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index 5e7bda5d5c..609d262127 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -2304,8 +2304,6 @@ class ManyToManyField(RelatedField): self.db_table = db_table self.swappable = swappable - # Many-to-many fields are always nullable. - self.null = True def check(self, **kwargs): errors = super(ManyToManyField, self).check(**kwargs) @@ -2552,7 +2550,6 @@ class ManyToManyField(RelatedField): def deconstruct(self): name, path, args, kwargs = super(ManyToManyField, self).deconstruct() # Handle the simpler arguments. - del kwargs["null"] if self.db_table is not None: kwargs['db_table'] = self.db_table if self.remote_field.db_constraint is not True: diff --git a/tests/admin_filters/tests.py b/tests/admin_filters/tests.py index f01f9dff10..5a230cd983 100644 --- a/tests/admin_filters/tests.py +++ b/tests/admin_filters/tests.py @@ -523,6 +523,16 @@ class ListFiltersTests(TestCase): self.assertEqual(choice['selected'], True) self.assertEqual(choice['query_string'], '?books_contributed__id__exact=%d' % self.django_book.pk) + # With one book, the list filter should appear because there is also a + # (None) option. + Book.objects.exclude(pk=self.djangonaut_book.pk).delete() + filterspec = changelist.get_filters(request)[0] + self.assertEqual(len(filterspec), 2) + # With no books remaining, no list filters should appear. + Book.objects.all().delete() + filterspec = changelist.get_filters(request)[0] + self.assertEqual(len(filterspec), 0) + def test_relatedonlyfieldlistfilter_foreignkey(self): modeladmin = BookAdminRelatedOnlyFilter(Book, site) diff --git a/tests/model_fields/test_field_flags.py b/tests/model_fields/test_field_flags.py index 84227d4192..f86be8f45a 100644 --- a/tests/model_fields/test_field_flags.py +++ b/tests/model_fields/test_field_flags.py @@ -216,3 +216,9 @@ class FieldFlagsTests(test.SimpleTestCase): reverse_field = field.remote_field self.assertEqual(field.model, reverse_field.related_model) self.assertEqual(field.related_model, reverse_field.model) + + def test_null(self): + # null isn't well defined for a ManyToManyField, but changing it to + # True causes backwards compatibility problems (#25320). + self.assertFalse(AllFieldsModel._meta.get_field('m2m').null) + self.assertTrue(AllFieldsModel._meta.get_field('reverse2').null)