From 2017972cbce5cec72bd14d0ecd61697b54a76760 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Michael=20Elsd=C3=B6rfer?= Date: Thu, 26 Jun 2008 17:15:56 +0000 Subject: [PATCH] added support for pagination --- README | 41 ++++++++++++++++++++++----- django_tables/models.py | 26 +++++++++++++++-- django_tables/tables.py | 61 ++++++++++++++++++++++++++++++++++------ tests/test_basic.py | 31 ++++++++++++++++++++- tests/test_models.py | 62 +++++++++++++++++++++++++++++++++++++---- 5 files changed, 197 insertions(+), 24 deletions(-) diff --git a/README b/README index 9afb539..f1f32ab 100644 --- a/README +++ b/README @@ -311,18 +311,45 @@ static and ModelTables. Tables and Pagination --------------------- - table = MyTable(queryset) - p = Paginator(table.rows, 10) # paginator will need to be able to handle our modelproxy +If your table has a large number of rows, you probably want to paginate +the output. There are two distinct approaches. - or +First, you can just paginate over ``rows`` as you would do with any other +data: table = MyTable(queryset) - table.pagination = Paginator(10, padding=2) + paginator = Paginator(table.rows, 10) + page = paginator.page(1) - or +You're not necessarily restricted to Django's own paginator (or subclasses) - +any paginator should work with this approach, so long it only requires +``rows`` to implement ``len()``, slicing, and, in the case of ModelTables, a +``count()`` method. The latter means that the ``QuerySetPaginator`` also +works as expected. - table.paginate(DiggPaginator, 10, padding=2) +Alternatively, you may use the ``paginate`` feature: + table = MyTable(queryset) + table.paginate(QuerySetPaginator, page=1, 10, padding=2) + for row in table.rows.page(): + pass + table.paginator # new attributes + table.page + +The table will automatically create an instance of ``QuerySetPaginator``, +passing it's own data as the first argument and additionally any arguments +you have specified, except for ``page``. You may use any paginator, as long +as it follows the Django protocol: + + * Take data as first argument. + * Support a page() method returning an object with an ``object_list`` + attribute, exposing the paginated data. + +Note that due to the abstraction layer that django-tables represents, it is +not necessary to use Django's QuerySetPaginator with ModelTables. Since the +table knows that it holds a queryset, it will automatically choose to use +count() to determine the data length (which is exactly what +QuerySetPaginator would do). Ordering Syntax --------------- @@ -400,4 +427,4 @@ TODO the template engine handle it - tests could use some refactoring, they are currently all over the place - what happens if duplicate column names are used? we currently don't - check for that at all. \ No newline at end of file + check for that at all \ No newline at end of file diff --git a/django_tables/models.py b/django_tables/models.py index 23082a0..9aea735 100644 --- a/django_tables/models.py +++ b/django_tables/models.py @@ -1,6 +1,7 @@ from django.core.exceptions import FieldError from django.utils.datastructures import SortedDict -from tables import BaseTable, DeclarativeColumnsMetaclass, Column, BoundRow +from tables import BaseTable, DeclarativeColumnsMetaclass, \ + Column, BoundRow, Rows __all__ = ('BaseModelTable', 'ModelTable') @@ -82,6 +83,7 @@ class BaseModelTable(BaseTable): self.queryset = data super(BaseModelTable, self).__init__(self.queryset, *args, **kwargs) + self._rows = ModelRows(self) def _validate_column_name(self, name, purpose): """Overridden. Only allow model-based fields and valid model @@ -115,7 +117,7 @@ class BaseModelTable(BaseTable): # reset caches self._columns._reset() - self._rows = None + self._rows._reset() queryset = self.queryset if self.order_by: @@ -129,6 +131,26 @@ class BaseModelTable(BaseTable): class ModelTable(BaseModelTable): __metaclass__ = ModelTableMetaclass +class ModelRows(Rows): + def __init__(self, *args, **kwargs): + super(ModelRows, self).__init__(*args, **kwargs) + self.row_klass = BoundModelRow + + def _reset(self): + self._length = None + + def __len__(self): + """Use the queryset count() method to get the length, instead of + loading all results into memory. This allows, for example, + smart paginators that use len() to perform better. + """ + if getattr(self, '_length', None) is None: + self._length = self.table.data.count() + return self._length + + # for compatibility with QuerySetPaginator + count = __len__ + class BoundModelRow(BoundRow): """Special version of the BoundRow class that can handle model instances as data. diff --git a/django_tables/tables.py b/django_tables/tables.py index 136fda2..8fab3d7 100644 --- a/django_tables/tables.py +++ b/django_tables/tables.py @@ -120,7 +120,7 @@ class BaseTable(object): """ self._data = data self._snapshot = None # will store output dataset (ordered...) - self._rows = None # will store BoundRow objects + self._rows = Rows(self) self._columns = Columns(self) self._order_by = order_by @@ -147,7 +147,7 @@ class BaseTable(object): # reset caches self._columns._reset() - self._rows = None + self._rows._reset() snapshot = copy.copy(self._data) for row in snapshot: @@ -257,12 +257,9 @@ class BaseTable(object): raise KeyError('Key %r not found in Table' % name) return BoundColumn(self, column, name) - columns = property(lambda s: s._columns) # just to make it readonly - - def _get_rows(self): - for row in self.data: - yield BoundRow(self, row) - rows = property(lambda s: s._get_rows()) + # just to make those readonly + columns = property(lambda s: s._columns) + rows = property(lambda s: s._rows) def as_html(self): pass @@ -278,6 +275,12 @@ class BaseTable(object): """ self._build_snapshot() + def paginate(self, klass, *args, **kwargs): + page = kwargs.pop('page', 1) + self.paginator = klass(self.rows, *args, **kwargs) + self.page = self.paginator.page(page) + + class Table(BaseTable): "A collection of columns, plus their associated data rows." # This is a separate class from BaseTable in order to abstract the way @@ -291,7 +294,7 @@ class Columns(object): This is bound to a table and provides it's ``columns`` property. It provides access to those columns in different ways (iterator, item-based, filtered and unfiltered etc)., stuff that would not be - possible with a simple iterator on the table class. + possible with a simple iterator in the table class. Note that when you define your column using a name override, e.g. ``author_name = tables.Column(name="author")``, then the column will @@ -408,6 +411,46 @@ class BoundColumn(StrAndUnicode): def as_html(self): pass +class Rows(object): + """Container for spawning BoundRows. + + This is bound to a table and provides it's ``rows`` property. It + provides functionality that would not be possible with a simple + iterator in the table class. + """ + def __init__(self, table, row_klass=None): + self.table = table + self.row_klass = row_klass and row_klass or BoundRow + + def _reset(self): + pass # we currently don't use a cache + + def all(self): + """Return all rows.""" + for row in self.table.data: + yield self.row_klass(self.table, row) + + def page(self): + """Return rows on current page (if paginated).""" + if not hasattr(self.table, 'page'): + return None + return iter(self.table.page.object_list) + + def __iter__(self): + return iter(self.all()) + + def __len__(self): + return len(self.table.data) + + def __getitem__(self, key): + if isinstance(key, slice): + result = list() + for row in self.table.data[key]: + result.append(self.row_klass(self.table, row)) + return result + else: + return self.row_klass(self, table, self.table.data[key]) + class BoundRow(object): """Represents a single row of data, bound to a table. diff --git a/tests/test_basic.py b/tests/test_basic.py index 205e497..cc6c33d 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -5,6 +5,7 @@ This includes the core, as well as static data, non-model tables. from math import sqrt from py.test import raises +from django.core.paginator import Paginator import django_tables as tables def test_declaration(): @@ -194,4 +195,32 @@ def test_callable(): # data function is called while sorting math.order_by = ('sqrt',) - assert [row['sqrt'] for row in math] == [1,1,2] \ No newline at end of file + assert [row['sqrt'] for row in math] == [1,1,2] + +def test_pagination(): + class BookTable(tables.Table): + name = tables.Column() + + # create some sample data + data = [] + for i in range(1,101): + data.append({'name': 'Book Nr. %d'%i}) + books = BookTable(data) + + # external paginator + paginator = Paginator(books.rows, 10) + assert paginator.num_pages == 10 + page = paginator.page(1) + assert len(page.object_list) == 10 + assert page.has_previous() == False + assert page.has_next() == True + + # integrated paginator + books.paginate(Paginator, 10, page=1) + # rows is now paginated + assert len(list(books.rows.page())) == 10 + assert len(list(books.rows.all())) == 100 + # new attributes + assert books.paginator.num_pages == 10 + assert books.page.has_previous() == False + assert books.page.has_next() == True \ No newline at end of file diff --git a/tests/test_models.py b/tests/test_models.py index 528d921..cb7c3a6 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -5,6 +5,7 @@ Sets up a temporary Django project using a memory SQLite database. from py.test import raises from django.conf import settings +from django.core.paginator import * import django_tables as tables def setup_module(module): @@ -195,10 +196,6 @@ def test_sort(): countries.order_by = ('custom1', 'custom2') assert countries.order_by == () -def test_pagination(): - # TODO: support pagination - pass - def test_callable(): """Some of the callable code is reimplemented for modeltables, so test some specifics again. @@ -262,4 +259,59 @@ def test_column_data(): # neato trick: a callable data= column is sortable, if otherwise refers # to correct model column; can be used to rewrite what is displayed countries.order_by = 'name' - assert countries.order_by == ('name',) \ No newline at end of file + assert countries.order_by == ('name',) + +def test_pagination(): + """Pretty much the same as static table pagination, but make sure we + provide the capability, at least for paginators that use it, to not + have the complete queryset loaded (by use of a count() query). + + Note: This test changes the available cities, make sure it is last, + or that tests that follow are written appropriately. + """ + from django.db import connection + + class CityTable(tables.ModelTable): + class Meta: + model = City + columns = ['name'] + cities = CityTable() + + # add some sample data + City.objects.all().delete() + for i in range(1,101): + City.objects.create(name="City %d"%i) + + # for query logging + settings.DEBUG = True + + # external paginator + start_querycount = len(connection.queries) + paginator = Paginator(cities.rows, 10) + assert paginator.num_pages == 10 + page = paginator.page(1) + assert len(page.object_list) == 10 + assert page.has_previous() == False + assert page.has_next() == True + # Make sure the queryset is not loaded completely - there must be two + # queries, one a count(). This check is far from foolproof... + assert len(connection.queries)-start_querycount == 2 + + # using a queryset paginator is possible as well (although unnecessary) + paginator = QuerySetPaginator(cities.rows, 10) + assert paginator.num_pages == 10 + + # integrated paginator + start_querycount = len(connection.queries) + cities.paginate(Paginator, 10, page=1) + # rows is now paginated + assert len(list(cities.rows.page())) == 10 + assert len(list(cities.rows.all())) == 100 + # new attributes + assert cities.paginator.num_pages == 10 + assert cities.page.has_previous() == False + assert cities.page.has_next() == True + assert len(connection.queries)-start_querycount == 2 + + # reset + settings.DEBUG = False \ No newline at end of file -- 2.26.2