Repository: encode/django-rest-framework

Issue:
Search and filter controls for Browsable API & Admin
The search and filter classes should render in the browsable API in a way that ensures they can be used without having to manually edit the URL querystring.



Discussion:
:+1: definitely very useful, but I'm wondering how exactly we'll make filtering work, since filtering can happen for different fields. Would it only work for `SearchFilter`?

Ask me when I get there :p

More seriously tho: I've been assuming we'll need an interface on filter classes that can be used to define  if and how they should render to HTML.

Any appetite for pushing whatever is needed/can be pushed into Django Filter here? 

I'm more or less on top of the issues backlog there: next up is improving the documentation and then I wanted to think through where it's going — there's seems to be a tendency to sprout new Filter subclasses like mushrooms on a damp night — compatibility with DRF, whilst not everything, seems like an important _feature_

@carltongibson I don't think we'll need to do much pushing into Django Filter. We can probably just add a simple wrapper around its own HTML generation stuff.

OK, well as needed.

@tomchristie This issue should probably be labeled as `Browseable API`.

Not a distinction that we make any more: https://github.com/tomchristie/django-rest-framework/labels

Still very much on the roadmap but I'd like to start tying up 3.1.0 since there's a bunch of great improvements in there already. We may have 3.2.0 for the first portion of HTML stuff and 3.3.0 for the final bit of the admin api work, but currently undecided (may just all be 3.2.0)


Resolving patch:
diff --git a/rest_framework/compat.py b/rest_framework/compat.py
--- a/rest_framework/compat.py
+++ b/rest_framework/compat.py
@@ -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
diff --git a/rest_framework/filters.py b/rest_framework/filters.py
--- a/rest_framework/filters.py
+++ b/rest_framework/filters.py
@@ -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):
@@ -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'
@@ -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
@@ -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):
         """
@@ -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:
@@ -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):
         """
@@ -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:
@@ -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):
@@ -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):
     """
diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py
--- a/rest_framework/renderers.py
+++ b/rest_framework/renderers.py
@@ -374,6 +374,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
 
@@ -584,6 +585,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.
@@ -631,6 +663,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,


Test patch: