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 `. +* 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, '' % field_name) + for field_name in expected_not_sortable_fields: + self.assertContains(response, '' % field_name) + + def test_get_sortable_by_columns_subset(self): + response = self.client.get(reverse('admin6:admin_views_actor_changelist')) + self.assertContains(response, '') + self.assertContains(response, '') + + 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, '' % field_name) + self.assertNotContains(response, '') + self.assertNotContains(response, '