From 5b8532a21682348dfd0a57a9fe4c4ad119c4192c Mon Sep 17 00:00:00 2001 From: =?utf8?q?Michael=20Elsd=C3=B6rfer?= Date: Tue, 24 Jun 2008 01:24:37 +0000 Subject: [PATCH] support column "data" option, relationship spanning; cleaned up docs --- README | 178 +++++++++++++++++++++++++++++---------- django_tables/columns.py | 12 ++- django_tables/models.py | 78 +++++++++++++---- django_tables/tables.py | 58 ++++++++----- tests/test_basic.py | 63 ++++++++------ tests/test_models.py | 55 ++++++++++-- 6 files changed, 335 insertions(+), 109 deletions(-) diff --git a/README b/README index 33a6bf4..9afb539 100644 --- a/README +++ b/README @@ -57,6 +57,11 @@ 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. +There are few requirements for the source data of a table. It should be an +iterable with dict-like objects. Values found in the source data that are +not associated with a column are ignored, missing values are replaced by +the column default or None. + Common Workflow ~~~~~~~~~~~~~~~ @@ -105,17 +110,12 @@ access columns directly: {% endfor %} -Advanced Features -~~~~~~~~~~~~~~~~~ +Dynamic Data +~~~~~~~~~~~~ -There are few requirements for the source data of a table. It should be an -iterable with dict-like objects. Values found in the source data that are -not associated with a column are ignored, missing values are replaced by -the column default or None. - -If any value in the source data is a callable, it will be passed it's own row -instance and is expected to return the actual value for this particular table -cell. +If any value in the source data is a callable, it will be passed it's own +row instance and is expected to return the actual value for this particular +table cell. Similarily, the colunn default value may also be callable that will takes the row instance as an argument (representing the row that the default is @@ -161,6 +161,7 @@ If you just want to use ModelTables, but without auto-generated columns, you do not have to list all model fields in the ``exclude`` Meta option. Instead, simply don't specify a model. + Custom Columns ~~~~~~~~~~~~~~ @@ -175,45 +176,69 @@ model fields: Just make sure your model objects do provide an attribute with that name. Functions are also supported, so ``Country.custom`` could be a callable. + +Spanning relationships +~~~~~~~~~~~~~~~~~~~~~~ + +Let's assume you have a ``Country`` model, with a foreignkey ``capital`` +pointing to the ``City`` model. While displaying a list of countries, +you might want want to link to the capital's geographic location, which is +stored in ``City.geo`` as a ``(lat, long)`` tuple, on Google Maps. + +ModelTables support relationship spanning syntax of Django's database api: + + class CountryTable(tables.ModelTable): + city__geo = tables.Column(name="geo") + +This will add a column named "geo", based on the field by the same name +from the "city" relationship. Note that the name used to define the column +is what will be used to access the data, while the name-overwrite passed to +the column constructor just defines a prettier name for us to work with. +This is to be consistent with auto-generated columns based on model fields, +where the field/column name naturally equals the source name. + +However, to make table defintions more visually appealing and easier to +read, an alternative syntax is supported: setting the column ``data`` +property to the appropriate string. + + class CountryTable(tables.ModelTable): + geo = tables.Column(data='city__geo') + +Note that you don't need to define a relationship's fields as columns +simple to access them: + + for country in countries.rows: + print country.city.id + print country.city.geo + print country.city.founder.name + + 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). + regardless of the ``sortable`` property (it is ignored). - * A ModelTable column's ``default`` value does not affect ordering. - This differs from the non-model table behaviour. + * A ModelTable column's ``default`` or ``data`` value does not affect + ordering. This differs from the non-model table behaviour. If a column is mapped to a method on the model, that method will be called without arguments. This behavior differs from non-model tables, where a row object will be passed. -Columns (# TODO) ----------------- - -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. +If you are using callables (e.g. for the ``default`` or ``data`` column +options), they will generally be run when a row is accessed, and +possible repeatetly when accessed more than once. This behavior differs from +non-model tables, where they would be called once, when the table is +generated. - ``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). default might be a callable. +Columns +------- - 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. +Columns are what defines a table. Therefore, the way you configure your +columns determines to a large extend how your table operates. ``django_tables.columns`` currently defines three classes, ``Column``, ``TextColumn`` and ``NumberColumn``. However, the two subclasses currently @@ -221,6 +246,68 @@ 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. +There are no required arguments. The following is fine: + + class MyTable(tables.Table): + c = tables.Column() + +It will result in a column named "c" in the table. You can specify the +``name`` to override this: + + c = tables.Column(name="count") + +The column is now called and accessed via "count", although the table will +still use "c" to read it's values from the source. You can however modify +that as well, by specifying ``data``: + + c = tables.Column(name="count", data="count") + +For most practicual purposes, "c" is now meaningless. While in most cases +you will just define your column using the name you want it to have, the +above is useful when working with columns automatically generated from +models: + + class BookTable(tables.ModelTable): + book_name = tables.Column(name="name") + author = tables.Column(data="info__author__name") + class Meta: + model = Book + +The overwritten ``book_name`` field/column will now be exposed as the +cleaner "name", and the new "author" column retrieves it's values from +``Book.info.author.name``. + +Note: ``data`` may also be a callable which will be passed a row object. + +Apart from their internal name, you can define a string that will be used +when for display via ``verbose_name``: + + pubdate = tables.Column(verbose_name="Published") + +The verbose name will be used, for example, if you put in a template: + + {{ column }} + +If you don't want a column to be sortable by the user: + + pubdate = tables.Column(sortable=False) + +If you don't want to expose a column (but still require it to exist, for +example because it should be sortable nonetheless): + + pubdate = tables.Column(visible=False) + +The column and it's values will now be skipped when iterating through the +table, although it can still be accessed manually. + +Finally, you can specify default values for your columns: + + health_points = tables.Column(default=100) + +Note that how the default is used and when it is applied differs between +static and ModelTables. + + Tables and Pagination --------------------- @@ -236,12 +323,6 @@ Tables and Pagination table.paginate(DiggPaginator, 10, padding=2) -Works exactly like in the Django database API. Order may be specified as -a list (or tuple) of column names. If prefixed with a hypen, the ordering -for that particular field will be in reverse order. - -Random ordering is currently not supported. - Ordering Syntax --------------- @@ -307,5 +388,16 @@ TODO - let columns change their default ordering (ascending/descending) - filters - grouping - - choices support for columns (internal column value will be looked up for output) - - for columns that span model relationships, automatically generate select_related() \ No newline at end of file + - choices support for columns (internal column value will be looked up + for output) + - for columns that span model relationships, automatically generate + select_related(); this is important, since right now such an e.g. + order_by would cause rows to be dropped (inner join). + - initialize auto-generated columns with the relevant properties of the + model fields (verbose_name, editable=visible?, ...) + - remove support for callable fields? this has become obsolete since we + Column.data property; also, it's easy to make the call manually, or let + the template engine handle it + - tests could use some refactoring, they are currently all over the place + - what happens if duplicate column names are used? we currently don't + check for that at all. \ No newline at end of file diff --git a/django_tables/columns.py b/django_tables/columns.py index 4fae60b..51e71b1 100644 --- a/django_tables/columns.py +++ b/django_tables/columns.py @@ -19,6 +19,15 @@ class Column(object): or normal). Also, you can specify a callable, which will be passed a ``BoundRow`` instance and is expected to return the default to be used. + Additionally, you may specify ``data``. It works very much like + ``default``, except it's effect does not depend on the actual cell + value. When given a function, it will always be called with a row object, + expected to return the cell value. If given a string, that name will be + used to read the data from the source (instead of the column's name). + + Note the interaction with ``default``. If ``default`` is specified as + well, it will be used whenver ``data`` yields in a None value. + 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 @@ -31,11 +40,12 @@ class Column(object): # Tracks each time a Column instance is created. Used to retain order. creation_counter = 0 - def __init__(self, verbose_name=None, name=None, default=None, + def __init__(self, verbose_name=None, name=None, default=None, data=None, visible=True, inaccessible=False, sortable=True): self.verbose_name = verbose_name self.name = name self.default = default + self.data = data self.visible = visible self.inaccessible = inaccessible self.sortable = sortable diff --git a/django_tables/models.py b/django_tables/models.py index 5a421f8..23082a0 100644 --- a/django_tables/models.py +++ b/django_tables/models.py @@ -1,3 +1,4 @@ +from django.core.exceptions import FieldError from django.utils.datastructures import SortedDict from tables import BaseTable, DeclarativeColumnsMetaclass, Column, BoundRow @@ -27,7 +28,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() # TODO: chose the right column type + column = Column() # TODO: chose correct column type, with right options if column: field_list.append((f.name, column)) return SortedDict(field_list) @@ -83,14 +84,29 @@ class BaseModelTable(BaseTable): super(BaseModelTable, self).__init__(self.queryset, *args, **kwargs) def _validate_column_name(self, name, purpose): - """Overridden. Only allow model-based fields to be sorted.""" + """Overridden. Only allow model-based fields and valid model + spanning relationships to be sorted.""" + + # let the base class sort out the easy ones + result = super(BaseModelTable, self)._validate_column_name(name, purpose) + if not result: + return False + if purpose == 'order_by': + column = self.columns[name] + lookup = column.declared_name + if column.column.data and not callable(column.column.data): + lookup = column.column.data + try: - decl_name = self.columns[name].declared_name - self._meta.model._meta.get_field(decl_name) - except Exception: #TODO: models.FieldDoesNotExist: + # let django validate the lookup + _temp = self.queryset.order_by(lookup) + _temp.query.as_sql() + except FieldError: return False - return super(BaseModelTable, self)._validate_column_name(name, purpose) + + # if we haven't failed by now, the column should be valid + return True def _build_snapshot(self): """Overridden. The snapshot in this case is simply a queryset @@ -133,14 +149,44 @@ class BoundModelRow(BoundRow): # 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 callable(result): - result = result() - elif result is None: + if boundcol.column.data: + if callable(boundcol.column.data): + result = boundcol.column.data(self) + if not result: + if boundcol.column.default is not None: + return boundcol.get_default(self) + return result + else: + name = boundcol.column.data + else: + name = boundcol.declared_name + + + # try to resolve relationships spanning attributes + bits = name.split('__') + current = self.data + for bit in bits: + # note the difference between the attribute being None and not + # existing at all; assume "value doesn't exist" in the former + # (e.g. a relationship has no value), raise error in the latter. + # a more proper solution perhaps would look at the model meta + # data instead to find out whether a relationship is valid; see + # also ``_validate_column_name``, where such a mechanism is + # already implemented). + if not hasattr(current, bit): + raise ValueError("Could not resolve %s from %s" % (bit, name)) + + current = getattr(current, bit) + if callable(current): + current = current() + # important that we break in None case, or a relationship + # spanning across a null-key will raise an exception in the + # next iteration, instead of defaulting. + if current is None: + break + + if current is None: + # ...the whole name (i.e. the last bit) resulted in None if boundcol.column.default is not None: - result = boundcol.get_default(self) - - return result \ No newline at end of file + return boundcol.get_default(self) + return current \ No newline at end of file diff --git a/django_tables/tables.py b/django_tables/tables.py index 44a2a9a..136fda2 100644 --- a/django_tables/tables.py +++ b/django_tables/tables.py @@ -151,22 +151,27 @@ class BaseTable(object): snapshot = copy.copy(self._data) for row in snapshot: - # delete unknown columns that are in the source data, but that - # we don't know about. - # TODO: does this even make sense? we might in some cases save - # memory, but at what performance cost? - decl_colnames = [c.declared_name for c in self.columns.all()] - for key in row.keys(): - if not key in decl_colnames: - del row[key] - # add data for defined columns that is missing from the source. - # we do this so that colunn default values can affect sorting, - # which is the current design decision. + # add data that is missing from the source. we do this now so + # that the colunn ``default`` and ``data`` values can affect + # sorting (even when callables are used)! + # This is a design decision - the alternative would be to + # resolve the values when they are accessed, and either do not + # support sorting them at all, or run the callables during + # sorting. for column in self.columns.all(): - if not column.declared_name in row: - # since rows are not really in the picture yet, create a - # temporary row object for this call. - row[column.declared_name] = column.get_default(BoundRow(self, row)) + name_in_source = column.declared_name + if column.column.data: + if callable(column.column.data): + # if data is a callable, use it's return value + row[name_in_source] = column.column.data(BoundRow(self, row)) + else: + name_in_source = column.column.data + + # the following will be True if: + # * the source does not provide that column or provides None + # * the column did provide a data callable that returned None + if row.get(name_in_source, None) is None: + row[name_in_source] = column.get_default(BoundRow(self, row)) if self.order_by: sort_table(snapshot, self._cols_to_fields(self.order_by)) @@ -183,21 +188,29 @@ class BaseTable(object): the user), converts column names to the names we have to use to retrieve a column's data from the source. - Right now, the name used in the table declaration is used for - access, but a column can define it's own alias-like name that will - be used to refer to the column from outside. + Usually, the name used in the table declaration is used for accessing + the source (while a column can define an alias-like name that will + be used to refer to it from the "outside"). However, a column can + override this by giving a specific source field name via ``data``. Supports prefixed column names as used e.g. in order_by ("-field"). """ result = [] for ident in names: + # handle order prefix if ident[:1] == '-': name = ident[1:] prefix = '-' else: name = ident prefix = '' - result.append(prefix + self.columns[name].declared_name) + # find the field name + column = self.columns[name] + if column.column.data and not callable(column.column.data): + name_in_source = column.column.data + else: + name_in_source = column.declared_name + result.append(prefix + name_in_source) return result def _validate_column_name(self, name, purpose): @@ -412,7 +425,12 @@ class BoundRow(object): def __getitem__(self, name): """Returns this row's value for a column. All other access methods, e.g. __iter__, lead ultimately to this.""" - result = self.data[self.table.columns[name].declared_name] + + # We are supposed to return ``name``, but the column might be + # named differently in the source data. + result = self.data[self.table._cols_to_fields([name])[0]] + + # if the field we are pointing to is a callable, remove it if callable(result): result = result(self) return result diff --git a/tests/test_basic.py b/tests/test_basic.py index 4deddc9..205e497 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -3,6 +3,7 @@ This includes the core, as well as static data, non-model tables. """ +from math import sqrt from py.test import raises import django_tables as tables @@ -46,24 +47,25 @@ def test_declaration(): assert 'motto' in StateTable2.base_columns def test_basic(): - class BookTable(tables.Table): + class StuffTable(tables.Table): name = tables.Column() answer = tables.Column(default=42) c = tables.Column(name="count", default=1) - books = BookTable([ - {'id': 1, 'name': 'Foo: Bar'}, + email = tables.Column(data="@") + stuff = StuffTable([ + {'id': 1, 'name': 'Foo Bar', '@': 'foo@bar.org'}, ]) # access without order_by works - books.data - books.rows + stuff.data + stuff.rows # make sure BoundColumnn.name always gives us the right thing, whether # the column explicitely defines a name or not. - books.columns['count'].name == 'count' - books.columns['answer'].name == 'answer' + stuff.columns['count'].name == 'count' + stuff.columns['answer'].name == 'answer' - for r in books.rows: + for r in stuff.rows: # unknown fields are removed/not-accessible assert 'name' in r assert not 'id' in r @@ -76,17 +78,20 @@ def test_basic(): assert 'count' in r assert r['count'] == 1 + # columns with data= option work fine + assert r['email'] == 'foo@bar.org' + # 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 + assert id(stuff.base_columns) != id(StuffTable.base_columns) + stuff.base_columns['test'] = tables.Column() + assert not 'test' in StuffTable.base_columns # optionally, exceptions can be raised when input is invalid tables.options.IGNORE_INVALID_OPTIONS = False - raises(Exception, "books.order_by = '-name,made-up-column'") - raises(Exception, "books.order_by = ('made-up-column',)") + raises(Exception, "stuff.order_by = '-name,made-up-column'") + raises(Exception, "stuff.order_by = ('made-up-column',)") # when a column name is overwritten, the original won't work anymore - raises(Exception, "books.order_by = 'c'") + raises(Exception, "stuff.order_by = 'c'") # reset for future tests tables.options.IGNORE_INVALID_OPTIONS = True @@ -117,18 +122,19 @@ def test_sort(): id = tables.Column() name = tables.Column() pages = tables.Column(name='num_pages') # test rewritten names - language = tables.Column(default='en') # default affects sorting + language = tables.Column(default='en') # default affects sorting + rating = tables.Column(data='*') # test data field option 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, 'pages': 60, 'name': 'Z: The Book', '*': 5}, # language: en + {'id': 2, 'pages': 100, 'language': 'de', 'name': 'A: The Book', '*': 2}, + {'id': 3, 'pages': 80, 'language': 'de', 'name': 'A: The Book, Vol. 2', '*': 4}, + {'id': 4, 'pages': 110, 'language': 'fr', 'name': 'A: The Book, French Edition'}, # rating (with data option) is missing ]) def test_order(order, result): books.order_by = order - assert [b['id'] for b in books.data] == result + assert [b['id'] for b in books.rows] == result # test various orderings test_order(('num_pages',), [1,3,2,4]) @@ -138,7 +144,10 @@ def test_sort(): # using a simple string (for convinience as well as querystring passing test_order('-num_pages', [4,2,3,1]) test_order('language,num_pages', [3,2,1,4]) - # TODO: test that unrewritte name has no effect + # if overwritten, the declared fieldname has no effect + test_order('pages,name', [2,4,3,1]) # == ('name',) + # sort by column with "data" option + test_order('rating', [4,2,3,1]) # [bug] test alternative order formats if passed to constructor BookTable([], 'language,-num_pages') @@ -152,7 +161,7 @@ def test_sort(): test_order(('language', 'num_pages'), [1,3,2,4]) # as if: 'num_pages' def test_callable(): - """Data fields, ``default`` option can be callables. + """Data fields, ``default`` and ``data`` options can be callables. """ class MathTable(tables.Table): @@ -160,6 +169,7 @@ def test_callable(): rhs = tables.Column() op = tables.Column(default='+') sum = tables.Column(default=lambda d: calc(d['op'], d['lhs'], d['rhs'])) + sqrt = tables.Column(data=lambda d: int(sqrt(d['sum']))) math = MathTable([ {'lhs': 1, 'rhs': lambda x: x['lhs']*3}, # 1+3 @@ -174,9 +184,14 @@ def test_callable(): elif op == '-': return lhs-rhs assert [calc(row['op'], row['lhs'], row['rhs']) for row in math] == [4,1,3] - # function is called while sorting + # field function is called while sorting math.order_by = ('-rhs',) assert [row['rhs'] for row in math] == [9,4,3] + # default function is called while sorting math.order_by = ('sum',) - assert [row['sum'] for row in math] == [1,3,4] \ No newline at end of file + assert [row['sum'] for row in math] == [1,3,4] + + # data function is called while sorting + math.order_by = ('sqrt',) + assert [row['sqrt'] for row in math] == [1,1,2] \ No newline at end of file diff --git a/tests/test_models.py b/tests/test_models.py index 9b4b231..528d921 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -19,7 +19,7 @@ def setup_module(module): class City(models.Model): name = models.TextField() - population = models.IntegerField() + population = models.IntegerField(null=True) class Meta: app_label = 'testapp' module.City = City @@ -42,10 +42,12 @@ def setup_module(module): call_command('syncdb', verbosity=1, interactive=False) # create a couple of objects + berlin=City(name="Berlin"); berlin.save() + amsterdam=City(name="Amsterdam"); amsterdam.save() Country(name="Austria", tld="au", population=8, system="republic").save() - Country(name="Germany", tld="de", population=81).save() + Country(name="Germany", tld="de", population=81, capital=berlin).save() Country(name="France", tld="fr", population=64, system="republic").save() - Country(name="Netherlands", tld="nl", population=16, system="monarchy").save() + Country(name="Netherlands", tld="nl", population=16, system="monarchy", capital=amsterdam).save() def test_declaration(): """Test declaration, declared columns and default model field columns. @@ -194,6 +196,7 @@ def test_sort(): assert countries.order_by == () def test_pagination(): + # TODO: support pagination pass def test_callable(): @@ -216,5 +219,47 @@ def test_callable(): assert [row['example_domain'] for row in countries] == \ [row['null'] for row in countries] -# TODO: pagination -# TODO: support relationship spanning columns \ No newline at end of file +def test_relationships(): + """Test relationship spanning.""" + + class CountryTable(tables.ModelTable): + # add relationship spanning columns (using different approaches) + capital_name = tables.Column(data='capital__name') + capital__population = tables.Column(name="capital_population") + invalid = tables.Column(data="capital__invalid") + class Meta: + model = Country + countries = CountryTable(Country.objects.select_related('capital')) + + # ordering and field access works + countries.order_by = 'capital_name' + assert [row['capital_name'] for row in countries.rows] == \ + [None, None, 'Amsterdam', 'Berlin'] + + countries.order_by = 'capital_population' + assert [row['capital_population'] for row in countries.rows] == \ + [None, None, None, None] + + # ordering by a column with an invalid relationship fails silently + countries.order_by = 'invalid' + assert countries.order_by == () + +def test_column_data(): + """Further test the ``data`` column property in a ModelTable scenario. + Other tests already touched on this, for example ``test_realtionships``. + """ + + class CountryTable(tables.ModelTable): + name = tables.Column(data=lambda d: "hidden") + default_and_data = tables.Column(data=lambda d: None, default=4) + class Meta: + model = Country + countries = CountryTable(Country) + + # callable data works, even with a default set + assert [row['default_and_data'] for row in countries] == [4,4,4,4] + + # neato trick: a callable data= column is sortable, if otherwise refers + # to correct model column; can be used to rewrite what is displayed + countries.order_by = 'name' + assert countries.order_by == ('name',) \ No newline at end of file -- 2.26.2