diff --git a/django/contrib/admin/templatetags/admin_list.py b/django/contrib/admin/templatetags/admin_list.py index 5c8d21d30d..f4b6d8d59f 100644 --- a/django/contrib/admin/templatetags/admin_list.py +++ b/django/contrib/admin/templatetags/admin_list.py @@ -24,16 +24,14 @@ from .base import InclusionAdminNode register = Library() -DOT = '.' - @register.simple_tag def paginator_number(cl, i): """ Generate an individual page index link in a paginated list. """ - if i == DOT: - return '… ' + if i == cl.paginator.ELLIPSIS: + return format_html('{} ', cl.paginator.ELLIPSIS) elif i == cl.page_num: return format_html('{} ', i) else: @@ -49,39 +47,8 @@ def pagination(cl): """ Generate the series of links to the pages in a paginated list. """ - paginator, page_num = cl.paginator, cl.page_num - pagination_required = (not cl.show_all or not cl.can_show_all) and cl.multi_page - if not pagination_required: - page_range = [] - else: - ON_EACH_SIDE = 3 - ON_ENDS = 2 - - # If there are 10 or fewer pages, display links to every page. - # Otherwise, do some fancy - if paginator.num_pages <= 10: - page_range = range(1, paginator.num_pages + 1) - else: - # Insert "smart" pagination links, so that there are always ON_ENDS - # links at either end of the list of pages, and there are always - # ON_EACH_SIDE links at either end of the "current page" link. - page_range = [] - if page_num > (1 + ON_EACH_SIDE + ON_ENDS) + 1: - page_range += [ - *range(1, ON_ENDS + 1), DOT, - *range(page_num - ON_EACH_SIDE, page_num + 1), - ] - else: - page_range.extend(range(1, page_num + 1)) - if page_num < (paginator.num_pages - ON_EACH_SIDE - ON_ENDS) - 1: - page_range += [ - *range(page_num + 1, page_num + ON_EACH_SIDE + 1), DOT, - *range(paginator.num_pages - ON_ENDS + 1, paginator.num_pages + 1) - ] - else: - page_range.extend(range(page_num + 1, paginator.num_pages + 1)) - + page_range = cl.paginator.get_elided_page_range(cl.page_num) if pagination_required else [] need_show_all_link = cl.can_show_all and not cl.show_all and cl.multi_page return { 'cl': cl, diff --git a/django/core/paginator.py b/django/core/paginator.py index ec0ebe0403..7db64913d9 100644 --- a/django/core/paginator.py +++ b/django/core/paginator.py @@ -25,6 +25,9 @@ class EmptyPage(InvalidPage): class Paginator: + # Translators: String used to replace omitted page numbers in elided page + # range generated by paginators, e.g. [1, 2, '…', 5, 6, 7, '…', 9, 10]. + ELLIPSIS = _('…') def __init__(self, object_list, per_page, orphans=0, allow_empty_first_page=True): @@ -128,6 +131,37 @@ class Paginator: stacklevel=3 ) + def get_elided_page_range(self, number=1, *, on_each_side=3, on_ends=2): + """ + Return a 1-based range of pages with some values elided. + + If the page range is larger than a given size, the whole range is not + provided and a compact form is returned instead, e.g. for a paginator + with 50 pages, if page 43 were the current page, the output, with the + default arguments, would be: + + 1, 2, …, 40, 41, 42, 43, 44, 45, 46, …, 49, 50. + """ + number = self.validate_number(number) + + if self.num_pages <= (on_each_side + on_ends) * 2: + yield from self.page_range + return + + if number > (1 + on_each_side + on_ends) + 1: + yield from range(1, on_ends + 1) + yield self.ELLIPSIS + yield from range(number - on_each_side, number + 1) + else: + yield from range(1, number + 1) + + if number < (self.num_pages - on_each_side - on_ends) - 1: + yield from range(number + 1, number + on_each_side + 1) + yield self.ELLIPSIS + yield from range(self.num_pages - on_ends + 1, self.num_pages + 1) + else: + yield from range(number + 1, self.num_pages + 1) + class Page(collections.abc.Sequence): diff --git a/docs/ref/paginator.txt b/docs/ref/paginator.txt index 877d6ba0d8..4cc5483828 100644 --- a/docs/ref/paginator.txt +++ b/docs/ref/paginator.txt @@ -78,9 +78,40 @@ Methods Returns a :class:`Page` object with the given 1-based index. Raises :exc:`InvalidPage` if the given page number doesn't exist. +.. method:: Paginator.get_elided_page_range(number, *, on_each_side=3, on_ends=2) + + .. versionadded:: 3.2 + + Returns a 1-based list of page numbers similar to + :attr:`Paginator.page_range`, but may add an ellipsis to either or both + sides of the current page number when :attr:`Paginator.num_pages` is large. + + The number of pages to include on each side of the current page number is + determined by the ``on_each_side`` argument which defaults to 3. + + The number of pages to include at the beginning and end of page range is + determined by the ``on_ends`` argument which defaults to 2. + + For example, with the default values for ``on_each_side`` and ``on_ends``, + if the current page is 10 and there are 50 pages, the page range will be + ``[1, 2, '…', 7, 8, 9, 10, 11, 12, 13, '…', 49, 50]``. This will result in + pages 4, 5, and 6 to the left of and 8, 9, and 10 to the right of the + current page as well as pages 1 and 2 at the start and 49 and 50 at the + end. + + Raises :exc:`InvalidPage` if the given page number doesn't exist. + Attributes ---------- +.. attribute:: Paginator.ELLIPSIS + + .. versionadded:: 3.2 + + A translatable string used as a substitute for elided page numbers in the + page range returned by :meth:`~Paginator.get_elided_page_range`. Default is + ``'…'``. + .. attribute:: Paginator.count The total number of objects, across all pages. diff --git a/docs/releases/3.2.txt b/docs/releases/3.2.txt index 8908413cfe..23b687f593 100644 --- a/docs/releases/3.2.txt +++ b/docs/releases/3.2.txt @@ -273,6 +273,14 @@ Models expressions that don't need to be selected but are used for filtering, ordering, or as a part of complex expressions. +Pagination +~~~~~~~~~~ + +* The new :meth:`django.core.paginator.Paginator.get_elided_page_range` method + allows generating a page range with some of the values elided. If there are a + large number of pages, this can be helpful for generating a reasonable number + of page links in a template. + Requests and Responses ~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/admin_changelist/tests.py b/tests/admin_changelist/tests.py index bd4d406c10..28acc401c1 100644 --- a/tests/admin_changelist/tests.py +++ b/tests/admin_changelist/tests.py @@ -1242,32 +1242,32 @@ class ChangeListTests(TestCase): request = self.factory.get('/group/') request.user = self.superuser cl = m.get_changelist_instance(request) - per_page = cl.list_per_page = 10 + cl.list_per_page = 10 - for page_num, objects_count, expected_page_range in [ - (1, per_page, []), - (1, per_page * 2, [1, 2]), - (6, per_page * 11, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]), - (6, per_page * 12, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), - (6, per_page * 13, [1, 2, 3, 4, 5, 6, 7, 8, 9, '.', 12, 13]), - (7, per_page * 12, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), - (7, per_page * 13, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]), - (7, per_page * 14, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, '.', 13, 14]), - (8, per_page * 13, [1, 2, '.', 5, 6, 7, 8, 9, 10, 11, 12, 13]), - (8, per_page * 14, [1, 2, '.', 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]), - (8, per_page * 15, [1, 2, '.', 5, 6, 7, 8, 9, 10, 11, '.', 14, 15]), + ELLIPSIS = cl.paginator.ELLIPSIS + for number, pages, expected in [ + (1, 1, []), + (1, 2, [1, 2]), + (6, 11, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]), + (6, 12, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), + (6, 13, [1, 2, 3, 4, 5, 6, 7, 8, 9, ELLIPSIS, 12, 13]), + (7, 12, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), + (7, 13, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]), + (7, 14, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ELLIPSIS, 13, 14]), + (8, 13, [1, 2, ELLIPSIS, 5, 6, 7, 8, 9, 10, 11, 12, 13]), + (8, 14, [1, 2, ELLIPSIS, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]), + (8, 15, [1, 2, ELLIPSIS, 5, 6, 7, 8, 9, 10, 11, ELLIPSIS, 14, 15]), ]: with self.subTest(number=number, pages=pages): - # assuming exactly `objects_count` objects + # assuming exactly `pages * cl.list_per_page` objects Group.objects.all().delete() - for i in range(objects_count): + for i in range(pages * cl.list_per_page): Group.objects.create(name='test band') # setting page number and calculating page range - cl.page_num = page_num + cl.page_num = number cl.get_results(request) - real_page_range = pagination(cl)['page_range'] - self.assertEqual(expected_page_range, list(real_page_range)) + self.assertEqual(list(pagination(cl)['page_range']), expected) def test_object_tools_displayed_no_add_permission(self): """ diff --git a/tests/pagination/tests.py b/tests/pagination/tests.py index f82d333ae7..d781bdf8a7 100644 --- a/tests/pagination/tests.py +++ b/tests/pagination/tests.py @@ -1,3 +1,5 @@ +import collections.abc +import unittest.mock import warnings from datetime import datetime @@ -304,6 +306,125 @@ class PaginationTests(SimpleTestCase): with self.subTest(page=page): self.assertEqual(expected, list(next(page_iterator))) + def test_get_elided_page_range(self): + # Paginator.validate_number() must be called: + paginator = Paginator([1, 2, 3], 2) + with unittest.mock.patch.object(paginator, 'validate_number') as mock: + mock.assert_not_called() + list(paginator.get_elided_page_range(2)) + mock.assert_called_with(2) + + ELLIPSIS = Paginator.ELLIPSIS + + # Range is not elided if not enough pages when using default arguments: + paginator = Paginator(range(10 * 100), 100) + page_range = paginator.get_elided_page_range(1) + self.assertIsInstance(page_range, collections.abc.Generator) + self.assertNotIn(ELLIPSIS, page_range) + paginator = Paginator(range(10 * 100 + 1), 100) + self.assertIsInstance(page_range, collections.abc.Generator) + page_range = paginator.get_elided_page_range(1) + self.assertIn(ELLIPSIS, page_range) + + # Range should be elided if enough pages when using default arguments: + tests = [ + # on_each_side=3, on_ends=2 + (1, [1, 2, 3, 4, ELLIPSIS, 49, 50]), + (6, [1, 2, 3, 4, 5, 6, 7, 8, 9, ELLIPSIS, 49, 50]), + (7, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ELLIPSIS, 49, 50]), + (8, [1, 2, ELLIPSIS, 5, 6, 7, 8, 9, 10, 11, ELLIPSIS, 49, 50]), + (43, [1, 2, ELLIPSIS, 40, 41, 42, 43, 44, 45, 46, ELLIPSIS, 49, 50]), + (44, [1, 2, ELLIPSIS, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50]), + (45, [1, 2, ELLIPSIS, 42, 43, 44, 45, 46, 47, 48, 49, 50]), + (50, [1, 2, ELLIPSIS, 47, 48, 49, 50]), + ] + paginator = Paginator(range(5000), 100) + for number, expected in tests: + with self.subTest(number=number): + page_range = paginator.get_elided_page_range(number) + self.assertIsInstance(page_range, collections.abc.Generator) + self.assertEqual(list(page_range), expected) + + # Range is not elided if not enough pages when using custom arguments: + tests = [ + (6, 2, 1, 1), (8, 1, 3, 1), (8, 4, 0, 1), (4, 1, 1, 1), + # When on_each_side and on_ends are both <= 1 but not both == 1 it + # is a special case where the range is not elided until an extra + # page is added. + (2, 0, 1, 2), (2, 1, 0, 2), (1, 0, 0, 2), + ] + for pages, on_each_side, on_ends, elided_after in tests: + for offset in range(elided_after + 1): + with self.subTest(pages=pages, offset=elided_after, on_each_side=on_each_side, on_ends=on_ends): + paginator = Paginator(range((pages + offset) * 100), 100) + page_range = paginator.get_elided_page_range( + 1, + on_each_side=on_each_side, + on_ends=on_ends, + ) + self.assertIsInstance(page_range, collections.abc.Generator) + if offset < elided_after: + self.assertNotIn(ELLIPSIS, page_range) + else: + self.assertIn(ELLIPSIS, page_range) + + # Range should be elided if enough pages when using custom arguments: + tests = [ + # on_each_side=2, on_ends=1 + (1, 2, 1, [1, 2, 3, ELLIPSIS, 50]), + (4, 2, 1, [1, 2, 3, 4, 5, 6, ELLIPSIS, 50]), + (5, 2, 1, [1, 2, 3, 4, 5, 6, 7, ELLIPSIS, 50]), + (6, 2, 1, [1, ELLIPSIS, 4, 5, 6, 7, 8, ELLIPSIS, 50]), + (45, 2, 1, [1, ELLIPSIS, 43, 44, 45, 46, 47, ELLIPSIS, 50]), + (46, 2, 1, [1, ELLIPSIS, 44, 45, 46, 47, 48, 49, 50]), + (47, 2, 1, [1, ELLIPSIS, 45, 46, 47, 48, 49, 50]), + (50, 2, 1, [1, ELLIPSIS, 48, 49, 50]), + # on_each_side=1, on_ends=3 + (1, 1, 3, [1, 2, ELLIPSIS, 48, 49, 50]), + (5, 1, 3, [1, 2, 3, 4, 5, 6, ELLIPSIS, 48, 49, 50]), + (6, 1, 3, [1, 2, 3, 4, 5, 6, 7, ELLIPSIS, 48, 49, 50]), + (7, 1, 3, [1, 2, 3, ELLIPSIS, 6, 7, 8, ELLIPSIS, 48, 49, 50]), + (44, 1, 3, [1, 2, 3, ELLIPSIS, 43, 44, 45, ELLIPSIS, 48, 49, 50]), + (45, 1, 3, [1, 2, 3, ELLIPSIS, 44, 45, 46, 47, 48, 49, 50]), + (46, 1, 3, [1, 2, 3, ELLIPSIS, 45, 46, 47, 48, 49, 50]), + (50, 1, 3, [1, 2, 3, ELLIPSIS, 49, 50]), + # on_each_side=4, on_ends=0 + (1, 4, 0, [1, 2, 3, 4, 5, ELLIPSIS]), + (5, 4, 0, [1, 2, 3, 4, 5, 6, 7, 8, 9, ELLIPSIS]), + (6, 4, 0, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ELLIPSIS]), + (7, 4, 0, [ELLIPSIS, 3, 4, 5, 6, 7, 8, 9, 10, 11, ELLIPSIS]), + (44, 4, 0, [ELLIPSIS, 40, 41, 42, 43, 44, 45, 46, 47, 48, ELLIPSIS]), + (45, 4, 0, [ELLIPSIS, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50]), + (46, 4, 0, [ELLIPSIS, 42, 43, 44, 45, 46, 47, 48, 49, 50]), + (50, 4, 0, [ELLIPSIS, 46, 47, 48, 49, 50]), + # on_each_side=0, on_ends=1 + (1, 0, 1, [1, ELLIPSIS, 50]), + (2, 0, 1, [1, 2, ELLIPSIS, 50]), + (3, 0, 1, [1, 2, 3, ELLIPSIS, 50]), + (4, 0, 1, [1, ELLIPSIS, 4, ELLIPSIS, 50]), + (47, 0, 1, [1, ELLIPSIS, 47, ELLIPSIS, 50]), + (48, 0, 1, [1, ELLIPSIS, 48, 49, 50]), + (49, 0, 1, [1, ELLIPSIS, 49, 50]), + (50, 0, 1, [1, ELLIPSIS, 50]), + # on_each_side=0, on_ends=0 + (1, 0, 0, [1, ELLIPSIS]), + (2, 0, 0, [1, 2, ELLIPSIS]), + (3, 0, 0, [ELLIPSIS, 3, ELLIPSIS]), + (48, 0, 0, [ELLIPSIS, 48, ELLIPSIS]), + (49, 0, 0, [ELLIPSIS, 49, 50]), + (50, 0, 0, [ELLIPSIS, 50]), + ] + paginator = Paginator(range(5000), 100) + for number, on_each_side, on_ends, expected in tests: + with self.subTest(number=number, on_each_side=on_each_side, on_ends=on_ends): + page_range = paginator.get_elided_page_range( + number, + on_each_side=on_each_side, + on_ends=on_ends, + ) + self.assertIsInstance(page_range, collections.abc.Generator) + self.assertEqual(list(page_range), expected) + class ModelPaginationTests(TestCase): """