When creating a new Django Admin page a common conversation between the developer and the support personal might sound like this:
Developer: Hey, I'm adding a new admin page for transactions. Can you tell me how you want to search for transactions?
Support: Sure, I usually just search by the username.
Developer: Cool.
search_fields = (
user__username,
)
Anything else?
Support: I sometimes also want to search by the user email address.
Developer: OK.
search_fields = (
user__username,
user__email,
)
Support: And the first and last name of course.
Developer: Yeah, OK.
search_fields = (
user__username,
user__email,
user__first_name,
user__last_name,
)
Developer: Is that it?
Support: Well, sometimes I need to search by the payment voucher number.
Developer: OK.
search_fields = (
user__username,
user__email,
user__first_name,
user__last_name,
payment__voucher_number,
)
Developer: Anything else?
Support: Some customers send their invoices and ask questions so I search by the invoice number as well.
Developer: FINE!
search_fields = (
user__username,
user__email,
user__first_name,
user__last_name,
payment__voucher_number,
invoice__invoice_number,
)
Developer: OK, are you sure this is it?
Support: Well, developers sometimes forward tickets to us and they use these long random strings. I'm never really sure what they are so I just search and hope for the best.
Developer: These are called UUID's.
search_fields = (
user__username,
user__email,
user__first_name,
user__last_name,
payment__voucher_number,
invoice__invoice_number,
uid,
user__uid,
payment__uid,
invoice__uid,
)
Developer: So is that it?
Support: Yes, for now…
The Problem With Search Fields
Django Admin search fields are great, throw a bunch of fields in search_fields
and Django will handle the rest.
The problem with search field begins when there are too many of them.
When the admin user want to search by UID or email, Django has no idea this is what the user intended so it has to search by all the fields listed in search_fields
. These "match any" queries have huge WHERE clauses and lots of joins and can quickly become very slow.
Using a regular ListFilter is not an option -ListFilter
will render a list of choices from the distinct values of the field. Some fields we listed above are unique and the others have many distinct values - Showing choices is not an option.
Bridging the gap between Django and the user
We started thinking of ways we can create multiple search fields - one for each field or group of fields. We thought that if the user want to search by email or UID there is no reason to search by any other field.
After some thought we came up with a solution - a custom SimpleListFilter:
- ListFilter allows for custom filtering logic.
- ListFilter can have a custom template.
- Django already has support for multiple ListFilters.
We wanted it to look like this:
Implementing InputFilter
What we want to do is have a ListFilter with a text input instead of choices.
Before we dive into the implementation, let's start from the end. This is how we want to use our InputFilter
in a ModelAdmin
:
class UIDFilter(InputFilter):
parameter_name = 'uid'
title = _('UID')
def queryset(self, request, queryset):
if self.value() is not None:
uid = self.value()
return queryset.filter(
Q(uid=uid) |
Q(payment__uid=uid) |
Q(user__uid=uid)
)
And use it like any other list filter in a ModelAdmin
:
class TransactionAdmin(admin.ModelAdmin):
# ...
list_filter = (
UUIDFilter,
)
# ...
- We create a custom filter for the uuid field -
UIDFilter
. - We set the
parameter_name
in the URL to beuid
. A URL filtered by uid will look like this/admin/app/transaction?uid=<uid>
- If the user entered a uid we search by transaction uid, payment uid or user uid.
So far this is just like a regular custom ListFilter.
Now that we have a better idea of what we want let's implement our InputFilter
:
class InputFilter(admin.SimpleListFilter):
template = 'admin/input_filter.html'
def lookups(self, request, model_admin):
# Dummy, required to show the filter.
return ((),)
We inherit from SimpleListFilter
and override the template. We don't have any lookups and we want the template to render a text input instead of choices:
<!-- templates/admin/input_filter.html -->
{% load i18n %}
<h3>{% blocktrans with filter_title=title %} By {{ filter_title }} {% endblocktrans %}</h3>
<ul>
<li>
<form method="GET" action="">
<input
type="text"
value="{{ spec.value|default_if_none:'' }}"
name="{{ spec.parameter_name }}"/>
</form>
</li>
</ul>
We use similar markup to Django's existing list filter to make it native. The template renders a simple form with a GET action and a text field for the parameter. When this form is submitted the URL will be updated with the parameter name and the submitted value.
Play Nice With Other Filters
So far our filter works but only if there are no other filters. If we want to play nice with other filters we need to consider them in our form. To do that, we need to get their values.
The list filter has another function called "choices". The function accepts a changelist
object that contains all the information about the current view and return a list of choices.
We don't have any choices, so we are going to use this function to extract all the filters that were applied to the queryset and expose them to the template:
class InputFilter(admin.SimpleListFilter):
template = 'admin/input_filter.html'
def lookups(self, request, model_admin):
# Dummy, required to show the filter.
return ((),)
def choices(self, changelist):
# Grab only the "all" option.
all_choice = next(super().choices(changelist))
all_choice['query_parts'] = (
(k, v)
for k, v in changelist.get_filters_params().items()
if k != self.parameter_name
)
yield all_choice
To include the filters we add a hidden input field for each parameter:
<!-- templates/admin/input_filter.html -->
{% load i18n %}
<h3>{% blocktrans with filter_title=title %} By {{ filter_title }} {% endblocktrans %}</h3>
<ul>
<li>
{% with choices.0 as all_choice %}
<form method="GET" action="">
{% for k, v in all_choice.query_parts %}
<input type="hidden" name="{{ k }}" value="{{ v }}" />
{% endfor %}
<input
type="text"
value="{{ spec.value|default_if_none:'' }}"
name="{{ spec.parameter_name }}"/>
</form>
{% endwith %}
</li>
</ul>
Now we have a filter with a text input that plays nice with other filters. The only thing left to do it to add a "clear" option.
To clear the filter we need a URL that include all filters except ours:
<!-- templates/admin/input_filter.html -->
...
<input
type="text"
value="{{ spec.value|default_if_none:'' }}"
name="{{ spec.parameter_name }}"/>
{% if not all_choice.selected %}
<strong><a href="{{ all_choice.query_string }}">⨉ {% trans 'Remove' %}</a></strong>
{% endif %}
...
Voilà!
This is what we get:
The complete code of admin.py can be found in this gist and the complete code of the tempalte can be found in this gist.
Bonus
Search Multiple Words Similar to Django Search
You might have noticed that when searching multiple words Django find results that include at least one of the words and not all.
For example, if you search for a user "John Duo" Django will find both "John Foo" and "Bar Due". This is very convenient when searching for things like full name, product names and so on.
We can implement a similar condition using our InputFilter
:
from django.db.models import Q
class UserFilter(InputFilter):
parameter_name = 'user'
title = _('User')
def queryset(self, request, queryset):
term = self.value()
if term is None:
return
any_name = Q()
for bit in term.split():
any_name &= (
Q(user__first_name__icontains=bit) |
Q(user__last_name__icontains=bit)
)
return queryset.filter(any_name)
This is it!