From f5e5b2fd1cf3e66087aafdf9a33376d4d21b5537 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Michael=20Elsd=C3=B6rfer?= Date: Wed, 24 Mar 2010 17:30:36 +0100 Subject: [PATCH] Minor refactoring. The BaseTable implementation is now split into an actual "BaseTable", and a "MemoryTable". This is because in the future we'd like to offer at least one additional table implementation, an SQlTable, which is capable of doing raw queries. As a result, a better split of the base funtionality and what is provided by a base class is appropriate. --- README | 8 +- django_tables/__init__.py | 5 +- django_tables/{tables.py => base.py} | 79 +---- django_tables/memory.py | 90 +++++ django_tables/models.py | 21 +- tests/test_basic.py | 479 +++++---------------------- tests/test_memory.py | 325 ++++++++++++++++++ tests/test_templates.py | 8 +- 8 files changed, 535 insertions(+), 480 deletions(-) rename django_tables/{tables.py => base.py} (85%) create mode 100644 django_tables/memory.py create mode 100644 tests/test_memory.py diff --git a/README b/README index 622785b..db1930f 100644 --- a/README +++ b/README @@ -22,7 +22,7 @@ Working with Tables A table class looks very much like a form: import django_tables as tables - class CountryTable(tables.Table): + class CountryTable(tables.MemoryTable): name = tables.Column(verbose_name="Country Name") population = tables.Column(sortable=False, visible=False) time_zone = tables.Column(name="tz", default="UTC+1") @@ -131,7 +131,7 @@ Table Options Table-specific options are implemented using the same inner ``Meta`` class concept as known from forms and models in Django: - class MyTable(tables.Table): + class MyTable(tables.MemoryTable): class Meta: sortable = True @@ -269,7 +269,7 @@ the base column class will continue to work by itself. There are no required arguments. The following is fine: - class MyTable(tables.Table): + class MyTable(tables.MemoryTable): c = tables.Column() It will result in a column named "c" in the table. You can specify the @@ -613,4 +613,4 @@ have subclasses for each model class, even if it just redirects to use the correct filter class; If not using django-filter, we wouldn't have different filter types; filters -would just hold the data, and each column would know how to apply it. \ No newline at end of file +would just hold the data, and each column would know how to apply it. diff --git a/django_tables/__init__.py b/django_tables/__init__.py index 399df8d..6a3916a 100644 --- a/django_tables/__init__.py +++ b/django_tables/__init__.py @@ -1,3 +1,4 @@ -from tables import * +from memory import * from models import * -from columns import * \ No newline at end of file +from columns import * +from base import * \ No newline at end of file diff --git a/django_tables/tables.py b/django_tables/base.py similarity index 85% rename from django_tables/tables.py rename to django_tables/base.py index 94274e8..8ab77be 100644 --- a/django_tables/tables.py +++ b/django_tables/base.py @@ -6,35 +6,16 @@ from django.utils.encoding import StrAndUnicode from django.utils.text import capfirst from columns import Column -__all__ = ('BaseTable', 'Table', 'options') -def sort_table(data, order_by): - """Sort a list of dicts according to the fieldnames in the - ``order_by`` iterable. Prefix with hypen for reverse. +__all__ = ('BaseTable', 'options') - Dict values can be callables. - """ - def _cmp(x, y): - for name, reverse in instructions: - lhs, rhs = x.get(name), y.get(name) - res = cmp((callable(lhs) and [lhs(x)] or [lhs])[0], - (callable(rhs) and [rhs(y)] or [rhs])[0]) - if res != 0: - return reverse and -res or res - return 0 - instructions = [] - for o in order_by: - if o.startswith('-'): - instructions.append((o[1:], True,)) - else: - instructions.append((o, False,)) - data.sort(cmp=_cmp) class TableOptions(object): def __init__(self, options=None): super(TableOptions, self).__init__() self.sortable = getattr(options, 'sortable', None) + class DeclarativeColumnsMetaclass(type): """ Metaclass that converts Column attributes to a dictionary called @@ -189,7 +170,13 @@ class DefaultOptions(object): IGNORE_INVALID_OPTIONS = True options = DefaultOptions() + class BaseTable(object): + """A collection of columns, plus their associated data rows. + """ + + __metaclass__ = DeclarativeColumnsMetaclass + def __init__(self, data, order_by=None): """Create a new table instance with the iterable ``data``. @@ -219,52 +206,15 @@ class BaseTable(object): self.base_columns = copy.deepcopy(type(self).base_columns) def _build_snapshot(self): - """Rebuilds the table whenever it's options change. + """Rebuild the table for the current set of options. Whenver the table options change, e.g. say a new sort order, this method will be asked to regenerate the actual table from the linked data source. - In the case of this base table implementation, a copy of the - source data is created, and then modified appropriately. - - # TODO: currently this is called whenever data changes; it is - # probably much better to do this on-demand instead, when the - # data is *needed* for the first time. + Subclasses should override this. """ - - # reset caches - self._columns._reset() - self._rows._reset() - - snapshot = copy.copy(self._data) - for row in snapshot: - # add data that is missing from the source. we do this now so - # that the colunn ``default`` and ``data`` values can affect - # sorting (even when callables are used)! - # This is a design decision - the alternative would be to - # resolve the values when they are accessed, and either do not - # support sorting them at all, or run the callables during - # sorting. - for column in self.columns.all(): - name_in_source = column.declared_name - if column.column.data: - if callable(column.column.data): - # if data is a callable, use it's return value - row[name_in_source] = column.column.data(BoundRow(self, row)) - else: - name_in_source = column.column.data - - # the following will be True if: - # * the source does not provide that column or provides None - # * the column did provide a data callable that returned None - if row.get(name_in_source, None) is None: - row[name_in_source] = column.get_default(BoundRow(self, row)) - - if self.order_by: - actual_order_by = self._resolve_sort_directions(self.order_by) - sort_table(snapshot, self._cols_to_fields(actual_order_by)) - self._snapshot = snapshot + self._snapshot = copy.copy(self._data) def _get_data(self): if self._snapshot is None: @@ -384,13 +334,6 @@ class BaseTable(object): raise Http404(str(e)) -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 - # self.columns is specified. - __metaclass__ = DeclarativeColumnsMetaclass - - class Columns(object): """Container for spawning BoundColumns. diff --git a/django_tables/memory.py b/django_tables/memory.py new file mode 100644 index 0000000..cd2118b --- /dev/null +++ b/django_tables/memory.py @@ -0,0 +1,90 @@ +import copy +from base import BaseTable, BoundRow + + +__all__ = ('MemoryTable', 'Table',) + + +def sort_table(data, order_by): + """Sort a list of dicts according to the fieldnames in the + ``order_by`` iterable. Prefix with hypen for reverse. + + Dict values can be callables. + """ + def _cmp(x, y): + for name, reverse in instructions: + lhs, rhs = x.get(name), y.get(name) + res = cmp((callable(lhs) and [lhs(x)] or [lhs])[0], + (callable(rhs) and [rhs(y)] or [rhs])[0]) + if res != 0: + return reverse and -res or res + return 0 + instructions = [] + for o in order_by: + if o.startswith('-'): + instructions.append((o[1:], True,)) + else: + instructions.append((o, False,)) + data.sort(cmp=_cmp) + + +class MemoryTable(BaseTable): + + # This is a separate class from BaseTable in order to abstract the way + # self.columns is specified. + + def _build_snapshot(self): + """Rebuilds the table whenever it's options change. + + Whenver the table options change, e.g. say a new sort order, + this method will be asked to regenerate the actual table from + the linked data source. + + In the case of this base table implementation, a copy of the + source data is created, and then modified appropriately. + + # TODO: currently this is called whenever data changes; it is + # probably much better to do this on-demand instead, when the + # data is *needed* for the first time. + """ + + # reset caches + self._columns._reset() + self._rows._reset() + + snapshot = copy.copy(self._data) + for row in snapshot: + # add data that is missing from the source. we do this now so + # that the colunn ``default`` and ``data`` values can affect + # sorting (even when callables are used)! + # This is a design decision - the alternative would be to + # resolve the values when they are accessed, and either do not + # support sorting them at all, or run the callables during + # sorting. + for column in self.columns.all(): + name_in_source = column.declared_name + if column.column.data: + if callable(column.column.data): + # if data is a callable, use it's return value + row[name_in_source] = column.column.data(BoundRow(self, row)) + else: + name_in_source = column.column.data + + # the following will be True if: + # * the source does not provide that column or provides None + # * the column did provide a data callable that returned None + if row.get(name_in_source, None) is None: + row[name_in_source] = column.get_default(BoundRow(self, row)) + + if self.order_by: + actual_order_by = self._resolve_sort_directions(self.order_by) + sort_table(snapshot, self._cols_to_fields(actual_order_by)) + self._snapshot = snapshot + + +class Table(MemoryTable): + def __new__(cls, *a, **kw): + from warnings import warn + warn('"Table" has been renamed to "MemoryTable". Please use the '+ + 'new name.', DeprecationWarning) + return MemoryTable.__new__(cls) diff --git a/django_tables/models.py b/django_tables/models.py index 3650edd..a7cba82 100644 --- a/django_tables/models.py +++ b/django_tables/models.py @@ -1,9 +1,11 @@ from django.core.exceptions import FieldError from django.utils.datastructures import SortedDict -from tables import BaseTable, DeclarativeColumnsMetaclass, \ +from base import BaseTable, DeclarativeColumnsMetaclass, \ Column, BoundRow, Rows, TableOptions, rmprefix, toggleprefix -__all__ = ('BaseModelTable', 'ModelTable') + +__all__ = ('ModelTable',) + class ModelTableOptions(TableOptions): def __init__(self, options=None): @@ -12,6 +14,7 @@ class ModelTableOptions(TableOptions): self.columns = getattr(options, 'columns', None) self.exclude = getattr(options, 'exclude', None) + def columns_for_model(model, columns=None, exclude=None): """ Returns a ``SortedDict`` containing form columns for the given model. @@ -54,7 +57,8 @@ class ModelTableMetaclass(DeclarativeColumnsMetaclass): self.base_columns = columns return self -class BaseModelTable(BaseTable): + +class ModelTable(BaseTable): """Table that is based on a model. Similar to ModelForm, a column will automatically be created for all @@ -72,6 +76,9 @@ class BaseModelTable(BaseTable): just don't any data at all, the model the table is based on will provide it. """ + + __metaclass__ = ModelTableMetaclass + def __init__(self, data=None, *args, **kwargs): if data == None: if self._meta.model is None: @@ -83,7 +90,7 @@ class BaseModelTable(BaseTable): else: self.queryset = data - super(BaseModelTable, self).__init__(self.queryset, *args, **kwargs) + super(ModelTable, self).__init__(self.queryset, *args, **kwargs) self._rows = ModelRows(self) def _validate_column_name(self, name, purpose): @@ -91,7 +98,7 @@ class BaseModelTable(BaseTable): spanning relationships to be sorted.""" # let the base class sort out the easy ones - result = super(BaseModelTable, self)._validate_column_name(name, purpose) + result = super(ModelTable, self)._validate_column_name(name, purpose) if not result: return False @@ -121,7 +128,7 @@ class BaseModelTable(BaseTable): continue try: # Let Django validate the lookup by asking it to build - # the final query; the way to do this has changed in + # the final query; the way to do this has changed in # Django 1.2, and we try to support both versions. _temp = self.queryset.order_by(lookup).query if hasattr(_temp, 'as_sql'): @@ -157,8 +164,6 @@ class BaseModelTable(BaseTable): for row in self.data: yield BoundModelRow(self, row) -class ModelTable(BaseModelTable): - __metaclass__ = ModelTableMetaclass class ModelRows(Rows): def __init__(self, *args, **kwargs): diff --git a/tests/test_basic.py b/tests/test_basic.py index 372532a..d171bd0 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,394 +1,85 @@ -"""Test the base table functionality. - -This includes the core, as well as static data, non-model tables. -""" - -from math import sqrt -from nose.tools import assert_raises -from django.core.paginator import Paginator -from django.http import Http404 -import django_tables as tables - -def test_declaration(): - """ - Test defining tables by declaration. - """ - - class GeoAreaTable(tables.Table): - name = tables.Column() - population = tables.Column() - - assert len(GeoAreaTable.base_columns) == 2 - assert 'name' in GeoAreaTable.base_columns - assert not hasattr(GeoAreaTable, 'name') - - class CountryTable(GeoAreaTable): - capital = tables.Column() - - assert len(CountryTable.base_columns) == 3 - assert 'capital' in CountryTable.base_columns - - # multiple inheritance - class AddedMixin(tables.Table): - added = tables.Column() - class CityTable(GeoAreaTable, AddedMixin): - mayer = tables.Column() - - assert len(CityTable.base_columns) == 4 - assert 'added' in CityTable.base_columns - - # modelforms: support switching from a non-model table hierarchy to a - # modeltable hierarchy (both base class orders) - class StateTable1(tables.ModelTable, GeoAreaTable): - motto = tables.Column() - class StateTable2(GeoAreaTable, tables.ModelTable): - motto = tables.Column() - - assert len(StateTable1.base_columns) == len(StateTable2.base_columns) == 3 - assert 'motto' in StateTable1.base_columns - assert 'motto' in StateTable2.base_columns - -def test_basic(): - class StuffTable(tables.Table): - name = tables.Column() - answer = tables.Column(default=42) - c = tables.Column(name="count", default=1) - email = tables.Column(data="@") - stuff = StuffTable([ - {'id': 1, 'name': 'Foo Bar', '@': 'foo@bar.org'}, - ]) - - # access without order_by works - stuff.data - stuff.rows - - # make sure BoundColumnn.name always gives us the right thing, whether - # the column explicitely defines a name or not. - stuff.columns['count'].name == 'count' - stuff.columns['answer'].name == 'answer' - - for r in stuff.rows: - # unknown fields are removed/not-accessible - assert 'name' in r - assert not 'id' in r - # missing data is available as default - assert 'answer' in r - assert r['answer'] == 42 # note: different from prev. line! - - # all that still works when name overrides are used - assert not 'c' in r - assert 'count' in r - assert r['count'] == 1 - - # columns with data= option work fine - assert r['email'] == 'foo@bar.org' - - # try to splice rows by index - assert 'name' in stuff.rows[0] - assert isinstance(stuff.rows[0:], list) - - # [bug] splicing the table gives us valid, working rows - assert list(stuff[0]) == list(stuff.rows[0]) - assert stuff[0]['name'] == 'Foo Bar' - - # changing an instance's base_columns does not change the class - assert id(stuff.base_columns) != id(StuffTable.base_columns) - stuff.base_columns['test'] = tables.Column() - assert not 'test' in StuffTable.base_columns - - # optionally, exceptions can be raised when input is invalid - tables.options.IGNORE_INVALID_OPTIONS = False - try: - assert_raises(ValueError, setattr, stuff, 'order_by', '-name,made-up-column') - assert_raises(ValueError, setattr, stuff, 'order_by', ('made-up-column',)) - # when a column name is overwritten, the original won't work anymore - assert_raises(ValueError, setattr, stuff, 'order_by', 'c') - # reset for future tests - finally: - tables.options.IGNORE_INVALID_OPTIONS = True - -def test_caches(): - """Ensure the various caches are effective. - """ - - class BookTable(tables.Table): - name = tables.Column() - answer = tables.Column(default=42) - books = BookTable([ - {'name': 'Foo: Bar'}, - ]) - - assert id(list(books.columns)[0]) == id(list(books.columns)[0]) - # TODO: row cache currently not used - #assert id(list(books.rows)[0]) == id(list(books.rows)[0]) - - # test that caches are reset after an update() - old_column_cache = id(list(books.columns)[0]) - old_row_cache = id(list(books.rows)[0]) - books.update() - assert id(list(books.columns)[0]) != old_column_cache - assert id(list(books.rows)[0]) != old_row_cache - -def test_meta_sortable(): - """Specific tests for sortable table meta option.""" - - def mktable(default_sortable): - class BookTable(tables.Table): - id = tables.Column(sortable=True) - name = tables.Column(sortable=False) - author = tables.Column() - class Meta: - sortable = default_sortable - return BookTable([]) - - global_table = mktable(None) - for default_sortable, results in ( - (None, (True, False, True)), # last bool is global default - (True, (True, False, True)), # last bool is table default - (False, (True, False, False)), # last bool is table default - ): - books = mktable(default_sortable) - assert [c.sortable for c in books.columns] == list(results) - - # it also works if the meta option is manually changed after - # class and instance creation - global_table._meta.sortable = default_sortable - assert [c.sortable for c in global_table.columns] == list(results) - -def test_sort(): - class BookTable(tables.Table): - id = tables.Column(direction='desc') - name = tables.Column() - pages = tables.Column(name='num_pages') # test rewritten names - language = tables.Column(default='en') # default affects sorting - rating = tables.Column(data='*') # test data field option - - books = BookTable([ - {'id': 1, 'pages': 60, 'name': 'Z: The Book', '*': 5}, # language: en - {'id': 2, 'pages': 100, 'language': 'de', 'name': 'A: The Book', '*': 2}, - {'id': 3, 'pages': 80, 'language': 'de', 'name': 'A: The Book, Vol. 2', '*': 4}, - {'id': 4, 'pages': 110, 'language': 'fr', 'name': 'A: The Book, French Edition'}, # rating (with data option) is missing - ]) - - # None is normalized to an empty order by tuple, ensuring iterability; - # it also supports all the cool methods that we offer for order_by. - # This is true for the default case... - assert books.order_by == () - iter(books.order_by) - assert hasattr(books.order_by, 'toggle') - # ...as well as when explicitly set to None. - books.order_by = None - assert books.order_by == () - iter(books.order_by) - assert hasattr(books.order_by, 'toggle') - - # test various orderings - def test_order(order, result): - books.order_by = order - assert [b['id'] for b in books.rows] == result - test_order(('num_pages',), [1,3,2,4]) - test_order(('-num_pages',), [4,2,3,1]) - test_order(('name',), [2,4,3,1]) - test_order(('language', 'num_pages'), [3,2,1,4]) - # using a simple string (for convinience as well as querystring passing - test_order('-num_pages', [4,2,3,1]) - test_order('language,num_pages', [3,2,1,4]) - # if overwritten, the declared fieldname has no effect - test_order('pages,name', [2,4,3,1]) # == ('name',) - # sort by column with "data" option - test_order('rating', [4,2,3,1]) - - # test the column with a default ``direction`` set to descending - test_order('id', [4,3,2,1]) - test_order('-id', [1,2,3,4]) - # changing the direction afterwards is fine too - books.base_columns['id'].direction = 'asc' - test_order('id', [1,2,3,4]) - test_order('-id', [4,3,2,1]) - # a invalid direction string raises an exception - assert_raises(ValueError, setattr, books.base_columns['id'], 'direction', 'blub') - - # [bug] test alternative order formats if passed to constructor - BookTable([], 'language,-num_pages') - - # test invalid order instructions - books.order_by = 'xyz' - assert not books.order_by - books.base_columns['language'].sortable = False - books.order_by = 'language' - assert not books.order_by - test_order(('language', 'num_pages'), [1,3,2,4]) # as if: 'num_pages' - - # [bug] order_by did not run through setter when passed to init - books = BookTable([], order_by='name') - assert books.order_by == ('name',) - - # test table.order_by extensions - books.order_by = '' - assert books.order_by.polarize(False) == () - assert books.order_by.polarize(True) == () - assert books.order_by.toggle() == () - assert books.order_by.polarize(False, ['id']) == ('id',) - assert books.order_by.polarize(True, ['id']) == ('-id',) - assert books.order_by.toggle(['id']) == ('id',) - books.order_by = 'id,-name' - assert books.order_by.polarize(False, ['name']) == ('id', 'name') - assert books.order_by.polarize(True, ['name']) == ('id', '-name') - assert books.order_by.toggle(['name']) == ('id', 'name') - # ``in`` operator works - books.order_by = 'name' - assert 'name' in books.order_by - books.order_by = '-name' - assert 'name' in books.order_by - assert not 'language' in books.order_by - -def test_callable(): - """Data fields, ``default`` and ``data`` options can be callables. - """ - - class MathTable(tables.Table): - lhs = tables.Column() - rhs = tables.Column() - op = tables.Column(default='+') - sum = tables.Column(default=lambda d: calc(d['op'], d['lhs'], d['rhs'])) - sqrt = tables.Column(data=lambda d: int(sqrt(d['sum']))) - - math = MathTable([ - {'lhs': 1, 'rhs': lambda x: x['lhs']*3}, # 1+3 - {'lhs': 9, 'rhs': lambda x: x['lhs'], 'op': '/'}, # 9/9 - {'lhs': lambda x: x['rhs']+3, 'rhs': 4, 'op': '-'}, # 7-4 - ]) - - # function is called when queried - def calc(op, lhs, rhs): - if op == '+': return lhs+rhs - elif op == '/': return lhs/rhs - elif op == '-': return lhs-rhs - assert [calc(row['op'], row['lhs'], row['rhs']) for row in math] == [4,1,3] - - # field function is called while sorting - math.order_by = ('-rhs',) - assert [row['rhs'] for row in math] == [9,4,3] - - # default function is called while sorting - math.order_by = ('sum',) - assert [row['sum'] for row in math] == [1,3,4] - - # data function is called while sorting - math.order_by = ('sqrt',) - 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 - # exceptions are converted into 404s - assert_raises(Http404, books.paginate, Paginator, 10, page=9999) - assert_raises(Http404, books.paginate, Paginator, 10, page="abc") - - -# TODO: all the column stuff might warrant it's own test file -def test_columns(): - """Test Table.columns container functionality. - """ - - class BookTable(tables.Table): - id = tables.Column(sortable=False, visible=False) - name = tables.Column(sortable=True) - pages = tables.Column(sortable=True) - language = tables.Column(sortable=False) - books = BookTable([]) - - assert list(books.columns.sortable()) == [c for c in books.columns if c.sortable] - - # .columns iterator only yields visible columns - assert len(list(books.columns)) == 3 - # visiblity of columns can be changed at instance-time - books.columns['id'].visible = True - assert len(list(books.columns)) == 4 - - -def test_column_order(): - """Test the order functionality of bound columns. - """ - - class BookTable(tables.Table): - id = tables.Column() - name = tables.Column() - pages = tables.Column() - language = tables.Column() - books = BookTable([]) - - # the basic name property is a no-brainer - books.order_by = '' - assert [c.name for c in books.columns] == ['id','name','pages','language'] - - # name_reversed will always reverse, no matter what - for test in ['', 'name', '-name']: - books.order_by = test - assert [c.name_reversed for c in books.columns] == ['-id','-name','-pages','-language'] - - # name_toggled will always toggle - books.order_by = '' - assert [c.name_toggled for c in books.columns] == ['id','name','pages','language'] - books.order_by = 'id' - assert [c.name_toggled for c in books.columns] == ['-id','name','pages','language'] - books.order_by = '-name' - assert [c.name_toggled for c in books.columns] == ['id','name','pages','language'] - # other columns in an order_by will be dismissed - books.order_by = '-id,name' - assert [c.name_toggled for c in books.columns] == ['id','-name','pages','language'] - - # with multi-column order, this is slightly more complex - books.order_by = '' - assert [str(c.order_by) for c in books.columns] == ['id','name','pages','language'] - assert [str(c.order_by_reversed) for c in books.columns] == ['-id','-name','-pages','-language'] - assert [str(c.order_by_toggled) for c in books.columns] == ['id','name','pages','language'] - books.order_by = 'id' - assert [str(c.order_by) for c in books.columns] == ['id','id,name','id,pages','id,language'] - assert [str(c.order_by_reversed) for c in books.columns] == ['-id','id,-name','id,-pages','id,-language'] - assert [str(c.order_by_toggled) for c in books.columns] == ['-id','id,name','id,pages','id,language'] - books.order_by = '-pages,id' - assert [str(c.order_by) for c in books.columns] == ['-pages,id','-pages,id,name','pages,id','-pages,id,language'] - assert [str(c.order_by_reversed) for c in books.columns] == ['-pages,-id','-pages,id,-name','-pages,id','-pages,id,-language'] - assert [str(c.order_by_toggled) for c in books.columns] == ['-pages,-id','-pages,id,name','pages,id','-pages,id,language'] - - # querying whether a column is ordered is possible - books.order_by = '' - assert [c.is_ordered for c in books.columns] == [False, False, False, False] - books.order_by = 'name' - assert [c.is_ordered for c in books.columns] == [False, True, False, False] - assert [c.is_ordered_reverse for c in books.columns] == [False, False, False, False] - assert [c.is_ordered_straight for c in books.columns] == [False, True, False, False] - books.order_by = '-pages' - assert [c.is_ordered for c in books.columns] == [False, False, True, False] - assert [c.is_ordered_reverse for c in books.columns] == [False, False, True, False] - assert [c.is_ordered_straight for c in books.columns] == [False, False, False, False] - # and even works with multi-column ordering - books.order_by = 'id,-pages' - assert [c.is_ordered for c in books.columns] == [True, False, True, False] - assert [c.is_ordered_reverse for c in books.columns] == [False, False, True, False] - assert [c.is_ordered_straight for c in books.columns] == [True, False, False, False] \ No newline at end of file +"""Test the core table functionality. +""" + + +from nose.tools import assert_raises +from django.http import Http404 +from django.core.paginator import Paginator +import django_tables as tables +from django_tables.base import BaseTable + + +class TestTable(BaseTable): + pass + + +def test_declaration(): + """ + Test defining tables by declaration. + """ + + class GeoAreaTable(TestTable): + name = tables.Column() + population = tables.Column() + + assert len(GeoAreaTable.base_columns) == 2 + assert 'name' in GeoAreaTable.base_columns + assert not hasattr(GeoAreaTable, 'name') + + class CountryTable(GeoAreaTable): + capital = tables.Column() + + assert len(CountryTable.base_columns) == 3 + assert 'capital' in CountryTable.base_columns + + # multiple inheritance + class AddedMixin(TestTable): + added = tables.Column() + class CityTable(GeoAreaTable, AddedMixin): + mayer = tables.Column() + + assert len(CityTable.base_columns) == 4 + assert 'added' in CityTable.base_columns + + # modelforms: support switching from a non-model table hierarchy to a + # modeltable hierarchy (both base class orders) + class StateTable1(tables.ModelTable, GeoAreaTable): + motto = tables.Column() + class StateTable2(GeoAreaTable, tables.ModelTable): + motto = tables.Column() + + assert len(StateTable1.base_columns) == len(StateTable2.base_columns) == 3 + assert 'motto' in StateTable1.base_columns + assert 'motto' in StateTable2.base_columns + + +def test_pagination(): + class BookTable(TestTable): + 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 + # exceptions are converted into 404s + assert_raises(Http404, books.paginate, Paginator, 10, page=9999) + assert_raises(Http404, books.paginate, Paginator, 10, page="abc") \ No newline at end of file diff --git a/tests/test_memory.py b/tests/test_memory.py new file mode 100644 index 0000000..5c9cfdd --- /dev/null +++ b/tests/test_memory.py @@ -0,0 +1,325 @@ +"""Test the memory table functionality. + +TODO: A bunch of those tests probably fit better into test_basic, since +they aren't really MemoryTable specific. +""" + +from math import sqrt +from nose.tools import assert_raises +from django.core.paginator import Paginator +import django_tables as tables + + +def test_basic(): + class StuffTable(tables.MemoryTable): + name = tables.Column() + answer = tables.Column(default=42) + c = tables.Column(name="count", default=1) + email = tables.Column(data="@") + stuff = StuffTable([ + {'id': 1, 'name': 'Foo Bar', '@': 'foo@bar.org'}, + ]) + + # access without order_by works + stuff.data + stuff.rows + + # make sure BoundColumnn.name always gives us the right thing, whether + # the column explicitely defines a name or not. + stuff.columns['count'].name == 'count' + stuff.columns['answer'].name == 'answer' + + for r in stuff.rows: + # unknown fields are removed/not-accessible + assert 'name' in r + assert not 'id' in r + # missing data is available as default + assert 'answer' in r + assert r['answer'] == 42 # note: different from prev. line! + + # all that still works when name overrides are used + assert not 'c' in r + assert 'count' in r + assert r['count'] == 1 + + # columns with data= option work fine + assert r['email'] == 'foo@bar.org' + + # try to splice rows by index + assert 'name' in stuff.rows[0] + assert isinstance(stuff.rows[0:], list) + + # [bug] splicing the table gives us valid, working rows + assert list(stuff[0]) == list(stuff.rows[0]) + assert stuff[0]['name'] == 'Foo Bar' + + # changing an instance's base_columns does not change the class + assert id(stuff.base_columns) != id(StuffTable.base_columns) + stuff.base_columns['test'] = tables.Column() + assert not 'test' in StuffTable.base_columns + + # optionally, exceptions can be raised when input is invalid + tables.options.IGNORE_INVALID_OPTIONS = False + try: + assert_raises(ValueError, setattr, stuff, 'order_by', '-name,made-up-column') + assert_raises(ValueError, setattr, stuff, 'order_by', ('made-up-column',)) + # when a column name is overwritten, the original won't work anymore + assert_raises(ValueError, setattr, stuff, 'order_by', 'c') + # reset for future tests + finally: + tables.options.IGNORE_INVALID_OPTIONS = True + +def test_caches(): + """Ensure the various caches are effective. + """ + + class BookTable(tables.MemoryTable): + name = tables.Column() + answer = tables.Column(default=42) + books = BookTable([ + {'name': 'Foo: Bar'}, + ]) + + assert id(list(books.columns)[0]) == id(list(books.columns)[0]) + # TODO: row cache currently not used + #assert id(list(books.rows)[0]) == id(list(books.rows)[0]) + + # test that caches are reset after an update() + old_column_cache = id(list(books.columns)[0]) + old_row_cache = id(list(books.rows)[0]) + books.update() + assert id(list(books.columns)[0]) != old_column_cache + assert id(list(books.rows)[0]) != old_row_cache + +def test_meta_sortable(): + """Specific tests for sortable table meta option.""" + + def mktable(default_sortable): + class BookTable(tables.MemoryTable): + id = tables.Column(sortable=True) + name = tables.Column(sortable=False) + author = tables.Column() + class Meta: + sortable = default_sortable + return BookTable([]) + + global_table = mktable(None) + for default_sortable, results in ( + (None, (True, False, True)), # last bool is global default + (True, (True, False, True)), # last bool is table default + (False, (True, False, False)), # last bool is table default + ): + books = mktable(default_sortable) + assert [c.sortable for c in books.columns] == list(results) + + # it also works if the meta option is manually changed after + # class and instance creation + global_table._meta.sortable = default_sortable + assert [c.sortable for c in global_table.columns] == list(results) + +def test_sort(): + class BookTable(tables.MemoryTable): + id = tables.Column(direction='desc') + name = tables.Column() + pages = tables.Column(name='num_pages') # test rewritten names + language = tables.Column(default='en') # default affects sorting + rating = tables.Column(data='*') # test data field option + + books = BookTable([ + {'id': 1, 'pages': 60, 'name': 'Z: The Book', '*': 5}, # language: en + {'id': 2, 'pages': 100, 'language': 'de', 'name': 'A: The Book', '*': 2}, + {'id': 3, 'pages': 80, 'language': 'de', 'name': 'A: The Book, Vol. 2', '*': 4}, + {'id': 4, 'pages': 110, 'language': 'fr', 'name': 'A: The Book, French Edition'}, # rating (with data option) is missing + ]) + + # None is normalized to an empty order by tuple, ensuring iterability; + # it also supports all the cool methods that we offer for order_by. + # This is true for the default case... + assert books.order_by == () + iter(books.order_by) + assert hasattr(books.order_by, 'toggle') + # ...as well as when explicitly set to None. + books.order_by = None + assert books.order_by == () + iter(books.order_by) + assert hasattr(books.order_by, 'toggle') + + # test various orderings + def test_order(order, result): + books.order_by = order + assert [b['id'] for b in books.rows] == result + test_order(('num_pages',), [1,3,2,4]) + test_order(('-num_pages',), [4,2,3,1]) + test_order(('name',), [2,4,3,1]) + test_order(('language', 'num_pages'), [3,2,1,4]) + # using a simple string (for convinience as well as querystring passing + test_order('-num_pages', [4,2,3,1]) + test_order('language,num_pages', [3,2,1,4]) + # if overwritten, the declared fieldname has no effect + test_order('pages,name', [2,4,3,1]) # == ('name',) + # sort by column with "data" option + test_order('rating', [4,2,3,1]) + + # test the column with a default ``direction`` set to descending + test_order('id', [4,3,2,1]) + test_order('-id', [1,2,3,4]) + # changing the direction afterwards is fine too + books.base_columns['id'].direction = 'asc' + test_order('id', [1,2,3,4]) + test_order('-id', [4,3,2,1]) + # a invalid direction string raises an exception + assert_raises(ValueError, setattr, books.base_columns['id'], 'direction', 'blub') + + # [bug] test alternative order formats if passed to constructor + BookTable([], 'language,-num_pages') + + # test invalid order instructions + books.order_by = 'xyz' + assert not books.order_by + books.base_columns['language'].sortable = False + books.order_by = 'language' + assert not books.order_by + test_order(('language', 'num_pages'), [1,3,2,4]) # as if: 'num_pages' + + # [bug] order_by did not run through setter when passed to init + books = BookTable([], order_by='name') + assert books.order_by == ('name',) + + # test table.order_by extensions + books.order_by = '' + assert books.order_by.polarize(False) == () + assert books.order_by.polarize(True) == () + assert books.order_by.toggle() == () + assert books.order_by.polarize(False, ['id']) == ('id',) + assert books.order_by.polarize(True, ['id']) == ('-id',) + assert books.order_by.toggle(['id']) == ('id',) + books.order_by = 'id,-name' + assert books.order_by.polarize(False, ['name']) == ('id', 'name') + assert books.order_by.polarize(True, ['name']) == ('id', '-name') + assert books.order_by.toggle(['name']) == ('id', 'name') + # ``in`` operator works + books.order_by = 'name' + assert 'name' in books.order_by + books.order_by = '-name' + assert 'name' in books.order_by + assert not 'language' in books.order_by + +def test_callable(): + """Data fields, ``default`` and ``data`` options can be callables. + """ + + class MathTable(tables.MemoryTable): + lhs = tables.Column() + rhs = tables.Column() + op = tables.Column(default='+') + sum = tables.Column(default=lambda d: calc(d['op'], d['lhs'], d['rhs'])) + sqrt = tables.Column(data=lambda d: int(sqrt(d['sum']))) + + math = MathTable([ + {'lhs': 1, 'rhs': lambda x: x['lhs']*3}, # 1+3 + {'lhs': 9, 'rhs': lambda x: x['lhs'], 'op': '/'}, # 9/9 + {'lhs': lambda x: x['rhs']+3, 'rhs': 4, 'op': '-'}, # 7-4 + ]) + + # function is called when queried + def calc(op, lhs, rhs): + if op == '+': return lhs+rhs + elif op == '/': return lhs/rhs + elif op == '-': return lhs-rhs + assert [calc(row['op'], row['lhs'], row['rhs']) for row in math] == [4,1,3] + + # field function is called while sorting + math.order_by = ('-rhs',) + assert [row['rhs'] for row in math] == [9,4,3] + + # default function is called while sorting + math.order_by = ('sum',) + assert [row['sum'] for row in math] == [1,3,4] + + # data function is called while sorting + math.order_by = ('sqrt',) + assert [row['sqrt'] for row in math] == [1,1,2] + + +# TODO: all the column stuff might warrant it's own test file +def test_columns(): + """Test Table.columns container functionality. + """ + + class BookTable(tables.MemoryTable): + id = tables.Column(sortable=False, visible=False) + name = tables.Column(sortable=True) + pages = tables.Column(sortable=True) + language = tables.Column(sortable=False) + books = BookTable([]) + + assert list(books.columns.sortable()) == [c for c in books.columns if c.sortable] + + # .columns iterator only yields visible columns + assert len(list(books.columns)) == 3 + # visiblity of columns can be changed at instance-time + books.columns['id'].visible = True + assert len(list(books.columns)) == 4 + + +def test_column_order(): + """Test the order functionality of bound columns. + """ + + class BookTable(tables.MemoryTable): + id = tables.Column() + name = tables.Column() + pages = tables.Column() + language = tables.Column() + books = BookTable([]) + + # the basic name property is a no-brainer + books.order_by = '' + assert [c.name for c in books.columns] == ['id','name','pages','language'] + + # name_reversed will always reverse, no matter what + for test in ['', 'name', '-name']: + books.order_by = test + assert [c.name_reversed for c in books.columns] == ['-id','-name','-pages','-language'] + + # name_toggled will always toggle + books.order_by = '' + assert [c.name_toggled for c in books.columns] == ['id','name','pages','language'] + books.order_by = 'id' + assert [c.name_toggled for c in books.columns] == ['-id','name','pages','language'] + books.order_by = '-name' + assert [c.name_toggled for c in books.columns] == ['id','name','pages','language'] + # other columns in an order_by will be dismissed + books.order_by = '-id,name' + assert [c.name_toggled for c in books.columns] == ['id','-name','pages','language'] + + # with multi-column order, this is slightly more complex + books.order_by = '' + assert [str(c.order_by) for c in books.columns] == ['id','name','pages','language'] + assert [str(c.order_by_reversed) for c in books.columns] == ['-id','-name','-pages','-language'] + assert [str(c.order_by_toggled) for c in books.columns] == ['id','name','pages','language'] + books.order_by = 'id' + assert [str(c.order_by) for c in books.columns] == ['id','id,name','id,pages','id,language'] + assert [str(c.order_by_reversed) for c in books.columns] == ['-id','id,-name','id,-pages','id,-language'] + assert [str(c.order_by_toggled) for c in books.columns] == ['-id','id,name','id,pages','id,language'] + books.order_by = '-pages,id' + assert [str(c.order_by) for c in books.columns] == ['-pages,id','-pages,id,name','pages,id','-pages,id,language'] + assert [str(c.order_by_reversed) for c in books.columns] == ['-pages,-id','-pages,id,-name','-pages,id','-pages,id,-language'] + assert [str(c.order_by_toggled) for c in books.columns] == ['-pages,-id','-pages,id,name','pages,id','-pages,id,language'] + + # querying whether a column is ordered is possible + books.order_by = '' + assert [c.is_ordered for c in books.columns] == [False, False, False, False] + books.order_by = 'name' + assert [c.is_ordered for c in books.columns] == [False, True, False, False] + assert [c.is_ordered_reverse for c in books.columns] == [False, False, False, False] + assert [c.is_ordered_straight for c in books.columns] == [False, True, False, False] + books.order_by = '-pages' + assert [c.is_ordered for c in books.columns] == [False, False, True, False] + assert [c.is_ordered_reverse for c in books.columns] == [False, False, True, False] + assert [c.is_ordered_straight for c in books.columns] == [False, False, False, False] + # and even works with multi-column ordering + books.order_by = 'id,-pages' + assert [c.is_ordered for c in books.columns] == [True, False, True, False] + assert [c.is_ordered_reverse for c in books.columns] == [False, False, True, False] + assert [c.is_ordered_straight for c in books.columns] == [True, False, False, False] \ No newline at end of file diff --git a/tests/test_templates.py b/tests/test_templates.py index 8ac15b7..21b8a0b 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -11,7 +11,7 @@ from django.http import HttpRequest import django_tables as tables def test_order_by(): - class BookTable(tables.Table): + class BookTable(tables.MemoryTable): id = tables.Column() name = tables.Column() books = BookTable([ @@ -25,7 +25,7 @@ def test_order_by(): assert str(books.order_by) == 'name,-id' def test_columns_and_rows(): - class CountryTable(tables.Table): + class CountryTable(tables.MemoryTable): name = tables.TextColumn() capital = tables.TextColumn(sortable=False) population = tables.NumberColumn(verbose_name="Population Size") @@ -71,7 +71,7 @@ def test_columns_and_rows(): def test_render(): """For good measure, render some actual templates.""" - class CountryTable(tables.Table): + class CountryTable(tables.MemoryTable): name = tables.TextColumn() capital = tables.TextColumn() population = tables.NumberColumn(verbose_name="Population Size") @@ -101,7 +101,7 @@ def test_templatetags(): add_to_builtins('django_tables.app.templatetags.tables') # [bug] set url param tag handles an order_by tuple with multiple columns - class MyTable(tables.Table): + class MyTable(tables.MemoryTable): f1 = tables.Column() f2 = tables.Column() t = Template('{% set_url_param x=table.order_by %}') -- 2.26.2