django admin list_filter "or" condition

8.4k views Asked by At

sorry if this question has been answered before, but I did a lot of googling without success.

I know how to create custom list_filters in admin views (e.g. subclassing SimpleFilter).

What I really would like, is a way (on the admin list view) to "check" different filters that combines them in a OR formula.

As an example, suppose you have:

# models.py
class Foo(models.Model):
    foobar = ...
    foofie = ...
...

# admin.py
class FooAdmin(admin.ModelAdmin):
    list_filter = ( "foobar", "foofie" )
...

In the admin list view generated by FooAdmin I can choose to filter records either by foobar or by foofie. Is there a way to filter them by the formula: foobar = X OR foofie = Y, where X and Y are two values that foobar and foofie can assume?

Is it even possible?

I know not everything is possible in the django admin views, but it seems a very common request, I wonder if I missed to understand or read something.

Also third party apps allowing it are welcome. Thanks :)

4

There are 4 answers

0
Mithril On BEST ANSWER

I found a third party app just now, it is django-advanced-filters which may fit you requirement .

It has:

The OR field

OR is an additional field that is added to every rule's available fields.

It allows constructing queries with OR statements. You can use it by creating an "empty" rule with this field "between" a set of 1 or more rules.

I have run a test, add a OR field would work. This is the screenshot: enter image description here

0
Dima  Kudosh On

Firstly I try to explain the working of django admin filters. When you want to filter your queryset in admin page django looks for all registered filters. If you set value for filter django filter queryset with this value. If you set more than one filter django filter your queryset twice, this equal to queryset = queryset.filter(param1=1).filter(param2=2) or in SQL: SELECT ... WHERE param1=1 AND param2=2. It because you can't do it with standard django's filters. But you can write your own filter like this:

from django.contrib.admin import SimpleListFilter
from django.db.models import Q
from functools import reduce
import operator
from django.core.exceptions import FieldError


class ORListFilter(SimpleListFilter):
title = ''
parameter_name = ''
search_field = ('',)

def queryset(self, request, queryset):
    filters = request.GET.copy()
    try: #for search
        search_field_value = filters.pop('q')[0]
        query_params = [Q((key, search_field_value)) for key in self.search_field]
        try:
            queryset = queryset.filter(reduce(operator.or_, query_params))
        except FieldError:
            pass
    except KeyError:
        pass
    try:
        query_params = [Q((key, value)) for key, value in filters.dict().items()]
        queryset = queryset.filter(reduce(operator.or_, query_params))
    except TypeError:
        pass
    return queryset

def lookups(self, request, model_admin):
    qs = model_admin.get_queryset(request)
    parameters = qs.all().values(self.parameter_name).distinct()
    for parameter in parameters:
        value = dict(parameter).pop(self.parameter_name, None)
        if value:
            yield (value, value)
        else:
            yield (None, 'NULL')

class Field1Filter(ORListFilter):
    title = 'title'
    parameter_name = 'field1'
    search_field = ('search1', 'search2')


class Field2Filter(ORListFilter):
    title = 'title'
    parameter_name = 'field2'
    search_field = ('search1', 'search2')

And register it in admin:

search_fields = ('search1', 'search2')
list_filter = (Field1Filter, Field2Filter)

It doesn't work with standard django's filters and all values in list_filter must inherited from ORListFilter class. Also it doesn't work with datetime filters, but you can add this ability.

0
JimmyYe On

Figured out a solution:

import operator
from functools import reduce
from django.contrib.admin import ListFilter, FieldListFilter
from django.db.models import Q
from django.contrib.admin.utils import (
    get_fields_from_path, lookup_needs_distinct, prepare_lookup_value,
)
from django.http import QueryDict


class OrListFilter(ListFilter):
    parameter_prefix = None
    fields = None

    def __init__(self, request, params, model, model_admin):
        super(OrListFilter, self).__init__(
            request, params, model, model_admin)
        if self.parameter_prefix is None:
            raise ImproperlyConfigured(
                "The list filter '%s' does not specify "
                "a 'parameter_prefix'." % self.__class__.__name__)

        self.model_admin = model_admin
        self.model = model
        self.request = request
        self.filter_specs = self.get_filters(request, {}, prefix=self.parameter_prefix+'-')

        for p in self.expected_parameters():
            if p in params:
                value = params.pop(p)
                field = p.split('-')[1]
                self.used_parameters[field] = prepare_lookup_value(field, value)

    def has_output(self):
        return True

    # see https://github.com/django/django/blob/1.8.5/django/contrib/admin/views/main.py#L104
    def get_filters(self, request, params, prefix=''):
        filter_specs = []
        for field_path in self.fields:
            field = get_fields_from_path(self.model, field_path)[-1]
            field_list_filter_class = FieldListFilter.create
            spec = field_list_filter_class(field, request, params,
                self.model, self.model_admin, field_path=prefix + field_path)
            # Check if we need to use distinct()
            # use_distinct = (use_distinct or
            #                 lookup_needs_distinct(self.lookup_opts,
            #                                       field_path))
            filter_specs.append(spec)
        return filter_specs

    def expected_parameters(self):
        parameters = []
        for spec in self.filter_specs:
            parameters += spec.expected_parameters()
        return parameters

    def choices(self, cl):
        return []

    def queryset(self, request, queryset):
        origin_GET = request.GET.copy()
        fake_GET = QueryDict(mutable=True)
        fake_GET.update(self.used_parameters)
        request.GET = fake_GET
        all_params = {}
        for spec in self.get_filters(request, self.used_parameters):
            if spec and spec.has_output():
                all_params.update(spec.used_parameters)

        try:
            query_params = [Q((key, value)) for key, value in all_params.items()]
            queryset = queryset.filter(reduce(operator.or_, query_params))
        except TypeError as e:
            pass

        # restore
        request.GET = origin_GET
        return queryset


class OrFilter(OrListFilter):
    title = 'Or filter'
    parameter_prefix = 'or1'
    fields = ("foobar", "foofie")


class FooAdmin(admin.ModelAdmin):
    list_filter = (OrFilter, )

app_name/templates/admin/app_name/change_list.html:

{% extends "admin/change_list.html" %}
{% load i18n admin_list %}

{% block filters %}
  {% if cl.has_filters %}
    <div id="changelist-filter">
      <h2>{% trans 'Filter' %}</h2>
      {% for spec in cl.filter_specs %}
        {% if spec.filter_specs %}
          {% admin_list_filter cl spec %}
          <ul>
            {% for sub_spec in spec.filter_specs %}
              <li>{% admin_list_filter cl sub_spec %}</li>
            {% endfor %}
          </ul>
        {% else %}
          {% admin_list_filter cl spec %}
        {% endif %}
      {% endfor %}
    </div>
  {% endif %}
{% endblock %}

Borrowed some code from @dima-kudosh.

Explanation

ChangeList.get_filters() creates ListFilters (filter_specs) from ModelAdmin.list_filter, then uses ListFilter.queryset() to get_queryset().

FieldListFilter.queryset() uses used_parameters to filter queryset: queryset.filter(**self.used_parameters).

So we can create FieldListFilters from OrListFilter.fields and use their used_parameters to construct OR queries:

all_params = {}
for spec in self.get_filters(request, self.used_parameters):
    if spec and spec.has_output():
        all_params.update(spec.used_parameters)

try:
    query_params = [Q((key, value)) for key, value in all_params.items()]
    queryset = queryset.filter(reduce(operator.or_, query_params))
except TypeError as e:
    pass
0
discopatrick On

Django Admin Multiple Choice List Filter is a Django app that I wrote to fulfil this requirement, after searching through many posts like this one.

MultipleChoiceListFilter extends SimpleListFilter to allow you to filter on multiple options.

The UI uses clickable links to 'include' and 'exclude' choices from the 'OR' query, rather than ticking/unticking a checkbox. Thus, you have to wait for a round trip to the server, and for the page to refresh, after each click. This could be a performance/UX issue, especially for large numbers of objects.

The behaviour of the 'All' link, and of each choice link, is preserved from the SimpleListFilter - i.e. you can reset the filter to all, or just one, of the choices.

Currently included choices are highlighted in the filter (in blue in the screenshot below).

The template is overridable so you change the interface to suit your needs. Personally I think a bit more space between the choice name and the include/exclude link might help to differentiate the two. Or perhaps a switch icon would be more intuitive than the word 'include'/'exclude'.

Screenshot from Django Admin Multiple Choice List Filter