Как заменить поиск 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: