From ef2512b2ffdb719e5c0fb82142f9ce8478282823 Mon Sep 17 00:00:00 2001
From: Alexander Gaevsky <sasha@sasha0.ru>
Date: Tue, 9 Feb 2016 02:35:03 +0200
Subject: [PATCH] Fixed #25790 -- Allowed disable column sorting in the admin
 changelist.

Thanks Ramiro Morales for completing the patch.
---
 django/contrib/admin/options.py               |  7 ++++
 .../contrib/admin/templatetags/admin_list.py  | 18 +++++----
 django/contrib/admin/views/main.py            |  3 +-
 docs/ref/contrib/admin/index.txt              | 34 ++++++++++++++++
 docs/releases/2.1.txt                         |  4 ++
 tests/admin_views/admin.py                    | 40 +++++++++++++++++++
 tests/admin_views/tests.py                    | 30 ++++++++++++++
 tests/admin_views/urls.py                     |  1 +
 8 files changed, 129 insertions(+), 8 deletions(-)

diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
index 268ce012d1..de28525b78 100644
--- a/django/contrib/admin/options.py
+++ b/django/contrib/admin/options.py
@@ -111,6 +111,7 @@ class BaseModelAdmin(metaclass=forms.MediaDefiningClass):
     formfield_overrides = {}
     readonly_fields = ()
     ordering = None
+    sortable_by = None
     view_on_site = True
     show_full_result_count = True
     checks_class = BaseModelAdminChecks
@@ -353,6 +354,10 @@ class BaseModelAdmin(metaclass=forms.MediaDefiningClass):
             qs = qs.order_by(*ordering)
         return qs
 
+    def get_sortable_by(self, request):
+        """Hook for specifying which fields can be sorted in the changelist."""
+        return self.sortable_by if self.sortable_by is not None else self.get_list_display(request)
+
     def lookup_allowed(self, lookup, value):
         from django.contrib.admin.filters import SimpleListFilter
 
@@ -688,6 +693,7 @@ class ModelAdmin(BaseModelAdmin):
         # Add the action checkboxes if any actions are available.
         if self.get_actions(request):
             list_display = ['action_checkbox'] + list(list_display)
+        sortable_by = self.get_sortable_by(request)
         ChangeList = self.get_changelist(request)
         return ChangeList(
             request,
@@ -702,6 +708,7 @@ class ModelAdmin(BaseModelAdmin):
             self.list_max_show_all,
             self.list_editable,
             self,
+            sortable_by,
         )
 
     def get_object(self, request, object_id, from_field=None):
diff --git a/django/contrib/admin/templatetags/admin_list.py b/django/contrib/admin/templatetags/admin_list.py
index ab0db80301..604021c55d 100644
--- a/django/contrib/admin/templatetags/admin_list.py
+++ b/django/contrib/admin/templatetags/admin_list.py
@@ -100,6 +100,7 @@ def result_headers(cl):
             model_admin=cl.model_admin,
             return_attr=True
         )
+        is_field_sortable = cl.sortable_by is None or field_name in cl.sortable_by
         if attr:
             field_name = _coerce_field_name(field_name, i)
             # Potentially not sortable
@@ -115,13 +116,16 @@ def result_headers(cl):
 
             admin_order_field = getattr(attr, "admin_order_field", None)
             if not admin_order_field:
-                # Not sortable
-                yield {
-                    "text": text,
-                    "class_attrib": format_html(' class="column-{}"', field_name),
-                    "sortable": False,
-                }
-                continue
+                is_field_sortable = False
+
+        if not is_field_sortable:
+            # Not sortable
+            yield {
+                'text': text,
+                'class_attrib': format_html(' class="column-{}"', field_name),
+                'sortable': False,
+            }
+            continue
 
         # OK, it is sortable if we got this far
         th_classes = ['sortable', 'column-{}'.format(field_name)]
diff --git a/django/contrib/admin/views/main.py b/django/contrib/admin/views/main.py
index 086e66e3b7..5141b9e2f1 100644
--- a/django/contrib/admin/views/main.py
+++ b/django/contrib/admin/views/main.py
@@ -35,7 +35,7 @@ IGNORED_PARAMS = (
 class ChangeList:
     def __init__(self, request, model, list_display, list_display_links,
                  list_filter, date_hierarchy, search_fields, list_select_related,
-                 list_per_page, list_max_show_all, list_editable, model_admin):
+                 list_per_page, list_max_show_all, list_editable, model_admin, sortable_by):
         self.model = model
         self.opts = model._meta
         self.lookup_opts = self.opts
@@ -50,6 +50,7 @@ class ChangeList:
         self.list_max_show_all = list_max_show_all
         self.model_admin = model_admin
         self.preserved_filters = model_admin.get_preserved_filters(request)
+        self.sortable_by = sortable_by
 
         # Get search parameters from the query string.
         try:
diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt
index 9a2edb698c..a917498aed 100644
--- a/docs/ref/contrib/admin/index.txt
+++ b/docs/ref/contrib/admin/index.txt
@@ -1296,6 +1296,22 @@ subclass::
     a full count on the table which can be expensive if the table contains a
     large number of rows.
 
+.. attribute:: ModelAdmin.sortable_by
+
+    .. versionadded:: 2.1
+
+    By default, the change list page allows sorting by all model fields (and
+    callables that have the ``admin_order_field`` property) specified in
+    :attr:`list_display`.
+
+    If you want to disable sorting for some columns, set ``sortable_by`` to
+    a collection (e.g. ``list``, ``tuple``, or ``set``) of the subset of
+    :attr:`list_display` that you want to be sortable. An empty collection
+    disables sorting for all columns.
+
+    If you need to specify this list dynamically, implement a
+    :meth:`~ModelAdmin.get_sortable_by` method instead.
+
 .. attribute:: ModelAdmin.view_on_site
 
     Set ``view_on_site`` to control whether or not to display the "View on site" link.
@@ -1564,6 +1580,24 @@ templates used by the :class:`ModelAdmin` views:
     to return the same kind of sequence type as for the
     :attr:`~ModelAdmin.search_fields` attribute.
 
+.. method:: ModelAdmin.get_sortable_by(request)
+
+    .. versionadded:: 2.1
+
+    The ``get_sortable_by()`` method is passed the ``HttpRequest`` and is
+    expected to return a collection (e.g. ``list``, ``tuple``, or ``set``) of
+    field names that will be sortable in the change list page.
+
+    Its default implementation returns :attr:`sortable_by` if it's set,
+    otherwise it defers to :meth:`get_list_display`.
+
+    For example, to prevent one or more columns from being sortable::
+
+        class PersonAdmin(admin.ModelAdmin):
+
+            def get_sortable_by(self, request):
+                return {*self.get_list_display(request)} - {'rank'}
+
 .. method:: ModelAdmin.get_inline_instances(request, obj=None)
 
     The ``get_inline_instances`` method is given the ``HttpRequest`` and the
diff --git a/docs/releases/2.1.txt b/docs/releases/2.1.txt
index 2fba791ed9..52519170db 100644
--- a/docs/releases/2.1.txt
+++ b/docs/releases/2.1.txt
@@ -43,6 +43,10 @@ Minor features
 * You can now :ref:`override the the default admin site
   <overriding-default-admin-site>`.
 
+* The new :attr:`.ModelAdmin.sortable_by` attribute and
+  :meth:`.ModelAdmin.get_sortable_by` method allow limiting the columns that
+  can be sorted in the change list page.
+
 :mod:`django.contrib.admindocs`
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
diff --git a/tests/admin_views/admin.py b/tests/admin_views/admin.py
index 3cfefb74e4..04e40c2e0d 100644
--- a/tests/admin_views/admin.py
+++ b/tests/admin_views/admin.py
@@ -1069,3 +1069,43 @@ site2.register(Person, save_as_continue=False)
 site7 = admin.AdminSite(name="admin7")
 site7.register(Article, ArticleAdmin2)
 site7.register(Section)
+
+
+# Used to test ModelAdmin.sortable_by and get_sortable_by().
+class ArticleAdmin6(admin.ModelAdmin):
+    list_display = (
+        'content', 'date', callable_year, 'model_year', 'modeladmin_year',
+        'model_year_reversed', 'section',
+    )
+    sortable_by = ('date', callable_year)
+
+    def modeladmin_year(self, obj):
+        return obj.date.year
+    modeladmin_year.admin_order_field = 'date'
+
+
+class ActorAdmin6(admin.ModelAdmin):
+    list_display = ('name', 'age')
+    sortable_by = ('name',)
+
+    def get_sortable_by(self, request):
+        return ('age',)
+
+
+class ChapterAdmin6(admin.ModelAdmin):
+    list_display = ('title', 'book')
+    sortable_by = ()
+
+
+class ColorAdmin6(admin.ModelAdmin):
+    list_display = ('value',)
+
+    def get_sortable_by(self, request):
+        return ()
+
+
+site6 = admin.AdminSite(name='admin6')
+site6.register(Article, ArticleAdmin6)
+site6.register(Actor, ActorAdmin6)
+site6.register(Chapter, ChapterAdmin6)
+site6.register(Color, ColorAdmin6)
diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py
index 3089307ba2..54a0590d21 100644
--- a/tests/admin_views/tests.py
+++ b/tests/admin_views/tests.py
@@ -131,6 +131,7 @@ class AdminViewBasicTestCase(TestCase):
         cls.chap4 = Chapter.objects.create(title='Chapter 2', content='[ insert contents here ]', book=cls.b2)
         cls.cx1 = ChapterXtra1.objects.create(chap=cls.chap1, xtra='ChapterXtra1 1')
         cls.cx2 = ChapterXtra1.objects.create(chap=cls.chap3, xtra='ChapterXtra1 2')
+        Actor.objects.create(name='Palin', age=27)
 
         # Post data for edit inline
         cls.inline_post_data = {
@@ -930,6 +931,35 @@ class AdminViewBasicTest(AdminViewBasicTestCase):
         self.assertContains(response, 'question__expires__month=10')
         self.assertContains(response, 'question__expires__year=2016')
 
+    def test_sortable_by_columns_subset(self):
+        expected_sortable_fields = ('date', 'callable_year')
+        expected_not_sortable_fields = (
+            'content', 'model_year', 'modeladmin_year', 'model_year_reversed',
+            'section',
+        )
+        response = self.client.get(reverse('admin6:admin_views_article_changelist'))
+        for field_name in expected_sortable_fields:
+            self.assertContains(response, '<th scope="col"  class="sortable column-%s">' % field_name)
+        for field_name in expected_not_sortable_fields:
+            self.assertContains(response, '<th scope="col"  class="column-%s">' % field_name)
+
+    def test_get_sortable_by_columns_subset(self):
+        response = self.client.get(reverse('admin6:admin_views_actor_changelist'))
+        self.assertContains(response, '<th scope="col"  class="sortable column-age">')
+        self.assertContains(response, '<th scope="col"  class="column-name">')
+
+    def test_sortable_by_no_column(self):
+        expected_not_sortable_fields = ('title', 'book')
+        response = self.client.get(reverse('admin6:admin_views_chapter_changelist'))
+        for field_name in expected_not_sortable_fields:
+            self.assertContains(response, '<th scope="col"  class="column-%s">' % field_name)
+        self.assertNotContains(response, '<th scope="col"  class="sortable column')
+
+    def test_get_sortable_by_no_column(self):
+        response = self.client.get(reverse('admin6:admin_views_color_changelist'))
+        self.assertContains(response, '<th scope="col"  class="column-value">')
+        self.assertNotContains(response, '<th scope="col"  class="sortable column')
+
 
 @override_settings(TEMPLATES=[{
     'BACKEND': 'django.template.backends.django.DjangoTemplates',
diff --git a/tests/admin_views/urls.py b/tests/admin_views/urls.py
index 9f8bdf5cba..c2d989b245 100644
--- a/tests/admin_views/urls.py
+++ b/tests/admin_views/urls.py
@@ -12,6 +12,7 @@ urlpatterns = [
     url(r'^test_admin/admin3/', (admin.site.get_urls(), 'admin', 'admin3'), {'form_url': 'pony'}),
     url(r'^test_admin/admin4/', customadmin.simple_site.urls),
     url(r'^test_admin/admin5/', admin.site2.urls),
+    url(r'^test_admin/admin6/', admin.site6.urls),
     url(r'^test_admin/admin7/', admin.site7.urls),
     # All admin views accept `extra_context` to allow adding it like this:
     url(r'^test_admin/admin8/', (admin.site.get_urls(), 'admin', 'admin-extra-context'), {'extra_context': {}}),