Django Admin:两个 ListFilter Spanning 多值关系
Posted
技术标签:
【中文标题】Django Admin:两个 ListFilter Spanning 多值关系【英文标题】:Django Admin: two ListFilter Spanning multi-valued relationships 【发布时间】:2021-11-17 13:55:06 【问题描述】:按照django's documentation 中的示例,我有一个博客模型和一个条目模型。
Entry 有一个到 Blog 的 ForeignKey:一个 Blog 有几个 Entries。
我有两个用于博客的 FieldListFilter:一个用于“条目标题”,一个用于“条目发布年份”。
如果在博客列表管理页面中我过滤了 entry__title='Lennon'
和 entry__published_year=2008
,那么我会看到所有博客至少有一个标题为“列侬”的条目和至少一个 2008 年的条目。他们不必是同一个条目。
但是,这不是我想要的。我想要的是过滤具有标题“列侬”和都来自 2008 年的条目的博客。
例如说我有这些数据:
Blog | Entry Title | Entry year |
---|---|---|
A | McCartney | 2008 |
A | Lennon | 2009 |
B | Lennon | 2008 |
Blog 的管理列表页面目前在 Blog A 中过滤,因为它有一个 2008 年的条目和一个“Lennon”条目,以及博客 B。我只想看到博客 B。
这是因为 django 在构建查询集时会这样做:
qs = qs.filter(title_filter)
qs = qs.filter(published_filter)
根据the docs,要获得所需的结果,只需调用一次过滤器:
qs = qs.filter(title_filter & published_filter)
如何通过管理中的过滤来实现此行为?
背景:
两个过滤器在多对多关系过滤方面有所不同。请参阅上面的文档链接。
MyModel.filter(a=b).filter(c=d)
MyModel.filter(a=b, c=d)
【问题讨论】:
你能展示你现有的管理模型吗? 【参考方案1】:因此,您指出的根本问题是 django 通过执行一系列过滤器来构建查询集,一旦过滤器“进入”查询集,就不容易更改它,因为每个过滤器都会构建查询集的 @ 987654321@对象。
但是,这并非不可能。这个解决方案是通用的,不需要了解您正在处理的模型/字段,但可能只适用于 SQL 后端,使用非公共 API(尽管根据我的经验,django 中的这些内部 API 非常稳定),并且它可以如果您使用其他自定义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),
)
【讨论】:
@dapthdazz 哇,这是一个很棒的解决方案。感谢您更新答案并添加示例数据表。【参考方案2】:解决方案
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
之外的所有内置列表过滤器都从FieldListFilter
继承其queryset
方法。这个方法只做queryset.filter(**self.used_parameters)
。
因此,通过覆盖expected_parameters
方法,我们可以控制应用每个过滤器时发生的情况。在这种情况下,我们在 entry-published 过滤器中进行所有实际过滤。
【讨论】:
【参考方案3】:Django 在列表中按顺序应用list_filter,并且每次检查列表过滤器类的queryset() 方法。如果返回 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
【讨论】:
【参考方案4】:覆盖查询集方法时如何:
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 )
您可以像这样使用任何运算符,例如 &(bitwise AND)、|(bitwise OR)。
【讨论】:
是的,这行得通。我没有创建两个 ListFilter,而是创建了一个 ListFilter,并且可以在一个filter()
调用中执行这两个过滤器。【参考方案5】:
通过在expected_parameters
中返回一个空列表[]
,让跨越多值关系的过滤器在ChangeList.get_queryset
的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),
)
ChangeList.get_queryset
的 qs.filter(**remaining_lookup_params)
的代码参考:
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)
【讨论】:
【参考方案6】:我想这就是你想要的。
entries = Entry.objects.filter(title='Lennon', published_year=2008)
blogs = Blog.objects.filter(entry__in=entries)
【讨论】:
是的,如果你可以使用 ORM,这项工作就可以了。我正在使用 django-admin 和两个 ListFilter。 ListFilter 只能实现queryset()
方法。对不起,你的回答很好,但不适合这个问题。以上是关于Django Admin:两个 ListFilter Spanning 多值关系的主要内容,如果未能解决你的问题,请参考以下文章
Django Admin:两个 ListFilter Spanning 多值关系
如何在 Django Admin 中访问 ManyToManyField 的两个方向?