Как заменить поиск Django текстовыми фильтрами для определенных полей

Для удобства чтения ознакомьтесь с этой статьей на моем веб-сайте.

При создании новой страницы администратора Django общий разговор между разработчиком и персоналом службы поддержки может звучать так:

Разработчик: я добавляю новую страницу администратора для транзакций. Подскажите, как вы хотите искать транзакции?

Поддержка. Конечно, я обычно ищу просто по имени пользователя.

Разработчик: Круто.

search_fields = (
    user__username,
)

Что-нибудь еще?

Поддержка: иногда мне также нужно выполнить поиск по адресу электронной почты пользователя.

Разработчик: ОК.

search_fields = (
   user__username,
   user__email,
)

Поддержка: И, конечно же, имя и фамилия.

Разработчик: Да ладно.

search_fields = (
    user__username,
    user__email,
    user__first_name,
    user__last_name,
)

Это оно?

Поддержка: иногда мне нужно выполнить поиск по номеру платежного ваучера.

Разработчик: ОК.

search_fields = (
    user__username,
    user__email,
    user__first_name,
    user__last_name,
    payment__voucher_number,
)

Что-нибудь еще?

Поддержка: некоторые клиенты отправляют свои счета и задают вопросы, поэтому я также ищу по номеру счета.

Разработчик: ОТЛИЧНО!

search_fields = (
    user__username,
    user__email,
    user__first_name,
    user__last_name,
    payment__voucher_number,
    invoice__invoice_number,
)

Хорошо, ты уверен, что это он?

Поддержка. Иногда разработчики пересылают нам заявки и используют эти длинные случайные строки. Я никогда не понимаю, что это такое, поэтому просто ищу и надеюсь на лучшее.

Разработчик: Они называются UUID.

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,
)

Так что это?

Поддержка: да, пока…

Проблема с полями поиска

Поля поиска в Django Admin великолепны - добавьте кучу полей в search_fields, и Django сделает все остальное.

Проблема с полем поиска начинается, когда их слишком много.

Когда пользователь-администратор хочет выполнить поиск по UID или электронной почте, Django не знает, что это именно то, что задумал пользователь, поэтому ему приходится искать по всем полям, перечисленным в search_fields. Эти запросы «сопоставить любому» содержат огромные предложения WHERE и множество объединений и могут быстро стать очень медленными.

Использование обычного ListFilter не является вариантом ListFilter будет отображать список вариантов выбора из различных значений поля. Некоторые поля, перечисленные выше, уникальны, а другие имеют множество различных значений - Отображение вариантов невозможно.

Преодоление разрыва между Django и пользователем

Мы начали думать о том, как создать несколько полей поиска - по одному для каждого поля или группы полей. Мы подумали, что если пользователь хочет искать по электронной почте или UID, нет причин искать по любому другому полю.

Поразмыслив, мы пришли к решению - собственный SimpleListFilter:

  • ListFilter позволяет настраивать логику фильтрации.
  • ListFilter может иметь настраиваемый шаблон.
  • Django уже поддерживает несколько ListFilters.

Мы хотели, чтобы это выглядело так:

Реализация InputFilter

Мы хотим создать ListFilter с вводом текста вместо вариантов выбора.

Прежде чем мы погрузимся в реализацию, давайте начнем с конца. Вот как мы хотим использовать наш InputFilter в 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)
            )

И используйте его, как любой другой фильтр списка в ModelAdmin:

class TransactionAdmin(admin.ModelAdmin):
    ...
    list_filter = (
        UUIDFilter,
    )
    ...
  • Мы создаем собственный фильтр для поля uuid — UIDFilter.
  • Мы устанавливаем parameter_name в URL как uid. URL-адрес, отфильтрованный по uid, будет выглядеть так /admin/app/transaction?uid=<uid>
  • Если пользователь ввел uid, мы ищем по uid транзакции, uid платежа или uid пользователя.

Пока что это похоже на обычный настраиваемый ListFilter.

Теперь, когда у нас есть лучшее представление о том, чего мы хотим, давайте реализуем наш InputFilter:

class InputFilter(admin.SimpleListFilter):
    template = 'admin/input_filter.html'
    def lookups(self, request, model_admin):
        # Dummy, required to show the filter.
        return ((),)

Мы наследуем от SimpleListFilter и переопределяем шаблон. У нас нет поисковых запросов, и мы хотим, чтобы шаблон отображал ввод текста вместо вариантов выбора:

// 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>

Мы используем разметку, аналогичную существующему фильтру списка Django, чтобы сделать его нативным. Шаблон отображает простую форму с действием GET и текстовым полем для параметра. Когда эта форма будет отправлена, URL-адрес будет обновлен с указанием имени параметра и отправленного значения.

Поиграйте с другими фильтрами

Пока наш фильтр работает, но только если других фильтров нет. Если мы хотим поиграть с другими фильтрами, нам нужно учитывать их в нашей форме. Для этого нам нужно получить их значения.

Фильтр списка имеет еще одну функцию, называемую «выбор». Функция принимает объект changelist, содержащий всю информацию о текущем представлении, и возвращает список вариантов.

У нас нет никаких вариантов, поэтому мы собираемся использовать эту функцию, чтобы извлечь все фильтры, которые были применены к набору запросов, и предоставить их шаблону:

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

Чтобы включить фильтры, мы добавляем скрытое поле ввода для каждого параметра:

// 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>

Теперь у нас есть фильтр с вводом текста, который отлично сочетается с другими фильтрами. Осталось только добавить опцию «очистить».

Чтобы очистить фильтр, нам нужен URL-адрес, включающий все фильтры, кроме нашего:

// 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 %}
...

Вуаля!

Вот что мы получаем:

Полный код:

Бонус

Искать несколько слов аналогично поиску Django

Вы могли заметить, что при поиске нескольких слов Django находит результаты, которые включают хотя бы одно из слов, а не все.

Например, если вы ищете пользователя «John Duo», Django найдет и «John Foo», и «Bar Due». Это очень удобно при поиске таких вещей, как полное имя, названия продуктов и так далее.

Мы можем реализовать подобное условие, используя наш 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)

Вот оно!

Посмотрите другие мои сообщения на Django Admin: