From 5fa4ccab7e42e86fa4a0681d21bd1326c9c5eac3 Mon Sep 17 00:00:00 2001 From: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> Date: Mon, 4 Nov 2024 16:09:55 +0100 Subject: [PATCH] Refs #26001 -- Handled relationship exact lookups in ModelAdmin.search_fields. --- django/contrib/admin/options.py | 53 +++++++++++++++------------------ tests/admin_changelist/admin.py | 1 + tests/admin_changelist/tests.py | 22 ++++++++++++++ 3 files changed, 47 insertions(+), 29 deletions(-) diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index 78063a134d2a..69b0cc037325 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -1178,17 +1178,17 @@ def get_search_results(self, request, queryset, search_term): # Apply keyword searches. def construct_search(field_name): if field_name.startswith("^"): - return "%s__istartswith" % field_name.removeprefix("^") + return "%s__istartswith" % field_name.removeprefix("^"), None elif field_name.startswith("="): - return "%s__iexact" % field_name.removeprefix("=") + return "%s__iexact" % field_name.removeprefix("="), None elif field_name.startswith("@"): - return "%s__search" % field_name.removeprefix("@") + return "%s__search" % field_name.removeprefix("@"), None # Use field_name if it includes a lookup. opts = queryset.model._meta lookup_fields = field_name.split(LOOKUP_SEP) # Go through the fields, following all relations. prev_field = None - for path_part in lookup_fields: + for i, path_part in enumerate(lookup_fields): if path_part == "pk": path_part = opts.pk.name try: @@ -1196,44 +1196,39 @@ def construct_search(field_name): except FieldDoesNotExist: # Use valid query lookups. if prev_field and prev_field.get_lookup(path_part): - return field_name + if path_part == "exact" and not isinstance( + prev_field, (models.CharField, models.TextField) + ): + field_name_without_exact = "__".join(lookup_fields[:i]) + alias = Cast( + field_name_without_exact, + output_field=models.CharField(), + ) + alias_name = "_".join(lookup_fields[:i]) + return f"{alias_name}_str", alias + else: + return field_name, None else: prev_field = field if hasattr(field, "path_infos"): # Update opts to follow the relation. opts = field.path_infos[-1].to_opts # Otherwise, use the field with icontains. - return "%s__icontains" % field_name + return "%s__icontains" % field_name, None may_have_duplicates = False search_fields = self.get_search_fields(request) if search_fields and search_term: - str_annotations = {} + str_aliases = {} orm_lookups = [] for field in search_fields: - if field.endswith("__exact"): - field_name = field.rsplit("__exact", 1)[0] - try: - field_obj = queryset.model._meta.get_field(field_name) - except FieldDoesNotExist: - lookup = construct_search(field) - orm_lookups.append(lookup) - continue - # Add string cast annotations for non-string exact lookups. - if not isinstance(field_obj, (models.CharField, models.TextField)): - str_annotations[f"{field_name}_str"] = Cast( - field_name, output_field=models.CharField() - ) - orm_lookups.append(f"{field_name}_str__exact") - else: - lookup = construct_search(field) - orm_lookups.append(lookup) - else: - lookup = construct_search(str(field)) - orm_lookups.append(lookup) + lookup, str_alias = construct_search(str(field)) + orm_lookups.append(lookup) + if str_alias: + str_aliases[lookup] = str_alias - if str_annotations: - queryset = queryset.annotate(**str_annotations) + if str_aliases: + queryset = queryset.alias(**str_aliases) term_queries = [] for bit in smart_split(search_term): diff --git a/tests/admin_changelist/admin.py b/tests/admin_changelist/admin.py index d9dc498e8427..937beea48f34 100644 --- a/tests/admin_changelist/admin.py +++ b/tests/admin_changelist/admin.py @@ -56,6 +56,7 @@ def get_queryset(self, request): class GrandChildAdmin(admin.ModelAdmin): list_display = ["name", "parent__name", "parent__parent__name"] + search_fields = ["parent__name__exact", "parent__age__exact"] site.register(GrandChild, GrandChildAdmin) diff --git a/tests/admin_changelist/tests.py b/tests/admin_changelist/tests.py index a823a72f7d7f..0be6a54ed496 100644 --- a/tests/admin_changelist/tests.py +++ b/tests/admin_changelist/tests.py @@ -879,6 +879,28 @@ def test_search_with_exact_lookup_for_non_string_field(self): cl = model_admin.get_changelist_instance(request) self.assertCountEqual(cl.queryset, expected_result) + def test_search_with_exact_lookup_relationship_field(self): + child = Child.objects.create(name="I am a child", age=11) + grandchild = GrandChild.objects.create(name="I am a grandchild", parent=child) + model_admin = GrandChildAdmin(GrandChild, custom_site) + + request = self.factory.get("/", data={SEARCH_VAR: "'I am a child'"}) + request.user = self.superuser + cl = model_admin.get_changelist_instance(request) + self.assertCountEqual(cl.queryset, [grandchild]) + for search_term, expected_result in [ + ("11", [grandchild]), + ("'I am a child'", [grandchild]), + ("1", []), + ("A", []), + ("random", []), + ]: + request = self.factory.get("/", data={SEARCH_VAR: search_term}) + request.user = self.superuser + with self.subTest(search_term=search_term): + cl = model_admin.get_changelist_instance(request) + self.assertCountEqual(cl.queryset, expected_result) + def test_no_distinct_for_m2m_in_list_filter_without_params(self): """ If a ManyToManyField is in list_filter but isn't in any lookup params,