Skip to content

Instantly share code, notes, and snippets.

@vdboor
Last active June 10, 2016 08:41
Show Gist options
  • Save vdboor/c2d87672132f21f4bb7e5d62e659da2d to your computer and use it in GitHub Desktop.
Save vdboor/c2d87672132f21f4bb7e5d62e659da2d to your computer and use it in GitHub Desktop.
Django list filters - provide a way to have filtering in lists. The behavior is completely form-driven, making it possible to write custom `filter_FIELDNAME()` methods in the form if needed.
from django import forms
from django.db.models import QuerySet, Q
class ListFilterForm(forms.Form):
"""
Base form for lists to add a search feature.
Tips:
* Make sure fields are not required.
* Use :class:`~django.forms.MultipleChoiceField` for checkboxes.
* Use :class:`~apps.base.forms.widgets.FilterSelectMultiple` for checkboxes as dropdowns.
"""
query = forms.CharField(required=False, widget=forms.HiddenInput)
#: Define how form fields map to queryset fields.
#: Any field that is not mentioned here, should be handled manually in :meth:`apply_filters`
queryset_fields = None
def __init__(self, *args, **kwargs):
self.search_fields = kwargs.pop('search_fields', ())
super(ListFilterForm, self).__init__(*args, **kwargs)
def _is_undefined(self, form_field, value):
# Allow explicit False for NullBooleanSelect, or empty QuerySet.
if value is None:
return True
elif isinstance(value, (list, tuple)):
return not value
elif isinstance(value, QuerySet):
# forms.ModelMultipleChoiceField() returns qs.none() when the value is not defined.
# Treat that as an None value. Don't evaluate the queryset.
# When a valid queryset is given, make sure it's used as subquery only.
return value.query.is_empty()
else:
return False
def get_queryset_fields(self):
"""
Return which fields are filtered by the form.
By default, any field that is declared in the form,
will be used as a filter, unless there is a custom ``filter_`` method for it.
:return: A dict of ``{'form field name': 'model field name'}``
"""
# This only contains statically defined fields,
# because the other mixins add fields dynamically during init.
if self.queryset_fields is None:
return dict((f, f) for f in self.fields.keys())
else:
return self.queryset_fields
def apply_filters(self, queryset):
"""
Apply the filters for all selected fields. Features:
* AND filter support
* IN filter support
* Find :samp:`filter_{field-name}()` methods to apply filters.
* Moved logic to the form, so advanced filters are easy to add.
"""
filter_args = []
filter_kwargs = {}
qs_filters = self.get_queryset_fields()
qs_filters['query'] = 'query' # ensure that's always there.
for form_field, model_field in qs_filters.iteritems():
value = self.cleaned_data.get(form_field)
if self._is_undefined(form_field, value):
continue
# Allow a custom filter
filter_method = getattr(self, 'filter_{0}'.format(form_field), None)
if filter_method is not None:
# Apply the filter directly.
queryset = filter_method(queryset)
assert isinstance(queryset, QuerySet), "{0}() broke the queryset".format(filter_method.__name__)
continue
# Standard handling, depending on the value type.
if isinstance(value, Q):
# Add to args
filter_args.append(value)
elif isinstance(value, (list, tuple, QuerySet)):
# Add to kwargs, but with 'in'
filter_kwargs["{0}__in".format(model_field)] = value
else:
# Add to kwargs.
filter_kwargs[model_field] = value
return queryset.filter(*filter_args, **filter_kwargs)
def filter_query(self, queryset):
"""
Update the queryset with the results from the 'query' text field.
This method is called via the general apply_filters code.
"""
query = self.cleaned_data['query']
if not query:
return queryset
query_words = query.split()
filters = None
for f in self.search_fields:
word_filters = None
for word in query_words:
q = Q(**{f + '__icontains': word})
word_filters = word_filters & q if word_filters else q
filters = filters | word_filters if filters else word_filters
if filters is not None:
queryset = queryset.filter(filters)
return queryset
import logging
from django.http import QueryDict
from django.utils.functional import cached_property
from django.views.generic.edit import FormMixin
from apps.utils.list_filters.forms import ListFilterForm
logger = logging.getLogger(__name__)
class ListFilterMixin(FormMixin):
"""
Extension to make sure lists can be properly filtered with a search box,
"""
#: The form used for filtering the list
form_class = ListFilterForm
#: The fields where the "query" field will seek through
search_fields = ()
def get_form_kwargs(self):
kwargs = super(ListFilterMixin, self).get_form_kwargs()
if self.request.method == 'GET' and self.request.GET:
kwargs['data'] = self.request.GET
else:
kwargs['data'] = QueryDict()
kwargs['search_fields'] = self.get_search_fields()
return kwargs
@cached_property
def form(self):
# Since there is no standard "setup()" step in views,
# turn this into an "initialize on demand" property.
form_class = self.get_form_class()
return self.get_form(form_class)
def get_base_queryset(self):
"""Helper to get ListView default queryset."""
return super(ListFilterMixin, self).get_queryset()
def get_queryset(self):
"""
Returns the filtered queryset.
"""
queryset = self.get_base_queryset()
form = self.form
if form.is_valid():
queryset = form.apply_filters(queryset)
else:
logger.debug("Invalid form data for list: %s", form.errors.as_data())
return queryset
def get_context_data(self, **kwargs):
context = super(ListFilterMixin, self).get_context_data(**kwargs)
context['form'] = self.form
return context
def get_search_fields(self):
# Allow to override per request.
return self.search_fields
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment