From 9f7eebd21400b73cc5b491947e409f801646380c Mon Sep 17 00:00:00 2001 From: Jan Pieter Waagmeester Date: Fri, 24 Feb 2017 17:59:05 +0100 Subject: [PATCH] Fixing 413 (#424) * Added (failing) unit test for #413 * Rough refactor of TableData into two different classes to declutter the ordering implementation * Move ordering related tests to separate file * Reorganize tests a bit * Fix #413, ordering by custum field raises error --- django_tables2/columns/base.py | 15 +- django_tables2/tables.py | 191 +++++++++++++------- tests/test_core.py | 284 +----------------------------- tests/test_models.py | 6 +- tests/test_ordering.py | 313 +++++++++++++++++++++++++++++++++ tests/test_tabledata.py | 109 ++++++++++++ 6 files changed, 568 insertions(+), 350 deletions(-) create mode 100644 tests/test_ordering.py create mode 100644 tests/test_tabledata.py diff --git a/django_tables2/columns/base.py b/django_tables2/columns/base.py index fc3bafae..6c1eea23 100644 --- a/django_tables2/columns/base.py +++ b/django_tables2/columns/base.py @@ -6,7 +6,6 @@ from django.utils import six from django.utils.safestring import SafeData - from django_tables2.templatetags.django_tables2 import title from django_tables2.utils import (Accessor, AttributeDict, OrderBy, OrderByTuple, call_with_appropriate) @@ -211,7 +210,8 @@ def order(self, queryset, is_descending): table or by subclassing `.Column`; but only overrides if second element in return tuple is True. - :returns: Tuple (queryset, boolean) + returns: + Tuple (queryset, boolean) ''' return (queryset, False) @@ -220,9 +220,10 @@ def from_field(cls, field): ''' Return a specialised column for the model field or `None`. - :param field: the field that needs a suitable column - :type field: model field instance - :returns: `.Column` object or `None` + Arguments: + field (Model Field instance): the field that needs a suitable column + Returns: + `.Column` object or `None` If the column isn't specialised for the given model field, it should return `None`. This gives other columns the opportunity to do better. @@ -474,8 +475,8 @@ def verbose_name(self): name = self.name.replace('_', ' ') # Try to use a model field's verbose_name - if hasattr(self.table.data, 'queryset') and hasattr(self.table.data.queryset, 'model'): - model = self.table.data.queryset.model + model = self.table.data.get_model() + if model: field = Accessor(self.accessor).get_field(model) if field: if hasattr(field, 'field'): diff --git a/django_tables2/tables.py b/django_tables2/tables.py index 9947bc13..e6a3720b 100644 --- a/django_tables2/tables.py +++ b/django_tables2/tables.py @@ -20,44 +20,123 @@ class TableData(object): ''' - Exposes a consistent API for :term:`table data`. - - Arguments: - data (`~django.db.query.QuerySet` or `list` of `dict`): iterable - containing data for each row - table (`~.Table`) + Base class for table data containers. ''' def __init__(self, data, table): + self.data = data self.table = table - # data may be a QuerySet-like objects with count() and order_by() - if (hasattr(data, 'count') and callable(data.count) and - hasattr(data, 'order_by') and callable(data.order_by)): - self.queryset = data - return - # do some light validation - if hasattr(data, '__iter__') or (hasattr(data, '__len__') and hasattr(data, '__getitem__')): - self.list = list(data) - return + def __getitem__(self, key): + ''' + Slicing returns a new `.TableData` instance, indexing returns a + single record. + ''' + return self.data[key] + + def get_model(self): + return getattr(self.data, 'model', None) + + @property + def ordering(self): + return None + + @property + def verbose_name(self): + return 'item' + + @property + def verbose_name_plural(self): + return 'items' + + @staticmethod + def from_data(data, table): + if TableQuerysetData.validate(data): + return TableQuerysetData(data, table) + elif TableListData.validate(data): + return TableListData(list(data), table) raise ValueError( 'data must be QuerySet-like (have count() and order_by()) or support' ' list(data) -- {} has neither'.format(type(data).__name__) ) + +class TableListData(TableData): + ''' + Table data container for a list of dicts, for example:: + + [ + {'name': 'John', 'age': 20}, + {'name': 'Brian', 'age': 25} + ] + + .. note:: + + Other structures might have worked in the past, but are not explicitly + supported or tested. + ''' + + @staticmethod + def validate(data): + ''' + Validates `data` for use in this container + ''' + return ( + hasattr(data, '__iter__') or + (hasattr(data, '__len__') and hasattr(data, '__getitem__')) + ) + + def __len__(self): + return len(self.data) + + def order_by(self, aliases): + ''' + Order the data based on order by aliases (prefixed column names) in the + table. + + Arguments: + aliases (`~.utils.OrderByTuple`): optionally prefixed names of + columns ('-' indicates descending order) in order of + significance with regard to data ordering. + ''' + accessors = [] + for alias in aliases: + bound_column = self.table.columns[OrderBy(alias).bare] + + # bound_column.order_by reflects the current ordering applied to + # the table. As such we need to check the current ordering on the + # column and use the opposite if it doesn't match the alias prefix. + if alias[0] != bound_column.order_by_alias[0]: + accessors += bound_column.order_by.opposite + else: + accessors += bound_column.order_by + + self.data.sort(key=OrderByTuple(accessors).key) + + +class TableQuerysetData(TableData): + ''' + Table data container for a queryset. + ''' + + @staticmethod + def validate(data): + ''' + Validates `data` for use in this container + ''' + return ( + hasattr(data, 'count') and callable(data.count) and + hasattr(data, 'order_by') and callable(data.order_by) + ) + def __len__(self): if not hasattr(self, '_length'): # 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. - self._length = ( - self.queryset.count() if hasattr(self, 'queryset') else len(self.list) - ) - return self._length + self._length = self.data.count() - @property - def data(self): - return self.queryset if hasattr(self, 'queryset') else self.list + return self._length @property def ordering(self): @@ -71,14 +150,14 @@ def ordering(self): This works by inspecting the actual underlying data. As such it's only supported for querysets. ''' - if hasattr(self, 'queryset'): - aliases = {} - for bound_column in self.table.columns: - aliases[bound_column.order_by_alias] = bound_column.order_by - try: - return next(segment(self.queryset.query.order_by, aliases)) - except StopIteration: - pass + + aliases = {} + for bound_column in self.table.columns: + aliases[bound_column.order_by_alias] = bound_column.order_by + try: + return next(segment(self.data.query.order_by, aliases)) + except StopIteration: + pass def order_by(self, aliases): ''' @@ -90,7 +169,7 @@ def order_by(self, aliases): columns ('-' indicates descending order) in order of significance with regard to data ordering. ''' - bound_column = None + modified_any = False accessors = [] for alias in aliases: bound_column = self.table.columns[OrderBy(alias).bare] @@ -102,51 +181,39 @@ def order_by(self, aliases): else: accessors += bound_column.order_by - if hasattr(self, 'queryset'): - # Custom ordering if bound_column: - self.queryset, modified = bound_column.order(self.queryset, alias[0] == '-') + queryset, modified = bound_column.order(self.data, alias[0] == '-') + if modified: - return - # Traditional ordering - if accessors: - order_by_accessors = (a.for_queryset() for a in accessors) - self.queryset = self.queryset.order_by(*order_by_accessors) - else: - self.list.sort(key=OrderByTuple(accessors).key) + self.data = queryset + modified_any = True - def __getitem__(self, key): - ''' - Slicing returns a new `.TableData` instance, indexing returns a - single record. - ''' - return self.data[key] + # custom ordering + if modified_any: + return True + + # Traditional ordering + if accessors: + order_by_accessors = (a.for_queryset() for a in accessors) + self.data = self.data.order_by(*order_by_accessors) @cached_property def verbose_name(self): ''' The full (singular) name for the data. - Queryset data has its model's `~django.db.Model.Meta.verbose_name` - honored. List data is checked for a `verbose_name` attribute, and - falls back to using `'item'`. + Model's `~django.db.Model.Meta.verbose_name` is honored. ''' - if hasattr(self, 'queryset'): - return self.queryset.model._meta.verbose_name - - return getattr(self.list, 'verbose_name', 'item') + return self.data.model._meta.verbose_name @cached_property def verbose_name_plural(self): ''' - The full (plural) name of the data. + The full (plural) name for the data. - This uses the same approach as `TableData.verbose_name`. + Model's `~django.db.Model.Meta.verbose_name` is honored. ''' - if hasattr(self, 'queryset'): - return self.queryset.model._meta.verbose_name_plural - - return getattr(self.list, 'verbose_name_plural', 'items') + return self.data.model._meta.verbose_name_plural class DeclarativeColumnsMetaclass(type): @@ -341,8 +408,6 @@ class TableBase(object): show_footer (bool): If `False`, the table footer will not be rendered, even if some columns have a footer, defaults to `True`. ''' - TableDataClass = TableData - def __init__(self, data, order_by=None, orderable=None, empty_text=None, exclude=None, attrs=None, row_attrs=None, sequence=None, prefix=None, order_by_field=None, page_field=None, @@ -351,7 +416,7 @@ def __init__(self, data, order_by=None, orderable=None, empty_text=None, super(TableBase, self).__init__() self.exclude = exclude or self._meta.exclude self.sequence = sequence - self.data = self.TableDataClass(data=data, table=self) + self.data = TableData.from_data(data=data, table=self) if default is None: default = self._meta.default self.default = default diff --git a/tests/test_core.py b/tests/test_core.py index b1336865..7e68a1a1 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -7,8 +7,7 @@ import django_tables2 as tables from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator -from django.utils import six -from django_tables2.tables import DeclarativeColumnsMetaclass +from django_tables2.tables import DeclarativeColumnsMetaclass, RequestConfig import pytest @@ -16,6 +15,12 @@ request = build_request('/') +MEMORY_DATA = [ + {'i': 2, 'alpha': 'b', 'beta': 'b'}, + {'i': 1, 'alpha': 'a', 'beta': 'c'}, + {'i': 3, 'alpha': 'c', 'beta': 'a'}, +] + class UnorderedTable(tables.Table): i = tables.Column() @@ -28,13 +33,6 @@ class Meta: order_by = 'alpha' -MEMORY_DATA = [ - {'i': 2, 'alpha': 'b', 'beta': 'b'}, - {'i': 1, 'alpha': 'a', 'beta': 'c'}, - {'i': 3, 'alpha': 'c', 'beta': 'a'}, -] - - def test_column_named_items(): ''' A column named items must not make the table fail @@ -152,12 +150,6 @@ class Meta: assert {'id': 'test_table_1'} == TestTable([]).attrs -def test_data_knows_its_name(): - table = tables.Table([{}]) - assert table.data.verbose_name == 'item' - assert table.data.verbose_name_plural == 'items' - - def test_datasource_untouched(): ''' Ensure that data that is provided to the table (the datasource) is not @@ -198,156 +190,6 @@ class PersonTable(tables.Table): table.as_html(request) -def test_data_validation(): - with pytest.raises(ValueError): - table = OrderedTable(None) - - class Bad: - def __len__(self): - pass - - with pytest.raises(ValueError): - table = OrderedTable(Bad()) - - class Ok: - def __len__(self): - return 1 - - def __getitem__(self, pos): - if pos != 0: - raise IndexError() - return {'a': 1} - - table = OrderedTable(Ok()) - assert len(table.rows) == 1 - - -def test_ordering(): - # fallback to Table.Meta - assert ('alpha', ) == OrderedTable([], order_by=None).order_by == OrderedTable([]).order_by - - # values of order_by are wrapped in tuples before being returned - assert OrderedTable([], order_by='alpha').order_by == ('alpha', ) - assert OrderedTable([], order_by=('beta', )).order_by == ('beta', ) - - table = OrderedTable([]) - table.order_by = [] - assert () == table.order_by == OrderedTable([], order_by=[]).order_by - - table = OrderedTable([]) - table.order_by = () - assert () == table.order_by == OrderedTable([], order_by=()).order_by - - table = OrderedTable([]) - table.order_by = '' - assert () == table.order_by == OrderedTable([], order_by='').order_by - - # apply an ordering - table = UnorderedTable([]) - table.order_by = 'alpha' - assert ('alpha', ) == UnorderedTable([], order_by='alpha').order_by == table.order_by - - table = OrderedTable([]) - table.order_by = 'alpha' - assert ('alpha', ) == OrderedTable([], order_by='alpha').order_by == table.order_by - - # let's check the data - table = OrderedTable(MEMORY_DATA, order_by='beta') - assert 3 == table.rows[0].get_cell('i') - - table = OrderedTable(MEMORY_DATA, order_by='-beta') - assert 1 == table.rows[0].get_cell('i') - - # allow fallback to Table.Meta.order_by - table = OrderedTable(MEMORY_DATA) - assert 1 == table.rows[0].get_cell('i') - - # column's can't be ordered if they're not allowed to be - class TestTable2(tables.Table): - a = tables.Column(orderable=False) - b = tables.Column() - - table = TestTable2([], order_by='a') - assert table.order_by == () - - table = TestTable2([], order_by='b') - assert table.order_by == ('b', ) - - # ordering disabled by default - class TestTable3(tables.Table): - a = tables.Column(orderable=True) - b = tables.Column() - - class Meta: - orderable = False - - table = TestTable3([], order_by='a') - assert table.order_by == ('a', ) - - table = TestTable3([], order_by='b') - assert table.order_by == () - - table = TestTable3([], orderable=True, order_by='b') - assert table.order_by == ('b', ) - - -def test_ordering_different_types(): - from datetime import datetime - - data = [ - {'i': 1, 'alpha': datetime.now(), 'beta': [1]}, - {'i': {}, 'alpha': None, 'beta': ''}, - {'i': 2, 'alpha': None, 'beta': []}, - ] - - table = OrderedTable(data) - assert "—" == table.rows[0].get_cell('alpha') - - table = OrderedTable(data, order_by='i') - if six.PY3: - assert {} == table.rows[0].get_cell('i') - else: - assert 1 == table.rows[0].get_cell('i') - - table = OrderedTable(data, order_by='beta') - assert [] == table.rows[0].get_cell('beta') - - -def test_multi_column_ordering(): - brad = {'first_name': 'Bradley', 'last_name': 'Ayers'} - brad2 = {'first_name': 'Bradley', 'last_name': 'Fake'} - chris = {'first_name': 'Chris', 'last_name': 'Doble'} - davina = {'first_name': 'Davina', 'last_name': 'Adisusila'} - ross = {'first_name': 'Ross', 'last_name': 'Ayers'} - - people = [brad, brad2, chris, davina, ross] - - class PersonTable(tables.Table): - first_name = tables.Column() - last_name = tables.Column() - - table = PersonTable(people, order_by=('first_name', 'last_name')) - assert [brad, brad2, chris, davina, ross] == [r.record for r in table.rows] - - table = PersonTable(people, order_by=('first_name', '-last_name')) - assert [brad2, brad, chris, davina, ross] == [r.record for r in table.rows] - - # let's try column order_by using multiple keys - class PersonTable(tables.Table): - name = tables.Column(order_by=('first_name', 'last_name')) - - # add 'name' key for each person. - for person in people: - person['name'] = '{p[first_name]} {p[last_name]}'.format(p=person) - assert brad['name'] == 'Bradley Ayers' - - table = PersonTable(people, order_by='name') - assert [brad, brad2, chris, davina, ross] == [r.record for r in table.rows] - - table = PersonTable(people, order_by='-name') - assert [ross, davina, chris, brad2, brad] == [r.record for r in table.rows] - - def test_column_count(): class SimpleTable(tables.Table): visible = tables.Column(visible=True) @@ -656,42 +498,6 @@ class Table(tables.Table): assert table.rows[0].get_cell('name') == 'efgh' -def test_list_table_data_supports_ordering(): - class Table(tables.Table): - name = tables.Column() - - data = [ - {'name': 'Bradley'}, - {'name': 'Davina'}, - ] - - table = Table(data) - assert table.rows[0].get_cell('name') == 'Bradley' - table.order_by = '-name' - assert table.rows[0].get_cell('name') == 'Davina' - - -def test_ordering_non_database_data(): - class Table(tables.Table): - name = tables.Column() - country = tables.Column() - - data = [ - {'name': 'Adrian', 'country': 'Australia'}, - {'name': 'Adrian', 'country': 'Brazil'}, - {'name': 'Audrey', 'country': 'Chile'}, - {'name': 'Bassie', 'country': 'Belgium'}, - ] - table = Table(data, order_by=('-name', '-country')) - - assert table.rows[0].get_cell('name') == 'Bassie' - assert table.rows[1].get_cell('name') == 'Audrey' - assert table.rows[2].get_cell('name') == 'Adrian' - assert table.rows[2].get_cell('country') == 'Brazil' - assert table.rows[3].get_cell('name') == 'Adrian' - assert table.rows[3].get_cell('country') == 'Australia' - - def test_as_values(): class Table(tables.Table): name = tables.Column() @@ -729,82 +535,6 @@ class Table(tables.Table): assert table.as_values() == expected -def test_table_ordering_attributes(): - class Table(tables.Table): - alpha = tables.Column() - beta = tables.Column() - - table = Table(MEMORY_DATA, attrs={ - 'th': { - 'class': 'custom-header-class', - '_ordering': { - 'orderable': 'sortable', - 'ascending': 'ascend', - 'descending': 'descend', - }, - }, - }, order_by='alpha') - - assert 'sortable' in table.columns[0].attrs['th']['class'] - assert 'ascend' in table.columns[0].attrs['th']['class'] - assert 'custom-header-class' in table.columns[1].attrs['th']['class'] - - -def test_table_ordering_attributes_in_meta(): - class Table(tables.Table): - alpha = tables.Column() - beta = tables.Column() - - class Meta(OrderedTable.Meta): - attrs = { - 'th': { - 'class': 'custom-header-class-in-meta', - '_ordering': { - 'orderable': 'sortable', - 'ascending': 'ascend', - 'descending': 'descend', - }, - } - } - - table = Table(MEMORY_DATA) - - assert 'sortable' in table.columns[0].attrs['th']['class'] - assert 'ascend' in table.columns[0].attrs['th']['class'] - assert 'custom-header-class-in-meta' in table.columns[1].attrs['th']['class'] - - -def test_column_ordering_attributes(): - class Table(tables.Table): - alpha = tables.Column(attrs={ - 'th': { - 'class': 'custom-header-class', - '_ordering': { - 'orderable': 'sort', - 'ascending': 'ascending' - } - } - }) - beta = tables.Column(attrs={ - 'th': { - '_ordering': { - 'orderable': 'canOrder', - } - }, - 'td': { - 'class': 'cell-2' - } - }) - - table = Table(MEMORY_DATA, attrs={'class': 'only-on-table'}, order_by='alpha') - - assert 'only-on-table' not in table.columns[0].attrs['th']['class'] - assert 'custom-header-class' in table.columns[0].attrs['th']['class'] - assert 'ascending' in table.columns[0].attrs['th']['class'] - assert 'sort' in table.columns[0].attrs['th']['class'] - assert 'canOrder' in table.columns[1].attrs['th']['class'] - - def test_row_attrs(): class Table(tables.Table): alpha = tables.Column() diff --git a/tests/test_models.py b/tests/test_models.py index 2ce0b0e9..a3092931 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,10 +1,9 @@ # coding: utf-8 +import django_tables2 as tables import pytest from django.db.models.functions import Length from django.utils import six -import django_tables2 as tables - from .app.models import Occupation, Person, PersonProxy from .utils import build_request @@ -119,6 +118,7 @@ class PersonTable(tables.Table): # however both fields that use the ``first_name`` field should just use a # titlised version of the column name as the column header. table = PersonTable(Person.objects.all()) + # Should be generated (capitalized column name) assert 'First Name' == table.columns['first_name'].verbose_name assert 'First Name' == table.columns['fn1'].verbose_name @@ -147,7 +147,7 @@ class Meta: model = Person # Issue #16 - table = PersonTable([]) + table = PersonTable(Person.objects.all()) assert 'Translation Test' == table.columns['trans_test'].verbose_name assert 'Translation Test Lazy' == table.columns['trans_test_lazy'].verbose_name assert 'Web Site' == table.columns['website'].verbose_name diff --git a/tests/test_ordering.py b/tests/test_ordering.py new file mode 100644 index 00000000..e47306fc --- /dev/null +++ b/tests/test_ordering.py @@ -0,0 +1,313 @@ +# coding: utf-8 +from __future__ import absolute_import, unicode_literals + +import django_tables2 as tables +from django.utils import six +from django_tables2.tables import RequestConfig + +import pytest + +from .app.models import Person +from .utils import build_request + +request = build_request('/') + + +MEMORY_DATA = [ + {'i': 2, 'alpha': 'b', 'beta': 'b'}, + {'i': 1, 'alpha': 'a', 'beta': 'c'}, + {'i': 3, 'alpha': 'c', 'beta': 'a'}, +] + + +class UnorderedTable(tables.Table): + i = tables.Column() + alpha = tables.Column() + beta = tables.Column() + + +class OrderedTable(UnorderedTable): + class Meta: + order_by = 'alpha' + + +def test_ordering(): + # fallback to Table.Meta + assert ('alpha', ) == OrderedTable([], order_by=None).order_by == OrderedTable([]).order_by + + # values of order_by are wrapped in tuples before being returned + assert OrderedTable([], order_by='alpha').order_by == ('alpha', ) + assert OrderedTable([], order_by=('beta', )).order_by == ('beta', ) + + table = OrderedTable([]) + table.order_by = [] + assert () == table.order_by == OrderedTable([], order_by=[]).order_by + + table = OrderedTable([]) + table.order_by = () + assert () == table.order_by == OrderedTable([], order_by=()).order_by + + table = OrderedTable([]) + table.order_by = '' + assert () == table.order_by == OrderedTable([], order_by='').order_by + + # apply an ordering + table = UnorderedTable([]) + table.order_by = 'alpha' + assert ('alpha', ) == UnorderedTable([], order_by='alpha').order_by == table.order_by + + table = OrderedTable([]) + table.order_by = 'alpha' + assert ('alpha', ) == OrderedTable([], order_by='alpha').order_by == table.order_by + + # let's check the data + table = OrderedTable(MEMORY_DATA, order_by='beta') + assert 3 == table.rows[0].get_cell('i') + + table = OrderedTable(MEMORY_DATA, order_by='-beta') + assert 1 == table.rows[0].get_cell('i') + + # allow fallback to Table.Meta.order_by + table = OrderedTable(MEMORY_DATA) + assert 1 == table.rows[0].get_cell('i') + + # column's can't be ordered if they're not allowed to be + class TestTable2(tables.Table): + a = tables.Column(orderable=False) + b = tables.Column() + + table = TestTable2([], order_by='a') + assert table.order_by == () + + table = TestTable2([], order_by='b') + assert table.order_by == ('b', ) + + # ordering disabled by default + class TestTable3(tables.Table): + a = tables.Column(orderable=True) + b = tables.Column() + + class Meta: + orderable = False + + table = TestTable3([], order_by='a') + assert table.order_by == ('a', ) + + table = TestTable3([], order_by='b') + assert table.order_by == () + + table = TestTable3([], orderable=True, order_by='b') + assert table.order_by == ('b', ) + + +def test_ordering_different_types(): + from datetime import datetime + + data = [ + {'i': 1, 'alpha': datetime.now(), 'beta': [1]}, + {'i': {}, 'alpha': None, 'beta': ''}, + {'i': 2, 'alpha': None, 'beta': []}, + ] + + table = OrderedTable(data) + assert '—' == table.rows[0].get_cell('alpha') + + table = OrderedTable(data, order_by='i') + if six.PY3: + assert {} == table.rows[0].get_cell('i') + else: + assert 1 == table.rows[0].get_cell('i') + + table = OrderedTable(data, order_by='beta') + assert [] == table.rows[0].get_cell('beta') + + +brad = {'first_name': 'Bradley', 'last_name': 'Ayers'} +brad2 = {'first_name': 'Bradley', 'last_name': 'Fake'} +chris = {'first_name': 'Chris', 'last_name': 'Doble'} +davina = {'first_name': 'Davina', 'last_name': 'Adisusila'} +ross = {'first_name': 'Ross', 'last_name': 'Ayers'} + +people = [brad, brad2, chris, davina, ross] + + +def test_multi_column_ordering_by_table(): + + class PersonTable(tables.Table): + first_name = tables.Column() + last_name = tables.Column() + + table = PersonTable(people, order_by=('first_name', 'last_name')) + assert [brad, brad2, chris, davina, ross] == [r.record for r in table.rows] + + table = PersonTable(people, order_by=('first_name', '-last_name')) + assert [brad2, brad, chris, davina, ross] == [r.record for r in table.rows] + + +def test_multi_column_ordering_by_column(): + # let's try column order_by using multiple keys + class PersonTable(tables.Table): + name = tables.Column(order_by=('first_name', 'last_name')) + + # add 'name' key for each person. + for person in people: + person['name'] = '{p[first_name]} {p[last_name]}'.format(p=person) + assert brad['name'] == 'Bradley Ayers' + + table = PersonTable(people, order_by='name') + assert [brad, brad2, chris, davina, ross] == [r.record for r in table.rows] + + table = PersonTable(people, order_by='-name') + assert [ross, davina, chris, brad2, brad] == [r.record for r in table.rows] + + +@pytest.mark.django_db +def test_ordering_by_custom_field(): + ''' + When defining a custom field in a table, as name=tables.Column() with + methods to render and order render_name and order_name, sorting by this + column causes an error if the custom field is not in last position. + (issue #413) + ''' + + Person.objects.create(first_name='Alice', last_name='Beta') + Person.objects.create(first_name='Bob', last_name='Alpha') + + from django.db.models import F, Value + from django.db.models.functions import Concat + + class PersonTable(tables.Table): + first_name = tables.Column() + last_name = tables.Column() + full_name = tables.Column() + + def render_full_name(self, record): + return record.last_name + ' ' + record.first_name + + def order_full_name(self, queryset, is_descending): + queryset = queryset.annotate( + full_name=Concat(F('last_name'), Value(' '), F('first_name')) + ).order_by(('-' if is_descending else '') + 'full_name') + return queryset, True + + class Meta: + model = Person + fields = ('first_name', 'last_name', 'full_name') + + table = PersonTable(Person.objects.all()) + request = build_request('/?sort=full_name&sort=first_name') + RequestConfig(request).configure(table) + + assert table.rows[0].record.first_name == 'Bob' + + +def test_list_table_data_supports_ordering(): + class Table(tables.Table): + name = tables.Column() + + data = [ + {'name': 'Bradley'}, + {'name': 'Davina'}, + ] + + table = Table(data) + assert table.rows[0].get_cell('name') == 'Bradley' + table.order_by = '-name' + assert table.rows[0].get_cell('name') == 'Davina' + + +def test_ordering_non_database_data(): + class Table(tables.Table): + name = tables.Column() + country = tables.Column() + + data = [ + {'name': 'Adrian', 'country': 'Australia'}, + {'name': 'Adrian', 'country': 'Brazil'}, + {'name': 'Audrey', 'country': 'Chile'}, + {'name': 'Bassie', 'country': 'Belgium'}, + ] + table = Table(data, order_by=('-name', '-country')) + + assert table.rows[0].get_cell('name') == 'Bassie' + assert table.rows[1].get_cell('name') == 'Audrey' + assert table.rows[2].get_cell('name') == 'Adrian' + assert table.rows[2].get_cell('country') == 'Brazil' + assert table.rows[3].get_cell('name') == 'Adrian' + assert table.rows[3].get_cell('country') == 'Australia' + + +def test_table_ordering_attributes(): + class Table(tables.Table): + alpha = tables.Column() + beta = tables.Column() + + table = Table(MEMORY_DATA, attrs={ + 'th': { + 'class': 'custom-header-class', + '_ordering': { + 'orderable': 'sortable', + 'ascending': 'ascend', + 'descending': 'descend', + }, + }, + }, order_by='alpha') + + assert 'sortable' in table.columns[0].attrs['th']['class'] + assert 'ascend' in table.columns[0].attrs['th']['class'] + assert 'custom-header-class' in table.columns[1].attrs['th']['class'] + + +def test_table_ordering_attributes_in_meta(): + class Table(tables.Table): + alpha = tables.Column() + beta = tables.Column() + + class Meta(OrderedTable.Meta): + attrs = { + 'th': { + 'class': 'custom-header-class-in-meta', + '_ordering': { + 'orderable': 'sortable', + 'ascending': 'ascend', + 'descending': 'descend', + }, + } + } + + table = Table(MEMORY_DATA) + + assert 'sortable' in table.columns[0].attrs['th']['class'] + assert 'ascend' in table.columns[0].attrs['th']['class'] + assert 'custom-header-class-in-meta' in table.columns[1].attrs['th']['class'] + + +def test_column_ordering_attributes(): + class Table(tables.Table): + alpha = tables.Column(attrs={ + 'th': { + 'class': 'custom-header-class', + '_ordering': { + 'orderable': 'sort', + 'ascending': 'ascending' + } + } + }) + beta = tables.Column(attrs={ + 'th': { + '_ordering': { + 'orderable': 'canOrder', + } + }, + 'td': { + 'class': 'cell-2' + } + }) + + table = Table(MEMORY_DATA, attrs={'class': 'only-on-table'}, order_by='alpha') + + assert 'only-on-table' not in table.columns[0].attrs['th']['class'] + assert 'custom-header-class' in table.columns[0].attrs['th']['class'] + assert 'ascending' in table.columns[0].attrs['th']['class'] + assert 'sort' in table.columns[0].attrs['th']['class'] + assert 'canOrder' in table.columns[1].attrs['th']['class'] diff --git a/tests/test_tabledata.py b/tests/test_tabledata.py new file mode 100644 index 00000000..cee6679b --- /dev/null +++ b/tests/test_tabledata.py @@ -0,0 +1,109 @@ +# coding: utf-8 +from __future__ import unicode_literals + +from copy import deepcopy + +import pytest +from django_tables2.tables import TableData, TableListData, TableQuerysetData + +from .app.models import Person + + +def test_TableData_factory_invalid_data_None(): + with pytest.raises(ValueError): + TableData.from_data(None, table={}) + + +def test_TableData_factory_invalid_data_int(): + with pytest.raises(ValueError): + TableData.from_data(1, table={}) + + +def test_TableData_factory_invalid_data_classes(): + class Klass(object): + pass + + with pytest.raises(ValueError): + TableData.from_data(Klass(), table={}) + + class Bad(object): + def __len__(self): + pass + + with pytest.raises(ValueError): + TableData.from_data(Bad(), table={}) + + +@pytest.mark.django_db +def test_TableData_factory_valid_QuerySet(): + data = TableData.from_data(Person.objects.all(), table={}) + assert isinstance(data, TableQuerysetData) + + +def test_TableData_factory_valid_list_of_dicts(): + data = TableData.from_data([{'name': 'John'}, {'name': 'Pete'}], table={}) + assert isinstance(data, TableListData) + assert len(data) == 2 + + +def test_TableData_factory_valid_tuple_of_dicts(): + data = TableData.from_data(({'name': 'John'}, {'name': 'Pete'}), table={}) + assert isinstance(data, TableListData) + assert len(data) == 2 + + +def test_TableData_factory_valid_class(): + class Datasource(object): + def __len__(self): + return 1 + + def __getitem__(self, pos): + if pos != 0: + raise IndexError() + return {'a': 1} + + data = TableData.from_data(Datasource(), table={}) + assert len(data) == 1 + + +def test_tabledata_knows_its_default_name(): + data = TableData.from_data([{}], table={}) + assert data.verbose_name == 'item' + assert data.verbose_name_plural == 'items' + + +def test_tabledata_knows_its_name(): + data = TableData.from_data(Person.objects.all(), table={}) + + assert data.verbose_name == 'person' + assert data.verbose_name_plural == 'people' + + +# def test_tabledata_is_untouched(): +# ''' +# Ensure that data that is provided to the table (the datasource) is not +# modified by table operations. +# ''' +# +# MEMORY_DATA = [ +# {'i': 2, 'alpha': 'b', 'beta': 'b'}, +# {'i': 1, 'alpha': 'a', 'beta': 'c'}, +# {'i': 3, 'alpha': 'c', 'beta': 'a'}, +# ] +# +# class Table(tables.Table): +# i = tables.Column() +# alpha = tables.Column() +# beta = tables.Column() +# +# original_data = deepcopy(MEMORY_DATA) +# +# table = Table(MEMORY_DATA) +# table.order_by = 'i' +# list(table.rows) +# assert MEMORY_DATA == original_data +# +# table = Table(MEMORY_DATA) +# table.order_by = 'beta' +# list(table.rows) +# assert MEMORY_DATA == original_data