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/__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 new file mode 100644 index 00000000..5cdc4fdf --- /dev/null +++ b/django_tables2/paginators.py @@ -0,0 +1,70 @@ +from __future__ import unicode_literals + +from django.core.paginator import EmptyPage, Page, PageNotAnInteger, Paginator +from django.utils.translation import ugettext as _ + + +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): + self._num_pages = None + + super(LazyPaginator, self).__init__(object_list, per_page, **kwargs) + + 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) + 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 > (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[: self.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 Page(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/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..bb602dd0 --- /dev/null +++ b/tests/test_paginators.py @@ -0,0 +1,63 @@ +from __future__ import absolute_import, unicode_literals + +from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator +from django.test import TestCase + +from django_tables2 import LazyPaginator + + +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) + + +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) + + 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/tests/test_templatetags.py b/tests/test_templatetags.py index d2268fa1..424aca9d 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 @@ -242,23 +244,25 @@ def test_render_attributes_test(self): 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 +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)) + self.assertEqual( + table_page_range(paginator.page(10), paginator), [1, "...", 4, 5, 6, 7, 8, 9, 10, 11] + ) 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("?") 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