mirror of
				https://github.com/encode/django-rest-framework.git
				synced 2025-11-04 09:57:55 +03:00 
			
		
		
		
	Fix #7722.
Render BooleanFields with allow_null=True as HTML select rather than as HTML checkbox in Browsable API #7722.
This commit is contained in:
		
							parent
							
								
									0323d6f895
								
							
						
					
					
						commit
						5c9bd01b5f
					
				| 
						 | 
					@ -215,6 +215,7 @@ select.html | `ChoiceField` or relational field types | hide_label
 | 
				
			||||||
radio.html | `ChoiceField` or relational field types | inline, hide_label
 | 
					radio.html | `ChoiceField` or relational field types | inline, hide_label
 | 
				
			||||||
select_multiple.html | `MultipleChoiceField` or relational fields with `many=True` | 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_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
 | 
					fieldset.html | Nested serializer | hide_label
 | 
				
			||||||
list_fieldset.html | `ListField` or nested serializer with `many=True` |   hide_label
 | 
					list_fieldset.html | `ListField` or nested serializer with `many=True` |   hide_label
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -720,6 +720,10 @@ class BooleanField(Field):
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    NULL_VALUES = {'null', 'Null', 'NULL', '', None}
 | 
					    NULL_VALUES = {'null', 'Null', 'NULL', '', None}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def _is_nullable_boolean_field(self):
 | 
				
			||||||
 | 
					        return self.allow_null
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def to_internal_value(self, data):
 | 
					    def to_internal_value(self, data):
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            if data in self.TRUE_VALUES:
 | 
					            if data in self.TRUE_VALUES:
 | 
				
			||||||
| 
						 | 
					@ -741,6 +745,14 @@ class BooleanField(Field):
 | 
				
			||||||
            return None
 | 
					            return None
 | 
				
			||||||
        return bool(value)
 | 
					        return bool(value)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def iter_options(self):
 | 
				
			||||||
 | 
					        choices = {
 | 
				
			||||||
 | 
					            "": _("Unknown"),
 | 
				
			||||||
 | 
					            True: _("Yes"),
 | 
				
			||||||
 | 
					            False: _("No"),
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return iter_options(choices)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class NullBooleanField(BooleanField):
 | 
					class NullBooleanField(BooleanField):
 | 
				
			||||||
    initial = None
 | 
					    initial = None
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -329,6 +329,9 @@ class HTMLFormRenderer(BaseRenderer):
 | 
				
			||||||
        if isinstance(field._field, serializers.HiddenField):
 | 
					        if isinstance(field._field, serializers.HiddenField):
 | 
				
			||||||
            return ''
 | 
					            return ''
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        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 = self.default_style[field].copy()
 | 
				
			||||||
        style.update(field.style)
 | 
					        style.update(field.style)
 | 
				
			||||||
        if 'template_pack' not in style:
 | 
					        if 'template_pack' not in style:
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,25 @@
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<div class="form-group">
 | 
				
			||||||
 | 
					  {% if field.label %}
 | 
				
			||||||
 | 
					    <label class="col-sm-2 control-label {% if style.hide_label %}sr-only{% endif %}">
 | 
				
			||||||
 | 
					      {{ field.label }}
 | 
				
			||||||
 | 
					    </label>
 | 
				
			||||||
 | 
					  {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div class="col-sm-10">
 | 
				
			||||||
 | 
					    <select class="form-control" name="{{ field.name }}">
 | 
				
			||||||
 | 
					        {% for select in field.iter_options %}
 | 
				
			||||||
 | 
					            <option value="{{ select.value }}" {% if select.value == field.value %}selected{% endif %}>{{ select.display_text }}</option>
 | 
				
			||||||
 | 
					        {% endfor %}
 | 
				
			||||||
 | 
					    </select>
 | 
				
			||||||
 | 
					    {% if field.errors %}
 | 
				
			||||||
 | 
					      {% for error in field.errors %}
 | 
				
			||||||
 | 
					        <span class="help-block">{{ error }}</span>
 | 
				
			||||||
 | 
					      {% endfor %}
 | 
				
			||||||
 | 
					    {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    {% if field.help_text %}
 | 
				
			||||||
 | 
					      <span class="help-block">{{ field.help_text|safe }}</span>
 | 
				
			||||||
 | 
					    {% endif %}
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,15 @@
 | 
				
			||||||
 | 
					{% load rest_framework %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<div class="form-group {% if field.errors %}has-error{% endif %}">
 | 
				
			||||||
 | 
					  {% if field.label %}
 | 
				
			||||||
 | 
					    <label class="sr-only">
 | 
				
			||||||
 | 
					      {{ field.label }}
 | 
				
			||||||
 | 
					    </label>
 | 
				
			||||||
 | 
					  {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <select class="form-control" name="{{ field.name }}">
 | 
				
			||||||
 | 
					    {% for select in field.iter_options %}
 | 
				
			||||||
 | 
					        <option value="{{ select.value }}" {% if select.value|as_string == field.value|as_string %}selected{% endif %} >{{ select.display_text }}</option>
 | 
				
			||||||
 | 
					    {% endfor %}
 | 
				
			||||||
 | 
					  </select>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,23 @@
 | 
				
			||||||
 | 
					<div class="form-group {% if field.errors %}has-error{% endif %}">
 | 
				
			||||||
 | 
					  {% if field.label %}
 | 
				
			||||||
 | 
					    <label {% if style.hide_label %}class="sr-only"{% endif %}>
 | 
				
			||||||
 | 
					      {{ field.label }}
 | 
				
			||||||
 | 
					    </label>
 | 
				
			||||||
 | 
					  {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <select class="form-control" name="{{ field.name }}">
 | 
				
			||||||
 | 
					    {% for select in field.iter_options %}
 | 
				
			||||||
 | 
					    <option value="{{ select.value }}" {% if select.value == field.value %}selected{% endif %}>{{ select.display_text }}</option>
 | 
				
			||||||
 | 
					    {% endfor %}
 | 
				
			||||||
 | 
					  </select>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  {% if field.errors %}
 | 
				
			||||||
 | 
					    {% for error in field.errors %}
 | 
				
			||||||
 | 
					      <span class="help-block">{{ error }}</span>
 | 
				
			||||||
 | 
					    {% endfor %}
 | 
				
			||||||
 | 
					  {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  {% if field.help_text %}
 | 
				
			||||||
 | 
					    <span class="help-block">{{ field.help_text|safe }}</span>
 | 
				
			||||||
 | 
					  {% endif %}
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
| 
						 | 
					@ -76,6 +76,9 @@ class BoundField:
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def as_form_field(self):
 | 
					    def as_form_field(self):
 | 
				
			||||||
 | 
					        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
 | 
					            value = '' if (self.value is None or self.value is False) else self.value
 | 
				
			||||||
        return self.__class__(self._field, value, self.errors, self._prefix)
 | 
					        return self.__class__(self._field, value, self.errors, self._prefix)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -129,6 +132,8 @@ class NestedBoundField(BoundField):
 | 
				
			||||||
        for key, value in self.value.items():
 | 
					        for key, value in self.value.items():
 | 
				
			||||||
            if isinstance(value, (list, dict)):
 | 
					            if isinstance(value, (list, dict)):
 | 
				
			||||||
                values[key] = value
 | 
					                values[key] = value
 | 
				
			||||||
 | 
					            elif getattr(self.fields[key], '_is_nullable_boolean_field', False):
 | 
				
			||||||
 | 
					                values[key] = '' if value is None else value
 | 
				
			||||||
            else:
 | 
					            else:
 | 
				
			||||||
                values[key] = '' if (value is None or value is False) else force_str(value)
 | 
					                values[key] = '' if (value is None or value is False) else force_str(value)
 | 
				
			||||||
        return self.__class__(self._field, values, self.errors, self._prefix)
 | 
					        return self.__class__(self._field, values, self.errors, self._prefix)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,3 +1,4 @@
 | 
				
			||||||
 | 
					import pytest
 | 
				
			||||||
from django.http import QueryDict
 | 
					from django.http import QueryDict
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from rest_framework import serializers
 | 
					from rest_framework import serializers
 | 
				
			||||||
| 
						 | 
					@ -59,11 +60,13 @@ class TestSimpleBoundField:
 | 
				
			||||||
    def test_as_form_fields(self):
 | 
					    def test_as_form_fields(self):
 | 
				
			||||||
        class ExampleSerializer(serializers.Serializer):
 | 
					        class ExampleSerializer(serializers.Serializer):
 | 
				
			||||||
            bool_field = serializers.BooleanField()
 | 
					            bool_field = serializers.BooleanField()
 | 
				
			||||||
 | 
					            nullable_bool_field = serializers.BooleanField(allow_null=True)
 | 
				
			||||||
            null_field = serializers.IntegerField(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.is_valid()
 | 
				
			||||||
        assert serializer['bool_field'].as_form_field().value == ''
 | 
					        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 == ''
 | 
					        assert serializer['null_field'].as_form_field().value == ''
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_rendering_boolean_field(self):
 | 
					    def test_rendering_boolean_field(self):
 | 
				
			||||||
| 
						 | 
					@ -90,6 +93,55 @@ class TestSimpleBoundField:
 | 
				
			||||||
        rendered_packed = ''.join(rendered.split())
 | 
					        rendered_packed = ''.join(rendered.split())
 | 
				
			||||||
        assert rendered_packed == expected_packed
 | 
					        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 = (
 | 
				
			||||||
 | 
					                '<divclass="form-group">'
 | 
				
			||||||
 | 
					                '<label>Boolfield</label>'
 | 
				
			||||||
 | 
					                '<selectclass="form-control"name="bool_field">'
 | 
				
			||||||
 | 
					                '<optionvalue="">Unknown</option>'
 | 
				
			||||||
 | 
					                '<optionvalue="True"selected>Yes</option>'
 | 
				
			||||||
 | 
					                '<optionvalue="False">No</option>'
 | 
				
			||||||
 | 
					                '</select>'
 | 
				
			||||||
 | 
					                '</div>'
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        elif bool_field_value is False:
 | 
				
			||||||
 | 
					            expected_packed = (
 | 
				
			||||||
 | 
					                '<divclass="form-group">'
 | 
				
			||||||
 | 
					                '<label>Boolfield</label>'
 | 
				
			||||||
 | 
					                '<selectclass="form-control"name="bool_field">'
 | 
				
			||||||
 | 
					                '<optionvalue="">Unknown</option>'
 | 
				
			||||||
 | 
					                '<optionvalue="True">Yes</option>'
 | 
				
			||||||
 | 
					                '<optionvalue="False"selected>No</option>'
 | 
				
			||||||
 | 
					                '</select>'
 | 
				
			||||||
 | 
					                '</div>'
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        elif bool_field_value is None:
 | 
				
			||||||
 | 
					            expected_packed = (
 | 
				
			||||||
 | 
					                '<divclass="form-group">'
 | 
				
			||||||
 | 
					                '<label>Boolfield</label>'
 | 
				
			||||||
 | 
					                '<selectclass="form-control"name="bool_field">'
 | 
				
			||||||
 | 
					                '<optionvalue=""selected>Unknown</option>'
 | 
				
			||||||
 | 
					                '<optionvalue="True">Yes</option>'
 | 
				
			||||||
 | 
					                '<optionvalue="False">No</option>'
 | 
				
			||||||
 | 
					                '</select>'
 | 
				
			||||||
 | 
					                '</div>'
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        rendered_packed = ''.join(rendered.split())
 | 
				
			||||||
 | 
					        assert rendered_packed == expected_packed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class CustomJSONField(serializers.JSONField):
 | 
					class CustomJSONField(serializers.JSONField):
 | 
				
			||||||
    pass
 | 
					    pass
 | 
				
			||||||
| 
						 | 
					@ -120,6 +172,7 @@ class TestNestedBoundField:
 | 
				
			||||||
    def test_as_form_fields(self):
 | 
					    def test_as_form_fields(self):
 | 
				
			||||||
        class Nested(serializers.Serializer):
 | 
					        class Nested(serializers.Serializer):
 | 
				
			||||||
            bool_field = serializers.BooleanField()
 | 
					            bool_field = serializers.BooleanField()
 | 
				
			||||||
 | 
					            nullable_bool_field = serializers.BooleanField(allow_null=True)
 | 
				
			||||||
            null_field = serializers.IntegerField(allow_null=True)
 | 
					            null_field = serializers.IntegerField(allow_null=True)
 | 
				
			||||||
            json_field = serializers.JSONField()
 | 
					            json_field = serializers.JSONField()
 | 
				
			||||||
            custom_json_field = CustomJSONField()
 | 
					            custom_json_field = CustomJSONField()
 | 
				
			||||||
| 
						 | 
					@ -129,12 +182,13 @@ class TestNestedBoundField:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        serializer = ExampleSerializer(
 | 
					        serializer = ExampleSerializer(
 | 
				
			||||||
            data={'nested': {
 | 
					            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'},
 | 
					                'json_field': {'bool_item': True, 'number': 1, 'text_item': 'text'},
 | 
				
			||||||
                'custom_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.is_valid()
 | 
				
			||||||
        assert serializer['nested']['bool_field'].as_form_field().value == ''
 | 
					        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']['null_field'].as_form_field().value == ''
 | 
				
			||||||
        assert serializer['nested']['json_field'].as_form_field().value == '''{
 | 
					        assert serializer['nested']['json_field'].as_form_field().value == '''{
 | 
				
			||||||
    "bool_item": true,
 | 
					    "bool_item": true,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -364,6 +364,8 @@ class TestBooleanHTMLInput:
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        HTML checkboxes do not send any value, but should be treated
 | 
					        HTML checkboxes do not send any value, but should be treated
 | 
				
			||||||
        as `False` by BooleanField.
 | 
					        as `False` by BooleanField.
 | 
				
			||||||
 | 
					        Note: BooleanFields are rendered as HTML checkboxes
 | 
				
			||||||
 | 
					        only if allow_null=False.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        class TestSerializer(serializers.Serializer):
 | 
					        class TestSerializer(serializers.Serializer):
 | 
				
			||||||
            archived = serializers.BooleanField()
 | 
					            archived = serializers.BooleanField()
 | 
				
			||||||
| 
						 | 
					@ -376,6 +378,8 @@ class TestBooleanHTMLInput:
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        HTML checkboxes do not send any value, but should be treated
 | 
					        HTML checkboxes do not send any value, but should be treated
 | 
				
			||||||
        as `False` by BooleanField, even if the field is required=False.
 | 
					        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):
 | 
					        class TestSerializer(serializers.Serializer):
 | 
				
			||||||
            archived = serializers.BooleanField(required=False)
 | 
					            archived = serializers.BooleanField(required=False)
 | 
				
			||||||
| 
						 | 
					@ -384,6 +388,22 @@ class TestBooleanHTMLInput:
 | 
				
			||||||
        assert serializer.is_valid()
 | 
					        assert serializer.is_valid()
 | 
				
			||||||
        assert serializer.validated_data == {'archived': False}
 | 
					        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:
 | 
					class TestHTMLInput:
 | 
				
			||||||
    def test_empty_html_charfield_with_default(self):
 | 
					    def test_empty_html_charfield_with_default(self):
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
		Reference in New Issue
	
	Block a user