From f204bb9912eef739e32c101eaa4f0feaabc9edd3 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Michael=20Elsd=C3=B6rfer?= Date: Fri, 20 Jun 2008 12:30:56 +0000 Subject: [PATCH] support callables --- README | 30 ++++++++++++++++++++++++++---- django_tables/columns.py | 6 ++++-- django_tables/models.py | 17 ++++++++--------- django_tables/tables.py | 30 +++++++++++++++++++++++++++--- tests/test_basic.py | 32 +++++++++++++++++++++++++++++++- tests/test_models.py | 39 ++++++++++++++++++++++++++++++++++----- 6 files changed, 130 insertions(+), 24 deletions(-) diff --git a/README b/README index 2b7ec37..33a6bf4 100644 --- a/README +++ b/README @@ -105,6 +105,23 @@ access columns directly: {% endfor %} +Advanced Features +~~~~~~~~~~~~~~~~~ + +There are few requirements for the source data of a table. It should be an +iterable with dict-like objects. Values found in the source data that are +not associated with a column are ignored, missing values are replaced by +the column default or None. + +If any value in the source data is a callable, it will be passed it's own row +instance and is expected to return the actual value for this particular table +cell. + +Similarily, the colunn default value may also be callable that will takes +the row instance as an argument (representing the row that the default is +needed for). + + ModelTables ----------- @@ -169,8 +186,12 @@ ModelTables currently have some restrictions with respect to ordering: * A ModelTable column's ``default`` value does not affect ordering. This differs from the non-model table behaviour. -Columns -------- +If a column is mapped to a method on the model, that method will be called +without arguments. This behavior differs from non-model tables, where a +row object will be passed. + +Columns (# TODO) +---------------- verbose_name, default, visible, sortable ``verbose_name`` defines a display name for this column used for output. @@ -184,7 +205,7 @@ verbose_name, default, visible, sortable ``default`` is the default value for this column. If the data source does not provide None for a row, the default will be used instead. Note that whether this effects ordering might depend on the table type (model - or normal). + or normal). default might be a callable. You can use ``visible`` to flag the column as hidden by default. However, this can be overridden by the ``visibility`` argument to the @@ -286,4 +307,5 @@ TODO - let columns change their default ordering (ascending/descending) - filters - grouping - - choices support for columns (internal column value will be looked up for output) \ No newline at end of file + - choices support for columns (internal column value will be looked up for output) + - for columns that span model relationships, automatically generate select_related() \ No newline at end of file diff --git a/django_tables/columns.py b/django_tables/columns.py index 9b8aced..4fae60b 100644 --- a/django_tables/columns.py +++ b/django_tables/columns.py @@ -14,9 +14,10 @@ class Column(object): use the model field name. ``default`` is the default value for this column. If the data source - does not provide None for a row, the default will be used instead. Note + does provide ``None`` for a row, the default will be used instead. Note that whether this effects ordering might depend on the table type (model - or normal). + or normal). Also, you can specify a callable, which will be passed a + ``BoundRow`` instance and is expected to return the default to be used. You can use ``visible`` to flag the column as hidden by default. However, this can be overridden by the ``visibility`` argument to the @@ -26,6 +27,7 @@ class Column(object): Setting ``sortable`` to False will result in this column being unusable in ordering. """ + # Tracks each time a Column instance is created. Used to retain order. creation_counter = 0 diff --git a/django_tables/models.py b/django_tables/models.py index 26d742e..5a421f8 100644 --- a/django_tables/models.py +++ b/django_tables/models.py @@ -127,21 +127,20 @@ class BoundModelRow(BoundRow): """ # find the column for the requested field, for reference - boundcol = (name in self.table._columns) \ - and self.table._columns[name]\ - or None + boundcol = self.table._columns[name] # If the column has a name override (we know then that is was also # used for access, e.g. if the condition is true, then # ``boundcol.column.name == name``), we need to make sure we use the # declaration name to access the model field. if boundcol.column.name: - name = boundcol.declared_name + name = boundcol.declared_name result = getattr(self.data, name, None) - if result is None: - if boundcol and boundcol.column.default is not None: - result = boundcol.column.default - else: - raise AttributeError() + if callable(result): + result = result() + elif result is None: + if boundcol.column.default is not None: + result = boundcol.get_default(self) + return result \ No newline at end of file diff --git a/django_tables/tables.py b/django_tables/tables.py index 5216207..44a2a9a 100644 --- a/django_tables/tables.py +++ b/django_tables/tables.py @@ -9,10 +9,14 @@ __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. + + Dict values can be callables. """ def _cmp(x, y): for name, reverse in instructions: - res = cmp(x.get(name), y.get(name)) + 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 @@ -135,6 +139,10 @@ class BaseTable(object): 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 @@ -156,7 +164,9 @@ class BaseTable(object): # which is the current design decision. for column in self.columns.all(): if not column.declared_name in row: - row[column.declared_name] = column.column.default + # since rows are not really in the picture yet, create a + # temporary row object for this call. + row[column.declared_name] = column.get_default(BoundRow(self, row)) if self.order_by: sort_table(snapshot, self._cols_to_fields(self.order_by)) @@ -363,6 +373,17 @@ class BoundColumn(StrAndUnicode): name = property(lambda s: s.column.name or s.declared_name) + def get_default(self, row): + """Since a column's ``default`` property may be a callable, we need + this function to resolve it when needed. + + Make sure ``row`` is a ``BoundRow`` objects, since that is what + we promise the callable will get. + """ + if callable(self.column.default): + return self.column.default(row) + return self.column.default + def _get_values(self): # TODO: build a list of values used pass @@ -391,7 +412,10 @@ class BoundRow(object): def __getitem__(self, name): """Returns this row's value for a column. All other access methods, e.g. __iter__, lead ultimately to this.""" - return self.data[self.table.columns[name].declared_name] + result = self.data[self.table.columns[name].declared_name] + if callable(result): + result = result(self) + return result def __contains__(self, item): """Check by both row object and column name.""" diff --git a/tests/test_basic.py b/tests/test_basic.py index e2f8d16..4deddc9 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -149,4 +149,34 @@ def test_sort(): 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' \ No newline at end of file + test_order(('language', 'num_pages'), [1,3,2,4]) # as if: 'num_pages' + +def test_callable(): + """Data fields, ``default`` option 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'])) + + 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] + + # function is called while sorting + math.order_by = ('-rhs',) + assert [row['rhs'] for row in math] == [9,4,3] + + math.order_by = ('sum',) + assert [row['sum'] for row in math] == [1,3,4] \ No newline at end of file diff --git a/tests/test_models.py b/tests/test_models.py index b2ca09c..9b4b231 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -3,6 +3,7 @@ Sets up a temporary Django project using a memory SQLite database. """ +from py.test import raises from django.conf import settings import django_tables as tables @@ -29,7 +30,10 @@ def setup_module(module): capital = models.ForeignKey(City, blank=True, null=True) tld = models.TextField(verbose_name='Domain Extension', max_length=2) system = models.TextField(blank=True, null=True) - null = models.TextField(blank=True, null=True) # tests expect this to be always null! + null = models.TextField(blank=True, null=True) # tests expect this to be always null! + null2 = models.TextField(blank=True, null=True) # - " - + def example_domain(self): + return 'example.%s' % self.tld class Meta: app_label = 'testapp' module.Country = Country @@ -51,7 +55,7 @@ def test_declaration(): class Meta: model = Country - assert len(CountryTable.base_columns) == 7 + assert len(CountryTable.base_columns) == 8 assert 'name' in CountryTable.base_columns assert not hasattr(CountryTable, 'name') @@ -63,7 +67,7 @@ def test_declaration(): model = Country exclude = ['tld'] - assert len(CountryTable.base_columns) == 7 + assert len(CountryTable.base_columns) == 8 assert 'projected' in CountryTable.base_columns assert 'capital' in CountryTable.base_columns assert not 'tld' in CountryTable.base_columns @@ -103,9 +107,14 @@ def test_basic(): assert not 'does-not-exist' in r # ...so are excluded fields assert not 'id' in r + # [bug] access to data that might be available, but does not + # have a corresponding column is denied. + raises(Exception, "r['id']") # missing data is available with default values assert 'null' in r assert r['null'] == "foo" # note: different from prev. line! + # if everything else fails (no default), we get None back + assert r['null2'] is None # all that still works when name overrides are used assert not 'tld' in r @@ -121,6 +130,7 @@ def test_basic(): capital = tables.Column() system = tables.Column() null = tables.Column(default="foo") + null2 = tables.Column() tld = tables.Column(name="domain") countries = CountryTable(Country) test_country_table(countries) @@ -186,6 +196,25 @@ def test_sort(): def test_pagination(): pass +def test_callable(): + """Some of the callable code is reimplemented for modeltables, so + test some specifics again. + """ + + class CountryTable(tables.ModelTable): + null = tables.Column(default=lambda s: s['example_domain']) + example_domain = tables.Column() + class Meta: + model = Country + countries = CountryTable(Country) + + # model method is called + assert [row['example_domain'] for row in countries] == \ + ['example.'+row['tld'] for row in countries] + + # column default method is called + assert [row['example_domain'] for row in countries] == \ + [row['null'] for row in countries] + # TODO: pagination -# TODO: support function column sources both for modeltables (methods on model) and static tables (functions in dict) -# TODO: support relationship spanning columns (we could generate select_related() automatically) \ No newline at end of file +# TODO: support relationship spanning columns \ No newline at end of file -- 2.26.2