Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions rest_framework/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -1754,15 +1754,28 @@ def get_value(self, dictionary):
# We override the default field access in order to support
# dictionaries in HTML forms.
if html.is_html_input(dictionary):
return html.parse_html_dict(dictionary, prefix=self.field_name)
result = html.parse_html_dict(dictionary, prefix=self.field_name, default=empty)
if result is not empty:
return result
# If the field name itself is present in the input,
# treat it as an explicit empty dict (e.g. clearing the field).
if self.field_name in dictionary:
return {}
return empty
return dictionary.get(self.field_name, empty)

def to_internal_value(self, data):
"""
Dicts of native values <- Dicts of primitive datatypes.
"""
if html.is_html_input(data):
data = html.parse_html_dict(data)
# Coerce HTML form inputs (e.g. QueryDict/MultiValueDict) to a plain dict.
# Use `.dict()` when available to preserve existing behaviour of taking
# the first value for each key, otherwise fall back to `dict()`.
if hasattr(data, 'dict'):
data = data.dict()
else:
data = dict(data)
if not isinstance(data, dict):
self.fail('not_a_dict', input_type=type(data).__name__)
if not self.allow_empty and len(data) == 0:
Expand Down
2 changes: 1 addition & 1 deletion rest_framework/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,7 @@ def get_value(self, dictionary):
# We override the default field access in order to support
# nested HTML forms.
if html.is_html_input(dictionary):
return html.parse_html_dict(dictionary, prefix=self.field_name) or empty
return html.parse_html_dict(dictionary, prefix=self.field_name, default=empty)
return dictionary.get(self.field_name, empty)

def run_validation(self, data=empty):
Expand Down
7 changes: 5 additions & 2 deletions rest_framework/utils/html.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def parse_html_list(dictionary, prefix='', default=None):
return [ret[item] for item in sorted(ret)] if ret else default


def parse_html_dict(dictionary, prefix=''):
def parse_html_dict(dictionary, prefix='', default=None):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks consistent with what we do for parsing html list 👍🏻 :

#5927

"""
Comment on lines +69 to 70
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parse_html_dict now returns default when no matching keys are found, and since default defaults to None this changes the previous behavior of returning an empty MultiValueDict() for missing prefixes. If any external callers rely on the old behavior, they may now get None and fail unexpectedly; consider keeping backward-compatible behavior (e.g., use a sentinel to detect “no default provided” and return an empty MultiValueDict in that case) or explicitly documenting this as a public API change.

Copilot uses AI. Check for mistakes.
Used to support dictionary values in HTML forms.

Expand All @@ -81,6 +81,9 @@ def parse_html_dict(dictionary, prefix=''):
'email': 'example@example.com'
}
}

:returns a MultiValueDict of the parsed data, or the value specified in
``default`` if the dict field was not present in the input
"""
ret = MultiValueDict()
regex = re.compile(r'^%s\.(.+)$' % re.escape(prefix))
Expand All @@ -92,4 +95,4 @@ def parse_html_dict(dictionary, prefix=''):
value = dictionary.getlist(field)
ret.setlist(key, value)

return ret
return ret if ret else default
68 changes: 68 additions & 0 deletions tests/test_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -2522,6 +2522,74 @@ def test_allow_empty_disallowed(self):

assert exc_info.value.detail == ['This dictionary may not be empty.']

def test_query_dict_input_with_dot_separated_keys(self):
"""
DictField should correctly parse HTML form (QueryDict) input
with dot-separated keys.
"""
class TestSerializer(serializers.Serializer):
data = serializers.DictField(child=serializers.CharField())

serializer = TestSerializer(data=QueryDict('data.a=1&data.b=2'))
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data == {'data': {'a': '1', 'b': '2'}}

def test_query_dict_input_no_values_uses_default(self):
"""
When no matching keys are present in the QueryDict and a default
is set, the field should return the default value.
"""
class TestSerializer(serializers.Serializer):
a = serializers.IntegerField(required=True)
data = serializers.DictField(default=lambda: {'x': 'y'})

serializer = TestSerializer(data=QueryDict('a=1'))
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data == {'a': 1, 'data': {'x': 'y'}}

def test_query_dict_input_no_values_no_default_and_not_required(self):
"""
When no matching keys are present in the QueryDict, there is no
default, and the field is not required, the field should be
skipped entirely from validated_data.
"""
class TestSerializer(serializers.Serializer):
data = serializers.DictField(required=False)

serializer = TestSerializer(data=QueryDict(''))
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data == {}

def test_query_dict_input_no_values_required(self):
"""
When no matching keys are present in the QueryDict and the field
is required, validation should fail.
"""
class TestSerializer(serializers.Serializer):
data = serializers.DictField(required=True)

serializer = TestSerializer(data=QueryDict(''))
assert not serializer.is_valid()
assert 'data' in serializer.errors

def test_partial_update_can_clear_html_dict_field(self):
"""
Test that a partial update can clear a DictField when provided with an
empty string value through a QueryDict.
"""
class TestSerializer(serializers.Serializer):
field_name = serializers.DictField(required=False)
other_field = serializers.CharField(required=False)

serializer = TestSerializer(
data=QueryDict('field_name='),
partial=True,
)
assert serializer.is_valid()
assert 'field_name' in serializer.validated_data
assert serializer.validated_data['field_name'] == {}
assert 'other_field' not in serializer.validated_data


class TestNestedDictField(FieldValues):
"""
Expand Down
20 changes: 20 additions & 0 deletions tests/test_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,26 @@ class Serializer(serializers.Serializer):
assert Serializer({'nested': {'a': '3', 'b': {}}}).data == {'nested': {'a': '3', 'c': '2'}}
assert Serializer({'nested': {'a': '3', 'b': {'c': '4'}}}).data == {'nested': {'a': '3', 'c': '4'}}

def test_nested_serializer_not_required_with_querydict(self):
"""
When a nested serializer is not required and the QueryDict does
not contain any matching prefixed keys, the nested serializer
should be omitted from validated_data. Regression test for #6234.
"""
from django.http import QueryDict

class NestedSerializer(serializers.Serializer):
x = serializers.CharField()

class ParentSerializer(serializers.Serializer):
name = serializers.CharField()
nested = NestedSerializer(required=False)

serializer = ParentSerializer(data=QueryDict("name=test"))
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data == {"name": "test"}
assert "nested" not in serializer.validated_data

def test_default_for_allow_null(self):
"""
Without an explicit default, allow_null implies default=None when serializing. #5518 #5708
Expand Down
Loading