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.
- djangorestframework_tabulator-0.1.0.dist-info/METADATA +186 -0
- djangorestframework_tabulator-0.1.0.dist-info/RECORD +8 -0
- djangorestframework_tabulator-0.1.0.dist-info/WHEEL +4 -0
- djangorestframework_tabulator-0.1.0.dist-info/licenses/LICENSE +21 -0
- rest_framework_tabulator/__init__.py +14 -0
- rest_framework_tabulator/filters.py +273 -0
- rest_framework_tabulator/pagination.py +60 -0
- rest_framework_tabulator/py.typed +0 -0
|
@@ -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,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
|