Merge branch 'development' v0.5.0
authorBradley Ayers <bradley.ayers@enigmainteractive.com>
Tue, 7 Jun 2011 03:16:40 +0000 (13:16 +1000)
committerBradley Ayers <bradley.ayers@enigmainteractive.com>
Tue, 7 Jun 2011 03:16:40 +0000 (13:16 +1000)
django_tables/columns.py
django_tables/tables.py
django_tables/templatetags/django_tables.py
django_tables/views.py [new file with mode: 0644]
docs/conf.py
docs/index.rst
setup.py
tests/columns.py
tests/core.py
tests/models.py
tests/templates.py

index 23734dc57a42454c43190c72b46a558e948c378b..e3626d2a6b74f8906c970340c0c9601f0e259875 100644 (file)
@@ -5,11 +5,14 @@ from django.utils.datastructures import SortedDict
 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.
@@ -43,7 +46,6 @@ class Column(object):
     :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
@@ -73,7 +75,6 @@ class Column(object):
 
         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
 
@@ -92,7 +93,6 @@ class Column(object):
             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
 
@@ -137,7 +137,6 @@ class CheckBoxColumn(Column):
     :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)
@@ -209,7 +208,6 @@ class LinkColumn(Column):
         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)
@@ -283,7 +281,6 @@ class TemplateColumn(Column):
         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)
@@ -325,7 +322,6 @@ class BoundColumn(object):
 
         ``age`` is the name.
     """
-
     def __init__(self, table, column, name):
         self._table = table
         self._column = column
@@ -339,31 +335,35 @@ class BoundColumn(object):
         """
         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
@@ -371,7 +371,6 @@ class BoundColumn(object):
         """
         If this column is sorted, return the associated :class:`.OrderBy`
         instance, otherwise ``None``.
-
         """
         try:
             return self.table.order_by[self.name]
@@ -380,14 +379,18 @@ class BoundColumn(object):
 
     @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
@@ -413,8 +416,12 @@ class BoundColumn(object):
         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
@@ -427,24 +434,23 @@ class BoundColumn(object):
     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
@@ -453,7 +459,6 @@ class BoundColumns(object):
     :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
@@ -466,7 +471,6 @@ class BoundColumns(object):
         (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():
@@ -476,27 +480,37 @@ class BoundColumns(object):
                 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.
@@ -505,51 +519,54 @@ class BoundColumns(object):
         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
 
@@ -557,7 +574,6 @@ class BoundColumns(object):
 
             columns['speed']  # returns a bound column with name 'speed'
             columns[0]        # returns the first column
-
         """
         self._spawn_columns()
         if isinstance(index, int):
index f3ba21e9ab59d2bb0ea6e9cccf913d6ff95082e1..5f479dafa1e5c840263ae9294ea3aaa4e85fa44d 100644 (file)
@@ -6,6 +6,8 @@ from django.http import Http404
 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
@@ -14,17 +16,64 @@ 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):
@@ -92,11 +141,11 @@ class DeclarativeColumnsMetaclass(type):
     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))
 
@@ -104,22 +153,25 @@ class DeclarativeColumnsMetaclass(type):
         # 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)
 
 
@@ -127,23 +179,22 @@ class TableOptions(object):
     """
     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):
@@ -186,21 +237,28 @@ 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()
@@ -234,6 +292,18 @@ class Table(StrAndUnicode):
         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
@@ -280,7 +350,11 @@ class Table(StrAndUnicode):
 
         :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)
index b455eaee0dd0ca08629a9ef9d2b869e582f3888c..1166a3de3ccf68dc56c745b3071c5ffa73bd7339 100644 (file)
@@ -14,6 +14,7 @@ Examples:
 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
@@ -89,18 +90,26 @@ class RenderTableNode(template.Node):
         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
diff --git a/django_tables/views.py b/django_tables/views.py
new file mode 100644 (file)
index 0000000..886ffe3
--- /dev/null
@@ -0,0 +1,81 @@
+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.
+    """
index e83fc4ff44fe53e673a80218ca9c396843b05180..6cc411cd9283e1857771c1bcd695a0df7cea6391 100644 (file)
 # 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.
@@ -50,9 +54,9 @@ project = u'django-tables'
 # 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.
index 3abe9a5402e8c1d397911a635632c4761991f923..c40d661dedd3843f0406697878718bfd0d548545 100644 (file)
@@ -175,9 +175,15 @@ Any iterable can be used as table data, and there's builtin support for
 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
 
@@ -615,6 +621,65 @@ which can be iterated over:
     </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
 =============
 
@@ -636,6 +701,9 @@ 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
@@ -643,44 +711,139 @@ API Reference
         :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:
index d6f4170d64aa9b29cc71b150a688969a174e76ac..c4453b72158ae707a8c6d8cea81c725dd52d5e6a 100755 (executable)
--- a/setup.py
+++ b/setup.py
@@ -4,7 +4,7 @@ from setuptools import setup, find_packages
 
 setup(
     name='django-tables',
-    version='0.4.3',
+    version='0.5.0',
     description='Table framework for Django',
 
     author='Bradley Ayers',
index 939a588d11a85388fd9b4771e1b256d9dae901a1..2af414266b33ca1f262a311461d32b146a4e02f0 100644 (file)
@@ -4,6 +4,7 @@ from attest import Tests, Assert
 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
@@ -32,6 +33,76 @@ def sortable():
     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())
 
index b329b077c0c5e431c601d3d4acb8da425b9ab21c..24f5b3aeedbf0296aeb5ae26b17d95f2f663d628 100644 (file)
@@ -6,34 +6,30 @@ 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()
 
-        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()
@@ -61,60 +57,83 @@ def declarations(context):
 
 
 @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
@@ -147,7 +166,7 @@ def sorting(ctx):
 
 
 @core.test
-def column_count(context):
+def column_count():
     class SimpleTable(tables.Table):
         visible = tables.Column(visible=True)
         hidden = tables.Column(visible=False)
@@ -157,16 +176,50 @@ def column_count(context):
 
 
 @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):
@@ -175,7 +228,7 @@ def pagination():
     # 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
@@ -187,7 +240,7 @@ def pagination():
 
     # 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
index aed6251bd680be8820642e4791f1393f8c27681f..a6f349315fde5e5150a714b0705d59e4e86ae563 100644 (file)
@@ -79,3 +79,15 @@ def verbose_name():
     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
index 2a5f482d6ae9fb8bd05e4eb8d728d2edba318e58..6831919f33ad0c68a05f04a37bfac4e71156202e 100644 (file)
@@ -1,63 +1,54 @@
 # -*- 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'))
@@ -68,9 +59,9 @@ def as_html(context):
 
 
 @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
@@ -89,38 +80,45 @@ def custom_rendering(context):
 
 
 @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())