Skip to content

Commit 6fe0e96

Browse files
committed
Merge remote-tracking branch 'upstream/main'
2 parents d1533ed + c075508 commit 6fe0e96

File tree

12 files changed

+461
-38
lines changed

12 files changed

+461
-38
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ answer newbie questions, and generally made Django that much better:
164164
Bhuvnesh Sharma <[email protected]>
165165
Bill Fenner <[email protected]>
166166
Bjørn Stabell <[email protected]>
167+
Blayze Wilhelm <https://github.com/blayzen-w>
167168
Bo Marchman <[email protected]>
168169
Bogdan Mateescu
169170
Bojan Mihelac <[email protected]>

django/contrib/admin/static/admin/js/SelectFilter2.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ Requires core.js and SelectBox.js.
7272
selector_available,
7373
interpolate(gettext('Choose all %s'), [field_name]),
7474
'id', field_id + '_add_all',
75-
'class', 'selector-chooseall'
75+
'class', 'selector-chooseall',
76+
'type', 'button'
7677
);
7778

7879
// <ul class="selector-chooser">
@@ -83,14 +84,16 @@ Requires core.js and SelectBox.js.
8384
quickElement('li', selector_chooser),
8485
interpolate(gettext('Choose selected %s'), [field_name]),
8586
'id', field_id + '_add',
86-
'class', 'selector-add'
87+
'class', 'selector-add',
88+
'type', 'button'
8789
);
8890
const remove_button = quickElement(
8991
'button',
9092
quickElement('li', selector_chooser),
9193
interpolate(gettext('Remove selected %s'), [field_name]),
9294
'id', field_id + '_remove',
93-
'class', 'selector-remove'
95+
'class', 'selector-remove',
96+
'type', 'button'
9497
);
9598

9699
// <div class="selector-chosen">
@@ -142,7 +145,8 @@ Requires core.js and SelectBox.js.
142145
selector_chosen,
143146
interpolate(gettext('Remove all %s'), [field_name]),
144147
'id', field_id + '_remove_all',
145-
'class', 'selector-clearall'
148+
'class', 'selector-clearall',
149+
'type', 'button'
146150
);
147151

148152
from_box.name = from_box.name + '_old';

django/contrib/contenttypes/fields.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,15 @@ def get_attname_column(self):
5555
attname, column = super().get_attname_column()
5656
return attname, None
5757

58+
@cached_property
59+
def ct_field_attname(self):
60+
return self.model._meta.get_field(self.ct_field).attname
61+
5862
def get_filter_kwargs_for_object(self, obj):
5963
"""See corresponding method on Field"""
6064
return {
6165
self.fk_field: getattr(obj, self.fk_field),
62-
self.ct_field: getattr(obj, self.ct_field),
66+
self.ct_field_attname: getattr(obj, self.ct_field_attname),
6367
}
6468

6569
def get_forward_related_filter(self, obj):

django/db/models/query.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import copy
66
import operator
77
import warnings
8+
from functools import reduce
89
from itertools import chain, islice
910

1011
from asgiref.sync import sync_to_async
@@ -20,7 +21,7 @@
2021
router,
2122
transaction,
2223
)
23-
from django.db.models import AutoField, DateField, DateTimeField, Field, sql
24+
from django.db.models import AutoField, DateField, DateTimeField, Field, Max, sql
2425
from django.db.models.constants import LOOKUP_SEP, OnConflict
2526
from django.db.models.deletion import Collector
2627
from django.db.models.expressions import Case, DatabaseDefault, F, Value, When
@@ -800,6 +801,7 @@ def bulk_create(
800801
objs = list(objs)
801802
objs_with_pk, objs_without_pk = self._prepare_for_bulk_create(objs)
802803
with transaction.atomic(using=self.db, savepoint=False):
804+
self._handle_order_with_respect_to(objs)
803805
if objs_with_pk:
804806
returned_columns = self._batched_insert(
805807
objs_with_pk,
@@ -840,6 +842,37 @@ def bulk_create(
840842

841843
return objs
842844

845+
def _handle_order_with_respect_to(self, objs):
846+
if objs and (order_wrt := self.model._meta.order_with_respect_to):
847+
get_filter_kwargs_for_object = order_wrt.get_filter_kwargs_for_object
848+
attnames = list(get_filter_kwargs_for_object(objs[0]))
849+
group_keys = set()
850+
obj_groups = []
851+
for obj in objs:
852+
group_key = tuple(get_filter_kwargs_for_object(obj).values())
853+
group_keys.add(group_key)
854+
obj_groups.append((obj, group_key))
855+
filters = [
856+
Q.create(list(zip(attnames, group_key))) for group_key in group_keys
857+
]
858+
next_orders = (
859+
self.model._base_manager.using(self.db)
860+
.filter(reduce(operator.or_, filters))
861+
.values_list(*attnames)
862+
.annotate(_order__max=Max("_order") + 1)
863+
)
864+
# Create mapping of group values to max order.
865+
group_next_orders = dict.fromkeys(group_keys, 0)
866+
group_next_orders.update(
867+
(tuple(group_key), next_order) for *group_key, next_order in next_orders
868+
)
869+
# Assign _order values to new objects.
870+
for obj, group_key in obj_groups:
871+
if getattr(obj, "_order", None) is None:
872+
group_next_order = group_next_orders[group_key]
873+
obj._order = group_next_order
874+
group_next_orders[group_key] += 1
875+
843876
bulk_create.alters_data = True
844877

845878
async def abulk_create(
@@ -1147,7 +1180,9 @@ def in_bulk(self, id_list=None, *, field_name="pk"):
11471180
if not id_list:
11481181
return {}
11491182
filter_key = "{}__in".format(field_name)
1150-
batch_size = connections[self.db].features.max_query_params
1183+
max_params = connections[self.db].features.max_query_params or 0
1184+
num_fields = len(opts.pk_fields) if field_name == "pk" else 1
1185+
batch_size = max_params // num_fields
11511186
id_list = tuple(id_list)
11521187
# If the database has a limit on the number of query parameters
11531188
# (e.g. SQLite), retrieve objects in batches if necessary.

django/http/request.py

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -93,20 +93,25 @@ def accepted_types(self):
9393
"""Return a list of MediaType instances, in order of preference."""
9494
header_value = self.headers.get("Accept", "*/*")
9595
return sorted(
96-
(MediaType(token) for token in header_value.split(",") if token.strip()),
97-
key=operator.attrgetter("quality", "specificity"),
96+
(
97+
media_type
98+
for token in header_value.split(",")
99+
if token.strip() and (media_type := MediaType(token)).quality != 0
100+
),
101+
key=operator.attrgetter("specificity", "quality"),
98102
reverse=True,
99103
)
100104

101105
def accepted_type(self, media_type):
102106
"""
103107
Return the preferred MediaType instance which matches the given media type.
104108
"""
109+
media_type = MediaType(media_type)
105110
return next(
106111
(
107112
accepted_type
108113
for accepted_type in self.accepted_types
109-
if accepted_type.match(media_type)
114+
if media_type.match(accepted_type)
110115
),
111116
None,
112117
)
@@ -689,13 +694,13 @@ def encode(k, v):
689694

690695
class MediaType:
691696
def __init__(self, media_type_raw_line):
692-
full_type, self.params = parse_header_parameters(
697+
full_type, self._params = parse_header_parameters(
693698
media_type_raw_line if media_type_raw_line else ""
694699
)
695700
self.main_type, _, self.sub_type = full_type.partition("/")
696701

697702
def __str__(self):
698-
params_str = "".join("; %s=%s" % (k, v) for k, v in self.params.items())
703+
params_str = "".join("; %s=%s" % (k, v) for k, v in self._params.items())
699704
return "%s%s%s" % (
700705
self.main_type,
701706
("/%s" % self.sub_type) if self.sub_type else "",
@@ -705,23 +710,45 @@ def __str__(self):
705710
def __repr__(self):
706711
return "<%s: %s>" % (self.__class__.__qualname__, self)
707712

708-
@property
709-
def is_all_types(self):
710-
return self.main_type == "*" and self.sub_type == "*"
713+
@cached_property
714+
def params(self):
715+
params = self._params.copy()
716+
params.pop("q", None)
717+
return params
711718

712719
def match(self, other):
713-
if self.is_all_types:
714-
return True
715-
other = MediaType(other)
716-
return self.main_type == other.main_type and self.sub_type in {
717-
"*",
718-
other.sub_type,
719-
}
720+
if not other:
721+
return False
722+
723+
if not isinstance(other, MediaType):
724+
other = MediaType(other)
725+
726+
main_types = [self.main_type, other.main_type]
727+
sub_types = [self.sub_type, other.sub_type]
728+
729+
# Main types and sub types must be defined.
730+
if not all((*main_types, *sub_types)):
731+
return False
732+
733+
# Main types must match or one be "*", same for sub types.
734+
for this_type, other_type in (main_types, sub_types):
735+
if this_type != other_type and this_type != "*" and other_type != "*":
736+
return False
737+
738+
if bool(self.params) == bool(other.params):
739+
# If both have params or neither have params, they must be identical.
740+
result = self.params == other.params
741+
else:
742+
# If self has params and other does not, it's a match.
743+
# If other has params and self does not, don't match.
744+
result = bool(self.params or not other.params)
745+
746+
return result
720747

721748
@cached_property
722749
def quality(self):
723750
try:
724-
quality = float(self.params.get("q", 1))
751+
quality = float(self._params.get("q", 1))
725752
except ValueError:
726753
# Discard invalid values.
727754
return 1
@@ -741,7 +768,7 @@ def specificity(self):
741768
return 0
742769
elif self.sub_type == "*":
743770
return 1
744-
elif self.quality == 1:
771+
elif not self.params:
745772
return 2
746773
return 3
747774

docs/ref/request-response.txt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,41 @@ Methods
445445
>>> request.get_preferred_type(["application/xml", "text/plain"])
446446
None
447447

448+
If the mime type includes parameters, these are also considered when
449+
determining the preferred media type. For example, with an ``Accept``
450+
header of ``text/vcard;version=3.0,text/html;q=0.5``, the return value of
451+
``request.get_preferred_type()`` depends on the available media types:
452+
453+
.. code-block:: pycon
454+
455+
>>> request.get_preferred_type(
456+
... [
457+
... "text/vcard; version=4.0",
458+
... "text/vcard; version=3.0",
459+
... "text/vcard",
460+
... "text/directory",
461+
... ]
462+
... )
463+
"text/vcard; version=3.0"
464+
>>> request.get_preferred_type(
465+
... [
466+
... "text/vcard; version=4.0",
467+
... "text/html",
468+
... ]
469+
... )
470+
"text/html"
471+
>>> request.get_preferred_type(
472+
... [
473+
... "text/vcard; version=4.0",
474+
... "text/vcard",
475+
... "text/directory",
476+
... ]
477+
... )
478+
None
479+
480+
(For further details on how content negotiation is performed, see
481+
:rfc:`7231#section-5.3.2`.)
482+
448483
Most browsers send ``Accept: */*`` by default, meaning they don't have a
449484
preference, in which case the first item in ``media_types`` would be
450485
returned.

docs/releases/5.2.2.txt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,16 @@ Bugfixes
3030
* Fixed a regression in Django 5.2 that caused a crash when using ``OuterRef``
3131
in PostgreSQL aggregate functions ``ArrayAgg``, ``StringAgg``, and
3232
``JSONBAgg`` (:ticket:`36405`).
33+
34+
* Fixed a regression in Django 5.2 where admin's ``filter_horizontal`` buttons
35+
lacked ``type="button"``, causing them to intercept form submission when
36+
pressing the Enter key (:ticket:`36423`).
37+
38+
* Fixed a bug in Django 5.2 where calling ``QuerySet.in_bulk()`` with an
39+
``id_list`` argument on models with a ``CompositePrimaryKey`` failed to
40+
observe database parameter limits (:ticket:`36416`).
41+
42+
* Fixed a bug in Django 5.2 where :meth:`HttpRequest.get_preferred_type()
43+
<django.http.HttpRequest.get_preferred_type>` did not account for media type
44+
parameters in ``Accept`` headers, reducing specificity in content negotiation
45+
(:ticket:`36411`).

js_tests/admin/SelectFilter2.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ QUnit.test('init', function(assert) {
3131
assert.equal($('.selector-chosen .selector-chosen-title .helptext').text(), 'Remove things by selecting them and then select the "Remove" arrow button.');
3232
assert.equal($('.selector-filter label .help-tooltip')[0].getAttribute("aria-label"), "Type into this box to filter down the list of available things.");
3333
assert.equal($('.selector-filter label .help-tooltip')[1].getAttribute("aria-label"), "Type into this box to filter down the list of selected things.");
34+
assert.equal($('#test button:not([type="button"])').length, 0);
3435
});
3536

3637
QUnit.test('filtering available options', function(assert) {

tests/admin_widgets/tests.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1737,6 +1737,48 @@ def test_refresh_page(self):
17371737

17381738
self.assertCountSeleniumElements("#id_students_to > option", 2)
17391739

1740+
def test_form_submission_via_enter_key_with_filter_horizontal(self):
1741+
"""
1742+
The main form can be submitted correctly by pressing the enter key.
1743+
There is no shadowing from other buttons inside the form.
1744+
"""
1745+
from selenium.webdriver.common.by import By
1746+
from selenium.webdriver.common.keys import Keys
1747+
1748+
self.school.students.set([self.peter])
1749+
self.school.alumni.set([self.lisa])
1750+
1751+
self.admin_login(username="super", password="secret", login_url="/")
1752+
self.selenium.get(
1753+
self.live_server_url
1754+
+ reverse("admin:admin_widgets_school_change", args=(self.school.id,))
1755+
)
1756+
1757+
self.wait_page_ready()
1758+
self.select_option("#id_students_from", str(self.lisa.id))
1759+
self.selenium.find_element(By.ID, "id_students_add").click()
1760+
self.select_option("#id_alumni_from", str(self.peter.id))
1761+
self.selenium.find_element(By.ID, "id_alumni_add").click()
1762+
1763+
# Trigger form submission via Enter key on a text input field.
1764+
name_input = self.selenium.find_element(By.ID, "id_name")
1765+
name_input.click()
1766+
name_input.send_keys(Keys.ENTER)
1767+
1768+
# Form was submitted, success message should be shown.
1769+
self.wait_for_text(
1770+
"li.success", "The school “School of Awesome” was changed successfully."
1771+
)
1772+
1773+
# Changes should be stored properly in the database.
1774+
school = School.objects.get(id=self.school.id)
1775+
self.assertSequenceEqual(
1776+
school.students.all().order_by("name"), [self.lisa, self.peter]
1777+
)
1778+
self.assertSequenceEqual(
1779+
school.alumni.all().order_by("name"), [self.lisa, self.peter]
1780+
)
1781+
17401782

17411783
class AdminRawIdWidgetSeleniumTests(AdminWidgetSeleniumTestCase):
17421784
def setUp(self):

tests/composite_pk/tests.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,22 @@ def test_in_bulk(self):
147147
result = Comment.objects.in_bulk([self.comment.pk])
148148
self.assertEqual(result, {self.comment.pk: self.comment})
149149

150+
@unittest.mock.patch.object(
151+
type(connection.features), "max_query_params", new_callable=lambda: 10
152+
)
153+
def test_in_bulk_batching(self, mocked_max_query_params):
154+
Comment.objects.all().delete()
155+
num_requiring_batching = (connection.features.max_query_params // 2) + 1
156+
comments = [
157+
Comment(id=i, tenant=self.tenant, user=self.user)
158+
for i in range(1, num_requiring_batching + 1)
159+
]
160+
Comment.objects.bulk_create(comments)
161+
id_list = list(Comment.objects.values_list("pk", flat=True))
162+
with self.assertNumQueries(2):
163+
comment_dict = Comment.objects.in_bulk(id_list=id_list)
164+
self.assertQuerySetEqual(comment_dict, id_list)
165+
150166
def test_iterator(self):
151167
"""
152168
Test the .iterator() method of composite_pk models.

0 commit comments

Comments
 (0)