From 590057db854d97dbce1830ef499be08e9758ffc6 Mon Sep 17 00:00:00 2001 From: Jan Pieter Waagmeester Date: Thu, 23 Aug 2018 18:21:51 +0200 Subject: [PATCH 1/6] Add LazyPaginator --- django_tables2/paginators.py | 93 ++++++++++++++++++++++++++++++++++++ django_tables2/views.py | 2 +- example/app/views.py | 3 +- tests/utils.py | 2 +- 4 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 django_tables2/paginators.py diff --git a/django_tables2/paginators.py b/django_tables2/paginators.py new file mode 100644 index 00000000..72f0280d --- /dev/null +++ b/django_tables2/paginators.py @@ -0,0 +1,93 @@ +from __future__ import unicode_literals + +from django.core.paginator import EmptyPage, Page, PageNotAnInteger, Paginator +from django.utils.translation import ugettext as _ + + +class CustomPage(Page): + """Handle different number of items on the first page.""" + + def start_index(self): + """Return the 1-based index of the first item on this page.""" + paginator = self.paginator + # Special case, return zero if no items. + if paginator.count == 0: + return 0 + elif self.number == 1: + return 1 + return (self.number - 2) * paginator.per_page + paginator.first_page + 1 + + def end_index(self): + """Return the 1-based index of the last item on this page.""" + paginator = self.paginator + # Special case for the last page because there can be orphans. + if self.number == paginator.num_pages: + return paginator.count + return (self.number - 1) * paginator.per_page + paginator.first_page + + +class LazyPaginator(Paginator): + """ + Implement lazy pagination. + + Handle different number of items on the first page. + """ + + def __init__(self, object_list, per_page, **kwargs): + self._num_pages = None + self.first_page = kwargs.pop("first_page", per_page) + + super(LazyPaginator, self).__init__(object_list, per_page, **kwargs) + + def get_current_per_page(self, number): + return self.first_page if number == 1 else self.per_page + + def validate_number(self, number): + """Validate the given 1-based page number.""" + try: + if isinstance(number, float) and not number.is_integer(): + raise ValueError + number = int(number) + except (TypeError, ValueError): + raise PageNotAnInteger(_("That page number is not an integer")) + if number < 1: + raise EmptyPage(_("That page number is less than 1")) + return number + + def page(self, number): + number = self.validate_number(number) + current_per_page = self.get_current_per_page(number) + if number == 1: + bottom = 0 + else: + bottom = (number - 2) * self.per_page + self.first_page + top = bottom + current_per_page + # Retrieve more objects to check if there is a next page. + objects = list(self.object_list[bottom : top + self.orphans + 1]) + objects_count = len(objects) + if objects_count > (current_per_page + self.orphans): + # If another page is found, increase the total number of pages. + self._num_pages = number + 1 + # In any case, return only objects for this page. + objects = objects[:current_per_page] + elif (number != 1) and (objects_count <= self.orphans): + raise EmptyPage("That page contains no results") + else: + # This is the last page. + self._num_pages = number + return CustomPage(objects, number, self) + + def _get_count(self): + raise NotImplementedError + + count = property(_get_count) + + def _get_num_pages(self): + return self._num_pages + + num_pages = property(_get_num_pages) + + def _get_page_range(self): + raise NotImplementedError + + page_range = property(_get_page_range) diff --git a/django_tables2/views.py b/django_tables2/views.py index e35622d9..52e10a19 100644 --- a/django_tables2/views.py +++ b/django_tables2/views.py @@ -61,7 +61,7 @@ class SingleTableMixin(TableMixinBase): `~.tables.Table.paginate`. If you want to use a non-standard paginator for example, you can add a key - `klass` to the dict, containing a custom `Pagintor` class. + `klass` to the dict, containing a custom `Paginator` class. This mixin plays nice with the Django's ``.MultipleObjectMixin`` by using ``.get_queryset`` as a fall back for the table data source. diff --git a/example/app/views.py b/example/app/views.py index 5db2364c..a07f0ee7 100644 --- a/example/app/views.py +++ b/example/app/views.py @@ -10,6 +10,7 @@ from django_filters.views import FilterView from django_tables2 import MultiTableMixin, RequestConfig, SingleTableMixin, SingleTableView +from django_tables2.paginators import LazyPaginator from .data import COUNTRIES from .filters import PersonFilter @@ -109,7 +110,7 @@ def bootstrap(request): create_fake_data() table = BootstrapTable(Person.objects.all().select_related("country"), order_by="-name") - RequestConfig(request, paginate={"per_page": 10}).configure(table) + RequestConfig(request, paginate={"klass": LazyPaginator, "per_page": 10}).configure(table) return render(request, "bootstrap_template.html", {"table": table}) diff --git a/tests/utils.py b/tests/utils.py index 43f0b791..7ac00f8b 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -21,7 +21,7 @@ def build_request(uri="/", user=None): """ Return a fresh HTTP GET / request. - This is essentially a heavily cutdown version of Django 1.3's + This is essentially a heavily cutdown version of Django's `~django.test.client.RequestFactory`. """ path, _, querystring = uri.partition("?") From a9c3a524455400ced09370453db42963e5983434 Mon Sep 17 00:00:00 2001 From: Jan Pieter Waagmeester Date: Fri, 24 Aug 2018 10:14:21 +0200 Subject: [PATCH 2/6] Add tests for LazyPaginator, remove CustomPage --- django_tables2/__init__.py | 2 ++ django_tables2/paginators.py | 26 ++---------------- tests/test_ordering.py | 2 +- tests/test_paginators.py | 53 ++++++++++++++++++++++++++++++++++++ tox.ini | 5 ++-- 5 files changed, 61 insertions(+), 27 deletions(-) create mode 100644 tests/test_paginators.py diff --git a/django_tables2/__init__.py b/django_tables2/__init__.py index e8585eef..14b06f46 100644 --- a/django_tables2/__init__.py +++ b/django_tables2/__init__.py @@ -18,6 +18,7 @@ ) from .config import RequestConfig from .utils import A +from .paginators import LazyPaginator from .views import SingleTableMixin, SingleTableView, MultiTableMixin @@ -46,4 +47,5 @@ "SingleTableMixin", "SingleTableView", "MultiTableMixin", + "LazyPaginator", ) diff --git a/django_tables2/paginators.py b/django_tables2/paginators.py index 72f0280d..8c49529a 100644 --- a/django_tables2/paginators.py +++ b/django_tables2/paginators.py @@ -4,28 +4,6 @@ from django.utils.translation import ugettext as _ -class CustomPage(Page): - """Handle different number of items on the first page.""" - - def start_index(self): - """Return the 1-based index of the first item on this page.""" - paginator = self.paginator - # Special case, return zero if no items. - if paginator.count == 0: - return 0 - elif self.number == 1: - return 1 - return (self.number - 2) * paginator.per_page + paginator.first_page + 1 - - def end_index(self): - """Return the 1-based index of the last item on this page.""" - paginator = self.paginator - # Special case for the last page because there can be orphans. - if self.number == paginator.num_pages: - return paginator.count - return (self.number - 1) * paginator.per_page + paginator.first_page - - class LazyPaginator(Paginator): """ Implement lazy pagination. @@ -71,11 +49,11 @@ def page(self, number): # In any case, return only objects for this page. objects = objects[:current_per_page] elif (number != 1) and (objects_count <= self.orphans): - raise EmptyPage("That page contains no results") + raise EmptyPage(_("That page contains no results")) else: # This is the last page. self._num_pages = number - return CustomPage(objects, number, self) + return Page(objects, number, self) def _get_count(self): raise NotImplementedError diff --git a/tests/test_ordering.py b/tests/test_ordering.py index 55d21903..2aef5fd8 100644 --- a/tests/test_ordering.py +++ b/tests/test_ordering.py @@ -7,7 +7,7 @@ from django.utils import six import django_tables2 as tables -from django_tables2.tables import RequestConfig +from django_tables2 import RequestConfig from .app.models import Person from .utils import build_request diff --git a/tests/test_paginators.py b/tests/test_paginators.py new file mode 100644 index 00000000..b2937cf5 --- /dev/null +++ b/tests/test_paginators.py @@ -0,0 +1,53 @@ +from __future__ import absolute_import, unicode_literals + +from django.core.paginator import EmptyPage, PageNotAnInteger +from django.test import TestCase + +from django_tables2 import LazyPaginator + + +class LazyPaginatorTest(TestCase): + def test_no_count_call(self): + class FakeQuerySet: + objects = range(1, 10 ** 6) + + def count(self): + raise AssertionError("LazyPaginator should not call QuerySet.count()") + + def __getitem__(self, key): + return self.objects[key] + + def __iter__(self): + yield next(self.objects) + + paginator = LazyPaginator(FakeQuerySet(), 10) + # num_pages initially is None, but is page_number + 1 after requesting a page. + self.assertEqual(paginator.num_pages, None) + + paginator.page(1) + self.assertEqual(paginator.num_pages, 2) + paginator.page(3) + self.assertEqual(paginator.num_pages, 4) + + paginator.page(1.0) + # and again decreases when a lower page nu + self.assertEqual(paginator.num_pages, 2) + + with self.assertRaises(PageNotAnInteger): + paginator.page(1.5) + + with self.assertRaises(EmptyPage): + paginator.page(-1) + + with self.assertRaises(NotImplementedError): + paginator.count() + + with self.assertRaises(NotImplementedError): + paginator.page_range() + + # last page + last_page_number = 10 ** 5 + paginator.page(last_page_number) + + with self.assertRaises(EmptyPage): + paginator.page(last_page_number + 1) diff --git a/tox.ini b/tox.ini index d519bd18..7ce6ec1d 100644 --- a/tox.ini +++ b/tox.ini @@ -9,7 +9,8 @@ envlist = docs, flake8, isort, - potypo + potypo, + black [travis] python: @@ -50,7 +51,6 @@ commands = deps = -r{toxinidir}/docs/requirements.txt - [testenv:flake8] basepython = python3.6 deps = flake8 @@ -65,6 +65,7 @@ max-line-length = 120 basepython = python3.6 deps = black commands = black --check + [testenv:isort] basepython = python3.6 deps = isort==4.2.15 From 46f4a4f56001b943e355b8d5f30b07556dea449b Mon Sep 17 00:00:00 2001 From: Jan Pieter Waagmeester Date: Fri, 24 Aug 2018 10:32:09 +0200 Subject: [PATCH 3/6] remove TitleTagTest, as we are not maintaining a title tag anymore --- tests/test_templatetags.py | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/tests/test_templatetags.py b/tests/test_templatetags.py index d2268fa1..20cc487e 100644 --- a/tests/test_templatetags.py +++ b/tests/test_templatetags.py @@ -240,25 +240,3 @@ def test_render_attributes_test(self): html = template.render(Context({"attrs": AttributeDict({"class": "table table-striped"})})) self.assertEqual(html, 'class="table table-striped"') - - -class TitleTagTest(SimpleTestCase): - def test_should_only_apply_to_words_without_uppercase_letters(self): - expectations = { - "a brown fox": "A Brown Fox", - "a brown foX": "A Brown foX", - "black FBI": "Black FBI", - "f.b.i": "F.B.I", - "start 6pm": "Start 6pm", - # Some cyrillic samples - "руда лисиця": "Руда Лисиця", - "руда лисицЯ": "Руда лисицЯ", - "діяльність СБУ": "Діяльність СБУ", - "а.б.в": "А.Б.В", - "вага 6кг": "Вага 6кг", - "у 80-их роках": "У 80-их Роках", - } - - for raw, expected in expectations.items(): - template = Template("{% load django_tables2 %}{{ x|title }}") - assert template.render(Context({"x": raw})), expected From fc5d2bbf93f55f76a107d6ad303969f7a1ef554d Mon Sep 17 00:00:00 2001 From: Jan Pieter Waagmeester Date: Fri, 24 Aug 2018 10:46:24 +0200 Subject: [PATCH 4/6] Add test for table_page_range template tag --- tests/test_templatetags.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/tests/test_templatetags.py b/tests/test_templatetags.py index 20cc487e..08dff7a4 100644 --- a/tests/test_templatetags.py +++ b/tests/test_templatetags.py @@ -2,12 +2,14 @@ from __future__ import unicode_literals from django.core.exceptions import ImproperlyConfigured +from django.core.paginator import Paginator from django.template import Context, RequestContext, Template, TemplateSyntaxError from django.test import SimpleTestCase, TestCase, override_settings from django.utils import six from django.utils.six.moves.urllib.parse import parse_qs -from django_tables2 import RequestConfig, Table, TemplateColumn +from django_tables2 import LazyPaginator, RequestConfig, Table, TemplateColumn +from django_tables2.templatetags.django_tables2 import table_page_range from django_tables2.utils import AttributeDict from .app.models import Region @@ -240,3 +242,24 @@ def test_render_attributes_test(self): html = template.render(Context({"attrs": AttributeDict({"class": "table table-striped"})})) self.assertEqual(html, 'class="table table-striped"') + + +class TablePageRangeTest(SimpleTestCase): + def test_table_page_range(self): + paginator = Paginator(range(1, 1000), 10) + self.assertEqual( + table_page_range(paginator.page(1), paginator), [1, 2, 3, 4, 5, 6, 7, 8, "...", 100] + ) + self.assertEqual( + table_page_range(paginator.page(10), paginator), + [1, "...", 7, 8, 9, 10, 11, 12, "...", 100], + ) + self.assertEqual( + table_page_range(paginator.page(100), paginator), + [1, "...", 93, 94, 95, 96, 97, 98, 99, 100], + ) + + def test_table_page_range_lazy(self): + paginator = LazyPaginator(range(1, 1000), 10) + + self.assertEqual(table_page_range(paginator.page(1), paginator), range(1, 3)) From 4673f8cc9d6dab62e17d875755a807308fd43629 Mon Sep 17 00:00:00 2001 From: Jan Pieter Waagmeester Date: Fri, 24 Aug 2018 11:02:40 +0200 Subject: [PATCH 5/6] Simplify LazyPaginator even more, more tests --- CONTRIBUTING.md | 4 ++-- django_tables2/paginators.py | 19 +++++-------------- tests/test_paginators.py | 32 +++++++++++++++++++++----------- tests/test_templatetags.py | 3 +++ 4 files changed, 31 insertions(+), 27 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57600637..ff546f94 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,8 +34,8 @@ You can also run the tests only in your current environment, using To generate a html coverage report: ``` -covarage run --source=django_tables2 manage.py test -s -cov +coverage run --source=django_tables2 manage.py test +coverage html ``` ## Building the documentation diff --git a/django_tables2/paginators.py b/django_tables2/paginators.py index 8c49529a..98f85d42 100644 --- a/django_tables2/paginators.py +++ b/django_tables2/paginators.py @@ -6,20 +6,15 @@ class LazyPaginator(Paginator): """ - Implement lazy pagination. + Implement lazy pagination, preventing any count() queries. - Handle different number of items on the first page. """ def __init__(self, object_list, per_page, **kwargs): self._num_pages = None - self.first_page = kwargs.pop("first_page", per_page) super(LazyPaginator, self).__init__(object_list, per_page, **kwargs) - def get_current_per_page(self, number): - return self.first_page if number == 1 else self.per_page - def validate_number(self, number): """Validate the given 1-based page number.""" try: @@ -34,20 +29,16 @@ def validate_number(self, number): def page(self, number): number = self.validate_number(number) - current_per_page = self.get_current_per_page(number) - if number == 1: - bottom = 0 - else: - bottom = (number - 2) * self.per_page + self.first_page - top = bottom + current_per_page + bottom = (number - 1) * self.per_page + top = bottom + self.per_page # Retrieve more objects to check if there is a next page. objects = list(self.object_list[bottom : top + self.orphans + 1]) objects_count = len(objects) - if objects_count > (current_per_page + self.orphans): + if objects_count > (self.per_page + self.orphans): # If another page is found, increase the total number of pages. self._num_pages = number + 1 # In any case, return only objects for this page. - objects = objects[:current_per_page] + objects = objects[: self.per_page] elif (number != 1) and (objects_count <= self.orphans): raise EmptyPage(_("That page contains no results")) else: diff --git a/tests/test_paginators.py b/tests/test_paginators.py index b2937cf5..bb602dd0 100644 --- a/tests/test_paginators.py +++ b/tests/test_paginators.py @@ -1,25 +1,35 @@ from __future__ import absolute_import, unicode_literals -from django.core.paginator import EmptyPage, PageNotAnInteger +from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.test import TestCase from django_tables2 import LazyPaginator -class LazyPaginatorTest(TestCase): - def test_no_count_call(self): - class FakeQuerySet: - objects = range(1, 10 ** 6) +class FakeQuerySet: + objects = range(1, 10 ** 6) - def count(self): - raise AssertionError("LazyPaginator should not call QuerySet.count()") + def count(self): + raise AssertionError("LazyPaginator should not call QuerySet.count()") - def __getitem__(self, key): - return self.objects[key] + def __getitem__(self, key): + return self.objects[key] - def __iter__(self): - yield next(self.objects) + def __iter__(self): + yield next(self.objects) + +class LazyPaginatorTest(TestCase): + def test_compare_to_default_paginator(self): + objects = list(range(1, 1000)) + + paginator = Paginator(objects, 10) + lazy_paginator = LazyPaginator(objects, 10) + self.assertEqual(paginator.page(1).object_list, lazy_paginator.page(1).object_list) + self.assertEqual(paginator.page(10).object_list, lazy_paginator.page(10).object_list) + self.assertEqual(paginator.page(100).object_list, lazy_paginator.page(100).object_list) + + def test_no_count_call(self): paginator = LazyPaginator(FakeQuerySet(), 10) # num_pages initially is None, but is page_number + 1 after requesting a page. self.assertEqual(paginator.num_pages, None) diff --git a/tests/test_templatetags.py b/tests/test_templatetags.py index 08dff7a4..424aca9d 100644 --- a/tests/test_templatetags.py +++ b/tests/test_templatetags.py @@ -263,3 +263,6 @@ def test_table_page_range_lazy(self): paginator = LazyPaginator(range(1, 1000), 10) self.assertEqual(table_page_range(paginator.page(1), paginator), range(1, 3)) + self.assertEqual( + table_page_range(paginator.page(10), paginator), [1, "...", 4, 5, 6, 7, 8, 9, 10, 11] + ) From f49623d80d435ba803c5f4f8bd238eb71119d9e5 Mon Sep 17 00:00:00 2001 From: Jan Pieter Waagmeester Date: Fri, 24 Aug 2018 11:21:24 +0200 Subject: [PATCH 6/6] Add example of LazyPaginator usage --- django_tables2/paginators.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/django_tables2/paginators.py b/django_tables2/paginators.py index 98f85d42..5cdc4fdf 100644 --- a/django_tables2/paginators.py +++ b/django_tables2/paginators.py @@ -8,6 +8,14 @@ class LazyPaginator(Paginator): """ Implement lazy pagination, preventing any count() queries. + Usage with SingleTableView:: + + class UserListView(SingleTableView): + table_class = UserTable + table_data = User.objects.all() + table_pagination = { + "klass": LazyPaginator + } """ def __init__(self, object_list, per_page, **kwargs):