diff --git a/docs/topics/html-and-forms.md b/docs/topics/html-and-forms.md
index 18774926b5..0186adbce9 100644
--- a/docs/topics/html-and-forms.md
+++ b/docs/topics/html-and-forms.md
@@ -215,6 +215,7 @@ select.html | `ChoiceField` or relational field types | hide_label
radio.html | `ChoiceField` or relational field types | inline, hide_label
select_multiple.html | `MultipleChoiceField` or relational fields with `many=True` | hide_label
checkbox_multiple.html | `MultipleChoiceField` or relational fields with `many=True` | inline, hide_label
-checkbox.html | `BooleanField` | hide_label
+checkbox.html | `BooleanField` with `allow_null=False` | hide_label
+select_boolean.html | `BooleanField` with `allow_null=True` | hide_label
fieldset.html | Nested serializer | hide_label
list_fieldset.html | `ListField` or nested serializer with `many=True` | hide_label
diff --git a/rest_framework/fields.py b/rest_framework/fields.py
index e4be54751d..d6473eab12 100644
--- a/rest_framework/fields.py
+++ b/rest_framework/fields.py
@@ -720,6 +720,10 @@ class BooleanField(Field):
}
NULL_VALUES = {'null', 'Null', 'NULL', '', None}
+ @property
+ def _is_nullable_boolean_field(self):
+ return self.allow_null
+
def to_internal_value(self, data):
try:
if data in self.TRUE_VALUES:
@@ -741,6 +745,14 @@ def to_representation(self, value):
return None
return bool(value)
+ def iter_options(self):
+ choices = {
+ "": _("Unknown"),
+ True: _("Yes"),
+ False: _("No"),
+ }
+ return iter_options(choices)
+
class NullBooleanField(BooleanField):
initial = None
diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py
index 5b7ba8a8c8..4f01db87fb 100644
--- a/rest_framework/renderers.py
+++ b/rest_framework/renderers.py
@@ -329,7 +329,10 @@ def render_field(self, field, parent_style):
if isinstance(field._field, serializers.HiddenField):
return ''
- style = self.default_style[field].copy()
+ if isinstance(field._field, serializers.BooleanField) and field._field.allow_null:
+ style = {'base_template': 'select_boolean.html'}
+ else:
+ style = self.default_style[field].copy()
style.update(field.style)
if 'template_pack' not in style:
style['template_pack'] = parent_style.get('template_pack', self.template_pack)
diff --git a/rest_framework/templates/rest_framework/horizontal/select_boolean.html b/rest_framework/templates/rest_framework/horizontal/select_boolean.html
new file mode 100644
index 0000000000..1efd44edee
--- /dev/null
+++ b/rest_framework/templates/rest_framework/horizontal/select_boolean.html
@@ -0,0 +1,25 @@
+
+
diff --git a/rest_framework/templates/rest_framework/inline/select_boolean.html b/rest_framework/templates/rest_framework/inline/select_boolean.html
new file mode 100644
index 0000000000..edb240b87e
--- /dev/null
+++ b/rest_framework/templates/rest_framework/inline/select_boolean.html
@@ -0,0 +1,15 @@
+{% load rest_framework %}
+
+
+ {% if field.label %}
+
+ {{ field.label }}
+
+ {% endif %}
+
+
+ {% for select in field.iter_options %}
+ {{ select.display_text }}
+ {% endfor %}
+
+
\ No newline at end of file
diff --git a/rest_framework/templates/rest_framework/vertical/select_boolean.html b/rest_framework/templates/rest_framework/vertical/select_boolean.html
new file mode 100644
index 0000000000..715b7a2139
--- /dev/null
+++ b/rest_framework/templates/rest_framework/vertical/select_boolean.html
@@ -0,0 +1,23 @@
+
+ {% if field.label %}
+
+ {{ field.label }}
+
+ {% endif %}
+
+
+ {% for select in field.iter_options %}
+ {{ select.display_text }}
+ {% endfor %}
+
+
+ {% if field.errors %}
+ {% for error in field.errors %}
+ {{ error }}
+ {% endfor %}
+ {% endif %}
+
+ {% if field.help_text %}
+ {{ field.help_text|safe }}
+ {% endif %}
+
\ No newline at end of file
diff --git a/rest_framework/utils/serializer_helpers.py b/rest_framework/utils/serializer_helpers.py
index 4cd2ada314..35c99befd7 100644
--- a/rest_framework/utils/serializer_helpers.py
+++ b/rest_framework/utils/serializer_helpers.py
@@ -76,7 +76,10 @@ def __repr__(self):
)
def as_form_field(self):
- value = '' if (self.value is None or self.value is False) else self.value
+ if getattr(self._field, '_is_nullable_boolean_field', False):
+ value = '' if self.value is None else self.value
+ else:
+ value = '' if (self.value is None or self.value is False) else self.value
return self.__class__(self._field, value, self.errors, self._prefix)
@@ -129,6 +132,8 @@ def as_form_field(self):
for key, value in self.value.items():
if isinstance(value, (list, dict)):
values[key] = value
+ elif getattr(self.fields[key], '_is_nullable_boolean_field', False):
+ values[key] = '' if value is None else value
else:
values[key] = '' if (value is None or value is False) else force_str(value)
return self.__class__(self._field, values, self.errors, self._prefix)
diff --git a/tests/test_bound_fields.py b/tests/test_bound_fields.py
index eee7d9b852..878627ea71 100644
--- a/tests/test_bound_fields.py
+++ b/tests/test_bound_fields.py
@@ -1,3 +1,4 @@
+import pytest
from django.http import QueryDict
from rest_framework import serializers
@@ -59,11 +60,13 @@ class ExampleSerializer(serializers.Serializer):
def test_as_form_fields(self):
class ExampleSerializer(serializers.Serializer):
bool_field = serializers.BooleanField()
+ nullable_bool_field = serializers.BooleanField(allow_null=True)
null_field = serializers.IntegerField(allow_null=True)
- serializer = ExampleSerializer(data={'bool_field': False, 'null_field': None})
+ serializer = ExampleSerializer(data={'bool_field': False, 'nullable_bool_field': False, 'null_field': None})
assert serializer.is_valid()
assert serializer['bool_field'].as_form_field().value == ''
+ assert serializer['nullable_bool_field'].as_form_field().value is False
assert serializer['null_field'].as_form_field().value == ''
def test_rendering_boolean_field(self):
@@ -90,6 +93,55 @@ class ExampleSerializer(serializers.Serializer):
rendered_packed = ''.join(rendered.split())
assert rendered_packed == expected_packed
+ @pytest.mark.parametrize('bool_field_value', [True, False, None])
+ def test_rendering_nullable_boolean_field(self, bool_field_value):
+ from rest_framework.renderers import HTMLFormRenderer
+
+ class ExampleSerializer(serializers.Serializer):
+ bool_field = serializers.BooleanField(
+ allow_null=True,
+ style={'base_template': 'select_boolean.html', 'template_pack': 'rest_framework/vertical'})
+
+ serializer = ExampleSerializer(data={'bool_field': bool_field_value})
+ assert serializer.is_valid()
+ renderer = HTMLFormRenderer()
+ rendered = renderer.render_field(serializer['bool_field'], {})
+ if bool_field_value is True:
+ expected_packed = (
+ ''
+ 'Boolfield '
+ ''
+ 'Unknown'
+ 'Yes'
+ 'No'
+ ''
+ ''
+ )
+ elif bool_field_value is False:
+ expected_packed = (
+ ''
+ 'Boolfield '
+ ''
+ 'Unknown'
+ 'Yes'
+ 'No'
+ ''
+ ''
+ )
+ elif bool_field_value is None:
+ expected_packed = (
+ ''
+ 'Boolfield '
+ ''
+ 'Unknown'
+ 'Yes'
+ 'No'
+ ''
+ ''
+ )
+ rendered_packed = ''.join(rendered.split())
+ assert rendered_packed == expected_packed
+
class CustomJSONField(serializers.JSONField):
pass
@@ -120,6 +172,7 @@ class ExampleSerializer(serializers.Serializer):
def test_as_form_fields(self):
class Nested(serializers.Serializer):
bool_field = serializers.BooleanField()
+ nullable_bool_field = serializers.BooleanField(allow_null=True)
null_field = serializers.IntegerField(allow_null=True)
json_field = serializers.JSONField()
custom_json_field = CustomJSONField()
@@ -129,12 +182,13 @@ class ExampleSerializer(serializers.Serializer):
serializer = ExampleSerializer(
data={'nested': {
- 'bool_field': False, 'null_field': None,
+ 'bool_field': False, 'nullable_bool_field': False, 'null_field': None,
'json_field': {'bool_item': True, 'number': 1, 'text_item': 'text'},
'custom_json_field': {'bool_item': True, 'number': 1, 'text_item': 'text'},
}})
assert serializer.is_valid()
assert serializer['nested']['bool_field'].as_form_field().value == ''
+ assert serializer['nested']['nullable_bool_field'].as_form_field().value is False
assert serializer['nested']['null_field'].as_form_field().value == ''
assert serializer['nested']['json_field'].as_form_field().value == '''{
"bool_item": true,
diff --git a/tests/test_fields.py b/tests/test_fields.py
index 5842553f02..823d426822 100644
--- a/tests/test_fields.py
+++ b/tests/test_fields.py
@@ -364,6 +364,8 @@ def test_empty_html_checkbox(self):
"""
HTML checkboxes do not send any value, but should be treated
as `False` by BooleanField.
+ Note: BooleanFields are rendered as HTML checkboxes
+ only if allow_null=False.
"""
class TestSerializer(serializers.Serializer):
archived = serializers.BooleanField()
@@ -376,6 +378,8 @@ def test_empty_html_checkbox_not_required(self):
"""
HTML checkboxes do not send any value, but should be treated
as `False` by BooleanField, even if the field is required=False.
+ Note: BooleanFields are rendered as HTML checkboxes
+ only if allow_null=False.
"""
class TestSerializer(serializers.Serializer):
archived = serializers.BooleanField(required=False)
@@ -384,6 +388,22 @@ class TestSerializer(serializers.Serializer):
assert serializer.is_valid()
assert serializer.validated_data == {'archived': False}
+ @pytest.mark.parametrize(('select_option_value', 'expected_internal_value'), (('', None), ('True', True), ('False', False)))
+ def test_nullable_boolean_html(self, select_option_value, expected_internal_value):
+ """
+ If allow_null=True, BooleanField is rendered as HTML select element
+ containing three option elements with values '', 'True', and 'False'.
+ If option value=False selected, the internal value False is expected.
+ If option value=True selected, the internal value True is expected.
+ If option value= (the empty string) selected, the internal value None is expected.
+ """
+ class TestSerializer(serializers.Serializer):
+ archived = serializers.BooleanField(allow_null=True)
+
+ serializer = TestSerializer(data=QueryDict('archived={}'.format(select_option_value)))
+ assert serializer.is_valid()
+ assert serializer.validated_data == {'archived': expected_internal_value}
+
class TestHTMLInput:
def test_empty_html_charfield_with_default(self):