Fixed #29227 -- Allowed BooleanField to be null=True.

Thanks Lynn Cyrin for contributing to the patch, and Nick Pope for review.
This commit is contained in:
Tim Graham 2017-05-06 10:56:28 -04:00
parent 73f7d1755f
commit 5fa4f40f45
32 changed files with 158 additions and 98 deletions

View File

@ -243,7 +243,7 @@ class BooleanFieldListFilter(FieldListFilter):
'query_string': changelist.get_query_string({self.lookup_kwarg: lookup}, [self.lookup_kwarg2]), 'query_string': changelist.get_query_string({self.lookup_kwarg: lookup}, [self.lookup_kwarg2]),
'display': title, 'display': title,
} }
if isinstance(self.field, models.NullBooleanField): if self.field.null:
yield { yield {
'selected': self.lookup_val2 == 'True', 'selected': self.lookup_val2 == 'True',
'query_string': changelist.get_query_string({self.lookup_kwarg2: 'True'}, [self.lookup_kwarg]), 'query_string': changelist.get_query_string({self.lookup_kwarg2: 'True'}, [self.lookup_kwarg]),

View File

@ -394,8 +394,8 @@ def display_for_field(value, field, empty_value_display):
if getattr(field, 'flatchoices', None): if getattr(field, 'flatchoices', None):
return dict(field.flatchoices).get(value, empty_value_display) return dict(field.flatchoices).get(value, empty_value_display)
# NullBooleanField needs special-case null-handling, so it comes # BooleanField needs special-case null-handling, so it comes before the
# before the general null test. # general null test.
elif isinstance(field, (models.BooleanField, models.NullBooleanField)): elif isinstance(field, (models.BooleanField, models.NullBooleanField)):
return _boolean_icon(value) return _boolean_icon(value)
elif value is None: elif value is None:

View File

@ -41,6 +41,7 @@ class BulkInsertMapper:
types = { types = {
'BigIntegerField': NUMBER, 'BigIntegerField': NUMBER,
'BinaryField': BLOB, 'BinaryField': BLOB,
'BooleanField': NUMBER,
'DateField': DATE, 'DateField': DATE,
'DateTimeField': TIMESTAMP, 'DateTimeField': TIMESTAMP,
'DecimalField': NUMBER, 'DecimalField': NUMBER,

View File

@ -988,41 +988,16 @@ class BooleanField(Field):
empty_strings_allowed = False empty_strings_allowed = False
default_error_messages = { default_error_messages = {
'invalid': _("'%(value)s' value must be either True or False."), 'invalid': _("'%(value)s' value must be either True or False."),
'invalid_nullable': _("'%(value)s' value must be either True, False, or None."),
} }
description = _("Boolean (Either True or False)") description = _("Boolean (Either True or False)")
def __init__(self, *args, **kwargs):
kwargs['blank'] = True
super().__init__(*args, **kwargs)
def check(self, **kwargs):
return [
*super().check(**kwargs),
*self._check_null(**kwargs),
]
def _check_null(self, **kwargs):
if getattr(self, 'null', False):
return [
checks.Error(
'BooleanFields do not accept null values.',
hint='Use a NullBooleanField instead.',
obj=self,
id='fields.E110',
)
]
else:
return []
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
del kwargs['blank']
return name, path, args, kwargs
def get_internal_type(self): def get_internal_type(self):
return "BooleanField" return "BooleanField"
def to_python(self, value): def to_python(self, value):
if self.null and value in self.empty_values:
return None
if value in (True, False): if value in (True, False):
# if value is 1 or 0 than it's equal to True or False, but we want # if value is 1 or 0 than it's equal to True or False, but we want
# to return a true bool for semantic reasons. # to return a true bool for semantic reasons.
@ -1032,7 +1007,7 @@ class BooleanField(Field):
if value in ('f', 'False', '0'): if value in ('f', 'False', '0'):
return False return False
raise exceptions.ValidationError( raise exceptions.ValidationError(
self.error_messages['invalid'], self.error_messages['invalid_nullable' if self.null else 'invalid'],
code='invalid', code='invalid',
params={'value': value}, params={'value': value},
) )
@ -1044,15 +1019,16 @@ class BooleanField(Field):
return self.to_python(value) return self.to_python(value)
def formfield(self, **kwargs): def formfield(self, **kwargs):
# Unlike most fields, BooleanField figures out include_blank from
# self.null instead of self.blank.
if self.choices: if self.choices:
include_blank = not (self.has_default() or 'initial' in kwargs) include_blank = not (self.has_default() or 'initial' in kwargs)
defaults = {'choices': self.get_choices(include_blank=include_blank)} defaults = {'choices': self.get_choices(include_blank=include_blank)}
else: else:
defaults = {'form_class': forms.BooleanField} form_class = forms.NullBooleanField if self.null else forms.BooleanField
defaults.update(kwargs) # In HTML checkboxes, 'required' means "must be checked" which is
return super().formfield(**defaults) # different from the choices case ("must select some value").
# required=False allows unchecked checkboxes.
defaults = {'form_class': form_class, 'required': False}
return super().formfield(**{**defaults, **kwargs})
class CharField(Field): class CharField(Field):

View File

@ -138,7 +138,8 @@ Model fields
* **fields.E007**: Primary keys must not have ``null=True``. * **fields.E007**: Primary keys must not have ``null=True``.
* **fields.E008**: All ``validators`` must be callable. * **fields.E008**: All ``validators`` must be callable.
* **fields.E100**: ``AutoField``\s must set primary_key=True. * **fields.E100**: ``AutoField``\s must set primary_key=True.
* **fields.E110**: ``BooleanField``\s do not accept null values. * **fields.E110**: ``BooleanField``\s do not accept null values. *This check
appeared before support for null values was added in Django 2.1.*
* **fields.E120**: ``CharField``\s must define a ``max_length`` attribute. * **fields.E120**: ``CharField``\s must define a ``max_length`` attribute.
* **fields.E121**: ``max_length`` must be a positive integer. * **fields.E121**: ``max_length`` must be a positive integer.
* **fields.W122**: ``max_length`` is ignored when used with ``IntegerField``. * **fields.W122**: ``max_length`` is ignored when used with ``IntegerField``.

View File

@ -606,9 +606,8 @@ subclass::
and add that method's name to ``list_display``. (See below for more and add that method's name to ``list_display``. (See below for more
on custom methods in ``list_display``.) on custom methods in ``list_display``.)
* If the field is a ``BooleanField`` or ``NullBooleanField``, Django * If the field is a ``BooleanField``, Django will display a pretty "on" or
will display a pretty "on" or "off" icon instead of ``True`` or "off" icon instead of ``True`` or ``False``.
``False``.
* If the string given is a method of the model, ``ModelAdmin`` or a * If the string given is a method of the model, ``ModelAdmin`` or a
callable, Django will HTML-escape the output by default. To escape callable, Django will HTML-escape the output by default. To escape

View File

@ -61,9 +61,6 @@ set ``blank=True`` if you wish to permit empty values in forms, as the
When using the Oracle database backend, the value ``NULL`` will be stored to When using the Oracle database backend, the value ``NULL`` will be stored to
denote the empty string regardless of this attribute. denote the empty string regardless of this attribute.
If you want to accept :attr:`~Field.null` values with :class:`BooleanField`,
use :class:`NullBooleanField` instead.
``blank`` ``blank``
--------- ---------
@ -442,15 +439,18 @@ case it can't be included in a :class:`~django.forms.ModelForm`.
A true/false field. A true/false field.
The default form widget for this field is a The default form widget for this field is :class:`~django.forms.CheckboxInput`,
:class:`~django.forms.CheckboxInput`. or :class:`~django.forms.NullBooleanSelect` if :attr:`null=True <Field.null>`.
If you need to accept :attr:`~Field.null` values then use
:class:`NullBooleanField` instead.
The default value of ``BooleanField`` is ``None`` when :attr:`Field.default` The default value of ``BooleanField`` is ``None`` when :attr:`Field.default`
isn't defined. isn't defined.
.. versionchanged:: 2.1
In older versions, this field doesn't permit ``null=True``, so you have to
use :class:`NullBooleanField` instead. Using the latter is now discouraged
as it's likely to be deprecated in a future version of Django.
``CharField`` ``CharField``
------------- -------------
@ -1008,9 +1008,8 @@ values are stored as null.
.. class:: NullBooleanField(**options) .. class:: NullBooleanField(**options)
Like a :class:`BooleanField`, but allows ``NULL`` as one of the options. Use Like :class:`BooleanField` with ``null=True``. Use that instead of this field
this instead of a :class:`BooleanField` with ``null=True``. The default form as it's likely to be deprecated in a future version of Django.
widget for this field is a :class:`~django.forms.NullBooleanSelect`.
``PositiveIntegerField`` ``PositiveIntegerField``
------------------------ ------------------------

View File

@ -223,6 +223,10 @@ Models
* :meth:`.QuerySet.order_by` and :meth:`distinct(*fields) <.QuerySet.distinct>` * :meth:`.QuerySet.order_by` and :meth:`distinct(*fields) <.QuerySet.distinct>`
now support using field transforms. now support using field transforms.
* :class:`~django.db.models.BooleanField` can now be ``null=True``. This is
encouraged instead of :class:`~django.db.models.NullBooleanField`, which will
likely be deprecated in the future.
Requests and Responses Requests and Responses
~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~

View File

@ -67,7 +67,9 @@ Model field Form field
``True`` on the model field, otherwise not ``True`` on the model field, otherwise not
represented in the form. represented in the form.
:class:`BooleanField` :class:`~django.forms.BooleanField` :class:`BooleanField` :class:`~django.forms.BooleanField`, or
:class:`~django.forms.NullBooleanField` if
``null=True``.
:class:`CharField` :class:`~django.forms.CharField` with :class:`CharField` :class:`~django.forms.CharField` with
``max_length`` set to the model field's ``max_length`` set to the model field's

View File

@ -28,7 +28,8 @@ class Book(models.Model):
verbose_name='Employee', verbose_name='Employee',
blank=True, null=True, blank=True, null=True,
) )
is_best_seller = models.NullBooleanField(default=0) is_best_seller = models.BooleanField(default=0, null=True)
is_best_seller2 = models.NullBooleanField(default=0)
date_registered = models.DateField(null=True) date_registered = models.DateField(null=True)
# This field name is intentionally 2 characters long (#16080). # This field name is intentionally 2 characters long (#16080).
no = models.IntegerField(verbose_name='number', blank=True, null=True) no = models.IntegerField(verbose_name='number', blank=True, null=True)

View File

@ -142,6 +142,10 @@ class BookAdmin(ModelAdmin):
ordering = ('-id',) ordering = ('-id',)
class BookAdmin2(ModelAdmin):
list_filter = ('year', 'author', 'contributors', 'is_best_seller2', 'date_registered', 'no')
class BookAdminWithTupleBooleanFilter(BookAdmin): class BookAdminWithTupleBooleanFilter(BookAdmin):
list_filter = ( list_filter = (
'year', 'year',
@ -267,18 +271,22 @@ class ListFiltersTests(TestCase):
self.djangonaut_book = Book.objects.create( self.djangonaut_book = Book.objects.create(
title='Djangonaut: an art of living', year=2009, title='Djangonaut: an art of living', year=2009,
author=self.alfred, is_best_seller=True, date_registered=self.today, author=self.alfred, is_best_seller=True, date_registered=self.today,
is_best_seller2=True,
) )
self.bio_book = Book.objects.create( self.bio_book = Book.objects.create(
title='Django: a biography', year=1999, author=self.alfred, title='Django: a biography', year=1999, author=self.alfred,
is_best_seller=False, no=207, is_best_seller=False, no=207,
is_best_seller2=False,
) )
self.django_book = Book.objects.create( self.django_book = Book.objects.create(
title='The Django Book', year=None, author=self.bob, title='The Django Book', year=None, author=self.bob,
is_best_seller=None, date_registered=self.today, no=103, is_best_seller=None, date_registered=self.today, no=103,
is_best_seller2=None,
) )
self.guitar_book = Book.objects.create( self.guitar_book = Book.objects.create(
title='Guitar for dummies', year=2002, is_best_seller=True, title='Guitar for dummies', year=2002, is_best_seller=True,
date_registered=self.one_week_ago, date_registered=self.one_week_ago,
is_best_seller2=True,
) )
self.guitar_book.contributors.set([self.bob, self.lisa]) self.guitar_book.contributors.set([self.bob, self.lisa])
@ -738,6 +746,54 @@ class ListFiltersTests(TestCase):
self.assertIs(choice['selected'], True) self.assertIs(choice['selected'], True)
self.assertEqual(choice['query_string'], '?is_best_seller__isnull=True') self.assertEqual(choice['query_string'], '?is_best_seller__isnull=True')
def test_booleanfieldlistfilter_nullbooleanfield(self):
modeladmin = BookAdmin2(Book, site)
request = self.request_factory.get('/')
changelist = modeladmin.get_changelist_instance(request)
request = self.request_factory.get('/', {'is_best_seller2__exact': 0})
changelist = modeladmin.get_changelist_instance(request)
# Make sure the correct queryset is returned
queryset = changelist.get_queryset(request)
self.assertEqual(list(queryset), [self.bio_book])
# Make sure the correct choice is selected
filterspec = changelist.get_filters(request)[0][3]
self.assertEqual(filterspec.title, 'is best seller2')
choice = select_by(filterspec.choices(changelist), "display", "No")
self.assertIs(choice['selected'], True)
self.assertEqual(choice['query_string'], '?is_best_seller2__exact=0')
request = self.request_factory.get('/', {'is_best_seller2__exact': 1})
changelist = modeladmin.get_changelist_instance(request)
# Make sure the correct queryset is returned
queryset = changelist.get_queryset(request)
self.assertEqual(list(queryset), [self.guitar_book, self.djangonaut_book])
# Make sure the correct choice is selected
filterspec = changelist.get_filters(request)[0][3]
self.assertEqual(filterspec.title, 'is best seller2')
choice = select_by(filterspec.choices(changelist), "display", "Yes")
self.assertIs(choice['selected'], True)
self.assertEqual(choice['query_string'], '?is_best_seller2__exact=1')
request = self.request_factory.get('/', {'is_best_seller2__isnull': 'True'})
changelist = modeladmin.get_changelist_instance(request)
# Make sure the correct queryset is returned
queryset = changelist.get_queryset(request)
self.assertEqual(list(queryset), [self.django_book])
# Make sure the correct choice is selected
filterspec = changelist.get_filters(request)[0][3]
self.assertEqual(filterspec.title, 'is best seller2')
choice = select_by(filterspec.choices(changelist), "display", "Unknown")
self.assertIs(choice['selected'], True)
self.assertEqual(choice['query_string'], '?is_best_seller2__isnull=True')
def test_fieldlistfilter_underscorelookup_tuple(self): def test_fieldlistfilter_underscorelookup_tuple(self):
""" """
Ensure ('fieldpath', ClassName ) lookups pass lookup_allowed checks Ensure ('fieldpath', ClassName ) lookups pass lookup_allowed checks

View File

@ -166,6 +166,10 @@ class UtilsTests(SimpleTestCase):
expected = '<img src="%sadmin/img/icon-unknown.svg" alt="None">' % settings.STATIC_URL expected = '<img src="%sadmin/img/icon-unknown.svg" alt="None">' % settings.STATIC_URL
self.assertHTMLEqual(display_value, expected) self.assertHTMLEqual(display_value, expected)
display_value = display_for_field(None, models.BooleanField(null=True), self.empty_value)
expected = '<img src="%sadmin/img/icon-unknown.svg" alt="None" />' % settings.STATIC_URL
self.assertHTMLEqual(display_value, expected)
display_value = display_for_field(None, models.DecimalField(), self.empty_value) display_value = display_for_field(None, models.DecimalField(), self.empty_value)
self.assertEqual(display_value, self.empty_value) self.assertEqual(display_value, self.empty_value)

View File

@ -456,7 +456,7 @@ class Post(models.Model):
default=datetime.date.today, default=datetime.date.today,
help_text="Some help text for the date (with unicode ŠĐĆŽćžšđ)" help_text="Some help text for the date (with unicode ŠĐĆŽćžšđ)"
) )
public = models.NullBooleanField() public = models.BooleanField(null=True, blank=True)
def awesomeness_level(self): def awesomeness_level(self):
return "Very awesome." return "Very awesome."
@ -692,7 +692,7 @@ class OtherStory(models.Model):
class ComplexSortedPerson(models.Model): class ComplexSortedPerson(models.Model):
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
age = models.PositiveIntegerField() age = models.PositiveIntegerField()
is_employee = models.NullBooleanField() is_employee = models.BooleanField(null=True)
class PluggableSearchPerson(models.Model): class PluggableSearchPerson(models.Model):

View File

@ -521,13 +521,15 @@ class NonAggregateAnnotationTestCase(TestCase):
books = Book.objects.annotate( books = Book.objects.annotate(
is_book=Value(True, output_field=BooleanField()), is_book=Value(True, output_field=BooleanField()),
is_pony=Value(False, output_field=BooleanField()), is_pony=Value(False, output_field=BooleanField()),
is_none=Value(None, output_field=NullBooleanField()), is_none=Value(None, output_field=BooleanField(null=True)),
is_none_old=Value(None, output_field=NullBooleanField()),
) )
self.assertGreater(len(books), 0) self.assertGreater(len(books), 0)
for book in books: for book in books:
self.assertIs(book.is_book, True) self.assertIs(book.is_book, True)
self.assertIs(book.is_pony, False) self.assertIs(book.is_pony, False)
self.assertIsNone(book.is_none) self.assertIsNone(book.is_none)
self.assertIsNone(book.is_none_old)
def test_annotation_in_f_grouped_by_annotation(self): def test_annotation_in_f_grouped_by_annotation(self):
qs = ( qs = (

View File

@ -50,7 +50,7 @@ class Tests(unittest.TestCase):
def test_boolean_constraints(self): def test_boolean_constraints(self):
"""Boolean fields have check constraints on their values.""" """Boolean fields have check constraints on their values."""
for field in (BooleanField(), NullBooleanField()): for field in (BooleanField(), NullBooleanField(), BooleanField(null=True)):
with self.subTest(field=field): with self.subTest(field=field):
field.set_attributes_from_name('is_nice') field.set_attributes_from_name('is_nice')
self.assertIn('"IS_NICE" IN (0,1)', field.db_check(connection)) self.assertIn('"IS_NICE" IN (0,1)', field.db_check(connection))

View File

@ -74,7 +74,8 @@ class NullableFields(models.Model):
duration_field = models.DurationField(null=True, default=datetime.timedelta(1)) duration_field = models.DurationField(null=True, default=datetime.timedelta(1))
float_field = models.FloatField(null=True, default=3.2) float_field = models.FloatField(null=True, default=3.2)
integer_field = models.IntegerField(null=True, default=2) integer_field = models.IntegerField(null=True, default=2)
null_boolean_field = models.NullBooleanField(null=True, default=False) null_boolean_field = models.BooleanField(null=True, default=False)
null_boolean_field_old = models.NullBooleanField(null=True, default=False)
positive_integer_field = models.PositiveIntegerField(null=True, default=3) positive_integer_field = models.PositiveIntegerField(null=True, default=3)
positive_small_integer_field = models.PositiveSmallIntegerField(null=True, default=4) positive_small_integer_field = models.PositiveSmallIntegerField(null=True, default=4)
small_integer_field = models.SmallIntegerField(null=True, default=5) small_integer_field = models.SmallIntegerField(null=True, default=5)

View File

@ -9,7 +9,8 @@ from django.db import models
class Donut(models.Model): class Donut(models.Model):
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
is_frosted = models.BooleanField(default=False) is_frosted = models.BooleanField(default=False)
has_sprinkles = models.NullBooleanField() has_sprinkles = models.BooleanField(null=True)
has_sprinkles_old = models.NullBooleanField()
baked_date = models.DateField(null=True) baked_date = models.DateField(null=True)
baked_time = models.TimeField(null=True) baked_time = models.TimeField(null=True)
consumed_at = models.DateTimeField(null=True) consumed_at = models.DateTimeField(null=True)

View File

@ -12,14 +12,18 @@ class DataTypesTestCase(TestCase):
d = Donut(name='Apple Fritter') d = Donut(name='Apple Fritter')
self.assertFalse(d.is_frosted) self.assertFalse(d.is_frosted)
self.assertIsNone(d.has_sprinkles) self.assertIsNone(d.has_sprinkles)
self.assertIsNone(d.has_sprinkles_old)
d.has_sprinkles = True d.has_sprinkles = True
d.has_sprinkles_old = True
self.assertTrue(d.has_sprinkles) self.assertTrue(d.has_sprinkles)
self.assertTrue(d.has_sprinkles_old)
d.save() d.save()
d2 = Donut.objects.get(name='Apple Fritter') d2 = Donut.objects.get(name='Apple Fritter')
self.assertFalse(d2.is_frosted) self.assertFalse(d2.is_frosted)
self.assertTrue(d2.has_sprinkles) self.assertTrue(d2.has_sprinkles)
self.assertTrue(d2.has_sprinkles_old)
def test_date_type(self): def test_date_type(self):
d = Donut(name='Apple Fritter') d = Donut(name='Apple Fritter')

View File

@ -25,7 +25,8 @@ class CaseTestModel(models.Model):
if Image: if Image:
image = models.ImageField(null=True) image = models.ImageField(null=True)
generic_ip_address = models.GenericIPAddressField(null=True) generic_ip_address = models.GenericIPAddressField(null=True)
null_boolean = models.NullBooleanField() null_boolean = models.BooleanField(null=True)
null_boolean_old = models.NullBooleanField()
positive_integer = models.PositiveIntegerField(null=True) positive_integer = models.PositiveIntegerField(null=True)
positive_small_integer = models.PositiveSmallIntegerField(null=True) positive_small_integer = models.PositiveSmallIntegerField(null=True)
slug = models.SlugField(default='') slug = models.SlugField(default='')

View File

@ -820,6 +820,19 @@ class CaseExpressionTests(TestCase):
transform=attrgetter('integer', 'null_boolean') transform=attrgetter('integer', 'null_boolean')
) )
def test_update_null_boolean_old(self):
CaseTestModel.objects.update(
null_boolean_old=Case(
When(integer=1, then=True),
When(integer=2, then=False),
),
)
self.assertQuerysetEqual(
CaseTestModel.objects.all().order_by('pk'),
[(1, True), (2, False), (3, None), (2, False), (3, None), (3, None), (4, None)],
transform=attrgetter('integer', 'null_boolean_old')
)
def test_update_positive_integer(self): def test_update_positive_integer(self):
CaseTestModel.objects.update( CaseTestModel.objects.update(
positive_integer=Case( positive_integer=Case(

View File

@ -169,7 +169,7 @@ class HasLinkThing(HasLinks):
class A(models.Model): class A(models.Model):
flag = models.NullBooleanField() flag = models.BooleanField(null=True)
content_type = models.ForeignKey(ContentType, models.CASCADE) content_type = models.ForeignKey(ContentType, models.CASCADE)
object_id = models.PositiveIntegerField() object_id = models.PositiveIntegerField()
content_object = GenericForeignKey('content_type', 'object_id') content_object = GenericForeignKey('content_type', 'object_id')

View File

@ -44,7 +44,7 @@ class ColumnTypes(models.Model):
id = models.AutoField(primary_key=True) id = models.AutoField(primary_key=True)
big_int_field = models.BigIntegerField() big_int_field = models.BigIntegerField()
bool_field = models.BooleanField(default=False) bool_field = models.BooleanField(default=False)
null_bool_field = models.NullBooleanField() null_bool_field = models.BooleanField(null=True)
char_field = models.CharField(max_length=10) char_field = models.CharField(max_length=10)
null_char_field = models.CharField(max_length=10, blank=True, null=True) null_char_field = models.CharField(max_length=10, blank=True, null=True)
date_field = models.DateField() date_field = models.DateField()

View File

@ -38,24 +38,6 @@ class AutoFieldTests(SimpleTestCase):
]) ])
@isolate_apps('invalid_models_tests')
class BooleanFieldTests(SimpleTestCase):
def test_nullable_boolean_field(self):
class Model(models.Model):
field = models.BooleanField(null=True)
field = Model._meta.get_field('field')
self.assertEqual(field.check(), [
Error(
'BooleanFields do not accept null values.',
hint='Use a NullBooleanField instead.',
obj=field,
id='fields.E110',
),
])
@isolate_apps('invalid_models_tests') @isolate_apps('invalid_models_tests')
class CharFieldTests(TestCase): class CharFieldTests(TestCase):

View File

@ -119,7 +119,7 @@ class Child7(Parent):
# RelatedManagers # RelatedManagers
class RelatedModel(models.Model): class RelatedModel(models.Model):
test_gfk = GenericRelation('RelationModel', content_type_field='gfk_ctype', object_id_field='gfk_id') test_gfk = GenericRelation('RelationModel', content_type_field='gfk_ctype', object_id_field='gfk_id')
exact = models.NullBooleanField() exact = models.BooleanField(null=True)
def __str__(self): def __str__(self):
return str(self.pk) return str(self.pk)

View File

@ -101,7 +101,8 @@ class Post(models.Model):
class NullBooleanModel(models.Model): class NullBooleanModel(models.Model):
nbfield = models.NullBooleanField() nbfield = models.BooleanField(null=True, blank=True)
nbfield_old = models.NullBooleanField()
class BooleanModel(models.Model): class BooleanModel(models.Model):

View File

@ -24,12 +24,18 @@ class BooleanFieldTests(TestCase):
self._test_get_prep_value(models.BooleanField()) self._test_get_prep_value(models.BooleanField())
def test_nullbooleanfield_get_prep_value(self): def test_nullbooleanfield_get_prep_value(self):
self._test_get_prep_value(models.BooleanField(null=True))
def test_nullbooleanfield_old_get_prep_value(self):
self._test_get_prep_value(models.NullBooleanField()) self._test_get_prep_value(models.NullBooleanField())
def test_booleanfield_to_python(self): def test_booleanfield_to_python(self):
self._test_to_python(models.BooleanField()) self._test_to_python(models.BooleanField())
def test_nullbooleanfield_to_python(self): def test_nullbooleanfield_to_python(self):
self._test_to_python(models.BooleanField(null=True))
def test_nullbooleanfield_old_to_python(self):
self._test_to_python(models.NullBooleanField()) self._test_to_python(models.NullBooleanField())
def test_booleanfield_choices_blank(self): def test_booleanfield_choices_blank(self):
@ -42,6 +48,8 @@ class BooleanFieldTests(TestCase):
self.assertEqual(f.formfield().choices, choices) self.assertEqual(f.formfield().choices, choices)
def test_nullbooleanfield_formfield(self): def test_nullbooleanfield_formfield(self):
f = models.BooleanField(null=True)
self.assertIsInstance(f.formfield(), forms.NullBooleanField)
f = models.NullBooleanField() f = models.NullBooleanField()
self.assertIsInstance(f.formfield(), forms.NullBooleanField) self.assertIsInstance(f.formfield(), forms.NullBooleanField)
@ -54,13 +62,15 @@ class BooleanFieldTests(TestCase):
b2.refresh_from_db() b2.refresh_from_db()
self.assertIs(b2.bfield, False) self.assertIs(b2.bfield, False)
b3 = NullBooleanModel.objects.create(nbfield=True) b3 = NullBooleanModel.objects.create(nbfield=True, nbfield_old=True)
b3.refresh_from_db() b3.refresh_from_db()
self.assertIs(b3.nbfield, True) self.assertIs(b3.nbfield, True)
self.assertIs(b3.nbfield_old, True)
b4 = NullBooleanModel.objects.create(nbfield=False) b4 = NullBooleanModel.objects.create(nbfield=False, nbfield_old=False)
b4.refresh_from_db() b4.refresh_from_db()
self.assertIs(b4.nbfield, False) self.assertIs(b4.nbfield, False)
self.assertIs(b4.nbfield_old, False)
# When an extra clause exists, the boolean conversions are applied with # When an extra clause exists, the boolean conversions are applied with
# an offset (#13293). # an offset (#13293).
@ -73,8 +83,8 @@ class BooleanFieldTests(TestCase):
""" """
bmt = BooleanModel.objects.create(bfield=True) bmt = BooleanModel.objects.create(bfield=True)
bmf = BooleanModel.objects.create(bfield=False) bmf = BooleanModel.objects.create(bfield=False)
nbmt = NullBooleanModel.objects.create(nbfield=True) nbmt = NullBooleanModel.objects.create(nbfield=True, nbfield_old=True)
nbmf = NullBooleanModel.objects.create(nbfield=False) nbmf = NullBooleanModel.objects.create(nbfield=False, nbfield_old=False)
m1 = FksToBooleans.objects.create(bf=bmt, nbf=nbmt) m1 = FksToBooleans.objects.create(bf=bmt, nbf=nbmt)
m2 = FksToBooleans.objects.create(bf=bmf, nbf=nbmf) m2 = FksToBooleans.objects.create(bf=bmf, nbf=nbmf)
@ -88,8 +98,10 @@ class BooleanFieldTests(TestCase):
mc = FksToBooleans.objects.select_related().get(pk=m2.id) mc = FksToBooleans.objects.select_related().get(pk=m2.id)
self.assertIs(mb.bf.bfield, True) self.assertIs(mb.bf.bfield, True)
self.assertIs(mb.nbf.nbfield, True) self.assertIs(mb.nbf.nbfield, True)
self.assertIs(mb.nbf.nbfield_old, True)
self.assertIs(mc.bf.bfield, False) self.assertIs(mc.bf.bfield, False)
self.assertIs(mc.nbf.nbfield, False) self.assertIs(mc.nbf.nbfield, False)
self.assertIs(mc.nbf.nbfield_old, False)
def test_null_default(self): def test_null_default(self):
""" """
@ -105,6 +117,7 @@ class BooleanFieldTests(TestCase):
nb = NullBooleanModel() nb = NullBooleanModel()
self.assertIsNone(nb.nbfield) self.assertIsNone(nb.nbfield)
self.assertIsNone(nb.nbfield_old)
nb.save() # no error nb.save() # no error
@ -120,5 +133,5 @@ class ValidationTest(SimpleTestCase):
NullBooleanField shouldn't throw a validation error when given a value NullBooleanField shouldn't throw a validation error when given a value
of None. of None.
""" """
nullboolean = NullBooleanModel(nbfield=None) nullboolean = NullBooleanModel(nbfield=None, nbfield_old=None)
nullboolean.full_clean() nullboolean.full_clean()

View File

@ -168,7 +168,7 @@ class Migration(migrations.Migration):
name='AggregateTestModel', name='AggregateTestModel',
fields=[ fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('boolean_field', models.NullBooleanField()), ('boolean_field', models.BooleanField(null=True)),
('char_field', models.CharField(max_length=30, blank=True)), ('char_field', models.CharField(max_length=30, blank=True)),
('integer_field', models.IntegerField(null=True)), ('integer_field', models.IntegerField(null=True)),
] ]

View File

@ -156,7 +156,7 @@ class AggregateTestModel(models.Model):
""" """
char_field = models.CharField(max_length=30, blank=True) char_field = models.CharField(max_length=30, blank=True)
integer_field = models.IntegerField(null=True) integer_field = models.IntegerField(null=True)
boolean_field = models.NullBooleanField() boolean_field = models.BooleanField(null=True)
class StatTestModel(models.Model): class StatTestModel(models.Model):

View File

@ -676,7 +676,7 @@ class Student(models.Model):
class Classroom(models.Model): class Classroom(models.Model):
name = models.CharField(max_length=20) name = models.CharField(max_length=20)
has_blackboard = models.NullBooleanField() has_blackboard = models.BooleanField(null=True)
school = models.ForeignKey(School, models.CASCADE) school = models.ForeignKey(School, models.CASCADE)
students = models.ManyToManyField(Student, related_name='classroom') students = models.ManyToManyField(Student, related_name='classroom')

View File

@ -18,7 +18,7 @@ class BinaryData(models.Model):
class BooleanData(models.Model): class BooleanData(models.Model):
data = models.BooleanField(default=False) data = models.BooleanField(default=False, null=True)
class CharData(models.Model): class CharData(models.Model):
@ -226,10 +226,6 @@ class IntegerPKData(models.Model):
class GenericIPAddressPKData(models.Model): class GenericIPAddressPKData(models.Model):
data = models.GenericIPAddressField(primary_key=True) data = models.GenericIPAddressField(primary_key=True)
# This is just a Boolean field with null=True, and we can't test a PK value of NULL.
# class NullBooleanPKData(models.Model):
# data = models.NullBooleanField(primary_key=True)
class PositiveIntegerPKData(models.Model): class PositiveIntegerPKData(models.Model):
data = models.PositiveIntegerField(primary_key=True) data = models.PositiveIntegerField(primary_key=True)

View File

@ -200,6 +200,7 @@ test_data = [
(data_obj, 2, BinaryData, None), (data_obj, 2, BinaryData, None),
(data_obj, 5, BooleanData, True), (data_obj, 5, BooleanData, True),
(data_obj, 6, BooleanData, False), (data_obj, 6, BooleanData, False),
(data_obj, 7, BooleanData, None),
(data_obj, 10, CharData, "Test Char Data"), (data_obj, 10, CharData, "Test Char Data"),
(data_obj, 11, CharData, ""), (data_obj, 11, CharData, ""),
(data_obj, 12, CharData, "None"), (data_obj, 12, CharData, "None"),
@ -334,8 +335,6 @@ The end."""),
(pk_obj, 682, IntegerPKData, 0), (pk_obj, 682, IntegerPKData, 0),
# (XX, ImagePKData # (XX, ImagePKData
(pk_obj, 695, GenericIPAddressPKData, "fe80:1424:2223:6cff:fe8a:2e8a:2151:abcd"), (pk_obj, 695, GenericIPAddressPKData, "fe80:1424:2223:6cff:fe8a:2e8a:2151:abcd"),
# (pk_obj, 700, NullBooleanPKData, True),
# (pk_obj, 701, NullBooleanPKData, False),
(pk_obj, 720, PositiveIntegerPKData, 123456789), (pk_obj, 720, PositiveIntegerPKData, 123456789),
(pk_obj, 730, PositiveSmallIntegerPKData, 12), (pk_obj, 730, PositiveSmallIntegerPKData, 12),
(pk_obj, 740, SlugPKData, "this-is-a-slug"), (pk_obj, 740, SlugPKData, "this-is-a-slug"),

View File

@ -23,6 +23,10 @@ class ValidationMessagesTest(TestCase):
f = models.BooleanField() f = models.BooleanField()
self._test_validation_messages(f, 'fõo', ["'fõo' value must be either True or False."]) self._test_validation_messages(f, 'fõo', ["'fõo' value must be either True or False."])
def test_nullable_boolean_field_raises_error_message(self):
f = models.BooleanField(null=True)
self._test_validation_messages(f, 'fõo', ["'fõo' value must be either True, False, or None."])
def test_float_field_raises_error_message(self): def test_float_field_raises_error_message(self):
f = models.FloatField() f = models.FloatField()
self._test_validation_messages(f, 'fõo', ["'fõo' value must be a float."]) self._test_validation_messages(f, 'fõo', ["'fõo' value must be a float."])