support callables
authorMichael Elsdörfer <michael@elsdoerfer.info>
Fri, 20 Jun 2008 12:30:56 +0000 (12:30 +0000)
committerMichael Elsdörfer <michael@elsdoerfer.info>
Fri, 20 Jun 2008 12:30:56 +0000 (12:30 +0000)
README
django_tables/columns.py
django_tables/models.py
django_tables/tables.py
tests/test_basic.py
tests/test_models.py

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