qnc-data-tables 0.0.6a0__tar.gz
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.
- qnc_data_tables-0.0.6a0/PKG-INFO +7 -0
- qnc_data_tables-0.0.6a0/README.md +7 -0
- qnc_data_tables-0.0.6a0/pyproject.toml +18 -0
- qnc_data_tables-0.0.6a0/qnc_data_tables/__init__.py +23 -0
- qnc_data_tables-0.0.6a0/qnc_data_tables/_core.py +500 -0
- qnc_data_tables-0.0.6a0/qnc_data_tables/_sort_specification.py +35 -0
- qnc_data_tables-0.0.6a0/qnc_data_tables/py.typed +0 -0
- qnc_data_tables-0.0.6a0/qnc_data_tables.egg-info/PKG-INFO +7 -0
- qnc_data_tables-0.0.6a0/qnc_data_tables.egg-info/SOURCES.txt +11 -0
- qnc_data_tables-0.0.6a0/qnc_data_tables.egg-info/dependency_links.txt +1 -0
- qnc_data_tables-0.0.6a0/qnc_data_tables.egg-info/requires.txt +2 -0
- qnc_data_tables-0.0.6a0/qnc_data_tables.egg-info/top_level.txt +1 -0
- qnc_data_tables-0.0.6a0/setup.cfg +4 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
1. Install a front-end (we recommend @qnc/qnc_data_tables)
|
|
2
|
+
2. `pip install qnc_data_tables`
|
|
3
|
+
3. Create a utility function to render a qnc_data_tables.TableManager as whatever markup is required by your front-end. If you're using @qnc/qnc_data_tables, then `test_project.render_table_manager.render_table_manager` is a working sample/starting point.
|
|
4
|
+
4. In a view for a page that renders a data table:
|
|
5
|
+
- create a qnc_data_tables.TableManager instance
|
|
6
|
+
- if request.method == POST, return table_manager.handle(...)
|
|
7
|
+
- otherwise, use your utility function to render the table_manager on your page
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools >= 77.0.3"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[tool.setuptools]
|
|
6
|
+
packages = ["qnc_data_tables"]
|
|
7
|
+
|
|
8
|
+
[project]
|
|
9
|
+
name = "qnc_data_tables"
|
|
10
|
+
version = "0.0.6-a"
|
|
11
|
+
dependencies = [
|
|
12
|
+
# we can probably relax this, but this is the initial version we're building for
|
|
13
|
+
"django>=4.2.19,<6",
|
|
14
|
+
"html_generators>=2.8.0,<3",
|
|
15
|
+
]
|
|
16
|
+
requires-python = ">= 3.11"
|
|
17
|
+
description = "Work-in-progress"
|
|
18
|
+
readme = "TODO"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from ._core import (
|
|
2
|
+
Column,
|
|
3
|
+
ColumnFormatter,
|
|
4
|
+
ExtraSortFunction,
|
|
5
|
+
LineCapper,
|
|
6
|
+
TableManager,
|
|
7
|
+
)
|
|
8
|
+
from ._sort_specification import (
|
|
9
|
+
ColumnSort,
|
|
10
|
+
FunctionSort,
|
|
11
|
+
encode_sort,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Column",
|
|
16
|
+
"ColumnFormatter",
|
|
17
|
+
"ColumnSort",
|
|
18
|
+
"encode_sort",
|
|
19
|
+
"ExtraSortFunction",
|
|
20
|
+
"FunctionSort",
|
|
21
|
+
"LineCapper",
|
|
22
|
+
"TableManager",
|
|
23
|
+
]
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from collections.abc import Iterable, Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import (
|
|
6
|
+
Any,
|
|
7
|
+
Callable,
|
|
8
|
+
Generic,
|
|
9
|
+
Literal,
|
|
10
|
+
Optional,
|
|
11
|
+
Protocol,
|
|
12
|
+
Sequence,
|
|
13
|
+
TypedDict,
|
|
14
|
+
TypeVar,
|
|
15
|
+
assert_never,
|
|
16
|
+
cast,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
import html_generators as h
|
|
20
|
+
from django.db.models import Model, QuerySet
|
|
21
|
+
from django.http import (
|
|
22
|
+
HttpResponse,
|
|
23
|
+
HttpResponseBadRequest,
|
|
24
|
+
JsonResponse,
|
|
25
|
+
QueryDict,
|
|
26
|
+
)
|
|
27
|
+
from django.utils.translation import gettext as _
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
ModelType = TypeVar("ModelType", bound=Model)
|
|
32
|
+
AggregateResult = TypeVar("AggregateResult")
|
|
33
|
+
PK = TypeVar("PK")
|
|
34
|
+
|
|
35
|
+
# Some definitions:
|
|
36
|
+
HtmlGeneratorsContent = Any # Content meant for display by html_generators
|
|
37
|
+
InlineHtmlGeneratorsContent = Any # Inline content meant for display by html generators
|
|
38
|
+
FilterFunction = Callable[[QueryDict, QuerySet[ModelType]], QuerySet[ModelType]]
|
|
39
|
+
SortFunction = Callable[[QuerySet[ModelType]], QuerySet[ModelType]]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ColumnFormatter(Generic[ModelType]):
|
|
43
|
+
"""
|
|
44
|
+
To be used by "advanced" Column instances, where a simple Callable formatter isn't powerful enough.
|
|
45
|
+
|
|
46
|
+
aggregator:
|
|
47
|
+
Will be called by the Column once, before generating an entire page of table data. The result will be passed to the formatter function for each row. This allows you to create a separate queryset of related data, and then use info from that queryset in each row. This can useful when following foreign keys backward (eg. when `related_name` is set to '+', or when you are using type-checked code and don't want to use automagical reverse accessors).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
aggregator: "Callable[[QuerySet[ModelType]], AggregateResult]",
|
|
53
|
+
formatter: "Callable[[ModelType, AggregateResult], HtmlGeneratorsContent]",
|
|
54
|
+
):
|
|
55
|
+
def make_simple_formatter(
|
|
56
|
+
qs: QuerySet[ModelType],
|
|
57
|
+
) -> Callable[[ModelType], HtmlGeneratorsContent]:
|
|
58
|
+
aggregate_result = aggregator(qs)
|
|
59
|
+
|
|
60
|
+
def format(o: ModelType):
|
|
61
|
+
return formatter(o, aggregate_result)
|
|
62
|
+
|
|
63
|
+
return format
|
|
64
|
+
|
|
65
|
+
self.make_simple_formatter = make_simple_formatter
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Column(Generic[ModelType]):
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
# Note - may also be a lazy (translatable) string
|
|
73
|
+
header: str,
|
|
74
|
+
# Note - if you return anything other than an h.Element, in will be wrapped in a <div>
|
|
75
|
+
# Note - if you return an h.Element, it will be used as the cell element directly (and have the cell's background color, border, padding, etc.)
|
|
76
|
+
# If you do NOT want that to happen, wrap that element in a plain div
|
|
77
|
+
# So you if you want "an <i>apple</i>" to show up on one line, you have to wrap in div AND span
|
|
78
|
+
formatter: "Callable[[ModelType], HtmlGeneratorsContent]|ColumnFormatter[ModelType]",
|
|
79
|
+
# Set to false to make initially-disabled, but still available in column list
|
|
80
|
+
enabled: bool = True,
|
|
81
|
+
# Fixed columns are "sticky" when scrolling horizontally
|
|
82
|
+
# They are "transported" to the left-most part of the table
|
|
83
|
+
# They cannot be sorted/disabled by the user
|
|
84
|
+
# Intended mainly for "main action" column
|
|
85
|
+
fixed: bool = False,
|
|
86
|
+
sort_function: "Optional[SortFunction[ModelType]]" = None,
|
|
87
|
+
# Called only when fetching a page of data which includes this column, and before passing the queryset to your sort_function (when sorting on this column)
|
|
88
|
+
prepare: "Callable[[QuerySet[ModelType]], QuerySet[ModelType]]" = lambda qs: qs,
|
|
89
|
+
width: int = 150,
|
|
90
|
+
# Note - may also be a lazy (translatable) string
|
|
91
|
+
help_text: str = "",
|
|
92
|
+
) -> None:
|
|
93
|
+
self.header = header
|
|
94
|
+
self.formatter = formatter
|
|
95
|
+
self.sort_function = sort_function
|
|
96
|
+
self.prepare = prepare
|
|
97
|
+
self.width = width
|
|
98
|
+
self.help_text = help_text
|
|
99
|
+
|
|
100
|
+
self.enabled = enabled
|
|
101
|
+
self.fixed = fixed
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
OverflowFormatter = Callable[[int, int], InlineHtmlGeneratorsContent]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _default_line_overflow_indicator(total: int, overflow_count: int):
|
|
108
|
+
text = (
|
|
109
|
+
(
|
|
110
|
+
# In this case, max_lines is set to 1.
|
|
111
|
+
# There are no "normal" lines showing.
|
|
112
|
+
_("{count} items").format(count=total)
|
|
113
|
+
)
|
|
114
|
+
if total == overflow_count
|
|
115
|
+
# max_lines is more than 1. At least one "normal line" is visible above this.
|
|
116
|
+
else (
|
|
117
|
+
h.template(
|
|
118
|
+
_("{plus_sign}{count} more"),
|
|
119
|
+
count=overflow_count,
|
|
120
|
+
plus_sign=h.B("+"),
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return h.Span(text, class_="django-data-tables-overflow-indicator")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class LineCapper:
|
|
129
|
+
"""
|
|
130
|
+
Utility class for rendering multi-line content that is capped to a certain number of lines.
|
|
131
|
+
|
|
132
|
+
You'll likely want to create one instance per TableManager instance, so that all of your columns are using the same # of max lines.
|
|
133
|
+
|
|
134
|
+
Columns that render a "single thing" (like an email address) which should never take up more than one line have no need for this. Just output that thing directly.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(self, max_lines: int):
|
|
138
|
+
self.max_lines = max_lines
|
|
139
|
+
|
|
140
|
+
def _unwrapped_line_items(
|
|
141
|
+
self,
|
|
142
|
+
line_items: Iterable[InlineHtmlGeneratorsContent],
|
|
143
|
+
overflow_formatter: OverflowFormatter,
|
|
144
|
+
):
|
|
145
|
+
items = [item for item in line_items if item]
|
|
146
|
+
total = len(items)
|
|
147
|
+
if total > self.max_lines:
|
|
148
|
+
show_lines = self.max_lines - 1
|
|
149
|
+
return (
|
|
150
|
+
(h.Div(item) for item in items[:show_lines]),
|
|
151
|
+
h.Div(overflow_formatter(total, total - show_lines)),
|
|
152
|
+
)
|
|
153
|
+
return (h.Div(item) for item in items)
|
|
154
|
+
|
|
155
|
+
def line_items(
|
|
156
|
+
self,
|
|
157
|
+
line_items: Iterable[InlineHtmlGeneratorsContent],
|
|
158
|
+
overflow_formatter: OverflowFormatter = _default_line_overflow_indicator,
|
|
159
|
+
) -> h.Element:
|
|
160
|
+
"""
|
|
161
|
+
Each item will be rendered on it's own line.
|
|
162
|
+
If there are more items than max lines, the last line will be used to show an "overflow indicator".
|
|
163
|
+
|
|
164
|
+
Note - the line_items should NOT include any br's' or block-level elements. If they do, cells might end up showing more than max_lines.
|
|
165
|
+
"""
|
|
166
|
+
return h.Div(
|
|
167
|
+
self._unwrapped_line_items(line_items, overflow_formatter),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def wrapping_text(self, text: str, newline_indicator: str = "|") -> h.Element:
|
|
171
|
+
"""
|
|
172
|
+
Let text wrap, but show "indicator" for explicit newlines.
|
|
173
|
+
Useful for content that was typed into a textarea, or anything else that might have explicit newlines.
|
|
174
|
+
|
|
175
|
+
Default newline indicator is "fullwidth vertical line", which has a little space to either side, unlike plain pipe/bar character.
|
|
176
|
+
|
|
177
|
+
Uses fairly new -webkit-line-clamp.
|
|
178
|
+
Works fairly well IFF the column width is wider than most content words.
|
|
179
|
+
|
|
180
|
+
If a single word is wider than column, it gets broken accross multiple lines (often without hyphenation).
|
|
181
|
+
We use the CSS "hyphens" property to _try_ to force hyphens when breaking words, but that seems to rely on a "hyphenation dictionary", and only works when breaking certain words.
|
|
182
|
+
If last line in cell is a single overflowing word (without hyphenation), there is no visual indicator for overflow at all.
|
|
183
|
+
"""
|
|
184
|
+
wrapped_content = h.Div(
|
|
185
|
+
newline_indicator.join(text.splitlines()),
|
|
186
|
+
class_="django-data-tables-wrapping-content",
|
|
187
|
+
style=f"-webkit-line-clamp: {self.max_lines}",
|
|
188
|
+
)
|
|
189
|
+
# We need another wrapper element to be used as the cell.
|
|
190
|
+
# You should not use padding on any element which uses -webkit-line-clamp, the padding should go on a wrapper element. Otherwise, truncated text will actually be visible in the padding area.
|
|
191
|
+
return h.Div(wrapped_content)
|
|
192
|
+
|
|
193
|
+
def wrapping_content(self, content: InlineHtmlGeneratorsContent) -> h.Element:
|
|
194
|
+
"""
|
|
195
|
+
Similar to wrapping_text, but does NOT show "newline indicators".
|
|
196
|
+
|
|
197
|
+
We recommend using wrapping_text for most use cases.
|
|
198
|
+
Use this only if you need to pass html content (formatting), not just plain text.
|
|
199
|
+
|
|
200
|
+
Really intended only basic phrasing content.
|
|
201
|
+
Things like email addresses tend to look better if left on one line (with overflow indicator).
|
|
202
|
+
"""
|
|
203
|
+
wrapped_content = h.Div(
|
|
204
|
+
content,
|
|
205
|
+
class_="django-data-tables-wrapping-content",
|
|
206
|
+
style=f"-webkit-line-clamp: {self.max_lines}",
|
|
207
|
+
)
|
|
208
|
+
# We need another wrapper element to be used as the cell.
|
|
209
|
+
# You should not use padding on any element which uses -webkit-line-clamp, the padding should go on a wrapper element. Otherwise, truncated text will actually be visible in the padding area.
|
|
210
|
+
return h.Div(wrapped_content)
|
|
211
|
+
|
|
212
|
+
def preserving_text(self, text: str) -> h.Element:
|
|
213
|
+
"""
|
|
214
|
+
White space is preserved.
|
|
215
|
+
Each line will show an ellipsis if it overflows.
|
|
216
|
+
The last line will simply show a mid-line ellipsis if there are more lines than max_lines.
|
|
217
|
+
"""
|
|
218
|
+
return h.Div(
|
|
219
|
+
self._unwrapped_line_items(
|
|
220
|
+
text.splitlines(),
|
|
221
|
+
lambda total, overflow_count: "⋯",
|
|
222
|
+
),
|
|
223
|
+
style="white-space: pre;",
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# Note - we _might_ also want to support prewrap, and/or a variation thereof (preserving line breaks, but collapsing/trimming spaces)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class NotResponsible(Exception):
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class DataError(Exception):
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def make_sort_key(column_key: str, reverse: bool):
|
|
238
|
+
"""
|
|
239
|
+
Used by TableManager when interpreting the sort_key passed by the front-end.
|
|
240
|
+
May be useful when configuring a default sort key on your front-end.
|
|
241
|
+
"""
|
|
242
|
+
if reverse:
|
|
243
|
+
return f"_column_reverse_{column_key}"
|
|
244
|
+
return f"_column_forward_{column_key}"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclass(kw_only=True)
|
|
248
|
+
class ExtraSortFunction(Generic[ModelType]):
|
|
249
|
+
title: str
|
|
250
|
+
function: "SortFunction[ModelType]"
|
|
251
|
+
help_text: str = ""
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class PKEncoder(Protocol, Generic[PK]):
|
|
255
|
+
def encode(self, value: PK) -> str: ...
|
|
256
|
+
|
|
257
|
+
def decode(self, value: str) -> PK:
|
|
258
|
+
"""May also raise ValueError"""
|
|
259
|
+
...
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class IntEncoder:
|
|
263
|
+
@staticmethod
|
|
264
|
+
def encode(value: int):
|
|
265
|
+
return str(value)
|
|
266
|
+
|
|
267
|
+
@staticmethod
|
|
268
|
+
def decode(value: str):
|
|
269
|
+
return int(value)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass(kw_only=True)
|
|
273
|
+
class TableManager(Generic[ModelType, PK]):
|
|
274
|
+
"""
|
|
275
|
+
Implements the back-end of the "QNC Data Table API V1".
|
|
276
|
+
|
|
277
|
+
Note that while we only implement the back-end, we _collect_ some information that is only needed by the front-end. This makes it easy for consumers to write a function which accepts a TableManager instance and creates whatever HTML/JS is needed by your front-end.
|
|
278
|
+
|
|
279
|
+
queryset:
|
|
280
|
+
- all of the objects that _may_ be shown in the table
|
|
281
|
+
- if filter_function is used, the displayed result set may be a subset of this
|
|
282
|
+
|
|
283
|
+
filter_function:
|
|
284
|
+
- a function to be called
|
|
285
|
+
|
|
286
|
+
columns:
|
|
287
|
+
- the available columns
|
|
288
|
+
|
|
289
|
+
extra_sort_functions:
|
|
290
|
+
- if non-empty, the front-end should render a "sort widget" somewhere, with these as options
|
|
291
|
+
- we (the back-end) are only concerned with the key and the ExtraSortFunction.function
|
|
292
|
+
- we collect the title and help_text solely so that your "render function" has this information available when setting up the front-end
|
|
293
|
+
|
|
294
|
+
fallback_sort_by_pk:
|
|
295
|
+
If the front-end has not specified any sort order, sort queryset by pk.
|
|
296
|
+
Default is True (ensures that we always use a specified/consistent ordering)
|
|
297
|
+
Set to False if queryset is already ordered, and you want to retain that ordering when front-end does not specify ordering.
|
|
298
|
+
Note: we _may_ drop/ignore this is in the future. It would probably be better to just always "post order" the queryset by pk (ie, _append_ "pk" to current ordering), even if front-end specifies sorting (in case column's sort function doesn't guarantee unique sorting). Unfortunately, this isn't possible without using undocumented parts of Django.
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
queryset: "QuerySet[ModelType]"
|
|
302
|
+
|
|
303
|
+
filter_function: "FilterFunction[ModelType]" = (
|
|
304
|
+
lambda query_dict, query_set: query_set
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
columns: Mapping[str, Column[ModelType]]
|
|
308
|
+
|
|
309
|
+
extra_sort_functions: Mapping[str, ExtraSortFunction[ModelType]] = field(
|
|
310
|
+
default_factory=dict[str, ExtraSortFunction[ModelType]]
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
fallback_sort_by_pk: bool = True
|
|
314
|
+
|
|
315
|
+
page_limit: int = 100
|
|
316
|
+
table_limit: int = 1000
|
|
317
|
+
key_prefix: str = "table-manager"
|
|
318
|
+
|
|
319
|
+
pk_encoder: PKEncoder[PK] = IntEncoder # type: ignore ; IntEncoder is more specific than PKEncoder[PK], but I _think_ that is okay ; with this in place, pyright still correctly infers the type of PK whether pk_encoder is specified or not
|
|
320
|
+
|
|
321
|
+
def handle(self, post_data: QueryDict) -> HttpResponse:
|
|
322
|
+
"""
|
|
323
|
+
Return whatever API response django_data_tables.js called for.
|
|
324
|
+
If the request wasn't actually directed at us, return HttpResponseBadRequest.
|
|
325
|
+
"""
|
|
326
|
+
try:
|
|
327
|
+
return self.handle_raising(post_data)
|
|
328
|
+
except NotResponsible as e:
|
|
329
|
+
log.warning(e)
|
|
330
|
+
print("not responsible")
|
|
331
|
+
return HttpResponseBadRequest()
|
|
332
|
+
except DataError as e:
|
|
333
|
+
log.warning(e)
|
|
334
|
+
print("data error")
|
|
335
|
+
|
|
336
|
+
return HttpResponseBadRequest(str(e))
|
|
337
|
+
|
|
338
|
+
def handle_if_responsible(self, post_data: QueryDict) -> HttpResponse | None:
|
|
339
|
+
"""
|
|
340
|
+
Return whatever API response django_data_tables.js called for, or None if the request wasn't directed at us.
|
|
341
|
+
|
|
342
|
+
Useful on endpoints that handle multiple data tables (ie. multiple TableManagers with different ids). Eg:
|
|
343
|
+
`return table_manager_1.handle_if_responsible(request.POST) or table_manager_2.handle(request.POST)`
|
|
344
|
+
"""
|
|
345
|
+
try:
|
|
346
|
+
return self.handle_raising(post_data)
|
|
347
|
+
except NotResponsible:
|
|
348
|
+
return None
|
|
349
|
+
except DataError as e:
|
|
350
|
+
log.warning(e)
|
|
351
|
+
return HttpResponseBadRequest(str(e))
|
|
352
|
+
|
|
353
|
+
def handle_raising(self, post_data: QueryDict) -> HttpResponse:
|
|
354
|
+
"""
|
|
355
|
+
Return whatever API response django_data_tables.js called for.
|
|
356
|
+
If the request wasn't actually directed at us, raise NotResponsible.
|
|
357
|
+
If the request is directed at us but doesn't comply with the "data tables API" or contains unexpected column/sort keys, raise DataError.
|
|
358
|
+
|
|
359
|
+
For simple data table pages, handle (and/or handle_if_responsible) should be sufficient.
|
|
360
|
+
"""
|
|
361
|
+
action = post_data.get(f"{self.key_prefix}_action")
|
|
362
|
+
|
|
363
|
+
if action == "get_result_count":
|
|
364
|
+
return JsonResponse(
|
|
365
|
+
dict(
|
|
366
|
+
count=self.filter_function(post_data, self.queryset).count(),
|
|
367
|
+
filter_data_understood=True,
|
|
368
|
+
)
|
|
369
|
+
)
|
|
370
|
+
if action == "get_ids":
|
|
371
|
+
qs = self.filter_function(post_data, self.queryset)
|
|
372
|
+
sort_key = post_data.get(f"{self.key_prefix}_sort_key", None)
|
|
373
|
+
qs, sort_key_understood = self._sort(qs, sort_key)
|
|
374
|
+
|
|
375
|
+
return JsonResponse(
|
|
376
|
+
dict(
|
|
377
|
+
filter_data_understood=True,
|
|
378
|
+
sort_key_understood=sort_key_understood,
|
|
379
|
+
ids=[
|
|
380
|
+
self.pk_encoder.encode(pk)
|
|
381
|
+
for pk in qs.values_list("pk", flat=True)
|
|
382
|
+
],
|
|
383
|
+
),
|
|
384
|
+
)
|
|
385
|
+
if action == "get_table_data":
|
|
386
|
+
ids, ids_understood = self._parse_pks(
|
|
387
|
+
post_data.get(f"{self.key_prefix}_ids", "")
|
|
388
|
+
)
|
|
389
|
+
columns, columns_understood = self._parse_columns(
|
|
390
|
+
post_data.get(f"{self.key_prefix}_columns", "")
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
return JsonResponse(
|
|
394
|
+
dict(
|
|
395
|
+
column_keys_understood=columns_understood,
|
|
396
|
+
ids_understood=ids_understood,
|
|
397
|
+
rows=list(self._get_rows(ids, columns))
|
|
398
|
+
if ids_understood and columns_understood
|
|
399
|
+
else [],
|
|
400
|
+
)
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
raise NotResponsible()
|
|
404
|
+
|
|
405
|
+
def _sort(
|
|
406
|
+
self,
|
|
407
|
+
qs: "QuerySet[ModelType]",
|
|
408
|
+
sort_key: str | None,
|
|
409
|
+
) -> tuple["QuerySet[ModelType]", bool]:
|
|
410
|
+
"""
|
|
411
|
+
Return value is (sorted query set, "sort key was understood")
|
|
412
|
+
"""
|
|
413
|
+
if sort_key is None:
|
|
414
|
+
if self.fallback_sort_by_pk:
|
|
415
|
+
qs = qs.order_by("pk")
|
|
416
|
+
return qs, True
|
|
417
|
+
|
|
418
|
+
for key, sort_function in self.extra_sort_functions.items():
|
|
419
|
+
if key == sort_key:
|
|
420
|
+
return sort_function.function(qs), True
|
|
421
|
+
|
|
422
|
+
for column_key, column in self.columns.items():
|
|
423
|
+
if column.sort_function:
|
|
424
|
+
if sort_key == make_sort_key(column_key, False):
|
|
425
|
+
return column.sort_function(qs), True
|
|
426
|
+
if sort_key == make_sort_key(column_key, True):
|
|
427
|
+
return column.sort_function(qs).reverse(), True
|
|
428
|
+
|
|
429
|
+
return qs, False
|
|
430
|
+
|
|
431
|
+
def _parse_pks(self, pks: str) -> tuple[list[PK], bool]:
|
|
432
|
+
try:
|
|
433
|
+
data = json.loads(pks)
|
|
434
|
+
except json.JSONDecodeError:
|
|
435
|
+
return [], False
|
|
436
|
+
|
|
437
|
+
if not isinstance(data, list):
|
|
438
|
+
return [], False
|
|
439
|
+
|
|
440
|
+
result = list[PK]()
|
|
441
|
+
for value in cast(list[object], data):
|
|
442
|
+
if not isinstance(value, str):
|
|
443
|
+
return [], False
|
|
444
|
+
result.append(self.pk_encoder.decode(value))
|
|
445
|
+
|
|
446
|
+
return result, True
|
|
447
|
+
|
|
448
|
+
def _parse_columns(
|
|
449
|
+
self, columns: str
|
|
450
|
+
) -> tuple[Mapping[str, Column[ModelType]], bool]:
|
|
451
|
+
try:
|
|
452
|
+
data = json.loads(columns)
|
|
453
|
+
except json.JSONDecodeError:
|
|
454
|
+
return {}, False
|
|
455
|
+
|
|
456
|
+
if not isinstance(data, list):
|
|
457
|
+
return {}, False
|
|
458
|
+
|
|
459
|
+
result = dict[str, Column[ModelType]]()
|
|
460
|
+
|
|
461
|
+
for column in data: # type: ignore
|
|
462
|
+
if column not in self.columns:
|
|
463
|
+
return {}, False
|
|
464
|
+
result[column] = self.columns[column]
|
|
465
|
+
|
|
466
|
+
return result, True
|
|
467
|
+
|
|
468
|
+
def _get_rows(
|
|
469
|
+
self, ids: list[PK], columns: Mapping[str, Column[ModelType]]
|
|
470
|
+
) -> Sequence[tuple[str, list[tuple[str, str]]]]:
|
|
471
|
+
pk_order_map = dict((id, index) for index, id in enumerate(ids))
|
|
472
|
+
|
|
473
|
+
qs = self.queryset.filter(pk__in=ids)
|
|
474
|
+
|
|
475
|
+
for column in columns.values():
|
|
476
|
+
qs = column.prepare(qs)
|
|
477
|
+
|
|
478
|
+
simple_formatters: "dict[str, Callable[[ModelType], HtmlGeneratorsContent]]" = (
|
|
479
|
+
dict()
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
for key, column in columns.items():
|
|
483
|
+
simple_formatters[key] = (
|
|
484
|
+
column.formatter.make_simple_formatter(qs) # type: ignore ; no idea why I VSCode thinks there is a type error here ; even without this "ignore comment", pyright does not complain when running manually
|
|
485
|
+
if isinstance(column.formatter, ColumnFormatter)
|
|
486
|
+
else column.formatter
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
rows = [
|
|
490
|
+
(
|
|
491
|
+
row.pk,
|
|
492
|
+
[
|
|
493
|
+
(key, str(h.Fragment(formatter(row))))
|
|
494
|
+
for key, formatter in simple_formatters.items()
|
|
495
|
+
],
|
|
496
|
+
)
|
|
497
|
+
for row in qs
|
|
498
|
+
]
|
|
499
|
+
rows.sort(key=lambda row: pk_order_map[row[0]])
|
|
500
|
+
return [(self.pk_encoder.encode(row[0]), row[1]) for row in rows]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utilities to help define/encode sorting options to pass @qnc/qnc_data_tables front-end
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ColumnSort:
|
|
11
|
+
column_key: str
|
|
12
|
+
direction: Literal["forward", "reverse"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class FunctionSort:
|
|
17
|
+
function_key: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def encode_sort(sort: ColumnSort | FunctionSort | None):
|
|
21
|
+
"""
|
|
22
|
+
Intended to be used by your "render table manager" function if you are using @qnc/qnc_data_tables
|
|
23
|
+
"""
|
|
24
|
+
if not sort:
|
|
25
|
+
return None
|
|
26
|
+
if isinstance(sort, ColumnSort):
|
|
27
|
+
return dict(
|
|
28
|
+
type="column",
|
|
29
|
+
column_key=sort.column_key,
|
|
30
|
+
direction=sort.direction,
|
|
31
|
+
)
|
|
32
|
+
return dict(
|
|
33
|
+
type="function",
|
|
34
|
+
function_key=sort.function_key,
|
|
35
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
qnc_data_tables/__init__.py
|
|
4
|
+
qnc_data_tables/_core.py
|
|
5
|
+
qnc_data_tables/_sort_specification.py
|
|
6
|
+
qnc_data_tables/py.typed
|
|
7
|
+
qnc_data_tables.egg-info/PKG-INFO
|
|
8
|
+
qnc_data_tables.egg-info/SOURCES.txt
|
|
9
|
+
qnc_data_tables.egg-info/dependency_links.txt
|
|
10
|
+
qnc_data_tables.egg-info/requires.txt
|
|
11
|
+
qnc_data_tables.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
qnc_data_tables
|