from __future__ import annotations
import logging
import re
from functools import lru_cache
from typing import TYPE_CHECKING, Any, Final, NoReturn
from django.db.models import Model, Q
from django.utils.html import escape
from wildewidgets.views import JSONResponseView
if TYPE_CHECKING:
from django.db import models
logger = logging.getLogger(__name__)
[docs]class DatatableMixin:
"""
A mixin that provides server-side processing for jQuery DataTables.
This mixin handles all the server-side processing required by jQuery DataTables,
including sorting, filtering, searching, and pagination. It can work with Django
QuerySets and provides a customizable framework for rendering data.
The mixin works with both older (<1.10) and newer versions of DataTables,
automatically adapting to the format of parameters sent by the client.
Example:
.. code-block:: python
class MyDatatableView(DatatableMixin, JSONResponseView):
model = MyModel
columns = ['id', 'name', 'description']
order_columns = ['id', 'name', 'description']
def render_description_column(self, row, column):
return f"{row.description[:50]}..."
"""
#: The Django model that this datatable will be based on.
#: If not provided, you must implement the :py:meth:`get_initial_queryset`
#: method to return a queryset.
model: type[models.Model] | None = None
#: The names of the columns to display in our table
columns: list[str] = [] # noqa: RUF012
#: internal cache for columns definition
_columns: list[Any] = [] # noqa: RUF012
#: The list of column names by which the table will be ordered.
order_columns: list[str] = [] # noqa: RUF012
#: The max number of of records that will be returned, so that we can protect our
#: our server from rendering huge amounts of data
max_display_length: int = 100
#: Set to ``True`` if you are using datatables < 1.10
pre_camel_case_notation: bool = False
#: Replace any column value that is ``None`` with this string
none_string: str = ""
#: If set to ``True`` then values returned by :py:meth:`render_column`` will
#: be escaped
escape_values: bool = True
#: This gets set by the dataTables Javascript when it does the AJAX call
columns_data: list[dict[str, Any]] = [] # noqa: RUF012
#: This determines the type of results. If the AJAX call passes us a data attribute
#: that is not an integer, it expects a dictionary with specific fields in
#: the response, see: `dataTables columns.data
#: <https://datatables.net/reference/option/columns.data>`_
is_data_list: bool = True
#: The filter method to use for global searches and single column filtering
#: for case-insensitive "startswith" searches
FILTER_ISTARTSWITH: Final[str] = "istartswith"
#: The filter method to use for global searches and single column filtering
#: for case-insensitive "contins" searches
FILTER_ICONTAINS: Final[str] = "icontains"
def __init__(
self,
model: type[models.Model] | None = None,
order_columns: list[str] | None = None,
max_display_length: int | None = None,
none_string: str | None = None,
is_data_list: bool | None = None,
escape_values: bool | None = None,
**kwargs,
):
self.model = model or self.model
self.order_columns = order_columns or self.order_columns
self.max_display_length = max_display_length or self.max_display_length
self.none_string = none_string or self.none_string
self.is_data_list = is_data_list or self.is_data_list
self.escape_values = escape_values or self.escape_values
super().__init__(**kwargs)
@property
def _querydict(self):
if self.request.method == "POST": # type: ignore[attr-defined]
return self.request.POST # type: ignore[attr-defined]
return self.request.GET # type: ignore[attr-defined]
[docs] def get_initial_queryset(self) -> models.QuerySet:
"""
Get the initial queryset for the datatable.
By default, returns all objects from the model specified in the `model`
attribute. Override this method to provide custom querysets, filtering
by user permissions, or any other logic needed to determine the base
dataset.
Returns:
QuerySet: The initial queryset for the datatable
Raises:
NotImplementedError: If no model is specified and this method is not
overridden
"""
if not self.model:
msg = "You need to provide a model or implement get_initial_queryset"
raise NotImplementedError(msg)
return self.model.objects.all()
[docs] def get_filter_method(self) -> str:
"""
Get the preferred Django queryset filter method for searches.
This determines how text searching is performed on columns. By default, uses
"istartswith" for case-insensitive prefix matching, but can be overridden to
use other methods like "icontains" for substring matching.
Returns:
str: The filter method string (e.g., "istartswith", "icontains")
"""
return self.FILTER_ISTARTSWITH
[docs] def initialize(self, *args, **kwargs): # noqa: ARG002
"""
Initialize the datatable by determining the DataTables version in use.
This method checks request parameters to detect whether the client is using
DataTables <1.10 (which uses different parameter naming conventions), and
configures the mixin accordingly.
Args:
*args: Variable length argument list
**kwargs: Arbitrary keyword arguments
"""
if "iSortingCols" in self._querydict:
self.pre_camel_case_notation = True
[docs] def get_order_columns(self) -> list[str]:
"""
Get the list of columns that can be used for ordering/sorting.
This method returns the columns that can be sorted in the datatable. It first
checks the :py:attr:`order_columns` attribute, then falls back to
extracting column information from the dataTables.js request parameters if
using newer versions.
Returns:
list[str]: List of column names that can be sorted
"""
if self.order_columns or self.pre_camel_case_notation:
return self.order_columns
# try to build list of order_columns using request data
order_columns = []
for column_def in self.columns_data:
if column_def["name"] or not self.is_data_list:
# if self.is_data_list is False then we have a column name in
# the 'data' attribute, otherwise 'data' attribute is an integer
# with column index
if column_def["orderable"]:
if self.is_data_list:
order_columns.append(column_def["name"])
else:
order_columns.append(column_def.get("data"))
else:
order_columns.append("")
else:
# fallback to columns
order_columns = self._columns
break
self.order_columns = order_columns
return order_columns
[docs] def get_columns(self) -> list[str]:
"""
Get the list of columns to display in the datatable.
This method determines which columns will be included in the response.
It first checks the :py:class:`columns` attribute, then falls back to
extracting column information from the dataTables.js request parameters
if using newer versions.
Returns:
list[str]: List of column names to display
"""
if self.columns or self.pre_camel_case_notation:
return self.columns
columns = []
for column_def in self.columns_data:
# if self.is_data_list is True then 'data' atribute is an integer -
# column index, so we cannot use it as a column name, let's try
# 'name' attribute instead
col_name = column_def["name"] if self.is_data_list else column_def["data"]
if col_name:
columns.append(col_name)
else:
return self.columns
return columns
@staticmethod
def _column_value(obj: Any, key: str) -> Any:
"""
Extract a value from a model instance or dictionary.
This helper method retrieves attribute or dictionary values safely, handling
nested attribute access with dot notation.
Args:
obj: :py:class:`~django.db.models.Model` instance or dictionary for
the current row
key: Attribute or key name to retrieve
Returns:
Any: The value from the object, or None if not found
"""
if isinstance(obj, dict):
return obj.get(key, None)
return getattr(obj, key, None)
def _render_column(self, row: Any, column: str) -> str:
"""
Render a column value from a model instance or dictionary.
This internal method handles the actual value extraction and formatting,
including support for Django's choice fields via ``get_FIELDNAME_display``
methods, nested attribute access with dot notation, and HTML escaping.
Args:
row: :py:class:`~django.db.models.Model` instance or dictionary for
the current row
column: Column name or path (can use dot notation for nested access)
Returns:
str: The rendered value as a string
"""
# try to find rightmost object
obj = row
parts = column.split(".")
for part in parts[:-1]:
if obj is None:
break
obj = getattr(obj, part)
# try using get_OBJECT_display for choice fields
if hasattr(obj, f"get_{parts[-1]}_display"):
value = getattr(obj, f"get_{parts[-1]}_display")()
else:
value = self._column_value(obj, parts[-1])
if value is None:
value = self.none_string if value is None else value
if self.escape_values:
value = escape(value)
return value
[docs] def render_column(self, row: Any, column: str) -> str:
"""
Render a column value for display in the datatable.
This is the main method for formatting column values. Override this or create
methods named ``get_FIELDNAME_display`` on your Django model to
customize how specific columns are displayed. See
:py:meth:`_render_column` for details on how to implement custom
rendering logic.
Args:
row: :py:class:`~django.db.models.Model` instance or dictionary for
the current row
column: Column name
Returns:
str: The rendered value as a string, ready for display
"""
return self._render_column(row, column)
[docs] def ordering(self, qs: models.QuerySet) -> models.QuerySet[Model]: # noqa: PLR0912
"""
Apply ordering to the queryset based on dataTables.js parameters.
This method parses sorting parameters from the dataTables.js request
and applies them to the queryset. It supports multi-column sorting and
handles both older and newer dataTables.js parameter formats.
Args:
qs: The queryset to order
Returns:
models.QuerySet: The ordered queryset
"""
# Number of columns that are used in sorting
sorting_cols = 0
if self.pre_camel_case_notation:
try:
sorting_cols = int(self._querydict.get("iSortingCols", 0))
except ValueError:
sorting_cols = 0
else:
sort_key = f"order[{sorting_cols}][column]"
while sort_key in self._querydict:
sorting_cols += 1
sort_key = f"order[{sorting_cols}][column]"
order = []
order_columns = self.get_order_columns()
for i in range(sorting_cols):
# sorting column
sort_dir = "asc"
try:
if self.pre_camel_case_notation:
sort_col = int(self._querydict.get(f"iSortCol_{i}"))
# sorting order
sort_dir = self._querydict.get(f"sSortDir_{i}")
else:
sort_col = int(self._querydict.get(f"order[{i}][column]"))
# sorting order
sort_dir = self._querydict.get(f"order[{i}][dir]")
except ValueError:
sort_col = 0
sdir = "-" if sort_dir == "desc" else ""
sortcol = order_columns[sort_col]
if not sortcol:
continue
# pylint: disable=consider-using-f-string
if isinstance(sortcol, list):
for sc in sortcol:
order.append("{}{}".format(sdir, sc.replace(".", "__"))) # noqa: PERF401
else:
order.append("{}{}".format(sdir, sortcol.replace(".", "__")))
if order:
return qs.order_by(*order)
return qs
[docs] def paging(self, qs: models.QuerySet) -> models.QuerySet:
"""
Apply pagination to the queryset based on dataTables.js parameters.
This method extracts pagination parameters (start position and length)
from the dataTables.js request and slices the queryset accordingly.
Args:
qs: The queryset to paginate
Returns:
models.QuerySet: The paginated queryset
"""
if self.pre_camel_case_notation:
limit = min(
int(self._querydict.get("iDisplayLength", 10)), self.max_display_length
)
start = int(self._querydict.get("iDisplayStart", 0))
else:
limit = min(int(self._querydict.get("length", 10)), self.max_display_length)
start = int(self._querydict.get("start", 0))
# if pagination is disabled ("paging": false)
if limit == -1:
return qs
offset = start + limit
return qs[start:offset]
[docs] def extract_datatables_column_data(self) -> list[dict[str, str]]:
"""
Extract column configuration data from the dataTables.js request.
This method parses the complex column configuration parameters sent by
dataTables.js 1.10+ into a more manageable structure for internal use.
Returns:
list[dict[str, str]]: List of column configuration dictionaries
"""
request_dict = self._querydict
col_data = []
if not self.pre_camel_case_notation:
counter = 0
data_name_key = f"columns[{counter}][name]"
while data_name_key in request_dict:
searchable = (
request_dict.get(f"columns[{counter}][searchable]") == "true"
)
orderable = request_dict.get(f"columns[{counter}][orderable]") == "true"
col_data.append(
{
"name": request_dict.get(data_name_key),
"data": request_dict.get(f"columns[{counter}][data]"),
"searchable": searchable,
"orderable": orderable,
"search.value": request_dict.get(
f"columns[{counter}][search][value]"
),
"search.regex": request_dict.get(
f"columns[{counter}][search][regex]"
),
}
)
counter += 1
data_name_key = f"columns[{counter}][name]"
return col_data
[docs] def prepare_results(
self, qs: models.QuerySet
) -> list[list[str]] | list[dict[str, Any]]:
"""
Transform queryset results into the format expected by dataTables.js.
This method converts Django model instances into either:
- Lists of values (when :py:attr:`is_data_list` is True)
- Dictionaries mapping column names to values (when
:py:attr:`is_data_list` is False)
It calls :py:meth:`render_column` for each value to allow custom formatting.
Args:
qs: The queryset containing the results to display
Returns:
Either a list of lists (row-based format) or a list of dictionaries
(column-based format) depending on the DataTables configuration
"""
if self.is_data_list:
return [
[self.render_column(item, column) for column in self._columns]
for item in qs
]
_dict_data: list[dict[str, Any]] = []
for item in qs:
_dict_data.append( # noqa: PERF401
{
col_data["data"]: self.render_column(item, col_data["data"])
for col_data in self.columns_data
}
)
return _dict_data
[docs] def handle_exception(self, e: Exception) -> NoReturn:
"""
Handle exceptions that occur during processing.
This method logs the exception and re-raises it by default. Override
this method to implement custom exception handling.
Args:
e: The exception that was raised
Raises:
Exception: Re-raises the original exception
"""
logger.exception(str(e))
raise e
[docs] def get_context_data(self, *args, **kwargs):
"""
Process the DataTables request and prepare the response data.
This is the main entry point that:
1. Initializes the configuration based on the request
2. Gets the initial queryset
3. Applies filtering, sorting, and pagination
4. Formats the results as expected by DataTables
Args:
*args: Variable length argument list
Keyword Args:
**kwargs: Arbitrary keyword arguments
Returns:
dict: Response data in the format expected by DataTables
Note:
The returned dictionary contains different keys depending on the
dataTables.js version in use.
"""
try:
self.initialize(*args, **kwargs)
# prepare columns data (for DataTables 1.10+)
self.columns_data = self.extract_datatables_column_data()
# determine the response type based on the 'data' field passed from
# JavaScript https://datatables.net/reference/option/columns.data
# col['data'] can be an integer (return list) or string (return
# dictionary) we only check for the first column definition here as
# there is no way to return list and dictionary at once
self.is_data_list = True
if self.columns_data:
self.is_data_list = False
try:
int(self.columns_data[0]["data"])
self.is_data_list = True
except ValueError:
pass
# prepare list of columns to be returned
self._columns = self.get_columns()
qs = self.get_initial_queryset()
total_records = qs.count()
qs = self.filter_queryset(qs) # type: ignore[attr-defined]
total_display_records = qs.count()
qs = self.ordering(qs)
qs = self.paging(qs)
# prepare output data
if self.pre_camel_case_notation:
ret = {
"sEcho": int(self._querydict.get("sEcho", 0)),
"iTotalRecords": total_records,
"iTotalDisplayRecords": total_display_records,
"aaData": self.prepare_results(qs),
}
else:
ret = {
"draw": int(self._querydict.get("draw", 0)),
"recordsTotal": total_records,
"recordsFiltered": total_display_records,
"data": self.prepare_results(qs),
}
except Exception as e: # noqa: BLE001
return self.handle_exception(e)
else:
return ret
[docs]class BaseDatatableView(DatatableMixin, JSONResponseView): # type: ignore[misc]
"""
Base view for handling dataTables.js server-side processing.
This class combines the dataTables.js processing functionality from
:py:class:`DatatableMixin` with the JSON response handling from
:py:class:`wildewidgets.JSONResponseView` to create a complete server-side
processing view for dataTables.js.
Extend this class and override the necessary methods to create a custom
dataTables.js server-side processing view.
"""
[docs]class DatatableAJAXView(BaseDatatableView):
"""
Enhanced view for handling dataTables.js AJAX requests with advanced features.
This class extends :py:class:`BaseDatatableView` with additional functionality for:
- Advanced column configuration parsing
- Per-column filtering and searching
- Support for dotted attribute notation and relationship traversal
- Custom column rendering based on method naming conventions
It's designed to be extended for specific datatables, with custom filtering
and rendering logic implemented through method overrides.
Example:
.. code-block:: python
class UserTableView(DatatableAJAXView):
model = User
columns = ['id', 'username', 'email', 'is_staff', 'date_joined']
def render_is_staff_column(self, row, column):
return '✓' if row.is_staff else '✗'
def render_date_joined_column(self, row, column):
return row.date_joined.strftime('%Y-%m-%d')
"""
[docs] def columns(self, querydict: dict[str, Any]) -> dict[str, Any]: # type: ignore[override]
"""
Parse dataTables.js column configuration into a more usable format.
This method converts the flattened column configuration from dataTables.js
into a nested dictionary structure that's easier to work with. It maps
column data by column name rather than index for more intuitive access.
Note:
This overrides the :py:attr:`columns` attribute from
:py:class:`DatatableMixin` to provide a more advanced parsing
mechanism.
Args:
querydict: The query parameters from the dataTables.js request
Returns:
dict[str, Any]: Dictionary mapping column names to their configuration
"""
# First organize by column index, then transform to column name indexing
by_number = self._parse_columns_by_index(querydict)
return self._convert_to_name_indexed(by_number)
def _parse_value(self, value: str) -> bool | str:
"""
Convert string boolean values to Python booleans.
Args:
value: The string value to parse
Returns:
bool | str: Returns True or False for "true" or "false", otherwise
returns the original value
"""
if value == "true":
return True
if value == "false":
return False
return value
def _parse_columns_by_index(
self, querydict: dict[str, Any]
) -> dict[str, dict[str, Any]]:
"""
Parse column parameters and organize by column index.
Handles both direct attributes ``(columns[n][attr])`` and
nested attributes ``(columns[n][attr][subattr])``.
Args:
querydict: The query parameters from the DataTables request
Returns:
dict[str, dict[str, Any]]: Dictionary mapping column index to its attributes
and values, structured for easy access by column number.
"""
by_number: dict[str, dict[str, Any]] = {}
for key, value in querydict.items():
if not key.startswith("columns"):
continue
parts = key.split("[")
if len(parts) < 3: # noqa: PLR2004
continue
# Extract key components
column_number = parts[1][:-1] # "n" from columns[n]
column_attribute = parts[2][:-1] # "attr" from [attr]
# Initialize dict for this column if needed
if column_number not in by_number:
by_number[column_number] = {"column_number": int(column_number)}
# Process the value based on depth
parsed_value = self._parse_value(value)
if (
len(parts) == 4 # noqa: PLR2004
): # Nested attribute like columns[n][search][value]
nested_attribute = parts[3][:-1]
# Create or update nested dictionary
if column_attribute not in by_number[column_number]:
by_number[column_number][column_attribute] = {
nested_attribute: parsed_value
}
else:
by_number[column_number][column_attribute][nested_attribute] = (
parsed_value
)
else: # Direct attribute like columns[n][data]
by_number[column_number][column_attribute] = parsed_value
return by_number
def _convert_to_name_indexed(
self, by_number: dict[str, dict[str, Any]]
) -> dict[str, Any]:
"""
Transform ``by_number`` from column-index-indexed to column-name-indexed
structure.
This makes lookups more intuitive by using column names rather than positions.
Args:
by_number: Dictionary mapping column indices to their attributes
Returns:
dict[str, Any]: Dictionary mapping column names to their attributes
and values, structured for easy access by column name.
"""
by_name = {}
for column_data in by_number.values():
# Skip columns without a data attribute
if "data" not in column_data:
continue
# Use the 'data' attribute as the key
column_name = column_data["data"]
by_name[column_name] = column_data
return by_name
[docs] @lru_cache(maxsize=4) # noqa: B019
def searchable_columns(self) -> list[str]:
"""
Get the list of column names that are marked as searchable.
This method parses the column configuration to identify which columns
should be included in global searches and per-column filtering.
The result is cached for performance.
Returns:
list[str]: List of searchable column names
"""
return [
key
for key, value in self.columns(self._querydict).items()
if value["searchable"]
]
[docs] def column_specific_searches(self) -> list[tuple[str, str]]:
"""
Get a list of active per-column search filters.
This method identifies columns that have search values specified
in the DataTables request, which happens when a user enters a search
term in a specific column's filter input.
Returns:
list[tuple[str, str]]: List of (column_name, search_value) pairs
"""
return [
(key, value["search"]["value"])
for key, value in self.columns(self._querydict).items()
if value["search"]["value"]
]
[docs] def single_column_filter(
self, qs: models.QuerySet, column: str, value: str
) -> models.QuerySet:
"""
Apply filtering to a queryset based on a single column search.
This method filters the queryset by a specific column value. It supports:
1. Custom filtering via ``filter_COLUMNNAME_column`` methods
2. Default ``icontains`` filtering for searchable columns
Args:
qs: The queryset to filter
column: The column name to filter on
value: The search value to filter by
Returns:
models.QuerySet: The filtered queryset
Example:
To implement custom filtering for a specific column:
.. code-block:: python
from django.db.models import QuerySet
def filter_status_column(
self,
qs: QuerySet,
column: str,
value: str
) -> QuerySet:
if value.lower() == 'active':
return qs.filter(is_active=True)
elif value.lower() == 'inactive':
return qs.filter(is_active=False)
return qs
"""
attr_name = f"filter_{column}_column"
if hasattr(self, attr_name):
qs = getattr(self, attr_name)(qs, column, value)
elif column in self.searchable_columns():
kwarg_name = f"{column}__icontains"
qs = qs.filter(**{kwarg_name: value})
return qs
[docs] def search_query(self, qs: models.QuerySet, value: str) -> Q | None:
"""
Build a Q object for performing global search across multiple columns.
This method constructs a query that searches all searchable columns
for the given value, using OR logic to match records that have the value
in any searchable column.
Args:
qs: The queryset (not used in the default implementation)
value: The search term to look for
Returns:
Q | None: A Django Q object representing the search, or None if no
searchable columns exist
"""
query: Q | None = None
for column in self.searchable_columns():
attr_name = f"search_{column}_column"
if hasattr(self, attr_name):
q = getattr(self, attr_name)(qs, column, value)
else:
kwarg_name = f"{column}__icontains"
q = Q(**{kwarg_name: value})
query = query | q if query else q
return query
[docs] def search(self, qs: models.QuerySet, value: str) -> models.QuerySet:
"""
Apply a global search across all searchable columns.
This method is called when a user enters a search term in the main
dataTables.js search input. It applies the search across all searchable
columns and returns distinct results.
Args:
qs: The queryset to search
value: The search term
Returns:
models.QuerySet: The filtered queryset containing only matching records
"""
query = self.search_query(qs, value)
return qs.filter(query).distinct()
[docs] def filter_queryset(self, qs: models.QuerySet) -> models.QuerySet:
"""
Apply all filtering to the queryset based on dataTables.js parameters.
This method handles both:
1. Per-column searches specified by column-specific filters
2. Global searches from the main dataTables.js search input
Args:
qs: The queryset to filter
Returns:
models.QuerySet: The filtered queryset
"""
column_searches = self.column_specific_searches()
if column_searches:
for column, value in column_searches:
qs = self.single_column_filter(qs, column, value)
_value = self.request.GET.get("search[value]")
if _value:
qs = self.search(qs, _value)
return qs
def _render_column(self, row: Any, column: str) -> str:
"""
Preprocess column names before rendering values.
This method converts Django-style relationship traversal notation (`__`)
to dot notation (`.`) for proper attribute access.
Args:
row: The model instance or dictionary for this row
column: The column name, possibly using relationship notation
Returns:
str: The rendered column value
"""
column = re.sub("__", ".", column)
return super()._render_column(row, column)
[docs] def render_column(self, row: Any, column: str) -> str:
"""
Render a column value with custom rendering support.
This method enables custom column rendering through convention-based
method naming. If a method named ``render_COLUMNNAME_column`` exists, it
will be called to render the column instead of the default implementation.
Args:
row: The model instance or dictionary for this row
column: The column name
Returns:
str: The rendered column value
Example:
To customize rendering for a 'status' column:
.. code-block:: python
def render_status_column(self, row: Model, column: str) -> str:
status = row.status
if status == 'active':
return f'<span class="badge bg-success">{status}</span>'
return f'<span class="badge bg-secondary">{status}</span>'
"""
attr_name = f"render_{column}_column"
if hasattr(self, attr_name):
return getattr(self, attr_name)(row, column)
return super().render_column(row, column)