1. 程式人生 > >How to add a text filter to Django Admin

How to add a text filter to Django Admin

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:

A text list filter

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_filters = (
UUIDFilter,
)
...
  • We create a custom filter for the uuid field — UIDFilter.
  • We set the parameter_name in the URL to be uid. 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:

InputFilter with other filters and a remove button

The complete code: