From c4da12d53b002c7f177cdba393cfc5907973bd79 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Michael=20Elsd=C3=B6rfer?= 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 %} + + {% endfor %} + + {% for row in table.rows %} + {% for value in row %} +
{{ column }}
{{ 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