Merge pull request #3315 from tomchristie/filters

First pass at HTML rendering for filters
This commit is contained in:
Tom Christie 2015-10-22 11:42:35 +01:00
commit c53c9eddfe
15 changed files with 273 additions and 12 deletions

View File

@ -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. using the `GenericAPIView` class based views.
from django.contrib.auth.models import User 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 filters
from rest_framework import generics from rest_framework import generics
class UserListView(generics.ListAPIView): class UserListView(generics.ListAPIView):
queryset = User.objects.all() queryset = User.objects.all()
@ -141,6 +141,13 @@ To use REST framework's `DjangoFilterBackend`, first install `django-filter`.
pip 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 #### Specifying filter fields
@ -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]. 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`. 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): class UserListView(generics.ListAPIView):
@ -274,7 +285,11 @@ For more details, see the [Django documentation][search-django-admin].
## OrderingFilter ## 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: For example, to order users by username:
@ -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. 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 # Third party packages
The following third party packages provide additional filter implementations. The following third party packages provide additional filter implementations.

BIN
docs/img/django-filter.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
docs/img/search-filter.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@ -77,6 +77,26 @@ try:
except ImportError: except ImportError:
django_filters = None 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 # Django-guardian is optional. Import only if guardian is in INSTALLED_APPS
# Fixes (#1712). We keep the try/except for the test suite. # Fixes (#1712). We keep the try/except for the test suite.
guardian = None guardian = None

View File

@ -7,14 +7,57 @@ from __future__ import unicode_literals
import operator import operator
from functools import reduce from functools import reduce
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.db import models from django.db import models
from django.template import Context, loader
from django.utils import six 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 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): class BaseFilterBackend(object):
@ -34,6 +77,7 @@ class DjangoFilterBackend(BaseFilterBackend):
A filter backend that uses django-filter. A filter backend that uses django-filter.
""" """
default_filter_set = FilterSet default_filter_set = FilterSet
template = filter_template
def __init__(self): def __init__(self):
assert django_filters, 'Using DjangoFilterBackend, but django-filter is not installed' assert django_filters, 'Using DjangoFilterBackend, but django-filter is not installed'
@ -55,7 +99,7 @@ class DjangoFilterBackend(BaseFilterBackend):
return filter_class return filter_class
if filter_fields: if filter_fields:
class AutoFilterSet(self.default_filter_set): class AutoFilterSet(FilterSet):
class Meta: class Meta:
model = queryset.model model = queryset.model
fields = filter_fields fields = filter_fields
@ -72,10 +116,20 @@ class DjangoFilterBackend(BaseFilterBackend):
return queryset 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): class SearchFilter(BaseFilterBackend):
# The URL query parameter used for the search. # The URL query parameter used for the search.
search_param = api_settings.SEARCH_PARAM search_param = api_settings.SEARCH_PARAM
template = 'rest_framework/filters/search.html'
def get_search_terms(self, request): def get_search_terms(self, request):
""" """
@ -99,7 +153,6 @@ class SearchFilter(BaseFilterBackend):
def filter_queryset(self, request, queryset, view): def filter_queryset(self, request, queryset, view):
search_fields = getattr(view, 'search_fields', None) search_fields = getattr(view, 'search_fields', None)
search_terms = self.get_search_terms(request) search_terms = self.get_search_terms(request)
if not search_fields or not search_terms: if not search_fields or not search_terms:
@ -123,11 +176,25 @@ class SearchFilter(BaseFilterBackend):
# in the resulting queryset. # in the resulting queryset.
return distinct(queryset, base) 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): class OrderingFilter(BaseFilterBackend):
# The URL query parameter used for the ordering. # The URL query parameter used for the ordering.
ordering_param = api_settings.ORDERING_PARAM ordering_param = api_settings.ORDERING_PARAM
ordering_fields = None ordering_fields = None
template = 'rest_framework/filters/ordering.html'
def get_ordering(self, request, queryset, view): def get_ordering(self, request, queryset, view):
""" """
@ -153,7 +220,7 @@ class OrderingFilter(BaseFilterBackend):
return (ordering,) return (ordering,)
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) valid_fields = getattr(view, 'ordering_fields', self.ordering_fields)
if valid_fields is None: if valid_fields is None:
@ -164,15 +231,30 @@ class OrderingFilter(BaseFilterBackend):
"'serializer_class' or 'ordering_fields' attribute.") "'serializer_class' or 'ordering_fields' attribute.")
raise ImproperlyConfigured(msg % self.__class__.__name__) raise ImproperlyConfigured(msg % self.__class__.__name__)
valid_fields = [ valid_fields = [
field.source or field_name (field.source or field_name, field.label)
for field_name, field in serializer_class().fields.items() 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__': elif valid_fields == '__all__':
# View explicitly allows filtering on any model field # View explicitly allows filtering on any model field
valid_fields = [field.name for field in queryset.model._meta.fields] valid_fields = [
valid_fields += queryset.query.aggregates.keys() (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] return [term for term in fields if term.lstrip('-') in valid_fields]
def filter_queryset(self, request, queryset, view): def filter_queryset(self, request, queryset, view):
@ -183,6 +265,25 @@ class OrderingFilter(BaseFilterBackend):
return queryset 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): class DjangoObjectPermissionsFilter(BaseFilterBackend):
""" """

View File

@ -364,6 +364,7 @@ class BrowsableAPIRenderer(BaseRenderer):
media_type = 'text/html' media_type = 'text/html'
format = 'api' format = 'api'
template = 'rest_framework/api.html' template = 'rest_framework/api.html'
filter_template = 'rest_framework/filters/base.html'
charset = 'utf-8' charset = 'utf-8'
form_renderer_class = HTMLFormRenderer form_renderer_class = HTMLFormRenderer
@ -571,6 +572,37 @@ class BrowsableAPIRenderer(BaseRenderer):
def get_breadcrumbs(self, request): def get_breadcrumbs(self, request):
return get_breadcrumbs(request.path, 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): def get_context(self, data, accepted_media_type, renderer_context):
""" """
Returns the context used to render. Returns the context used to render.
@ -618,6 +650,8 @@ class BrowsableAPIRenderer(BaseRenderer):
'delete_form': self.get_rendered_html_form(data, view, 'DELETE', request), 'delete_form': self.get_rendered_html_form(data, view, 'DELETE', request),
'options_form': self.get_rendered_html_form(data, view, 'OPTIONS', 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_put_form': raw_data_put_form,
'raw_data_post_form': raw_data_post_form, 'raw_data_post_form': raw_data_post_form,
'raw_data_patch_form': raw_data_patch_form, 'raw_data_patch_form': raw_data_patch_form,

View File

@ -73,3 +73,11 @@ pre {
border-bottom: none; border-bottom: none;
padding-bottom: 0px; padding-bottom: 0px;
} }
#filtersModal form input[type=submit] {
width: auto;
}
#filtersModal .modal-body h2 {
margin-top: 0
}

View File

@ -111,6 +111,13 @@
</form> </form>
{% endif %} {% 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="content-main">
<div class="page-header"> <div class="page-header">
<h1>{{ name }}</h1> <h1>{{ name }}</h1>
@ -218,6 +225,8 @@
</div> </div>
{% endif %} {% endif %}
{% if filter_form %}{{ filter_form }}{% endif %}
{% block script %} {% block script %}
<script src="{% static "rest_framework/js/jquery-1.11.3.min.js" %}"></script> <script src="{% static "rest_framework/js/jquery-1.11.3.min.js" %}"></script>
<script src="{% static "rest_framework/js/ajax-form.js" %}"></script> <script src="{% static "rest_framework/js/ajax-form.js" %}"></script>

View File

@ -1,5 +1,6 @@
{% load staticfiles %} {% load staticfiles %}
{% load rest_framework %} {% load rest_framework %}
{% load i18n %}
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
@ -105,6 +106,13 @@
</form> </form>
{% endif %} {% 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="content-main">
<div class="page-header"> <div class="page-header">
<h1>{{ name }}</h1> <h1>{{ name }}</h1>
@ -246,6 +254,11 @@
}); });
</script> </script>
{% endblock %} {% endblock %}
{% if filter_form %}
{{ filter_form }}
{% endif %}
</body> </body>
{% endblock %} {% endblock %}
</html> </html>

View File

@ -0,0 +1,16 @@
<div class="modal fade" id="filtersModal" tabindex="-1" role="dialog" aria-labelledby="filters" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title">Filters</h4>
</div>
<div class="modal-body">
{% for element in elements %}
{% if not forloop.first %}<hr/>{% endif %}
{{ element }}
{% endfor %}
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,6 @@
{% load i18n %}
<h2>{% trans "Field filters" %}</h2>
<form class="form" action="" method="get">
{{ filter.form.as_p }}
<button type="submit" class="btn btn-primary">{% trans "Submit" %}</button>
</form>

View File

@ -0,0 +1,5 @@
{% load crispy_forms_tags %}
{% load i18n %}
<h2>{% trans "Field filters" %}</h2>
{% crispy filter.form %}

View File

@ -0,0 +1,14 @@
{% load rest_framework %}
{% load i18n %}
<h2>{% trans "Ordering" %}</h2>
<div class="list-group">
{% for key, label in options %}
{% if key == current %}
<a href="{% add_query_param request param key %}" class="list-group-item active">
<span class="glyphicon glyphicon-ok" style="float: right" aria-hidden="true"></span> {{ label }}
</a>
{% else %}
<a href="{% add_query_param request param key %}" class="list-group-item">{{ label }}</a>
{% endif %}
{% endfor %}
</div>

View File

@ -0,0 +1,12 @@
{% load i18n %}
<h2>{% trans "Search" %}</h2>
<form class="form-inline">
<div class="form-group">
<div class="input-group">
<input type="text" class="form-control" style="width: 350px" name="{{ param }}" value="{{ term }}">
<span class="input-group-btn">
<button class="btn btn-default" type="submit"><span class="glyphicon glyphicon-search" aria-hidden="true"></span> Search</button>
</span>
</div>
</div>
</form>