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