From: michael <>
Date: Fri, 20 Jun 2008 12:30:56 +0000 (+0000)
Subject: support callables
X-Git-Tag: 0.1~17
X-Git-Url: http://git.tremily.us/?a=commitdiff_plain;h=5a0e0d254800cb085743cdf259e14497dbffcd68;p=django-tables2.git

support callables
---

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