From: Bradley Ayers Date: Fri, 4 Feb 2011 02:28:44 +0000 (+1000) Subject: lots of major changes X-Git-Tag: 0.4.0-alpha1~4 X-Git-Url: http://git.tremily.us/?a=commitdiff_plain;h=3f23472e8240bd13a1f8a85687e2aee93fc1bf81;p=django-tables2.git lots of major changes --- diff --git a/.gitignore b/.gitignore index 3bc5040..64390b9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,5 @@ *.pyc - /MANIFEST -/dist -/docs/_build/* -/build - -/BRANCH_TODO - -# Project files -/.project -/.pydevproject -/*.wpr +dist/ +docs/_build/ +django_tables.egg-info/ diff --git a/LICENSE b/LICENSE deleted file mode 100644 index a1eb278..0000000 --- a/LICENSE +++ /dev/null @@ -1,27 +0,0 @@ -Copyright (c) 2008, Michael Elsdörfer -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions -are met: - - 1. Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - - 2. Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following - disclaimer in the documentation and/or other materials - provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE -COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, -INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, -BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT -LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN -ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -POSSIBILITY OF SUCH DAMAGE. diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 208f9d5..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,4 +0,0 @@ -include README -include LICENSE -include MANIFEST.in -recursive-include tests *.py diff --git a/README b/README deleted file mode 100644 index 2c28c87..0000000 --- a/README +++ /dev/null @@ -1,4 +0,0 @@ -django-tables - a Django QuerySet renderer. - -Documentation: - http://elsdoerfer.name/docs/django-tables/ diff --git a/TODO b/TODO deleted file mode 100644 index 159c80c..0000000 --- a/TODO +++ /dev/null @@ -1,120 +0,0 @@ -Document how the user can access the raw row data; and possible make this -easier by falling back to the raw data directly if a column is accessed -which doesn't exist. - -There's no particular reason why this should be Django-specific. Now with -the base table better abstracted, we should be able to easily create a -SQLAlchemyTable or a StormTable. - -If the table were passed a ``request`` object, it could generate columns -proper sort links without requiring the set_url_param tag. However, that -might introduce a Django dependency. Possibly rather than the request we -could expect a dict of query string values. - -It would be cool if for non-model tables, a custom compare function could -be provided to modify the sort. This would require a minor refactor in -which we have multiple different table types subclass a base table, and -the subclass allowing it's columns to support additional kwargs. - -"data", is used to format for display, affect sorting; this stuff needs -some serious redesign. - -as_html methods are all empty right now - -table.column[].values is a stub - -Filters + grouping - -Choices support for columns (internal column value will be looked up for -output - -For columns that span model relationships, automatically generate -select_related(); this is important, since right now such an e.g. -order_by would cause rows to be dropped (inner join). - -Initialize auto-generated columns with the relevant properties of the model -fields (verbose_name, editable=visible?, ...) - -Remove support for callable fields? this has become obsolete since we -Column.data property; also, it's easy to make the call manually, or let -the template engine handle it. - -Tests could use some refactoring, they are currently all over the place - -What happens if duplicate column names are used? we currently don't check -for that at all. - - -Filters -~~~~~~~ - -Filtering is already easy (just use the normal queryset methods), but -filter support in django-tables would want to hide the Django ORM syntax -from the user. - - * For example, say a ``models.DateTimeField`` should be filtered - by year: the user would only see ``date=2008`` rather than maybe - ``published_at__year=2008``. - - * Say you want to filter out ``UserProfile`` rows that do not have - an avatar image set. The user would only see ```no_avatar``, which - in Django ORM syntax might map to - ``Q(avatar__isnull=True) | Q(avatar='')``. - -Filters would probably always belong to a column, and be defined along -side one. - - class BookTable(tables.ModelTable): - date = tables.Column(filter='published_at__year') - -If a filter is needed that does not belong to a single colunn, a column -would have to be defined for just that filter. A ``tables.Filter`` object -could be provided that would basically be a column, but with default -properties set so that the column functionality is disabled as far as -possible (i.e. ``visible=False`` etc): - - class BookTable(tables.ModelTable): - date = tables.Column(filter='published_at__year') - has_cover = tables.Filter('cover__isnull', value=True) - -Or, if Filter() gets a lot of additional functionality like ``value``, -we could generally make it available to all filters like so: - - class BookTable(tables.ModelTable): - date = tables.Column(filter=tables.Filter('published_at__year', default=2008)) - has_cover = tables.Filter('cover__isnull', value=True) - -More complex filters should be supported to (e.g. combine multiple Q -objects, support ``exclude`` as well as ``filter``). Allowing the user -to somehow specify a callable probably is the easiest way to enable this. - -The filter querystring syntax, as exposed to the user, could look like this: - - /view/?filter=name:value - /view/?filter=name - -It would also be cool if filters could be combined. However, in that case -it would also make sense to make it possible to choose individual filters -which cannot be combined with any others, or maybe even allow the user -to specify complex dependencies. That may be pushing it though, and anyway -won't make it into the first version. - - /view/?filter=name:value,foo:bar - -We need to think about how we make the functionality available to -templates. - -Another feature that would be important is the ability to limit the valid -values for a filter, e.g. in the date example only years from 2000 to 2008. - -Use django-filters: - - would support html output - - would not work at all with our planned QueryTable - - conflicts somewhat in that it also allows ordering - -To autoamtically allow filtering a column with filter=True, we would need to -have subclasses for each model class, even if it just redirects to use the -correct filter class; - -If not using django-filter, we wouldn't have different filter types; filters -would just hold the data, and each column would know how to apply it. diff --git a/django-tables.komodoproject b/django-tables.komodoproject new file mode 100644 index 0000000..bf27c2a --- /dev/null +++ b/django-tables.komodoproject @@ -0,0 +1,10 @@ + + + + + + *.*~;*.bak;*.tmp;CVS;.#*;*.pyo;*.pyc;.svn;*%*;tmp*.html;.DS_Store;.komodotools;*.komodoproject;*.egg-info;.git + + 1 + + diff --git a/django_tables/__init__.py b/django_tables/__init__.py index 76d8069..afe96dd 100644 --- a/django_tables/__init__.py +++ b/django_tables/__init__.py @@ -1,7 +1,25 @@ -__version__ = (0, 3, 'dev') +# -*- coding: utf-8 -*- +__version__ = (0, 2, 0, 'dev') -from memory import * -from models import * -from columns import * -from options import * +def get_version(): + version = '%s.%s' % (__version__[0], __version__[1]) + if __version__[2]: + version = '%s.%s' % (version, __version__[2]) + if __version__[3] != '': + version = '%s %s' % (version, __version__[3]) + return version + +# We want to make get_version() available to setup.py even if Django is not +# available or we are not inside a Django project (so we do distutils stuff). +try: + # this fails if project settings module isn't configured + from django.contrib import admin +except ImportError: + import warnings + warnings.warn('django-tables requires Django to be configured (settings) ' + 'prior to use, however this has not been done. Version information ' + 'will still be available.') +else: + from tables import * + from columns import * diff --git a/django_tables/app/models.py b/django_tables/app/models.py deleted file mode 100644 index 6a28b36..0000000 --- a/django_tables/app/models.py +++ /dev/null @@ -1,2 +0,0 @@ -# Empty models.py file required for Django -# INSTALLED_APPS loading. diff --git a/django_tables/app/templatetags/tables.py b/django_tables/app/templatetags/tables.py deleted file mode 100644 index f081ab4..0000000 --- a/django_tables/app/templatetags/tables.py +++ /dev/null @@ -1,72 +0,0 @@ -# coding: utf8 -""" -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) diff --git a/django_tables/base.py b/django_tables/base.py deleted file mode 100644 index 75b5e95..0000000 --- a/django_tables/base.py +++ /dev/null @@ -1,614 +0,0 @@ -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 3307f09..532f9b0 100644 --- a/django_tables/columns.py +++ b/django_tables/columns.py @@ -1,33 +1,14 @@ -__all__ = ( - 'Column', 'TextColumn', 'NumberColumn', -) +# -*- coding: utf-8 -*- +from django.utils.encoding import force_unicode, StrAndUnicode +from django.utils.datastructures import SortedDict +from django.utils.text import capfirst + 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 @@ -38,48 +19,214 @@ class Column(object): 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 + Data can be formatted by using ``formatter``, which accepts a callable as + an argument (e.g. lambda x: x.upper()) + """ # 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): + def __init__(self, verbose_name=None, accessor=None, default=None, + visible=True, sortable=None, formatter=None): + if not (accessor is None or isinstance(accessor, basestring) or + callable(accessor)): + raise TypeError('accessor must be a string or callable, not %s' % + accessor.__class__.__name__) + if callable(accessor) and default is not None: + raise TypeError('accessor must be string when default is used, not' + ' callable') + self.accessor = accessor + self._default = default + self.formatter = formatter + self.sortable = sortable 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) + @property + def default(self): + """Since ``Column.default`` property may be a callable, this function + handles access. + """ + return self._default() if callable(self._default) else self._default + + def render(self, table, bound_column, bound_row): + """Returns a cell's content. + This method can be overridden by ``render_FOO`` methods on the table or + by subclassing ``Column``. + """ + return table.data.data_for_cell(bound_column=bound_column, + bound_row=bound_row) + + +class CheckBoxColumn(Column): + """A subclass of Column that renders its column data as a checkbox + + ``name`` is the html name of the checkbox. + """ + def __init__(self, attrs=None, *args, **kwargs): + super(CheckBoxColumn, self).__init__(*args, **kwargs) + self.attrs = attrs or {} + + def render(self, bound_column, bound_row): + from django.template import Template, Context + attrs = {'name': bound_column.name} + attrs.update(self.attrs) + t = Template('') + return t.render(Context({ + 'value': self.value(bound_column=bound_column, + bound_row=bound_row), + 'attrs': attrs, + })) + + +class BoundColumn(StrAndUnicode): + """'Runtime' version of ``Column`` that is bound to a table instance, + and thus knows about the table's data. The difference between BoundColumn + and Column, is a BoundColumn is aware of actual values (e.g. its name) + where-as Column is not. + + For convenience, all Column properties are available from this class. + """ + def __init__(self, table, column, name): + """*table* - the table in which this column exists + *column* - the column class + *name* – the variable name used when the column was defined in the + table class + """ + self.table = table + self.column = column + self.name = name + + def __unicode__(self): + s = self.column.verbose_name or self.name.replace('_', ' ') + return capfirst(force_unicode(s)) + + @property + def accessor(self): + return self.column.accessor or self.name + + @property + def default(self): + return self.column.default + + @property + def formatter(self): + return self.column.formatter + + @property + def 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: - self._direction = value + return True # the default value - direction = property(lambda s: s._direction, _set_direction) + @property + def verbose_name(self): + return self.column.verbose_name + @property + def visible(self): + return self.column.visible -class TextColumn(Column): - pass -class NumberColumn(Column): - pass +class Columns(object): + """Container for spawning BoundColumns. + + This is bound to a table and provides its ``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. + """ + def __init__(self, table): + self.table = table + # ``self._columns`` attribute stores the bound columns (columns that + # have a real name, ) + self._columns = SortedDict() + + def _spawn_columns(self): + # (re)build the "_columns" cache of BoundColumn objects (note that + # ``base_columns`` might have changed since last time); creating + # BoundColumn instances can be costly, so we reuse existing ones. + new_columns = SortedDict() + for name, column in self.table.base_columns.items(): + if name in self._columns: + new_columns[name] = self._columns[name] + else: + new_columns[name] = BoundColumn(self.table, column, 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, index): + """Return a column by name or index.""" + self._spawn_columns() + if isinstance(index, int): + return self._columns.value_for_index(index) + elif isinstance(index, basestring): + return self._columns[index] + else: + raise TypeError('row indices must be integers or str, not %s' % + index.__class__.__name__) diff --git a/django_tables/memory.py b/django_tables/memory.py deleted file mode 100644 index 40d147c..0000000 --- a/django_tables/memory.py +++ /dev/null @@ -1,90 +0,0 @@ -import copy -from base import BaseTable, BoundRow - - -__all__ = ('MemoryTable', 'Table',) - - -def sort_table(data, order_by): - """Sort a list of dicts according to the fieldnames in the - ``order_by`` iterable. Prefix with hypen for reverse. - - Dict values can be callables. - """ - def _cmp(x, y): - for name, reverse in instructions: - lhs, rhs = x.get(name), y.get(name) - res = cmp((callable(lhs) and [lhs(x)] or [lhs])[0], - (callable(rhs) and [rhs(y)] or [rhs])[0]) - if res != 0: - return reverse and -res or res - return 0 - instructions = [] - for o in order_by: - if o.startswith('-'): - instructions.append((o[1:], True,)) - else: - instructions.append((o, False,)) - data.sort(cmp=_cmp) - - -class MemoryTable(BaseTable): - """Table that is based on an in-memory dataset (a list of dict-like - objects). - """ - - def _build_snapshot(self): - """Rebuilds the table whenever it's options change. - - 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. - - In the case of this base table implementation, a copy of the - source data is created, and then modified appropriately. - - # TODO: currently this is called whenever data changes; it is - # probably much better to do this on-demand instead, when the - # data is *needed* for the first time. - """ - - # reset caches - self._columns._reset() - self._rows._reset() - - snapshot = copy.copy(self._data) - for row in snapshot: - # add data that is missing from the source. we do this now so - # that the colunn ``default`` and ``data`` values can affect - # sorting (even when callables are used)! - # This is a design decision - the alternative would be to - # resolve the values when they are accessed, and either do not - # support sorting them at all, or run the callables during - # sorting. - for column in self.columns.all(): - name_in_source = column.declared_name - if column.column.data: - if callable(column.column.data): - # if data is a callable, use it's return value - row[name_in_source] = column.column.data(BoundRow(self, row)) - else: - name_in_source = column.column.data - - # the following will be True if: - # * the source does not provide that column or provides None - # * the column did provide a data callable that returned None - if row.get(name_in_source, None) is None: - row[name_in_source] = column.get_default(BoundRow(self, row)) - - if self.order_by: - actual_order_by = self._resolve_sort_directions(self.order_by) - sort_table(snapshot, self._cols_to_fields(actual_order_by)) - return snapshot - - -class Table(MemoryTable): - def __new__(cls, *a, **kw): - from warnings import warn - warn('"Table" has been renamed to "MemoryTable". Please use the '+ - 'new name.', DeprecationWarning) - return MemoryTable.__new__(cls) diff --git a/django_tables/models.py b/django_tables/models.py index b3ccb09..1d38612 100644 --- a/django_tables/models.py +++ b/django_tables/models.py @@ -1,234 +1 @@ -from django.core.exceptions import FieldError -from django.utils.datastructures import SortedDict -from base import BaseTable, DeclarativeColumnsMetaclass, \ - Column, BoundRow, Rows, TableOptions, rmprefix, toggleprefix - - -__all__ = ('ModelTable',) - - -class ModelTableOptions(TableOptions): - def __init__(self, options=None): - super(ModelTableOptions, self).__init__(options) - self.model = getattr(options, 'model', None) - self.columns = getattr(options, 'columns', None) - self.exclude = getattr(options, 'exclude', None) - - -def columns_for_model(model, columns=None, exclude=None): - """ - Returns a ``SortedDict`` containing form columns for the given model. - - ``columns`` is an optional list of field names. If provided, only the - named model fields will be included in the returned column list. - - ``exclude`` is an optional list of field names. If provided, the named - model fields will be excluded from the returned list of columns, even - if they are listed in the ``fields`` argument. - """ - - field_list = [] - opts = model._meta - for f in opts.fields + opts.many_to_many: - if (columns and not f.name in columns) or \ - (exclude and f.name in exclude): - continue - column = Column(verbose_name=f.verbose_name) # TODO: chose correct column type, with right options - if column: - field_list.append((f.name, column)) - field_dict = SortedDict(field_list) - if columns: - field_dict = SortedDict( - [(c, field_dict.get(c)) for c in columns - if ((not exclude) or (exclude and c not in exclude))] - ) - return field_dict - - -class BoundModelRow(BoundRow): - """Special version of the BoundRow class that can handle model instances - as data. - - We could simply have ModelTable spawn the normal BoundRow objects - with the instance converted to a dict instead. However, this way allows - us to support non-field attributes and methods on the model as well. - """ - - def _default_render(self, boundcol): - """In the case of a model table, the accessor may use ``__`` to - span instances. We need to resolve this. - """ - # try to resolve relationships spanning attributes - bits = boundcol.accessor.split('__') - current = self.data - for bit in bits: - # note the difference between the attribute being None and not - # existing at all; assume "value doesn't exist" in the former - # (e.g. a relationship has no value), raise error in the latter. - # a more proper solution perhaps would look at the model meta - # data instead to find out whether a relationship is valid; see - # also ``_validate_column_name``, where such a mechanism is - # already implemented). - if not hasattr(current, bit): - raise ValueError("Could not resolve %s from %s" % (bit, boundcol.accessor)) - - current = getattr(current, bit) - if callable(current): - current = current() - # important that we break in None case, or a relationship - # spanning across a null-key will raise an exception in the - # next iteration, instead of defaulting. - if current is None: - break - - if current is None: - # ...the whole name (i.e. the last bit) resulted in None - if boundcol.column.default is not None: - return boundcol.get_default(self) - return current - - -class ModelRows(Rows): - row_class = BoundModelRow - - def __init__(self, *args, **kwargs): - super(ModelRows, self).__init__(*args, **kwargs) - - def _reset(self): - self._length = None - - def __len__(self): - """Use the queryset count() method to get the length, instead of - loading all results into memory. This allows, for example, - smart paginators that use len() to perform better. - """ - if getattr(self, '_length', None) is None: - self._length = self.table.data.count() - return self._length - - # for compatibility with QuerySetPaginator - count = __len__ - - -class ModelTableMetaclass(DeclarativeColumnsMetaclass): - def __new__(cls, name, bases, attrs): - # Let the default form meta class get the declared columns; store - # those in a separate attribute so that ModelTable inheritance with - # differing models works as expected (the behaviour known from - # ModelForms). - self = super(ModelTableMetaclass, cls).__new__( - cls, name, bases, attrs, parent_cols_from='declared_columns') - self.declared_columns = self.base_columns - - opts = self._meta = ModelTableOptions(getattr(self, 'Meta', None)) - # if a model is defined, then build a list of default columns and - # let the declared columns override them. - if opts.model: - columns = columns_for_model(opts.model, opts.columns, opts.exclude) - columns.update(self.declared_columns) - self.base_columns = columns - return self - - -class ModelTable(BaseTable): - """Table that is based on a model. - - Similar to ModelForm, a column will automatically be created for all - the model's fields. You can modify this behaviour with a inner Meta - class: - - class MyTable(ModelTable): - class Meta: - model = MyModel - exclude = ['fields', 'to', 'exclude'] - columns = ['fields', 'to', 'include'] - - One difference to a normal table is the initial data argument. It can - be a queryset or a model (it's default manager will be used). If you - just don't any data at all, the model the table is based on will - provide it. - """ - - __metaclass__ = ModelTableMetaclass - - rows_class = ModelRows - - def __init__(self, data=None, *args, **kwargs): - if data == None: - if self._meta.model is None: - raise ValueError('Table without a model association needs ' - 'to be initialized with data') - self.queryset = self._meta.model._default_manager.all() - elif hasattr(data, '_default_manager'): # saves us db.models import - self.queryset = data._default_manager.all() - else: - self.queryset = data - - super(ModelTable, self).__init__(self.queryset, *args, **kwargs) - - def _validate_column_name(self, name, purpose): - """Overridden. Only allow model-based fields and valid model - spanning relationships to be sorted.""" - - # let the base class sort out the easy ones - result = super(ModelTable, self)._validate_column_name(name, purpose) - if not result: - return False - - if purpose == 'order_by': - column = self.columns[name] - - # "data" can really be used in two different ways. It is - # slightly confusing and potentially should be changed. - # It can either refer to an attribute/field which the table - # column should represent, or can be a callable (or a string - # pointing to a callable attribute) that is used to render to - # cell. The difference is that in the latter case, there may - # still be an actual source model field behind the column, - # stored in "declared_name". In other words, we want to filter - # out column names that are not oderable, and the column name - # we need to check may either be stored in "data" or in - # "declared_name", depending on if and what kind of value is - # in "data". This is the reason why we try twice. - # - # See also bug #282964. - # - # TODO: It might be faster to try to resolve the given name - # manually recursing the model metadata rather than - # constructing a queryset. - for lookup in (column.column.data, column.declared_name): - if not lookup or callable(lookup): - continue - try: - # Let Django validate the lookup by asking it to build - # the final query; the way to do this has changed in - # Django 1.2, and we try to support both versions. - _temp = self.queryset.order_by(lookup).query - if hasattr(_temp, 'as_sql'): - _temp.as_sql() - else: - from django.db import DEFAULT_DB_ALIAS - _temp.get_compiler(DEFAULT_DB_ALIAS).as_sql() - break - except FieldError: - pass - else: - return False - - # if we haven't failed by now, the column should be valid - return True - - def _build_snapshot(self): - """Overridden. The snapshot in this case is simply a queryset - with the necessary filters etc. attached. - """ - - # reset caches - self._columns._reset() - self._rows._reset() - - queryset = self.queryset - if self.order_by: - actual_order_by = self._resolve_sort_directions(self.order_by) - queryset = queryset.order_by(*self._cols_to_fields(actual_order_by)) - return queryset +"""Needed to make this package a Django app""" diff --git a/django_tables/options.py b/django_tables/options.py deleted file mode 100644 index 57331fb..0000000 --- a/django_tables/options.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Global module options. - -I'm not entirely happy about these existing at this point; maybe we can -get rid of them. -""" - - -__all__ = ('options',) - - -# A common use case is passing incoming query values directly into the -# table constructor - data that can easily be invalid, say if manually -# modified by a user. So by default, such errors will be silently -# ignored. Set the option below to False if you want an exceptions to be -# raised instead. -class DefaultOptions(object): - IGNORE_INVALID_OPTIONS = True -options = DefaultOptions() diff --git a/django_tables/rows.py b/django_tables/rows.py new file mode 100644 index 0000000..9491023 --- /dev/null +++ b/django_tables/rows.py @@ -0,0 +1,80 @@ +class BoundRow(object): + """Represents a single row of in a table. + + BoundRow provides a layer on top of the table data that exposes final + rendered cell values for the table. This means that formatting (via + Column.formatter or overridden Column.render in subclasses) applied to the + values from the table's data. + """ + def __init__(self, table, data): + self.table = table + self.data = data + + def __iter__(self): + for value in self.values: + yield value + + def __getitem__(self, name): + """Returns the final rendered value for a cell in the row, given the + name of a column. + """ + bound_column = self.table.columns[name] + # use custom render_FOO methods on the table + custom = getattr(self.table, 'render_%s' % name, None) + if custom: + return custom(bound_column, self) + return bound_column.column.render(self.table, bound_column, self) + + 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 + + @property + def values(self): + for column in self.table.columns: + yield self[column.name] + + +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. + """ + def __init__(self, table): + self.table = table + + def all(self): + """Return all rows.""" + for row in self.table.data: + yield BoundRow(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) + + # for compatibility with QuerySetPaginator + count = __len__ + + def __getitem__(self, key): + if isinstance(key, slice): + result = list() + for row in self.table.data[key]: + result.append(BoundRow(self.table, row)) + return result + elif isinstance(key, int): + return BoundRow(self.table, self.table.data[key]) + else: + raise TypeError('Key must be a slice or integer.') diff --git a/django_tables/tables.py b/django_tables/tables.py new file mode 100644 index 0000000..db87cc2 --- /dev/null +++ b/django_tables/tables.py @@ -0,0 +1,278 @@ +# -*- coding: utf8 -*- +import copy +from django.db.models.query import QuerySet +from django.core.paginator import Paginator +from django.utils.datastructures import SortedDict +from django.http import Http404 +from django.template.loader import get_template +from django.template import Context +from .utils import rmprefix, toggleprefix, OrderByTuple, Accessor +from .columns import Column +from .memory import sort_table +from .rows import Rows, BoundRow +from .columns import Columns + +__all__ = ('Table',) + +QUERYSET_ACCESSOR_SEPARATOR = '__' + +class TableData(object): + """Exposes a consistent API for a table data. It currently supports a query + set and a list of dicts. + """ + def __init__(self, data, table): + self._data = data if not isinstance(data, QuerySet) else None + self._queryset = data if isinstance(data, QuerySet) else None + self._table = table + + # work with a copy of the data that has missing values populated with + # defaults. + if self._data: + self._data = copy.copy(self._data) + self._populate_missing_values(self._data) + + def __len__(self): + # Use the queryset count() method to get the length, instead of + # loading all results into memory. This allows, for example, + # smart paginators that use len() to perform better. + return self._queryset.count() if self._queryset else len(self._data) + + def order_by(self, order_by): + """Order the data based on column names in the table.""" + # translate order_by to something suitable for this data + order_by = self._translate_order_by(order_by) + if self._queryset: + # need to convert the '.' separators to '__' (filter syntax) + order_by = order_by.replace(Accessor.SEPARATOR, + QUERYSET_ACCESSOR_SEPARATOR) + self._queryset = self._queryset.order_by(**order_by) + else: + self._data.sort(cmp=order_by.cmp) + + def _translate_order_by(self, order_by): + """Translate from column names to column accessors""" + translated = [] + for name in order_by: + # handle order prefix + prefix, name = ((name[0], name[1:]) if name[0] == '-' + else ('', name)) + # find the accessor name + column = self._table.columns[name] + if not isinstance(column.accessor, basestring): + raise TypeError('unable to sort on a column that uses a ' + 'callable accessor') + translated.append(prefix + column.accessor) + return OrderByTuple(translated) + + def _populate_missing_values(self, data): + """Populates self._data with missing values based on the default value + for each column. It will create new items in the dataset (not modify + existing ones). + """ + for i, item in enumerate(data): + # add data that is missing from the source. we do this now + # so that the column's ``default`` values can affect + # sorting (even when callables are used)! + # + # This is a design decision - the alternative would be to + # resolve the values when they are accessed, and either do + # not support sorting them at all, or run the callables + # during sorting. + modified_item = None + for bound_column in self._table.columns.all(): + # the following will be True if: + # * the source does not provide a value for the column + # or the value is None + # * the column did provide a data callable that + # returned None + accessor = Accessor(bound_column.accessor) + try: + if accessor.resolve(item) is None: # may raise ValueError + raise ValueError('None values also need replacing') + except ValueError: + if modified_item is None: + modified_item = copy.copy(item) + modified_item[accessor.bits[0]] = bound_column.default + if modified_item is not None: + data[i] = modified_item + + def data_for_cell(self, bound_column, bound_row, apply_formatter=True): + """Calculate the value of a cell given a bound row and bound column. + + *formatting* – Apply column formatter after retrieving the value from + the data. + """ + value = Accessor(bound_column.accessor).resolve(bound_row.data) + # try and use default value if we've only got 'None' + if value is None and bound_column.default is not None: + value = bound_column.default() + if apply_formatter and bound_column.formatter: + value = bound_column.formatter(value) + return value + + def __getitem__(self, key): + return self._data[key] + + +class DeclarativeColumnsMetaclass(type): + """Metaclass that converts Column attributes on the class 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 determines 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 = [(name, attrs.pop(name)) for name, column in attrs.items() + if isinstance(column, 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]: + cols_attr = (parent_cols_from if (parent_cols_from and + hasattr(base, parent_cols_from)) + else 'base_columns') + if hasattr(base, cols_attr): + columns = getattr(base, cols_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) + + +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', ()) + + +class Table(object): + """A collection of columns, plus their associated data rows.""" + __metaclass__ = DeclarativeColumnsMetaclass + + # 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', (), {})() + TableDataClass = TableData + + 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 + ``BoundColumn`` 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._rows = Rows(self) # bound rows + self._columns = Columns(self) # bound columns + self._data = self.TableDataClass(data=data, table=self) + + # None is a valid order, so we must use DefaultOrder as a flag + # to fall back to the table sort order. + self.order_by = (self._meta.order_by if order_by is Table.DefaultOrder + else 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 __unicode__(self): + return self.as_html() + + @property + def data(self): + return self._data + + @property + def order_by(self): + return self._order_by + + @order_by.setter + def order_by(self, value): + """Order the rows of the table based columns. ``value`` must be a + sequence of column names. + """ + # accept both string and tuple instructions + order_by = value.split(',') if isinstance(value, basestring) else value + order_by = () if order_by is None else order_by + # validate, raise exception on failure + for o in order_by: + name = rmprefix(o) + if name not in self.columns: + raise ValueError('Column "%s" can not be used for ordering as ' + 'it does not exist in the table' % name) + if not self.columns[name].sortable: + raise ValueError('Column "%s" can not be used for ordering as ' + 'the column has explicitly forbidden it.' % + name) + + new = OrderByTuple(order_by) + if not (hasattr(self, '_order_by') and self._order_by == new): + self._order_by = new + self._data.order_by(new) + + @property + def rows(self): + return self._rows + + @property + def columns(self): + return self._columns + + def as_html(self): + """Render the table to a simple HTML table. + + The rendered table won't include pagination or sorting, as those + features require a RequestContext. Use the ``render_table`` template + tag (requires ``{% load django_tables %}``) if you require this extra + functionality. + """ + template = get_template('django_tables/basic_table.html') + return template.render(Context({'table': self})) + + def paginate(self, klass=Paginator, page=1, *args, **kwargs): + self.paginator = klass(self.rows, *args, **kwargs) + try: + self.page = self.paginator.page(page) + except Exception as e: + raise Http404(str(e)) diff --git a/django_tables/templates/django_tables/basic_table.html b/django_tables/templates/django_tables/basic_table.html new file mode 100644 index 0000000..50fb701 --- /dev/null +++ b/django_tables/templates/django_tables/basic_table.html @@ -0,0 +1,20 @@ +{% spaceless %} + + + + {% for column in table.columns %} + + {% endfor %} + + + + {% for row in table.rows %} + + {% for value in row %} + + {% endfor %} + + {% endfor %} + +
{{ column }}
{{ value }}
+{% endspaceless %} diff --git a/django_tables/templates/django_tables/table.html b/django_tables/templates/django_tables/table.html new file mode 100644 index 0000000..27ecfd6 --- /dev/null +++ b/django_tables/templates/django_tables/table.html @@ -0,0 +1,21 @@ +{% load django_tables %} +{% spaceless %} + + + + {% for column in table.columns %} + + {% endfor %} + + + + {% for row in table.rows %} + + {% for cell in row %} + + {% endfor %} + + {% endfor %} + +
{{ column }}
{{ cell }}
+{% endspaceless %} diff --git a/django_tables/app/__init__.py b/django_tables/templatetags/__init__.py similarity index 100% rename from django_tables/app/__init__.py rename to django_tables/templatetags/__init__.py diff --git a/django_tables/templatetags/django_tables.py b/django_tables/templatetags/django_tables.py new file mode 100644 index 0000000..db15886 --- /dev/null +++ b/django_tables/templatetags/django_tables.py @@ -0,0 +1,107 @@ +""" +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.template.loader import get_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 s.encode('utf-8') if isinstance(s, unicode) else s + 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_set_url_param(parser, token): + bits = token.contents.split() + qschanges = {} + for i in bits[1:]: + try: + a, b = i.split('=', 1) + a = a.strip() + b = b.strip() + a_line_iter = StringIO.StringIO(a).readline + keys = list(tokenize.generate_tokens(a_line_iter)) + if keys[0][0] == tokenize.NAME: + # workaround bug #5270 + b = (template.Variable(b) if b == '""' else + 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_set_url_param) + + +class RenderTableNode(template.Node): + def __init__(self, table_var_name): + self.table_var = template.Variable(table_var_name) + + def render(self, context): + context = template.Context({ + 'request': context.get('request', None), + 'table': self.table_var.resolve(context) + }) + return get_template('django_tables/table.html').render(context) + + +def do_render_table(parser, token): + try: + _, table_var_name = token.contents.split() + except ValueError: + raise template.TemplateSyntaxError,\ + "%r tag requires a single argument" % token.contents.split()[0] + return RenderTableNode(table_var_name) + +register.tag('render_table', do_render_table) diff --git a/django_tables/tests/__init__.py b/django_tables/tests/__init__.py new file mode 100644 index 0000000..2cedec1 --- /dev/null +++ b/django_tables/tests/__init__.py @@ -0,0 +1,13 @@ +from attest import Tests +from .core import core +from .templates import templates +#from .memory import memory +#from .models import models + +tests = Tests([core, templates]) + +def suite(): + return tests.test_suite() + +if __name__ == '__main__': + tests.main() diff --git a/django_tables/tests/core.py b/django_tables/tests/core.py new file mode 100644 index 0000000..f22d7b2 --- /dev/null +++ b/django_tables/tests/core.py @@ -0,0 +1,206 @@ +"""Test the core table functionality.""" + +import copy +from attest import Tests, Assert +from django.http import Http404 +from django.core.paginator import Paginator +import django_tables as tables +from django_tables import utils + +core = Tests() + + +@core.context +def context(): + class Context(object): + memory_data = [ + {'i': 2, 'alpha': 'b', 'beta': 'b'}, + {'i': 1, 'alpha': 'a', 'beta': 'c'}, + {'i': 3, 'alpha': 'c', 'beta': 'a'}, + ] + + class UnsortedTable(tables.Table): + i = tables.Column() + alpha = tables.Column() + beta = tables.Column() + + table = UnsortedTable(memory_data) + + yield Context + + +@core.test +def declarations(context): + """Test defining tables by declaration.""" + class GeoAreaTable(tables.Table): + name = tables.Column() + population = tables.Column() + + assert len(GeoAreaTable.base_columns) == 2 + assert 'name' in GeoAreaTable.base_columns + assert not hasattr(GeoAreaTable, 'name') + + class CountryTable(GeoAreaTable): + capital = tables.Column() + + assert len(CountryTable.base_columns) == 3 + assert 'capital' in CountryTable.base_columns + + # multiple inheritance + class AddedMixin(tables.Table): + added = tables.Column() + + class CityTable(GeoAreaTable, AddedMixin): + mayor = tables.Column() + + assert len(CityTable.base_columns) == 4 + assert 'added' in CityTable.base_columns + + +@core.test +def datasource_untouched(context): + """Ensure that data that is provided to the table (the datasource) is not + modified by table operations. + """ + original_data = copy.deepcopy(context.memory_data) + + table = context.UnsortedTable(context.memory_data) + table.order_by = 'i' + list(table.rows) + assert context.memory_data == Assert(original_data) + + table = context.UnsortedTable(context.memory_data) + table.order_by = 'beta' + list(table.rows) + assert context.memory_data == Assert(original_data) + + +@core.test +def sorting(context): + class MyUnsortedTable(tables.Table): + i = tables.Column() + alpha = tables.Column() + beta = tables.Column() + + # various different ways to say the same thing: don't sort + Assert(MyUnsortedTable([]).order_by) == () + Assert(MyUnsortedTable([], order_by=None).order_by) == () + Assert(MyUnsortedTable([], order_by=[]).order_by) == () + Assert(MyUnsortedTable([], order_by=()).order_by) == () + + # values of order_by are wrapped in tuples before being returned + Assert(MyUnsortedTable([], order_by='alpha').order_by) == ('alpha',) + Assert(MyUnsortedTable([], order_by=('beta',)).order_by) == ('beta',) + + # a rewritten order_by is also wrapped + table = MyUnsortedTable([]) + table.order_by = 'alpha' + assert ('alpha',) == table.order_by + + # default sort order can be specified in table options + class MySortedTable(MyUnsortedTable): + class Meta: + order_by = 'alpha' + + # order_by is inherited from the options if not explitly set + table = MySortedTable([]) + assert ('alpha',) == table.order_by + + # ...but can be overloaded at __init___ + table = MySortedTable([], order_by='beta') + assert ('beta',) == table.order_by + + # ...or rewritten later + table = MySortedTable(context.memory_data) + table.order_by = 'beta' + assert ('beta',) == table.order_by + assert 3 == table.rows[0]['i'] + + # ...or reset to None (unsorted), ignoring the table default + table = MySortedTable(context.memory_data, order_by=None) + assert () == table.order_by + assert 2 == table.rows[0]['i'] + + +@core.test +def row_subscripting(context): + row = context.table.rows[0] + # attempt number indexing + Assert(row[0]) == 2 + Assert(row[1]) == 'b' + Assert(row[2]) == 'b' + with Assert.raises(IndexError) as error: + row[3] + # attempt column name indexing + Assert(row['i']) == 2 + Assert(row['alpha']) == 'b' + Assert(row['beta']) == 'b' + with Assert.raises(KeyError) as error: + row['gamma'] + + +@core.test +def column_count(context): + class SimpleTable(tables.Table): + visible = tables.Column(visible=True) + hidden = tables.Column(visible=False) + + # The columns container supports the len() builtin + assert len(SimpleTable([]).columns) == 1 + + +@core.test +def column_accessor(context): + class SimpleTable(context.UnsortedTable): + col1 = tables.Column(accessor='alpha.upper.isupper') + col2 = tables.Column(accessor='alpha.upper') + table = SimpleTable(context.memory_data) + row = table.rows[0] + Assert(row['col1']) == True + Assert(row['col2']) == 'B' + + +@core.test +def pagination(): + class BookTable(tables.Table): + name = tables.Column() + + # create some sample data + data = [] + for i in range(1,101): + data.append({'name': 'Book Nr. %d' % i}) + books = BookTable(data) + + # external paginator + paginator = Paginator(books.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 + + # integrated paginator + books.paginate(Paginator, page=1, per_page=10) + # rows is now paginated + assert len(list(books.rows.page())) == 10 + assert len(list(books.rows.all())) == 100 + # new attributes + assert books.paginator.num_pages == 10 + assert books.page.has_previous() == False + assert books.page.has_next() == True + # exceptions are converted into 404s + with Assert.raises(Http404) as error: + books.paginate(Paginator, page=9999, per_page=10) + books.paginate(Paginator, page='abc', per_page=10) + + +@core.test +def utilities(): + assert utils.rmprefix('thing') == 'thing' + assert utils.rmprefix('-thing') == 'thing' + assert utils.toggleprefix('thing') == '-thing' + assert utils.toggleprefix('-thing') == 'thing' + + +if __name__ == '__main__': + core.main() diff --git a/tests/test_memory.py b/django_tables/tests/memory.py similarity index 98% rename from tests/test_memory.py rename to django_tables/tests/memory.py index fe5ef6b..20d52ac 100644 --- a/tests/test_memory.py +++ b/django_tables/tests/memory.py @@ -5,17 +5,20 @@ they aren't really MemoryTable specific. """ from math import sqrt -from nose.tools import assert_raises +from attest import Tests from django.core.paginator import Paginator import django_tables as tables +memory = Tests() -def test_basic(): - class StuffTable(tables.MemoryTable): +@memory.test +def basics(): + class StuffTable(tables.Table): 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'}, ]) @@ -45,10 +48,6 @@ def test_basic(): # 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' diff --git a/tests/test_models.py b/django_tables/tests/models.py similarity index 99% rename from tests/test_models.py rename to django_tables/tests/models.py index 0b3f79a..c0bfaa5 100644 --- a/tests/test_models.py +++ b/django_tables/tests/models.py @@ -3,12 +3,15 @@ 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 +from attest import Tests +models = Tests() +''' + def setup_module(module): settings.configure(**{ 'DATABASE_ENGINE': 'sqlite3', @@ -362,3 +365,4 @@ def test_pagination(): # reset settings.DEBUG = False +''' diff --git a/django_tables/tests/templates.py b/django_tables/tests/templates.py new file mode 100644 index 0000000..a591ffa --- /dev/null +++ b/django_tables/tests/templates.py @@ -0,0 +1,74 @@ +# -*- coding: utf8 -*- +"""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 +from django.http import HttpRequest +import django_tables as tables +from attest import Tests, Assert + +templates = Tests() + + +@templates.context +def context(): + class Context(object): + class CountryTable(tables.Table): + name = tables.Column() + capital = tables.Column(sortable=False) + population = tables.Column(verbose_name='Population Size') + currency = tables.Column(visible=False) + tld = tables.Column(visible=False, verbose_name='Domain') + calling_code = tables.Column(accessor='cc', + verbose_name='Phone Ext.') + + data = [ + {'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} + ] + yield Context + + +@templates.test +def as_html(context): + countries = context.CountryTable(context.data) + countries.as_html() + + +@templates.test +def custom_rendering(context): + """For good measure, render some actual templates.""" + countries = context.CountryTable(context.data) + context = Context({'countries': countries}) + + # automatic and manual column verbose names + template = Template('{% for column in countries.columns %}{{ column }}/' + '{{ column.name }} {% endfor %}') + result = ('Name/name Capital/capital Population Size/population ' + 'Phone Ext./calling_code ') + Assert(result) == template.render(context) + + # row values + template = Template('{% for row in countries.rows %}{% for value in row %}' + '{{ value }} {% endfor %}{% endfor %}') + result = ('Germany Berlin 83 49 France None 64 33 Netherlands Amsterdam ' + 'None 31 Austria None 8 43 ') + Assert(result) == template.render(context) + +@templates.test +def templatetag(context): + # ensure it works with a multi-order-by + countries = context.CountryTable(context.data, + order_by=('name', 'population')) + t = Template('{% load django_tables %}{% render_table table %}') + t.render(Context({'request': HttpRequest(), 'table': countries})) diff --git a/django_tables/app/templatetags/__init__.py b/django_tables/tests/testapp/__init__.py similarity index 100% rename from django_tables/app/templatetags/__init__.py rename to django_tables/tests/testapp/__init__.py diff --git a/tests/testapp/models.py b/django_tables/tests/testapp/models.py similarity index 100% rename from tests/testapp/models.py rename to django_tables/tests/testapp/models.py diff --git a/django_tables/utils.py b/django_tables/utils.py new file mode 100644 index 0000000..ac052b6 --- /dev/null +++ b/django_tables/utils.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +from django.utils.datastructures import SortedDict +from django.template import Context +from django.utils.encoding import force_unicode, StrAndUnicode + + +__all__ = ('BaseTable', 'options') + + +def rmprefix(s): + """Normalize a column name by removing a potential sort prefix""" + return s[1:] if s[:1] == '-' else s + + +def toggleprefix(s): + """Remove - prefix is existing, or add if missing.""" + return s[1:] if s[:1] == '-' else '-' + s + + +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 = '-' if reverse else '' + return OrderByTuple( + [o if (names and rmprefix(o) not in names) + else prefix + rmprefix(o) 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( + [o if (names and rmprefix(o) not in names) + else (o[1:] if o[:1] == '-' else '-' + o) for o in self] + + [name for name in names if not name in self] + ) + + @property + def cmp(self): + """Return a function suitable for sorting a list""" + def _cmp(a, b): + for accessor, reverse in instructions: + res = cmp(accessor.resolve(a), accessor.resolve(b)) + if res != 0: + return -res if reverse else res + return 0 + instructions = [] + for o in self: + if o.startswith('-'): + instructions.append((Accessor(o[1:]), True)) + else: + instructions.append((Accessor(o), False)) + return _cmp + + +class Accessor(object): + SEPARATOR = '.' + + def __init__(self, path): + self.path = path + + def resolve(self, context): + if callable(self.path): + return self.path(context) + else: + # Try to resolve relationships spanning attributes. This is + # basically a copy/paste from django/template/base.py in + # Variable._resolve_lookup() + current = context + for bit in self.bits: + try: # dictionary lookup + current = current[bit] + except (TypeError, AttributeError, KeyError): + try: # attribute lookup + current = getattr(current, bit) + except (TypeError, AttributeError): + try: # list-index lookup + current = current[int(bit)] + except (IndexError, # list index out of range + ValueError, # invalid literal for int() + KeyError, # dict without `int(bit)` key + TypeError, # unsubscriptable object + ): + raise ValueError('Failed lookup for key [%s] in %r' + ', when resolving the accessor %s' + % (bit, current, self.path)) + if callable(current): + current = current() + # important that we break in None case, or a relationship + # spanning across a null-key will raise an exception in the + # next iteration, instead of defaulting. + if current is None: + break + return current + + @property + def bits(self): + return self.path.split(self.SEPARATOR) diff --git a/docs/Makefile b/docs/Makefile index 05ce818..4dab0d9 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -12,20 +12,26 @@ PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -.PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest help: @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: -rm -rf $(BUILDDIR)/* @@ -40,6 +46,11 @@ dirhtml: @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @@ -65,12 +76,42 @@ qthelp: @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-tables.qhc" +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/django-tables" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-tables" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ - "run these through (pdf)latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + make -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes diff --git a/docs/columns.rst b/docs/columns.rst deleted file mode 100644 index e9c7a1a..0000000 --- a/docs/columns.rst +++ /dev/null @@ -1,98 +0,0 @@ -================= -All about Columns -================= - -Columns are what defines a table. Therefore, the way you configure your -columns determines to a large extend how your table operates. - -``django_tables.columns`` currently defines three classes, ``Column``, -``TextColumn`` and ``NumberColumn``. However, the two subclasses currently -don't do anything special at all, so you can simply use the base class. -While this will likely change in the future (e.g. when grouping is added), -the base column class will continue to work by itself. - -There are no required arguments. The following is fine: - -.. code-block:: python - - class MyTable(tables.MemoryTable): - c = tables.Column() - -It will result in a column named ``c`` in the table. You can specify the -``name`` to override this: - -.. code-block:: python - - c = tables.Column(name="count") - -The column is now called and accessed via "count", although the table will -still use ``c`` to read it's values from the source. You can however modify -that as well, by specifying ``data``: - -.. code-block:: python - - c = tables.Column(name="count", data="count") - -For practicual purposes, ``c`` is now meaningless. While in most cases -you will just define your column using the name you want it to have, the -above is useful when working with columns automatically generated from -models: - -.. code-block:: python - - class BookTable(tables.ModelTable): - book_name = tables.Column(name="name") - author = tables.Column(data="info__author__name") - class Meta: - model = Book - -The overwritten ``book_name`` field/column will now be exposed as the -cleaner ``name``, and the new ``author`` column retrieves it's values from -``Book.info.author.name``. - -Apart from their internal name, you can define a string that will be used -when for display via ``verbose_name``: - -.. code-block:: python - - pubdate = tables.Column(verbose_name="Published") - -The verbose name will be used, for example, if you put in a template: - -.. code-block:: django - - {{ column }} - -If you don't want a column to be sortable by the user: - -.. code-block:: python - - pubdate = tables.Column(sortable=False) - -Sorting is also affected by ``direction``, which can be used to change the -*default* sort direction to descending. Note that this option only indirectly -translates to the actual direction. Normal und reverse order, the terms -django-tables exposes, now simply mean different things. - -.. code-block:: python - - pubdate = tables.Column(direction='desc') - -If you don't want to expose a column (but still require it to exist, for -example because it should be sortable nonetheless): - -.. code-block:: python - - pubdate = tables.Column(visible=False) - -The column and it's values will now be skipped when iterating through the -table, although it can still be accessed manually. - -Finally, you can specify default values for your columns: - -.. code-block:: python - - health_points = tables.Column(default=100) - -Note that how the default is used and when it is applied differs between -table types. diff --git a/docs/conf.py b/docs/conf.py index 4a407dd..2463f23 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # django-tables documentation build configuration file, created by -# sphinx-quickstart on Fri Mar 26 08:40:14 2010. +# sphinx-quickstart on Wed Jan 5 13:04:34 2011. # # This file is execfile()d with the current directory set to its containing dir. # @@ -16,13 +16,16 @@ import sys, os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.append(os.path.abspath('.')) +#sys.path.insert(0, os.path.abspath('.')) # -- General configuration ----------------------------------------------------- +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc'] +extensions = [] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -31,23 +34,23 @@ templates_path = ['_templates'] source_suffix = '.rst' # The encoding of source files. -#source_encoding = 'utf-8' +#source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = u'django-tables' -copyright = u'2010, Michael Elsdörfer' +#copyright = u'' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = '0.1' +version = '0.2' # The full version, including alpha/beta/rc tags. -release = '0.1' +release = '0.2-dev' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -59,12 +62,9 @@ release = '0.1' # Else, today_fmt is used as the format for a strftime call. #today_fmt = '%B %d, %Y' -# List of documents that shouldn't be included in the build. -#unused_docs = [] - -# List of directories, relative to source directory, that shouldn't be searched -# for source files. -exclude_trees = ['_build'] +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] # The reST default role (used for this markup: `text`) to use for all documents. #default_role = None @@ -89,8 +89,8 @@ pygments_style = 'sphinx' # -- Options for HTML output --------------------------------------------------- -# The theme to use for HTML and HTML Help pages. Major themes that come with -# Sphinx are currently 'default' and 'sphinxdoc'. +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. html_theme = 'default' # Theme options are theme-specific and customize the look and feel of a theme @@ -138,7 +138,7 @@ html_static_path = ['_static'] #html_additional_pages = {} # If false, no module index is generated. -#html_use_modindex = True +#html_domain_indices = True # If false, no index is generated. #html_use_index = True @@ -149,13 +149,19 @@ html_static_path = ['_static'] # If true, links to the reST sources are added to the pages. #html_show_sourcelink = True +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. #html_use_opensearch = '' -# If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = '' +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'django-tablesdoc' @@ -172,8 +178,8 @@ htmlhelp_basename = 'django-tablesdoc' # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index.rst', 'django-tables.tex', u'django-tables Documentation', - u'Michael Elsdörfer', 'manual'), + ('index', 'django-tables.tex', u'django-tables Documentation', + u'n/a', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -184,6 +190,12 @@ latex_documents = [ # not chapters. #latex_use_parts = False +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + # Additional stuff for the LaTeX preamble. #latex_preamble = '' @@ -191,4 +203,14 @@ latex_documents = [ #latex_appendices = [] # If false, no module index is generated. -#latex_use_modindex = True +#latex_domain_indices = True + + +# -- Options for manual page output -------------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'django-tables', u'django-tables Documentation', + [u'n/a'], 1) +] diff --git a/docs/features/index.rst b/docs/features/index.rst deleted file mode 100644 index e3e0a27..0000000 --- a/docs/features/index.rst +++ /dev/null @@ -1,11 +0,0 @@ -=============== -How to do stuff -=============== - -This section will explain some specific features in more detail. - -.. toctree:: - :maxdepth: 1 - - ordering - pagination diff --git a/docs/features/ordering.rst b/docs/features/ordering.rst deleted file mode 100644 index 4f81f1d..0000000 --- a/docs/features/ordering.rst +++ /dev/null @@ -1,170 +0,0 @@ -================= -Sorting the table -================= - -``django-tables`` allows you to specify which column the user can sort, -and will validate and resolve an incoming query string value the the -correct ordering. - -It will also help you rendering the correct links to change the sort -order in your template. - - -Specify which columns are sortable ----------------------------------- - -Tables can take a ``sortable`` option through an inner ``Meta``, the same -concept as known from forms and models in Django: - -.. code-block:: python - - class MyTable(tables.MemoryTable): - class Meta: - sortable = True - -This will be the default value for all columns, and it defaults to ``True``. -You can override the table default for each individual column: - -.. code-block:: python - - class MyTable(tables.MemoryTable): - foo = tables.Column(sortable=False) - class Meta: - sortable = True - - -Setting the table ordering --------------------------- - -Your table both takes a ``order_by`` argument in it's constructor, and you -can change the order by assigning to the respective attribute: - -.. code-block:: python - - table = MyTable(order_by='-foo') - table.order_by = 'foo' - -You can see that the value expected is pretty much what is used by the -Django database API: An iterable of column names, optionally using a hyphen -as a prefix to indicate reverse order. However, you may also pass a -comma-separated string: - -.. code-block:: python - - table = MyTable(order_by='column1,-column2') - -When you set ``order_by``, the value is parsed right away, and subsequent -reads will give you the normalized value: - -.. code-block:: python - - >>> table.order_by = ='column1,-column2' - >>> table.order_by - ('column1', '-column2') - -Note: Random ordering is currently not supported. - - -Error handling -~~~~~~~~~~~~~~ - -Passing incoming query string values from the request directly to the -table constructor is a common thing to do. However, such data can easily -contain invalid column names, be it that a user manually modified it, -or someone put up a broken link. In those cases, you usually would not want -to raise an exception (nor be notified by Django's error notification -mechanism) - there is nothing you could do anyway. - -Because of this, such errors will by default be silently ignored. For -example, if one out of three columns in an "order_by" is invalid, the other -two will still be applied: - -.. code-block:: python - - >>> table.order_by = ('name', 'totallynotacolumn', '-date) - >>> table.order_by - ('name', '-date) - -This ensures that the following table will be created regardless of the -value in ``sort``: - -.. code-block:: python - - table = MyTable(data, order_by=request.GET.get('sort')) - -However, if you want, you can disable this behaviour and have an exception -raised instead, using: - -.. code-block:: python - - import django_tables - django_tables.options.IGNORE_INVALID_OPTIONS = False - - -Interacting with order ----------------------- - -Letting the user change the order of a table is a common scenario. With -respect to Django, this means adding links to your table output that will -send off the appropriate arguments to the server. ``django-tables`` -attempts to help with you that. - -A bound column, that is a column accessed through a table instance, provides -the following attributes: - -- ``name_reversed`` will simply return the column name prefixed with a - hyphen; this is useful in templates, where string concatenation can - at times be difficult. - -- ``name_toggled`` checks the tables current order, and will then - return the column either prefixed with an hyphen (for reverse ordering) - or without, giving you the exact opposite order. If the column is - currently not ordered, it will start off in non-reversed order. - -It is easy to be confused about the difference between the ``reverse`` and -``toggle`` terminology. ``django-tables`` tries to put a normal/reverse-order -abstraction on top of "ascending/descending", where as normal order could -potentially mean either ascending or descending, depending on the column. - -Something you commonly see is a table that indicates which column it is -currently ordered by through little arrows. To implement this, you will -find useful: - -- ``is_ordered``: Returns ``True`` if the column is in the current - ``order_by``, regardless of the polarity. - -- ``is_ordered_reverse``, ``is_ordered_straight``: Returns ``True`` if the - column is ordered in reverse or non-reverse, respectively, otherwise - ``False``. - -The above is usually enough for most simple cases, where tables are only -ordered by a single column. For scenarios in which multi-column order is -used, additional attributes are available: - -- ``order_by``: Return the current order, but with the current column - set to normal ordering. If the current column is not already part of - the order, it is appended. Any existing columns in the order are - maintained as-is. - -- ``order_by_reversed``, ``order_by_toggled``: Similarly, return the - table's current ``order_by`` with the column set to reversed or toggled, - respectively. Again, it is appended if not already ordered. - -Additionally, ``table.order_by.toggle()`` may also be useful in some cases: -It will toggle all order columns and should thus give you the exact -opposite order. - -The following is a simple example of single-column ordering. It shows a list -of sortable columns, each clickable, and an up/down arrow next to the one -that is currently used to sort the table. - -.. code-block:: django - - Sort by: - {% for column in table.columns %} - {% if column.sortable %} - {{ column }} - {% if column.is_ordered_straight %}{% endif %} - {% if column.is_ordered_reverse %}{% endif %} - {% endif %} - {% endfor %} diff --git a/docs/features/pagination.rst b/docs/features/pagination.rst deleted file mode 100644 index 7640ffe..0000000 --- a/docs/features/pagination.rst +++ /dev/null @@ -1,47 +0,0 @@ ----------- -Pagination ----------- - -If your table has a large number of rows, you probably want to paginate -the output. There are two distinct approaches. - -First, you can just paginate over ``rows`` as you would do with any other -data: - -.. code-block:: python - - table = MyTable(queryset) - paginator = Paginator(table.rows, 10) - page = paginator.page(1) - -You're not necessarily restricted to Django's own paginator (or subclasses) - -any paginator should work with this approach, so long it only requires -``rows`` to implement ``len()``, slicing, and, in the case of a -``ModelTable``, a ``count()`` method. The latter means that the -``QuerySetPaginator`` also works as expected. - -Alternatively, you may use the ``paginate`` feature: - -.. code-block:: python - - table = MyTable(queryset) - table.paginate(Paginator, 10, page=1, orphans=2) - for row in table.rows.page(): - pass - table.paginator # new attributes - table.page - -The table will automatically create an instance of ``Paginator``, -passing it's own data as the first argument and additionally any arguments -you have specified, except for ``page``. You may use any paginator, as long -as it follows the Django protocol: - -* Take data as first argument. -* Support a page() method returning an object with an ``object_list`` - attribute, exposing the paginated data. - -Note that due to the abstraction layer that ``django-tables`` represents, it -is not necessary to use Django's ``QuerySetPaginator`` with model tables. -Since the table knows that it holds a queryset, it will automatically choose -to use count() to determine the data length (which is exactly what -``QuerySetPaginator`` would do). diff --git a/docs/index.rst b/docs/index.rst index a79fb6e..1e3d091 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,145 +1,488 @@ -========================================== -django-tables - A Django Queryset renderer -========================================== +===================================================== +django-tables - An app for creating HTML tables +===================================================== +django-tables simplifies the task of turning sets of datainto HTML tables. It +has native support for pagination and sorting. It does for HTML tables what +``django.forms`` does for HTML forms. -``django-tables`` wants to help you present data while allowing your user -to apply common tabular transformations on it. +Quick start guide +================= -Currently, this mostly mostly means "sorting", i.e. parsing a query string -coming from the browser (while supporting multiple sort fields, restricting -the fields that may be sorted, exposing fields under different names) and -generating the proper links to allow the user to change the sort order. +1. Download and install the package. +2. Install the tables framework by adding ``'django_tables'`` to your + ``INSTALLED_APPS`` setting. +3. Ensure that ``'django.core.context_processors.request'`` is in your + ``TEMPLATE_CONTEXT_PROCESSORS`` setting. +4. Write table classes for the types of tables you want to display. +5. Create an instance of a table in a view, provide it your data, and pass it + to a template for display. +6. Use ``{{ table.as_html }}``, the + :ref:`template tag `, or your own + custom template code to display the table. -In the future, filtering and grouping will hopefully be added. +Tables +====== -A simple example ----------------- +For each type of table you want to display, you will need to create a subclass +of ``django_tables.Table`` that describes the structure of the table. + +In this example we are going to take some data describing three countries and +turn it into a HTML table. We start by creating our data: + +.. code-block:: python + + >>> countries = [ + ... {'name': 'Australia', 'population': 21, 'tz': 'UTC +10', 'visits': 1}, + ... {'name': 'Germany', 'population', 81, 'tz': 'UTC +1', 'visits': 2}, + ... {'name': 'Mexico', 'population': 107, 'tz': 'UTC -6', 'visits': 0}, + ... ] -The API looks similar to that of Django's ``ModelForms``: +Next we subclass ``django_tables.Table`` to create a table that describes our +data. The API should look very familiar since it's based on Django's +database model API: .. code-block:: python - import django_tables as tables + >>> import django_tables as tables + >>> class CountryTable(tables.Table): + ... name = tables.Column() + ... population = tables.Column() + ... tz = tables.Column(verbose_name='Time Zone') + ... visits = tables.Column() + +See :ref:`columns` for more information. - class CountryTable(tables.MemoryTable): - name = tables.Column(verbose_name="Country Name") - population = tables.Column(sortable=False, visible=False) - time_zone = tables.Column(name="tz", default="UTC+1") -Instead of fields, you declare a column for every piece of data you want -to expose to the user. +Providing data +-------------- -To use the table, create an instance: +To use the table, simply create an instance of the table class and pass in your +data. e.g. following on from the above example: .. code-block:: python - countries = CountryTable([{'name': 'Germany', population: 80}, - {'name': 'France', population: 64}]) + >>> table = CountryTable(countries) -Decide how the table should be sorted: +Tables have support for any iterable data that contains objects with +attributes that can be accessed as property or dictionary syntax: .. code-block:: python - countries.order_by = ('name',) - assert [row.name for row in countries.row] == ['France', 'Germany'] + >>> table = SomeTable([{'a': 1, 'b': 2}, {'a': 4, 'b': 8}]) # valid + >>> table = SomeTable(SomeModel.objects.all()) # also valid - countries.order_by = ('-population',) - assert [row.name for row in countries.row] == ['Germany', 'France'] +Each item in the data corresponds to one row in the table. By default, the +table uses column names as the keys (or attributes) for extracting cell values +from the data. This can be changed by using the :attr:`~Column.accessor` +argument. -If you pass the table object along into a template, you can do: + +Displaying a table +------------------ + +There are two ways to display a table, the easiest way is to use the table's +own ``as_html`` method: .. code-block:: django - {% for column in countries.columns %} - {{ column }} - {% endfor %} + {{ table.as_html }} + +Which will render something like: -Which will give you: ++--------------+------------+---------+ +| Country Name | Population | Tz | ++==============+============+=========+ +| Australia | 21 | UTC +10 | ++--------------+------------+---------+ +| Germany | 81 | UTC +1 | ++--------------+------------+---------+ +| Mexico | 107 | UTC -6 | ++--------------+------------+---------+ + +The downside of this approach is that pagination and sorting will not be +available. These features require the use of the ``{% render_table %}`` +template tag: .. code-block:: django - Country Name - Timezone + {% load django_tables %} + {% render_table table %} -Note that ``population`` is skipped (as it has ``visible=False``), that the -declared verbose name for the ``name`` column is used, and that ``time_zone`` -is converted into a more beautiful string for output automatically. +See :ref:`template tags` for more information. -Common Workflow -~~~~~~~~~~~~~~~ +Ordering +-------- -Usually, you are going to use a table like this. Assuming ``CountryTable`` -is defined as above, your view will create an instance and pass it to the -template: +Controlling the order that the rows are displayed (sorting) is simple, just use +the :attr:`~Table.order_by` property or pass it in when initialising the +instance: .. code-block:: python - def list_countries(request): - data = ... - countries = CountryTable(data, order_by=request.GET.get('sort')) - return render_to_response('list.html', {'table': countries}) + >>> # order_by argument when creating table instances + >>> table = CountryTable(countries, order_by='name, -population') + >>> table = CountryTable(countries, order_by=('name', '-population')) + >>> # order_by property on table instances + >>> table = CountryTable(countries) + >>> table.order_by = 'name, -population' + >>> table.order_by = ('name', '-population') + -Note that we are giving the incoming ``sort`` query string value directly to -the table, asking for a sort. All invalid column names will (by default) be -ignored. In this example, only ``name`` and ``tz`` are allowed, since: +Customising the output +====================== - * ``population`` has ``sortable=False`` - * ``time_zone`` has it's name overwritten with ``tz``. +There are a number of options available for changing the way the table is +rendered. Each approach provides balance of ease-of-use and control (the more +control you want, the less easy it is to use). -Then, in the ``list.html`` template, write: + +Column formatter +---------------- + +If all you want to do is change the way a column is formatted, you can simply +provide the :attr:`~Column.formatter` argument to a :class:`Column` when you +define the :class:`Table`: + +.. code-block:: python + + >>> import django_tables as tables + >>> class SimpleTable(tables.Table): + ... id = tables.Column(formatter=lambda x: '#%d' % x) + ... age = tables.Column(formatter=lambda x: '%d years old' % x) + ... + >>> table = SimpleTable([{'age': 31, 'id': 10}, {'age': 34, 'id': 11}]) + >>> row = table.rows[0] + >>> for cell in row: + ... print cell + ... + #10 + 31 years old + +The limitation of this approach is that you're unable to incorporate any +run-time information of the table into the formatter. For example it would not +be possible to incorporate the row number into the cell's value. + + +Column render method +-------------------- + +This approach provides a lot of control, but is only suitable if you intend to +customise the rendering for a single table (otherwise you'll end up having to +copy & paste the method to every table you want to modify – which violates +DRY). + + >>> import django_tables as tables + >>> class SimpleTable(tables.Table): + ... row_number = tables.Column() + ... id = tables.Column(formatter=lambda x: '#%d' % x) + ... age = tables.Column(formatter=lambda x: '%d years old' % x) + ... + ... def render_row_number(self, bound_column, bound_row): + ... value = + ... + ... def render_id(self, bound_column, bound_row): + ... value = self.column. + ... + >>> table = SimpleTable([{'age': 31, 'id': 10}, {'age': 34, 'id': 11}]) + >>> row = table.rows[0] + >>> for cell in row: + ... print cell + ... + #10 + 31 years old + +If you want full control over the way the table is rendered, create +and render the template yourself: .. code-block:: django + {% load django_tables %} - - {% for column in table.columns %} - - {% endfor %} - - {% for row in table.rows %} - - {% for value in row %} - - {% endfor %} - - {% endfor %} + + + {% for column in table.columns %} + + {% endfor %} + + + + {% for row in table.rows %} + + {% for cell in row %} + + {% endfor %} + + {% endfor %} +
{{ column }}
{{ value }}
{{ column }}
{{ cell }}
-This will output the data as an HTML table. Note how the table is now fully -sortable, since our link passes along the column name via the querystring, -which in turn will be used by the server for ordering. ``order_by`` accepts -comma-separated strings as input, and ``{{ column.name_toggled }}`` will be -rendered as a such a string. -Instead of the iterator, you can alos use your knowledge of the table -structure to access columns directly: + +Columns +======= + +The :class:`Columns` class provides an container for :class:`BoundColumn` +instances. The simplest way to access the contained columns is to iterate over +the instance: + +Each :class:`Table` instance has an instance as its :attr:`~Table.columns` +property. Iterating over the instance yields only the visible columns. To +access all columns (including those that are hidden), use the +:func:`~Columns.all` method. + +Additionally, the :func:`~Columns.sortable` method provides access to all the +sortable columns. + + +Column options +-------------- + +Each column takes a certain set of column-specific arguments (documented in the +:ref:`column reference `). + +There's also a set of common arguments available to all column types. All are +optional. Here's a summary of them. + + :attr:`~Column.verbose_name` + A pretty human readable version of the column name. Typically this is + used in the header cells in the HTML output. + + :attr:`~Column.accessor` + A string or callable that specifies the attribute to access when + retrieving the value for a cell in this column from the data-set. + Multiple lookups can be achieved by providing a dot separated list of + lookups, e.g. ``"user.first_name"``. The functionality is identical to + that of Django's template variable syntax, e.g. ``{{ user.first_name + }}`` + + A callable should be used if the dot separated syntax is not capable of + describing the lookup properly. The callable will be passed a single + item from the data (if the table is using :class:`QuerySet` data, this + would be a :class:`Model` instance), and is expected to return the + correct value for the column. + + Consider the following: + + .. code-block:: python + + >>> import django_tables as tables + >>> data = [ + ... {'dot.separated.key': 1}, + ... {'dot.separated.key': 2}, + ... ] + ... + >>> class SlightlyComplexTable(tables.Table): + >>> dot_seperated_key = tables.Column(accessor=lambda x: x['dot.separated.key']) + ... + >>> table = SlightlyComplexTable(data) + >>> for row in table.rows: + >>> print row['dot_seperated_key'] + ... + 1 + 2 + + This would not have worked: + + .. code-block:: python + + dot_seperated_key = tables.Column(accessor='dot.separated.key') + + :attr:`~Column.default` + The default value for the column. This can be a value or a callable + object [1]_. If an object in the data provides :const:`None` for a + column, the default will be used instead. + + The default value may affect ordering, depending on the type of + data the table is using. The only case where ordering is not affected + ing when a :class:`QuerySet` is used as the table data (since sorting + is performed by the database). + + .. [1] The provided callable object must not expect to receive any + arguments. + + :attr:`~Column.visible` + If :const:`False`, this column will not be in the HTML output. + + When a field is not visible, it is removed from the table's + :attr:`~Column.columns` iterable. + + :attr:`~Column.sortable` + If :const:`False`, this column will not be allowed to be used in + ordering the table. + + :attr:`~Column.formatter` + A callable object that is used as a final step in formatting the value + for a cell. The callable will be passed the string that would have + otherwise been displayed in the cell. + + +Rows +==== + +Row objects +----------- + +A row object represents a single row in a table. + +To access the rendered value of each cell in a row, you can iterate over the +row: + +.. code-block:: python + + >>> import django_tables as tables + >>> class SimpleTable(tables.Table): + ... a = tables.Column() + ... b = tables.CheckBoxColumn(attrs={'name': 'my_chkbox'}) + ... + >>> table = SimpleTable([{'a': 1, 'b': 2}]) + >>> row = table.rows[0] # we only have one row, so let's use it + >>> for cell in row: + ... print cell + ... + 1 + + +Alternatively you can treat it like a list and use indexing to retrieve a +specific cell. It should be noted that this will raise an IndexError on +failure. + +.. code-block:: python + + >>> row[0] + 1 + >>> row[1] + u'' + >>> row[2] + ... + IndexError: list index out of range + +Finally you can also treat it like a dictionary and use column names as the +keys. This will raise KeyError on failure (unlike the above indexing using +integers). + +.. code-block:: python + + >>> row['a'] + 1 + >>> row['b'] + u'' + >>> row['c'] + ... + KeyError: 'c' + + + +Template tags +============= + +.. _template_tags.render_table: + +render_table +------------ + +If you want to render a table that provides support for sorting and pagination, +you must use the ``{% render_table %}`` template tag. In this example ``table`` +is an instance of a :class:`django_tables.Table` that has been put into the +template context: + +.. code-block:: django + + {% load django_tables %} + {% render_table table %} + + +.. _template_tags.set_url_param: + +set_url_param +------------- + +This template tag is a utility that allows you to update a portion of the +query-string without overwriting the entire thing. However you shouldn't need +to use this template tag unless you are rendering the table from scratch (i.e. +not using ``as_html()`` or ``{% render_table %}``). + +This is very useful if you want the give your users the ability to interact +with your table (e.g. change the ordering), because you will need to create +urls with the appropriate queries. + +Let's assume we have the query-string +``?search=pirates&sort=name&page=5`` and we want to update the ``sort`` +parameter: .. code-block:: django - {% if table.columns.tz.visible %} - {{ table.columns.tz }} - {% endfor %} + {% set_url_param sort="dob" %} # ?search=pirates&sort=dob&page=5 + {% set_url_param sort="" %} # ?search=pirates&page=5 + {% set_url_param sort="" search="" %} # ?page=5 -In Detail -========= -.. toctree:: - :maxdepth: 2 +A table instance bound to data has two attributes ``columns`` and ``rows``, +which can be iterated over: - installation - types/index - features/index - columns - templates +.. code-block:: django + + + + + {% for column in table.columns %} + + {% endfor %} + + + + {% for row in table.rows %} + + {% for value in row %} + + {% endfor %} + + {% endfor %} + +
{{ column }}
{{ value }}
-Indices and tables -================== -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` +Custom render methods +--------------------- + +Often, displaying a raw value of a table cell is not good enough. For +example, if your table has a ``rating`` column, you might want to show +an image showing the given number of **stars**, rather than the plain +numeric value. + +While you can always write your templates so that the column in question +is treated separately, either by conditionally checking for a column name, +or by explicitely rendering each column manually (as opposed to simply +looping over the ``rows`` and ``columns`` attributes), this is often +tedious to do. + +Instead, you can opt to move certain formatting responsibilites into +your Python code: + +.. code-block:: python + class BookTable(tables.ModelTable): + name = tables.Column() + rating = tables.Column(accessor='rating_int') + + def render_rating(self, bound_table): + if bound_table.rating_count == 0: + return '' + else: + return '' % bound_table.rating_int + +When accessing ``table.rows[i].rating``, the ``render_rating`` method +will be called. Note the following: + +- What is passed is underlying raw data object, in this case, the model + instance. This gives you access to data values that may not have been defined + as a column. +- For the method name, the public name of the column must be used, not the + internal field name. That is, it's ``render_rating``, not + ``render_rating_int``. +- The method is called whenever the cell value is retrieved by you, whether from + Python code or within templates. However, operations by ``django-tables``, + like sorting, always work with the raw data. diff --git a/docs/installation.rst b/docs/installation.rst deleted file mode 100644 index 93bc128..0000000 --- a/docs/installation.rst +++ /dev/null @@ -1,15 +0,0 @@ ------------- -Installation ------------- - -Adding ``django-tables`` to your ``INSTALLED_APPS`` setting is optional. -It'll get you the ability to load some template utilities via -``{% load tables %}``, but apart from that, -``import django_tables as tables`` should get you going. - - -Running the test suite ----------------------- - -The test suite uses nose: - http://somethingaboutorange.com/mrl/projects/nose/ diff --git a/docs/make.bat b/docs/make.bat index 28bda72..f2874c0 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -2,7 +2,9 @@ REM Command file for Sphinx documentation -set SPHINXBUILD=sphinx-build +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) set BUILDDIR=_build set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . if NOT "%PAPER%" == "" ( @@ -14,16 +16,21 @@ if "%1" == "" goto help if "%1" == "help" ( :help echo.Please use `make ^` where ^ is one of - echo. html to make standalone HTML files - echo. dirhtml to make HTML files named index.html in directories - echo. pickle to make pickle files - echo. json to make JSON files - echo. htmlhelp to make HTML files and a HTML help project - echo. qthelp to make HTML files and a qthelp project - echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter - echo. changes to make an overview over all changed/added/deprecated items - echo. linkcheck to check all external links for integrity - echo. doctest to run all doctests embedded in the documentation if enabled + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. changes to make an overview over all changed/added/deprecated items + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled goto end ) @@ -35,6 +42,7 @@ if "%1" == "clean" ( if "%1" == "html" ( %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/html. goto end @@ -42,13 +50,23 @@ if "%1" == "html" ( if "%1" == "dirhtml" ( %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. goto end ) +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + if "%1" == "pickle" ( %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the pickle files. goto end @@ -56,6 +74,7 @@ if "%1" == "pickle" ( if "%1" == "json" ( %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the JSON files. goto end @@ -63,6 +82,7 @@ if "%1" == "json" ( if "%1" == "htmlhelp" ( %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run HTML Help Workshop with the ^ .hhp project file in %BUILDDIR%/htmlhelp. @@ -71,6 +91,7 @@ if "%1" == "htmlhelp" ( if "%1" == "qthelp" ( %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run "qcollectiongenerator" with the ^ .qhcp project file in %BUILDDIR%/qthelp, like this: @@ -80,15 +101,49 @@ if "%1" == "qthelp" ( goto end ) +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + if "%1" == "latex" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 echo. echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. goto end ) +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + if "%1" == "changes" ( %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 echo. echo.The overview file is in %BUILDDIR%/changes. goto end @@ -96,6 +151,7 @@ if "%1" == "changes" ( if "%1" == "linkcheck" ( %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 echo. echo.Link check complete; look for any errors in the above output ^ or in %BUILDDIR%/linkcheck/output.txt. @@ -104,6 +160,7 @@ or in %BUILDDIR%/linkcheck/output.txt. if "%1" == "doctest" ( %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 echo. echo.Testing of doctests in the sources finished, look at the ^ results in %BUILDDIR%/doctest/output.txt. diff --git a/docs/templates.rst b/docs/templates.rst deleted file mode 100644 index c3ee8bb..0000000 --- a/docs/templates.rst +++ /dev/null @@ -1,110 +0,0 @@ -=================== -Rendering the table -=================== - -A table instance bound to data has two attributes ``columns`` and ``rows``, -which can be iterate over: - -.. code-block:: django - - - - {% for column in table.columns %} - - {% endfor %} - - {% for row in table.rows %} - - {% for value in row %} - - {% endfor %} - - {% endfor %} -
{{ column }}
{{ value }}
- -For the attributes available on a bound column, see :doc:`features/index`, -depending on what you want to accomplish. - - -Custom render methods ---------------------- - -Often, displaying a raw value of a table cell is not good enough. For -example, if your table has a ``rating`` column, you might want to show -an image showing the given number of **stars**, rather than the plain -numeric value. - -While you can always write your templates so that the column in question -is treated separately, either by conditionally checking for a column name, -or by explicitely rendering each column manually (as opposed to simply -looping over the ``rows`` and ``columns`` attributes), this is often -tedious to do. - -Instead, you can opt to move certain formatting responsibilites into -your Python code: - -.. code-block:: django - - class BookTable(tables.ModelTable): - name = tables.Column() - rating_int = tables.Column(name="rating") - - def render_rating(self, instance): - if instance.rating_count == 0: - return '' - else: - return '' % instance.rating_int - -When accessing ``table.rows[i].rating``, the ``render_rating`` method -will be called. Note the following: - - - What is passed is underlying raw data object, in this case, the - model instance. This gives you access to data values that may not - have been defined as a column. - - For the method name, the public name of the column must be used, not - the internal field name. That is, it's ``render_rating``, not - ``render_rating_int``. - - The method is called whenever the cell value is retrieved by you, - whether from Python code or within templates. However, operations by - ``django-tables``, like sorting, always work with the raw data. - - -The table.columns container ---------------------------- - -While you can iterate through the ``columns`` attribute and get all the -currently visible columns, it further provides features that go beyond -a simple iterator. - -You can access all columns, regardless of their visibility, through -``columns.all``. - -``columns.sortable`` is a handy shortcut that exposes all columns which's -``sortable`` attribute is True. This can be very useful in templates, when -doing {% if column.sortable %} can conflict with {{ forloop.last }}. - - -Template Utilities ------------------- - -If you want the give your users the ability to interact with your table (e.g. -change the ordering), you will need to create urls with the appropriate -queries. To simplify that process, django-tables comes with a helpful -templatetag: - -.. code-block:: django - - {% set_url_param sort="name" %} # ?sort=name - {% set_url_param sort="" %} # delete "sort" param - -The template library can be found in 'django_modules.app.templates.tables'. -If you add ''django_modules.app' to your ``INSTALLED_APPS`` setting, you -will be able to do: - -.. code-block:: django - - {% load tables %} - -Note: The tag requires the current request to be available as ``request`` -in the context (usually, this means activating the Django request context -processor). diff --git a/docs/types/index.rst b/docs/types/index.rst deleted file mode 100644 index 1aaac35..0000000 --- a/docs/types/index.rst +++ /dev/null @@ -1,12 +0,0 @@ -=========== -Table types -=========== - -Different types of tables are available: - -.. toctree:: - :maxdepth: 1 - - MemoryTable - uses dicts as the data source - ModelTable - wraps around a Django Model - SqlTable - is based on a raw SQL query diff --git a/docs/types/memory.rst b/docs/types/memory.rst deleted file mode 100644 index ae09ae7..0000000 --- a/docs/types/memory.rst +++ /dev/null @@ -1,20 +0,0 @@ ------------ -MemoryTable ------------ - -This table expects an iterable of ``dict`` (or compatible) objects as the -data source. Values found in the data that are not associated with a column -are ignored, missing values are replaced by the column default or ``None``. - -Sorting is done in memory, in pure Python. - -Dynamic Data -~~~~~~~~~~~~ - -If any value in the source data is a callable, it will be passed it's own -row instance and is expected to return the actual value for this particular -table cell. - -Similarily, the colunn default value may also be callable that will take -the row instance as an argument (representing the row that the default is -needed for). diff --git a/docs/types/models.rst b/docs/types/models.rst deleted file mode 100644 index 699a2f5..0000000 --- a/docs/types/models.rst +++ /dev/null @@ -1,134 +0,0 @@ ----------- -ModelTable ----------- - -This table type is based on a Django model. It will use the Model's data, -and, like a ``ModelForm``, can automatically generate it's columns based -on the mode fields. - -.. code-block:: python - - class CountryTable(tables.ModelTable): - id = tables.Column(sortable=False, visible=False) - class Meta: - model = Country - exclude = ['clicks'] - -In this example, the table will have one column for each model field, -with the exception of ``clicks``, which is excluded. The column for ``id`` -is overwritten to both hide it by default and deny it sort capability. - -When instantiating a ``ModelTable``, you usually pass it a queryset to -provide the table data: - -.. code-block:: python - - qs = Country.objects.filter(continent="europe") - countries = CountryTable(qs) - -However, you can also just do: - -.. code-block:: python - - countries = CountryTable() - -and all rows exposed by the default manager of the model the table is based -on will be used. - -If you are using model inheritance, then the following also works: - -.. code-block:: python - - countries = CountryTable(CountrySubclass) - -Note that while you can pass any model, it really only makes sense if the -model also provides fields for the columns you have defined. - -If you just want to use a ``ModelTable``, but without auto-generated -columns, you do not have to list all model fields in the ``exclude`` -``Meta`` option. Instead, simply don't specify a model. - - -Custom Columns -~~~~~~~~~~~~~~ - -You an add custom columns to your ModelTable that are not based on actual -model fields: - -.. code-block:: python - - class CountryTable(tables.ModelTable): - custom = tables.Column(default="foo") - class Meta: - model = Country - -Just make sure your model objects do provide an attribute with that name. -Functions are also supported, so ``Country.custom`` could be a callable. - - -Spanning relationships -~~~~~~~~~~~~~~~~~~~~~~ - -Let's assume you have a ``Country`` model, with a ``ForeignKey`` ``capital`` -pointing to the ``City`` model. While displaying a list of countries, -you might want want to link to the capital's geographic location, which is -stored in ``City.geo`` as a ``(lat, long)`` tuple, on, say, a Google Map. - -``ModelTable`` supports the relationship spanning syntax of Django's -database API: - -.. code-block:: python - - class CountryTable(tables.ModelTable): - city__geo = tables.Column(name="geo") - -This will add a column named "geo", based on the field by the same name -from the "city" relationship. Note that the name used to define the column -is what will be used to access the data, while the name-overwrite passed to -the column constructor just defines a prettier name for us to work with. -This is to be consistent with auto-generated columns based on model fields, -where the field/column name naturally equals the source name. - -However, to make table defintions more visually appealing and easier to -read, an alternative syntax is supported: setting the column ``data`` -property to the appropriate string. - -.. code-block:: python - - class CountryTable(tables.ModelTable): - geo = tables.Column(data='city__geo') - -Note that you don't need to define a relationship's fields as separate -columns if you already have a column for the relationship itself, i.e.: - -.. code-block:: python - - class CountryTable(tables.ModelTable): - city = tables.Column() - - for country in countries.rows: - print country.city.id - print country.city.geo - print country.city.founder.name - - -``ModelTable`` Specialties -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -``ModelTable`` currently has some restrictions with respect to ordering: - -* Custom columns not based on a model field do not support ordering, - regardless of the ``sortable`` property (it is ignored). - -* A ``ModelTable`` column's ``default`` or ``data`` value does not affect - ordering. This differs from the non-model table behaviour. - -If a column is mapped to a method on the model, that method will be called -without arguments. This behavior differs from memory tables, where a -row object will be passed. - -If you are using callables (e.g. for the ``default`` or ``data`` column -options), they will generally be run when a row is accessed, and -possible repeatedly when accessed more than once. This behavior differs from -memory tables, where they would be called once, when the table is -generated. diff --git a/docs/types/sql.rst b/docs/types/sql.rst deleted file mode 100644 index 6f12f47..0000000 --- a/docs/types/sql.rst +++ /dev/null @@ -1,9 +0,0 @@ --------- -SqlTable --------- - -This table is backed by an SQL query that you specified. It'll help you -ensure that pagination and sorting options are properly reflected in the -query. - -**Currently not implemented yet.** diff --git a/requirements-dev.pip b/requirements-dev.pip deleted file mode 100644 index 8c5e259..0000000 --- a/requirements-dev.pip +++ /dev/null @@ -1,3 +0,0 @@ -django -nose -Sphinx diff --git a/setup.py b/setup.py index 6e34acd..2dfeb42 100755 --- a/setup.py +++ b/setup.py @@ -1,47 +1,79 @@ -#!/usr/bin/env python -import os +# -*- coding: utf8 -*- from distutils.core import setup +from distutils.command.install_data import install_data +from distutils.command.install import INSTALL_SCHEMES +import os +import sys + +class osx_install_data(install_data): + # On MacOS, the platform-specific lib dir is /System/Library/Framework/Python/.../ + # which is wrong. Python 2.5 supplied with MacOS 10.5 has an Apple-specific fix + # for this in distutils.command.install_data#306. It fixes install_lib but not + # install_data, which is why we roll our own install_data class. + def finalize_options(self): + # By the time finalize_options is called, install.install_lib is set to the + # fixed directory, so we set the installdir to install_lib. The + # install_data class uses ('install_data', 'install_dir') instead. + self.set_undefined_options('install', ('install_lib', 'install_dir')) + install_data.finalize_options(self) -# Figure out the version; this could be done by importing the -# module, though that requires Django to be already installed, -# which may not be the case when processing a pip requirements -# file, for example. -import re -here = os.path.dirname(os.path.abspath(__file__)) -version_re = re.compile( - r'__version__ = (\(.*?\))') -fp = open(os.path.join(here, 'django_tables', '__init__.py')) -version = None -for line in fp: - match = version_re.search(line) - if match: - version = eval(match.group(1)) - break +if sys.platform == "darwin": + cmdclasses = {'install_data': osx_install_data} else: - raise Exception("Cannot find version in __init__.py") -fp.close() + cmdclasses = {'install_data': install_data} + +def fullsplit(path, result=None): + """ + Split a pathname into components (the opposite of os.path.join) in a + platform-neutral way. + """ + if result is None: + result = [] + head, tail = os.path.split(path) + if head == '': + return [tail] + result + if head == path: + return result + return fullsplit(head, [tail] + result) + +# Tell distutils to put the data_files in platform-specific installation +# locations. See here for an explanation: +# http://groups.google.com/group/comp.lang.python/browse_thread/thread/35ec7b2fed36eaec/2105ee4d9e8042cb +for scheme in INSTALL_SCHEMES.values(): + scheme['data'] = scheme['purelib'] +# Compile the list of packages available, because distutils doesn't have +# an easy way to do this. +packages, data_files = [], [] +root_dir = os.path.dirname(__file__) +if root_dir != '': + os.chdir(root_dir) +package_dir = 'django_tables' -def find_packages(root): - # so we don't depend on setuptools; from the Storm ORM setup.py - packages = [] - for directory, subdirectories, files in os.walk(root): - if '__init__.py' in files: - packages.append(directory.replace(os.sep, '.')) - return packages +for dirpath, dirnames, filenames in os.walk(package_dir): + # Ignore dirnames that start with '.' + for i, dirname in enumerate(dirnames): + if dirname.startswith('.'): del dirnames[i] + if '__init__.py' in filenames: + packages.append('.'.join(fullsplit(dirpath))) + elif filenames: + data_files.append([dirpath, [os.path.join(dirpath, f) for f in filenames]]) +# Small hack for working with bdist_wininst. +# See http://mail.python.org/pipermail/distutils-sig/2004-August/004134.html +if len(sys.argv) > 1 and sys.argv[1] == 'bdist_wininst': + for file_info in data_files: + file_info[0] = '\\PURELIB\\%s' % file_info[0] setup( name = 'django-tables', - version=".".join(map(str, version)), - description = 'Render QuerySets as tabular data in Django.', - author = 'Michael Elsdoerfer', - author_email = 'michael@elsdoerfer.info', - license = 'BSD', - url = 'http://launchpad.net/django-tables', + version = __import__(package_dir).get_version().replace(' ', '-'), + description = 'Table framework for Django', + author = 'Bradley Ayers', + author_email = 'bradley.ayers@gmail.com', + url = '', classifiers = [ - 'Development Status :: 3 - Alpha', 'Environment :: Web Environment', 'Framework :: Django', 'Intended Audience :: Developers', @@ -50,6 +82,10 @@ setup( 'Programming Language :: Python', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Software Development :: Libraries', - ], - packages = find_packages('django_tables'), + ], + packages = packages, + data_files = data_files, + cmdclass = cmdclasses, + requires = ['django(>=1.1)'], + install_requires = ['django>=1.1'] ) diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index db2ee0d..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# make django-tables available for import for tests -import os, sys -sys.path.append(os.path.join(os.path.dirname(__file__), '..')) diff --git a/tests/test_basic.py b/tests/test_basic.py deleted file mode 100644 index fda93f3..0000000 --- a/tests/test_basic.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Test the core table functionality. -""" - - -from nose.tools import assert_raises, assert_equal -from django.http import Http404 -from django.core.paginator import Paginator -import django_tables as tables -from django_tables.base import BaseTable - - -class TestTable(BaseTable): - pass - - -def test_declaration(): - """ - Test defining tables by declaration. - """ - - class GeoAreaTable(TestTable): - name = tables.Column() - population = tables.Column() - - assert len(GeoAreaTable.base_columns) == 2 - assert 'name' in GeoAreaTable.base_columns - assert not hasattr(GeoAreaTable, 'name') - - class CountryTable(GeoAreaTable): - capital = tables.Column() - - assert len(CountryTable.base_columns) == 3 - assert 'capital' in CountryTable.base_columns - - # multiple inheritance - class AddedMixin(TestTable): - added = tables.Column() - class CityTable(GeoAreaTable, AddedMixin): - mayer = tables.Column() - - assert len(CityTable.base_columns) == 4 - assert 'added' in CityTable.base_columns - - # modelforms: support switching from a non-model table hierarchy to a - # modeltable hierarchy (both base class orders) - class StateTable1(tables.ModelTable, GeoAreaTable): - motto = tables.Column() - class StateTable2(GeoAreaTable, tables.ModelTable): - motto = tables.Column() - - assert len(StateTable1.base_columns) == len(StateTable2.base_columns) == 3 - assert 'motto' in StateTable1.base_columns - assert 'motto' in StateTable2.base_columns - - -def test_sort(): - class MyUnsortedTable(TestTable): - alpha = tables.Column() - beta = tables.Column() - n = tables.Column() - - test_data = [ - {'alpha': "mmm", 'beta': "mmm", 'n': 1 }, - {'alpha': "aaa", 'beta': "zzz", 'n': 2 }, - {'alpha': "zzz", 'beta': "aaa", 'n': 3 }] - - # various different ways to say the same thing: don't sort - assert_equal(MyUnsortedTable(test_data ).order_by, ()) - assert_equal(MyUnsortedTable(test_data, order_by=None).order_by, ()) - assert_equal(MyUnsortedTable(test_data, order_by=[] ).order_by, ()) - assert_equal(MyUnsortedTable(test_data, order_by=() ).order_by, ()) - - # values of order_by are wrapped in tuples before being returned - assert_equal(('alpha',), MyUnsortedTable([], order_by='alpha').order_by) - assert_equal(('beta',), MyUnsortedTable([], order_by=('beta',)).order_by) - assert_equal((), MyUnsortedTable([]).order_by) - - # a rewritten order_by is also wrapped - table = MyUnsortedTable([]) - table.order_by = 'alpha' - assert_equal(('alpha',), table.order_by) - - # default sort order can be specified in table options - class MySortedTable(MyUnsortedTable): - class Meta: - order_by = 'alpha' - - # order_by is inherited from the options if not explitly set - table = MySortedTable(test_data) - assert_equal(('alpha',), table.order_by) - - # ...but can be overloaded at __init___ - table = MySortedTable(test_data, order_by='beta') - assert_equal(('beta',), table.order_by) - - # ...or rewritten later - table = MySortedTable(test_data) - table.order_by = 'beta' - assert_equal(('beta',), table.order_by) - - # ...or reset to None (unsorted), ignoring the table default - table = MySortedTable(test_data, order_by=None) - assert_equal((), table.order_by) - assert_equal(1, table.rows[0]['n']) - - -def test_column_count(): - class MyTable(TestTable): - visbible = tables.Column(visible=True) - hidden = tables.Column(visible=False) - - # The columns container supports the len() builtin - assert len(MyTable([]).columns) == 1 - - -def test_pagination(): - class BookTable(TestTable): - name = tables.Column() - - # create some sample data - data = [] - for i in range(1,101): - data.append({'name': 'Book Nr. %d'%i}) - books = BookTable(data) - - # external paginator - paginator = Paginator(books.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 - - # integrated paginator - books.paginate(Paginator, 10, page=1) - # rows is now paginated - assert len(list(books.rows.page())) == 10 - assert len(list(books.rows.all())) == 100 - # new attributes - assert books.paginator.num_pages == 10 - assert books.page.has_previous() == False - assert books.page.has_next() == True - # exceptions are converted into 404s - assert_raises(Http404, books.paginate, Paginator, 10, page=9999) - assert_raises(Http404, books.paginate, Paginator, 10, page="abc") diff --git a/tests/test_templates.py b/tests/test_templates.py deleted file mode 100644 index 50f4dcb..0000000 --- a/tests/test_templates.py +++ /dev/null @@ -1,110 +0,0 @@ -# coding: utf8 -"""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' diff --git a/tests/testapp/__init__.py b/tests/testapp/__init__.py deleted file mode 100644 index e69de29..0000000