From 63bf4a2fe43c26c4091233aa2be308c9881e25b4 Mon Sep 17 00:00:00 2001 From: Michael Elsdoerfer Date: Wed, 25 Aug 2010 00:27:08 +0200 Subject: [PATCH] Convert a bunch of files to use *nix line endings. --- django_tables/__init__.py | 12 +- django_tables/app/templatetags/tables.py | 140 +-- django_tables/base.py | 1228 +++++++++++----------- django_tables/columns.py | 168 +-- tests/__init__.py | 4 +- tests/test_memory.py | 698 ++++++------ tests/test_models.py | 658 ++++++------ tests/test_templates.py | 218 ++-- 8 files changed, 1563 insertions(+), 1563 deletions(-) diff --git a/django_tables/__init__.py b/django_tables/__init__.py index b41bdaf..12ea230 100644 --- a/django_tables/__init__.py +++ b/django_tables/__init__.py @@ -1,7 +1,7 @@ -__version__ = (0, 3, 'dev') - - -from memory import * -from models import * -from columns import * +__version__ = (0, 3, 'dev') + + +from memory import * +from models import * +from columns import * from options import * \ No newline at end of file diff --git a/django_tables/app/templatetags/tables.py b/django_tables/app/templatetags/tables.py index fe03ef8..7dc6803 100644 --- a/django_tables/app/templatetags/tables.py +++ b/django_tables/app/templatetags/tables.py @@ -1,71 +1,71 @@ -""" -Allows setting/changing/removing of chosen url query string parameters, -while maintaining any existing others. - -Expects the current request to be available in the context as ``request``. - -Examples: - - {% set_url_param page=next_page %} - {% set_url_param page="" %} - {% set_url_param filter="books" page=1 %} -""" - -import urllib -import tokenize -import StringIO -from django import template -from django.utils.safestring import mark_safe - -register = template.Library() - -class SetUrlParamNode(template.Node): - def __init__(self, changes): - self.changes = changes - - def render(self, context): - request = context.get('request', None) - if not request: return "" - - # Note that we want params to **not** be a ``QueryDict`` (thus we - # don't use it's ``copy()`` method), as it would force all values - # to be unicode, and ``urllib.urlencode`` can't handle that. - params = dict(request.GET) - for key, newvalue in self.changes.items(): - newvalue = newvalue.resolve(context) - if newvalue=='' or newvalue is None: params.pop(key, False) - else: params[key] = unicode(newvalue) - # ``urlencode`` chokes on unicode input, so convert everything to - # utf8. Note that if some query arguments passed to the site have - # their non-ascii characters screwed up when passed though this, - # it's most likely not our fault. Django (the ``QueryDict`` class - # to be exact) uses your projects DEFAULT_CHARSET to decode incoming - # query strings, whereas your browser might encode the url - # differently. For example, typing "ä" in my German Firefox's (v2) - # address bar results in "%E4" being passed to the server (in - # iso-8859-1), but Django might expect utf-8, where ä would be - # "%C3%A4" - def mkstr(s): - if isinstance(s, list): return map(mkstr, s) - else: return (isinstance(s, unicode) and [s.encode('utf-8')] or [s])[0] - params = dict([(mkstr(k), mkstr(v)) for k, v in params.items()]) - # done, return (string is already safe) - return '?'+urllib.urlencode(params, doseq=True) - -def do_seturlparam(parser, token): - bits = token.contents.split() - qschanges = {} - for i in bits[1:]: - try: - a, b = i.split('=', 1); a = a.strip(); b = b.strip() - keys = list(tokenize.generate_tokens(StringIO.StringIO(a).readline)) - if keys[0][0] == tokenize.NAME: - if b == '""': b = template.Variable('""') # workaround bug #5270 - else: b = parser.compile_filter(b) - qschanges[str(a)] = b - else: raise ValueError - except ValueError: - raise template.TemplateSyntaxError, "Argument syntax wrong: should be key=value" - return SetUrlParamNode(qschanges) - +""" +Allows setting/changing/removing of chosen url query string parameters, +while maintaining any existing others. + +Expects the current request to be available in the context as ``request``. + +Examples: + + {% set_url_param page=next_page %} + {% set_url_param page="" %} + {% set_url_param filter="books" page=1 %} +""" + +import urllib +import tokenize +import StringIO +from django import template +from django.utils.safestring import mark_safe + +register = template.Library() + +class SetUrlParamNode(template.Node): + def __init__(self, changes): + self.changes = changes + + def render(self, context): + request = context.get('request', None) + if not request: return "" + + # Note that we want params to **not** be a ``QueryDict`` (thus we + # don't use it's ``copy()`` method), as it would force all values + # to be unicode, and ``urllib.urlencode`` can't handle that. + params = dict(request.GET) + for key, newvalue in self.changes.items(): + newvalue = newvalue.resolve(context) + if newvalue=='' or newvalue is None: params.pop(key, False) + else: params[key] = unicode(newvalue) + # ``urlencode`` chokes on unicode input, so convert everything to + # utf8. Note that if some query arguments passed to the site have + # their non-ascii characters screwed up when passed though this, + # it's most likely not our fault. Django (the ``QueryDict`` class + # to be exact) uses your projects DEFAULT_CHARSET to decode incoming + # query strings, whereas your browser might encode the url + # differently. For example, typing "ä" in my German Firefox's (v2) + # address bar results in "%E4" being passed to the server (in + # iso-8859-1), but Django might expect utf-8, where ä would be + # "%C3%A4" + def mkstr(s): + if isinstance(s, list): return map(mkstr, s) + else: return (isinstance(s, unicode) and [s.encode('utf-8')] or [s])[0] + params = dict([(mkstr(k), mkstr(v)) for k, v in params.items()]) + # done, return (string is already safe) + return '?'+urllib.urlencode(params, doseq=True) + +def do_seturlparam(parser, token): + bits = token.contents.split() + qschanges = {} + for i in bits[1:]: + try: + a, b = i.split('=', 1); a = a.strip(); b = b.strip() + keys = list(tokenize.generate_tokens(StringIO.StringIO(a).readline)) + if keys[0][0] == tokenize.NAME: + if b == '""': b = template.Variable('""') # workaround bug #5270 + else: b = parser.compile_filter(b) + qschanges[str(a)] = b + else: raise ValueError + except ValueError: + raise template.TemplateSyntaxError, "Argument syntax wrong: should be key=value" + return SetUrlParamNode(qschanges) + register.tag('set_url_param', do_seturlparam) \ No newline at end of file diff --git a/django_tables/base.py b/django_tables/base.py index bd8ac9f..75b5e95 100644 --- a/django_tables/base.py +++ b/django_tables/base.py @@ -1,614 +1,614 @@ -import copy -from django.http import Http404 -from django.core import paginator -from django.utils.datastructures import SortedDict -from django.utils.encoding import force_unicode, StrAndUnicode -from django.utils.text import capfirst -from columns import Column -from options import options - - -__all__ = ('BaseTable', 'options') - - -class TableOptions(object): - def __init__(self, options=None): - super(TableOptions, self).__init__() - self.sortable = getattr(options, 'sortable', None) - self.order_by = getattr(options, 'order_by', None) - - -class DeclarativeColumnsMetaclass(type): - """ - Metaclass that converts Column attributes to a dictionary called - 'base_columns', taking into account parent class 'base_columns' - as well. - """ - def __new__(cls, name, bases, attrs, parent_cols_from=None): - """ - The ``parent_cols_from`` argument determins from which attribute - we read the columns of a base class that this table might be - subclassing. This is useful for ``ModelTable`` (and possibly other - derivatives) which might want to differ between the declared columns - and others. - - Note that if the attribute specified in ``parent_cols_from`` is not - found, we fall back to the default (``base_columns``), instead of - skipping over that base. This makes a table like the following work: - - class MyNewTable(tables.ModelTable, MyNonModelTable): - pass - - ``MyNewTable`` will be built by the ModelTable metaclass, which will - call this base with a modified ``parent_cols_from`` argument - specific to ModelTables. Since ``MyNonModelTable`` is not a - ModelTable, and thus does not provide that attribute, the columns - from that base class would otherwise be ignored. - """ - - # extract declared columns - columns = [(column_name, attrs.pop(column_name)) - for column_name, obj in attrs.items() - if isinstance(obj, Column)] - columns.sort(lambda x, y: cmp(x[1].creation_counter, - y[1].creation_counter)) - - # If this class is subclassing other tables, add their fields as - # well. Note that we loop over the bases in *reverse* - this is - # necessary to preserve the correct order of columns. - for base in bases[::-1]: - col_attr = (parent_cols_from and hasattr(base, parent_cols_from)) \ - and parent_cols_from\ - or 'base_columns' - if hasattr(base, col_attr): - columns = getattr(base, col_attr).items() + columns - # Note that we are reusing an existing ``base_columns`` attribute. - # This is because in certain inheritance cases (mixing normal and - # ModelTables) this metaclass might be executed twice, and we need - # to avoid overriding previous data (because we pop() from attrs, - # the second time around columns might not be registered again). - # An example would be: - # class MyNewTable(MyOldNonModelTable, tables.ModelTable): pass - if not 'base_columns' in attrs: - attrs['base_columns'] = SortedDict() - attrs['base_columns'].update(SortedDict(columns)) - - attrs['_meta'] = TableOptions(attrs.get('Meta', None)) - return type.__new__(cls, name, bases, attrs) - - -def rmprefix(s): - """Normalize a column name by removing a potential sort prefix""" - return (s[:1]=='-' and [s[1:]] or [s])[0] - -def toggleprefix(s): - """Remove - prefix is existing, or add if missing.""" - return ((s[:1] == '-') and [s[1:]] or ["-"+s])[0] - -class OrderByTuple(tuple, StrAndUnicode): - """Stores 'order by' instructions; Used to render output in a format - we understand as input (see __unicode__) - especially useful in - templates. - - Also supports some functionality to interact with and modify - the order. - """ - def __unicode__(self): - """Output in our input format.""" - return ",".join(self) - - def __contains__(self, name): - """Determine whether a column is part of this order.""" - for o in self: - if rmprefix(o) == name: - return True - return False - - def is_reversed(self, name): - """Returns a bool indicating whether the column is ordered - reversed, None if it is missing.""" - for o in self: - if o == '-'+name: - return True - return False - def is_straight(self, name): - """The opposite of is_reversed.""" - for o in self: - if o == name: - return True - return False - - def polarize(self, reverse, names=()): - """Return a new tuple with the columns from ``names`` set to - "reversed" (e.g. prefixed with a '-'). Note that the name is - ambiguous - do not confuse this with ``toggle()``. - - If names is not specified, all columns are reversed. If a - column name is given that is currently not part of the order, - it is added. - """ - prefix = reverse and '-' or '' - return OrderByTuple( - [ - ( - # add either untouched, or reversed - (names and rmprefix(o) not in names) - and [o] - or [prefix+rmprefix(o)] - )[0] - for o in self] - + - [prefix+name for name in names if not name in self] - ) - - def toggle(self, names=()): - """Return a new tuple with the columns from ``names`` toggled - with respect to their "reversed" state. E.g. a '-' prefix will - be removed is existing, or added if lacking. Do not confuse - with ``reverse()``. - - If names is not specified, all columns are toggled. If a - column name is given that is currently not part of the order, - it is added in non-reverse form.""" - return OrderByTuple( - [ - ( - # add either untouched, or toggled - (names and rmprefix(o) not in names) - and [o] - or ((o[:1] == '-') and [o[1:]] or ["-"+o]) - )[0] - for o in self] - + - [name for name in names if not name in self] - ) - - -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 in 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 _reset(self): - """Used by parent table class.""" - 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 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[exposed_name] = BoundColumn(self.table, column, decl_name) - self._columns = new_columns - - def all(self): - """Iterate through all columns, regardless of visiblity (as - opposed to ``__iter__``. - - This is used internally a lot. - """ - 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 names(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 sortable(self): - """Iterate through all sortable columns. - - This is primarily useful in templates, where iterating over the full - set and checking {% if column.sortable %} can be problematic in - conjunction with e.g. {{ forloop.last }} (the last column might not - be the actual last that is rendered). - """ - for column in self.all(): - if column.sortable: - yield column - - def __iter__(self): - """Iterate through all *visible* bound columns. - - This is primarily geared towards table rendering. - """ - for column in self.all(): - if 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.names() - else: - return item in self.all() - - def __len__(self): - self._spawn_columns() - return len([1 for c in self._columns.values() if c.visible]) - - 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. - - Note that the name that is passed in tells us how this field is - delared in the bound table. The column itself can overwrite this name. - While the overwritten name will be hat mostly counts, we need to - remember the one used for declaration as well, or we won't know how - to read a column's value from the source. - """ - def __init__(self, table, column, name): - self.table = table - self.column = column - self.declared_name = name - # expose some attributes of the column more directly - self.visible = column.visible - - @property - def accessor(self): - """The key to use when accessing this column's values in the - source data. - """ - return self.column.data if self.column.data else self.declared_name - - def _get_sortable(self): - if self.column.sortable is not None: - return self.column.sortable - elif self.table._meta.sortable is not None: - return self.table._meta.sortable - else: - return True # the default value - sortable = property(_get_sortable) - - name = property(lambda s: s.column.name or s.declared_name) - name_reversed = property(lambda s: "-"+s.name) - def _get_name_toggled(self): - o = self.table.order_by - if (not self.name in o) or o.is_reversed(self.name): return self.name - else: return self.name_reversed - name_toggled = property(_get_name_toggled) - - is_ordered = property(lambda s: s.name in s.table.order_by) - is_ordered_reverse = property(lambda s: s.table.order_by.is_reversed(s.name)) - is_ordered_straight = property(lambda s: s.table.order_by.is_straight(s.name)) - order_by = property(lambda s: s.table.order_by.polarize(False, [s.name])) - order_by_reversed = property(lambda s: s.table.order_by.polarize(True, [s.name])) - order_by_toggled = property(lambda s: s.table.order_by.toggle([s.name])) - - def get_default(self, row): - """Since a column's ``default`` property may be a callable, we need - this function to resolve it when needed. - - Make sure ``row`` is a ``BoundRow`` object, since that is what - we promise the callable will get. - """ - if callable(self.column.default): - return self.column.default(row) - return self.column.default - - def _get_values(self): - # TODO: build a list of values used - pass - values = property(_get_values) - - def __unicode__(self): - s = self.column.verbose_name or self.name.replace('_', ' ') - return capfirst(force_unicode(s)) - - 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 this row's value for a column. All other access methods, - e.g. __iter__, lead ultimately to this.""" - - column = self.table.columns[name] - - render_func = getattr(self.table, 'render_%s' % name, False) - if render_func: - return render_func(self.data) - else: - return self._default_render(column) - - def _default_render(self, column): - """Returns a cell's content. This is used unless the user - provides a custom ``render_FOO`` method. - """ - result = self.data[column.accessor] - - # if the field we are pointing to is a callable, remove it - if callable(result): - result = result(self) - return result - - 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 - - -class Rows(object): - """Container for spawning BoundRows. - - This is bound to a table and provides it's ``rows`` property. It - provides functionality that would not be possible with a simple - iterator in the table class. - """ - - row_class = BoundRow - - def __init__(self, table): - self.table = table - - def _reset(self): - pass # we currently don't use a cache - - def all(self): - """Return all rows.""" - for row in self.table.data: - yield self.row_class(self.table, row) - - def page(self): - """Return rows on current page (if paginated).""" - if not hasattr(self.table, 'page'): - return None - return iter(self.table.page.object_list) - - def __iter__(self): - return iter(self.all()) - - def __len__(self): - return len(self.table.data) - - def __getitem__(self, key): - if isinstance(key, slice): - result = list() - for row in self.table.data[key]: - result.append(self.row_class(self.table, row)) - return result - elif isinstance(key, int): - return self.row_class(self.table, self.table.data[key]) - else: - raise TypeError('Key must be a slice or integer.') - - -class BaseTable(object): - """A collection of columns, plus their associated data rows. - """ - - __metaclass__ = DeclarativeColumnsMetaclass - - rows_class = Rows - - # this value is not the same as None. it means 'use the default sort - # order', which may (or may not) be inherited from the table options. - # None means 'do not sort the data', ignoring the default. - DefaultOrder = type('DefaultSortType', (), {})() - - def __init__(self, data, order_by=DefaultOrder): - """Create a new table instance with the iterable ``data``. - - If ``order_by`` is specified, the data will be sorted accordingly. - Otherwise, the sort order can be specified in the table options. - - 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._snapshot = None # will store output dataset (ordered...) - self._rows = self.rows_class(self) - self._columns = Columns(self) - - # None is a valid order, so we must use DefaultOrder as a flag - # to fall back to the table sort order. set the attr via the - # property, to wrap it in an OrderByTuple before being stored - if order_by != BaseTable.DefaultOrder: - self.order_by = order_by - - else: - self.order_by = self._meta.order_by - - # 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 _reset_snapshot(self, reason): - """Called to reset the current snaptshot, for example when - options change that could affect it. - - ``reason`` is given so that subclasses can decide that a - given change may not affect their snaptshot. - """ - self._snapshot = None - - def _build_snapshot(self): - """Rebuild the table for the current set of options. - - Whenver the table options change, e.g. say a new sort order, - this method will be asked to regenerate the actual table from - the linked data source. - - Subclasses should override this. - """ - return self._data - - def _get_data(self): - if self._snapshot is None: - self._snapshot = self._build_snapshot() - return self._snapshot - data = property(lambda s: s._get_data()) - - def _resolve_sort_directions(self, order_by): - """Given an ``order_by`` tuple, this will toggle the hyphen-prefixes - according to each column's ``direction`` option, e.g. it translates - between the ascending/descending and the straight/reverse terminology. - """ - result = [] - for inst in order_by: - if self.columns[rmprefix(inst)].column.direction == Column.DESC: - inst = toggleprefix(inst) - result.append(inst) - return result - - def _cols_to_fields(self, names): - """Utility function. Given a list of column names (as exposed to - the user), converts column names to the names we have to use to - retrieve a column's data from the source. - - 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 = '' - # find the field name - column = self.columns[name] - result.append(prefix + column.accessor) - return result - - 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].sortable - else: - return True - - def _set_order_by(self, value): - self._reset_snapshot('order_by') - # accept both string and tuple instructions - order_by = (isinstance(value, basestring) \ - and [value.split(',')] \ - or [value])[0] - if order_by: - # validate, remove all invalid order instructions - validated_order_by = [] - for o in order_by: - if self._validate_column_name(rmprefix(o), "order_by"): - validated_order_by.append(o) - elif not options.IGNORE_INVALID_OPTIONS: - raise ValueError('Column name %s is invalid.' % o) - self._order_by = OrderByTuple(validated_order_by) - else: - self._order_by = OrderByTuple() - - order_by = property(lambda s: s._order_by, _set_order_by) - - def __unicode__(self): - return self.as_html() - - def __iter__(self): - for row in self.rows: - yield row - - def __getitem__(self, key): - return self.rows[key] - - # just to make those readonly - columns = property(lambda s: s._columns) - rows = property(lambda s: s._rows) - - def as_html(self): - pass - - def update(self): - """Update the table based on it's current options. - - Normally, you won't have to call this method, since the table - updates itself (it's caches) automatically whenever you change - any of the properties. However, in some rare cases those - changes might not be picked up, for example if you manually - change ``base_columns`` or any of the columns in it. - """ - self._build_snapshot() - - def paginate(self, klass, *args, **kwargs): - page = kwargs.pop('page', 1) - self.paginator = klass(self.rows, *args, **kwargs) - try: - self.page = self.paginator.page(page) - except paginator.InvalidPage, e: - raise Http404(str(e)) +import copy +from django.http import Http404 +from django.core import paginator +from django.utils.datastructures import SortedDict +from django.utils.encoding import force_unicode, StrAndUnicode +from django.utils.text import capfirst +from columns import Column +from options import options + + +__all__ = ('BaseTable', 'options') + + +class TableOptions(object): + def __init__(self, options=None): + super(TableOptions, self).__init__() + self.sortable = getattr(options, 'sortable', None) + self.order_by = getattr(options, 'order_by', None) + + +class DeclarativeColumnsMetaclass(type): + """ + Metaclass that converts Column attributes to a dictionary called + 'base_columns', taking into account parent class 'base_columns' + as well. + """ + def __new__(cls, name, bases, attrs, parent_cols_from=None): + """ + The ``parent_cols_from`` argument determins from which attribute + we read the columns of a base class that this table might be + subclassing. This is useful for ``ModelTable`` (and possibly other + derivatives) which might want to differ between the declared columns + and others. + + Note that if the attribute specified in ``parent_cols_from`` is not + found, we fall back to the default (``base_columns``), instead of + skipping over that base. This makes a table like the following work: + + class MyNewTable(tables.ModelTable, MyNonModelTable): + pass + + ``MyNewTable`` will be built by the ModelTable metaclass, which will + call this base with a modified ``parent_cols_from`` argument + specific to ModelTables. Since ``MyNonModelTable`` is not a + ModelTable, and thus does not provide that attribute, the columns + from that base class would otherwise be ignored. + """ + + # extract declared columns + columns = [(column_name, attrs.pop(column_name)) + for column_name, obj in attrs.items() + if isinstance(obj, Column)] + columns.sort(lambda x, y: cmp(x[1].creation_counter, + y[1].creation_counter)) + + # If this class is subclassing other tables, add their fields as + # well. Note that we loop over the bases in *reverse* - this is + # necessary to preserve the correct order of columns. + for base in bases[::-1]: + col_attr = (parent_cols_from and hasattr(base, parent_cols_from)) \ + and parent_cols_from\ + or 'base_columns' + if hasattr(base, col_attr): + columns = getattr(base, col_attr).items() + columns + # Note that we are reusing an existing ``base_columns`` attribute. + # This is because in certain inheritance cases (mixing normal and + # ModelTables) this metaclass might be executed twice, and we need + # to avoid overriding previous data (because we pop() from attrs, + # the second time around columns might not be registered again). + # An example would be: + # class MyNewTable(MyOldNonModelTable, tables.ModelTable): pass + if not 'base_columns' in attrs: + attrs['base_columns'] = SortedDict() + attrs['base_columns'].update(SortedDict(columns)) + + attrs['_meta'] = TableOptions(attrs.get('Meta', None)) + return type.__new__(cls, name, bases, attrs) + + +def rmprefix(s): + """Normalize a column name by removing a potential sort prefix""" + return (s[:1]=='-' and [s[1:]] or [s])[0] + +def toggleprefix(s): + """Remove - prefix is existing, or add if missing.""" + return ((s[:1] == '-') and [s[1:]] or ["-"+s])[0] + +class OrderByTuple(tuple, StrAndUnicode): + """Stores 'order by' instructions; Used to render output in a format + we understand as input (see __unicode__) - especially useful in + templates. + + Also supports some functionality to interact with and modify + the order. + """ + def __unicode__(self): + """Output in our input format.""" + return ",".join(self) + + def __contains__(self, name): + """Determine whether a column is part of this order.""" + for o in self: + if rmprefix(o) == name: + return True + return False + + def is_reversed(self, name): + """Returns a bool indicating whether the column is ordered + reversed, None if it is missing.""" + for o in self: + if o == '-'+name: + return True + return False + def is_straight(self, name): + """The opposite of is_reversed.""" + for o in self: + if o == name: + return True + return False + + def polarize(self, reverse, names=()): + """Return a new tuple with the columns from ``names`` set to + "reversed" (e.g. prefixed with a '-'). Note that the name is + ambiguous - do not confuse this with ``toggle()``. + + If names is not specified, all columns are reversed. If a + column name is given that is currently not part of the order, + it is added. + """ + prefix = reverse and '-' or '' + return OrderByTuple( + [ + ( + # add either untouched, or reversed + (names and rmprefix(o) not in names) + and [o] + or [prefix+rmprefix(o)] + )[0] + for o in self] + + + [prefix+name for name in names if not name in self] + ) + + def toggle(self, names=()): + """Return a new tuple with the columns from ``names`` toggled + with respect to their "reversed" state. E.g. a '-' prefix will + be removed is existing, or added if lacking. Do not confuse + with ``reverse()``. + + If names is not specified, all columns are toggled. If a + column name is given that is currently not part of the order, + it is added in non-reverse form.""" + return OrderByTuple( + [ + ( + # add either untouched, or toggled + (names and rmprefix(o) not in names) + and [o] + or ((o[:1] == '-') and [o[1:]] or ["-"+o]) + )[0] + for o in self] + + + [name for name in names if not name in self] + ) + + +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 in 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 _reset(self): + """Used by parent table class.""" + 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 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[exposed_name] = BoundColumn(self.table, column, decl_name) + self._columns = new_columns + + def all(self): + """Iterate through all columns, regardless of visiblity (as + opposed to ``__iter__``. + + This is used internally a lot. + """ + 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 names(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 sortable(self): + """Iterate through all sortable columns. + + This is primarily useful in templates, where iterating over the full + set and checking {% if column.sortable %} can be problematic in + conjunction with e.g. {{ forloop.last }} (the last column might not + be the actual last that is rendered). + """ + for column in self.all(): + if column.sortable: + yield column + + def __iter__(self): + """Iterate through all *visible* bound columns. + + This is primarily geared towards table rendering. + """ + for column in self.all(): + if 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.names() + else: + return item in self.all() + + def __len__(self): + self._spawn_columns() + return len([1 for c in self._columns.values() if c.visible]) + + 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. + + Note that the name that is passed in tells us how this field is + delared in the bound table. The column itself can overwrite this name. + While the overwritten name will be hat mostly counts, we need to + remember the one used for declaration as well, or we won't know how + to read a column's value from the source. + """ + def __init__(self, table, column, name): + self.table = table + self.column = column + self.declared_name = name + # expose some attributes of the column more directly + self.visible = column.visible + + @property + def accessor(self): + """The key to use when accessing this column's values in the + source data. + """ + return self.column.data if self.column.data else self.declared_name + + def _get_sortable(self): + if self.column.sortable is not None: + return self.column.sortable + elif self.table._meta.sortable is not None: + return self.table._meta.sortable + else: + return True # the default value + sortable = property(_get_sortable) + + name = property(lambda s: s.column.name or s.declared_name) + name_reversed = property(lambda s: "-"+s.name) + def _get_name_toggled(self): + o = self.table.order_by + if (not self.name in o) or o.is_reversed(self.name): return self.name + else: return self.name_reversed + name_toggled = property(_get_name_toggled) + + is_ordered = property(lambda s: s.name in s.table.order_by) + is_ordered_reverse = property(lambda s: s.table.order_by.is_reversed(s.name)) + is_ordered_straight = property(lambda s: s.table.order_by.is_straight(s.name)) + order_by = property(lambda s: s.table.order_by.polarize(False, [s.name])) + order_by_reversed = property(lambda s: s.table.order_by.polarize(True, [s.name])) + order_by_toggled = property(lambda s: s.table.order_by.toggle([s.name])) + + def get_default(self, row): + """Since a column's ``default`` property may be a callable, we need + this function to resolve it when needed. + + Make sure ``row`` is a ``BoundRow`` object, since that is what + we promise the callable will get. + """ + if callable(self.column.default): + return self.column.default(row) + return self.column.default + + def _get_values(self): + # TODO: build a list of values used + pass + values = property(_get_values) + + def __unicode__(self): + s = self.column.verbose_name or self.name.replace('_', ' ') + return capfirst(force_unicode(s)) + + 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 this row's value for a column. All other access methods, + e.g. __iter__, lead ultimately to this.""" + + column = self.table.columns[name] + + render_func = getattr(self.table, 'render_%s' % name, False) + if render_func: + return render_func(self.data) + else: + return self._default_render(column) + + def _default_render(self, column): + """Returns a cell's content. This is used unless the user + provides a custom ``render_FOO`` method. + """ + result = self.data[column.accessor] + + # if the field we are pointing to is a callable, remove it + if callable(result): + result = result(self) + return result + + 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 + + +class Rows(object): + """Container for spawning BoundRows. + + This is bound to a table and provides it's ``rows`` property. It + provides functionality that would not be possible with a simple + iterator in the table class. + """ + + row_class = BoundRow + + def __init__(self, table): + self.table = table + + def _reset(self): + pass # we currently don't use a cache + + def all(self): + """Return all rows.""" + for row in self.table.data: + yield self.row_class(self.table, row) + + def page(self): + """Return rows on current page (if paginated).""" + if not hasattr(self.table, 'page'): + return None + return iter(self.table.page.object_list) + + def __iter__(self): + return iter(self.all()) + + def __len__(self): + return len(self.table.data) + + def __getitem__(self, key): + if isinstance(key, slice): + result = list() + for row in self.table.data[key]: + result.append(self.row_class(self.table, row)) + return result + elif isinstance(key, int): + return self.row_class(self.table, self.table.data[key]) + else: + raise TypeError('Key must be a slice or integer.') + + +class BaseTable(object): + """A collection of columns, plus their associated data rows. + """ + + __metaclass__ = DeclarativeColumnsMetaclass + + rows_class = Rows + + # this value is not the same as None. it means 'use the default sort + # order', which may (or may not) be inherited from the table options. + # None means 'do not sort the data', ignoring the default. + DefaultOrder = type('DefaultSortType', (), {})() + + def __init__(self, data, order_by=DefaultOrder): + """Create a new table instance with the iterable ``data``. + + If ``order_by`` is specified, the data will be sorted accordingly. + Otherwise, the sort order can be specified in the table options. + + 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._snapshot = None # will store output dataset (ordered...) + self._rows = self.rows_class(self) + self._columns = Columns(self) + + # None is a valid order, so we must use DefaultOrder as a flag + # to fall back to the table sort order. set the attr via the + # property, to wrap it in an OrderByTuple before being stored + if order_by != BaseTable.DefaultOrder: + self.order_by = order_by + + else: + self.order_by = self._meta.order_by + + # 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 _reset_snapshot(self, reason): + """Called to reset the current snaptshot, for example when + options change that could affect it. + + ``reason`` is given so that subclasses can decide that a + given change may not affect their snaptshot. + """ + self._snapshot = None + + def _build_snapshot(self): + """Rebuild the table for the current set of options. + + Whenver the table options change, e.g. say a new sort order, + this method will be asked to regenerate the actual table from + the linked data source. + + Subclasses should override this. + """ + return self._data + + def _get_data(self): + if self._snapshot is None: + self._snapshot = self._build_snapshot() + return self._snapshot + data = property(lambda s: s._get_data()) + + def _resolve_sort_directions(self, order_by): + """Given an ``order_by`` tuple, this will toggle the hyphen-prefixes + according to each column's ``direction`` option, e.g. it translates + between the ascending/descending and the straight/reverse terminology. + """ + result = [] + for inst in order_by: + if self.columns[rmprefix(inst)].column.direction == Column.DESC: + inst = toggleprefix(inst) + result.append(inst) + return result + + def _cols_to_fields(self, names): + """Utility function. Given a list of column names (as exposed to + the user), converts column names to the names we have to use to + retrieve a column's data from the source. + + 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 = '' + # find the field name + column = self.columns[name] + result.append(prefix + column.accessor) + return result + + 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].sortable + else: + return True + + def _set_order_by(self, value): + self._reset_snapshot('order_by') + # accept both string and tuple instructions + order_by = (isinstance(value, basestring) \ + and [value.split(',')] \ + or [value])[0] + if order_by: + # validate, remove all invalid order instructions + validated_order_by = [] + for o in order_by: + if self._validate_column_name(rmprefix(o), "order_by"): + validated_order_by.append(o) + elif not options.IGNORE_INVALID_OPTIONS: + raise ValueError('Column name %s is invalid.' % o) + self._order_by = OrderByTuple(validated_order_by) + else: + self._order_by = OrderByTuple() + + order_by = property(lambda s: s._order_by, _set_order_by) + + def __unicode__(self): + return self.as_html() + + def __iter__(self): + for row in self.rows: + yield row + + def __getitem__(self, key): + return self.rows[key] + + # just to make those readonly + columns = property(lambda s: s._columns) + rows = property(lambda s: s._rows) + + def as_html(self): + pass + + def update(self): + """Update the table based on it's current options. + + Normally, you won't have to call this method, since the table + updates itself (it's caches) automatically whenever you change + any of the properties. However, in some rare cases those + changes might not be picked up, for example if you manually + change ``base_columns`` or any of the columns in it. + """ + self._build_snapshot() + + def paginate(self, klass, *args, **kwargs): + page = kwargs.pop('page', 1) + self.paginator = klass(self.rows, *args, **kwargs) + try: + self.page = self.paginator.page(page) + except paginator.InvalidPage, e: + raise Http404(str(e)) diff --git a/django_tables/columns.py b/django_tables/columns.py index 5715029..70ae897 100644 --- a/django_tables/columns.py +++ b/django_tables/columns.py @@ -1,85 +1,85 @@ -__all__ = ( - 'Column', 'TextColumn', 'NumberColumn', -) - -class Column(object): - """Represents a single column of a table. - - ``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 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). 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 - to the user, set ``inaccessible`` to True. - - Setting ``sortable`` to False will result in this column being unusable - in ordering. You can further change the *default* sort direction to - descending using ``direction``. Note that this option changes the actual - direction only indirectly. Normal und reverse order, the terms - django-tables exposes, now simply mean different things. - """ - - ASC = 1 - DESC = 2 - - # 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, data=None, - visible=True, inaccessible=False, sortable=None, - direction=ASC): - self.verbose_name = verbose_name - self.name = name - self.default = default - self.data = data - if callable(self.data): - raise DeprecationWarning(('The Column "data" argument may no '+ - 'longer be a callable. Add a '+ - '``render_%s`` method to your '+ - 'table instead.') % (name or 'FOO')) - self.visible = visible - self.inaccessible = inaccessible - self.sortable = sortable - self.direction = direction - - self.creation_counter = Column.creation_counter - Column.creation_counter += 1 - - def _set_direction(self, value): - if isinstance(value, basestring): - if value in ('asc', 'desc'): - self._direction = (value == 'asc') and Column.ASC or Column.DESC - else: - raise ValueError('Invalid direction value: %s' % value) - else: - self._direction = value - - direction = property(lambda s: s._direction, _set_direction) - - -class TextColumn(Column): - pass - -class NumberColumn(Column): +__all__ = ( + 'Column', 'TextColumn', 'NumberColumn', +) + +class Column(object): + """Represents a single column of a table. + + ``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 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). 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 + to the user, set ``inaccessible`` to True. + + Setting ``sortable`` to False will result in this column being unusable + in ordering. You can further change the *default* sort direction to + descending using ``direction``. Note that this option changes the actual + direction only indirectly. Normal und reverse order, the terms + django-tables exposes, now simply mean different things. + """ + + ASC = 1 + DESC = 2 + + # 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, data=None, + visible=True, inaccessible=False, sortable=None, + direction=ASC): + self.verbose_name = verbose_name + self.name = name + self.default = default + self.data = data + if callable(self.data): + raise DeprecationWarning(('The Column "data" argument may no '+ + 'longer be a callable. Add a '+ + '``render_%s`` method to your '+ + 'table instead.') % (name or 'FOO')) + self.visible = visible + self.inaccessible = inaccessible + self.sortable = sortable + self.direction = direction + + self.creation_counter = Column.creation_counter + Column.creation_counter += 1 + + def _set_direction(self, value): + if isinstance(value, basestring): + if value in ('asc', 'desc'): + self._direction = (value == 'asc') and Column.ASC or Column.DESC + else: + raise ValueError('Invalid direction value: %s' % value) + else: + self._direction = value + + direction = property(lambda s: s._direction, _set_direction) + + +class TextColumn(Column): + pass + +class NumberColumn(Column): pass \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py index 9ec883c..85fb830 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,3 +1,3 @@ -# make django-tables available for import for tests -import os, sys +# make django-tables available for import for tests +import os, sys sys.path.append(os.path.join(os.path.dirname(__file__), '..')) \ No newline at end of file diff --git a/tests/test_memory.py b/tests/test_memory.py index cfb2bfe..315a697 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -1,350 +1,350 @@ -"""Test the memory table functionality. - -TODO: A bunch of those tests probably fit better into test_basic, since -they aren't really MemoryTable specific. -""" - -from math import sqrt -from nose.tools import assert_raises -from django.core.paginator import Paginator -import django_tables as tables - - -def test_basic(): - class StuffTable(tables.MemoryTable): - name = tables.Column() - answer = tables.Column(default=42) - c = tables.Column(name="count", default=1) - email = tables.Column(data="@") - stuff = StuffTable([ - {'id': 1, 'name': 'Foo Bar', '@': 'foo@bar.org'}, - ]) - - # access without order_by works - stuff.data - stuff.rows - - # make sure BoundColumnn.name always gives us the right thing, whether - # the column explicitely defines a name or not. - stuff.columns['count'].name == 'count' - stuff.columns['answer'].name == 'answer' - - for r in stuff.rows: - # unknown fields are removed/not-accessible - 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 - - # columns with data= option work fine - assert r['email'] == 'foo@bar.org' - - # try to splice rows by index - assert 'name' in stuff.rows[0] - assert isinstance(stuff.rows[0:], list) - - # [bug] splicing the table gives us valid, working rows - assert list(stuff[0]) == list(stuff.rows[0]) - assert stuff[0]['name'] == 'Foo Bar' - - # changing an instance's base_columns does not change the class - 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 - try: - assert_raises(ValueError, setattr, stuff, 'order_by', '-name,made-up-column') - assert_raises(ValueError, setattr, stuff, 'order_by', ('made-up-column',)) - # when a column name is overwritten, the original won't work anymore - assert_raises(ValueError, setattr, stuff, 'order_by', 'c') - # reset for future tests - finally: - tables.options.IGNORE_INVALID_OPTIONS = True - - -class TestRender: - """Test use of the render_* methods. - """ - - def test(self): - class TestTable(tables.MemoryTable): - private_name = tables.Column(name='public_name') - def render_public_name(self, data): - # We are given the actual data dict and have direct access - # to additional values for which no field is defined. - return "%s:%s" % (data['private_name'], data['additional']) - - table = TestTable([{'private_name': 'FOO', 'additional': 'BAR'}]) - assert table.rows[0]['public_name'] == 'FOO:BAR' - - def test_not_sorted(self): - """The render methods are not considered when sorting. - """ - class TestTable(tables.MemoryTable): - foo = tables.Column() - def render_foo(self, data): - return -data['foo'] # try to cause a reverse sort - table = TestTable([{'foo': 1}, {'foo': 2}], order_by='asc') - # Result is properly sorted, and the render function has never been called - assert [r['foo'] for r in table.rows] == [-1, -2] - - -def test_caches(): - """Ensure the various caches are effective. - """ - - class BookTable(tables.MemoryTable): - name = tables.Column() - answer = tables.Column(default=42) - books = BookTable([ - {'name': 'Foo: Bar'}, - ]) - - 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]) - - # test that caches are reset after an update() - old_column_cache = id(list(books.columns)[0]) - old_row_cache = id(list(books.rows)[0]) - books.update() - assert id(list(books.columns)[0]) != old_column_cache - assert id(list(books.rows)[0]) != old_row_cache - -def test_meta_sortable(): - """Specific tests for sortable table meta option.""" - - def mktable(default_sortable): - class BookTable(tables.MemoryTable): - id = tables.Column(sortable=True) - name = tables.Column(sortable=False) - author = tables.Column() - class Meta: - sortable = default_sortable - return BookTable([]) - - global_table = mktable(None) - for default_sortable, results in ( - (None, (True, False, True)), # last bool is global default - (True, (True, False, True)), # last bool is table default - (False, (True, False, False)), # last bool is table default - ): - books = mktable(default_sortable) - assert [c.sortable for c in books.columns] == list(results) - - # it also works if the meta option is manually changed after - # class and instance creation - global_table._meta.sortable = default_sortable - assert [c.sortable for c in global_table.columns] == list(results) - - -def test_sort(): - class BookTable(tables.MemoryTable): - id = tables.Column(direction='desc') - name = tables.Column() - pages = tables.Column(name='num_pages') # test rewritten names - 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', '*': 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 - ]) - - # None is normalized to an empty order by tuple, ensuring iterability; - # it also supports all the cool methods that we offer for order_by. - # This is true for the default case... - assert books.order_by == () - iter(books.order_by) - assert hasattr(books.order_by, 'toggle') - # ...as well as when explicitly set to None. - books.order_by = None - assert books.order_by == () - iter(books.order_by) - assert hasattr(books.order_by, 'toggle') - - # test various orderings - def test_order(order, result): - books.order_by = order - assert [b['id'] for b in books.rows] == result - 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', 'num_pages'), [3,2,1,4]) - # 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]) - # 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]) - - # test the column with a default ``direction`` set to descending - test_order('id', [4,3,2,1]) - test_order('-id', [1,2,3,4]) - # changing the direction afterwards is fine too - books.base_columns['id'].direction = 'asc' - test_order('id', [1,2,3,4]) - test_order('-id', [4,3,2,1]) - # a invalid direction string raises an exception - assert_raises(ValueError, setattr, books.base_columns['id'], 'direction', 'blub') - - # [bug] test alternative order formats if passed to constructor - BookTable([], 'language,-num_pages') - - # test invalid order instructions - books.order_by = 'xyz' - assert not books.order_by - books.base_columns['language'].sortable = False - books.order_by = 'language' - assert not books.order_by - test_order(('language', 'num_pages'), [1,3,2,4]) # as if: 'num_pages' - - # [bug] order_by did not run through setter when passed to init - books = BookTable([], order_by='name') - assert books.order_by == ('name',) - - # test table.order_by extensions - books.order_by = '' - assert books.order_by.polarize(False) == () - assert books.order_by.polarize(True) == () - assert books.order_by.toggle() == () - assert books.order_by.polarize(False, ['id']) == ('id',) - assert books.order_by.polarize(True, ['id']) == ('-id',) - assert books.order_by.toggle(['id']) == ('id',) - books.order_by = 'id,-name' - assert books.order_by.polarize(False, ['name']) == ('id', 'name') - assert books.order_by.polarize(True, ['name']) == ('id', '-name') - assert books.order_by.toggle(['name']) == ('id', 'name') - # ``in`` operator works - books.order_by = 'name' - assert 'name' in books.order_by - books.order_by = '-name' - assert 'name' in books.order_by - assert not 'language' in books.order_by - - -def test_callable(): - """Data fields and the ``default`` option can be callables. - """ - - class MathTable(tables.MemoryTable): - lhs = tables.Column() - rhs = tables.Column() - op = tables.Column(default='+') - sum = tables.Column(default=lambda d: calc(d['op'], d['lhs'], d['rhs'])) - - math = MathTable([ - {'lhs': 1, 'rhs': lambda x: x['lhs']*3}, # 1+3 - {'lhs': 9, 'rhs': lambda x: x['lhs'], 'op': '/'}, # 9/9 - {'lhs': lambda x: x['rhs']+3, 'rhs': 4, 'op': '-'}, # 7-4 - ]) - - # function is called when queried - def calc(op, lhs, rhs): - if op == '+': return lhs+rhs - elif op == '/': return lhs/rhs - elif op == '-': return lhs-rhs - assert [calc(row['op'], row['lhs'], row['rhs']) for row in math] == [4,1,3] - - # 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] - - -# TODO: all the column stuff might warrant it's own test file -def test_columns(): - """Test Table.columns container functionality. - """ - - class BookTable(tables.MemoryTable): - id = tables.Column(sortable=False, visible=False) - name = tables.Column(sortable=True) - pages = tables.Column(sortable=True) - language = tables.Column(sortable=False) - books = BookTable([]) - - assert list(books.columns.sortable()) == [c for c in books.columns if c.sortable] - - # .columns iterator only yields visible columns - assert len(list(books.columns)) == 3 - # visiblity of columns can be changed at instance-time - books.columns['id'].visible = True - assert len(list(books.columns)) == 4 - - -def test_column_order(): - """Test the order functionality of bound columns. - """ - - class BookTable(tables.MemoryTable): - id = tables.Column() - name = tables.Column() - pages = tables.Column() - language = tables.Column() - books = BookTable([]) - - # the basic name property is a no-brainer - books.order_by = '' - assert [c.name for c in books.columns] == ['id','name','pages','language'] - - # name_reversed will always reverse, no matter what - for test in ['', 'name', '-name']: - books.order_by = test - assert [c.name_reversed for c in books.columns] == ['-id','-name','-pages','-language'] - - # name_toggled will always toggle - books.order_by = '' - assert [c.name_toggled for c in books.columns] == ['id','name','pages','language'] - books.order_by = 'id' - assert [c.name_toggled for c in books.columns] == ['-id','name','pages','language'] - books.order_by = '-name' - assert [c.name_toggled for c in books.columns] == ['id','name','pages','language'] - # other columns in an order_by will be dismissed - books.order_by = '-id,name' - assert [c.name_toggled for c in books.columns] == ['id','-name','pages','language'] - - # with multi-column order, this is slightly more complex - books.order_by = '' - assert [str(c.order_by) for c in books.columns] == ['id','name','pages','language'] - assert [str(c.order_by_reversed) for c in books.columns] == ['-id','-name','-pages','-language'] - assert [str(c.order_by_toggled) for c in books.columns] == ['id','name','pages','language'] - books.order_by = 'id' - assert [str(c.order_by) for c in books.columns] == ['id','id,name','id,pages','id,language'] - assert [str(c.order_by_reversed) for c in books.columns] == ['-id','id,-name','id,-pages','id,-language'] - assert [str(c.order_by_toggled) for c in books.columns] == ['-id','id,name','id,pages','id,language'] - books.order_by = '-pages,id' - assert [str(c.order_by) for c in books.columns] == ['-pages,id','-pages,id,name','pages,id','-pages,id,language'] - assert [str(c.order_by_reversed) for c in books.columns] == ['-pages,-id','-pages,id,-name','-pages,id','-pages,id,-language'] - assert [str(c.order_by_toggled) for c in books.columns] == ['-pages,-id','-pages,id,name','pages,id','-pages,id,language'] - - # querying whether a column is ordered is possible - books.order_by = '' - assert [c.is_ordered for c in books.columns] == [False, False, False, False] - books.order_by = 'name' - assert [c.is_ordered for c in books.columns] == [False, True, False, False] - assert [c.is_ordered_reverse for c in books.columns] == [False, False, False, False] - assert [c.is_ordered_straight for c in books.columns] == [False, True, False, False] - books.order_by = '-pages' - assert [c.is_ordered for c in books.columns] == [False, False, True, False] - assert [c.is_ordered_reverse for c in books.columns] == [False, False, True, False] - assert [c.is_ordered_straight for c in books.columns] == [False, False, False, False] - # and even works with multi-column ordering - books.order_by = 'id,-pages' - assert [c.is_ordered for c in books.columns] == [True, False, True, False] - assert [c.is_ordered_reverse for c in books.columns] == [False, False, True, False] +"""Test the memory table functionality. + +TODO: A bunch of those tests probably fit better into test_basic, since +they aren't really MemoryTable specific. +""" + +from math import sqrt +from nose.tools import assert_raises +from django.core.paginator import Paginator +import django_tables as tables + + +def test_basic(): + class StuffTable(tables.MemoryTable): + name = tables.Column() + answer = tables.Column(default=42) + c = tables.Column(name="count", default=1) + email = tables.Column(data="@") + stuff = StuffTable([ + {'id': 1, 'name': 'Foo Bar', '@': 'foo@bar.org'}, + ]) + + # access without order_by works + stuff.data + stuff.rows + + # make sure BoundColumnn.name always gives us the right thing, whether + # the column explicitely defines a name or not. + stuff.columns['count'].name == 'count' + stuff.columns['answer'].name == 'answer' + + for r in stuff.rows: + # unknown fields are removed/not-accessible + 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 + + # columns with data= option work fine + assert r['email'] == 'foo@bar.org' + + # try to splice rows by index + assert 'name' in stuff.rows[0] + assert isinstance(stuff.rows[0:], list) + + # [bug] splicing the table gives us valid, working rows + assert list(stuff[0]) == list(stuff.rows[0]) + assert stuff[0]['name'] == 'Foo Bar' + + # changing an instance's base_columns does not change the class + 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 + try: + assert_raises(ValueError, setattr, stuff, 'order_by', '-name,made-up-column') + assert_raises(ValueError, setattr, stuff, 'order_by', ('made-up-column',)) + # when a column name is overwritten, the original won't work anymore + assert_raises(ValueError, setattr, stuff, 'order_by', 'c') + # reset for future tests + finally: + tables.options.IGNORE_INVALID_OPTIONS = True + + +class TestRender: + """Test use of the render_* methods. + """ + + def test(self): + class TestTable(tables.MemoryTable): + private_name = tables.Column(name='public_name') + def render_public_name(self, data): + # We are given the actual data dict and have direct access + # to additional values for which no field is defined. + return "%s:%s" % (data['private_name'], data['additional']) + + table = TestTable([{'private_name': 'FOO', 'additional': 'BAR'}]) + assert table.rows[0]['public_name'] == 'FOO:BAR' + + def test_not_sorted(self): + """The render methods are not considered when sorting. + """ + class TestTable(tables.MemoryTable): + foo = tables.Column() + def render_foo(self, data): + return -data['foo'] # try to cause a reverse sort + table = TestTable([{'foo': 1}, {'foo': 2}], order_by='asc') + # Result is properly sorted, and the render function has never been called + assert [r['foo'] for r in table.rows] == [-1, -2] + + +def test_caches(): + """Ensure the various caches are effective. + """ + + class BookTable(tables.MemoryTable): + name = tables.Column() + answer = tables.Column(default=42) + books = BookTable([ + {'name': 'Foo: Bar'}, + ]) + + 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]) + + # test that caches are reset after an update() + old_column_cache = id(list(books.columns)[0]) + old_row_cache = id(list(books.rows)[0]) + books.update() + assert id(list(books.columns)[0]) != old_column_cache + assert id(list(books.rows)[0]) != old_row_cache + +def test_meta_sortable(): + """Specific tests for sortable table meta option.""" + + def mktable(default_sortable): + class BookTable(tables.MemoryTable): + id = tables.Column(sortable=True) + name = tables.Column(sortable=False) + author = tables.Column() + class Meta: + sortable = default_sortable + return BookTable([]) + + global_table = mktable(None) + for default_sortable, results in ( + (None, (True, False, True)), # last bool is global default + (True, (True, False, True)), # last bool is table default + (False, (True, False, False)), # last bool is table default + ): + books = mktable(default_sortable) + assert [c.sortable for c in books.columns] == list(results) + + # it also works if the meta option is manually changed after + # class and instance creation + global_table._meta.sortable = default_sortable + assert [c.sortable for c in global_table.columns] == list(results) + + +def test_sort(): + class BookTable(tables.MemoryTable): + id = tables.Column(direction='desc') + name = tables.Column() + pages = tables.Column(name='num_pages') # test rewritten names + 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', '*': 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 + ]) + + # None is normalized to an empty order by tuple, ensuring iterability; + # it also supports all the cool methods that we offer for order_by. + # This is true for the default case... + assert books.order_by == () + iter(books.order_by) + assert hasattr(books.order_by, 'toggle') + # ...as well as when explicitly set to None. + books.order_by = None + assert books.order_by == () + iter(books.order_by) + assert hasattr(books.order_by, 'toggle') + + # test various orderings + def test_order(order, result): + books.order_by = order + assert [b['id'] for b in books.rows] == result + 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', 'num_pages'), [3,2,1,4]) + # 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]) + # 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]) + + # test the column with a default ``direction`` set to descending + test_order('id', [4,3,2,1]) + test_order('-id', [1,2,3,4]) + # changing the direction afterwards is fine too + books.base_columns['id'].direction = 'asc' + test_order('id', [1,2,3,4]) + test_order('-id', [4,3,2,1]) + # a invalid direction string raises an exception + assert_raises(ValueError, setattr, books.base_columns['id'], 'direction', 'blub') + + # [bug] test alternative order formats if passed to constructor + BookTable([], 'language,-num_pages') + + # test invalid order instructions + books.order_by = 'xyz' + assert not books.order_by + books.base_columns['language'].sortable = False + books.order_by = 'language' + assert not books.order_by + test_order(('language', 'num_pages'), [1,3,2,4]) # as if: 'num_pages' + + # [bug] order_by did not run through setter when passed to init + books = BookTable([], order_by='name') + assert books.order_by == ('name',) + + # test table.order_by extensions + books.order_by = '' + assert books.order_by.polarize(False) == () + assert books.order_by.polarize(True) == () + assert books.order_by.toggle() == () + assert books.order_by.polarize(False, ['id']) == ('id',) + assert books.order_by.polarize(True, ['id']) == ('-id',) + assert books.order_by.toggle(['id']) == ('id',) + books.order_by = 'id,-name' + assert books.order_by.polarize(False, ['name']) == ('id', 'name') + assert books.order_by.polarize(True, ['name']) == ('id', '-name') + assert books.order_by.toggle(['name']) == ('id', 'name') + # ``in`` operator works + books.order_by = 'name' + assert 'name' in books.order_by + books.order_by = '-name' + assert 'name' in books.order_by + assert not 'language' in books.order_by + + +def test_callable(): + """Data fields and the ``default`` option can be callables. + """ + + class MathTable(tables.MemoryTable): + lhs = tables.Column() + rhs = tables.Column() + op = tables.Column(default='+') + sum = tables.Column(default=lambda d: calc(d['op'], d['lhs'], d['rhs'])) + + math = MathTable([ + {'lhs': 1, 'rhs': lambda x: x['lhs']*3}, # 1+3 + {'lhs': 9, 'rhs': lambda x: x['lhs'], 'op': '/'}, # 9/9 + {'lhs': lambda x: x['rhs']+3, 'rhs': 4, 'op': '-'}, # 7-4 + ]) + + # function is called when queried + def calc(op, lhs, rhs): + if op == '+': return lhs+rhs + elif op == '/': return lhs/rhs + elif op == '-': return lhs-rhs + assert [calc(row['op'], row['lhs'], row['rhs']) for row in math] == [4,1,3] + + # 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] + + +# TODO: all the column stuff might warrant it's own test file +def test_columns(): + """Test Table.columns container functionality. + """ + + class BookTable(tables.MemoryTable): + id = tables.Column(sortable=False, visible=False) + name = tables.Column(sortable=True) + pages = tables.Column(sortable=True) + language = tables.Column(sortable=False) + books = BookTable([]) + + assert list(books.columns.sortable()) == [c for c in books.columns if c.sortable] + + # .columns iterator only yields visible columns + assert len(list(books.columns)) == 3 + # visiblity of columns can be changed at instance-time + books.columns['id'].visible = True + assert len(list(books.columns)) == 4 + + +def test_column_order(): + """Test the order functionality of bound columns. + """ + + class BookTable(tables.MemoryTable): + id = tables.Column() + name = tables.Column() + pages = tables.Column() + language = tables.Column() + books = BookTable([]) + + # the basic name property is a no-brainer + books.order_by = '' + assert [c.name for c in books.columns] == ['id','name','pages','language'] + + # name_reversed will always reverse, no matter what + for test in ['', 'name', '-name']: + books.order_by = test + assert [c.name_reversed for c in books.columns] == ['-id','-name','-pages','-language'] + + # name_toggled will always toggle + books.order_by = '' + assert [c.name_toggled for c in books.columns] == ['id','name','pages','language'] + books.order_by = 'id' + assert [c.name_toggled for c in books.columns] == ['-id','name','pages','language'] + books.order_by = '-name' + assert [c.name_toggled for c in books.columns] == ['id','name','pages','language'] + # other columns in an order_by will be dismissed + books.order_by = '-id,name' + assert [c.name_toggled for c in books.columns] == ['id','-name','pages','language'] + + # with multi-column order, this is slightly more complex + books.order_by = '' + assert [str(c.order_by) for c in books.columns] == ['id','name','pages','language'] + assert [str(c.order_by_reversed) for c in books.columns] == ['-id','-name','-pages','-language'] + assert [str(c.order_by_toggled) for c in books.columns] == ['id','name','pages','language'] + books.order_by = 'id' + assert [str(c.order_by) for c in books.columns] == ['id','id,name','id,pages','id,language'] + assert [str(c.order_by_reversed) for c in books.columns] == ['-id','id,-name','id,-pages','id,-language'] + assert [str(c.order_by_toggled) for c in books.columns] == ['-id','id,name','id,pages','id,language'] + books.order_by = '-pages,id' + assert [str(c.order_by) for c in books.columns] == ['-pages,id','-pages,id,name','pages,id','-pages,id,language'] + assert [str(c.order_by_reversed) for c in books.columns] == ['-pages,-id','-pages,id,-name','-pages,id','-pages,id,-language'] + assert [str(c.order_by_toggled) for c in books.columns] == ['-pages,-id','-pages,id,name','pages,id','-pages,id,language'] + + # querying whether a column is ordered is possible + books.order_by = '' + assert [c.is_ordered for c in books.columns] == [False, False, False, False] + books.order_by = 'name' + assert [c.is_ordered for c in books.columns] == [False, True, False, False] + assert [c.is_ordered_reverse for c in books.columns] == [False, False, False, False] + assert [c.is_ordered_straight for c in books.columns] == [False, True, False, False] + books.order_by = '-pages' + assert [c.is_ordered for c in books.columns] == [False, False, True, False] + assert [c.is_ordered_reverse for c in books.columns] == [False, False, True, False] + assert [c.is_ordered_straight for c in books.columns] == [False, False, False, False] + # and even works with multi-column ordering + books.order_by = 'id,-pages' + assert [c.is_ordered for c in books.columns] == [True, False, True, False] + assert [c.is_ordered_reverse for c in books.columns] == [False, False, True, False] assert [c.is_ordered_straight for c in books.columns] == [True, False, False, False] \ No newline at end of file diff --git a/tests/test_models.py b/tests/test_models.py index 300183a..c8973d6 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,341 +1,341 @@ -"""Test ModelTable specific functionality. - -Sets up a temporary Django project using a memory SQLite database. -""" - -from nose.tools import assert_raises, assert_equal -from django.conf import settings -from django.core.paginator import * -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(null=True) - class Meta: - app_label = 'testapp' - module.City = City - - class Country(models.Model): - name = models.TextField() - population = models.IntegerField() - 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! - null2 = models.TextField(blank=True, null=True) # - " - - def example_domain(self): - return 'example.%s' % self.tld - class Meta: - app_label = 'testapp' - module.Country = Country - - # create the tables - 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, capital=berlin).save() - Country(name="France", tld="fr", population=64, system="republic").save() - Country(name="Netherlands", tld="nl", population=16, system="monarchy", capital=amsterdam).save() - - -class TestDeclaration: - """Test declaration, declared columns and default model field columns. +"""Test ModelTable specific functionality. + +Sets up a temporary Django project using a memory SQLite database. +""" + +from nose.tools import assert_raises, assert_equal +from django.conf import settings +from django.core.paginator import * +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(null=True) + class Meta: + app_label = 'testapp' + module.City = City + + class Country(models.Model): + name = models.TextField() + population = models.IntegerField() + 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! + null2 = models.TextField(blank=True, null=True) # - " - + def example_domain(self): + return 'example.%s' % self.tld + class Meta: + app_label = 'testapp' + module.Country = Country + + # create the tables + 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, capital=berlin).save() + Country(name="France", tld="fr", population=64, system="republic").save() + Country(name="Netherlands", tld="nl", population=16, system="monarchy", capital=amsterdam).save() + + +class TestDeclaration: + """Test declaration, declared columns and default model field columns. """ - def test_autogen_basic(self): - class CountryTable(tables.ModelTable): - class Meta: - model = Country - - assert len(CountryTable.base_columns) == 8 - assert 'name' in CountryTable.base_columns - assert not hasattr(CountryTable, 'name') - - # Override one model column, add another custom one, exclude one - class CountryTable(tables.ModelTable): - capital = tables.TextColumn(verbose_name='Name of capital') - projected = tables.Column(verbose_name="Projected Population") - class Meta: - model = Country - exclude = ['tld'] - - assert len(CountryTable.base_columns) == 8 - assert 'projected' in CountryTable.base_columns - assert 'capital' in CountryTable.base_columns - assert not 'tld' in CountryTable.base_columns - - # Inheritance (with a different model) + field restrictions - class CityTable(CountryTable): - class Meta: - model = City - columns = ['id', 'name'] - exclude = ['capital'] - - print CityTable.base_columns - assert len(CityTable.base_columns) == 4 - assert 'id' in CityTable.base_columns - 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 + def test_autogen_basic(self): + class CountryTable(tables.ModelTable): + class Meta: + model = Country + + assert len(CountryTable.base_columns) == 8 + assert 'name' in CountryTable.base_columns + assert not hasattr(CountryTable, 'name') + + # Override one model column, add another custom one, exclude one + class CountryTable(tables.ModelTable): + capital = tables.TextColumn(verbose_name='Name of capital') + projected = tables.Column(verbose_name="Projected Population") + class Meta: + model = Country + exclude = ['tld'] + + assert len(CountryTable.base_columns) == 8 + assert 'projected' in CountryTable.base_columns + assert 'capital' in CountryTable.base_columns + assert not 'tld' in CountryTable.base_columns + + # Inheritance (with a different model) + field restrictions + class CityTable(CountryTable): + class Meta: + model = City + columns = ['id', 'name'] + exclude = ['capital'] + + print CityTable.base_columns + assert len(CityTable.base_columns) == 4 + assert 'id' in CityTable.base_columns + 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?) def test_columns_custom_order(self): """Using the columns meta option, you can also modify the ordering. """ class CountryTable(tables.ModelTable): - foo = tables.Column() - class Meta: + foo = tables.Column() + class Meta: model = Country columns = ('system', 'population', 'foo', 'tld',) - assert [c.name for c in CountryTable().columns] == ['system', 'population', 'foo', 'tld'] - - -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() - - def test_country_table(table): - for r in table.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 - # [bug] access to data that might be available, but does not - # have a corresponding column is denied. - assert_raises(Exception, "r['id']") - # missing data is available with default values - assert 'null' in r - assert r['null'] == "foo" # note: different from prev. line! - # if everything else fails (no default), we get None back - assert r['null2'] is None - - # 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 - test_country_table(countries) - - # repeat the avove tests with a table that is not associated with a - # model, and all columns being created manually. - class CountryTable(tables.ModelTable): - name = tables.Column() - population = tables.Column() - capital = tables.Column() - system = tables.Column() - null = tables.Column(default="foo") - null2 = tables.Column() - tld = tables.Column(name="domain") - countries = CountryTable(Country) - test_country_table(countries) - - -def test_caches(): - """Make sure the caches work for model tables as well (parts are - reimplemented). - """ - class CountryTable(tables.ModelTable): - class Meta: - model = Country - exclude = ('id',) - countries = CountryTable() - - 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]) - - # test that caches are reset after an update() - old_column_cache = id(list(countries.columns)[0]) - old_row_cache = id(list(countries.rows)[0]) - countries.update() - assert id(list(countries.columns)[0]) != old_column_cache - assert id(list(countries.rows)[0]) != old_row_cache - -def test_sort(): - class CountryTable(tables.ModelTable): - tld = tables.Column(name="domain") - population = tables.Column() - system = tables.Column(default="republic") - custom1 = tables.Column() - custom2 = tables.Column(sortable=True) - class Meta: - model = Country - countries = CountryTable() - - def test_order(order, result, table=countries): - table.order_by = order - assert [r['id'] for r in table.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 with a "rewritten" column name - countries.order_by = 'domain,tld' # "tld" would be invalid... - countries.order_by == ('domain',) # ...and is therefore removed - 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 column with a default ``direction`` set to descending - class CityTable(tables.ModelTable): - name = tables.Column(direction='desc') - class Meta: - model = City - cities = CityTable() - test_order('name', [1,2], table=cities) # Berlin to Amsterdam - test_order('-name', [2,1], table=cities) # Amsterdam to Berlin - - # test invalid order instructions... - countries.order_by = 'invalid_field,population' - assert countries.order_by == ('population',) - # ...in case of 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_default_sort(): - class SortedCountryTable(tables.ModelTable): - class Meta: - model = Country - order_by = '-name' - - # the order_by option is provided by TableOptions - assert_equal('-name', SortedCountryTable()._meta.order_by) - - # the default order can be inherited from the table - assert_equal(('-name',), SortedCountryTable().order_by) - assert_equal(4, SortedCountryTable().rows[0]['id']) - - # and explicitly set (or reset) via __init__ - assert_equal(2, SortedCountryTable(order_by='system').rows[0]['id']) - assert_equal(1, SortedCountryTable(order_by=None).rows[0]['id']) - -def test_callable(): - """Some of the callable code is reimplemented for modeltables, so - test some specifics again. - """ - - class CountryTable(tables.ModelTable): - null = tables.Column(default=lambda s: s['example_domain']) - example_domain = tables.Column() - class Meta: - model = Country - countries = CountryTable(Country) - - # model method is called - assert [row['example_domain'] for row in countries] == \ - ['example.'+row['tld'] for row in countries] - - # column default method is called - assert [row['example_domain'] for row in countries] == \ - [row['null'] for row in countries] - -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_pagination(): - """Pretty much the same as static table pagination, but make sure we - provide the capability, at least for paginators that use it, to not - have the complete queryset loaded (by use of a count() query). - - Note: This test changes the available cities, make sure it is last, - or that tests that follow are written appropriately. - """ - from django.db import connection - - class CityTable(tables.ModelTable): - class Meta: - model = City - columns = ['name'] - cities = CityTable() - - # add some sample data - City.objects.all().delete() - for i in range(1,101): - City.objects.create(name="City %d"%i) - - # for query logging - settings.DEBUG = True - - # external paginator - start_querycount = len(connection.queries) - paginator = Paginator(cities.rows, 10) - assert paginator.num_pages == 10 - page = paginator.page(1) - assert len(page.object_list) == 10 - assert page.has_previous() == False - assert page.has_next() == True - # Make sure the queryset is not loaded completely - there must be two - # queries, one a count(). This check is far from foolproof... - assert len(connection.queries)-start_querycount == 2 - - # using a queryset paginator is possible as well (although unnecessary) - paginator = QuerySetPaginator(cities.rows, 10) - assert paginator.num_pages == 10 - - # integrated paginator - start_querycount = len(connection.queries) - cities.paginate(Paginator, 10, page=1) - # rows is now paginated - assert len(list(cities.rows.page())) == 10 - assert len(list(cities.rows.all())) == 100 - # new attributes - assert cities.paginator.num_pages == 10 - assert cities.page.has_previous() == False - assert cities.page.has_next() == True - assert len(connection.queries)-start_querycount == 2 - - # reset + assert [c.name for c in CountryTable().columns] == ['system', 'population', 'foo', 'tld'] + + +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() + + def test_country_table(table): + for r in table.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 + # [bug] access to data that might be available, but does not + # have a corresponding column is denied. + assert_raises(Exception, "r['id']") + # missing data is available with default values + assert 'null' in r + assert r['null'] == "foo" # note: different from prev. line! + # if everything else fails (no default), we get None back + assert r['null2'] is None + + # 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 + test_country_table(countries) + + # repeat the avove tests with a table that is not associated with a + # model, and all columns being created manually. + class CountryTable(tables.ModelTable): + name = tables.Column() + population = tables.Column() + capital = tables.Column() + system = tables.Column() + null = tables.Column(default="foo") + null2 = tables.Column() + tld = tables.Column(name="domain") + countries = CountryTable(Country) + test_country_table(countries) + + +def test_caches(): + """Make sure the caches work for model tables as well (parts are + reimplemented). + """ + class CountryTable(tables.ModelTable): + class Meta: + model = Country + exclude = ('id',) + countries = CountryTable() + + 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]) + + # test that caches are reset after an update() + old_column_cache = id(list(countries.columns)[0]) + old_row_cache = id(list(countries.rows)[0]) + countries.update() + assert id(list(countries.columns)[0]) != old_column_cache + assert id(list(countries.rows)[0]) != old_row_cache + +def test_sort(): + class CountryTable(tables.ModelTable): + tld = tables.Column(name="domain") + population = tables.Column() + system = tables.Column(default="republic") + custom1 = tables.Column() + custom2 = tables.Column(sortable=True) + class Meta: + model = Country + countries = CountryTable() + + def test_order(order, result, table=countries): + table.order_by = order + assert [r['id'] for r in table.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 with a "rewritten" column name + countries.order_by = 'domain,tld' # "tld" would be invalid... + countries.order_by == ('domain',) # ...and is therefore removed + 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 column with a default ``direction`` set to descending + class CityTable(tables.ModelTable): + name = tables.Column(direction='desc') + class Meta: + model = City + cities = CityTable() + test_order('name', [1,2], table=cities) # Berlin to Amsterdam + test_order('-name', [2,1], table=cities) # Amsterdam to Berlin + + # test invalid order instructions... + countries.order_by = 'invalid_field,population' + assert countries.order_by == ('population',) + # ...in case of 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_default_sort(): + class SortedCountryTable(tables.ModelTable): + class Meta: + model = Country + order_by = '-name' + + # the order_by option is provided by TableOptions + assert_equal('-name', SortedCountryTable()._meta.order_by) + + # the default order can be inherited from the table + assert_equal(('-name',), SortedCountryTable().order_by) + assert_equal(4, SortedCountryTable().rows[0]['id']) + + # and explicitly set (or reset) via __init__ + assert_equal(2, SortedCountryTable(order_by='system').rows[0]['id']) + assert_equal(1, SortedCountryTable(order_by=None).rows[0]['id']) + +def test_callable(): + """Some of the callable code is reimplemented for modeltables, so + test some specifics again. + """ + + class CountryTable(tables.ModelTable): + null = tables.Column(default=lambda s: s['example_domain']) + example_domain = tables.Column() + class Meta: + model = Country + countries = CountryTable(Country) + + # model method is called + assert [row['example_domain'] for row in countries] == \ + ['example.'+row['tld'] for row in countries] + + # column default method is called + assert [row['example_domain'] for row in countries] == \ + [row['null'] for row in countries] + +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_pagination(): + """Pretty much the same as static table pagination, but make sure we + provide the capability, at least for paginators that use it, to not + have the complete queryset loaded (by use of a count() query). + + Note: This test changes the available cities, make sure it is last, + or that tests that follow are written appropriately. + """ + from django.db import connection + + class CityTable(tables.ModelTable): + class Meta: + model = City + columns = ['name'] + cities = CityTable() + + # add some sample data + City.objects.all().delete() + for i in range(1,101): + City.objects.create(name="City %d"%i) + + # for query logging + settings.DEBUG = True + + # external paginator + start_querycount = len(connection.queries) + paginator = Paginator(cities.rows, 10) + assert paginator.num_pages == 10 + page = paginator.page(1) + assert len(page.object_list) == 10 + assert page.has_previous() == False + assert page.has_next() == True + # Make sure the queryset is not loaded completely - there must be two + # queries, one a count(). This check is far from foolproof... + assert len(connection.queries)-start_querycount == 2 + + # using a queryset paginator is possible as well (although unnecessary) + paginator = QuerySetPaginator(cities.rows, 10) + assert paginator.num_pages == 10 + + # integrated paginator + start_querycount = len(connection.queries) + cities.paginate(Paginator, 10, page=1) + # rows is now paginated + assert len(list(cities.rows.page())) == 10 + assert len(list(cities.rows.all())) == 100 + # new attributes + assert cities.paginator.num_pages == 10 + assert cities.page.has_previous() == False + assert cities.page.has_next() == True + assert len(connection.queries)-start_querycount == 2 + + # reset settings.DEBUG = False \ No newline at end of file diff --git a/tests/test_templates.py b/tests/test_templates.py index 21b8a0b..b056770 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -1,109 +1,109 @@ -"""Test template specific functionality. - -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 django.template import Template, Context, add_to_builtins -from django.http import HttpRequest -import django_tables as tables - -def test_order_by(): - class BookTable(tables.MemoryTable): - id = tables.Column() - name = tables.Column() - books = BookTable([ - {'id': 1, 'name': 'Foo: Bar'}, - ]) - - # cast to a string we get a value ready to be passed to the querystring - books.order_by = ('name',) - assert str(books.order_by) == 'name' - books.order_by = ('name', '-id') - assert str(books.order_by) == 'name,-id' - -def test_columns_and_rows(): - class CountryTable(tables.MemoryTable): - 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.") - - 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.MemoryTable): - 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', 'calling_code': 49}, - {'name': 'France', 'population': 64, 'currency': 'Euro (€)', 'tld': 'fr', 'calling_code': 33}, - {'name': 'Netherlands', 'capital': 'Amsterdam', 'calling_code': '31'}, - {'name': 'Austria', 'calling_code': 43, 'currency': 'Euro (€)', 'population': 8}]) - - 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" - -def test_templatetags(): - add_to_builtins('django_tables.app.templatetags.tables') - - # [bug] set url param tag handles an order_by tuple with multiple columns - class MyTable(tables.MemoryTable): - f1 = tables.Column() - f2 = tables.Column() - t = Template('{% set_url_param x=table.order_by %}') - table = MyTable([], order_by=('f1', 'f2')) - assert t.render(Context({'request': HttpRequest(), 'table': table})) == '?x=f1%2Cf2' +"""Test template specific functionality. + +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 django.template import Template, Context, add_to_builtins +from django.http import HttpRequest +import django_tables as tables + +def test_order_by(): + class BookTable(tables.MemoryTable): + id = tables.Column() + name = tables.Column() + books = BookTable([ + {'id': 1, 'name': 'Foo: Bar'}, + ]) + + # cast to a string we get a value ready to be passed to the querystring + books.order_by = ('name',) + assert str(books.order_by) == 'name' + books.order_by = ('name', '-id') + assert str(books.order_by) == 'name,-id' + +def test_columns_and_rows(): + class CountryTable(tables.MemoryTable): + 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.") + + 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.MemoryTable): + 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', 'calling_code': 49}, + {'name': 'France', 'population': 64, 'currency': 'Euro (€)', 'tld': 'fr', 'calling_code': 33}, + {'name': 'Netherlands', 'capital': 'Amsterdam', 'calling_code': '31'}, + {'name': 'Austria', 'calling_code': 43, 'currency': 'Euro (€)', 'population': 8}]) + + 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" + +def test_templatetags(): + add_to_builtins('django_tables.app.templatetags.tables') + + # [bug] set url param tag handles an order_by tuple with multiple columns + class MyTable(tables.MemoryTable): + f1 = tables.Column() + f2 = tables.Column() + t = Template('{% set_url_param x=table.order_by %}') + table = MyTable([], order_by=('f1', 'f2')) + assert t.render(Context({'request': HttpRequest(), 'table': table})) == '?x=f1%2Cf2' -- 2.26.2