Repository: carltongibson/django-filter

Issue:
Ordering filter problems
The ordering filter implementation is kind of screwy. If you look at the `FilterSetOrderingTests`, the ordering field was really designed to be used with just the `Meta.fields` list syntax. There aren't any tests that verify behavior with declared filters, or with the `Meta.fields` dict syntax. Take the following filter:

``` py
class F(FilterSet):
    example_filter = CharFilter(name='author__username')

    class Meta:
        model = Article
        fields = {
            'author__username': ['exact'],
            'published': ['exact', 'lt', 'gt'],
        }
        order_by = True
```

You get the resulting markup:

``` html
<tr>
<th><label for="id_o">Ordering:</label></th>
<td>
    <select id="id_o" name="o">
        <option value="published">Published</option>
        <option value="-published">Published (descending)</option>
        <option value="published__lt">Published</option>
        <option value="-published__lt">Published (descending)</option>
        <option value="published__gt">Published</option>
        <option value="-published__gt">Published (descending)</option>
        <option value="author__username">Username</option>
        <option value="-author__username">Username (descending)</option>
        <option value="example_filter">Example_filter</option>
        <option value="-example_filter">Example_filter (descending)</option>
    </select>
</td>
</tr>
```
- `order_by = True` only makes sense for FilterSets with one filter per model field (list syntax). 
  - multiple filters per field will show up as duplicates in the options list
- The filter labels are preferred for getting the display text for a choice, which only makes sense in the list syntax, where the labels are derived from the filters attribute name on the filterset. A verbose label, such as 'Author name starts with' doesn't make much sense as an option to sort by.
- The ordering option's display text for declared filters does not replace underscores with spaces.



Discussion:
Thinking about this a bit... the ordering field can be reworked into a proper filter. 
- Subclass CSVFilter to take advantage of multiple ordering
- override filter to call `order_by` instead
- automatically add reverse sort choices? Maybe this could be handled in a subclass
- `fields` would translate the public query param name (account) to the underlying model field name (username), upholding #230.

Usage:

``` py

class UserFilter(FilterSet):
   account = CharFilter(name='username')
   o = OrderingFilter(choices=(('account', 'Account')), fields={'account': 'username'})
    class Meta:
        model = User
```


Resolving patch:
diff --git a/django_filters/filters.py b/django_filters/filters.py
--- a/django_filters/filters.py
+++ b/django_filters/filters.py
@@ -2,6 +2,7 @@
 from __future__ import unicode_literals
 
 import warnings
+from collections import OrderedDict
 from datetime import timedelta
 
 from django import forms
@@ -10,6 +11,7 @@
 from django.db.models.constants import LOOKUP_SEP
 from django.conf import settings
 from django.utils import six
+from django.utils.itercompat import is_iterable
 from django.utils.timezone import now
 from django.utils.translation import ugettext_lazy as _
 
@@ -17,7 +19,7 @@
     Lookup, LookupTypeField, BaseCSVField, BaseRangeField, RangeField,
     DateRangeField, DateTimeRangeField, TimeRangeField, IsoDateTimeField
 )
-from .utils import deprecate
+from .utils import deprecate, pretty_name
 
 
 __all__ = [
@@ -39,6 +41,7 @@
     'MultipleChoiceFilter',
     'NumberFilter',
     'NumericRangeFilter',
+    'OrderingFilter',
     'RangeFilter',
     'TimeFilter',
     'TimeRangeFilter',
@@ -489,6 +492,102 @@ def __init__(self, *args, **kwargs):
         super(BaseRangeFilter, self).__init__(*args, **kwargs)
 
 
+class OrderingFilter(BaseCSVFilter, ChoiceFilter):
+    """
+    Enable queryset ordering. As an extension of ``ChoiceFilter`` it accepts
+    two additional arguments that are used to build the ordering choices.
+
+    * ``fields`` is a mapping of {model field name: parameter name}. The
+      parameter names are exposed in the choices and mask/alias the field
+      names used in the ``order_by()`` call. Similar to field ``choices``,
+      ``fields`` accepts the 'list of two-tuples' syntax that retains order.
+      ``fields`` may also just be an iterable of strings. In this case, the
+      field names simply double as the exposed parameter names.
+
+    * ``field_labels`` is an optional argument that allows you to customize
+      the display label for the corresponding parameter. It accepts a mapping
+      of {field name: human readable label}. Keep in mind that the key is the
+      field name, and not the exposed parameter name.
+
+    Additionally, you can just provide your own ``choices`` if you require
+    explicit control over the exposed options. For example, when you might
+    want to disable descending sort options.
+
+    This filter is also CSV-based, and accepts multiple ordering params. The
+    default select widget does not enable the use of this, but it is useful
+    for APIs.
+
+    """
+    descending_fmt = _('%s (descending)')
+
+    def __init__(self, *args, **kwargs):
+        """
+        ``fields`` may be either a mapping or an iterable.
+        ``field_labels`` must be a map of field names to display labels
+        """
+        fields = kwargs.pop('fields', {})
+        fields = self.normalize_fields(fields)
+        field_labels = kwargs.pop('field_labels', {})
+
+        self.param_map = {v: k for k, v in fields.items()}
+
+        if 'choices' not in kwargs:
+            kwargs['choices'] = self.build_choices(fields, field_labels)
+
+        kwargs.setdefault('label', _('Ordering'))
+        super(OrderingFilter, self).__init__(*args, **kwargs)
+
+    def get_ordering_value(self, param):
+        descending = param.startswith('-')
+        param = param[1:] if descending else param
+        field_name = self.param_map.get(param, param)
+
+        return "-%s" % field_name if descending else field_name
+
+    def filter(self, qs, value):
+        if value in EMPTY_VALUES:
+            return qs
+
+        ordering = [self.get_ordering_value(param) for param in value]
+        return qs.order_by(*ordering)
+
+    @classmethod
+    def normalize_fields(cls, fields):
+        """
+        Normalize the fields into an ordered map of {field name: param name}
+        """
+        # fields is a mapping, copy into new OrderedDict
+        if isinstance(fields, dict):
+            return OrderedDict(fields)
+
+        # convert iterable of values => iterable of pairs (field name, param name)
+        assert is_iterable(fields), \
+            "'fields' must be an iterable (e.g., a list, tuple, or mapping)."
+
+        # fields is an iterable of field names
+        assert all(isinstance(field, six.string_types) or
+                   is_iterable(field) and len(field) == 2  # may need to be wrapped in parens
+                   for field in fields), \
+            "'fields' must contain strings or (field name, param name) pairs."
+
+        return OrderedDict([
+            (f, f) if isinstance(f, six.string_types) else f for f in fields
+        ])
+
+    def build_choices(self, fields, labels):
+        ascending = [
+            (param, labels.get(field, pretty_name(param)))
+            for field, param in fields.items()
+        ]
+        descending = [
+            ('-%s' % pair[0], self.descending_fmt % pair[1])
+            for pair in ascending
+        ]
+
+        # interleave the ascending and descending choices
+        return [val for pair in zip(ascending, descending) for val in pair]
+
+
 class MethodFilter(Filter):
     """
     This filter will allow you to run a method that exists on the filterset class
diff --git a/django_filters/filterset.py b/django_filters/filterset.py
--- a/django_filters/filterset.py
+++ b/django_filters/filterset.py
@@ -2,12 +2,10 @@
 from __future__ import unicode_literals
 
 import copy
-import re
 from collections import OrderedDict
 
 from django import forms
 from django.forms.forms import NON_FIELD_ERRORS
-from django.core.validators import EMPTY_VALUES
 from django.db import models
 from django.db.models.constants import LOOKUP_SEP
 from django.db.models.fields.related import ForeignObjectRel
@@ -19,8 +17,8 @@
 from .filters import (Filter, CharFilter, BooleanFilter, BaseInFilter, BaseRangeFilter,
                       ChoiceFilter, DateFilter, DateTimeFilter, TimeFilter, ModelChoiceFilter,
                       ModelMultipleChoiceFilter, NumberFilter, UUIDFilter,
-                      DurationFilter)
-from .utils import try_dbfield, get_all_model_fields, get_model_field, resolve_field, deprecate
+                      DurationFilter, OrderingFilter)
+from .utils import try_dbfield, get_all_model_fields, get_model_field, resolve_field, pretty_name, deprecate
 
 
 ORDER_BY_FIELD = 'o'
@@ -153,6 +151,12 @@ def __init__(self, options=None):
 
         self.filter_overrides = getattr(options, 'filter_overrides', {})
 
+        if hasattr(options, 'order_by'):
+            deprecate('Meta.order_by has been deprecated.', 1)
+
+        if hasattr(options, 'order_by_field'):
+            deprecate('Meta.order_by_field has been deprecated.', 1)
+
         self.order_by = getattr(options, 'order_by', False)
         self.order_by_field = getattr(options, 'order_by_field', ORDER_BY_FIELD)
 
@@ -192,6 +196,14 @@ def __new__(cls, name, bases, attrs):
             deprecate('filter_overrides has been moved to the Meta class.')
             new_class._meta.filter_overrides = new_class.filter_overrides
 
+        assert not hasattr(new_class, 'get_order_by'), \
+            'get_order_by() has been deprecated. Subclass OrderingFilter and override .filter() instead. ' \
+            'See: https://django-filter.readthedocs.io/en/latest/migration.html"'
+
+        assert not hasattr(new_class, 'get_ordering_field'), \
+            'get_ordering_field() has been deprecated. Use OrderingFilter instead. ' \
+            'See: https://django-filter.readthedocs.io/en/latest/migration.html"'
+
         # TODO: replace with deprecations
         # if opts.model and opts.fields:
         if opts.model:
@@ -205,6 +217,12 @@ def __new__(cls, name, bases, attrs):
             raise TypeError("Meta.fields contains a field that isn't defined "
                             "on this FilterSet: {}".format(not_defined))
 
+        # TODO: remove with deprecations
+        # check key existence instead of setdefault - prevents unnecessary filter construction
+        order_by_field = new_class._meta.order_by_field
+        if opts.order_by and order_by_field not in filters:
+            filters[order_by_field] = new_class.get_ordering_filter(opts, filters)
+
         new_class.declared_filters = declared_filters
         new_class.base_filters = filters
         return new_class
@@ -330,23 +348,6 @@ def qs(self):
                 if value is not None:  # valid & clean data
                     qs = filter_.filter(qs, value)
 
-            if self._meta.order_by:
-                order_field = self.form.fields[self._meta.order_by_field]
-                data = self.form[self._meta.order_by_field].data
-                ordered_value = None
-                try:
-                    ordered_value = order_field.clean(data)
-                except forms.ValidationError:
-                    pass
-
-                # With a None-queryset, ordering must be enforced (#84).
-                if (ordered_value in EMPTY_VALUES and
-                        self.strict == STRICTNESS.RETURN_NO_RESULTS):
-                    ordered_value = self.form.fields[self._meta.order_by_field].choices[0][0]
-
-                if ordered_value:
-                    qs = qs.order_by(*self.get_order_by(ordered_value))
-
             self._qs = qs
 
         return self._qs
@@ -357,7 +358,7 @@ def form(self):
             fields = OrderedDict([
                 (name, filter_.field)
                 for name, filter_ in six.iteritems(self.filters)])
-            fields[self._meta.order_by_field] = self.ordering_field
+
             Form = type(str('%sForm' % self.__class__.__name__),
                         (self._meta.form,), fields)
             if self._meta.together:
@@ -368,49 +369,48 @@ def form(self):
                 self._form = Form(prefix=self.form_prefix)
         return self._form
 
-    def get_ordering_field(self):
-        if self._meta.order_by:
-            if isinstance(self._meta.order_by, (list, tuple)):
-                if isinstance(self._meta.order_by[0], (list, tuple)):
-                    # e.g. (('field', 'Display name'), ...)
-                    choices = [(f[0], f[1]) for f in self._meta.order_by]
-                else:
-                    choices = []
-                    for f in self._meta.order_by:
-                        if f[0] == '-':
-                            label = _('%s (descending)' % capfirst(f[1:]))
-                        else:
-                            label = capfirst(f)
-                        choices.append((f, label))
+    @classmethod
+    def get_ordering_filter(cls, opts, filters):
+        assert not isinstance(opts.fields, dict), \
+            "'order_by' is not compatible with the 'fields' dict syntax. Use OrderingFilter instead."
+
+        def display_text(name, fltr):
+            """
+            ``name`` is the filter's attribute name on the FilterSet
+            ``text`` is the current display text, which is either the ``name``
+                     or an explicitly assigned label.
+            """
+            # TODO: use `fltr._label` in label-improvements branch
+            text = fltr.label or name.lstrip('-')
+            if name.startswith('-'):
+                text = _('%s (descending)' % text)
+
+            return pretty_name(text)
+
+        if isinstance(opts.order_by, (list, tuple)):
+
+            # e.g. (('field', 'Display name'), ...)
+            if isinstance(opts.order_by[0], (list, tuple)):
+                choices = [(f[0], f[1]) for f in opts.order_by]
+                fields = {filters.get(f[0].lstrip('-')).name: f[0] for f in opts.order_by}
+                return OrderingFilter(choices=choices, fields=fields)
+
+            # e.g. ('field1', 'field2', ...)
             else:
-                # add asc and desc field names
-                # use the filter's label if provided
-                choices = []
-                for f, fltr in self.filters.items():
-                    choices.extend([
-                        (f, fltr.label or capfirst(f)),
-                        ("-%s" % (f), _('%s (descending)' % (fltr.label or capfirst(f))))
-                    ])
-            return forms.ChoiceField(label=_("Ordering"), required=False,
-                                     choices=choices)
+                # (filter name, filter instance)
+                order_by = [(f, filters.get(f.lstrip('-'))) for f in opts.order_by]
 
-    @property
-    def ordering_field(self):
-        if not hasattr(self, '_ordering_field'):
-            self._ordering_field = self.get_ordering_field()
-        return self._ordering_field
-
-    def get_order_by(self, order_choice):
-        re_ordering_field = re.compile(r'(?P<inverse>\-?)(?P<field>.*)')
-        m = re.match(re_ordering_field, order_choice)
-        inverted = m.group('inverse')
-        filter_api_name = m.group('field')
-
-        _filter = self.filters.get(filter_api_name, None)
-
-        if _filter and filter_api_name != _filter.name:
-            return [inverted + _filter.name]
-        return [order_choice]
+                # preference filter label over attribute name
+                choices = [(f, display_text(f, fltr)) for f, fltr in order_by]
+                fields = {fltr.name: f for f, fltr in order_by}
+                return OrderingFilter(choices=choices, fields=fields)
+
+        # opts.order_by = True
+        order_by = filters.items()
+
+        fields = [(fltr.name, f) for f, fltr in order_by]
+        labels = {f: display_text(f, fltr) for f, fltr in order_by}
+        return OrderingFilter(fields=fields, field_labels=labels)
 
     @classmethod
     def filters_for_model(cls, model, opts):
diff --git a/django_filters/utils.py b/django_filters/utils.py
--- a/django_filters/utils.py
+++ b/django_filters/utils.py
@@ -9,6 +9,11 @@
 from django.db.models.fields.related import ForeignObjectRel
 from django.utils import six, timezone
 
+try:
+    from django.forms.utils import pretty_name
+except ImportError:  # Django 1.8
+    from django.forms.forms import pretty_name
+
 from .compat import remote_field, remote_model
 from .exceptions import FieldLookupError
 


Test patch:
diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py
--- a/tests/test_deprecations.py
+++ b/tests/test_deprecations.py
@@ -2,10 +2,12 @@
 import functools
 import warnings
 import mock
+from django.core.exceptions import ValidationError
 from django.test import TestCase
 
 from django_filters import FilterSet
 from django_filters.filters import Filter, CharFilter, MethodFilter
+from django_filters.filterset import STRICTNESS
 from .models import User
 from .models import NetworkSetting
 from .models import SubnetMaskField
@@ -366,3 +368,327 @@ class F(FilterSet):
                 order_by_field = 'field'
 
             self.assertEqual(F._meta.order_by_field, 'field')
+
+
+class OrderByDeprecationTests(TestCase):
+    def test_order_by_notification(self):
+        with warnings.catch_warnings(record=True) as w:
+            warnings.simplefilter("always")
+
+            class F(FilterSet):
+                class Meta:
+                    order_by = True
+
+            self.assertEqual(len(w), 1)
+            self.assertTrue(issubclass(w[-1].category, DeprecationWarning))
+
+    def test_order_by_field_notification(self):
+        with warnings.catch_warnings(record=True) as w:
+            warnings.simplefilter("always")
+
+            class F(FilterSet):
+                class Meta:
+                    order_by_field = 'field'
+
+            self.assertEqual(len(w), 1)
+            self.assertTrue(issubclass(w[-1].category, DeprecationWarning))
+
+    def test_get_order_by_assertion(self):
+        with self.assertRaises(AssertionError):
+            class F(FilterSet):
+                def get_order_by(self):
+                    pass
+
+    def test_get_ordering_field_assertion(self):
+        with self.assertRaises(AssertionError):
+            class F(FilterSet):
+                def get_ordering_field(self):
+                    pass
+
+
+class DeprecatedOrderingFilterSetTests(TestCase):
+    def setUp(self):
+        self.alex = User.objects.create(username='alex', status=1)
+        self.jacob = User.objects.create(username='jacob', status=2)
+        self.aaron = User.objects.create(username='aaron', status=2)
+        self.carl = User.objects.create(username='carl', status=0)
+        self.qs = User.objects.all().order_by('id')
+
+    # old filterset tests
+    @silence
+    def test_ordering(self):
+        class F(FilterSet):
+            class Meta:
+                model = User
+                fields = ['username', 'status']
+                order_by = ['username', 'status']
+
+        f = F({'o': 'username'}, queryset=self.qs)
+        self.assertQuerysetEqual(
+            f.qs, ['aaron', 'alex', 'carl', 'jacob'], lambda o: o.username)
+
+        f = F({'o': 'status'}, queryset=self.qs)
+        self.assertQuerysetEqual(
+            f.qs, ['carl', 'alex', 'jacob', 'aaron'], lambda o: o.username)
+
+    @silence
+    def test_ordering_on_unknown_value(self):
+        class F(FilterSet):
+            class Meta:
+                model = User
+                fields = ['username', 'status']
+                order_by = ['status']
+
+        f = F({'o': 'username'}, queryset=self.qs)
+        self.assertQuerysetEqual(
+            f.qs, [], lambda o: o.username)
+
+    @silence
+    def test_ordering_on_unknown_value_results_in_default_ordering_without_strict(self):
+        class F(FilterSet):
+            class Meta:
+                model = User
+                fields = ['username', 'status']
+                order_by = ['status']
+                strict = STRICTNESS.IGNORE
+
+        self.assertFalse(F._meta.strict)
+        f = F({'o': 'username'}, queryset=self.qs)
+        self.assertQuerysetEqual(
+            f.qs, ['alex', 'jacob', 'aaron', 'carl'], lambda o: o.username)
+
+    @silence
+    def test_ordering_on_unknown_value_results_in_default_ordering_with_strict_raise(self):
+        class F(FilterSet):
+            class Meta:
+                model = User
+                fields = ['username', 'status']
+                order_by = ['status']
+                strict = STRICTNESS.RAISE_VALIDATION_ERROR
+
+        f = F({'o': 'username'}, queryset=self.qs)
+        with self.assertRaises(ValidationError) as excinfo:
+            f.qs.all()
+        self.assertEqual(excinfo.exception.message_dict,
+                         {'o': ['Select a valid choice. username is not one '
+                                'of the available choices.']})
+
+        # No default order_by should get applied.
+        f = F({}, queryset=self.qs)
+        self.assertQuerysetEqual(
+            f.qs, ['alex', 'jacob', 'aaron', 'carl'], lambda o: o.username)
+
+    @silence
+    def test_ordering_on_different_field(self):
+        class F(FilterSet):
+            class Meta:
+                model = User
+                fields = ['username', 'status']
+                order_by = True
+
+        f = F({'o': 'username'}, queryset=self.qs)
+        self.assertQuerysetEqual(
+            f.qs, ['aaron', 'alex', 'carl', 'jacob'], lambda o: o.username)
+
+        f = F({'o': 'status'}, queryset=self.qs)
+        self.assertQuerysetEqual(
+            f.qs, ['carl', 'alex', 'jacob', 'aaron'], lambda o: o.username)
+
+    @silence
+    def test_ordering_uses_filter_name(self):
+        class F(FilterSet):
+            account = CharFilter(name='username')
+
+            class Meta:
+                model = User
+                fields = ['account', 'status']
+                order_by = True
+
+        f = F({'o': 'account'}, queryset=self.qs)
+        self.assertQuerysetEqual(
+            f.qs, ['aaron', 'alex', 'carl', 'jacob'], lambda o: o.username)
+
+    @silence
+    def test_reverted_ordering_uses_filter_name(self):
+        class F(FilterSet):
+            account = CharFilter(name='username')
+
+            class Meta:
+                model = User
+                fields = ['account', 'status']
+                order_by = True
+
+        f = F({'o': '-account'}, queryset=self.qs)
+        self.assertQuerysetEqual(
+            f.qs, ['jacob', 'carl', 'alex', 'aaron'], lambda o: o.username)
+
+    @silence
+    def test_ordering_with_overridden_field_name(self):
+        """
+        Set the `order_by_field` on the filterset and ensure that the
+        field name is respected.
+        """
+        class F(FilterSet):
+            class Meta:
+                model = User
+                fields = ['username', 'status']
+                order_by = ['status']
+                order_by_field = 'order'
+
+        f = F({'order': 'status'}, queryset=self.qs)
+        self.assertQuerysetEqual(
+            f.qs, ['carl', 'alex', 'jacob', 'aaron'], lambda o: o.username)
+
+    @silence
+    def test_ordering_descending_set(self):
+        class F(FilterSet):
+            class Meta:
+                model = User
+                fields = ['username', 'status']
+                order_by = ['username', '-username']
+
+        f = F({'o': '-username'}, queryset=self.qs)
+        self.assertQuerysetEqual(
+            f.qs, ['jacob', 'carl', 'alex', 'aaron'], lambda o: o.username)
+
+    @silence
+    def test_ordering_descending_unset(self):
+        """ Test ordering descending works when order_by=True. """
+        class F(FilterSet):
+            class Meta:
+                model = User
+                fields = ['username', 'status']
+                order_by = True
+
+        f = F({'o': '-username'}, queryset=self.qs)
+        self.assertQuerysetEqual(
+            f.qs, ['jacob', 'carl', 'alex', 'aaron'], lambda o: o.username)
+
+
+class DeprecatedOrderingFormTests(TestCase):
+    @silence
+    def test_ordering(self):
+        class F(FilterSet):
+            class Meta:
+                model = User
+                fields = ['username', 'status']
+                order_by = ['status']
+
+        f = F().form
+        self.assertEqual(len(f.fields), 3)
+        self.assertIn('o', f.fields)
+        self.assertEqual(f.fields['o'].choices, [('status', 'Status')])
+
+    @silence
+    def test_ordering_uses_all_fields(self):
+        class F(FilterSet):
+            class Meta:
+                model = User
+                fields = ['username', 'status']
+                order_by = True
+
+        f = F().form
+        self.assertEqual(f.fields['o'].choices, [
+            ('username', 'Username'),
+            ('-username', 'Username (descending)'),
+            ('status', 'Status'),
+            ('-status', 'Status (descending)')])
+
+    @silence
+    def test_ordering_uses_filter_label(self):
+        class F(FilterSet):
+            username = CharFilter(label='Account')
+
+            class Meta:
+                model = User
+                fields = ['username', 'status']
+                order_by = True
+
+        f = F().form
+        self.assertEqual(f.fields['o'].choices, [
+            ('username', 'Account'),
+            ('-username', 'Account (descending)'),
+            ('status', 'Status'),
+            ('-status', 'Status (descending)')])
+
+    @silence
+    def test_ordering_uses_explicit_filter_name(self):
+        class F(FilterSet):
+            account = CharFilter(name='username')
+
+            class Meta:
+                model = User
+                fields = ['account', 'status']
+                order_by = True
+
+        f = F().form
+        self.assertEqual(f.fields['o'].choices, [
+            ('account', 'Account'),
+            ('-account', 'Account (descending)'),
+            ('status', 'Status'),
+            ('-status', 'Status (descending)')])
+
+    @silence
+    def test_ordering_with_overridden_field_name(self):
+        """
+        Set the `order_by_field` on the filterset and ensure that the
+        field name is respected.
+        """
+        class F(FilterSet):
+            class Meta:
+                model = User
+                fields = ['username', 'status']
+                order_by = ['status']
+                order_by_field = 'order'
+
+        f = F().form
+        self.assertNotIn('o', f.fields)
+        self.assertIn('order', f.fields)
+        self.assertEqual(f.fields['order'].choices, [('status', 'Status')])
+
+    @silence
+    def test_ordering_with_overridden_field_name_and_descending(self):
+        """
+        Set the `order_by_field` on the filterset and ensure that the
+        field name is respected.
+        """
+        class F(FilterSet):
+            class Meta:
+                model = User
+                fields = ['username', 'status']
+                order_by = ['status', '-status']
+                order_by_field = 'order'
+
+        f = F().form
+        self.assertNotIn('o', f.fields)
+        self.assertIn('order', f.fields)
+        self.assertEqual(f.fields['order'].choices, [('status', 'Status'), ('-status', 'Status (descending)')])
+
+    @silence
+    def test_ordering_with_overridden_field_name_and_using_all_fields(self):
+        class F(FilterSet):
+            class Meta:
+                model = User
+                fields = ['username', 'status']
+                order_by = True
+                order_by_field = 'order'
+
+        f = F().form
+        self.assertIn('order', f.fields)
+        self.assertEqual(f.fields['order'].choices, [
+            ('username', 'Username'),
+            ('-username', 'Username (descending)'),
+            ('status', 'Status'),
+            ('-status', 'Status (descending)')])
+
+    @silence
+    def test_ordering_with_custom_display_names(self):
+        class F(FilterSet):
+            class Meta:
+                model = User
+                fields = ['username', 'status']
+                order_by = [('status', 'Current status')]
+
+        f = F().form
+        self.assertEqual(
+            f.fields['o'].choices, [('status', 'Current status')])
diff --git a/tests/test_filters.py b/tests/test_filters.py
--- a/tests/test_filters.py
+++ b/tests/test_filters.py
@@ -1,6 +1,7 @@
 from __future__ import absolute_import
 from __future__ import unicode_literals
 
+from collections import OrderedDict
 from datetime import date, time, timedelta, datetime
 import mock
 import warnings
@@ -41,6 +42,7 @@
     BaseInFilter,
     BaseRangeFilter,
     UUIDFilter,
+    OrderingFilter,
     LOOKUP_TYPES)
 
 from tests.models import Book, User
@@ -994,3 +996,104 @@ class NumberInFilter(BaseRangeFilter, NumberFilter):
         f = NumberInFilter()
         f.filter(qs, [1, 2])
         qs.filter.assert_called_once_with(None__range=[1, 2])
+
+
+class OrderingFilterTests(TestCase):
+    def test_default_field(self):
+        f = OrderingFilter()
+        field = f.field
+        self.assertIsInstance(field, forms.ChoiceField)
+
+    def test_filtering(self):
+        qs = mock.Mock(spec=['order_by'])
+        f = OrderingFilter()
+        f.filter(qs, ['a', 'b'])
+        qs.order_by.assert_called_once_with('a', 'b')
+
+    def test_filtering_descending(self):
+        qs = mock.Mock(spec=['order_by'])
+        f = OrderingFilter()
+        f.filter(qs, ['-a'])
+        qs.order_by.assert_called_once_with('-a')
+
+    def test_filtering_with_fields(self):
+        qs = mock.Mock(spec=['order_by'])
+        f = OrderingFilter(fields={'a': 'b'})
+        f.filter(qs, ['b', '-b'])
+        qs.order_by.assert_called_once_with('a', '-a')
+
+    def test_filtering_skipped_with_none_value(self):
+        qs = mock.Mock(spec=['order_by'])
+        f = OrderingFilter()
+        result = f.filter(qs, None)
+        self.assertEqual(qs, result)
+
+    def test_choices_unaltered(self):
+        # provided 'choices' should not be altered when 'fields' is present
+        f = OrderingFilter(
+            choices=(('a', 'A'), ('b', 'B')),
+            fields=(('a', 'c'), ('b', 'd')),
+        )
+
+        self.assertSequenceEqual(f.field.choices, (
+            ('a', 'A'),
+            ('b', 'B'),
+        ))
+
+    def test_choices_from_fields(self):
+        f = OrderingFilter(
+            fields=(('a', 'c'), ('b', 'd')),
+        )
+
+        self.assertSequenceEqual(f.field.choices, (
+            ('c', 'C'),
+            ('-c', 'C (descending)'),
+            ('d', 'D'),
+            ('-d', 'D (descending)'),
+        ))
+
+    def test_field_labels(self):
+        f = OrderingFilter(
+            fields=(('a', 'c'), ('b', 'd')),
+            field_labels={'a': 'foo'},
+        )
+
+        self.assertSequenceEqual(f.field.choices, (
+            ('c', 'foo'),
+            ('-c', 'foo (descending)'),
+            ('d', 'D'),
+            ('-d', 'D (descending)'),
+        ))
+
+    def test_normalize_fields(self):
+        f = OrderingFilter.normalize_fields
+        O = OrderedDict
+
+        self.assertIn('a', f({'a': 'b'}))
+
+        self.assertEqual(
+            f(O([('a', 'b'), ('c', 'd')])),
+            O([('a', 'b'), ('c', 'd')])
+        )
+
+        self.assertEqual(
+            f([('a', 'b'), ('c', 'd')]),
+            O([('a', 'b'), ('c', 'd')])
+        )
+
+        self.assertEqual(
+            f(['a', 'b']),
+            O([('a', 'a'), ('b', 'b')])
+        )
+
+        with self.assertRaises(AssertionError) as ctx:
+            f(None)
+        self.assertEqual(str(ctx.exception), "'fields' must be an iterable (e.g., a list, tuple, or mapping).")
+
+        with self.assertRaises(AssertionError) as ctx:
+            f([('a', 'b', 'c')])
+        self.assertEqual(str(ctx.exception), "'fields' must contain strings or (field name, param name) pairs.")
+
+        with self.assertRaises(AssertionError) as ctx:
+            f([0, 1, 2])
+        self.assertEqual(str(ctx.exception), "'fields' must contain strings or (field name, param name) pairs.")
diff --git a/tests/test_filterset.py b/tests/test_filterset.py
--- a/tests/test_filterset.py
+++ b/tests/test_filterset.py
@@ -4,13 +4,11 @@
 import unittest
 
 import django
-from django.core.exceptions import ValidationError
 from django.db import models
 from django.test import TestCase
 
 from django_filters.filterset import FilterSet
 from django_filters.filterset import FILTER_FOR_DBFIELD_DEFAULTS
-from django_filters.filterset import STRICTNESS
 from django_filters.filters import Filter
 from django_filters.filters import BooleanFilter
 from django_filters.filters import CharFilter
@@ -565,190 +563,6 @@ class Meta:
         self.assertEqual(f.queryset, m)
 
 
-class FilterSetOrderingTests(TestCase):
-
-    def setUp(self):
-        self.alex = User.objects.create(username='alex', status=1)
-        self.jacob = User.objects.create(username='jacob', status=2)
-        self.aaron = User.objects.create(username='aaron', status=2)
-        self.carl = User.objects.create(username='carl', status=0)
-        # user_ids = list(User.objects.all().values_list('pk', flat=True))
-        self.qs = User.objects.all().order_by('id')
-
-    def test_ordering_when_unbound(self):
-        class F(FilterSet):
-            class Meta:
-                model = User
-                fields = ['username', 'status']
-                order_by = ['status']
-
-        f = F(queryset=self.qs)
-        self.assertQuerysetEqual(
-            f.qs, ['carl', 'alex', 'jacob', 'aaron'], lambda o: o.username)
-
-    def test_ordering(self):
-        class F(FilterSet):
-            class Meta:
-                model = User
-                fields = ['username', 'status']
-                order_by = ['username', 'status']
-
-        f = F({'o': 'username'}, queryset=self.qs)
-        self.assertQuerysetEqual(
-            f.qs, ['aaron', 'alex', 'carl', 'jacob'], lambda o: o.username)
-
-        f = F({'o': 'status'}, queryset=self.qs)
-        self.assertQuerysetEqual(
-            f.qs, ['carl', 'alex', 'jacob', 'aaron'], lambda o: o.username)
-
-    def test_ordering_on_unknown_value(self):
-        class F(FilterSet):
-            class Meta:
-                model = User
-                fields = ['username', 'status']
-                order_by = ['status']
-
-        f = F({'o': 'username'}, queryset=self.qs)
-        self.assertQuerysetEqual(
-            f.qs, [], lambda o: o.username)
-
-    def test_ordering_on_unknown_value_results_in_default_ordering_without_strict(self):
-        class F(FilterSet):
-            class Meta:
-                model = User
-                fields = ['username', 'status']
-                order_by = ['status']
-                strict = STRICTNESS.IGNORE
-
-        self.assertFalse(F._meta.strict)
-        f = F({'o': 'username'}, queryset=self.qs)
-        self.assertQuerysetEqual(
-            f.qs, ['alex', 'jacob', 'aaron', 'carl'], lambda o: o.username)
-
-    def test_ordering_on_unknown_value_results_in_default_ordering_with_strict_raise(self):
-        class F(FilterSet):
-            class Meta:
-                model = User
-                fields = ['username', 'status']
-                order_by = ['status']
-                strict = STRICTNESS.RAISE_VALIDATION_ERROR
-
-        f = F({'o': 'username'}, queryset=self.qs)
-        with self.assertRaises(ValidationError) as excinfo:
-            f.qs.all()
-        self.assertEqual(excinfo.exception.message_dict,
-                         {'o': ['Select a valid choice. username is not one '
-                                'of the available choices.']})
-
-        # No default order_by should get applied.
-        f = F({}, queryset=self.qs)
-        self.assertQuerysetEqual(
-            f.qs, ['alex', 'jacob', 'aaron', 'carl'], lambda o: o.username)
-
-    def test_ordering_on_different_field(self):
-        class F(FilterSet):
-            class Meta:
-                model = User
-                fields = ['username', 'status']
-                order_by = True
-
-        f = F({'o': 'username'}, queryset=self.qs)
-        self.assertQuerysetEqual(
-            f.qs, ['aaron', 'alex', 'carl', 'jacob'], lambda o: o.username)
-
-        f = F({'o': 'status'}, queryset=self.qs)
-        self.assertQuerysetEqual(
-            f.qs, ['carl', 'alex', 'jacob', 'aaron'], lambda o: o.username)
-
-    def test_ordering_uses_filter_name(self):
-        class F(FilterSet):
-            account = CharFilter(name='username')
-
-            class Meta:
-                model = User
-                fields = ['account', 'status']
-                order_by = True
-
-        f = F({'o': 'account'}, queryset=self.qs)
-        self.assertQuerysetEqual(
-            f.qs, ['aaron', 'alex', 'carl', 'jacob'], lambda o: o.username)
-
-    def test_reverted_ordering_uses_filter_name(self):
-        class F(FilterSet):
-            account = CharFilter(name='username')
-
-            class Meta:
-                model = User
-                fields = ['account', 'status']
-                order_by = True
-
-        f = F({'o': '-account'}, queryset=self.qs)
-        self.assertQuerysetEqual(
-            f.qs, ['jacob', 'carl', 'alex', 'aaron'], lambda o: o.username)
-
-    def test_ordering_with_overridden_field_name(self):
-        """
-        Set the `order_by_field` on the filterset and ensure that the
-        field name is respected.
-        """
-        class F(FilterSet):
-            class Meta:
-                model = User
-                fields = ['username', 'status']
-                order_by = ['status']
-                order_by_field = 'order'
-
-        f = F({'order': 'status'}, queryset=self.qs)
-        self.assertQuerysetEqual(
-            f.qs, ['carl', 'alex', 'jacob', 'aaron'], lambda o: o.username)
-
-    def test_ordering_descending_set(self):
-        class F(FilterSet):
-            class Meta:
-                model = User
-                fields = ['username', 'status']
-                order_by = ['username', '-username']
-
-        f = F({'o': '-username'}, queryset=self.qs)
-        self.assertQuerysetEqual(
-            f.qs, ['jacob', 'carl', 'alex', 'aaron'], lambda o: o.username)
-
-    def test_ordering_descending_unset(self):
-        """ Test ordering descending works when order_by=True. """
-        class F(FilterSet):
-            class Meta:
-                model = User
-                fields = ['username', 'status']
-                order_by = True
-
-        f = F({'o': '-username'}, queryset=self.qs)
-        self.assertQuerysetEqual(
-            f.qs, ['jacob', 'carl', 'alex', 'aaron'], lambda o: o.username)
-
-    def test_custom_ordering(self):
-
-        class F(FilterSet):
-            debug = True
-
-            class Meta:
-                model = User
-                fields = ['username', 'status']
-                order_by = ['username', 'status']
-
-            def get_order_by(self, order_choice):
-                if order_choice == 'status':
-                    return ['status', 'username']
-                return super(F, self).get_order_by(order_choice)
-
-        f = F({'o': 'username'}, queryset=self.qs)
-        self.assertQuerysetEqual(
-            f.qs, ['aaron', 'alex', 'carl', 'jacob'], lambda o: o.username)
-
-        f = F({'o': 'status'}, queryset=self.qs)
-        self.assertQuerysetEqual(
-            f.qs, ['carl', 'alex', 'aaron', 'jacob'], lambda o: o.username)
-
-
 class FilterSetTogetherTests(TestCase):
 
     def setUp(self):
diff --git a/tests/test_forms.py b/tests/test_forms.py
--- a/tests/test_forms.py
+++ b/tests/test_forms.py
@@ -164,125 +164,6 @@ class Meta:
         self.assertTrue(f.is_bound)
         self.assertEqual(f.data, {'title': 'Some book'})
 
-    def test_ordering(self):
-        class F(FilterSet):
-            class Meta:
-                model = User
-                fields = ['username', 'status']
-                order_by = ['status']
-
-        f = F().form
-        self.assertEqual(len(f.fields), 3)
-        self.assertIn('o', f.fields)
-        self.assertEqual(f.fields['o'].choices, [('status', 'Status')])
-
-    def test_ordering_uses_all_fields(self):
-        class F(FilterSet):
-            class Meta:
-                model = User
-                fields = ['username', 'status']
-                order_by = True
-
-        f = F().form
-        self.assertEqual(f.fields['o'].choices, [
-            ('username', 'Username'),
-            ('-username', 'Username (descending)'),
-            ('status', 'Status'),
-            ('-status', 'Status (descending)')])
-
-    def test_ordering_uses_filter_label(self):
-        class F(FilterSet):
-            username = CharFilter(label='Account')
-
-            class Meta:
-                model = User
-                fields = ['username', 'status']
-                order_by = True
-
-        f = F().form
-        self.assertEqual(f.fields['o'].choices, [
-            ('username', 'Account'),
-            ('-username', 'Account (descending)'),
-            ('status', 'Status'),
-            ('-status', 'Status (descending)')])
-
-    def test_ordering_uses_explicit_filter_name(self):
-        class F(FilterSet):
-            account = CharFilter(name='username')
-
-            class Meta:
-                model = User
-                fields = ['account', 'status']
-                order_by = True
-
-        f = F().form
-        self.assertEqual(f.fields['o'].choices, [
-            ('account', 'Account'),
-            ('-account', 'Account (descending)'),
-            ('status', 'Status'),
-            ('-status', 'Status (descending)')])
-
-    def test_ordering_with_overridden_field_name(self):
-        """
-        Set the `order_by_field` on the filterset and ensure that the
-        field name is respected.
-        """
-        class F(FilterSet):
-            class Meta:
-                model = User
-                fields = ['username', 'status']
-                order_by = ['status']
-                order_by_field = 'order'
-
-        f = F().form
-        self.assertNotIn('o', f.fields)
-        self.assertIn('order', f.fields)
-        self.assertEqual(f.fields['order'].choices, [('status', 'Status')])
-
-    def test_ordering_with_overridden_field_name_and_descending(self):
-        """
-        Set the `order_by_field` on the filterset and ensure that the
-        field name is respected.
-        """
-        class F(FilterSet):
-            class Meta:
-                model = User
-                fields = ['username', 'status']
-                order_by = ['status', '-status']
-                order_by_field = 'order'
-
-        f = F().form
-        self.assertNotIn('o', f.fields)
-        self.assertIn('order', f.fields)
-        self.assertEqual(f.fields['order'].choices, [('status', 'Status'), ('-status', 'Status (descending)')])
-
-    def test_ordering_with_overridden_field_name_and_using_all_fields(self):
-        class F(FilterSet):
-            class Meta:
-                model = User
-                fields = ['username', 'status']
-                order_by = True
-                order_by_field = 'order'
-
-        f = F().form
-        self.assertIn('order', f.fields)
-        self.assertEqual(f.fields['order'].choices, [
-            ('username', 'Username'),
-            ('-username', 'Username (descending)'),
-            ('status', 'Status'),
-            ('-status', 'Status (descending)')])
-
-    def test_ordering_with_custom_display_names(self):
-        class F(FilterSet):
-            class Meta:
-                model = User
-                fields = ['username', 'status']
-                order_by = [('status', 'Current status')]
-
-        f = F().form
-        self.assertEqual(
-            f.fields['o'].choices, [('status', 'Current status')])
-
     def test_limit_choices_to(self):
         User.objects.create(username='inactive', is_active=False, status=REGULAR)
         User.objects.create(username='active', is_active=True, status=REGULAR)