Skip to content

Commit

Permalink
Refs #26001 -- Handled relationship exact lookups in ModelAdmin.searc…
Browse files Browse the repository at this point in the history
…h_fields.
  • Loading branch information
sarahboyce committed Nov 5, 2024
1 parent 5bd5805 commit 5fa4cca
Show file tree
Hide file tree
Showing 3 changed files with 47 additions and 29 deletions.
53 changes: 24 additions & 29 deletions django/contrib/admin/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -1178,62 +1178,57 @@ 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:
field = opts.get_field(path_part)
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):
Expand Down
1 change: 1 addition & 0 deletions tests/admin_changelist/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions tests/admin_changelist/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit 5fa4cca

Please sign in to comment.