From 96984b9b0f1d88f096985a908ee67dc6f2b9a682 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Fri, 20 Feb 2026 09:15:37 -0500 Subject: [PATCH 1/3] Refs #36934, #35972 -- Forwardported release note for tolerating sequences in BuiltinLookup.as_sql(). Instead of cherry-picking a larger changeset (787cc96ef6197d73c7d4ad96f25500910c399603) and removing changes unsuitable for a backport, a partial backport was applied directly to stable/6.0.x to resolve #36934, so the release note needs to be forwardported. Forwardport of f9b820f8ac50aad025949087e660a551691832e4 from stable/6.0.x. --- docs/releases/6.0.3.txt | 4 ++++ docs/releases/6.0.txt | 2 ++ 2 files changed, 6 insertions(+) diff --git a/docs/releases/6.0.3.txt b/docs/releases/6.0.3.txt index ddd853cd49a0..1dff197d066b 100644 --- a/docs/releases/6.0.3.txt +++ b/docs/releases/6.0.3.txt @@ -11,3 +11,7 @@ Bugfixes * Fixed :exc:`NameError` when inspecting functions making use of deferred annotations in Python 3.14 (:ticket:`36903`). + +* Fixed :exc:`AttributeError` when subclassing builtin lookups and neglecting + to :ref:`override` ``as_sql()`` to accept any sequence + (:ticket:`36934`). diff --git a/docs/releases/6.0.txt b/docs/releases/6.0.txt index bfad64e48580..1697a095c9bf 100644 --- a/docs/releases/6.0.txt +++ b/docs/releases/6.0.txt @@ -454,6 +454,8 @@ If you haven't dealt with this warning by now, add settings, or ``default_auto_field = 'django.db.models.AutoField'`` to an app's ``AppConfig``, as needed. +.. _tuple-for-params: + Custom ORM expressions should return params as a tuple ------------------------------------------------------ From 283ea9e9e014adf0013c18700c36b98efa2f0aac Mon Sep 17 00:00:00 2001 From: SiHyunLee Date: Fri, 20 Feb 2026 23:43:41 +0900 Subject: [PATCH 2/3] Fixed #36127 -- Applied default empty display value to links otherwise containing only whitespace in admin. --- django/contrib/admin/options.py | 24 ++- django/contrib/admin/sites.py | 3 +- .../admin/auth/user/change_password.html | 4 +- .../admin/templates/admin/change_form.html | 4 +- .../templates/admin/delete_confirmation.html | 10 +- .../contrib/admin/templates/admin/index.html | 6 +- .../admin/templates/admin/object_history.html | 4 +- .../admin/templatetags/admin_filters.py | 12 ++ .../contrib/admin/templatetags/admin_list.py | 2 - django/contrib/admin/utils.py | 13 +- tests/admin_changelist/tests.py | 11 -- tests/admin_utils/tests.py | 13 ++ tests/admin_views/admin.py | 2 + tests/admin_views/tests.py | 184 +++++++++++++++++- 14 files changed, 253 insertions(+), 39 deletions(-) create mode 100644 django/contrib/admin/templatetags/admin_filters.py diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index b67b023bd313..c59cb2ab4cf6 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -22,6 +22,7 @@ from django.contrib.admin.utils import ( NestedObjects, construct_change_message, + display_for_value, flatten_fieldsets, get_deleted_objects, lookup_spawns_duplicates, @@ -74,6 +75,7 @@ SOURCE_MODEL_VAR = "_source_model" TO_FIELD_VAR = "_to_field" IS_FACETS_VAR = "_facets" +EMPTY_VALUE_STRING = "-" class ShowFacets(enum.Enum): @@ -1394,10 +1396,13 @@ def response_add(self, request, obj, post_url_continue=None): current_app=self.admin_site.name, ) # Add a link to the object's change form if the user can edit the obj. + obj_display = display_for_value(str(obj), EMPTY_VALUE_STRING) if self.has_change_permission(request, obj): - obj_repr = format_html('{}', urlquote(obj_url), obj) + obj_repr = format_html( + '{}', urlquote(obj_url), obj_display + ) else: - obj_repr = str(obj) + obj_repr = obj_display msg_dict = { "name": opts.verbose_name, "obj": obj_repr, @@ -1547,9 +1552,12 @@ def response_change(self, request, obj): preserved_filters = self.get_preserved_filters(request) preserved_qsl = self._get_preserved_qsl(request, preserved_filters) + obj_display = display_for_value(str(obj), EMPTY_VALUE_STRING) msg_dict = { "name": opts.verbose_name, - "obj": format_html('{}', urlquote(request.path), obj), + "obj": format_html( + '{}', urlquote(request.path), obj_display + ), } if "_continue" in request.POST: msg = format_html( @@ -1728,7 +1736,7 @@ def response_delete(self, request, obj_display, obj_id): _("The %(name)s “%(obj)s” was deleted successfully.") % { "name": self.opts.verbose_name, - "obj": obj_display, + "obj": display_for_value(str(obj_display), EMPTY_VALUE_STRING), }, messages.SUCCESS, ) @@ -1951,7 +1959,9 @@ def _changeform_view(self, request, object_id, form_url, extra_context): context = { **self.admin_site.each_context(request), "title": title % self.opts.verbose_name, - "subtitle": str(obj) if obj else None, + "subtitle": ( + display_for_value(str(obj), EMPTY_VALUE_STRING) if obj else None + ), "adminform": admin_form, "object_id": object_id, "original": obj, @@ -2252,6 +2262,7 @@ def _delete_view(self, request, object_id, extra_context): "subtitle": None, "object_name": object_name, "object": obj, + "escaped_object": display_for_value(str(obj), EMPTY_VALUE_STRING), "deleted_objects": deleted_objects, "model_count": dict(model_count).items(), "perms_lacking": perms_needed, @@ -2300,7 +2311,8 @@ def history_view(self, request, object_id, extra_context=None): context = { **self.admin_site.each_context(request), - "title": _("Change history: %s") % obj, + "title": _("Change history: %s") + % display_for_value(str(obj), EMPTY_VALUE_STRING), "subtitle": None, "action_list": page_obj, "page_range": page_range, diff --git a/django/contrib/admin/sites.py b/django/contrib/admin/sites.py index 410bf20da0ff..d160ec06d51d 100644 --- a/django/contrib/admin/sites.py +++ b/django/contrib/admin/sites.py @@ -5,6 +5,7 @@ from django.conf import settings from django.contrib.admin import ModelAdmin, actions from django.contrib.admin.exceptions import AlreadyRegistered, NotRegistered +from django.contrib.admin.options import EMPTY_VALUE_STRING from django.contrib.admin.views.autocomplete import AutocompleteJsonView from django.contrib.auth import REDIRECT_FIELD_NAME from django.contrib.auth.decorators import login_not_required @@ -50,7 +51,7 @@ class AdminSite: enable_nav_sidebar = True - empty_value_display = "-" + empty_value_display = EMPTY_VALUE_STRING login_form = None index_template = None diff --git a/django/contrib/admin/templates/admin/auth/user/change_password.html b/django/contrib/admin/templates/admin/auth/user/change_password.html index d3e546d28d89..e8d3da82bda2 100644 --- a/django/contrib/admin/templates/admin/auth/user/change_password.html +++ b/django/contrib/admin/templates/admin/auth/user/change_password.html @@ -1,6 +1,6 @@ {% extends "admin/base_site.html" %} {% load i18n static %} -{% load admin_urls %} +{% load admin_urls admin_filters %} {% block title %}{% if form.errors %}{% translate "Error:" %} {% endif %}{{ block.super }}{% endblock %} {% block extrastyle %} @@ -15,7 +15,7 @@
  • {% translate 'Home' %}
  • {{ opts.app_config.verbose_name }}
  • {{ opts.verbose_name_plural|capfirst }}
  • -
  • {{ original|truncatewords:"18" }}
  • +
  • {{ original|to_object_display_value|truncatewords:"18" }}
  • {% if form.user.has_usable_password %}{% translate 'Change password' %}{% else %}{% translate 'Set password' %}{% endif %}
  • {% endblock %} diff --git a/django/contrib/admin/templates/admin/change_form.html b/django/contrib/admin/templates/admin/change_form.html index 7116f1b8b8e4..2e06fab63f7e 100644 --- a/django/contrib/admin/templates/admin/change_form.html +++ b/django/contrib/admin/templates/admin/change_form.html @@ -1,5 +1,5 @@ {% extends "admin/base_site.html" %} -{% load i18n admin_urls static admin_modify %} +{% load i18n admin_urls static admin_modify admin_filters %} {% block title %}{% if errors %}{% translate "Error:" %} {% endif %}{{ block.super }}{% endblock %} {% block extrahead %}{{ block.super }} @@ -19,7 +19,7 @@
  • {% translate 'Home' %}
  • {{ opts.app_config.verbose_name }}
  • {% if has_view_permission %}{{ opts.verbose_name_plural|capfirst }}{% else %}{{ opts.verbose_name_plural|capfirst }}{% endif %}
  • -
  • {% if add %}{% blocktranslate with name=opts.verbose_name %}Add {{ name }}{% endblocktranslate %}{% else %}{{ original|truncatewords:"18" }}{% endif %}
  • +
  • {% if add %}{% blocktranslate with name=opts.verbose_name %}Add {{ name }}{% endblocktranslate %}{% else %}{{ original|to_object_display_value|truncatewords:"18" }}{% endif %}
  • {% endblock %} {% endif %} diff --git a/django/contrib/admin/templates/admin/delete_confirmation.html b/django/contrib/admin/templates/admin/delete_confirmation.html index 1d04008cc081..07823db373f0 100644 --- a/django/contrib/admin/templates/admin/delete_confirmation.html +++ b/django/contrib/admin/templates/admin/delete_confirmation.html @@ -1,5 +1,5 @@ {% extends "admin/base_site.html" %} -{% load i18n admin_urls static %} +{% load i18n admin_urls static admin_filters %} {% block extrahead %} {{ block.super }} @@ -14,7 +14,7 @@
  • {% translate 'Home' %}
  • {{ opts.app_config.verbose_name }}
  • {{ opts.verbose_name_plural|capfirst }}
  • -
  • {{ object|truncatewords:"18" }}
  • +
  • {{ object|to_object_display_value|truncatewords:"18" }}
  • {% translate 'Delete' %}
  • {% endblock %} @@ -22,17 +22,17 @@ {% block content %} {% if perms_lacking %} {% block delete_forbidden %} -

    {% blocktranslate with escaped_object=object %}Deleting the {{ object_name }} “{{ escaped_object }}” would result in deleting related objects, but your account doesn't have permission to delete the following types of objects:{% endblocktranslate %}

    +

    {% blocktranslate %}Deleting the {{ object_name }} “{{ escaped_object }}” would result in deleting related objects, but your account doesn't have permission to delete the following types of objects:{% endblocktranslate %}

      {{ perms_lacking|unordered_list }}
    {% endblock %} {% elif protected %} {% block delete_protected %} -

    {% blocktranslate with escaped_object=object %}Deleting the {{ object_name }} “{{ escaped_object }}” would require deleting the following protected related objects:{% endblocktranslate %}

    +

    {% blocktranslate %}Deleting the {{ object_name }} “{{ escaped_object }}” would require deleting the following protected related objects:{% endblocktranslate %}

      {{ protected|unordered_list }}
    {% endblock %} {% else %} {% block delete_confirm %} -

    {% blocktranslate with escaped_object=object %}Are you sure you want to delete the {{ object_name }} “{{ escaped_object }}”? All of the following related items will be deleted:{% endblocktranslate %}

    +

    {% blocktranslate %}Are you sure you want to delete the {{ object_name }} “{{ escaped_object }}”? All of the following related items will be deleted:{% endblocktranslate %}

    {% include "admin/includes/object_delete_summary.html" %}

    {% translate "Objects" %}

      {{ deleted_objects|unordered_list }}
    diff --git a/django/contrib/admin/templates/admin/index.html b/django/contrib/admin/templates/admin/index.html index 502515a8f532..6f39d375ebab 100644 --- a/django/contrib/admin/templates/admin/index.html +++ b/django/contrib/admin/templates/admin/index.html @@ -1,5 +1,5 @@ {% extends "admin/base_site.html" %} -{% load i18n static %} +{% load i18n static admin_filters %} {% block extrastyle %}{{ block.super }}{% endblock %} @@ -32,9 +32,9 @@

    {% translate 'My actions' %}

  • {% if entry.is_addition %}{% translate 'Added:' %}{% elif entry.is_change %}{% translate 'Changed:' %}{% elif entry.is_deletion %}{% translate 'Deleted:' %}{% endif %} {% if entry.is_deletion or not entry.get_admin_url %} - {{ entry.object_repr }} + {{ entry.object_repr|to_object_display_value }} {% else %} - {{ entry.object_repr }} + {{ entry.object_repr|to_object_display_value }} {% endif %}
    {% if entry.content_type %} diff --git a/django/contrib/admin/templates/admin/object_history.html b/django/contrib/admin/templates/admin/object_history.html index 130232666f23..108483a53d08 100644 --- a/django/contrib/admin/templates/admin/object_history.html +++ b/django/contrib/admin/templates/admin/object_history.html @@ -1,12 +1,12 @@ {% extends "admin/base_site.html" %} -{% load i18n admin_urls %} +{% load i18n admin_urls admin_filters %} {% block breadcrumbs %} {% endblock %} diff --git a/django/contrib/admin/templatetags/admin_filters.py b/django/contrib/admin/templatetags/admin_filters.py new file mode 100644 index 000000000000..d0b17970a696 --- /dev/null +++ b/django/contrib/admin/templatetags/admin_filters.py @@ -0,0 +1,12 @@ +from django import template +from django.contrib.admin.options import EMPTY_VALUE_STRING +from django.contrib.admin.utils import display_for_value +from django.template.defaultfilters import stringfilter + +register = template.Library() + + +@register.filter +@stringfilter +def to_object_display_value(value): + return display_for_value(str(value), EMPTY_VALUE_STRING) diff --git a/django/contrib/admin/templatetags/admin_list.py b/django/contrib/admin/templatetags/admin_list.py index 3aa937c787a6..a6adafabbefb 100644 --- a/django/contrib/admin/templatetags/admin_list.py +++ b/django/contrib/admin/templatetags/admin_list.py @@ -226,8 +226,6 @@ def link_in_col(is_first, field_name, cl): empty_value_display = getattr( attr, "empty_value_display", empty_value_display ) - if isinstance(value, str) and value.strip() == "": - value = "" if f is None or f.auto_created: if field_name == "action_checkbox": row_classes = ["action-checkbox"] diff --git a/django/contrib/admin/utils.py b/django/contrib/admin/utils.py index e21a6102b590..e604a2775030 100644 --- a/django/contrib/admin/utils.py +++ b/django/contrib/admin/utils.py @@ -18,6 +18,7 @@ from django.utils.hashable import make_hashable from django.utils.html import format_html from django.utils.regex_helper import _lazy_re_compile +from django.utils.safestring import SafeString from django.utils.text import capfirst from django.utils.translation import ngettext from django.utils.translation import override as translation_override @@ -131,6 +132,8 @@ def get_deleted_objects(objs, request, admin_site): Return a nested list of strings suitable for display in the template with the ``unordered_list`` filter. """ + from django.contrib.admin.options import EMPTY_VALUE_STRING + try: obj = objs[0] except IndexError: @@ -164,8 +167,12 @@ def format_callback(obj): return no_edit_link # Display a link to the admin page. + obj_display = display_for_value(str(obj), EMPTY_VALUE_STRING) return format_html( - '{}: {}', capfirst(opts.verbose_name), admin_url, obj + '{}: {}', + capfirst(opts.verbose_name), + admin_url, + obj_display, ) else: # Don't display link to edit, because it either has no @@ -468,7 +475,9 @@ def display_for_value(value, empty_value_display, boolean=False): if boolean: return _boolean_icon(value) - elif value in EMPTY_VALUES: + if isinstance(value, str) and not isinstance(value, SafeString): + value = value.strip() + if value in EMPTY_VALUES: return empty_value_display elif isinstance(value, bool): return str(value) diff --git a/tests/admin_changelist/tests.py b/tests/admin_changelist/tests.py index b067bc96609f..f0518494490d 100644 --- a/tests/admin_changelist/tests.py +++ b/tests/admin_changelist/tests.py @@ -1193,17 +1193,6 @@ def test_link_field_display_links(self): "http://blues_history.com" % g.pk, ) - def test_blank_str_display_links(self): - self.client.force_login(self.superuser) - gc = GrandChild.objects.create(name=" ") - response = self.client.get( - reverse("admin:admin_changelist_grandchild_changelist") - ) - self.assertContains( - response, - '-' % gc.pk, - ) - def test_clear_all_filters_link(self): self.client.force_login(self.superuser) url = reverse("admin:auth_user_changelist") diff --git a/tests/admin_utils/tests.py b/tests/admin_utils/tests.py index 81c6f495f8ef..eced9d206e99 100644 --- a/tests/admin_utils/tests.py +++ b/tests/admin_utils/tests.py @@ -313,6 +313,19 @@ def test_list_display_for_value_empty(self): display_value = display_for_value(value, self.empty_value) self.assertEqual(display_value, self.empty_value) + def test_list_display_for_value_consecutive_whitespace(self): + cases = [ + (" ", "-empty-"), + (" cheeze", "cheeze"), + ("pizza ", "pizza"), + (" chicken ", "chicken"), + (mark_safe(" soy chicken "), " soy chicken "), + ] + for value, expect_display_value in cases: + with self.subTest(value=value): + display_value = display_for_value(value, self.empty_value) + self.assertEqual(display_value, expect_display_value) + def test_label_for_field(self): """ Tests for label_for_field diff --git a/tests/admin_views/admin.py b/tests/admin_views/admin.py index 8e8f7a32cc00..6f7cd79e5048 100644 --- a/tests/admin_views/admin.py +++ b/tests/admin_views/admin.py @@ -709,6 +709,8 @@ class CoverLetterAdmin(admin.ModelAdmin): For testing fix for ticket #14529. """ + formfield_overrides = {models.CharField: {"strip": False}} + def get_queryset(self, request): return super().get_queryset(request).defer("date_written") diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py index fa9d9a2dc6f5..ea657fbf9fff 100644 --- a/tests/admin_views/tests.py +++ b/tests/admin_views/tests.py @@ -2862,7 +2862,9 @@ def test_change_view(self): self.assertContains(response, "

    Select article to view

    ") self.assertEqual(response.context["title"], "Select article to view") response = self.client.get(article_change_url) - self.assertContains(response, "View article | Django site admin") + self.assertContains( + response, "- | View article | Django site admin" + ) self.assertContains(response, "

    View article

    ") self.assertContains(response, "") self.assertContains( @@ -2891,7 +2893,7 @@ def test_change_view(self): self.assertEqual(response.context["title"], "Change article") self.assertContains( response, - "Change article | Django site admin", + "- | Change article | Django site admin", ) self.assertContains(response, "

    Change article

    ") post = self.client.post(article_change_url, change_dict) @@ -3016,7 +3018,9 @@ def test_change_view_without_object_change_permission(self): self.client.force_login(self.viewuser) response = self.client.get(change_url) self.assertEqual(response.context["title"], "View article") - self.assertContains(response, "View article | Django site admin") + self.assertContains( + response, "- | View article | Django site admin" + ) self.assertContains(response, "

    View article

    ") self.assertContains( response, @@ -3608,6 +3612,180 @@ def test_post_save_message_no_forbidden_links_visible(self): ) +@override_settings(ROOT_URLCONF="admin_views.urls") +class AdminConsecutiveWhiteSpaceObjectDisplayTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_superuser( + username=" ", password="secret", email="super@example.com" + ) + cls.obj = CoverLetter.objects.create(author=" ") + cls.change_link = reverse( + "admin:admin_views_coverletter_change", args=(cls.obj.pk,) + ) + + def setUp(self): + self.client.force_login(self.user) + + def test_display_consecutive_whitespace_object_in_breadcrumbs(self): + user_change_link = reverse("admin:auth_user_change", args=(self.user.pk,)) + cases = [ + ( + self.change_link, + '
  • ' + 'Cover letters
  • -
  • ', + ), + ( + reverse("admin:admin_views_coverletter_delete", args=(self.obj.pk,)), + f'
  • -
  • ' + "Delete
  • ", + ), + ( + reverse("admin:admin_views_coverletter_history", args=(self.obj.pk,)), + f'
  • -
  • ' + "History
  • ", + ), + ( + reverse("admin:auth_user_password_change", args=(self.user.pk,)), + f'
  • -
  • ' + "Change password
  • ", + ), + ] + for url, expected_breadcrumbs in cases: + with self.subTest(url=url, expected_breadcrumbs=expected_breadcrumbs): + response = self.client.get(url) + self.assertContains(response, expected_breadcrumbs, html=True) + + def test_display_consecutive_whitespace_object_in_delete_confirmation_page(self): + response = self.client.get( + reverse("admin:admin_views_coverletter_delete", args=(self.obj.pk,)) + ) + self.assertContains( + response, + "Are you sure you want to delete the cover letter “-”?", + ) + + # delete protected case + q = Question.objects.create(question=" ") + Answer.objects.create(question=q, answer="Because.") + response = self.client.get( + reverse("admin:admin_views_question_delete", args=(q.pk,)) + ) + self.assertContains( + response, + "Deleting the question “-” would require deleting the following protected " + "related objects", + ) + + # delete forbidden case + no_perms_user = User.objects.create_user( + username="no-perm", password="secret", is_staff=True + ) + no_perms_user.user_permissions.add( + get_perm(Question, get_permission_codename("view", Question._meta)) + ) + no_perms_user.user_permissions.add( + get_perm(Question, get_permission_codename("delete", Question._meta)) + ) + self.client.force_login(no_perms_user) + response = self.client.get( + reverse("admin:admin_views_question_delete", args=(q.pk,)) + ) + self.assertContains( + response, + "Deleting the question “-” would result in deleting related objects, " + "but your account doesn't have permission to delete " + "the following types of objects", + ) + + def test_display_consecutive_whitespace_object_in_changelist(self): + response = self.client.get(reverse("admin:admin_views_coverletter_changelist")) + self.assertContains(response, f'-') + + def test_display_consecutive_whitespace_object_in_deleted_object(self): + response = self.client.get( + reverse("admin:admin_views_coverletter_delete", args=(self.obj.pk,)) + ) + self.assertContains( + response, + '
      ' + f'
    • Cover letter: -
    ', + html=True, + ) + + def test_display_consecutive_whitespace_object_in_recent_action(self): + for action in [ADDITION, DELETION]: + LogEntry.objects.log_actions( + user_id=self.user.pk, + queryset=[self.obj], + action_flag=action, + change_message=[], + single_object=True, + ) + + response = self.client.get(reverse("admin:index")) + self.assertContains( + response, + '", + html=True, + ) + self.assertContains( + response, + '', + html=True, + ) + + def test_display_consecutive_whitespace_object_in_messages(self): + buttons = ["_save", "_continue", "_addanother"] + for button in buttons: + body = {"author": self.obj.author, button: "1"} + with self.subTest(obj=self.obj, button=button): + response = self.client.post( + reverse("admin:admin_views_coverletter_add"), body, follow=True + ) + latest_cl = CoverLetter.objects.latest("id") + change_link = reverse( + "admin:admin_views_coverletter_change", args=(latest_cl.pk,) + ) + self.assertContains( + response, + f'The cover letter “-” ' + "was added successfully.", + ) + response = self.client.post( + reverse( + "admin:admin_views_coverletter_change", args=(latest_cl.pk,) + ), + {**body, "author": " "}, + follow=True, + ) + self.assertContains( + response, + f'The cover letter “-” ' + "was changed successfully.", + ) + + new_obj = CoverLetter.objects.create(author=self.obj.author) + response = self.client.post( + reverse("admin:admin_views_coverletter_delete", args=(new_obj.pk,)), + {"post": "yes"}, + follow=True, + ) + self.assertContains(response, "The cover letter “-” was deleted successfully.") + + def test_display_consecutive_whitespace_object_in_sub_title(self): + response = self.client.get(self.change_link) + self.assertContains(response, "

    -

    ") + response = self.client.get( + reverse("admin:admin_views_coverletter_history", args=(self.obj.pk,)) + ) + self.assertContains(response, "

    Change history: -

    ") + + @override_settings( ROOT_URLCONF="admin_views.urls", TEMPLATES=[ From cb24bebfab08f55b05599ea1bdcdc159f071225c Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Fri, 20 Feb 2026 11:37:58 -0500 Subject: [PATCH 3/3] Refs #36938 -- Marked a test for union of ordered querysets as an expected failure on Oracle. Oracle's SQL parser does not allow ORDER BY in components of a union in some cases, so xfail this test until an exception can be raised. --- django/db/backends/oracle/features.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/django/db/backends/oracle/features.py b/django/db/backends/oracle/features.py index ea484e336e6a..39d857be5934 100644 --- a/django/db/backends/oracle/features.py +++ b/django/db/backends/oracle/features.py @@ -85,6 +85,9 @@ class DatabaseFeatures(BaseDatabaseFeatures): "annotations.tests.NonAggregateAnnotationTestCase.test_custom_functions", "annotations.tests.NonAggregateAnnotationTestCase." "test_custom_functions_can_ref_other_functions", + # A bug in Django with respect to unioning ordered querysets (#36938). + "queries.test_qs_combinators.QuerySetSetOperationTests." + "test_count_union_with_select_related_in_values", } insert_test_table_with_defaults = ( "INSERT INTO {} VALUES (DEFAULT, DEFAULT, DEFAULT)"