Skip to content

Table Definition

The table definition holds the configuration for a table, including its columns, data source, and other options.

Below you can check the available attributes of the TableDefinition class and their descriptions. Also, a full code is provided at the end of this document for reference.

Warning

Only one table can be rendered per page. The frontend controls (filters, columns, refresh, fullscreen, etc.) are wired up by global element id, so a second table on the same page would fight the first one over the same controls.

Table definition.

ATTRIBUTE DESCRIPTION
name

Unique identifier for the table.

TYPE: str

data_source

Data source for the table. Mix in :class:~ckanext.tables.cache.CachedDataSourceMixin on the data source to enable caching; the TTL and backend are configured there.

TYPE: BaseDataSource

ajax_url

(Optional) URL to fetch data from. Defaults to an auto-generated URL.

TYPE: str | None

columns

(Optional) List of ColumnDefinition objects.

TYPE: list[ColumnDefinition]

row_actions

(Optional) List of RowActionDefinition objects.

TYPE: list[RowActionDefinition]

bulk_actions

(Optional) List of BulkActionDefinition objects for action on multiple rows.

TYPE: list[BulkActionDefinition]

table_actions

(Optional) List of TableActionDefinition objects for actions on the table itself.

TYPE: list[TableActionDefinition]

exporters

(Optional) List of exporter classes for exporting table data.

TYPE: list[type[ExporterBase]]

placeholder

(Optional) Placeholder text for an empty table.

TYPE: str | None

page_size

(Optional) Number of rows per page. Defaults to 10.

TYPE: int

table_template

(Optional) Template to render the table. Defaults to tables/base.html.

TYPE: str

table_layout

(Optional) Layout for the table. Defaults to fitColumns.

TYPE: str

Source code in ckanext/tables/table.py
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
@dataclass
class TableDefinition:
    """Table definition.

    Attributes:
        name: Unique identifier for the table.
        data_source: Data source for the table. Mix in
            :class:`~ckanext.tables.cache.CachedDataSourceMixin` on the data
            source to enable caching; the TTL and backend are configured there.
        ajax_url: (Optional) URL to fetch data from. Defaults to an auto-generated URL.
        columns: (Optional) List of ColumnDefinition objects.
        row_actions: (Optional) List of RowActionDefinition objects.
        bulk_actions: (Optional) List of BulkActionDefinition objects for action on multiple rows.
        table_actions: (Optional) List of TableActionDefinition objects for actions on the table itself.
        exporters: (Optional) List of exporter classes for exporting table data.
        placeholder: (Optional) Placeholder text for an empty table.
        page_size: (Optional) Number of rows per page. Defaults to 10.
        table_template: (Optional) Template to render the table. Defaults to `tables/base.html`.
        table_layout: (Optional) Layout for the table. Defaults to `fitColumns`.
    """

    name: str
    data_source: BaseDataSource
    ajax_url: str | None = None
    columns: list[ColumnDefinition] = dataclass_field(default_factory=list)
    row_actions: list[RowActionDefinition] = dataclass_field(default_factory=list)
    bulk_actions: list[BulkActionDefinition] = dataclass_field(default_factory=list)
    table_actions: list[TableActionDefinition] = dataclass_field(default_factory=list)
    exporters: list[type[ExporterBase]] = dataclass_field(default_factory=list)
    placeholder: str | None = None
    page_size: int = 10
    table_template: str = "tables/base.html"
    table_layout: str = "fitColumns"

    def __post_init__(self):
        if isinstance(self.data_source, CachedDataSourceMixin):
            self._cache = self.data_source.cache_backend
            self._cache_key = self.data_source.get_cache_key()
            self._cache_ttl = self.data_source.cache_ttl
        else:
            self._cache = None
            self._cache_key = ""
            self._cache_ttl = 0

        self.id = f"table_{self.name}_{uuid.uuid4().hex[:8]}"

        # Scratch space formatters may use to memoise per-render work (e.g. a
        # lookup keyed by a value that recurs across rows on the same page) —
        # lives and dies with this TableDefinition instance, so it never
        # outlives the request that created it.
        self._formatter_cache: dict[Any, Any] = {}

        if self.placeholder is None:
            self.placeholder = tk._("No data found")

        if self.row_actions:
            self.columns.append(
                ColumnDefinition(
                    field=COLUMN_ACTIONS_FIELD,
                    title="",
                    formatters=[(formatters.ActionsFormatter, {})],
                    filterable=False,
                    tabulator_formatter="html",
                    sortable=False,
                    resizable=False,
                    width=50,
                ),
            )

    def get_tabulator_config(self) -> dict[str, Any]:
        columns = [col.to_dict() for col in self.columns]

        options: dict[str, Any] = {
            "columns": columns,
            "placeholder": self.placeholder,
            "sortMode": "remote",
            "layout": self.table_layout,
            "pagination": True,
            "paginationMode": "remote",
            "paginationSize": self.page_size,
            "paginationSizeSelector": [5, 10, 25, 50, 100],
            "minHeight": 300,
            "filterMode": "remote",
        }

        if self.ajax_url:
            options["ajaxURL"] = self.ajax_url

        if bool(self.bulk_actions):
            options.update(
                {
                    "rowHeader": {
                        "headerSort": False,
                        "resizable": False,
                        "headerHozAlign": "center",
                        "hozAlign": "center",
                        "vertAlign": "middle",
                        "formatter": "rowSelection",
                        "titleFormatter": "rowSelection",
                        "width": 48,
                        "height": 48,
                    }
                }
            )

        return options

    def get_row_actions(self) -> dict[str, dict[str, Any]]:
        return {
            action.action: {
                "name": action.action,
                "label": action.label,
                "icon": action.icon,
                "attrs": action.attrs,
                "with_confirmation": action.with_confirmation,
            }
            for action in self.row_actions
        }

    def render_table(self, **kwargs: Any) -> str:
        return tk.render(self.table_template, extra_vars={"table": self, **kwargs})

    def get_data(self, params: types.QueryParams) -> list[Any]:
        return [self._apply_formatters(row) for row in self.get_raw_data(params)]

    def get_raw_data(self, params: types.QueryParams, paginate: bool = True) -> list[dict[str, Any]]:
        if not paginate:
            return self.data_source.filter(params.filters).sort(params.sort_by, params.sort_order).all()

        return (
            self.data_source.filter(params.filters)
            .sort(params.sort_by, params.sort_order)
            .paginate(params.page, params.size)
            .all()
        )

    def get_total_count(self, params: types.QueryParams) -> int:
        cached = self._get_cached_count(params)

        if cached is not None:
            return cached

        # for total count we only apply filter, without sort and pagination
        count = self.data_source.filter(params.filters).count()

        self._set_cached_count(params, count)

        return count

    def _generation(self) -> str:
        """Return the current cache generation, bumped by refresh_data() to orphan old counts."""
        if self._cache is None:
            return "0"

        generation = self._cache.get(f"{self._cache_key}:gen")
        return generation if isinstance(generation, str) else "0"

    def _count_cache_key(self, params: types.QueryParams) -> str:
        """Return the cache sub-key for a given set of filters (count ignores page/size/sort)."""
        generation = self._generation()

        if not params.filters:
            return f"{self._cache_key}:count:{generation}"

        filters_key = "|".join(f"{f.field}:{f.operator}:{f.value}" for f in params.filters)
        return f"{self._cache_key}:count:{generation}:{filters_key}"

    def _get_cached_count(self, params: types.QueryParams) -> int | None:
        if self._cache is None:
            return None

        result = self._cache.get(self._count_cache_key(params))

        if result is not None:
            return int(result)

        return None

    def _set_cached_count(self, params: types.QueryParams, count: int) -> None:
        if self._cache is None:
            return

        self._cache.set(self._count_cache_key(params), count, self._cache_ttl)

    def _apply_formatters(self, row: dict[str, Any]) -> dict[str, Any]:
        """Apply formatters to each cell in a row.

        Always returns a fresh top-level dict, independent of *row* —
        ``ListDataSource`` in particular returns its rows as direct
        references to its own storage, not copies, so the result must not
        alias back to it. Formatters never mutate nested values, only
        replace a whole cell's value via ``formatted_row[column.field] =
        ...``, so a single shallow copy is enough — no need for the deep
        copy this used to make on every single row, including rows in
        tables with no formatters at all.
        """
        formatted_row = dict(row)

        if not any(column.formatters for column in self.columns):
            return formatted_row

        for column in self.columns:
            if not column.formatters:
                continue

            cell_value = row.get(column.field)

            for formatter_class, formatter_options in column.formatters:
                cell_value = formatter_class(column, formatted_row, row, self).format(cell_value, formatter_options)

            formatted_row[column.field] = cell_value

        return formatted_row

    @classmethod
    def check_access(cls, context: Context) -> None:
        """Check if the current user has access to view the table.

        This class method can be overridden in subclasses to implement
        custom access control logic.

        By default, it checks if the user has the `sysadmin` permission,
        which means that the table is available only to system administrators.

        Raises:
            tk.NotAuthorized: If the user does not have an access
        """
        tk.check_access("sysadmin", context)

    def get_bulk_action(self, action: str) -> BulkActionDefinition | None:
        return next((a for a in self.bulk_actions if a.action == action), None)

    def get_table_action(self, action: str) -> TableActionDefinition | None:
        return next((a for a in self.table_actions if a.action == action), None)

    def get_row_action(self, action: str) -> RowActionDefinition | None:
        return next((a for a in self.row_actions if a.action == action), None)

    def get_exporter(self, name: str) -> type[ExporterBase] | None:
        return next((e for e in self.exporters if e.name == name), None)

    def get_formatter_cache(self, namespace: str) -> dict[Any, Any]:
        """Return this table's per-render formatter scratch space for *namespace*."""
        return self._formatter_cache.setdefault(namespace, {})

    def refresh_data(self) -> None:
        if isinstance(self.data_source, CachedDataSourceMixin):
            self.data_source.invalidate()

check_access(context) classmethod

Check if the current user has access to view the table.

This class method can be overridden in subclasses to implement custom access control logic.

By default, it checks if the user has the sysadmin permission, which means that the table is available only to system administrators.

RAISES DESCRIPTION
NotAuthorized

If the user does not have an access

Source code in ckanext/tables/table.py
235
236
237
238
239
240
241
242
243
244
245
246
247
248
@classmethod
def check_access(cls, context: Context) -> None:
    """Check if the current user has access to view the table.

    This class method can be overridden in subclasses to implement
    custom access control logic.

    By default, it checks if the user has the `sysadmin` permission,
    which means that the table is available only to system administrators.

    Raises:
        tk.NotAuthorized: If the user does not have an access
    """
    tk.check_access("sysadmin", context)

get_formatter_cache(namespace)

Return this table's per-render formatter scratch space for namespace.

Source code in ckanext/tables/table.py
262
263
264
def get_formatter_cache(self, namespace: str) -> dict[Any, Any]:
    """Return this table's per-render formatter scratch space for *namespace*."""
    return self._formatter_cache.setdefault(namespace, {})