import copy
from collections import OrderedDict
from itertools import count
from django.conf import settings
from django.core.paginator import Paginator
from django.db import models
from django.template.loader import get_template
from django.utils.encoding import force_str
from . import columns
from .config import RequestConfig
from .data import TableData
from .rows import BoundRows
from .utils import Accessor, AttributeDict, OrderBy, OrderByTuple, Sequence
class DeclarativeColumnsMetaclass(type):
"""
Metaclass that converts `.Column` objects defined on a class to the
dictionary `.Table.base_columns`, taking into account parent class
`base_columns` as well.
"""
def __new__(mcs, name, bases, attrs):
attrs["_meta"] = opts = TableOptions(attrs.get("Meta", None), name)
# extract declared columns
cols, remainder = [], {}
for attr_name, attr in attrs.items():
if isinstance(attr, columns.Column):
attr._explicit = True
cols.append((attr_name, attr))
else:
remainder[attr_name] = attr
attrs = remainder
cols.sort(key=lambda x: x[1].creation_counter)
# If this class is subclassing other tables, add their fields as
# well. Note that we loop over the bases in *reverse* - this is
# necessary to preserve the correct order of columns.
parent_columns = []
for base in reversed(bases):
if hasattr(base, "base_columns"):
parent_columns = list(base.base_columns.items()) + parent_columns
# Start with the parent columns
base_columns = OrderedDict(parent_columns)
# Possibly add some generated columns based on a model
if opts.model:
extra = OrderedDict()
# honor Table.Meta.fields, fallback to model._meta.fields
if opts.fields is not None:
# Each item in opts.fields is the name of a model field or a normal attribute on the model
for field_name in opts.fields:
extra[field_name] = columns.library.column_for_field(
field=Accessor(field_name).get_field(opts.model),
accessor=field_name,
linkify=opts.linkify.get(field_name),
)
else:
for field in opts.model._meta.fields:
extra[field.name] = columns.library.column_for_field(
field, linkify=opts.linkify.get(field.name), accessor=field.name
)
# update base_columns with extra columns
for key, column in extra.items():
# skip current column because the parent was explicitly defined,
# and the current column is not.
if key in base_columns and base_columns[key]._explicit is True:
continue
base_columns[key] = column
# Explicit columns override both parent and generated columns
base_columns.update(OrderedDict(cols))
# Apply any explicit exclude setting
for exclusion in opts.exclude:
if exclusion in base_columns:
base_columns.pop(exclusion)
# Remove any columns from our remainder, else columns from our parent class will remain
for attr_name in remainder:
if attr_name in base_columns:
base_columns.pop(attr_name)
# Set localize on columns
for col_name in base_columns.keys():
localize_column = None
if col_name in opts.localize:
localize_column = True
# unlocalize gets higher precedence
if col_name in opts.unlocalize:
localize_column = False
if localize_column is not None:
base_columns[col_name].localize = localize_column
attrs["base_columns"] = base_columns
return super().__new__(mcs, name, bases, attrs)
class TableOptions:
"""
Extracts and exposes options for a `.Table` from a `.Table.Meta`
when the table is defined. See `.Table` for documentation on the impact of
variables in this class.
Arguments:
options (`.Table.Meta`): options for a table from `.Table.Meta`
"""
def __init__(self, options, class_name):
super().__init__()
self._check_types(options, class_name)
DJANGO_TABLES2_TEMPLATE = getattr(
settings, "DJANGO_TABLES2_TEMPLATE", "django_tables2/table.html"
)
DJANGO_TABLES2_TABLE_ATTRS = getattr(settings, "DJANGO_TABLES2_TABLE_ATTRS", {})
self.attrs = getattr(options, "attrs", DJANGO_TABLES2_TABLE_ATTRS)
self.row_attrs = getattr(options, "row_attrs", {})
self.pinned_row_attrs = getattr(options, "pinned_row_attrs", {})
self.default = getattr(options, "default", "—")
self.empty_text = getattr(options, "empty_text", None)
self.fields = getattr(options, "fields", None)
linkify = getattr(options, "linkify", [])
if not isinstance(linkify, dict):
linkify = dict.fromkeys(linkify, True)
self.linkify = linkify
self.exclude = getattr(options, "exclude", ())
order_by = getattr(options, "order_by", None)
if isinstance(order_by, str):
order_by = (order_by,)
self.order_by = OrderByTuple(order_by) if order_by is not None else None
self.order_by_field = getattr(options, "order_by_field", "sort")
self.page_field = getattr(options, "page_field", "page")
self.per_page = getattr(options, "per_page", 25)
self.per_page_field = getattr(options, "per_page_field", "per_page")
self.prefix = getattr(options, "prefix", "")
self.show_header = getattr(options, "show_header", True)
self.show_footer = getattr(options, "show_footer", True)
self.sequence = getattr(options, "sequence", ())
self.orderable = getattr(options, "orderable", True)
self.model = getattr(options, "model", None)
self.template_name = getattr(options, "template_name", DJANGO_TABLES2_TEMPLATE)
self.localize = getattr(options, "localize", ())
self.unlocalize = getattr(options, "unlocalize", ())
def _check_types(self, options, class_name):
"""
Check class Meta attributes to prevent common mistakes.
"""
if options is None:
return
checks = {
(bool,): ["show_header", "show_footer", "orderable"],
(int,): ["per_page"],
(tuple, list, set): ["fields", "sequence", "exclude", "localize", "unlocalize"],
(tuple, list, set, dict): ["linkify"],
str: ["template_name", "prefix", "order_by_field", "page_field", "per_page_field"],
(dict,): ["attrs", "row_attrs", "pinned_row_attrs"],
(tuple, list, str): ["order_by"],
(type(models.Model),): ["model"],
}
for types, keys in checks.items():
for key in keys:
value = getattr(options, key, None)
if value is not None and not isinstance(value, types):
expression = "{}.{} = {}".format(class_name, key, value.__repr__())
raise TypeError(
"{} (type {}), but type must be one of ({})".format(
expression, type(value).__name__, ", ".join([t.__name__ for t in types])
)
)
[docs]class Table(metaclass=DeclarativeColumnsMetaclass):
"""
A representation of a table.
Arguments:
data (QuerySet, list of dicts): The data to display.
This is a required variable, a `TypeError` will be raised if it's not passed.
order_by: (tuple or str): The default ordering tuple or comma separated str.
A hyphen `-` can be used to prefix a column name to indicate
*descending* order, for example: `("name", "-age")` or `name,-age`.
orderable (bool): Enable/disable column ordering on this table
empty_text (str): Empty text to render when the table has no data.
(default `.Table.Meta.empty_text`)
exclude (iterable or str): The names of columns that should not be
included in the table.
attrs (dict): HTML attributes to add to the ``<table>`` tag.
When accessing the attribute, the value is always returned as an
`.AttributeDict` to allow easily conversion to HTML.
row_attrs (dict): Add custom html attributes to the table rows.
Allows custom HTML attributes to be specified which will be added
to the ``<tr>`` tag of the rendered table.
pinned_row_attrs (dict): Same as row_attrs but for pinned rows.
sequence (iterable): The sequence/order of columns the columns (from
left to right).
Items in the sequence must be :term:`column names <column name>`, or
`"..."` (string containing three periods). `'...'` can be used as a
catch-all for columns that are not specified.
prefix (str): A prefix for query string fields.
To avoid name-clashes when using multiple tables on single page.
order_by_field (str): If not `None`, defines the name of the *order by*
query string field in the URL.
page_field (str): If not `None`, defines the name of the *current page*
query string field.
per_page_field (str): If not `None`, defines the name of the *per page*
query string field.
template_name (str): The template to render when using ``{% render_table %}``
(defaults to DJANGO_TABLES2_TEMPLATE, which is ``"django_tables2/table.html"``
by default).
default (str): Text to render in empty cells (determined by
`.Column.empty_values`, default `.Table.Meta.default`)
request: Django's request to avoid using `RequestConfig`
show_header (bool): If `False`, the table will not have a header
(`<thead>`), defaults to `True`
show_footer (bool): If `False`, the table footer will not be rendered,
even if some columns have a footer, defaults to `True`.
extra_columns (str, `.Column`): list of `(name, column)`-tuples containing
extra columns to add to the instance. If `column` is `None`, the column
with `name` will be removed from the table.
"""
def __init__(
self,
data=None,
order_by=None,
orderable=None,
empty_text=None,
exclude=None,
attrs=None,
row_attrs=None,
pinned_row_attrs=None,
sequence=None,
prefix=None,
order_by_field=None,
page_field=None,
per_page_field=None,
template_name=None,
default=None,
request=None,
show_header=None,
show_footer=True,
extra_columns=None,
):
super().__init__()
# note that although data is a keyword argument, it used to be positional
# so it is assumed to be the first argument to this method.
if data is None:
raise TypeError("Argument data to {} is required".format(type(self).__name__))
self.exclude = exclude or self._meta.exclude
self.sequence = sequence
self.data = TableData.from_data(data=data)
self.data.set_table(self)
if default is None:
default = self._meta.default
self.default = default
# Pinned rows #406
self.pinned_row_attrs = AttributeDict(pinned_row_attrs or self._meta.pinned_row_attrs)
self.pinned_data = {
"top": self.get_top_pinned_data(),
"bottom": self.get_bottom_pinned_data(),
}
self.rows = BoundRows(data=self.data, table=self, pinned_data=self.pinned_data)
self.attrs = AttributeDict(attrs if attrs is not None else self._meta.attrs)
for tag in ["thead", "tbody", "tfoot"]:
# Add these attrs even if they haven't been passed so we can safely refer to them in the templates
self.attrs[tag] = AttributeDict(self.attrs.get(tag, {}))
self.row_attrs = AttributeDict(row_attrs or self._meta.row_attrs)
self.empty_text = empty_text if empty_text is not None else self._meta.empty_text
self.orderable = orderable
self.prefix = prefix
self.order_by_field = order_by_field
self.page_field = page_field
self.per_page_field = per_page_field
self.show_header = show_header
self.show_footer = show_footer
# 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.
base_columns = copy.deepcopy(type(self).base_columns)
if extra_columns is not None:
for name, column in extra_columns:
if column is None and name in base_columns:
del base_columns[name]
else:
base_columns[name] = column
# Keep fully expanded ``sequence`` at _sequence so it's easily accessible
# during render. The priority is as follows:
# 1. sequence passed in as an argument
# 2. sequence declared in ``Meta``
# 3. sequence defaults to '...'
if sequence is not None:
sequence = sequence
elif self._meta.sequence:
sequence = self._meta.sequence
else:
if self._meta.fields is not None:
sequence = tuple(self._meta.fields) + ("...",)
else:
sequence = ("...",)
sequence = Sequence(sequence)
self._sequence = sequence.expand(base_columns.keys())
# reorder columns based on sequence.
base_columns = OrderedDict(((x, base_columns[x]) for x in sequence if x in base_columns))
self.columns = columns.BoundColumns(self, base_columns)
# `None` value for order_by means no order is specified. This means we
# `shouldn't touch our data's ordering in any way. *However*
# `table.order_by = None` means "remove any ordering from the data"
# (it's equivalent to `table.order_by = ()`).
if order_by is None and self._meta.order_by is not None:
order_by = self._meta.order_by
if order_by is None:
self._order_by = None
# If possible inspect the ordering on the data we were given and
# update the table to reflect that.
order_by = self.data.ordering
if order_by is not None:
self.order_by = order_by
else:
self.order_by = order_by
self.template_name = template_name
# If a request is passed, configure for request
if request:
RequestConfig(request).configure(self)
self._counter = count()
[docs] def get_top_pinned_data(self):
"""
Return data for top pinned rows containing data for each row.
Iterable type like: QuerySet, list of dicts, list of objects.
Having a non-zero number of pinned rows
will not result in an empty result set message being rendered,
even if there are no regular data rows
Returns:
`None` (default) no pinned rows at the top, iterable, data for pinned rows at the top.
Note:
To show pinned row this method should be overridden.
Example:
>>> class TableWithTopPinnedRows(Table):
... def get_top_pinned_data(self):
... return [{
... "column_a" : "some value",
... "column_c" : "other value",
... }]
"""
return None
[docs] def get_bottom_pinned_data(self):
"""
Return data for bottom pinned rows containing data for each row.
Iterable type like: QuerySet, list of dicts, list of objects.
Having a non-zero number of pinned rows
will not result in an empty result set message being rendered,
even if there are no regular data rows
Returns:
`None` (default) no pinned rows at the bottom, iterable, data for pinned rows at the bottom.
Note:
To show pinned row this method should be overridden.
Example:
>>> class TableWithBottomPinnedRows(Table):
... def get_bottom_pinned_data(self):
... return [{
... "column_a" : "some value",
... "column_c" : "other value",
... }]
"""
return None
[docs] def before_render(self, request):
"""
A way to hook into the moment just before rendering the template.
Can be used to hide a column.
Arguments:
request: contains the `WGSIRequest` instance, containing a `user` attribute if
`.django.contrib.auth.middleware.AuthenticationMiddleware` is added to
your `MIDDLEWARE_CLASSES`.
Example::
class Table(tables.Table):
name = tables.Column(orderable=False)
country = tables.Column(orderable=False)
def before_render(self, request):
if request.user.has_perm('foo.delete_bar'):
self.columns.hide('country')
else:
self.columns.show('country')
"""
return
[docs] def as_html(self, request):
"""
Render the table to an HTML table, adding `request` to the context.
"""
# reset counter for new rendering
self._counter = count()
template = get_template(self.template_name)
context = {"table": self, "request": request}
self.before_render(request)
return template.render(context)
[docs] def as_values(self, exclude_columns=None):
"""
Return a row iterator of the data which would be shown in the table where
the first row is the table headers.
arguments:
exclude_columns (iterable): columns to exclude in the data iterator.
This can be used to output the table data as CSV, excel, for example using the
`~.export.ExportMixin`.
If a column is defined using a :ref:`table.render_FOO`, the returned value from
that method is used. If you want to differentiate between the rendered cell
and a value, use a `value_Foo`-method::
class Table(tables.Table):
name = tables.Column()
def render_name(self, value):
return format_html('<span class="name">{}</span>', value)
def value_name(self, value):
return value
will have a value wrapped in `<span>` in the rendered HTML, and just returns
the value when `as_values()` is called.
Note that any invisible columns will be part of the row iterator.
"""
if exclude_columns is None:
exclude_columns = ()
columns = [
column
for column in self.columns.iterall()
if not (column.column.exclude_from_export or column.name in exclude_columns)
]
yield [force_str(column.header, strings_only=True) for column in columns]
for row in self.rows:
yield [
force_str(row.get_cell_value(column.name), strings_only=True) for column in columns
]
def has_footer(self):
"""
Returns True if any of the columns define a ``_footer`` attribute or a
``render_footer()`` method
"""
return self.show_footer and any(column.has_footer() for column in self.columns)
@property
def show_header(self):
return self._show_header if self._show_header is not None else self._meta.show_header
@show_header.setter
def show_header(self, value):
self._show_header = value
@property
def order_by(self):
return self._order_by
@order_by.setter
def order_by(self, value):
"""
Order the rows of the table based on columns.
Arguments:
value: iterable or comma separated string of order by aliases.
"""
# collapse empty values to ()
order_by = () if not value else value
# accept string
order_by = order_by.split(",") if isinstance(order_by, str) else order_by
valid = []
# everything's been converted to a iterable, accept iterable!
for alias in order_by:
name = OrderBy(alias).bare
if name in self.columns and self.columns[name].orderable:
valid.append(alias)
self._order_by = OrderByTuple(valid)
self.data.order_by(self._order_by)
@property
def order_by_field(self):
return (
self._order_by_field if self._order_by_field is not None else self._meta.order_by_field
)
@order_by_field.setter
def order_by_field(self, value):
self._order_by_field = value
@property
def page_field(self):
return self._page_field if self._page_field is not None else self._meta.page_field
@page_field.setter
def page_field(self, value):
self._page_field = value
[docs] def paginate(self, paginator_class=Paginator, per_page=None, page=1, *args, **kwargs):
"""
Paginates the table using a paginator and creates a ``page`` property
containing information for the current page.
Arguments:
paginator_class (`~django.core.paginator.Paginator`): A paginator class to
paginate the results.
per_page (int): Number of records to display on each page.
page (int): Page to display.
Extra arguments are passed to the paginator.
Pagination exceptions (`~django.core.paginator.EmptyPage` and
`~django.core.paginator.PageNotAnInteger`) may be raised from this
method and should be handled by the caller.
"""
per_page = per_page or self._meta.per_page
self.paginator = paginator_class(self.rows, per_page, *args, **kwargs)
self.page = self.paginator.page(page)
return self
@property
def per_page_field(self):
return (
self._per_page_field if self._per_page_field is not None else self._meta.per_page_field
)
@per_page_field.setter
def per_page_field(self, value):
self._per_page_field = value
@property
def prefix(self):
return self._prefix if self._prefix is not None else self._meta.prefix
@prefix.setter
def prefix(self, value):
self._prefix = value
@property
def prefixed_order_by_field(self):
return "%s%s" % (self.prefix, self.order_by_field)
@property
def prefixed_page_field(self):
return "%s%s" % (self.prefix, self.page_field)
@property
def prefixed_per_page_field(self):
return "%s%s" % (self.prefix, self.per_page_field)
@property
def sequence(self):
return self._sequence
@sequence.setter
def sequence(self, value):
if value:
value = Sequence(value)
value.expand(self.base_columns.keys())
self._sequence = value
@property
def orderable(self):
if self._orderable is not None:
return self._orderable
else:
return self._meta.orderable
@orderable.setter
def orderable(self, value):
self._orderable = value
@property
def template_name(self):
if self._template is not None:
return self._template
else:
return self._meta.template_name
@template_name.setter
def template_name(self, value):
self._template = value
@property
def paginated_rows(self):
"""
Return the rows for the current page if the table is paginated, else all rows.
"""
if hasattr(self, "page"):
return self.page.object_list
return self.rows
[docs] def get_column_class_names(self, classes_set, bound_column):
"""
Returns a set of HTML class names for cells (both ``td`` and ``th``) of a
**bound column** in this table.
By default this returns the column class names defined in the table's
attributes.
This method can be overridden to change the default behavior, for
example to simply `return classes_set`.
Arguments:
classes_set(set of string): a set of class names to be added
to the cell, retrieved from the column's attributes. In the case
of a header cell (th), this also includes ordering classes.
To set the classes for a column, see `.Column`.
To configure ordering classes, see :ref:`ordering-class-name`
bound_column(`.BoundColumn`): the bound column the class names are
determined for. Useful for accessing `bound_column.name`.
Returns:
A set of class names to be added to cells of this column
If you want to add the column names to the list of classes for a column,
override this method in your custom table::
class MyTable(tables.Table):
...
def get_column_class_names(self, classes_set, bound_column):
classes_set = super().get_column_class_names(classes_set, bound_column)
classes_set.add(bound_column.name)
return classes_set
"""
return classes_set
def table_factory(model, table=Table, fields=None, exclude=None, localize=None):
"""
Return Table class for given `model`, equivalent to defining a custom table class::
class MyTable(tables.Table):
class Meta:
model = model
Arguments:
model (`~django.db.models.Model`): Model associated with the new table
table (`.Table`): Base Table class used to create the new one
fields (list of str): Fields displayed in tables
exclude (list of str): Fields exclude in tables
localize (list of str): Fields to localize
"""
attrs = {"model": model}
if fields is not None:
attrs["fields"] = fields
if exclude is not None:
attrs["exclude"] = exclude
if localize is not None:
attrs["localize"] = localize
# If parent form class already has an inner Meta, the Meta we're
# creating needs to inherit from the parent's inner meta.
parent = (table.Meta, object) if hasattr(table, "Meta") else (object,)
Meta = type("Meta", parent, attrs)
# Give this new table class a reasonable name.
class_name = model.__name__ + "AutogeneratedTable"
# Class attributes for the new table class.
table_class_attrs = {"Meta": Meta}
return type(table)(class_name, (table,), table_class_attrs)