Django管理页面:两个ListFilter跨越多值关系

4
我有一个Blog模型和一个Entry模型,按照django文档中的示例进行操作。
Entry有一个对Blog的ForeignKey:一个Blog有多个Entries。
我有两个FieldListFilters用于Blog:一个是“Entry标题”,一个是“Entry发布年份”。
如果在Blog列表管理页面中同时过滤entry__title='Lennon'entry__published_year=2008,那么我会看到所有至少有一个标题为“Lennon”的条目和至少一篇来自2008年的条目的博客。 它们不必是同一篇文章。 然而,这不是我想要的。我想要的是筛选出既有标题为“Lennon”来自2008年的条目的博客。
例如,假设我有以下数据:
博客 文章标题 发布年份
A 麦卡特尼 2008
A 列侬 2009
B 列侬 2008

博客的管理列表页面目前过滤了博客A,因为它有一个来自2008年和一个“Lennon”的条目,以及博客B。我只想看到博客B。

这是因为Django在构建查询集时会这样做:

qs = qs.filter(title_filter)
qs = qs.filter(published_filter)

根据文档,为了获得所需的结果,只需要进行一次过滤调用:
qs = qs.filter(title_filter & published_filter)

我该如何在管理界面使用筛选器实现此行为?
背景:
两个筛选器在涉及多对多关系的筛选方面是不同的。请参见上面的文档链接。
MyModel.filter(a=b).filter(c=d)

MyModel.filter(a=b, c=d)

你能展示一下你现有的管理模型吗? - aaron
6个回答

3

因此,正如您指出的那样,根本问题在于Django通过一系列过滤器构建查询集,在"过滤器"进入查询集之后,难以轻松修改,因为每个过滤器都会构建查询集的Query对象。

然而,这并非不可能。这个解决方案是通用的,不需要了解你正在操作的模型/字段的信息,但可能只适用于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),
    )

1
哇,这是一个很棒的解决方案。感谢您更新答案并添加示例数据表。 - guettli

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

工作原理

  1. 在幕后,当启动一个筛选器时,Django 会遍历查询参数(来自请求),如果它们在该筛选器的“期望参数”中,则将它们存储在一个名为self.used_parameters的字典中。
  2. 除了 EmptyFieldListFilter 以外的所有内置列表过滤器都继承了它们的 queryset 方法来自 FieldListFilter。这个方法只是简单地执行 queryset.filter(**self.used_parameters)
  3. 因此,通过覆盖 expected_parameters 方法,我们可以控制每个筛选器应用时发生的事情。在这种情况下,我们在 entry-published 筛选器中执行所有实际的筛选操作。

1
Django在列表中逐个应用“list_filter”,并每次检查列表过滤器类的“queryset()”方法。如果返回的“queryset不为None”,则django将“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

1
当覆盖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 )

您可以使用任何运算符,如&(按位与)、|(按位或)等。


是的,这样可以。我不需要创建两个ListFilter,只需创建一个ListFilter,然后在一个filter()调用中执行两个过滤器。 - guettli

1
让跨多值关系的过滤器在ChangeList.get_querysetqs.filter(**remaining_lookup_params)中通过在expected_parameters中返回空列表[]来处理。与其他答案不同,这避免了过滤器之间的依赖关系。过滤器实现和用法:
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_querysetqs.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)

0

我认为这就是你想要的。

entries = Entry.objects.filter(title='Lennon', published_year=2008)
blogs = Blog.objects.filter(entry__in=entries)

是的,如果您可以使用ORM,这将起作用。我正在使用django-admin和两个ListFilter。ListFilter只能实现一个queryset()方法。很抱歉,您的答案很好,但不适合这个问题。 - guettli

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接