from django.utils.text import capfirst
from django.utils.safestring import mark_safe
from django.template import RequestContext, Context, Template
+from django.db.models.fields import FieldDoesNotExist
from .utils import OrderBy, A, AttributeDict
+from itertools import ifilter
class Column(object):
- """Represents a single column of a table.
+ """
+ Represents a single column of a table.
:class:`Column` objects control the way a column (including the cells that
fall within it) are rendered.
:type sortable: :class:`bool`
:param sortable: If :const:`False`, this column will not be allowed to
influence row ordering/sorting.
-
"""
#: Tracks each time a Column instance is created. Used to retain order.
creation_counter = 0
The default value passed into ``Column.default`` property may be a
callable, this function handles access.
-
"""
return self._default() if callable(self._default) else self._default
to the column name (it's only available on a :class:`.BoundColumn`
object hence accessing that first) when this property doesn't
return something useful.
-
"""
return self.verbose_name
:param header_attrs:
same as *attrs*, but applied **only** to the header checkbox
"""
-
def __init__(self, attrs=None, header_attrs=None, **extra):
params = {'sortable': False}
params.update(extra)
class PeopleTable(tables.Table):
name = tables.LinkColumn('people_detail', args=[A('pk')])
"""
-
def __init__(self, viewname, urlconf=None, args=None, kwargs=None,
current_app=None, attrs=None, **extra):
super(LinkColumn, self).__init__(**extra)
In order to use template tags or filters that require a
``RequestContext``, the table **must** be rendered via
:ref:`{% render_table %} <template-tags.render_table>`.
-
"""
def __init__(self, template_code=None, **extra):
super(TemplateColumn, self).__init__(**extra)
``age`` is the name.
"""
-
def __init__(self, table, column, name):
self._table = table
self._column = column
"""
Returns the string used to access data for this column out of the data
source.
-
"""
return self.column.accessor or A(self.name)
@property
def column(self):
- """Returns the :class:`.Column` object for this column."""
+ """
+ Returns the :class:`.Column` object for this column.
+ """
return self._column
@property
def default(self):
- """Returns the default value for this column."""
+ """
+ Returns the default value for this column.
+ """
return self.column.default
@property
def header(self):
"""
The value that should be used in the header cell for this column.
-
"""
return self.column.header or self.verbose_name
@property
def name(self):
- """Returns the string used to identify this column."""
+ """
+ Returns the string used to identify this column.
+ """
return self._name
@property
"""
If this column is sorted, return the associated :class:`.OrderBy`
instance, otherwise ``None``.
-
"""
try:
return self.table.order_by[self.name]
@property
def sortable(self):
- """Return a ``bool`` depending on whether this column is sortable."""
+ """
+ Return a ``bool`` depending on whether this column is sortable.
+ """
if self.column.sortable is not None:
return self.column.sortable
return self.table.sortable
@property
def table(self):
- """Return the :class:`Table` object that this column is part of."""
+ """
+ Return the :class:`Table` object that this column is part of.
+ """
return self._table
@property
if hasattr(self.table.data, 'queryset'):
model = self.table.data.queryset.model
parts = self.accessor.split('.')
+ field = None
for part in parts:
- field = model._meta.get_field(part)
+ try:
+ field = model._meta.get_field(part)
+ except FieldDoesNotExist:
+ break
if hasattr(field, 'rel') and hasattr(field.rel, 'to'):
model = field.rel.to
continue
def visible(self):
"""
Returns a :class:`bool` depending on whether this column is visible.
-
"""
return self.column.visible
class BoundColumns(object):
"""
- Container for spawning BoundColumns.
+ Container for spawning :class:`.BoundColumn` objects.
This is bound to a table and provides its :attr:`.Table.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.
- A :class:`.BoundColumns` object is a container for holding
- :class:`.BoundColumn` objects. It provides methods that make accessing
+ A ``BoundColumns`` object is a container for holding
+ ``BoundColumn`` objects. It provides methods that make accessing
columns easier than if they were stored in a ``list`` or
- :class:`dict`. :class:`Columns` has a similar API to a ``dict`` (it
+ ``dict``. ``Columns`` has a similar API to a ``dict`` (it
actually uses a ``SortedDict`` interally).
At the moment you'll only come across this class when you access a
:type table: :class:`.Table` object
:param table: the table containing the columns
"""
-
def __init__(self, table):
self.table = table
# ``self._columns`` attribute stores the bound columns (columns that
(note that :attr:`.base_columns` might have changed since last time);
creating :class:`.BoundColumn` instances can be costly, so we reuse
existing ones.
-
"""
columns = SortedDict()
for name, column in self.table.base_columns.items():
columns[name] = BoundColumn(self.table, column, name)
self._columns = columns
- def all(self):
+ def iternames(self):
+ return (name for name, column in self.iteritems())
+
+ def names(self):
+ return list(self.iternames())
+
+ def iterall(self):
"""
Return an iterator that exposes all :class:`.BoundColumn` objects,
regardless of visiblity or sortability.
-
"""
- self._spawn_columns()
- for column in self._columns.values():
- yield column
+ return (column for name, column in self.iteritems())
- def items(self):
+ def all(self):
+ return list(self.iterall())
+
+ def iteritems(self):
"""
Return an iterator of ``(name, column)`` pairs (where ``column`` is a
:class:`.BoundColumn` object).
-
"""
self._spawn_columns()
- for r in self._columns.items():
- yield r
+ if self.table.sequence:
+ return ((x, self._columns[x]) for x in self.table.sequence)
+ else:
+ return self._columns.iteritems()
- def sortable(self):
+ def items(self):
+ return list(self.iteritems())
+
+ def itersortable(self):
"""
Same as :meth:`.BoundColumns.all` but only returns sortable
:class:`.BoundColumn` objects.
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
+ return ifilter(lambda x: x.sortable, self.all())
- def visible(self):
+ def sortable(self):
+ return list(self.itersortable())
+
+ def itervisible(self):
"""
Same as :meth:`.sortable` but only returns visible
:class:`.BoundColumn` objects.
This is geared towards table rendering.
-
"""
- for column in self.all():
- if column.visible:
- yield column
+ return ifilter(lambda x: x.visible, self.all())
+
+ def visible(self):
+ return list(self.itervisible())
def __iter__(self):
- """Convenience API with identical functionality to :meth:`visible`."""
- return self.visible()
+ """
+ Convenience API with identical functionality to :meth:`visible`.
+ """
+ return self.itervisible()
def __contains__(self, item):
- """Check if a column is contained within a :class:`.Columns` object.
+ """
+ Check if a column is contained within a :class:`.Columns` object.
*item* can either be a :class:`.BoundColumn` object, or the name of a
column.
-
"""
- self._spawn_columns()
if isinstance(item, basestring):
- for key in self._columns.keys():
- if item == key:
- return True
- return False
+ return item in self.iternames()
else:
- return item in self.all()
+ # let's assume we were given a column
+ return item in self.iterall()
def __len__(self):
- """Return how many :class:`BoundColumn` objects are contained."""
+ """
+ Return how many :class:`BoundColumn` objects are contained (and
+ visible).
+ """
self._spawn_columns()
- return len([1 for c in self._columns.values() if c.visible])
+ return len(self.visible())
def __getitem__(self, index):
- """Retrieve a specific :class:`BoundColumn` object.
+ """
+ Retrieve a specific :class:`BoundColumn` object.
*index* can either be 0-indexed or the name of a column
columns['speed'] # returns a bound column with name 'speed'
columns[0] # returns the first column
-
"""
self._spawn_columns()
if isinstance(index, int):
from django.template.loader import get_template
from django.template import Context
from django.utils.encoding import StrAndUnicode
+from django.db.models.query import QuerySet
+from itertools import chain
from .utils import OrderBy, OrderByTuple, Accessor, AttributeDict
from .rows import BoundRows, BoundRow
from .columns import BoundColumns, Column
QUERYSET_ACCESSOR_SEPARATOR = '__'
+class Sequence(list):
+ """
+ Represents a column sequence, e.g. ("first_name", "...", "last_name")
+
+ This is used to represent ``Table.Meta.sequence`` or the Table
+ constructors's ``sequence`` keyword argument.
+
+ The sequence must be a list of column names and is used to specify the
+ order of the columns on a table. Optionally a "..." item can be inserted,
+ which is treated as a *catch-all* for column names that aren't explicitly
+ specified.
+ """
+ def expand(self, columns):
+ """
+ Expands the "..." item in the sequence into the appropriate column
+ names that should be placed there.
+
+ :raises: ``ValueError`` if the sequence is invalid for the columns.
+ """
+ # validation
+ if self.count("...") > 1:
+ raise ValueError("'...' must be used at most once in a sequence.")
+ elif "..." in self:
+ # Check for columns in the sequence that don't exist in *columns*
+ extra = (set(self) - set(("...", ))).difference(columns)
+ if extra:
+ raise ValueError(u"sequence contains columns that do not exist"
+ u" in the table. Remove '%s'."
+ % "', '".join(extra))
+ else:
+ diff = set(self) ^ set(columns)
+ if diff:
+ raise ValueError(u"sequence does not match columns. Fix '%s' "
+ u"or possibly add '...'." % "', '".join(diff))
+ # everything looks good, let's expand the "..." item
+ columns = columns[:] # don't modify
+ head = []
+ tail = []
+ target = head # start by adding things to the head
+ for name in self:
+ if name == "...":
+ # now we'll start adding elements to the tail
+ target = tail
+ continue
+ else:
+ target.append(columns.pop(columns.index(name)))
+ self[:] = list(chain(head, columns, tail))
+
+
class TableData(object):
"""
Exposes a consistent API for :term:`table data`. It currently supports a
:class:`QuerySet`, or a :class:`list` of :class:`dict` objects.
- This class is used by :class:.Table` to wrap any
+ This class is used by :class:`.Table` to wrap any
input table data.
"""
-
def __init__(self, data, table):
- from django.db.models.query import QuerySet
if isinstance(data, QuerySet):
self.queryset = data
elif isinstance(data, list):
as well.
"""
- def __new__(cls, name, bases, attrs, parent_cols_from=None):
+ def __new__(cls, name, bases, attrs):
"""Ughhh document this :)"""
# extract declared columns
- columns = [(name, attrs.pop(name)) for name, column in attrs.items()
- if isinstance(column, Column)]
+ 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))
# 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
+ if hasattr(base, "base_columns"):
+ columns = base.base_columns.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))
+ # class MyNewTable(MyOldNonTable, tables.Table): pass
+ if not "base_columns" in attrs:
+ attrs["base_columns"] = SortedDict()
+ attrs["base_columns"].update(SortedDict(columns))
+ attrs["_meta"] = opts = TableOptions(attrs.get("Meta", None))
+ for ex in opts.exclude:
+ if ex in attrs["base_columns"]:
+ attrs["base_columns"].pop(ex)
+ if opts.sequence:
+ opts.sequence.expand(attrs["base_columns"].keys())
+ attrs["base_columns"] = SortedDict(((x, attrs["base_columns"][x]) for x in opts.sequence))
return type.__new__(cls, name, bases, attrs)
"""
Extracts and exposes options for a :class:`.Table` from a ``class Meta``
when the table is defined.
+
+ :param options: options for a table
+ :type options: :class:`Meta` on a :class:`.Table`
"""
def __init__(self, options=None):
- """
-
- :param options: options for a table
- :type options: :class:`Meta` on a :class:`.Table`
-
- """
super(TableOptions, self).__init__()
- self.sortable = getattr(options, 'sortable', True)
- order_by = getattr(options, 'order_by', ())
+ self.attrs = AttributeDict(getattr(options, "attrs", {}))
+ self.empty_text = getattr(options, "empty_text", None)
+ self.exclude = getattr(options, "exclude", ())
+ order_by = getattr(options, "order_by", ())
if isinstance(order_by, basestring):
order_by = (order_by, )
self.order_by = OrderByTuple(order_by)
- self.attrs = AttributeDict(getattr(options, 'attrs', {}))
- self.empty_text = getattr(options, 'empty_text', None)
+ self.sequence = Sequence(getattr(options, "sequence", ()))
+ self.sortable = getattr(options, "sortable", True)
class Table(StrAndUnicode):
__metaclass__ = DeclarativeColumnsMetaclass
TableDataClass = TableData
- def __init__(self, data, order_by=None, sortable=None, empty_text=None):
- self._rows = BoundRows(self) # bound rows
- self._columns = BoundColumns(self) # bound columns
+ def __init__(self, data, order_by=None, sortable=None, empty_text=None,
+ exclude=None, attrs=None, sequence=None):
+ self._rows = BoundRows(self)
+ self._columns = BoundColumns(self)
self._data = self.TableDataClass(data=data, table=self)
+ self.attrs = attrs
self.empty_text = empty_text
self.sortable = sortable
+ # 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.
+ self.base_columns = copy.deepcopy(self.__class__.base_columns)
+ self.exclude = exclude or ()
+ for ex in self.exclude:
+ if ex in self.base_columns:
+ self.base_columns.pop(ex)
+ self.sequence = sequence
if order_by is None:
self.order_by = self._meta.order_by
else:
self.order_by = 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.
- self.base_columns = copy.deepcopy(type(self).base_columns)
def __unicode__(self):
return self.as_html()
self._order_by = order_by
self._data.order_by(order_by)
+ @property
+ def sequence(self):
+ return (self._sequence if self._sequence is not None
+ else self._meta.sequence)
+
+ @sequence.setter
+ def sequence(self, value):
+ if value:
+ value = Sequence(value)
+ value.expand(self.base_columns.keys())
+ self._sequence = value
+
@property
def sortable(self):
return (self._sortable if self._sortable is not None
:rtype: :class:`~.utils.AttributeDict` object.
"""
- return self._meta.attrs
+ return self._attrs if self._attrs is not None else self._meta.attrs
+
+ @attrs.setter
+ def attrs(self, value):
+ self._attrs = value
def paginate(self, klass=Paginator, per_page=25, page=1, *args, **kwargs):
self.paginator = klass(self.rows, per_page, *args, **kwargs)
import urllib
import tokenize
import StringIO
+from django.conf import settings
from django import template
from django.template.loader import get_template
from django.utils.safestring import mark_safe
self.table_var = template.Variable(table_var_name)
def render(self, context):
- table = self.table_var.resolve(context)
- if 'request' not in context:
- raise AssertionError('{% render_table %} requires that the '
- 'template context contains the HttpRequest in'
- ' a "request" variable, check your '
- ' TEMPLATE_CONTEXT_PROCESSORS setting.')
- context = template.Context({'request': context['request'], 'table': table})
try:
- table.request = context['request']
- return get_template('django_tables/table.html').render(context)
- finally:
- del table.request
+ # may raise VariableDoesNotExist
+ table = self.table_var.resolve(context)
+ if "request" not in context:
+ raise AssertionError("{% render_table %} requires that the "
+ "template context contains the HttpRequest in"
+ " a 'request' variable, check your "
+ " TEMPLATE_CONTEXT_PROCESSORS setting.")
+ context = template.Context({"request": context["request"],
+ "table": table})
+ try:
+ table.request = context["request"]
+ return get_template("django_tables/table.html").render(context)
+ finally:
+ del table.request
+ except:
+ if settings.DEBUG:
+ raise
+ else:
+ return settings.TEMPLATE_STRING_IF_INVALID
@register.tag
--- /dev/null
+from django.core.exceptions import ImproperlyConfigured
+from django.views.generic.base import TemplateResponseMixin
+from django.views.generic.list import BaseListView
+
+
+class SingleTableMixin(object):
+ """
+ Adds a Table object to the context. Typically used with
+ ``TemplateResponseMixin``.
+
+ :param table_class: table class
+ :type table_class: subclass of ``django_tables.Table``
+
+ :param table_data: data used to populate the table
+ :type table_data: any compatible data source
+
+ :param context_table_name: name of the table's template variable (default:
+ "table")
+ :type context_table_name: ``string``
+
+ This mixin plays nice with the Django's ``MultipleObjectMixin`` by using
+ ``get_queryset()`` as a fallback for the table data source.
+ """
+ table_class = None
+ table_data = None
+ context_table_name = None
+
+ def get_table(self):
+ """
+ Return a table object to use. The table has automatic support for
+ sorting and pagination.
+ """
+ table_class = self.get_table_class()
+ table = table_class(self.get_table_data(),
+ order_by=self.request.GET.get("sort"))
+ table.paginate(page=self.request.GET.get("page", 1))
+ return table
+
+ def get_table_class(self):
+ """
+ Return the class to use for the table.
+ """
+ if self.table_class:
+ return self.table_class
+ raise ImproperlyConfigured(u"A table class was not specified. Define"
+ u"%(cls)s.table_class"
+ % {"cls": self.__class__.__name__})
+
+ def get_context_table_name(self, table):
+ """
+ Get the name to use for the table's template variable.
+ """
+ return self.context_table_name or "table"
+
+ def get_table_data(self):
+ """
+ Return the table data that should be used to populate the rows.
+ """
+ if self.table_data:
+ return self.table_data
+ elif hasattr(self, "get_queryset"):
+ return self.get_queryset()
+ raise ImproperlyConfigured(u"Table data was not specified. Define "
+ u"%(cls)s.table_data"
+ % {"cls": self.__class__.__name__})
+
+ def get_context_data(self, **kwargs):
+ """
+ Overriden version of ``TemplateResponseMixin`` to inject the table into
+ the template's context.
+ """
+ context = super(SingleTableMixin, self).get_context_data(**kwargs)
+ table = self.get_table()
+ context[self.get_context_table_name(table)] = table
+ return context
+
+
+class SingleTableView(SingleTableMixin, TemplateResponseMixin, BaseListView):
+ """
+ Generic view that renders a template and passes in a ``Table`` object.
+ """
# All configuration values have a default; values that are commented out
# serve to show the default.
-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.
+import os
+import sys
+os.environ["DJANGO_SETTINGS_MODULE"] = "example.settings"
+# import our libs
sys.path.insert(0, os.path.join(os.path.abspath('.'), os.pardir))
+import example
import django_tables as tables
sys.path.pop(0)
+
# -- General configuration -----------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
# built documents.
#
# The short X.Y version.
-version = '0.4.3'
+version = '0.5.0'
# The full version, including alpha/beta/rc tags.
-release = '0.4.3'
+release = '0.5.0'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
Ordering
========
-Changing the way a table is ordered is easy and can be controlled via the
-:attr:`.Table.Meta.order_by` option. The following examples all achieve the
-same thing:
+.. note::
+
+ If you want to change the order in which columns are displayed, see
+ :attr:`Table.Meta.sequence`. Alternatively if you're interested in the
+ order of records within the table, read on.
+
+Changing the way records in a table are ordered is easy and can be controlled
+via the :attr:`.Table.Meta.order_by` option. The following examples all achieve
+the same thing:
.. code-block:: python
</table>
+Class Based Generic Mixins
+==========================
+
+Django 1.3 introduced `class based views`__ as a mechanism to reduce the
+repetition in view code. django-tables comes with a single class based view
+mixin: ``SingleTableMixin``. It makes it trivial to incorporate a table into a
+view/template, however it requires a few variables to be defined on the view:
+
+- ``table_class`` –- the table class to use, e.g. ``SimpleTable``
+- ``table_data`` (or ``get_table_data()``) -- the data used to populate the
+ table
+- ``context_table_name`` -- the name of template variable containing the table
+ object
+
+.. __: https://docs.djangoproject.com/en/1.3/topics/class-based-views/
+
+For example:
+
+.. code-block:: python
+
+ from django_tables.views import SingleTableMixin
+ from django.generic.views.list import ListView
+
+
+ class Simple(models.Model):
+ first_name = models.CharField(max_length=200)
+ last_name = models.CharField(max_length=200)
+
+
+ class SimpleTable(tables.Table):
+ first_name = tables.Column()
+ last_name = tables.Column()
+
+
+ class MyTableView(SingleTableMixin, ListView):
+ model = Simple
+ table_class = SimpleTable
+
+
+The template could then be as simple as:
+
+.. code-block:: django
+
+ {% load django_tables %}
+ {% render_table table %}
+
+Such little code is possible due to the example above taking advantage of
+default values and ``SimpleTableMixin``'s eagarness at finding data sources
+when one isn't explicitly defined.
+
+.. note::
+
+ If you want more than one table on a page, at the moment the simplest way
+ to do it is to use ``SimpleTableMixin`` for one table, and write the
+ boilerplate for the other yourself in ``get_context_data()``. Obviously
+ this isn't particularly elegant, and as such will hopefully be resolved in
+ the future.
+
+
API Reference
=============
.. class:: Table.Meta
+ Provides a way to define *global* settings for table, as opposed to
+ defining them for each instance.
+
.. attribute:: attrs
Allows custom HTML attributes to be specified which will be added to
:meth:`~django_tables.tables.Table.as_html` or the
:ref:`template-tags.render_table` template tag.
- Default: ``{}``
+ :type: ``dict``
+ :default: ``{}``
- :type: :class:`dict`
+ This is typically used to enable a theme for a table (which is done by
+ adding a CSS class to the ``<table>`` element). i.e.::
- .. attribute:: sortable
+ class SimpleTable(tables.Table):
+ name = tables.Column()
- The default value for determining if a :class:`.Column` is sortable.
+ class Meta:
+ attrs = {"class": "paleblue"}
- If the ``Table`` and ``Column`` don't specify a value, a column's
- ``sortable`` value will fallback to this. object specify. This provides
- an easy mechanism to disable sorting on an entire table, without adding
- ``sortable=False`` to each ``Column`` in a ``Table``.
+ .. note::
+
+ This functionality is also available via the ``attrs`` keyword
+ argument to a table's constructor.
- Default: :const:`True`
+ .. attribute:: empty_text
- :type: :class:`bool`
+ Defines the text to display when the table has no rows.
+
+ :type: ``string``
+ :default: ``None``
+
+ If the table is empty and ``bool(empty_text)`` is ``True``, a row is
+ displayed containing ``empty_text``. This is allows a message such as
+ *There are currently no FOO.* to be displayed.
+
+ .. note::
+
+ This functionality is also available via the ``empty_text`` keyword
+ argument to a table's constructor.
+
+ .. attribute:: exclude
+
+ Defines which columns should be excluded from the table. This is useful
+ in subclasses to exclude columns in a parent.
+
+ :type: tuple of ``string`` objects
+ :default: ``()``
+
+ Example::
+
+ >>> class Person(tables.Table):
+ ... first_name = tables.Column()
+ ... last_name = tables.Column()
+ ...
+ >>> Person.base_columns
+ {'first_name': <django_tables.columns.Column object at 0x10046df10>,
+ 'last_name': <django_tables.columns.Column object at 0x10046d8d0>}
+ >>> class ForgetfulPerson(Person):
+ ... class Meta:
+ ... exclude = ("last_name", )
+ ...
+ >>> ForgetfulPerson.base_columns
+ {'first_name': <django_tables.columns.Column object at 0x10046df10>}
+
+ .. note::
+
+ This functionality is also available via the ``exclude`` keyword
+ argument to a table's constructor.
.. attribute:: order_by
- The default ordering. e.g. ``('name', '-age')``
+ The default ordering. e.g. ``('name', '-age')``. A hyphen ``-`` can be
+ used to prefix a column name to indicate *descending* order.
- Default: ``()``
+ :type: ``tuple``
+ :default: ``()``
- :type: :class:`tuple`
+ .. note::
+ This functionality is also available via the ``order_by`` keyword
+ argument to a table's constructor.
-:class:`TableData` Objects:
-------------------------------
+ .. attribute:: sequence
-.. autoclass:: django_tables.tables.TableData
- :members: __init__, order_by, __getitem__, __len__
+ The sequence of the table columns. This allows the default order of
+ columns (the order they were defined in the Table) to be overridden.
+
+ :type: any iterable (e.g. ``tuple`` or ``list``)
+ :default: ``()``
+
+ The special item ``"..."`` can be used as a placeholder that will be
+ replaced with all the columns that weren't explicitly listed. This
+ allows you to add columns to the front or back when using inheritence.
+
+ Example::
+
+ >>> class Person(tables.Table):
+ ... first_name = tables.Column()
+ ... last_name = tables.Column()
+ ...
+ ... class Meta:
+ ... sequence = ("last_name", "...")
+ ...
+ >>> Person.base_columns.keys()
+ ['last_name', 'first_name']
+ The ``"..."`` item can be used at most once in the sequence value. If
+ it's not used, every column *must* be explicitly included. e.g. in the
+ above example, ``sequence = ("last_name", )`` would be **invalid**
+ because neither ``"..."`` or ``"first_name"`` where included.
-:class:`TableOptions` Objects:
+ .. note::
+
+ This functionality is also available via the ``sequence`` keyword
+ argument to a table's constructor.
+
+ .. attribute:: sortable
+
+ Whether columns are by default sortable, or not. i.e. the fallback for
+ value for a column's sortable value.
+
+ :type: ``bool``
+ :default: ``True``
+
+ If the ``Table`` and ``Column`` don't specify a value, a column's
+ ``sortable`` value will fallback to this. object specify. This provides
+ an easy mechanism to disable sorting on an entire table, without adding
+ ``sortable=False`` to each ``Column`` in a ``Table``.
+
+ .. note::
+
+ This functionality is also available via the ``sortable`` keyword
+ argument to a table's constructor.
+
+
+:class:`TableData` Objects:
------------------------------
-.. autoclass:: django_tables.tables.TableOptions
- :members:
+.. autoclass:: django_tables.tables.TableData
+ :members: __init__, order_by, __getitem__, __len__
:class:`Column` Objects:
setup(
name='django-tables',
- version='0.4.3',
+ version='0.5.0',
description='Table framework for Django',
author='Bradley Ayers',
from django_attest import TransactionTestContext
from django.test.client import RequestFactory
from django.template import Context, Template
+from django.core.exceptions import ImproperlyConfigured
import django_tables as tables
from django_tables import utils, A
from .testapp.models import Person
Assert(SimpleTable([]).columns['name'].sortable) is True
+@general.test
+def sequence():
+ """
+ Ensures that the sequence of columns is configurable.
+ """
+ class TestTable(tables.Table):
+ a = tables.Column()
+ b = tables.Column()
+ c = tables.Column()
+ Assert(["a", "b", "c"]) == TestTable([]).columns.names()
+ Assert(["b", "a", "c"]) == TestTable([], sequence=("b", "a", "c")).columns.names()
+
+ class TestTable2(TestTable):
+ class Meta:
+ sequence = ("b", "a", "c")
+ Assert(["b", "a", "c"]) == TestTable2([]).columns.names()
+ Assert(["a", "b", "c"]) == TestTable2([], sequence=("a", "b", "c")).columns.names()
+
+ # BAD, all columns must be specified, or must use "..."
+ with Assert.raises(ValueError):
+ class TestTable3(TestTable):
+ class Meta:
+ sequence = ("a", )
+ with Assert.raises(ValueError):
+ TestTable([], sequence=("a", ))
+
+ # GOOD, using a single "..." allows you to only specify some columns. The
+ # remaining columns are ordered based on their definition order
+ class TestTable4(TestTable):
+ class Meta:
+ sequence = ("...", )
+ Assert(["a", "b", "c"]) == TestTable4([]).columns.names()
+ Assert(["a", "b", "c"]) == TestTable([], sequence=("...", )).columns.names()
+
+ class TestTable5(TestTable):
+ class Meta:
+ sequence = ("b", "...")
+ Assert(["b", "a", "c"]) == TestTable5([]).columns.names()
+ Assert(["b", "a", "c"]) == TestTable([], sequence=("b", "...")).columns.names()
+
+ class TestTable6(TestTable):
+ class Meta:
+ sequence = ("...", "b")
+ Assert(["a", "c", "b"]) == TestTable6([]).columns.names()
+ Assert(["a", "c", "b"]) == TestTable([], sequence=("...", "b")).columns.names()
+
+ class TestTable7(TestTable):
+ class Meta:
+ sequence = ("b", "...", "a")
+ Assert(["b", "c", "a"]) == TestTable7([]).columns.names()
+ Assert(["b", "c", "a"]) == TestTable([], sequence=("b", "...", "a")).columns.names()
+
+ # Let's test inheritence
+ class TestTable8(TestTable):
+ d = tables.Column()
+ e = tables.Column()
+ f = tables.Column()
+
+ class Meta:
+ sequence = ("d", "...")
+
+ class TestTable9(TestTable):
+ d = tables.Column()
+ e = tables.Column()
+ f = tables.Column()
+
+ Assert(["d", "a", "b", "c", "e", "f"]) == TestTable8([]).columns.names()
+ Assert(["d", "a", "b", "c", "e", "f"]) == TestTable9([], sequence=("d", "...")).columns.names()
+
+
linkcolumn = Tests()
linkcolumn.context(TransactionTestContext())
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()
- class UnsortedTable(tables.Table):
- i = tables.Column()
- alpha = tables.Column()
- beta = tables.Column()
- class SortedTable(UnsortedTable):
- class Meta:
- order_by = 'alpha'
+class SortedTable(UnsortedTable):
+ class Meta:
+ order_by = 'alpha'
- table = UnsortedTable(memory_data)
- yield Context
+MEMORY_DATA = [
+ {'i': 2, 'alpha': 'b', 'beta': 'b'},
+ {'i': 1, 'alpha': 'a', 'beta': 'c'},
+ {'i': 3, 'alpha': 'c', 'beta': 'a'},
+]
@core.test
-def declarations(context):
+def declarations():
"""Test defining tables by declaration."""
class GeoAreaTable(tables.Table):
name = tables.Column()
@core.test
-def datasource_untouched(context):
+def attrs():
+ class TestTable(tables.Table):
+ class Meta:
+ attrs = {}
+ Assert({}) == TestTable([]).attrs
+
+ class TestTable2(tables.Table):
+ class Meta:
+ attrs = {"a": "b"}
+ Assert({"a": "b"}) == TestTable2([]).attrs
+
+ class TestTable3(tables.Table):
+ pass
+ Assert({}) == TestTable3([]).attrs
+ Assert({"a": "b"}) == TestTable3([], attrs={"a": "b"}).attrs
+
+ class TestTable4(tables.Table):
+ class Meta:
+ attrs = {"a": "b"}
+ Assert({"c": "d"}) == TestTable4([], attrs={"c": "d"}).attrs
+
+
+@core.test
+def datasource_untouched():
"""Ensure that data that is provided to the table (the datasource) is not
modified by table operations.
"""
- original_data = copy.deepcopy(context.memory_data)
+ original_data = copy.deepcopy(MEMORY_DATA)
- table = context.UnsortedTable(context.memory_data)
+ table = UnsortedTable(MEMORY_DATA)
table.order_by = 'i'
list(table.rows)
- assert context.memory_data == Assert(original_data)
+ assert MEMORY_DATA == Assert(original_data)
- table = context.UnsortedTable(context.memory_data)
+ table = UnsortedTable(MEMORY_DATA)
table.order_by = 'beta'
list(table.rows)
- assert context.memory_data == Assert(original_data)
+ assert MEMORY_DATA == Assert(original_data)
@core.test
-def sorting(ctx):
+def sorting():
# fallback to Table.Meta
- Assert(('alpha', )) == ctx.SortedTable([], order_by=None).order_by == ctx.SortedTable([]).order_by
+ Assert(('alpha', )) == SortedTable([], order_by=None).order_by == SortedTable([]).order_by
# values of order_by are wrapped in tuples before being returned
- Assert(ctx.SortedTable([], order_by='alpha').order_by) == ('alpha', )
- Assert(ctx.SortedTable([], order_by=('beta',)).order_by) == ('beta', )
+ Assert(SortedTable([], order_by='alpha').order_by) == ('alpha', )
+ Assert(SortedTable([], order_by=('beta',)).order_by) == ('beta', )
# "no sorting"
- table = ctx.SortedTable([])
+ table = SortedTable([])
table.order_by = []
- Assert(()) == table.order_by == ctx.SortedTable([], order_by=[]).order_by
+ Assert(()) == table.order_by == SortedTable([], order_by=[]).order_by
- table = ctx.SortedTable([])
+ table = SortedTable([])
table.order_by = ()
- Assert(()) == table.order_by == ctx.SortedTable([], order_by=()).order_by
+ Assert(()) == table.order_by == SortedTable([], order_by=()).order_by
- table = ctx.SortedTable([])
+ table = SortedTable([])
table.order_by = ''
- Assert(()) == table.order_by == ctx.SortedTable([], order_by='').order_by
+ Assert(()) == table.order_by == SortedTable([], order_by='').order_by
# apply a sorting
- table = ctx.UnsortedTable([])
+ table = UnsortedTable([])
table.order_by = 'alpha'
- Assert(('alpha', )) == ctx.UnsortedTable([], order_by='alpha').order_by == table.order_by
+ Assert(('alpha', )) == UnsortedTable([], order_by='alpha').order_by == table.order_by
- table = ctx.SortedTable([])
+ table = SortedTable([])
table.order_by = 'alpha'
- Assert(('alpha', )) == ctx.SortedTable([], order_by='alpha').order_by == table.order_by
+ Assert(('alpha', )) == SortedTable([], order_by='alpha').order_by == table.order_by
# let's check the data
- table = ctx.SortedTable(ctx.memory_data, order_by='beta')
+ table = SortedTable(MEMORY_DATA, order_by='beta')
Assert(3) == table.rows[0]['i']
# allow fallback to Table.Meta.order_by
- table = ctx.SortedTable(ctx.memory_data)
+ table = SortedTable(MEMORY_DATA)
Assert(1) == table.rows[0]['i']
# column's can't be sorted if they're not allowed to be
@core.test
-def column_count(context):
+def column_count():
class SimpleTable(tables.Table):
visible = tables.Column(visible=True)
hidden = tables.Column(visible=False)
@core.test
-def column_accessor(context):
- class SimpleTable(context.UnsortedTable):
+def column_accessor():
+ class SimpleTable(UnsortedTable):
col1 = tables.Column(accessor='alpha.upper.isupper')
col2 = tables.Column(accessor='alpha.upper')
- table = SimpleTable(context.memory_data)
+ table = SimpleTable(MEMORY_DATA)
row = table.rows[0]
Assert(row['col1']) is True
Assert(row['col2']) == 'B'
+@core.test
+def exclude_columns():
+ """
+ Defining ``Table.Meta.exclude`` or providing an ``exclude`` argument when
+ instantiating a table should have the same effect -- exclude those columns
+ from the table. It should have the same effect as not defining the
+ columns originally.
+ """
+ # Table(..., exclude=...)
+ table = UnsortedTable([], exclude=("i"))
+ Assert([c.name for c in table.columns]) == ["alpha", "beta"]
+
+ # Table.Meta: exclude=...
+ class PartialTable(UnsortedTable):
+ class Meta:
+ exclude = ("alpha", )
+ table = PartialTable([])
+ Assert([c.name for c in table.columns]) == ["i", "beta"]
+
+ # Inheritence -- exclude in parent, add in child
+ class AddonTable(PartialTable):
+ added = tables.Column()
+ table = AddonTable([])
+ Assert([c.name for c in table.columns]) == ["i", "beta", "added"]
+
+ # Inheritence -- exclude in child
+ class ExcludeTable(UnsortedTable):
+ added = tables.Column()
+ class Meta:
+ exclude = ("alpha", )
+ table = ExcludeTable([])
+ Assert([c.name for c in table.columns]) == ["i", "beta", "added"]
+
+
@core.test
def pagination():
class BookTable(tables.Table):
# create some sample data
data = []
for i in range(100):
- data.append({'name': 'Book No. %d' % i})
+ data.append({"name": "Book No. %d" % i})
books = BookTable(data)
# external paginator
# integrated paginator
books.paginate(page=1)
- Assert(hasattr(books, 'page')) is True
+ Assert(hasattr(books, "page")) is True
books.paginate(page=1, per_page=10)
Assert(len(list(books.page.object_list))) == 10
Assert('Name') == table.columns['r1'].verbose_name
Assert('Name') == table.columns['r2'].verbose_name
Assert('OVERRIDE') == table.columns['r3'].verbose_name
+
+@models.test
+def column_mapped_to_nonexistant_field():
+ """
+ Issue #9 describes how if a Table has a column that has an accessor that
+ targets a non-existent field, a FieldDoesNotExist error is raised.
+ """
+ class FaultyPersonTable(PersonTable):
+ missing = tables.Column()
+
+ table = FaultyPersonTable(Person.objects.all())
+ table.as_html() # the bug would cause this to raise FieldDoesNotExist
# -*- 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.template import Template, Context, VariableDoesNotExist
from django.http import HttpRequest
+from django.conf import settings
import django_tables as tables
from attest import Tests, Assert
from xml.etree import ElementTree as ET
+
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
+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.')
+
+
+MEMORY_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}
+]
@templates.test
-def as_html(context):
- table = context.CountryTable(context.data)
- root = ET.fromstring(table.as_html())
+def as_html():
+ table = CountryTable(MEMORY_DATA)
+ root = ET.fromstring(table.as_html())
Assert(len(root.findall('.//thead/tr'))) == 1
Assert(len(root.findall('.//thead/tr/th'))) == 4
Assert(len(root.findall('.//tbody/tr'))) == 4
Assert(len(root.findall('.//tbody/tr/td'))) == 16
-
+
# no data with no empty_text
- table = context.CountryTable([])
+ table = CountryTable([])
root = ET.fromstring(table.as_html())
Assert(1) == len(root.findall('.//thead/tr'))
Assert(4) == len(root.findall('.//thead/tr/th'))
Assert(0) == len(root.findall('.//tbody/tr'))
-
+
# no data WITH empty_text
- table = context.CountryTable([], empty_text='this table is empty')
+ table = CountryTable([], empty_text='this table is empty')
root = ET.fromstring(table.as_html())
Assert(1) == len(root.findall('.//thead/tr'))
Assert(4) == len(root.findall('.//thead/tr/th'))
@templates.test
-def custom_rendering(context):
+def custom_rendering():
"""For good measure, render some actual templates."""
- countries = context.CountryTable(context.data)
+ countries = CountryTable(MEMORY_DATA)
context = Context({'countries': countries})
# automatic and manual column verbose names
@templates.test
-def templatetag(context):
+def templatetag():
# ensure it works with a multi-order-by
- table = context.CountryTable(context.data, order_by=('name', 'population'))
+ table = CountryTable(MEMORY_DATA, order_by=('name', 'population'))
t = Template('{% load django_tables %}{% render_table table %}')
html = t.render(Context({'request': HttpRequest(), 'table': table}))
-
- root = ET.fromstring(html)
+
+ root = ET.fromstring(html)
Assert(len(root.findall('.//thead/tr'))) == 1
Assert(len(root.findall('.//thead/tr/th'))) == 4
Assert(len(root.findall('.//tbody/tr'))) == 4
Assert(len(root.findall('.//tbody/tr/td'))) == 16
-
+
# no data with no empty_text
- table = context.CountryTable([])
+ table = CountryTable([])
t = Template('{% load django_tables %}{% render_table table %}')
html = t.render(Context({'request': HttpRequest(), 'table': table}))
- root = ET.fromstring(html)
+ root = ET.fromstring(html)
Assert(len(root.findall('.//thead/tr'))) == 1
Assert(len(root.findall('.//thead/tr/th'))) == 4
Assert(len(root.findall('.//tbody/tr'))) == 0
-
+
# no data WITH empty_text
- table = context.CountryTable([], empty_text='this table is empty')
+ table = CountryTable([], empty_text='this table is empty')
t = Template('{% load django_tables %}{% render_table table %}')
html = t.render(Context({'request': HttpRequest(), 'table': table}))
- root = ET.fromstring(html)
+ root = ET.fromstring(html)
Assert(len(root.findall('.//thead/tr'))) == 1
Assert(len(root.findall('.//thead/tr/th'))) == 4
Assert(len(root.findall('.//tbody/tr'))) == 1
Assert(len(root.findall('.//tbody/tr/td'))) == 1
Assert(int(root.find('.//tbody/tr/td').attrib['colspan'])) == len(root.findall('.//thead/tr/th'))
Assert(root.find('.//tbody/tr/td').text) == 'this table is empty'
-
-
-
+
+ # variable that doesn't exist (issue #8)
+ t = Template('{% load django_tables %}{% render_table this_doesnt_exist %}')
+ with Assert.raises(VariableDoesNotExist):
+ settings.DEBUG = True
+ t.render(Context())
+
+ # Should be silent with debug off
+ settings.DEBUG = False
+ t.render(Context())