Skip to content

Commit

Permalink
Merge pull request #3315 from tomchristie/filters
Browse files Browse the repository at this point in the history
First pass at HTML rendering for filters
  • Loading branch information
tomchristie committed Oct 22, 2015
2 parents 6305ae8 + 0c6d467 commit c53c9ed
Show file tree
Hide file tree
Showing 15 changed files with 273 additions and 12 deletions.
29 changes: 26 additions & 3 deletions docs/api-guide/filtering.md
Expand Up @@ -95,9 +95,9 @@ You can also set the filter backends on a per-view, or per-viewset basis,
using the `GenericAPIView` class based views.

from django.contrib.auth.models import User
from myapp.serializers import UserSerializer
from myapp.serializers import UserSerializer
from rest_framework import filters
from rest_framework import generics
from rest_framework import generics

class UserListView(generics.ListAPIView):
queryset = User.objects.all()
Expand Down Expand Up @@ -141,6 +141,13 @@ To use REST framework's `DjangoFilterBackend`, first install `django-filter`.

pip install django-filter

If you are using the browsable API or admin API you may also want to install `crispy-forms`, which will enhance the presentation of the filter forms in HTML views, by allowing them to render Bootstrap 3 HTML.

pip install django-crispy-forms

With crispy forms installed, the browsable API will present a filtering control for `DjangoFilterBackend`, like so:

![Django Filter](../../docs/img/django-filter.png)

#### Specifying filter fields

Expand Down Expand Up @@ -237,6 +244,10 @@ For more details on using filter sets see the [django-filter documentation][djan

The `SearchFilter` class supports simple single query parameter based searching, and is based on the [Django admin's search functionality][search-django-admin].

When in use, the browsable API will include a `SearchFilter` control:

![Search Filter](../../docs/img/search-filter.png)

The `SearchFilter` class will only be applied if the view has a `search_fields` attribute set. The `search_fields` attribute should be a list of names of text type fields on the model, such as `CharField` or `TextField`.

class UserListView(generics.ListAPIView):
Expand Down Expand Up @@ -274,7 +285,11 @@ For more details, see the [Django documentation][search-django-admin].

## OrderingFilter

The `OrderingFilter` class supports simple query parameter controlled ordering of results. By default, the query parameter is named `'ordering'`, but this may by overridden with the `ORDERING_PARAM` setting.
The `OrderingFilter` class supports simple query parameter controlled ordering of results.

![Ordering Filter](../../docs/img/ordering-filter.png)

By default, the query parameter is named `'ordering'`, but this may by overridden with the `ORDERING_PARAM` setting.

For example, to order users by username:

Expand Down Expand Up @@ -389,6 +404,14 @@ For example, you might need to restrict users to only being able to see objects

We could achieve the same behavior by overriding `get_queryset()` on the views, but using a filter backend allows you to more easily add this restriction to multiple views, or to apply it across the entire API.

## Customizing the interface

Generic filters may also present an interface in the browsable API. To do so you should implement a `to_html()` method which returns a rendered HTML representation of the filter. This method should have the following signature:

`to_html(self, request, queryset, view)`

The method should return a rendered HTML string.

# Third party packages

The following third party packages provide additional filter implementations.
Expand Down
Binary file added docs/img/django-filter.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/ordering-filter.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/search-filter.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions rest_framework/compat.py
Expand Up @@ -77,6 +77,26 @@ def distinct(queryset, base):
except ImportError:
django_filters = None


# django-crispy-forms is optional
try:
import crispy_forms
except ImportError:
crispy_forms = None


if django.VERSION >= (1, 6):
def clean_manytomany_helptext(text):
return text
else:
# Up to version 1.5 many to many fields automatically suffix
# the `help_text` attribute with hardcoded text.
def clean_manytomany_helptext(text):
if text.endswith(' Hold down "Control", or "Command" on a Mac, to select more than one.'):
text = text[:-69]
return text


# Django-guardian is optional. Import only if guardian is in INSTALLED_APPS
# Fixes (#1712). We keep the try/except for the test suite.
guardian = None
Expand Down
119 changes: 110 additions & 9 deletions rest_framework/filters.py
Expand Up @@ -7,14 +7,57 @@
import operator
from functools import reduce

from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.db import models
from django.template import Context, loader
from django.utils import six
from django.utils.translation import ugettext_lazy as _

from rest_framework.compat import distinct, django_filters, guardian
from rest_framework.compat import (
crispy_forms, distinct, django_filters, guardian
)
from rest_framework.settings import api_settings

FilterSet = django_filters and django_filters.FilterSet or None
if 'crispy_forms' in settings.INSTALLED_APPS and crispy_forms and django_filters:
# If django-crispy-forms is installed, use it to get a bootstrap3 rendering
# of the DjangoFilterBackend controls when displayed as HTML.
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Submit

class FilterSet(django_filters.FilterSet):
def __init__(self, *args, **kwargs):
super(FilterSet, self).__init__(*args, **kwargs)
for field in self.form.fields.values():
field.help_text = None

layout_components = list(self.form.fields.keys()) + [
Submit('', _('Submit'), css_class='btn-default'),
]

helper = FormHelper()
helper.form_method = 'GET'
helper.template_pack = 'bootstrap3'
helper.layout = Layout(*layout_components)

self.form.helper = helper

filter_template = 'rest_framework/filters/django_filter_crispyforms.html'

elif django_filters:
# If django-crispy-forms is not installed, use the standard
# 'form.as_p' rendering when DjangoFilterBackend is displayed as HTML.
class FilterSet(django_filters.FilterSet):
def __init__(self, *args, **kwargs):
super(FilterSet, self).__init__(*args, **kwargs)
for field in self.form.fields.values():
field.help_text = None

filter_template = 'rest_framework/filters/django_filter.html'

else:
FilterSet = None
filter_template = None


class BaseFilterBackend(object):
Expand All @@ -34,6 +77,7 @@ class DjangoFilterBackend(BaseFilterBackend):
A filter backend that uses django-filter.
"""
default_filter_set = FilterSet
template = filter_template

def __init__(self):
assert django_filters, 'Using DjangoFilterBackend, but django-filter is not installed'
Expand All @@ -55,7 +99,7 @@ def get_filter_class(self, view, queryset=None):
return filter_class

if filter_fields:
class AutoFilterSet(self.default_filter_set):
class AutoFilterSet(FilterSet):
class Meta:
model = queryset.model
fields = filter_fields
Expand All @@ -72,10 +116,20 @@ def filter_queryset(self, request, queryset, view):

return queryset

def to_html(self, request, queryset, view):
cls = self.get_filter_class(view, queryset)
filter_instance = cls(request.query_params, queryset=queryset)
context = Context({
'filter': filter_instance
})
template = loader.get_template(self.template)
return template.render(context)


class SearchFilter(BaseFilterBackend):
# The URL query parameter used for the search.
search_param = api_settings.SEARCH_PARAM
template = 'rest_framework/filters/search.html'

def get_search_terms(self, request):
"""
Expand All @@ -99,7 +153,6 @@ def construct_search(self, field_name):

def filter_queryset(self, request, queryset, view):
search_fields = getattr(view, 'search_fields', None)

search_terms = self.get_search_terms(request)

if not search_fields or not search_terms:
Expand All @@ -123,11 +176,25 @@ def filter_queryset(self, request, queryset, view):
# in the resulting queryset.
return distinct(queryset, base)

def to_html(self, request, queryset, view):
if not getattr(view, 'search_fields', None):
return ''

term = self.get_search_terms(request)
term = term[0] if term else ''
context = Context({
'param': self.search_param,
'term': term
})
template = loader.get_template(self.template)
return template.render(context)


class OrderingFilter(BaseFilterBackend):
# The URL query parameter used for the ordering.
ordering_param = api_settings.ORDERING_PARAM
ordering_fields = None
template = 'rest_framework/filters/ordering.html'

def get_ordering(self, request, queryset, view):
"""
Expand All @@ -153,7 +220,7 @@ def get_default_ordering(self, view):
return (ordering,)
return ordering

def remove_invalid_fields(self, queryset, fields, view):
def get_valid_fields(self, queryset, view):
valid_fields = getattr(view, 'ordering_fields', self.ordering_fields)

if valid_fields is None:
Expand All @@ -164,15 +231,30 @@ def remove_invalid_fields(self, queryset, fields, view):
"'serializer_class' or 'ordering_fields' attribute.")
raise ImproperlyConfigured(msg % self.__class__.__name__)
valid_fields = [
field.source or field_name
(field.source or field_name, field.label)
for field_name, field in serializer_class().fields.items()
if not getattr(field, 'write_only', False)
if not getattr(field, 'write_only', False) and not field.source == '*'
]
elif valid_fields == '__all__':
# View explicitly allows filtering on any model field
valid_fields = [field.name for field in queryset.model._meta.fields]
valid_fields += queryset.query.aggregates.keys()
valid_fields = [
(field.name, getattr(field, 'label', field.name.title()))
for field in queryset.model._meta.fields
]
valid_fields += [
(key, key.title().split('__'))
for key in queryset.query.aggregates.keys()
]
else:
valid_fields = [
(item, item) if isinstance(item, six.string_types) else item
for item in valid_fields
]

return valid_fields

def remove_invalid_fields(self, queryset, fields, view):
valid_fields = [item[0] for item in self.get_valid_fields(queryset, view)]
return [term for term in fields if term.lstrip('-') in valid_fields]

def filter_queryset(self, request, queryset, view):
Expand All @@ -183,6 +265,25 @@ def filter_queryset(self, request, queryset, view):

return queryset

def get_template_context(self, request, queryset, view):
current = self.get_ordering(request, queryset, view)
current = None if current is None else current[0]
options = []
for key, label in self.get_valid_fields(queryset, view):
options.append((key, '%s - ascending' % label))
options.append(('-' + key, '%s - descending' % label))
return {
'request': request,
'current': current,
'param': self.ordering_param,
'options': options,
}

def to_html(self, request, queryset, view):
template = loader.get_template(self.template)
context = Context(self.get_template_context(request, queryset, view))
return template.render(context)


class DjangoObjectPermissionsFilter(BaseFilterBackend):
"""
Expand Down
34 changes: 34 additions & 0 deletions rest_framework/renderers.py
Expand Up @@ -364,6 +364,7 @@ class BrowsableAPIRenderer(BaseRenderer):
media_type = 'text/html'
format = 'api'
template = 'rest_framework/api.html'
filter_template = 'rest_framework/filters/base.html'
charset = 'utf-8'
form_renderer_class = HTMLFormRenderer

Expand Down Expand Up @@ -571,6 +572,37 @@ def get_description(self, view, status_code):
def get_breadcrumbs(self, request):
return get_breadcrumbs(request.path, request)

def get_filter_form(self, data, view, request):
if not hasattr(view, 'get_queryset') or not hasattr(view, 'filter_backends'):
return

# Infer if this is a list view or not.
paginator = getattr(view, 'paginator', None)
if isinstance(data, list):
pass
elif (paginator is not None and data is not None):
try:
paginator.get_results(data)
except (TypeError, KeyError):
return
elif not isinstance(data, list):
return

queryset = view.get_queryset()
elements = []
for backend in view.filter_backends:
if hasattr(backend, 'to_html'):
html = backend().to_html(request, queryset, view)
if html:
elements.append(html)

if not elements:
return

template = loader.get_template(self.filter_template)
context = Context({'elements': elements})
return template.render(context)

def get_context(self, data, accepted_media_type, renderer_context):
"""
Returns the context used to render.
Expand Down Expand Up @@ -618,6 +650,8 @@ def get_context(self, data, accepted_media_type, renderer_context):
'delete_form': self.get_rendered_html_form(data, view, 'DELETE', request),
'options_form': self.get_rendered_html_form(data, view, 'OPTIONS', request),

'filter_form': self.get_filter_form(data, view, request),

'raw_data_put_form': raw_data_put_form,
'raw_data_post_form': raw_data_post_form,
'raw_data_patch_form': raw_data_patch_form,
Expand Down
8 changes: 8 additions & 0 deletions rest_framework/static/rest_framework/css/default.css
Expand Up @@ -73,3 +73,11 @@ pre {
border-bottom: none;
padding-bottom: 0px;
}

#filtersModal form input[type=submit] {
width: auto;
}

#filtersModal .modal-body h2 {
margin-top: 0
}
9 changes: 9 additions & 0 deletions rest_framework/templates/rest_framework/admin.html
Expand Up @@ -111,6 +111,13 @@
</form>
{% endif %}

{% if filter_form %}
<button style="float: right; margin-right: 10px" data-toggle="modal" data-target="#filtersModal" class="btn btn-default">
<span class="glyphicon glyphicon-wrench" aria-hidden="true"></span>
{% trans "Filters" %}
</button>
{% endif %}

<div class="content-main">
<div class="page-header">
<h1>{{ name }}</h1>
Expand Down Expand Up @@ -218,6 +225,8 @@ <h4 class="modal-title" id="myModalLabel">{{ error_title }}</h4>
</div>
{% endif %}

{% if filter_form %}{{ filter_form }}{% endif %}

{% block script %}
<script src="{% static "rest_framework/js/jquery-1.11.3.min.js" %}"></script>
<script src="{% static "rest_framework/js/ajax-form.js" %}"></script>
Expand Down

0 comments on commit c53c9ed

Please sign in to comment.