Skip to content

Commit 5c9bd01

Browse files
committed
Fix #7722.
Render BooleanFields with allow_null=True as HTML select rather than as HTML checkbox in Browsable API #7722.
1 parent 0323d6f commit 5c9bd01

File tree

9 files changed

+163
-5
lines changed

9 files changed

+163
-5
lines changed

docs/topics/html-and-forms.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ select.html | `ChoiceField` or relational field types | hide_label
215215
radio.html | `ChoiceField` or relational field types | inline, hide_label
216216
select_multiple.html | `MultipleChoiceField` or relational fields with `many=True` | hide_label
217217
checkbox_multiple.html | `MultipleChoiceField` or relational fields with `many=True` | inline, hide_label
218-
checkbox.html | `BooleanField` | hide_label
218+
checkbox.html | `BooleanField` with `allow_null=False` | hide_label
219+
select_boolean.html | `BooleanField` with `allow_null=True` | hide_label
219220
fieldset.html | Nested serializer | hide_label
220221
list_fieldset.html | `ListField` or nested serializer with `many=True` | hide_label

rest_framework/fields.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,10 @@ class BooleanField(Field):
720720
}
721721
NULL_VALUES = {'null', 'Null', 'NULL', '', None}
722722

723+
@property
724+
def _is_nullable_boolean_field(self):
725+
return self.allow_null
726+
723727
def to_internal_value(self, data):
724728
try:
725729
if data in self.TRUE_VALUES:
@@ -741,6 +745,14 @@ def to_representation(self, value):
741745
return None
742746
return bool(value)
743747

748+
def iter_options(self):
749+
choices = {
750+
"": _("Unknown"),
751+
True: _("Yes"),
752+
False: _("No"),
753+
}
754+
return iter_options(choices)
755+
744756

745757
class NullBooleanField(BooleanField):
746758
initial = None

rest_framework/renderers.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,10 @@ def render_field(self, field, parent_style):
329329
if isinstance(field._field, serializers.HiddenField):
330330
return ''
331331

332-
style = self.default_style[field].copy()
332+
if isinstance(field._field, serializers.BooleanField) and field._field.allow_null:
333+
style = {'base_template': 'select_boolean.html'}
334+
else:
335+
style = self.default_style[field].copy()
333336
style.update(field.style)
334337
if 'template_pack' not in style:
335338
style['template_pack'] = parent_style.get('template_pack', self.template_pack)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
2+
<div class="form-group">
3+
{% if field.label %}
4+
<label class="col-sm-2 control-label {% if style.hide_label %}sr-only{% endif %}">
5+
{{ field.label }}
6+
</label>
7+
{% endif %}
8+
9+
<div class="col-sm-10">
10+
<select class="form-control" name="{{ field.name }}">
11+
{% for select in field.iter_options %}
12+
<option value="{{ select.value }}" {% if select.value == field.value %}selected{% endif %}>{{ select.display_text }}</option>
13+
{% endfor %}
14+
</select>
15+
{% if field.errors %}
16+
{% for error in field.errors %}
17+
<span class="help-block">{{ error }}</span>
18+
{% endfor %}
19+
{% endif %}
20+
21+
{% if field.help_text %}
22+
<span class="help-block">{{ field.help_text|safe }}</span>
23+
{% endif %}
24+
</div>
25+
</div>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{% load rest_framework %}
2+
3+
<div class="form-group {% if field.errors %}has-error{% endif %}">
4+
{% if field.label %}
5+
<label class="sr-only">
6+
{{ field.label }}
7+
</label>
8+
{% endif %}
9+
10+
<select class="form-control" name="{{ field.name }}">
11+
{% for select in field.iter_options %}
12+
<option value="{{ select.value }}" {% if select.value|as_string == field.value|as_string %}selected{% endif %} >{{ select.display_text }}</option>
13+
{% endfor %}
14+
</select>
15+
</div>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<div class="form-group {% if field.errors %}has-error{% endif %}">
2+
{% if field.label %}
3+
<label {% if style.hide_label %}class="sr-only"{% endif %}>
4+
{{ field.label }}
5+
</label>
6+
{% endif %}
7+
8+
<select class="form-control" name="{{ field.name }}">
9+
{% for select in field.iter_options %}
10+
<option value="{{ select.value }}" {% if select.value == field.value %}selected{% endif %}>{{ select.display_text }}</option>
11+
{% endfor %}
12+
</select>
13+
14+
{% if field.errors %}
15+
{% for error in field.errors %}
16+
<span class="help-block">{{ error }}</span>
17+
{% endfor %}
18+
{% endif %}
19+
20+
{% if field.help_text %}
21+
<span class="help-block">{{ field.help_text|safe }}</span>
22+
{% endif %}
23+
</div>

rest_framework/utils/serializer_helpers.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,10 @@ def __repr__(self):
7676
)
7777

7878
def as_form_field(self):
79-
value = '' if (self.value is None or self.value is False) else self.value
79+
if getattr(self._field, '_is_nullable_boolean_field', False):
80+
value = '' if self.value is None else self.value
81+
else:
82+
value = '' if (self.value is None or self.value is False) else self.value
8083
return self.__class__(self._field, value, self.errors, self._prefix)
8184

8285

@@ -129,6 +132,8 @@ def as_form_field(self):
129132
for key, value in self.value.items():
130133
if isinstance(value, (list, dict)):
131134
values[key] = value
135+
elif getattr(self.fields[key], '_is_nullable_boolean_field', False):
136+
values[key] = '' if value is None else value
132137
else:
133138
values[key] = '' if (value is None or value is False) else force_str(value)
134139
return self.__class__(self._field, values, self.errors, self._prefix)

tests/test_bound_fields.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import pytest
12
from django.http import QueryDict
23

34
from rest_framework import serializers
@@ -59,11 +60,13 @@ class ExampleSerializer(serializers.Serializer):
5960
def test_as_form_fields(self):
6061
class ExampleSerializer(serializers.Serializer):
6162
bool_field = serializers.BooleanField()
63+
nullable_bool_field = serializers.BooleanField(allow_null=True)
6264
null_field = serializers.IntegerField(allow_null=True)
6365

64-
serializer = ExampleSerializer(data={'bool_field': False, 'null_field': None})
66+
serializer = ExampleSerializer(data={'bool_field': False, 'nullable_bool_field': False, 'null_field': None})
6567
assert serializer.is_valid()
6668
assert serializer['bool_field'].as_form_field().value == ''
69+
assert serializer['nullable_bool_field'].as_form_field().value is False
6770
assert serializer['null_field'].as_form_field().value == ''
6871

6972
def test_rendering_boolean_field(self):
@@ -90,6 +93,55 @@ class ExampleSerializer(serializers.Serializer):
9093
rendered_packed = ''.join(rendered.split())
9194
assert rendered_packed == expected_packed
9295

96+
@pytest.mark.parametrize('bool_field_value', [True, False, None])
97+
def test_rendering_nullable_boolean_field(self, bool_field_value):
98+
from rest_framework.renderers import HTMLFormRenderer
99+
100+
class ExampleSerializer(serializers.Serializer):
101+
bool_field = serializers.BooleanField(
102+
allow_null=True,
103+
style={'base_template': 'select_boolean.html', 'template_pack': 'rest_framework/vertical'})
104+
105+
serializer = ExampleSerializer(data={'bool_field': bool_field_value})
106+
assert serializer.is_valid()
107+
renderer = HTMLFormRenderer()
108+
rendered = renderer.render_field(serializer['bool_field'], {})
109+
if bool_field_value is True:
110+
expected_packed = (
111+
'<divclass="form-group">'
112+
'<label>Boolfield</label>'
113+
'<selectclass="form-control"name="bool_field">'
114+
'<optionvalue="">Unknown</option>'
115+
'<optionvalue="True"selected>Yes</option>'
116+
'<optionvalue="False">No</option>'
117+
'</select>'
118+
'</div>'
119+
)
120+
elif bool_field_value is False:
121+
expected_packed = (
122+
'<divclass="form-group">'
123+
'<label>Boolfield</label>'
124+
'<selectclass="form-control"name="bool_field">'
125+
'<optionvalue="">Unknown</option>'
126+
'<optionvalue="True">Yes</option>'
127+
'<optionvalue="False"selected>No</option>'
128+
'</select>'
129+
'</div>'
130+
)
131+
elif bool_field_value is None:
132+
expected_packed = (
133+
'<divclass="form-group">'
134+
'<label>Boolfield</label>'
135+
'<selectclass="form-control"name="bool_field">'
136+
'<optionvalue=""selected>Unknown</option>'
137+
'<optionvalue="True">Yes</option>'
138+
'<optionvalue="False">No</option>'
139+
'</select>'
140+
'</div>'
141+
)
142+
rendered_packed = ''.join(rendered.split())
143+
assert rendered_packed == expected_packed
144+
93145

94146
class CustomJSONField(serializers.JSONField):
95147
pass
@@ -120,6 +172,7 @@ class ExampleSerializer(serializers.Serializer):
120172
def test_as_form_fields(self):
121173
class Nested(serializers.Serializer):
122174
bool_field = serializers.BooleanField()
175+
nullable_bool_field = serializers.BooleanField(allow_null=True)
123176
null_field = serializers.IntegerField(allow_null=True)
124177
json_field = serializers.JSONField()
125178
custom_json_field = CustomJSONField()
@@ -129,12 +182,13 @@ class ExampleSerializer(serializers.Serializer):
129182

130183
serializer = ExampleSerializer(
131184
data={'nested': {
132-
'bool_field': False, 'null_field': None,
185+
'bool_field': False, 'nullable_bool_field': False, 'null_field': None,
133186
'json_field': {'bool_item': True, 'number': 1, 'text_item': 'text'},
134187
'custom_json_field': {'bool_item': True, 'number': 1, 'text_item': 'text'},
135188
}})
136189
assert serializer.is_valid()
137190
assert serializer['nested']['bool_field'].as_form_field().value == ''
191+
assert serializer['nested']['nullable_bool_field'].as_form_field().value is False
138192
assert serializer['nested']['null_field'].as_form_field().value == ''
139193
assert serializer['nested']['json_field'].as_form_field().value == '''{
140194
"bool_item": true,

tests/test_fields.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,8 @@ def test_empty_html_checkbox(self):
364364
"""
365365
HTML checkboxes do not send any value, but should be treated
366366
as `False` by BooleanField.
367+
Note: BooleanFields are rendered as HTML checkboxes
368+
only if allow_null=False.
367369
"""
368370
class TestSerializer(serializers.Serializer):
369371
archived = serializers.BooleanField()
@@ -376,6 +378,8 @@ def test_empty_html_checkbox_not_required(self):
376378
"""
377379
HTML checkboxes do not send any value, but should be treated
378380
as `False` by BooleanField, even if the field is required=False.
381+
Note: BooleanFields are rendered as HTML checkboxes
382+
only if allow_null=False.
379383
"""
380384
class TestSerializer(serializers.Serializer):
381385
archived = serializers.BooleanField(required=False)
@@ -384,6 +388,22 @@ class TestSerializer(serializers.Serializer):
384388
assert serializer.is_valid()
385389
assert serializer.validated_data == {'archived': False}
386390

391+
@pytest.mark.parametrize(('select_option_value', 'expected_internal_value'), (('', None), ('True', True), ('False', False)))
392+
def test_nullable_boolean_html(self, select_option_value, expected_internal_value):
393+
"""
394+
If allow_null=True, BooleanField is rendered as HTML select element
395+
containing three option elements with values '', 'True', and 'False'.
396+
If option value=False selected, the internal value False is expected.
397+
If option value=True selected, the internal value True is expected.
398+
If option value= (the empty string) selected, the internal value None is expected.
399+
"""
400+
class TestSerializer(serializers.Serializer):
401+
archived = serializers.BooleanField(allow_null=True)
402+
403+
serializer = TestSerializer(data=QueryDict('archived={}'.format(select_option_value)))
404+
assert serializer.is_valid()
405+
assert serializer.validated_data == {'archived': expected_internal_value}
406+
387407

388408
class TestHTMLInput:
389409
def test_empty_html_charfield_with_default(self):

0 commit comments

Comments
 (0)