diff --git a/docs/topics/html-and-forms.md b/docs/topics/html-and-forms.md index 18774926b..0186adbce 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 e4be54751..d6473eab1 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 @@ class BooleanField(Field): 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 5b7ba8a8c..4f01db87f 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -329,7 +329,10 @@ class HTMLFormRenderer(BaseRenderer): 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 000000000..bbdc3361e --- /dev/null +++ b/rest_framework/templates/rest_framework/horizontal/select_boolean.html @@ -0,0 +1,25 @@ + +
+ {% if field.label %} + + {% endif %} + +
+ + {% 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/templates/rest_framework/inline/select_boolean.html b/rest_framework/templates/rest_framework/inline/select_boolean.html new file mode 100644 index 000000000..edb240b87 --- /dev/null +++ b/rest_framework/templates/rest_framework/inline/select_boolean.html @@ -0,0 +1,15 @@ +{% load rest_framework %} + +
+ {% if field.label %} + + {% endif %} + + +
\ 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 000000000..715b7a213 --- /dev/null +++ b/rest_framework/templates/rest_framework/vertical/select_boolean.html @@ -0,0 +1,23 @@ +
+ {% if field.label %} + + {% endif %} + + + + {% 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 4cd2ada31..35c99befd 100644 --- a/rest_framework/utils/serializer_helpers.py +++ b/rest_framework/utils/serializer_helpers.py @@ -76,7 +76,10 @@ class BoundField: ) 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 @@ class NestedBoundField(BoundField): 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 eee7d9b85..878627ea7 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 TestSimpleBoundField: 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 TestSimpleBoundField: 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 = ( + '' + '' + '' + 'Unknown' + 'Yes' + 'No' + '' + '' + ) + elif bool_field_value is False: + expected_packed = ( + '' + '' + '' + 'Unknown' + 'Yes' + 'No' + '' + '' + ) + elif bool_field_value is None: + expected_packed = ( + '' + '' + '' + 'Unknown' + 'Yes' + 'No' + '' + '' + ) + rendered_packed = ''.join(rendered.split()) + assert rendered_packed == expected_packed + class CustomJSONField(serializers.JSONField): pass @@ -120,6 +172,7 @@ class TestNestedBoundField: 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 TestNestedBoundField: 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 5842553f0..823d42682 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -364,6 +364,8 @@ class TestBooleanHTMLInput: """ 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 @@ class TestBooleanHTMLInput: """ 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 TestBooleanHTMLInput: 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):