From 0b783b0375a7114391940872e99d0c61dc58ab7d Mon Sep 17 00:00:00 2001
From: michael <>
Date: Tue, 17 Jun 2008 13:33:36 +0000
Subject: [PATCH] more work on modeltables, they are pretty much usable now
---
README | 194 +++++++++++++++++++++++++++++++++++++--
django_tables/columns.py | 3 +-
django_tables/models.py | 114 +++++++++++++++++++----
django_tables/tables.py | 41 ++++++---
tests/test_basic.py | 33 +++----
tests/test_models.py | 108 ++++++++++++++++++++--
6 files changed, 431 insertions(+), 62 deletions(-)
diff --git a/README b/README
index ec2e167..e318887 100644
--- a/README
+++ b/README
@@ -1,6 +1,8 @@
django-tables
=============
+A Django QuerySet renderer.
+
Installation
------------
@@ -13,13 +15,185 @@ Running the test suite
The test suite uses py.test (from http://codespeak.net/py/dist/test.html).
-Defining Tables
----------------
+Working with Tables
+-------------------
-Using Tables
-------------
+A table class looks very much like a form:
+
+ import django_tables as tables
+ class CountryTable(tables.Table):
+ name = tables.Column(verbose_name="Country Name")
+ population = tables.Column(sortable=False, visible=False)
+ time_zone = tables.Column(name="tz", default="UTC+1")
+
+Instead of fields, you declare a column for every piece of data you want to
+expose to the user.
+
+To use the table, create an instance:
+
+ countries = CountryTable([{'name': 'Germany', population: 80},
+ {'name': 'France', population: 64}])
+
+Decide how you the table should be sorted:
+
+ countries.order_by = ('name',)
+ assert [row.name for row in countries.row] == ['France', 'Germany']
+
+ countries.order_by = ('-population',)
+ assert [row.name for row in countries.row] == ['Germany', 'France']
+
+If you pass the table object along into a template, you can do:
+
+ {% for column in countries.columns %}
+ {{column}}
+ {% endfor %}
+
+Which will give you:
+
+ Country Name
+ Time Zone
+
+Note that ``population`` is skipped (as it has ``visible=False``), that the
+declared verbose name for the ``name`` column is used, and that ``time_zone``
+is converted into a more beautiful string for output automatically.
+
+Common Workflow
+~~~~~~~~~~~~~~~
+
+Usually, you are going to use a table like this. Assuming ``CountryTable``
+is defined as above, your view will create an instance and pass it to the
+template:
+
+ def list_countries(request):
+ data = ...
+ countries = CountryTable(data, order_by=request.GET.get('sort'))
+ return render_to_response('list.html', {'table': countries})
+
+Note that we are giving the incoming "sort" query string value directly to
+the table, asking for a sort. All invalid column names will (by default) be
+ignored. In this example, only "name" and "tz" are allowed, since:
+
+ * "population" has sortable=False
+ * "time_zone" has it's name overwritten with "tz".
+
+Then, in the "list.html" template, write:
+
+
+
+ {% for column in table.columns %}
+ {{ column }} |
+ {% endfor %}
+
+ {% for row in table.rows %}
+ {% for value in row %}
+ {{ value }} |
+ {% endfor %}
+ {% endfor %}
+ |
+
+This will output the data as an HTML table. Note how the table is now fully
+sortable, since our link passes along the column name via the querystring,
+which in turn will be used by the server for ordering. ``order_by`` accepts
+comma-separated strings as input, and "{{ table.order_by }}" will be rendered
+as a such a string.
+
+Instead of the iterator, you can use your knowledge of the table structure to
+access columns directly:
+
+ {% if table.columns.tz.visible %}
+ {{ table.columns.tz }}
+ {% endfor %}
+
+ModelTables
+-----------
+
+Like forms, tables can also be used with models:
+
+ class CountryTable(tables.ModelTable):
+ id = tables.Column(sortable=False, visible=False)
+ class Meta:
+ model = Country
+ exclude = ['clicks']
+
+The resulting table will have one column for each model field, with the
+exception of "clicks", which is excluded. The column for "id" is overwritten
+to both hide it and deny it sort capability.
+
+When instantiating a ModelTable, you usually pass it a queryset to provide
+the table data:
+
+ qs = Country.objects.filter(continent="europe")
+ countries = CountryTable(qs)
+
+However, you can also just do:
+
+ countries = CountryTable()
+
+and all rows exposed by the default manager of the model the table is based
+on will be used.
+
+If you are using model inheritance, then the following also works:
+
+ countries = CountryTable(CountrySubclass)
+
+Note that while you can pass any model, it really only makes sense if the
+model also provides fields for the columns you have defined.
+
+Custom Columns
+~~~~~~~~~~~~~~
+
+You an add custom columns to your ModelTable that are not based on actual
+model fields:
+
+ class CountryTable(tables.ModelTable):
+ custom = tables.Column(default="foo")
+ class Meta:
+ model = Country
+
+Just make sure your model objects do provide an attribute with that name.
+Functions are also supported, so ``Country.custom`` could be a callable.
+
+ModelTable Specialities
+~~~~~~~~~~~~~~~~~~~~~~~
+
+ModelTables currently have some restrictions with respect to ordering:
+
+ * Custom columns not based on a model field do not support ordering,
+ regardless of ``sortable`` property (it is ignored).
+
+ * A ModelTable column's ``default`` value does not affect ordering.
+ This differs from the non-model table behaviour.
+
+Columns
+-------
+
+verbose_name, default, visible, sortable
+ ``verbose_name`` defines a display name for this column used for output.
+
+ ``name`` is the internal name of the column. Normally you don't need to
+ specify this, as the attribute that you make the column available under
+ is used. However, in certain circumstances it can be useful to override
+ this default, e.g. when using ModelTables if you want a column to not
+ 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
+ that whether this effects ordering might depend on the table type (model
+ or normal).
+
+ 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.
-Create
+``django_tables.columns`` currently defines three classes, ``Column``,
+``TextColumn`` and ``NumberColumn``. However, the two subclasses currently
+don't do anything special at all, so you can simply use the base class.
+While this will likely change in the future (e.g. when grouping is added),
+the base column class will continue to work by itself.
Tables and Pagination
---------------------
@@ -73,7 +247,9 @@ be able to do:
TODO
----
- - Let columns change their default ordering (ascending/descending)
- - Filters
- - Grouping
- - Choices-like data
\ No newline at end of file
+ - as_html methods are all empty right now
+ - table.column[].values is a stub
+ - 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
diff --git a/django_tables/columns.py b/django_tables/columns.py
index 66ceb88..9b8aced 100644
--- a/django_tables/columns.py
+++ b/django_tables/columns.py
@@ -15,7 +15,8 @@ class Column(object):
``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 this currently affects ordering.
+ that whether this effects ordering might depend on the table type (model
+ or normal).
You can use ``visible`` to flag the column as hidden by default.
However, this can be overridden by the ``visibility`` argument to the
diff --git a/django_tables/models.py b/django_tables/models.py
index c0f8d1b..ab40038 100644
--- a/django_tables/models.py
+++ b/django_tables/models.py
@@ -1,5 +1,5 @@
from django.utils.datastructures import SortedDict
-from tables import BaseTable, DeclarativeColumnsMetaclass, Column
+from tables import BaseTable, DeclarativeColumnsMetaclass, Column, BoundRow
__all__ = ('BaseModelTable', 'ModelTable')
@@ -27,7 +27,7 @@ def columns_for_model(model, columns=None, exclude=None):
if (columns and not f.name in columns) or \
(exclude and f.name in exclude):
continue
- column = Column()
+ column = Column() # TODO: chose the right column type
if column:
field_list.append((f.name, column))
return SortedDict(field_list)
@@ -51,25 +51,107 @@ class ModelTableMetaclass(DeclarativeColumnsMetaclass):
self.base_columns = columns
return self
-class ModelDataProxy(object):
- pass
-
class BaseModelTable(BaseTable):
- def __init__(self, data, *args, **kwargs):
- super(BaseModelTable, self).__init__([], *args, **kwargs)
- if isinstance(data, models.Model):
- self.queryset = data._meta.default_manager.all()
+ """Table that is based on a model.
+
+ Similar to ModelForm, a column will automatically be created for all
+ the model's fields. You can modify this behaviour with a inner Meta
+ class:
+
+ class MyTable(ModelTable):
+ class Meta:
+ model = MyModel
+ exclude = ['fields', 'to', 'exclude']
+ fields = ['fields', 'to', 'include']
+
+ One difference to a normal table is the initial data argument. It can
+ be a queryset or a model (it's default manager will be used). If you
+ just don't any data at all, the model the table is based on will
+ provide it.
+ """
+ def __init__(self, data=None, *args, **kwargs):
+ if data == None:
+ self.queryset = self._meta.model._default_manager.all()
+ elif isinstance(data, models.Model):
+ self.queryset = data._default_manager.all()
else:
self.queryset = data
- def _get_data(self):
- """Overridden. Return a proxy object so we don't need to load the
- complete queryset.
- # TODO: we probably simply want to build the queryset
+ super(BaseModelTable, self).__init__(self.queryset, *args, **kwargs)
+
+ def _cols_to_fields(self, names):
+ """Utility function. Given a list of column names (as exposed to the
+ user), converts overwritten column names to their corresponding model
+ field name.
+
+ Supports prefixed field names as used e.g. in order_by ("-field").
+ """
+ result = []
+ for ident in names:
+ if ident[:1] == '-':
+ name = ident[1:]
+ prefix = '-'
+ else:
+ name = ident
+ prefix = ''
+ result.append(prefix + self.columns[name].declared_name)
+ return result
+
+ def _validate_column_name(self, name, purpose):
+ """Overridden. Only allow model-based fields to be sorted."""
+ if purpose == 'order_by':
+ try:
+ decl_name = self.columns[name].declared_name
+ self._meta.model._meta.get_field(decl_name)
+ except Exception: #TODO: models.FieldDoesNotExist:
+ return False
+ return super(BaseModelTable, self)._validate_column_name(name, purpose)
+
+ def _build_snapshot(self):
+ """Overridden. The snapshot in this case is simply a queryset
+ with the necessary filters etc. attached.
"""
- if self._data_cache is None:
- self._data_cache = ModelDataProxy(self.queryset)
- return self._data_cache
+ queryset = self.queryset
+ if self.order_by:
+ queryset = queryset.order_by(*self._cols_to_fields(self.order_by))
+ self._snapshot = queryset
+
+ def _get_rows(self):
+ for row in self.data:
+ yield BoundModelRow(self, row)
class ModelTable(BaseModelTable):
__metaclass__ = ModelTableMetaclass
+
+class BoundModelRow(BoundRow):
+ """Special version of the BoundRow class that can handle model instances
+ as data.
+
+ We could simply have ModelTable spawn the normal BoundRow objects
+ with the instance converted to a dict instead. However, this way allows
+ us to support non-field attributes and methods on the model as well.
+ """
+ def __getitem__(self, name):
+ """Overridden. Return this row's data for a certain column, with
+ custom handling for model tables.
+ """
+
+ # find the column for the requested field, for reference
+ boundcol = (name in self.table._columns) \
+ and self.table._columns[name]\
+ or None
+
+ # 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
+
+ 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()
+ return result
\ No newline at end of file
diff --git a/django_tables/tables.py b/django_tables/tables.py
index 338a5bb..556fff1 100644
--- a/django_tables/tables.py
+++ b/django_tables/tables.py
@@ -137,6 +137,19 @@ class BaseTable(object):
return self._snapshot
data = property(lambda s: s._get_data())
+ def _validate_column_name(self, name, purpose):
+ """Return True/False, depending on whether the column ``name`` is
+ valid for ``purpose``. Used to validate things like ``order_by``
+ instructions.
+
+ Can be overridden by subclasses to impose further restrictions.
+ """
+ if purpose == 'order_by':
+ return name in self.columns and\
+ self.columns[name].column.sortable
+ else:
+ return True
+
def _set_order_by(self, value):
if self._snapshot is not None:
self._snapshot = None
@@ -145,10 +158,8 @@ class BaseTable(object):
and [value.split(',')] \
or [value])[0]
# validate, remove all invalid order instructions
- def can_be_used(o):
- c = (o[:1]=='-' and [o[1:]] or [o])[0]
- 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)])
+ self._order_by = OrderByTuple([o for o in self._order_by
+ if self._validate_column_name((o[:1]=='-' and [o[1:]] or [o])[0], "order_by")])
# TODO: optionally, throw an exception
order_by = property(lambda s: s._order_by, _set_order_by)
@@ -171,7 +182,7 @@ class BaseTable(object):
def _get_rows(self):
for row in self.data:
yield BoundRow(self, row)
- rows = property(_get_rows)
+ rows = property(lambda s: s._get_rows())
def as_html(self):
pass
@@ -204,12 +215,13 @@ class Columns(object):
# ``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]
+ for decl_name, column in self.table.base_columns.items():
+ # take into account name overrides
+ exposed_name = column.name or decl_name
+ if exposed_name in self._columns:
+ new_columns[exposed_name] = self._columns[exposed_name]
else:
- new_columns[name] = BoundColumn(self, column, name)
+ new_columns[exposed_name] = BoundColumn(self, column, decl_name)
self._columns = new_columns
def all(self):
@@ -253,15 +265,19 @@ class Columns(object):
class BoundColumn(StrAndUnicode):
"""'Runtime' version of ``Column`` that is bound to a table instance,
and thus knows about the table's data.
+
+ Note name... TODO
"""
def __init__(self, table, column, name):
self.table = table
self.column = column
- self.name = column.name or name
+ self.declared_name = name
# expose some attributes of the column more directly
self.sortable = column.sortable
self.visible = column.visible
+ name = property(lambda s: s.column.name or s.declared_name)
+
def _get_values(self):
# TODO: build a list of values used
pass
@@ -288,7 +304,8 @@ class BoundRow(object):
yield value
def __getitem__(self, name):
- "Returns the value for the column with the given name."
+ """Returns this row's value for a column. All other access methods,
+ e.g. __iter__, lead ultimately to this."""
return self.data[name]
def __contains__(self, item):
diff --git a/tests/test_basic.py b/tests/test_basic.py
index 3d12f98..dee5d60 100644
--- a/tests/test_basic.py
+++ b/tests/test_basic.py
@@ -44,7 +44,6 @@ def test_declaration():
assert 'motto' in StateTable1.base_columns
assert 'motto' in StateTable2.base_columns
-
def test_basic():
class BookTable(tables.Table):
name = tables.Column()
@@ -55,9 +54,10 @@ def test_basic():
])
# access without order_by works
books.data
+ books.rows
for r in books.rows:
- # unknown fields are removed
+ # unknown fields are removed/not-accessible
assert 'name' in r
assert not 'id' in r
# missing data is available as default
@@ -75,21 +75,22 @@ def test_basic():
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])
+ assert id(list(books.columns)[0]) == id(list(books.columns)[0])
+ # TODO: row cache currently not used
+ #assert id(list(books.rows)[0]) == id(list(books.rows)[0])
def test_sort():
class BookTable(tables.Table):
id = tables.Column()
name = tables.Column()
- pages = tables.Column()
+ pages = tables.Column(name='num_pages') # test rewritten names
language = tables.Column(default='en') # default affects sorting
books = BookTable([
- {'id': 1, 'pages': 60, 'name': 'Z: The Book'}, # language: en
- {'id': 2, 'pages': 100, 'language': 'de', 'name': 'A: The Book'},
- {'id': 3, 'pages': 80, 'language': 'de', 'name': 'A: The Book, Vol. 2'},
- {'id': 4, 'pages': 110, 'language': 'fr', 'name': 'A: The Book, French Edition'},
+ {'id': 1, 'num_pages': 60, 'name': 'Z: The Book'}, # language: en
+ {'id': 2, 'num_pages': 100, 'language': 'de', 'name': 'A: The Book'},
+ {'id': 3, 'num_pages': 80, 'language': 'de', 'name': 'A: The Book, Vol. 2'},
+ {'id': 4, 'num_pages': 110, 'language': 'fr', 'name': 'A: The Book, French Edition'},
])
def test_order(order, result):
@@ -97,16 +98,16 @@ def test_sort():
assert [b['id'] for b in books.data] == result
# test various orderings
- test_order(('pages',), [1,3,2,4])
- test_order(('-pages',), [4,2,3,1])
+ test_order(('num_pages',), [1,3,2,4])
+ test_order(('-num_pages',), [4,2,3,1])
test_order(('name',), [2,4,3,1])
- test_order(('language', 'pages'), [3,2,1,4])
+ test_order(('language', 'num_pages'), [3,2,1,4])
# using a simple string (for convinience as well as querystring passing
- test_order('-pages', [4,2,3,1])
- test_order('language,pages', [3,2,1,4])
+ test_order('-num_pages', [4,2,3,1])
+ test_order('language,num_pages', [3,2,1,4])
# [bug] test alternative order formats if passed to constructor
- BookTable([], 'language,-pages')
+ BookTable([], 'language,-num_pages')
# test invalid order instructions
books.order_by = 'xyz'
@@ -114,4 +115,4 @@ def test_sort():
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
+ test_order(('language', 'num_pages'), [1,3,2,4]) # as if: 'num_pages'
\ No newline at end of file
diff --git a/tests/test_models.py b/tests/test_models.py
index 48191e7..eb32509 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -4,32 +4,44 @@ Sets up a temporary Django project using a memory SQLite database.
"""
from django.conf import settings
+import django_tables as tables
def setup_module(module):
settings.configure(**{
'DATABASE_ENGINE': 'sqlite3',
'DATABASE_NAME': ':memory:',
+ 'INSTALLED_APPS': ('tests.testapp',)
})
from django.db import models
+ from django.core.management import call_command
class City(models.Model):
name = models.TextField()
population = models.IntegerField()
+ class Meta:
+ app_label = 'testapp'
module.City = City
class Country(models.Model):
name = models.TextField()
population = models.IntegerField()
- capital = models.ForeignKey(City)
+ 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!
+ class Meta:
+ app_label = 'testapp'
module.Country = Country
+ # create the tables
+ call_command('syncdb', verbosity=1, interactive=False)
-def test_nothing():
- pass
-
-import django_tables as tables
+ # create a couple of objects
+ Country(name="Austria", tld="au", population=8, system="republic").save()
+ Country(name="Germany", tld="de", population=81).save()
+ Country(name="France", tld="fr", population=64, system="republic").save()
+ Country(name="Netherlands", tld="nl", population=16, system="monarchy").save()
def test_declaration():
"""Test declaration, declared columns and default model field columns.
@@ -39,7 +51,7 @@ def test_declaration():
class Meta:
model = Country
- assert len(CountryTable.base_columns) == 5
+ assert len(CountryTable.base_columns) == 7
assert 'name' in CountryTable.base_columns
assert not hasattr(CountryTable, 'name')
@@ -51,7 +63,7 @@ def test_declaration():
model = Country
exclude = ['tld']
- assert len(CountryTable.base_columns) == 5
+ assert len(CountryTable.base_columns) == 7
assert 'projected' in CountryTable.base_columns
assert 'capital' in CountryTable.base_columns
assert not 'tld' in CountryTable.base_columns
@@ -69,4 +81,84 @@ def test_declaration():
assert 'name' in CityTable.base_columns
assert 'projected' in CityTable.base_columns # declared in parent
assert not 'population' in CityTable.base_columns # not in Meta:columns
- assert 'capital' in CityTable.base_columns # in exclude, but only works on model fields (is that the right behaviour?)
\ No newline at end of file
+ assert 'capital' in CityTable.base_columns # in exclude, but only works on model fields (is that the right behaviour?)
+
+def test_basic():
+ """Some tests here are copied from ``test_basic.py`` but need to be
+ rerun with a ModelTable, as the implementation is different."""
+ class CountryTable(tables.ModelTable):
+ null = tables.Column(default="foo")
+ tld = tables.Column(name="domain")
+ class Meta:
+ model = Country
+ exclude = ('id',)
+ countries = CountryTable()
+
+ for r in countries.rows:
+ # "normal" fields exist
+ assert 'name' in r
+ # unknown fields are removed/not accessible
+ assert not 'does-not-exist' in r
+ # ...so are excluded fields
+ assert not 'id' in r
+ # missing data is available with default values
+ assert 'null' in r
+ assert r['null'] == "foo" # note: different from prev. line!
+
+ # all that still works when name overrides are used
+ assert not 'tld' in r
+ assert 'domain' in r
+ assert len(r['domain']) == 2 # valid country tld
+
+ # make sure the row and column caches work for model tables as well
+ assert id(list(countries.columns)[0]) == id(list(countries.columns)[0])
+ # TODO: row cache currently not used
+ #assert id(list(countries.rows)[0]) == id(list(countries.rows)[0])
+
+def test_sort():
+ class CountryTable(tables.ModelTable):
+ tld = tables.Column(name="domain")
+ system = tables.Column(default="republic")
+ custom1 = tables.Column()
+ custom2 = tables.Column(sortable=True)
+ class Meta:
+ model = Country
+ countries = CountryTable()
+
+ def test_order(order, result):
+ countries.order_by = order
+ assert [r['id'] for r in countries.rows] == result
+
+ # test various orderings
+ test_order(('population',), [1,4,3,2])
+ test_order(('-population',), [2,3,4,1])
+ test_order(('name',), [1,3,2,4])
+ # test sorting by a "rewritten" column name
+ countries.order_by = 'domain,tld'
+ countries.order_by == ('domain',)
+ test_order(('-domain',), [4,3,2,1])
+ # test multiple order instructions; note: one row is missing a "system"
+ # value, but has a default set; however, that has no effect on sorting.
+ test_order(('system', '-population'), [2,4,3,1])
+ # using a simple string (for convinience as well as querystring passing
+ test_order('-population', [2,3,4,1])
+ test_order('system,-population', [2,4,3,1])
+
+ # test invalid order instructions
+ countries.order_by = 'invalid_field,population'
+ assert countries.order_by == ('population',)
+ # ...for modeltables, this primarily means that only model-based colunns
+ # are currently sortable at all.
+ countries.order_by = ('custom1', 'custom2')
+ assert countries.order_by == ()
+
+def test_pagination():
+ pass
+
+# TODO: foreignkey columns: simply support foreignkeys, tuples and id, name dicts; support column choices attribute to validate id-only
+# TODO: pagination
+# TODO: support function column sources both for modeltables (methods on model) and static tables (functions in dict)
+# TODO: manual base columns change -> update() call (add as example in docstr here) -> rebuild snapshot: is row cache, column cache etc. reset?
+# TODO: test that boundcolumn.name works with name overrides and without
+# TODO: more beautiful auto column names
+# TODO: normal tables should handle name overrides differently; the backend data should still use declared_name
\ No newline at end of file
--
2.26.2