Source code for django_tables2.tables

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 = f"{class_name}.{key} = {value.__repr__()}"

                    allowed = ", ".join([t.__name__ for t in types])
                    raise TypeError(
                        f"{expression} (type {type(value).__name__}), but type must be one of ({allowed})"
                    )


[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(f"Argument data to {type(self).__name__} is required") 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 f"{self.prefix}{self.order_by_field}" @property def prefixed_page_field(self): return f"{self.prefix}{self.page_field}" @property def prefixed_per_page_field(self): return f"{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)