Skip to content

Commit dcb4ad1

Browse files
authored
Merge branch 'main' into improve-many-true-validation-guidance
2 parents bb6b3bb + 65aa4b8 commit dcb4ad1

6 files changed

Lines changed: 190 additions & 13 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,5 @@ venv/
2020
!.github
2121
!.gitignore
2222
!.pre-commit-config.yaml
23+
24+
.idea

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ dev = [
4444
{ include-group = "test" },
4545
]
4646
test = [
47-
"importlib-metadata<9.0",
47+
"importlib-metadata<10.0",
4848
# Pytest for running the tests.
4949
"pytest==9.*",
5050
"pytest-cov==7.*",
@@ -56,7 +56,7 @@ test = [
5656
docs = [
5757
# MkDocs to build our documentation.
5858
"mkdocs==1.6.1",
59-
"mkdocs-material[imaging]==9.7.5",
59+
"mkdocs-material[imaging]==9.7.6",
6060
# pylinkvalidator to check for broken links in documentation.
6161
"pylinkvalidator==0.3",
6262
]

rest_framework/fields.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1680,18 +1680,24 @@ def __init__(self, **kwargs):
16801680
self.validators.append(MinLengthValidator(self.min_length, message=message))
16811681

16821682
def get_value(self, dictionary):
1683-
if self.field_name not in dictionary:
1684-
if getattr(self.root, 'partial', False):
1685-
return empty
16861683
# We override the default field access in order to support
16871684
# lists in HTML forms.
16881685
if html.is_html_input(dictionary):
16891686
val = dictionary.getlist(self.field_name, [])
16901687
if len(val) > 0:
1691-
# Support QueryDict lists in HTML input.
1688+
# Support QueryDict lists and other list-like results in HTML input.
16921689
return val
1690+
# For partial updates, avoid calling parse_html_list unless indexed keys are present.
1691+
# This reduces unnecessary parsing overhead for omitted list fields.
1692+
if getattr(self.root, 'partial', False):
1693+
prefix = self.field_name + '['
1694+
if not any(key.startswith(prefix) for key in dictionary):
1695+
return empty
16931696
return html.parse_html_list(dictionary, prefix=self.field_name, default=empty)
16941697

1698+
# Non-HTML input: standard dictionary access
1699+
if self.field_name not in dictionary and getattr(self.root, 'partial', False):
1700+
return empty
16951701
return dictionary.get(self.field_name, empty)
16961702

16971703
def to_internal_value(self, data):
Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
<ul class="pager">
22
{% if previous_url %}
33
<li class="previous">
4-
<a href="{{ previous_url }}">&laquo; Previous</a>
4+
<a href="{{ previous_url }}" rel="prev" aria-label="Previous page">&laquo; Previous</a>
55
</li>
66
{% else %}
7-
<li class="previous disabled">
8-
<a href="#">&laquo; Previous</a>
7+
<li class="previous disabled" aria-disabled="true">
8+
<a href="#" tabindex="-1" aria-disabled="true">&laquo; Previous</a>
99
</li>
1010
{% endif %}
1111

1212
{% if next_url %}
1313
<li class="next">
14-
<a href="{{ next_url }}">Next &raquo;</a>
14+
<a href="{{ next_url }}" rel="next" aria-label="Next page">Next &raquo;</a>
1515
</li>
1616
{% else %}
17-
<li class="next disabled">
18-
<a href="#">Next &raquo;</a>
17+
<li class="next disabled" aria-disabled="true">
18+
<a href="#" tabindex="-1" aria-disabled="true">Next &raquo;</a>
1919
</li>
2020
{% endif %}
21-
</ul>
21+
</ul>

tests/test_fields.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,67 @@ class TestSerializer(serializers.Serializer):
576576
assert serializer.is_valid()
577577
assert serializer.validated_data == {'scores': ['']}
578578

579+
def test_partial_update_with_indexed_keys(self):
580+
"""
581+
Regression test for indexed HTML form keys with partial=True.
582+
When data is passed as `colors[0]=#ffffff&colors[1]=#000000`
583+
with partial=True, the field should parse indexed keys correctly.
584+
"""
585+
class TestSerializer(serializers.Serializer):
586+
colors = serializers.ListField(
587+
allow_null=True,
588+
child=serializers.CharField(max_length=7),
589+
required=False
590+
)
591+
name = serializers.CharField(max_length=100, required=False)
592+
593+
serializer = TestSerializer(
594+
data=QueryDict('colors[0]=#ffffff&colors[1]=#000000'),
595+
partial=True
596+
)
597+
assert serializer.is_valid()
598+
assert serializer.validated_data == {'colors': ['#ffffff', '#000000']}
599+
600+
def test_partial_update_omitted_list_field(self):
601+
"""
602+
When a ListField is omitted in a partial update (and there are no
603+
indexed keys for it), the field should be skipped and not included in
604+
the validated data.
605+
"""
606+
class TestSerializer(serializers.Serializer):
607+
colors = serializers.ListField(
608+
child=serializers.CharField(max_length=7),
609+
required=False
610+
)
611+
name = serializers.CharField(max_length=100)
612+
613+
# colors is omitted, only name is provided
614+
serializer = TestSerializer(
615+
data=QueryDict('name=Test'),
616+
partial=True
617+
)
618+
assert serializer.is_valid()
619+
assert serializer.validated_data == {'name': 'Test'}
620+
assert 'colors' not in serializer.validated_data
621+
622+
def test_partial_update_indexed_keys_ordering(self):
623+
"""
624+
Indexed keys should preserve the correct order even when
625+
they appear out of order in the QueryDict.
626+
"""
627+
class TestSerializer(serializers.Serializer):
628+
items = serializers.ListField(
629+
child=serializers.IntegerField(),
630+
required=False
631+
)
632+
633+
serializer = TestSerializer(
634+
data=QueryDict('items[2]=3&items[0]=1&items[1]=2'),
635+
partial=True
636+
)
637+
assert serializer.is_valid()
638+
assert serializer.validated_data == {'items': [1, 2, 3]}
639+
579640

580641
class TestCreateOnlyDefault:
581642
def setup_method(self):

tests/test_serializer_lists.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,51 @@ def test_validate_html_input(self):
286286
assert serializer.validated_data == expected_output
287287

288288

289+
class TestListFieldHTMLInput:
290+
"""
291+
Tests for ListField with HTML form input, including indexed keys.
292+
"""
293+
294+
def test_listfield_with_indexed_keys(self):
295+
"""
296+
Test that indexed keys (e.g., field[0], field[1]) work correctly
297+
in HTML form submissions.
298+
"""
299+
class CommunitySerializer(serializers.Serializer):
300+
colors = serializers.ListField(
301+
allow_null=True,
302+
child=serializers.CharField(label='Colors', max_length=7),
303+
required=False
304+
)
305+
# Simulate form data with indexed keys
306+
data = MultiValueDict({
307+
'colors[0]': ['#ffffff'],
308+
'colors[1]': ['#000000']
309+
})
310+
serializer = CommunitySerializer(data=data)
311+
assert serializer.is_valid()
312+
assert 'colors' in serializer.validated_data
313+
assert serializer.validated_data['colors'] == ['#ffffff', '#000000']
314+
315+
def test_listfield_standard_form_submission(self):
316+
"""
317+
Test standard HTML form list submission (e.g., multi-select).
318+
Ensures backward compatibility with existing behavior.
319+
"""
320+
class CommunitySerializer(serializers.Serializer):
321+
colors = serializers.ListField(
322+
child=serializers.CharField(label='Colors', max_length=7),
323+
required=True
324+
)
325+
# Standard multi-select form submission
326+
data = MultiValueDict({
327+
'colors': ['#ffffff', '#000000', '#ff0000']
328+
})
329+
serializer = CommunitySerializer(data=data)
330+
assert serializer.is_valid()
331+
assert serializer.validated_data['colors'] == ['#ffffff', '#000000', '#ff0000']
332+
333+
289334
class TestNestedListSerializerAllowEmpty:
290335
"""Tests the behavior of allow_empty=False when a ListSerializer is used as a field."""
291336

@@ -426,6 +471,69 @@ class MultipleChoiceSerializer(serializers.Serializer):
426471
assert serializer.validated_data == {}
427472
assert serializer.errors == {}
428473

474+
def test_partial_listfield_with_non_indexed_list(self):
475+
"""
476+
Test that ListField still works with non-indexed list submission
477+
in partial updates (backward compatibility check).
478+
"""
479+
class CommunitySerializer(serializers.Serializer):
480+
colors = serializers.ListField(
481+
allow_null=True,
482+
child=serializers.CharField(label='Colors', max_length=7),
483+
required=False
484+
)
485+
# Simulate standard HTML form list (e.g., multiple select)
486+
data = MultiValueDict({
487+
'colors': ['#ffffff', '#000000']
488+
})
489+
serializer = CommunitySerializer(data=data, partial=True)
490+
assert serializer.is_valid()
491+
assert 'colors' in serializer.validated_data
492+
assert serializer.validated_data['colors'] == ['#ffffff', '#000000']
493+
494+
def test_listfield_mixed_plain_and_indexed_keys(self):
495+
"""
496+
Test that when both plain field and indexed keys are present,
497+
the plain field takes precedence (standard HTML form behavior).
498+
"""
499+
class CommunitySerializer(serializers.Serializer):
500+
colors = serializers.ListField(
501+
allow_null=True,
502+
child=serializers.CharField(label='Colors', max_length=7),
503+
required=False
504+
)
505+
# When both present, getlist should win (standard HTML form behavior)
506+
data = MultiValueDict({
507+
'colors': ['#aaaaaa', '#bbbbbb'], # This should be used
508+
'colors[0]': ['#ffffff'], # These should be ignored
509+
'colors[1]': ['#000000']
510+
})
511+
serializer = CommunitySerializer(data=data, partial=True)
512+
assert serializer.is_valid()
513+
assert 'colors' in serializer.validated_data
514+
# Plain field values should take precedence
515+
assert serializer.validated_data['colors'] == ['#aaaaaa', '#bbbbbb']
516+
517+
def test_partial_listfield_no_data_returns_empty(self):
518+
"""
519+
Test that when a ListField is omitted in partial updates,
520+
it does not appear in validated_data (not even as an empty list).
521+
"""
522+
class CommunitySerializer(serializers.Serializer):
523+
name = serializers.CharField(max_length=100)
524+
colors = serializers.ListField(
525+
allow_null=True,
526+
child=serializers.CharField(label='Colors', max_length=7),
527+
required=False
528+
)
529+
data = MultiValueDict({
530+
'name': ['Community Name']
531+
})
532+
serializer = CommunitySerializer(data=data, partial=True)
533+
assert serializer.is_valid()
534+
assert 'name' in serializer.validated_data
535+
assert 'colors' not in serializer.validated_data # Should be skipped
536+
429537
def test_allow_empty_true(self):
430538
class ListSerializer(serializers.Serializer):
431539
update_field = serializers.IntegerField()

0 commit comments

Comments
 (0)