we're now in a state were so far existing features are complete; but more to come
authorMichael Elsdörfer <michael@elsdoerfer.info>
Sun, 15 Jun 2008 17:31:17 +0000 (17:31 +0000)
committerMichael Elsdörfer <michael@elsdoerfer.info>
Sun, 15 Jun 2008 17:31:17 +0000 (17:31 +0000)
django_tables/columns.py
django_tables/tables.py
tests/test_basic.py
tests/test_templates.py

index 1ee5e84b3dd795778d3131cba4cea59d033e7c69..66ceb88e8d2dd166ee0be828e54efafb821f470c 100644 (file)
@@ -17,7 +17,10 @@ class Column(object):
     does not provide None for a row, the default will be used instead. Note\r
     that this currently affects ordering.\r
 \r
-    You can use ``visible`` to flag the column as hidden.\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
+    table constructor. If you want to make the column completely unavailable\r
+    to the user, set ``inaccessible`` to True.\r
 \r
     Setting ``sortable`` to False will result in this column being unusable\r
     in ordering.\r
@@ -26,11 +29,12 @@ class Column(object):
     creation_counter = 0\r
 \r
     def __init__(self, verbose_name=None, name=None, default=None,\r
-                 visible=True, sortable=True):\r
+                 visible=True, inaccessible=False, sortable=True):\r
         self.verbose_name = verbose_name\r
         self.name = name\r
         self.default = default\r
         self.visible = visible\r
+        self.inaccessible = inaccessible\r
         self.sortable = sortable\r
 \r
         self.creation_counter = Column.creation_counter\r
index 140a9dc9f3ee3da6442a6056fd81bf97282e3445..338a5bb6181fddb9adb22f4cecdb23cdea3ad7f6 100644 (file)
@@ -3,7 +3,7 @@ from django.utils.datastructures import SortedDict
 from django.utils.encoding import StrAndUnicode\r
 from columns import Column\r
 \r
-__all__ = ('BaseTable', 'Table', 'Row')\r
+__all__ = ('BaseTable', 'Table')\r
 \r
 def sort_table(data, order_by):\r
     """Sort a list of dicts according to the fieldnames in the\r
@@ -23,12 +23,6 @@ def sort_table(data, order_by):
             instructions.append((o, False,))\r
     data.sort(cmp=_cmp)\r
 \r
-class Row(object):\r
-    def __init__(self, data):\r
-        self.data = data\r
-    def as_html(self):\r
-        pass\r
-\r
 class DeclarativeColumnsMetaclass(type):\r
     """\r
     Metaclass that converts Column attributes to a dictionary called\r
@@ -100,43 +94,52 @@ class BaseTable(object):
 \r
         If ``order_by`` is specified, the data will be sorted accordingly.\r
 \r
-        Note that unlike a ``Form``, tables are always bound to data.\r
+        Note that unlike a ``Form``, tables are always bound to data. Also\r
+        unlike a form, the ``columns`` attribute is read-only and returns\r
+        ``BoundColum`` wrappers, similar to the ``BoundField``'s you get\r
+        when iterating over a form. This is because the table iterator\r
+        already yields rows, and we need an attribute via which to expose\r
+        the (visible) set of (bound) columns - ``Table.columns`` is simply\r
+        the perfect fit for this. Instead, ``base_colums`` is copied to\r
+        table instances, so modifying that will not touch the class-wide\r
+        column list.\r
         """\r
         self._data = data\r
-        self._data_cache = None  # will store output dataset (ordered...)\r
-        self._row_cache = None   # will store Row objects\r
+        self._snapshot = None      # will store output dataset (ordered...)\r
+        self._rows = None          # will store BoundRow objects\r
+        self._columns = Columns(self)\r
         self._order_by = order_by\r
 \r
-        # The base_columns class attribute is the *class-wide* definition\r
-        # of columns. Because a particular *instance* of the class might\r
-        # want to alter self.columns, we create self.columns here by copying\r
-        # ``base_columns``. Instances should always modify self.columns;\r
-        # they should not modify self.base_columns.\r
-        self.columns = copy.deepcopy(self.base_columns)\r
+        # Make a copy so that modifying this will not touch the class\r
+        # definition. Note that this is different from forms, where the\r
+        # copy is made available in a ``fields`` attribute. See the\r
+        # ``Table`` class docstring for more information.\r
+        self.base_columns = copy.deepcopy(type(self).base_columns)\r
 \r
-    def _build_data_cache(self):\r
+    def _build_snapshot(self):\r
         snapshot = copy.copy(self._data)\r
         for row in snapshot:\r
-            # delete unknown column, and add missing ones\r
+            # delete unknown columns and add missing ones; note that\r
+            # self.columns already accounts for column name overrides.\r
             for column in row.keys():\r
                 if not column in self.columns:\r
                     del row[column]\r
-            for column in self.columns.keys():\r
-                if not column in row:\r
-                    row[column] = self.columns[column].default\r
+            for colname, colobj in self.columns.items():\r
+                if not colname in row:\r
+                    row[colname] = colobj.column.default\r
         if self.order_by:\r
             sort_table(snapshot, self.order_by)\r
-        self._data_cache = snapshot\r
+        self._snapshot = snapshot\r
 \r
     def _get_data(self):\r
-        if self._data_cache is None:\r
-            self._build_data_cache()\r
-        return self._data_cache\r
+        if self._snapshot is None:\r
+            self._build_snapshot()\r
+        return self._snapshot\r
     data = property(lambda s: s._get_data())\r
 \r
     def _set_order_by(self, value):\r
-        if self._data_cache is not None:\r
-            self._data_cache = None\r
+        if self._snapshot is not None:\r
+            self._snapshot = None\r
         # accept both string and tuple instructions\r
         self._order_by = (isinstance(value, basestring) \\r
             and [value.split(',')] \\r
@@ -144,7 +147,7 @@ class BaseTable(object):
         # validate, remove all invalid order instructions\r
         def can_be_used(o):\r
            c = (o[:1]=='-' and [o[1:]] or [o])[0]\r
-           return c in self.columns and self.columns[c].sortable\r
+           return c in self.base_columns and self.base_columns[c].sortable\r
         self._order_by = OrderByTuple([o for o in self._order_by if can_be_used(o)])\r
         # TODO: optionally, throw an exception\r
     order_by = property(lambda s: s._order_by, _set_order_by)\r
@@ -153,8 +156,8 @@ class BaseTable(object):
         return self.as_html()\r
 \r
     def __iter__(self):\r
-        for name, column in self.columns.items():\r
-            yield BoundColumn(self, column, name)\r
+        for row in self.rows:\r
+            yield row\r
 \r
     def __getitem__(self, name):\r
         try:\r
@@ -163,6 +166,13 @@ class BaseTable(object):
             raise KeyError('Key %r not found in Table' % name)\r
         return BoundColumn(self, column, name)\r
 \r
+    columns = property(lambda s: s._columns)  # just to make it readonly\r
+\r
+    def _get_rows(self):\r
+        for row in self.data:\r
+            yield BoundRow(self, row)\r
+    rows = property(_get_rows)\r
+\r
     def as_html(self):\r
         pass\r
 \r
@@ -172,18 +182,126 @@ class Table(BaseTable):
     # self.columns is specified.\r
     __metaclass__ = DeclarativeColumnsMetaclass\r
 \r
+\r
+class Columns(object):\r
+    """Container for spawning BoundColumns.\r
+\r
+    This is bound to a table and provides it's ``columns`` property. It\r
+    provides access to those columns in different ways (iterator,\r
+    item-based, filtered and unfiltered etc)., stuff that would not be\r
+    possible with a simple iterator on the table class.\r
+\r
+    Note that when you define your column using a name override, e.g.\r
+    ``author_name = tables.Column(name="author")``, then the column will\r
+    be exposed by this container as "author", not "author_name".\r
+    """\r
+    def __init__(self, table):\r
+        self.table = table\r
+        self._columns = SortedDict()\r
+\r
+    def _spawn_columns(self):\r
+        # (re)build the "_columns" cache of BoundColumn objects (note that\r
+        # ``base_columns`` might have changed since last time); creating\r
+        # BoundColumn instances can be costly, so we reuse existing ones.\r
+        new_columns = SortedDict()\r
+        for name, column in self.table.base_columns.items():\r
+            name = column.name or name  # take into account name overrides\r
+            if name in self._columns:\r
+                new_columns[name] = self._columns[name]\r
+            else:\r
+                new_columns[name] = BoundColumn(self, column, name)\r
+        self._columns = new_columns\r
+\r
+    def all(self):\r
+        self._spawn_columns()\r
+        for column in self._columns.values():\r
+            yield column\r
+\r
+    def items(self):\r
+        self._spawn_columns()\r
+        for r in self._columns.items():\r
+            yield r\r
+\r
+    def keys(self):\r
+        self._spawn_columns()\r
+        for r in self._columns.keys():\r
+            yield r\r
+\r
+    def index(self, name):\r
+        self._spawn_columns()\r
+        return self._columns.keyOrder.index(name)\r
+\r
+    def __iter__(self):\r
+        for column in self.all():\r
+            if column.column.visible:\r
+                yield column\r
+\r
+    def __contains__(self, item):\r
+        """Check by both column object and column name."""\r
+        self._spawn_columns()\r
+        if isinstance(item, basestring):\r
+            return item in self.keys()\r
+        else:\r
+            return item in self.all()\r
+\r
+    def __getitem__(self, name):\r
+        """Return a column by name."""\r
+        self._spawn_columns()\r
+        return self._columns[name]\r
+\r
+\r
 class BoundColumn(StrAndUnicode):\r
     """'Runtime' version of ``Column`` that is bound to a table instance,\r
     and thus knows about the table's data.\r
     """\r
+    def __init__(self, table, column, name):\r
+        self.table = table\r
+        self.column = column\r
+        self.name = column.name or name\r
+        # expose some attributes of the column more directly\r
+        self.sortable = column.sortable\r
+        self.visible = column.visible\r
+\r
     def _get_values(self):\r
-        # build a list of values used\r
+        # TODO: build a list of values used\r
         pass\r
     values = property(_get_values)\r
 \r
     def __unicode__(self):\r
-        """Renders this field as an HTML widget."""\r
-        return self.as_html()\r
+        return self.column.verbose_name or self.name\r
+\r
+    def as_html(self):\r
+        pass\r
+\r
+class BoundRow(object):\r
+    """Represents a single row of data, bound to a table.\r
+\r
+    Tables will spawn these row objects, wrapping around the actual data\r
+    stored in a row.\r
+    """\r
+    def __init__(self, table, data):\r
+        self.table = table\r
+        self.data = data\r
+\r
+    def __iter__(self):\r
+        for value in self.values:\r
+            yield value\r
+\r
+    def __getitem__(self, name):\r
+        "Returns the value for the column with the given name."\r
+        return self.data[name]\r
+\r
+    def __contains__(self, item):\r
+        """Check by both row object and column name."""\r
+        if isinstance(item, basestring):\r
+            return item in self.table._columns\r
+        else:\r
+            return item in self\r
+\r
+    def _get_values(self):\r
+        for column in self.table.columns:\r
+            yield self[column.name]\r
+    values = property(_get_values)\r
 \r
     def as_html(self):\r
         pass
\ No newline at end of file
index 3fe7fe6124f9214be4200991ee10e54f31f42bac..3d12f981c8ce06ec46053e6b9e0dd9655c6cec29 100644 (file)
@@ -48,14 +48,35 @@ def test_declaration():
 def test_basic():\r
     class BookTable(tables.Table):\r
         name = tables.Column()\r
+        answer = tables.Column(default=42)\r
+        c = tables.Column(name="count", default=1)\r
     books = BookTable([\r
         {'id': 1, 'name': 'Foo: Bar'},\r
     ])\r
     # access without order_by works\r
     books.data\r
-    # unknown fields are removed\r
-    for d in books.data:\r
-        assert not 'id' in d\r
+\r
+    for r in books.rows:\r
+        # unknown fields are removed\r
+        assert 'name' in r\r
+        assert not 'id' in r\r
+        # missing data is available as default\r
+        assert 'answer' in r\r
+        assert r['answer'] == 42   # note: different from prev. line!\r
+\r
+        # all that still works when name overrides are used\r
+        assert not 'c' in r\r
+        assert 'count' in r\r
+        assert r['count'] == 1\r
+\r
+    # changing an instance's base_columns does not change the class\r
+    assert id(books.base_columns) != id(BookTable.base_columns)\r
+    books.base_columns['test'] = tables.Column()\r
+    assert not 'test' in BookTable.base_columns\r
+\r
+    # make sure the row and column caches work\r
+    id(list(books.rows)[0]) == id(list(books.rows)[0])\r
+    id(list(books.columns)[0]) == id(list(books.columns)[0])\r
 \r
 def test_sort():\r
     class BookTable(tables.Table):\r
@@ -90,7 +111,7 @@ def test_sort():
     # test invalid order instructions\r
     books.order_by = 'xyz'\r
     assert not books.order_by\r
-    books.columns['language'].sortable = False\r
+    books.base_columns['language'].sortable = False\r
     books.order_by = 'language'\r
     assert not books.order_by\r
     test_order(('language', 'pages'), [1,3,2,4])  # as if: 'pages'
\ No newline at end of file
index 7d2e5bca3fd95dcc8749fec3fee2f2d5d2873c3f..9c0852f64b13c42351c38784c72b2d890beab296 100644 (file)
@@ -1,11 +1,15 @@
-"""Test template specific functionality.\r
+"""Test template specific functionality.\r
 \r
-Make sure tables expose their functionality to templates right.\r
+Make sure tables expose their functionality to templates right. This\r
+generally about testing "out"-functionality of the tables, whether\r
+via templates or otherwise. Whether a test belongs here or, say, in\r
+``test_basic``, is not always a clear-cut decision.\r
 """\r
 \r
+from py.test import raises\r
 import django_tables as tables\r
 \r
-def test_for_templates():\r
+def test_order_by():\r
     class BookTable(tables.Table):\r
         id = tables.Column()\r
         name = tables.Column()\r
@@ -19,38 +23,78 @@ def test_for_templates():
     books.order_by = ('name', '-id')\r
     assert str(books.order_by) == 'name,-id'\r
 \r
+def test_columns_and_rows():\r
+    class CountryTable(tables.Table):\r
+        name = tables.TextColumn()\r
+        capital = tables.TextColumn(sortable=False)\r
+        population = tables.NumberColumn(verbose_name="Population Size")\r
+        currency = tables.NumberColumn(visible=False, inaccessible=True)\r
+        tld = tables.TextColumn(visible=False, verbose_name="Domain")\r
+        calling_code = tables.NumberColumn(name="cc", verbose_name="Phone Ext.")\r
 \r
-"""\r
-<table>\r
-<tr>\r
-    {% for column in book.columns %}\r
-        <th><a href="{{ column.name }}">{{ column }}</a></th\r
-        <th><a href="{% set_url_param "sort" column.name }}">{{ column }}</a></th\r
-    {% endfor %}\r
-</tr>\r
-{% for row in book %}\r
-    <tr>\r
-        {% for value in row %}\r
-            <td>{{ value }]</td>\r
-        {% endfor %}\r
-    </tr>\r
-{% endfor %}\r
-</table>\r
-\r
-OR:\r
-\r
-<table>\r
-{% for row in book %}\r
-    <tr>\r
-        {% if book.columns.name.visible %}\r
-            <td>{{ row.name }]</td>\r
-        {% endif %}\r
-        {% if book.columns.score.visible %}\r
-            <td>{{ row.score }]</td>\r
-        {% endif %}\r
-    </tr>\r
-{% endfor %}\r
-</table>\r
-\r
-\r
-"""
\ No newline at end of file
+    countries = CountryTable(\r
+        [{'name': 'Germany', 'capital': 'Berlin', 'population': 83, 'currency': 'Euro (€)', 'tld': 'de', 'cc': 49},\r
+         {'name': 'France', 'population': 64, 'currency': 'Euro (€)', 'tld': 'fr', 'cc': 33},\r
+         {'name': 'Netherlands', 'capital': 'Amsterdam', 'cc': '31'},\r
+         {'name': 'Austria', 'cc': 43, 'currency': 'Euro (€)', 'population': 8}])\r
+\r
+    assert len(list(countries.columns)) == 4\r
+    assert len(list(countries.rows)) == len(list(countries)) == 4\r
+\r
+    # column name override, hidden columns\r
+    assert [c.name for c in countries.columns] == ['name', 'capital', 'population', 'cc']\r
+    # verbose_name, and fallback to field name\r
+    assert [unicode(c) for c in countries.columns] == ['name', 'capital', 'Population Size', 'Phone Ext.']\r
+\r
+    # data yielded by each row matches the defined columns\r
+    for row in countries.rows:\r
+        assert len(list(row)) == len(list(countries.columns))\r
+\r
+    # we can access each column and row by name...\r
+    assert countries.columns['population'].column.verbose_name == "Population Size"\r
+    assert countries.columns['cc'].column.verbose_name == "Phone Ext."\r
+    # ...even invisible ones\r
+    assert countries.columns['tld'].column.verbose_name == "Domain"\r
+    # ...and even inaccessible ones (but accessible to the coder)\r
+    assert countries.columns['currency'].column == countries.base_columns['currency']\r
+    # this also works for rows\r
+    for row in countries:\r
+        row['tld'], row['cc'], row['population']\r
+\r
+    # certain data is available on columns\r
+    assert countries.columns['currency'].sortable == True\r
+    assert countries.columns['capital'].sortable == False\r
+    assert countries.columns['name'].visible == True\r
+    assert countries.columns['tld'].visible == False\r
+\r
+\r
+def test_render():\r
+    """For good measure, render some actual templates."""\r
+\r
+    class CountryTable(tables.Table):\r
+        name = tables.TextColumn()\r
+        capital = tables.TextColumn()\r
+        population = tables.NumberColumn(verbose_name="Population Size")\r
+        currency = tables.NumberColumn(visible=False, inaccessible=True)\r
+        tld = tables.TextColumn(visible=False, verbose_name="Domain")\r
+        calling_code = tables.NumberColumn(name="cc", verbose_name="Phone Ext.")\r
+\r
+    countries = CountryTable(\r
+        [{'name': 'Germany', 'capital': 'Berlin', 'population': 83, 'currency': 'Euro (€)', 'tld': 'de', 'cc': 49},\r
+         {'name': 'France', 'population': 64, 'currency': 'Euro (€)', 'tld': 'fr', 'cc': 33},\r
+         {'name': 'Netherlands', 'capital': 'Amsterdam', 'cc': '31'},\r
+         {'name': 'Austria', 'cc': 43, 'currency': 'Euro (€)', 'population': 8}])\r
+\r
+    from django.template import Template, Context\r
+\r
+    assert Template("{% for column in countries.columns %}{{ column }}/{{ column.name }} {% endfor %}").\\r
+        render(Context({'countries': countries})) == \\r
+        "name/name capital/capital Population Size/population Phone Ext./cc "\r
+\r
+    assert Template("{% for row in countries %}{% for value in row %}{{ value }} {% endfor %}{% endfor %}").\\r
+        render(Context({'countries': countries})) == \\r
+        "Germany Berlin 83 49 France None 64 33 Netherlands Amsterdam None 31 Austria None 8 43 "\r
+\r
+    print Template("{% for row in countries %}{% if countries.columns.name.visible %}{{ row.name }} {% endif %}{% if countries.columns.tld.visible %}{{ row.tld }} {% endif %}{% endfor %}").\\r
+        render(Context({'countries': countries})) == \\r
+        "Germany France Netherlands Austria"
\ No newline at end of file