djangorestframework-tabulator 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,186 @@
1
+ Metadata-Version: 2.5
2
+ Name: djangorestframework-tabulator
3
+ Version: 0.1.0
4
+ Summary: Remote pagination and filtering for Tabulator (tabulator.info) on Django REST Framework.
5
+ Author: Esteban De La Fuente Rubio / Derafu
6
+ License: MIT License
7
+
8
+ Copyright (C) 2026 Esteban De La Fuente Rubio / Derafu <https://www.derafu.dev>
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ License-File: LICENSE
28
+ Keywords: django,djangorestframework,filtering,pagination,tabulator
29
+ Classifier: Framework :: Django
30
+ Classifier: Framework :: Django :: 4
31
+ Classifier: Framework :: Django :: 5
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Programming Language :: Python :: 3
35
+ Requires-Python: >=3.10
36
+ Requires-Dist: django>=4.2
37
+ Requires-Dist: djangorestframework>=3.15
38
+ Provides-Extra: dev
39
+ Requires-Dist: build; extra == 'dev'
40
+ Requires-Dist: django-stubs; extra == 'dev'
41
+ Requires-Dist: djangorestframework-stubs; extra == 'dev'
42
+ Requires-Dist: mypy; extra == 'dev'
43
+ Requires-Dist: pytest; extra == 'dev'
44
+ Requires-Dist: pytest-cov; extra == 'dev'
45
+ Requires-Dist: pytest-django; extra == 'dev'
46
+ Requires-Dist: ruff; extra == 'dev'
47
+ Requires-Dist: twine; extra == 'dev'
48
+ Description-Content-Type: text/x-rst
49
+
50
+ djangorestframework-tabulator
51
+ ==============================
52
+
53
+ |build-status-image| |pypi-version| |py-versions|
54
+
55
+ Remote pagination and filtering for `Tabulator <https://tabulator.info>`_
56
+ on top of `Django REST Framework <https://www.django-rest-framework.org>`_.
57
+
58
+ Why this exists
59
+ ----------------
60
+
61
+ Tabulator, in remote mode (``pagination: "remote"``, ``filterMode:
62
+ "remote"``, ``sortMode: "remote"``), sends pagination/filtering/sorting
63
+ using its own query string convention, and expects the response back in
64
+ its own shape — neither matches what DRF ships out of the box
65
+ (``PageNumberPagination`` responds with ``{count, next, previous,
66
+ results}``; its ``filter_backends`` don't understand nested bracketed
67
+ keys). This package translates both sides. `jQuery DataTables
68
+ <https://datatables.net>`_ already has a mature DRF adapter
69
+ (`djangorestframework-datatables
70
+ <https://pypi.org/project/djangorestframework-datatables/>`_); no
71
+ equivalent exists for Tabulator.
72
+
73
+ Usage
74
+ -----
75
+
76
+ .. code-block:: python
77
+
78
+ from rest_framework.generics import ListAPIView
79
+ from rest_framework_tabulator import TabulatorFilterBackend, TabulatorPagination
80
+
81
+ class DocumentListView(ListAPIView):
82
+ queryset = Document.objects.all()
83
+ serializer_class = DocumentSerializer
84
+ pagination_class = TabulatorPagination
85
+ filter_backends = [TabulatorFilterBackend]
86
+ filterset_fields = ['number', 'status', 'issued_at']
87
+ ordering_fields = ['issued_at', 'number', 'total']
88
+
89
+ Tabulator's contract (confirmed against the source code of
90
+ ``tabulator-tables/tabulator`` itself, not third-party documentation):
91
+
92
+ - Request: ``page``/``size`` (pagination), ``sort[i][field]``/
93
+ ``sort[i][dir]`` (sorting), ``filter[i][field]``/``filter[i][type]``/
94
+ ``filter[i][value]`` (filtering) — an array value (the ``in`` type) is
95
+ sent as ``filter[i][value][0]``, ``filter[i][value][1]``, etc.
96
+ - Expected response: ``{"data": [...], "last_page": N}``.
97
+
98
+ ``filterset_fields``/``ordering_fields`` on the view are allowlists of
99
+ fields (same attribute names ``django-filter``/``rest_framework.filters
100
+ .OrderingFilter`` already use, so they look familiar) — any field not
101
+ declared there is silently ignored, on both the filter and the sort
102
+ side. Leaving either one undeclared denies filtering/sorting entirely
103
+ rather than allowing every field by default.
104
+
105
+ Supported filter types
106
+ -----------------------
107
+
108
+ Every filter type Tabulator ships is supported (confirmed against
109
+ ``Filter/defaults/filters.js`` in the Tabulator source for the exact
110
+ set of built-in types):
111
+
112
+ .. list-table::
113
+ :header-rows: 1
114
+
115
+ * - Tabulator ``type``
116
+ - Django lookup
117
+ * - ``=``
118
+ - exact
119
+ * - ``!=``
120
+ - negated exact
121
+ * - ``<``, ``<=``, ``>``, ``>=``
122
+ - ``lt``, ``lte``, ``gt``, ``gte``
123
+ * - ``like``
124
+ - ``icontains``
125
+ * - ``keywords``
126
+ - ``icontains`` (see note below)
127
+ * - ``starts``
128
+ - ``istartswith``
129
+ * - ``ends``
130
+ - ``iendswith``
131
+ * - ``regex``
132
+ - ``regex``
133
+ * - ``in``
134
+ - ``in`` (array value)
135
+ * - ``smart``
136
+ - ported from Tabulator's own ``smart`` filter
137
+ * - ``smarter``
138
+ - ported from Tabulator's own ``smarter`` filter
139
+
140
+ ``keywords`` note: Tabulator's client-side ``keywords`` filter also
141
+ supports a custom word separator and an "all words must match" toggle
142
+ (``headerFilterFuncParams``), but neither is sent to the server in
143
+ remote mode — the ajax payload only ever carries ``{field, type,
144
+ value}``. It is approximated here as a plain substring match, same as
145
+ ``like``.
146
+
147
+ ``smart``/``smarter`` are a faithful port of Tabulator's own filter
148
+ functions, not a Django-specific addition: ``.`` matches any non-empty
149
+ value, ``!`` matches empty/null, a leading comparison operator does a
150
+ numeric comparison, a leading ``=`` does an exact match, multiple
151
+ whitespace-separated words become an ``AND`` of a substring match per
152
+ word, ``AND``/``OR`` combine sub-expressions left to right (no operator
153
+ precedence, same as the original), and anything else falls back to a
154
+ substring match.
155
+
156
+ A value that doesn't fit the field's type (e.g. a non-numeric string
157
+ against an integer field, or an invalid date) raises a DRF
158
+ ``ValidationError`` — a clean ``400`` naming the offending field —
159
+ instead of an unhandled ``500``. Django validates most field types
160
+ eagerly, right when the filter is applied, so this is caught there
161
+ rather than only when the queryset is evaluated.
162
+
163
+ License
164
+ -------
165
+
166
+ MIT — see ``LICENSE``.
167
+
168
+ Development
169
+ -----------
170
+
171
+ .. code-block:: bash
172
+
173
+ pip install -e '.[dev]'
174
+ pytest
175
+
176
+ .. |build-status-image| image:: https://github.com/derafu/djangorestframework-tabulator/actions/workflows/ci.yml/badge.svg
177
+ :target: https://github.com/derafu/djangorestframework-tabulator/actions/workflows/ci.yml
178
+ :alt: CI
179
+
180
+ .. |pypi-version| image:: https://img.shields.io/pypi/v/djangorestframework-tabulator.svg
181
+ :target: https://pypi.org/project/djangorestframework-tabulator/
182
+ :alt: Pypi version
183
+
184
+ .. |py-versions| image:: https://img.shields.io/pypi/pyversions/djangorestframework-tabulator.svg
185
+ :target: https://pypi.org/project/djangorestframework-tabulator/
186
+ :alt: Python versions
@@ -0,0 +1,8 @@
1
+ rest_framework_tabulator/__init__.py,sha256=CaNVrNVPDykAIqI83caW4C69Fzblx13DEKcHYQ9_HJw,379
2
+ rest_framework_tabulator/filters.py,sha256=xVsJai1nG--VXsugzuToSHZQNCAfle8gm5hpn21s3Go,9816
3
+ rest_framework_tabulator/pagination.py,sha256=hpIXhNKM-snwvr0MBMonepqOEVqyHrs4ObY-ASNgVCQ,2078
4
+ rest_framework_tabulator/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ djangorestframework_tabulator-0.1.0.dist-info/METADATA,sha256=6Zddb1Sp9sWvvyE0OxBFQVfk2nwy1i64OSEacvxStiU,7504
6
+ djangorestframework_tabulator-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
7
+ djangorestframework_tabulator-0.1.0.dist-info/licenses/LICENSE,sha256=A5tLhf0hAM09ItVNP4fXQ88Ti8BC7mpzC_oP-tL5xtw,1117
8
+ djangorestframework_tabulator-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (C) 2026 Esteban De La Fuente Rubio / Derafu <https://www.derafu.dev>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,14 @@
1
+ # Copyright (C) 2026 Esteban De La Fuente Rubio / Derafu <https://www.derafu.dev>
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Tabulator (tabulator.info) adapter for Django REST Framework."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from .filters import TabulatorFilterBackend
9
+ from .pagination import TabulatorPagination
10
+
11
+ __all__ = [
12
+ 'TabulatorFilterBackend',
13
+ 'TabulatorPagination',
14
+ ]
@@ -0,0 +1,273 @@
1
+ # Copyright (C) 2026 Esteban De La Fuente Rubio / Derafu <https://www.derafu.dev>
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Tabulator's remote filtering and sorting."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from django.core.exceptions import ValidationError as DjangoValidationError
12
+ from django.db.models import Q
13
+ from rest_framework.exceptions import ValidationError
14
+ from rest_framework.filters import BaseFilterBackend
15
+
16
+ if TYPE_CHECKING:
17
+ from django.db.models import QuerySet
18
+ from rest_framework.request import Request
19
+ from rest_framework.views import APIView
20
+
21
+ # `filter[0][field]=date&filter[0][type]=>=&filter[0][value]=2026-01-01`,
22
+ # `filter[0][value][0]=a&filter[0][value][1]=b` for an array value (the
23
+ # `in` type), and `sort[0][field]=date&sort[0][dir]=desc` — Django does
24
+ # not nest bracketed keys on its own (`request.GET` keeps each one as a
25
+ # literal key, e.g. `'filter[0][field]'`), so they have to be parsed by
26
+ # hand.
27
+ _FILTER_FIELD = re.compile(r'^filter\[(\d+)\]\[field\]$')
28
+ _FILTER_TYPE = re.compile(r'^filter\[(\d+)\]\[type\]$')
29
+ _FILTER_VALUE = re.compile(r'^filter\[(\d+)\]\[value\]$')
30
+ _FILTER_VALUE_ITEM = re.compile(r'^filter\[(\d+)\]\[value\]\[(\d+)\]$')
31
+ _SORT_KEY = re.compile(r'^sort\[(\d+)\]\[(field|dir)\]$')
32
+
33
+ # Tabulator type -> Django ORM lookup, for the types that map to a plain
34
+ # `field__lookup=value` query. `=`/`!=` are handled separately (equality
35
+ # needs no suffix, negation needs `~Q(...)`); `smart`/`smarter` build a
36
+ # `Q` of their own (see `_smart_filter`/`_smarter_filter`) since they
37
+ # don't reduce to a single lookup.
38
+ _LOOKUP_BY_TYPE = {
39
+ 'like': 'icontains',
40
+ # Tabulator's own `keywords` type additionally supports a custom
41
+ # word separator and an "all words must match" toggle
42
+ # (`headerFilterFuncParams`) — neither is sent to the server in
43
+ # remote mode (confirmed against `Filter.js`: the ajax payload only
44
+ # ever carries `{field, type, value}`), so this is approximated as
45
+ # a plain substring match, same as `like`.
46
+ 'keywords': 'icontains',
47
+ 'starts': 'istartswith',
48
+ 'ends': 'iendswith',
49
+ 'regex': 'regex',
50
+ 'in': 'in',
51
+ '<': 'lt',
52
+ '<=': 'lte',
53
+ '>': 'gt',
54
+ '>=': 'gte',
55
+ }
56
+
57
+ _IMPOSSIBLE = Q(pk__in=[])
58
+
59
+
60
+ def _parse_filters(query_params: Any) -> dict[str, dict[str, Any]]:
61
+ """Group `filter[i][...]` keys into `{i: {field, type, value}}`."""
62
+ entries: dict[str, dict[str, Any]] = {}
63
+ value_items: dict[str, dict[int, str]] = {}
64
+
65
+ for key, value in query_params.items():
66
+ if match := _FILTER_FIELD.match(key):
67
+ entries.setdefault(match.group(1), {})['field'] = value
68
+ elif match := _FILTER_TYPE.match(key):
69
+ entries.setdefault(match.group(1), {})['type'] = value
70
+ elif match := _FILTER_VALUE.match(key):
71
+ entries.setdefault(match.group(1), {})['value'] = value
72
+ elif match := _FILTER_VALUE_ITEM.match(key):
73
+ index, position = match.groups()
74
+ value_items.setdefault(index, {})[int(position)] = value
75
+
76
+ for index, items in value_items.items():
77
+ entries.setdefault(index, {})['value'] = [
78
+ items[position] for position in sorted(items)
79
+ ]
80
+
81
+ return entries
82
+
83
+
84
+ def _parse_sorters(query_params: Any) -> dict[str, dict[str, str]]:
85
+ """Group `sort[i][...]` keys into `{i: {field, dir}}`."""
86
+ grouped: dict[str, dict[str, str]] = {}
87
+ for key, value in query_params.items():
88
+ match = _SORT_KEY.match(key)
89
+ if not match:
90
+ continue
91
+ index, subkey = match.groups()
92
+ grouped.setdefault(index, {})[subkey] = value
93
+ return grouped
94
+
95
+
96
+ def _numeric_comparison(field: str, lookup: str, raw_number: str) -> Q:
97
+ """
98
+ `field__lookup=value`, or a clause that matches nothing.
99
+
100
+ Same behaviour as Tabulator's own comparison filters: comparing
101
+ against a value that isn't a number never matches, rather than
102
+ raising an error.
103
+ """
104
+ try:
105
+ value = float(raw_number.strip())
106
+ except ValueError:
107
+ return _IMPOSSIBLE
108
+ return Q(**{f'{field}__{lookup}': value})
109
+
110
+
111
+ def _smart_filter(field: str, raw: str) -> Q:
112
+ """
113
+ Port of Tabulator's own `smart` filter function to a Django `Q`.
114
+
115
+ Faithfully mirrors `Filter/defaults/filters.js` (``smart``): ``.``
116
+ matches any non-empty value, ``!`` matches empty/null, a leading
117
+ comparison operator (``<``, ``<=``, ``>``, ``>=``) does a numeric
118
+ comparison, a leading ``=`` does an exact match on the rest of the
119
+ string, several whitespace-separated words become an ``AND`` of a
120
+ substring match per word (delegated to `_smarter_filter`), and
121
+ anything else falls back to a plain substring match.
122
+ """
123
+ search = raw.strip()
124
+
125
+ if search == '.':
126
+ return ~Q(**{f'{field}__isnull': True}) & ~Q(**{field: ''})
127
+ if search == '!':
128
+ return Q(**{f'{field}__isnull': True}) | Q(**{field: ''})
129
+
130
+ comparisons = (('<=', 'lte'), ('>=', 'gte'), ('<', 'lt'), ('>', 'gt'))
131
+ for operator, lookup in comparisons:
132
+ if search.startswith(operator):
133
+ return _numeric_comparison(field, lookup, search[len(operator) :])
134
+
135
+ if search.startswith('='):
136
+ return Q(**{field: search[1:].strip()})
137
+
138
+ if ' ' in search:
139
+ terms = [term for term in search.split() if term]
140
+ if len(terms) > 1:
141
+ return _smarter_filter(field, ' AND '.join(terms))
142
+
143
+ return Q(**{f'{field}__icontains': search})
144
+
145
+
146
+ def _smarter_filter(field: str, raw: str) -> Q:
147
+ """
148
+ Port of Tabulator's own `smarter` filter function to a Django `Q`.
149
+
150
+ Splits on ``AND``/``OR`` (case-insensitive) and combines each part's
151
+ `_smart_filter` clause left to right, in the order they appear — no
152
+ operator precedence, same as the original.
153
+ """
154
+ search = raw.strip()
155
+ if not search:
156
+ return Q()
157
+
158
+ parts = re.split(r'\s+(AND|OR)\s+', search, flags=re.IGNORECASE)
159
+ if len(parts) == 1:
160
+ return _smart_filter(field, search)
161
+
162
+ result: Q | None = None
163
+ operator = 'AND'
164
+ for raw_part in parts:
165
+ part = raw_part.strip()
166
+ if part.upper() in ('AND', 'OR'):
167
+ operator = part.upper()
168
+ continue
169
+ clause = _smart_filter(field, part)
170
+ if result is None:
171
+ result = clause
172
+ elif operator == 'OR':
173
+ result |= clause
174
+ else:
175
+ result &= clause
176
+
177
+ return result if result is not None else Q()
178
+
179
+
180
+ _Q_BUILDERS = {
181
+ 'smart': _smart_filter,
182
+ 'smarter': _smarter_filter,
183
+ }
184
+
185
+
186
+ def _error_message(error: Exception) -> str:
187
+ """Plain text out of a `ValueError`/`ValidationError`."""
188
+ messages = getattr(error, 'messages', None)
189
+ return ' '.join(messages) if messages else str(error)
190
+
191
+
192
+ class TabulatorFilterBackend(BaseFilterBackend):
193
+ """
194
+ Translates the `filter`/`sort` Tabulator sends in remote mode.
195
+
196
+ Allowed fields are declared on the view, not here:
197
+ `filterset_fields`/`ordering_fields` (same attribute names already
198
+ used by `django-filter`/`rest_framework.filters.OrderingFilter`, so
199
+ they look familiar) — any field not declared there is silently
200
+ ignored. A field is never exposed just because the client asked for
201
+ it.
202
+ """
203
+
204
+ def filter_queryset(
205
+ self,
206
+ request: Request,
207
+ queryset: QuerySet[Any],
208
+ view: APIView,
209
+ ) -> QuerySet[Any]:
210
+ """Apply filtering, then sorting, to `queryset`."""
211
+ queryset = self._filter(request, queryset, view)
212
+ return self._sort(request, queryset, view)
213
+
214
+ def _filter(
215
+ self,
216
+ request: Request,
217
+ queryset: QuerySet[Any],
218
+ view: APIView,
219
+ ) -> QuerySet[Any]:
220
+ allowed = set(getattr(view, 'filterset_fields', None) or [])
221
+ entries = _parse_filters(request.query_params)
222
+
223
+ for entry in entries.values():
224
+ field = entry.get('field')
225
+ value = entry.get('value')
226
+ if not field or not value or field not in allowed:
227
+ continue
228
+
229
+ operator = entry.get('type', '=')
230
+ builder = _Q_BUILDERS.get(operator)
231
+ if builder:
232
+ clause = builder(field, value)
233
+ elif operator == '!=':
234
+ clause = ~Q(**{field: value})
235
+ else:
236
+ lookup = _LOOKUP_BY_TYPE.get(operator)
237
+ key = f'{field}__{lookup}' if lookup else field
238
+ clause = Q(**{key: value})
239
+
240
+ # Applied one entry at a time (equivalent to combining every
241
+ # clause with `&` and filtering once) so that a value that
242
+ # doesn't fit the field's type — Django validates this
243
+ # eagerly, right here at `.filter()`, not lazily when the
244
+ # queryset is evaluated — can be blamed on the field that
245
+ # caused it, and turned into a clean `400` instead of
246
+ # bubbling up as an unhandled `500`.
247
+ try:
248
+ queryset = queryset.filter(clause)
249
+ except (TypeError, ValueError, DjangoValidationError) as error:
250
+ raise ValidationError(
251
+ {field: _error_message(error)},
252
+ ) from error
253
+
254
+ return queryset
255
+
256
+ def _sort(
257
+ self,
258
+ request: Request,
259
+ queryset: QuerySet[Any],
260
+ view: APIView,
261
+ ) -> QuerySet[Any]:
262
+ allowed = set(getattr(view, 'ordering_fields', None) or [])
263
+ entries = _parse_sorters(request.query_params)
264
+
265
+ ordering = []
266
+ for entry in entries.values():
267
+ field = entry.get('field')
268
+ if not field or field not in allowed:
269
+ continue
270
+ descending = entry.get('dir') == 'desc'
271
+ ordering.append(f'-{field}' if descending else field)
272
+
273
+ return queryset.order_by(*ordering) if ordering else queryset
@@ -0,0 +1,60 @@
1
+ # Copyright (C) 2026 Esteban De La Fuente Rubio / Derafu <https://www.derafu.dev>
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Tabulator's remote pagination."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from rest_framework.pagination import PageNumberPagination
11
+ from rest_framework.response import Response
12
+
13
+
14
+ class TabulatorPagination(PageNumberPagination):
15
+ """
16
+ Shapes the paginated response the way Tabulator expects in remote mode.
17
+
18
+ Tabulator sends `page`/`size` as query params (confirmed against
19
+ Tabulator's own `Page.js`, not its documentation) — both names
20
+ already match what `PageNumberPagination` uses, so nothing needs
21
+ translating; `size` just needs to be turned into a client-
22
+ controllable option. The response Tabulator expects is
23
+ `{"data": [...], "last_page": N}`, not DRF's own `{count, next,
24
+ previous, results}` — that's the one method overridden here.
25
+ """
26
+
27
+ page_query_param = 'page'
28
+ page_size_query_param = 'size'
29
+ page_size = 20
30
+ max_page_size = 200
31
+
32
+ def get_paginated_response(self, data: list[Any]) -> Response:
33
+ """`{"data": [...], "last_page": N}` — the shape Tabulator expects."""
34
+ # `self.page` is only ever `None` before `paginate_queryset()` has
35
+ # run — DRF always calls that first, and only reaches this method
36
+ # if it returned something, so it's never `None` here.
37
+ assert self.page is not None
38
+ return Response(
39
+ {
40
+ 'last_page': self.page.paginator.num_pages,
41
+ 'data': data,
42
+ },
43
+ )
44
+
45
+ def get_paginated_response_schema(
46
+ self,
47
+ schema: dict[str, Any],
48
+ ) -> dict[str, Any]:
49
+ """OpenAPI schema for the shape above, for anyone generating docs."""
50
+ return {
51
+ 'type': 'object',
52
+ 'required': ['data', 'last_page'],
53
+ 'properties': {
54
+ 'last_page': {
55
+ 'type': 'integer',
56
+ 'example': 4,
57
+ },
58
+ 'data': schema,
59
+ },
60
+ }
File without changes