Skip to content

Commit c53c9ed

Browse files
committed
Merge pull request #3315 from tomchristie/filters
First pass at HTML rendering for filters
2 parents 6305ae8 + 0c6d467 commit c53c9ed

File tree

15 files changed

+273
-12
lines changed

15 files changed

+273
-12
lines changed

docs/api-guide/filtering.md

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,9 @@ You can also set the filter backends on a per-view, or per-viewset basis,
9595
using the `GenericAPIView` class based views.
9696

9797
from django.contrib.auth.models import User
98-
from myapp.serializers import UserSerializer
98+
from myapp.serializers import UserSerializer
9999
from rest_framework import filters
100-
from rest_framework import generics
100+
from rest_framework import generics
101101

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

142142
pip install django-filter
143143

144+
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.
145+
146+
pip install django-crispy-forms
147+
148+
With crispy forms installed, the browsable API will present a filtering control for `DjangoFilterBackend`, like so:
149+
150+
![Django Filter](../../docs/img/django-filter.png)
144151

145152
#### Specifying filter fields
146153

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

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

247+
When in use, the browsable API will include a `SearchFilter` control:
248+
249+
![Search Filter](../../docs/img/search-filter.png)
250+
240251
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`.
241252

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

275286
## OrderingFilter
276287

277-
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.
288+
The `OrderingFilter` class supports simple query parameter controlled ordering of results.
289+
290+
![Ordering Filter](../../docs/img/ordering-filter.png)
291+
292+
By default, the query parameter is named `'ordering'`, but this may by overridden with the `ORDERING_PARAM` setting.
278293

279294
For example, to order users by username:
280295

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

390405
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.
391406

407+
## Customizing the interface
408+
409+
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:
410+
411+
`to_html(self, request, queryset, view)`
412+
413+
The method should return a rendered HTML string.
414+
392415
# Third party packages
393416

394417
The following third party packages provide additional filter implementations.

docs/img/django-filter.png

13.4 KB
Loading

docs/img/ordering-filter.png

17.8 KB
Loading

docs/img/search-filter.png

8.92 KB
Loading

rest_framework/compat.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,26 @@ def distinct(queryset, base):
7777
except ImportError:
7878
django_filters = None
7979

80+
81+
# django-crispy-forms is optional
82+
try:
83+
import crispy_forms
84+
except ImportError:
85+
crispy_forms = None
86+
87+
88+
if django.VERSION >= (1, 6):
89+
def clean_manytomany_helptext(text):
90+
return text
91+
else:
92+
# Up to version 1.5 many to many fields automatically suffix
93+
# the `help_text` attribute with hardcoded text.
94+
def clean_manytomany_helptext(text):
95+
if text.endswith(' Hold down "Control", or "Command" on a Mac, to select more than one.'):
96+
text = text[:-69]
97+
return text
98+
99+
80100
# Django-guardian is optional. Import only if guardian is in INSTALLED_APPS
81101
# Fixes (#1712). We keep the try/except for the test suite.
82102
guardian = None

rest_framework/filters.py

Lines changed: 110 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,57 @@
77
import operator
88
from functools import reduce
99

10+
from django.conf import settings
1011
from django.core.exceptions import ImproperlyConfigured
1112
from django.db import models
13+
from django.template import Context, loader
1214
from django.utils import six
15+
from django.utils.translation import ugettext_lazy as _
1316

14-
from rest_framework.compat import distinct, django_filters, guardian
17+
from rest_framework.compat import (
18+
crispy_forms, distinct, django_filters, guardian
19+
)
1520
from rest_framework.settings import api_settings
1621

17-
FilterSet = django_filters and django_filters.FilterSet or None
22+
if 'crispy_forms' in settings.INSTALLED_APPS and crispy_forms and django_filters:
23+
# If django-crispy-forms is installed, use it to get a bootstrap3 rendering
24+
# of the DjangoFilterBackend controls when displayed as HTML.
25+
from crispy_forms.helper import FormHelper
26+
from crispy_forms.layout import Layout, Submit
27+
28+
class FilterSet(django_filters.FilterSet):
29+
def __init__(self, *args, **kwargs):
30+
super(FilterSet, self).__init__(*args, **kwargs)
31+
for field in self.form.fields.values():
32+
field.help_text = None
33+
34+
layout_components = list(self.form.fields.keys()) + [
35+
Submit('', _('Submit'), css_class='btn-default'),
36+
]
37+
38+
helper = FormHelper()
39+
helper.form_method = 'GET'
40+
helper.template_pack = 'bootstrap3'
41+
helper.layout = Layout(*layout_components)
42+
43+
self.form.helper = helper
44+
45+
filter_template = 'rest_framework/filters/django_filter_crispyforms.html'
46+
47+
elif django_filters:
48+
# If django-crispy-forms is not installed, use the standard
49+
# 'form.as_p' rendering when DjangoFilterBackend is displayed as HTML.
50+
class FilterSet(django_filters.FilterSet):
51+
def __init__(self, *args, **kwargs):
52+
super(FilterSet, self).__init__(*args, **kwargs)
53+
for field in self.form.fields.values():
54+
field.help_text = None
55+
56+
filter_template = 'rest_framework/filters/django_filter.html'
57+
58+
else:
59+
FilterSet = None
60+
filter_template = None
1861

1962

2063
class BaseFilterBackend(object):
@@ -34,6 +77,7 @@ class DjangoFilterBackend(BaseFilterBackend):
3477
A filter backend that uses django-filter.
3578
"""
3679
default_filter_set = FilterSet
80+
template = filter_template
3781

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

57101
if filter_fields:
58-
class AutoFilterSet(self.default_filter_set):
102+
class AutoFilterSet(FilterSet):
59103
class Meta:
60104
model = queryset.model
61105
fields = filter_fields
@@ -72,10 +116,20 @@ def filter_queryset(self, request, queryset, view):
72116

73117
return queryset
74118

119+
def to_html(self, request, queryset, view):
120+
cls = self.get_filter_class(view, queryset)
121+
filter_instance = cls(request.query_params, queryset=queryset)
122+
context = Context({
123+
'filter': filter_instance
124+
})
125+
template = loader.get_template(self.template)
126+
return template.render(context)
127+
75128

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

80134
def get_search_terms(self, request):
81135
"""
@@ -99,7 +153,6 @@ def construct_search(self, field_name):
99153

100154
def filter_queryset(self, request, queryset, view):
101155
search_fields = getattr(view, 'search_fields', None)
102-
103156
search_terms = self.get_search_terms(request)
104157

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

179+
def to_html(self, request, queryset, view):
180+
if not getattr(view, 'search_fields', None):
181+
return ''
182+
183+
term = self.get_search_terms(request)
184+
term = term[0] if term else ''
185+
context = Context({
186+
'param': self.search_param,
187+
'term': term
188+
})
189+
template = loader.get_template(self.template)
190+
return template.render(context)
191+
126192

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

132199
def get_ordering(self, request, queryset, view):
133200
"""
@@ -153,7 +220,7 @@ def get_default_ordering(self, view):
153220
return (ordering,)
154221
return ordering
155222

156-
def remove_invalid_fields(self, queryset, fields, view):
223+
def get_valid_fields(self, queryset, view):
157224
valid_fields = getattr(view, 'ordering_fields', self.ordering_fields)
158225

159226
if valid_fields is None:
@@ -164,15 +231,30 @@ def remove_invalid_fields(self, queryset, fields, view):
164231
"'serializer_class' or 'ordering_fields' attribute.")
165232
raise ImproperlyConfigured(msg % self.__class__.__name__)
166233
valid_fields = [
167-
field.source or field_name
234+
(field.source or field_name, field.label)
168235
for field_name, field in serializer_class().fields.items()
169-
if not getattr(field, 'write_only', False)
236+
if not getattr(field, 'write_only', False) and not field.source == '*'
170237
]
171238
elif valid_fields == '__all__':
172239
# View explicitly allows filtering on any model field
173-
valid_fields = [field.name for field in queryset.model._meta.fields]
174-
valid_fields += queryset.query.aggregates.keys()
240+
valid_fields = [
241+
(field.name, getattr(field, 'label', field.name.title()))
242+
for field in queryset.model._meta.fields
243+
]
244+
valid_fields += [
245+
(key, key.title().split('__'))
246+
for key in queryset.query.aggregates.keys()
247+
]
248+
else:
249+
valid_fields = [
250+
(item, item) if isinstance(item, six.string_types) else item
251+
for item in valid_fields
252+
]
253+
254+
return valid_fields
175255

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

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

184266
return queryset
185267

268+
def get_template_context(self, request, queryset, view):
269+
current = self.get_ordering(request, queryset, view)
270+
current = None if current is None else current[0]
271+
options = []
272+
for key, label in self.get_valid_fields(queryset, view):
273+
options.append((key, '%s - ascending' % label))
274+
options.append(('-' + key, '%s - descending' % label))
275+
return {
276+
'request': request,
277+
'current': current,
278+
'param': self.ordering_param,
279+
'options': options,
280+
}
281+
282+
def to_html(self, request, queryset, view):
283+
template = loader.get_template(self.template)
284+
context = Context(self.get_template_context(request, queryset, view))
285+
return template.render(context)
286+
186287

187288
class DjangoObjectPermissionsFilter(BaseFilterBackend):
188289
"""

rest_framework/renderers.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,7 @@ class BrowsableAPIRenderer(BaseRenderer):
364364
media_type = 'text/html'
365365
format = 'api'
366366
template = 'rest_framework/api.html'
367+
filter_template = 'rest_framework/filters/base.html'
367368
charset = 'utf-8'
368369
form_renderer_class = HTMLFormRenderer
369370

@@ -571,6 +572,37 @@ def get_description(self, view, status_code):
571572
def get_breadcrumbs(self, request):
572573
return get_breadcrumbs(request.path, request)
573574

575+
def get_filter_form(self, data, view, request):
576+
if not hasattr(view, 'get_queryset') or not hasattr(view, 'filter_backends'):
577+
return
578+
579+
# Infer if this is a list view or not.
580+
paginator = getattr(view, 'paginator', None)
581+
if isinstance(data, list):
582+
pass
583+
elif (paginator is not None and data is not None):
584+
try:
585+
paginator.get_results(data)
586+
except (TypeError, KeyError):
587+
return
588+
elif not isinstance(data, list):
589+
return
590+
591+
queryset = view.get_queryset()
592+
elements = []
593+
for backend in view.filter_backends:
594+
if hasattr(backend, 'to_html'):
595+
html = backend().to_html(request, queryset, view)
596+
if html:
597+
elements.append(html)
598+
599+
if not elements:
600+
return
601+
602+
template = loader.get_template(self.filter_template)
603+
context = Context({'elements': elements})
604+
return template.render(context)
605+
574606
def get_context(self, data, accepted_media_type, renderer_context):
575607
"""
576608
Returns the context used to render.
@@ -618,6 +650,8 @@ def get_context(self, data, accepted_media_type, renderer_context):
618650
'delete_form': self.get_rendered_html_form(data, view, 'DELETE', request),
619651
'options_form': self.get_rendered_html_form(data, view, 'OPTIONS', request),
620652

653+
'filter_form': self.get_filter_form(data, view, request),
654+
621655
'raw_data_put_form': raw_data_put_form,
622656
'raw_data_post_form': raw_data_post_form,
623657
'raw_data_patch_form': raw_data_patch_form,

rest_framework/static/rest_framework/css/default.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,11 @@ pre {
7373
border-bottom: none;
7474
padding-bottom: 0px;
7575
}
76+
77+
#filtersModal form input[type=submit] {
78+
width: auto;
79+
}
80+
81+
#filtersModal .modal-body h2 {
82+
margin-top: 0
83+
}

rest_framework/templates/rest_framework/admin.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,13 @@
111111
</form>
112112
{% endif %}
113113

114+
{% if filter_form %}
115+
<button style="float: right; margin-right: 10px" data-toggle="modal" data-target="#filtersModal" class="btn btn-default">
116+
<span class="glyphicon glyphicon-wrench" aria-hidden="true"></span>
117+
{% trans "Filters" %}
118+
</button>
119+
{% endif %}
120+
114121
<div class="content-main">
115122
<div class="page-header">
116123
<h1>{{ name }}</h1>
@@ -218,6 +225,8 @@ <h4 class="modal-title" id="myModalLabel">{{ error_title }}</h4>
218225
</div>
219226
{% endif %}
220227

228+
{% if filter_form %}{{ filter_form }}{% endif %}
229+
221230
{% block script %}
222231
<script src="{% static "rest_framework/js/jquery-1.11.3.min.js" %}"></script>
223232
<script src="{% static "rest_framework/js/ajax-form.js" %}"></script>

0 commit comments

Comments
 (0)