diff --git a/docs/topics/html-and-forms.md b/docs/topics/html-and-forms.md
index c7e51c1526..98bba87615 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
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 9b922ed918..654d9d8c0e 100644
--- a/rest_framework/fields.py
+++ b/rest_framework/fields.py
@@ -712,6 +712,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)
+
# String types...
diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py
index 8987fb962e..a688b8a2b9 100644
--- a/rest_framework/renderers.py
+++ b/rest_framework/renderers.py
@@ -331,6 +331,8 @@ def render_field(self, field, parent_style):
return ''
style = self.default_style[field].copy()
+ if isinstance(field._field, serializers.BooleanField) and field._field.allow_null:
+ style['base_template'] = 'select_boolean.html'
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..5e7778b73b
--- /dev/null
+++ b/rest_framework/templates/rest_framework/horizontal/select_boolean.html
@@ -0,0 +1,28 @@
+{% load rest_framework %}
+
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..562f7f230c
--- /dev/null
+++ b/rest_framework/templates/rest_framework/inline/select_boolean.html
@@ -0,0 +1,9 @@
+{% load rest_framework %}
+
+
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..150bb49484
--- /dev/null
+++ b/rest_framework/templates/rest_framework/vertical/select_boolean.html
@@ -0,0 +1,24 @@
+{% load rest_framework %}
+
+ {% if field.label %}
+
+ {% endif %}
+
+
+
+ {% if field.errors %} {% for error in field.errors %}
+ {{ error }}
+ {% endfor %} {% endif %} {% if field.help_text %}
+ {{ field.help_text|safe }}
+ {% endif %}
+
diff --git a/rest_framework/utils/serializer_helpers.py b/rest_framework/utils/serializer_helpers.py
index e6bd84f30e..00da6ec4f2 100644
--- a/rest_framework/utils/serializer_helpers.py
+++ b/rest_framework/utils/serializer_helpers.py
@@ -91,7 +91,10 @@ def __repr__(self):
)
def as_form_field(self):
- value = '' if (self.value is None or self.value is False) else self.value
+ if self._field.allow_null:
+ 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)
@@ -142,6 +145,8 @@ def as_form_field(self):
for key, value in self.value.items():
if isinstance(value, (list, dict)):
values[key] = value
+ elif self.fields[key].allow_null:
+ 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 737af4e751..4e4247d9df 100644
--- a/tests/test_bound_fields.py
+++ b/tests/test_bound_fields.py
@@ -61,11 +61,13 @@ def test_as_form_fields(self):
class ExampleSerializer(serializers.Serializer):
bool_field = serializers.BooleanField()
null_field = serializers.IntegerField(allow_null=True)
+ nullable_bool_field = serializers.BooleanField(allow_null=True)
- serializer = ExampleSerializer(data={'bool_field': False, 'null_field': None})
+ serializer = ExampleSerializer(data={'bool_field': False, 'null_field': None, 'nullable_bool_field': False})
assert serializer.is_valid()
assert serializer['bool_field'].as_form_field().value == ''
assert serializer['null_field'].as_form_field().value == ''
+ assert serializer['nullable_bool_field'].as_form_field().value is False
def test_rendering_boolean_field(self):
from rest_framework.renderers import HTMLFormRenderer
@@ -91,6 +93,112 @@ class ExampleSerializer(serializers.Serializer):
rendered_packed = ''.join(rendered.split())
assert rendered_packed == expected_packed
+ def test_rendering_nullable_boolean_field(self):
+ from rest_framework.renderers import HTMLFormRenderer
+
+ class ExampleSerializer(serializers.Serializer):
+ bool_field = serializers.BooleanField(allow_null=True)
+
+ renderer = HTMLFormRenderer()
+ cases = [
+ (True, (
+ 'Unknown'
+ 'Yes'
+ 'No'
+ )),
+ (False, (
+ 'Unknown'
+ 'Yes'
+ 'No'
+ )),
+ (None, (
+ 'Unknown'
+ 'Yes'
+ 'No'
+ )),
+ ]
+ for value, options in cases:
+ serializer = ExampleSerializer(data={'bool_field': value})
+ assert serializer.is_valid()
+ rendered = renderer.render_field(serializer['bool_field'], {})
+ expected_packed = (
+ ''
+ ''
+ ''
+ ) + options + ''
+ rendered_packed = ''.join(rendered.split())
+ assert rendered_packed == expected_packed
+
+ def test_rendering_nullable_boolean_field_with_explicit_style(self):
+ from rest_framework.renderers import HTMLFormRenderer
+
+ class ExampleSerializer(serializers.Serializer):
+ bool_field = serializers.BooleanField(
+ allow_null=True,
+ style={'base_template': 'checkbox.html', 'template_pack': 'rest_framework/vertical'})
+
+ serializer = ExampleSerializer(data={'bool_field': True})
+ assert serializer.is_valid()
+ renderer = HTMLFormRenderer()
+ rendered = renderer.render_field(serializer['bool_field'], {})
+ expected_packed = (
+ ''
+ ''
+ ''
+ ''
+ ''
+ )
+ rendered_packed = ''.join(rendered.split())
+ assert rendered_packed == expected_packed
+
+ def test_rendering_nullable_boolean_field_template_packs(self):
+ from rest_framework.renderers import HTMLFormRenderer
+
+ class ExampleSerializer(serializers.Serializer):
+ bool_field = serializers.BooleanField(allow_null=True)
+
+ cases = [
+ (True, 'Yes'),
+ (False, 'No'),
+ (None, 'Unknown'),
+ ]
+ for template_pack in ('rest_framework/vertical', 'rest_framework/horizontal', 'rest_framework/inline'):
+ renderer = HTMLFormRenderer()
+ renderer.template_pack = template_pack
+ for value, selected_option in cases:
+ serializer = ExampleSerializer(data={'bool_field': value})
+ assert serializer.is_valid()
+ rendered = renderer.render_field(serializer['bool_field'], {})
+ rendered_packed = ''.join(rendered.split())
+ assert selected_option in rendered_packed
+
+ def test_rendering_nullable_boolean_field_on_redisplay(self):
+ from rest_framework.renderers import HTMLFormRenderer
+
+ class ExampleSerializer(serializers.Serializer):
+ bool_field = serializers.BooleanField(allow_null=True)
+ other = serializers.CharField()
+
+ # After a failed validation the bound value is the raw submitted
+ # string, not a bool; the submitted option must stay selected.
+ cases = [
+ ('True', 'Yes'),
+ ('False', 'No'),
+ ('', 'Unknown'),
+ ]
+ for template_pack in ('rest_framework/vertical', 'rest_framework/horizontal', 'rest_framework/inline'):
+ renderer = HTMLFormRenderer()
+ renderer.template_pack = template_pack
+ for raw_value, selected_option in cases:
+ serializer = ExampleSerializer(data={'bool_field': raw_value})
+ assert not serializer.is_valid()
+ rendered = renderer.render_field(serializer['bool_field'], {})
+ rendered_packed = ''.join(rendered.split())
+ assert selected_option in rendered_packed
+
class CustomJSONField(serializers.JSONField):
pass
@@ -124,6 +232,7 @@ class Nested(serializers.Serializer):
null_field = serializers.IntegerField(allow_null=True)
json_field = serializers.JSONField()
custom_json_field = CustomJSONField()
+ nullable_bool_field = serializers.BooleanField(allow_null=True)
class ExampleSerializer(serializers.Serializer):
nested = Nested()
@@ -133,6 +242,7 @@ class ExampleSerializer(serializers.Serializer):
'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'},
+ 'nullable_bool_field': False,
}})
assert serializer.is_valid()
assert serializer['nested']['bool_field'].as_form_field().value == ''
@@ -147,6 +257,7 @@ class ExampleSerializer(serializers.Serializer):
"number": 1,
"text_item": "text"
}'''
+ assert serializer['nested'].as_form_field().value['nullable_bool_field'] is False
def test_rendering_nested_fields_with_none_value(self):
from rest_framework.renderers import HTMLFormRenderer
diff --git a/tests/test_fields.py b/tests/test_fields.py
index 4cbbc030e3..fffefaf0c9 100644
--- a/tests/test_fields.py
+++ b/tests/test_fields.py
@@ -845,6 +845,24 @@ class TestNullableBooleanField(TestBooleanField):
}
field = serializers.BooleanField(allow_null=True)
+ @pytest.mark.parametrize(
+ 'input_value,output_value',
+ [
+ ('', None),
+ ('True', True),
+ ('False', False),
+ ]
+ )
+ def test_nullable_boolean_html(self, input_value, output_value):
+ class TestSerializer(serializers.Serializer):
+ bool_field = serializers.BooleanField(allow_null=True)
+
+ data = QueryDict(mutable=True)
+ data.update({'bool_field': input_value})
+ serializer = TestSerializer(data=data)
+ assert serializer.is_valid()
+ assert serializer.validated_data['bool_field'] is output_value
+
# String types...