Django Admin: два ListFilter Spanning многозначных отношений
У меня есть модель Blog и модель Entry.
Запись имеет иностранный ключ к блогу: Один блог имеет несколько записей.
У меня есть два FieldListFilter для Blog: Один для "Заголовок записи", другой для "Год публикации записи".
Если фильтровать по entry_title=Lennon и entry_published_year=2008, то я увижу все блоги, в которых есть запись с названием "Lennon" и (возможно, другая) запись с pub-year=2008.
Это не то, чего я хочу.
Related docs: https://docs.djangoproject.com/en/3.2/topics/db/queries/#spanning-multi-valued-relationships
Это потому, что django делает следующее:
qs = qs.filter(title_filter)
qs = qs.filter(published_filter)
Для получения желаемого результата необходимо объединить оба вызова filter()
в один.
Как получить оба FieldListFilter в одном вызове filter()
?
Думаю, это то, что вам нужно.
entries = Entry.objects.filter(title='Lennon', published_year=2008)
blogs = Blog.objects.filter(entry__in=entries)
Django применяет list_filter последовательно в списке и проверяет каждый раз queryset() метод класса list filter. Если return queryset не None, то django assign queryset = modified_queryset_by_the_filter.
мы можем использовать эти очки.
мы можем сделать два пользовательских фильтра классов для этого,
первый EntryTitleFilter класс, метод queryset() которого возвращает None.
второй MyDateTimeFilter класс, в котором мы обращаемся к параметрам запроса обоих классов фильтров и применяем их в соответствии с нашими требованиями.
from django.contrib.admin.filters import DateFieldListFilter
class EntryTitleFilter(admin.SimpleListFilter):
title = 'Entry Title'
parameter_name = 'title'
def lookups(self, request, model_admin):
return [(item.title, item.title) for item in Entry.objects.all()]
def queryset(self, request, queryset):
# it returns None so queryset is not modified at this time.
return None
class MyDateFilter(DateFieldListFilter):
def __init__(self, *args, **kwargs):
super(MyDateFilter, self).__init__(*args, **kwargs)
def queryset(self, request, queryset):
# access title query params
title = request.GET.get('title')
if len(self.used_parameters) and title:
# if we have query params for both filter then
start_date = self.used_parameters.get('entry__pub_date__gte')
end_end = self.used_parameters.get('entry__pub_date__lt')
blog_ids = Entry.objects.filter(
pub_date__gte=start_date,
pub_date__lt=end_end,
title__icontains=title
).values('blog')
queryset = queryset.filter(id__in=blog_ids)
elif len(self.used_parameters):
# if only apply date filter
queryset = queryset.filter(**self.used_parameters)
elif title:
# if only apply title filter
blog_ids = Entry.objects.filter(title__icontains=title).values('blog')
queryset = queryset.filter(id__in=blog_ids)
else:
# otherwise
pass
return queryset
@admin.register(Blog)
class BlogAdmin(admin.ModelAdmin):
list_filter = [EntryTitleFilter, ('entry__pub_date', MyDateFilter),]
pass
Решение
from django.contrib import admin
from django.contrib.admin.filters import AllValuesFieldListFilter, DateFieldListFilter
from .models import Blog, Entry
class EntryTitleFilter(AllValuesFieldListFilter):
def expected_parameters(self):
return []
class EntryPublishedFilter(DateFieldListFilter):
def expected_parameters(self):
# Combine all of the actual queries into a single 'filter' call
return [
"entry__pub_date__gte",
"entry__pub_date__lt",
"entry__pub_date__isnull",
"entry__title",
"entry__title__isnull",
]
@admin.register(Blog)
class BlogAdmin(admin.ModelAdmin):
list_filter = (
("entry__pub_date", EntryPublishedFilter),
("entry__title", EntryTitleFilter),
)
Как это работает
- Под капотом, когда инициируется фильтр, django перебирает параметры запроса (из запроса), и если они есть в "ожидаемых параметрах" этого фильтра, он сохраняет их в дикте под названием
self.used_parameters
. - Все встроенные списковые фильтры, кроме
EmptyFieldListFilter
, наследуют свой методqueryset
отFieldListFilter
. Этот метод просто выполняетqueryset.filter(**self.used_parameters)
. - Таким образом, переопределяя метод
expected_parameters
, мы можем управлять тем, что происходит при применении каждого фильтра. В данном случае мы выполняем всю фактическую фильтрацию в фильтре entry-published. .
Позвольте фильтрам, охватывающим многозначные отношения, обрабатываться в ChangeList.get_queryset
'qs.filter(**remaining_lookup_params)
, возвращая пустой список []
в expected_parameters
.
В отличие от нескольких других ответов, это позволяет избежать зависимости между фильтрами.
def get_queryset(self, request): # First, we collect all the declared list filters. ( self.filter_specs, self.has_filters, remaining_lookup_params, filters_use_distinct, self.has_active_filters, ) = self.get_filters(request) # Then, we let every list filter modify the queryset to its liking. qs = self.root_queryset for filter_spec in self.filter_specs: new_qs = filter_spec.queryset(request, qs) if new_qs is not None: qs = new_qs try: # Finally, we apply the remaining lookup parameters from the query # string (i.e. those that haven't already been processed by the # filters). qs = qs.filter(**remaining_lookup_params)
Реализации и использование фильтров:
from django.contrib import admin
from .models import Blog, Entry
class EntryTitleFieldListFilter(admin.AllValuesFieldListFilter):
def expected_parameters(self):
return [] # Let filters spanning multi-valued relationships be handled in ChangeList.get_queryset: qs.filter(**remaining_lookup_params)
class EntryPublishedFieldListFilter(admin.AllValuesFieldListFilter):
def __init__(self, field, request, params, model, model_admin, field_path):
super().__init__(field, request, params, model, model_admin, field_path)
field_path = 'entry__pub_date__year'
self.lookup_kwarg = field_path
self.lookup_kwarg_isnull = '%s__isnull' % field_path
self.lookup_val = params.get(self.lookup_kwarg)
self.lookup_val_isnull = params.get(self.lookup_kwarg_isnull)
queryset = model_admin.get_queryset(request)
self.lookup_choices = queryset.distinct().order_by(self.lookup_kwarg).values_list(self.lookup_kwarg, flat=True)
def expected_parameters(self):
return [] # Let filters spanning multi-valued relationships be handled in ChangeList.get_queryset: qs.filter(**remaining_lookup_params)
@admin.register(Blog)
class BlogAdmin(admin.ModelAdmin):
list_filter = (
('entry__title', EntryTitleFieldListFilter),
('entry__pub_date', EntryPublishedFieldListFilter),
)
как насчет этого при переопределении метода queryset:
from django.db.models import Q
def queryset(self, request, queryset):
title_filter = Q(entry_title='Lennon')
published_filter = Q(entry_published_year=2008)
return queryset.filter(title_filter | published_filter )
Вы можете использовать любые операторы типа &(побитовое AND), |(побитовое OR) следующим образом.
Итак, фундаментальная проблема, как вы указываете, заключается в том, что django строит кверисет, выполняя последовательность фильтров, и как только фильтр оказывается "в" кверисете, его нелегко изменить, поскольку каждый фильтр строит объект Query
кверисета
Однако, это не невозможно. Это решение является общим и не требует знания моделей / полей, с которыми вы работаете, но, вероятно, работает только для SQL бэкендов, использует непубличные API (хотя, по моему опыту, эти внутренние API в django довольно стабильны), и это может привести к ошибкам, если вы используете другие пользовательские FieldListFilter
. Это название - лучшее, что я смог придумать:
from django.contrib.admin import (
FieldListFilter,
AllValuesFieldListFilter,
DateFieldListFilter,
)
def first(iter_):
for item in iter_:
return item
return None
class RelatedANDFieldListFilter(FieldListFilter):
def queryset(self, request, queryset):
# clone queryset to avoid mutating the one passed in
queryset = queryset.all()
qs = super().queryset(request, queryset)
if len(qs.query.where.children) == 0:
# no filters on this queryset yet, so just do the normal thing
return qs
new_lookup = qs.query.where.children[-1]
new_lookup_table = first(
table_name
for table_name, aliases in queryset.query.table_map.items()
if new_lookup.lhs.alias in aliases
)
if new_lookup_table is None:
# this is the first filter on this table, so nothing to do.
return qs
# find the table being joined to for this filter
main_table_lookup = first(
lookup
for lookup in queryset.query.where.children
if lookup.lhs.alias == new_lookup_table
)
assert main_table_lookup is not None
# Rebuild the lookup using the first joined table, instead of the new join to the same
# table but with a different alias in the query.
#
# This results in queries like:
#
# select * from table
# inner join other_table on (
# other_table.field1 == 'a' AND other_table.field2 == 'b'
# )
#
# instead of queries like:
#
# select * from table
# inner join other_table other_table on other_table.field1 == 'a'
# inner join other_table T1 on T1.field2 == 'b'
#
# which is why this works.
new_lookup_on_main_table_lhs = new_lookup.lhs.relabeled_clone(
{new_lookup.lhs.alias: new_lookup_table}
)
new_lookup_on_main_table = type(new_lookup)(new_lookup_on_main_table_lhs, new_lookup.rhs)
queryset.query.where.add(new_lookup_on_main_table, 'AND')
return queryset
Теперь вы можете просто сделать FieldListFilter
подклассы и смешать их, я просто сделал те, которые вы хотели из примера:
class RelatedANDAllValuesFieldListFilter(RelatedANDFieldListFilter, AllValuesFieldListFilter):
pass
class RelatedANDDateFieldListFilter(RelatedANDFieldListFilter, DateFieldListFilter):
pass
@admin.register(Blog)
class BlogAdmin(admin.ModelAdmin):
list_filter = (
("entry__pub_date", RelatedANDDateFieldListFilter),
("entry__title", RelatedANDAllValuesFieldListFilter),
)