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 %} +
+ {% 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/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...