From c0a40984432f25916bab2c4a0705cbbf67e6da55 Mon Sep 17 00:00:00 2001 From: Danish Khalid Date: Thu, 22 Oct 2026 16:19:42 +0500 Subject: [PATCH 1/3] Render nullable BooleanField as a three-state select in the browsable API --- docs/topics/html-and-forms.md | 3 +- rest_framework/fields.py | 12 ++++++ rest_framework/renderers.py | 7 ++- .../horizontal/select_boolean.html | 28 ++++++++++++ .../rest_framework/inline/select_boolean.html | 10 +++++ .../vertical/select_boolean.html | 23 ++++++++++ rest_framework/utils/serializer_helpers.py | 7 ++- tests/test_bound_fields.py | 43 ++++++++++++++++++- tests/test_fields.py | 18 ++++++++ 9 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 rest_framework/templates/rest_framework/horizontal/select_boolean.html create mode 100644 rest_framework/templates/rest_framework/inline/select_boolean.html create mode 100644 rest_framework/templates/rest_framework/vertical/select_boolean.html 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..0bc6bacdc5 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -680,6 +680,10 @@ class BooleanField(Field): } NULL_VALUES = {'null', '', None} + @property + def _is_nullable_boolean_field(self): + return self.allow_null + def __init__(self, **kwargs): if kwargs.get('allow_null', False): self.default_empty_html = None @@ -712,6 +716,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..7690b752fc 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -330,8 +330,11 @@ def render_field(self, field, parent_style): if isinstance(field._field, serializers.HiddenField): return '' - style = self.default_style[field].copy() - style.update(field.style) + 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) style['renderer'] = self 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..b76d0cb941 --- /dev/null +++ b/rest_framework/templates/rest_framework/horizontal/select_boolean.html @@ -0,0 +1,28 @@ +
+ {% 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..06ede64c28 --- /dev/null +++ b/rest_framework/templates/rest_framework/inline/select_boolean.html @@ -0,0 +1,10 @@ +{% 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..1ba9060398 --- /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 %} +
diff --git a/rest_framework/utils/serializer_helpers.py b/rest_framework/utils/serializer_helpers.py index e6bd84f30e..3044efec21 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 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) @@ -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 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 737af4e751..cca33b0ae6 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,42 @@ 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 + class CustomJSONField(serializers.JSONField): pass @@ -124,6 +162,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 +172,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 +187,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... From 30da0756875a9881aea586d4f88b1f4c9a3877ed Mon Sep 17 00:00:00 2001 From: Danish Khalid Date: Fri, 23 Oct 2026 10:52:12 +0500 Subject: [PATCH 2/3] Address review feedback for nullable BooleanField rendering - renderers: merge the select_boolean.html default into the field's style instead of replacing it, so explicit styles (base_template, template_pack) are honoured again - fields/serializer_helpers: drop the _is_nullable_boolean_field property and use allow_null directly - templates: use the plain select.value == field.value comparison on a single line in all three select_boolean.html packs (the horizontal pack's split if tag did not parse at all) - tests: pin the explicit-style behaviour and the selected option across all three template packs --- rest_framework/fields.py | 4 -- rest_framework/renderers.py | 7 ++- .../horizontal/select_boolean.html | 3 +- .../rest_framework/inline/select_boolean.html | 3 +- rest_framework/utils/serializer_helpers.py | 4 +- tests/test_bound_fields.py | 46 +++++++++++++++++++ 6 files changed, 53 insertions(+), 14 deletions(-) diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 0bc6bacdc5..654d9d8c0e 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -680,10 +680,6 @@ class BooleanField(Field): } NULL_VALUES = {'null', '', None} - @property - def _is_nullable_boolean_field(self): - return self.allow_null - def __init__(self, **kwargs): if kwargs.get('allow_null', False): self.default_empty_html = None diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index 7690b752fc..a688b8a2b9 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -330,11 +330,10 @@ def render_field(self, field, parent_style): 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) + 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) style['renderer'] = self diff --git a/rest_framework/templates/rest_framework/horizontal/select_boolean.html b/rest_framework/templates/rest_framework/horizontal/select_boolean.html index b76d0cb941..c576198c99 100644 --- a/rest_framework/templates/rest_framework/horizontal/select_boolean.html +++ b/rest_framework/templates/rest_framework/horizontal/select_boolean.html @@ -9,8 +9,7 @@
- {% for select in field.iter_options %} {% if select.value|as_string == - field.value|as_string %} + {% for select in field.iter_options %} {% if select.value == field.value %} {% else %} diff --git a/rest_framework/utils/serializer_helpers.py b/rest_framework/utils/serializer_helpers.py index 3044efec21..00da6ec4f2 100644 --- a/rest_framework/utils/serializer_helpers.py +++ b/rest_framework/utils/serializer_helpers.py @@ -91,7 +91,7 @@ def __repr__(self): ) def as_form_field(self): - if getattr(self._field, '_is_nullable_boolean_field', False): + 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 @@ -145,7 +145,7 @@ def as_form_field(self): 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): + 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) diff --git a/tests/test_bound_fields.py b/tests/test_bound_fields.py index cca33b0ae6..863545bda0 100644 --- a/tests/test_bound_fields.py +++ b/tests/test_bound_fields.py @@ -129,6 +129,52 @@ class ExampleSerializer(serializers.Serializer): 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 + class CustomJSONField(serializers.JSONField): pass From 22cfd683512838a9e16e0b91bf5ce631e1755dec Mon Sep 17 00:00:00 2001 From: Danish Khalid Date: Fri, 23 Oct 2026 12:25:42 +0500 Subject: [PATCH 3/3] Fix lost selection when a nullable BooleanField form is redisplayed Compare the option and bound values as strings in select_boolean.html (all three template packs): after a failed validation the bound value is the raw submitted string rather than a bool, so a plain comparison matched only the blank option. Add the missing load tag for the as_string filter to the vertical and horizontal packs, and a regression test covering the invalid-data path in all three packs. --- .../horizontal/select_boolean.html | 3 ++- .../rest_framework/inline/select_boolean.html | 2 +- .../vertical/select_boolean.html | 3 ++- tests/test_bound_fields.py | 24 +++++++++++++++++++ 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/rest_framework/templates/rest_framework/horizontal/select_boolean.html b/rest_framework/templates/rest_framework/horizontal/select_boolean.html index c576198c99..5e7778b73b 100644 --- a/rest_framework/templates/rest_framework/horizontal/select_boolean.html +++ b/rest_framework/templates/rest_framework/horizontal/select_boolean.html @@ -1,3 +1,4 @@ +{% load rest_framework %}
{% if field.label %}