qnc-data-tables 0.0.0a0__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.

Potentially problematic release.


This version of qnc-data-tables might be problematic. Click here for more details.

@@ -0,0 +1,13 @@
1
+ from ._core import (
2
+ Column,
3
+ ColumnFormatter,
4
+ LineCapper,
5
+ TableManager,
6
+ )
7
+
8
+ __all__ = [
9
+ "Column",
10
+ "ColumnFormatter",
11
+ "LineCapper",
12
+ "TableManager",
13
+ ]
@@ -0,0 +1,464 @@
1
+ import json
2
+ import logging
3
+ from collections.abc import Collection, Iterable, Mapping
4
+ from dataclasses import dataclass
5
+ from typing import (
6
+ Any,
7
+ Callable,
8
+ Generic,
9
+ Optional,
10
+ TypeAlias,
11
+ TypeVar,
12
+ cast,
13
+ Sequence,
14
+ )
15
+
16
+ import html_generators as h
17
+ from django.db.models import Model, QuerySet
18
+ from django.http import (
19
+ HttpResponse,
20
+ HttpResponseBadRequest,
21
+ JsonResponse,
22
+ QueryDict,
23
+ )
24
+ from django.utils.translation import gettext as _
25
+
26
+ log = logging.getLogger(__name__)
27
+
28
+ ModelType = TypeVar("ModelType", bound=Model)
29
+ AggregateResult = TypeVar("AggregateResult")
30
+
31
+ # Some definitions:
32
+ HtmlGeneratorsContent = Any # Content meant for display by html_generators
33
+ InlineHtmlGeneratorsContent = Any # Inline content meant for display by html generators
34
+ FilterFunction: TypeAlias = (
35
+ "Callable[[QueryDict, QuerySet[ModelType]], QuerySet[ModelType]]"
36
+ )
37
+ PK = Any # Anything that can be serialized as JSON
38
+ SortFunction: TypeAlias = "Callable[[QuerySet[ModelType]], QuerySet[ModelType]]"
39
+
40
+
41
+ class ColumnFormatter(Generic[ModelType]):
42
+ """
43
+ To be used by "advanced" Column instances, where a simple Callable formatter isn't powerful enough.
44
+
45
+ aggregator:
46
+ 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).
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ aggregator: "Callable[[QuerySet[ModelType]], AggregateResult]",
52
+ formatter: "Callable[[ModelType, AggregateResult], HtmlGeneratorsContent]",
53
+ ):
54
+ def make_simple_formatter(
55
+ qs: QuerySet[ModelType],
56
+ ) -> Callable[[ModelType], HtmlGeneratorsContent]:
57
+ aggregate_result = aggregator(qs)
58
+
59
+ def format(o: ModelType):
60
+ return formatter(o, aggregate_result)
61
+
62
+ return format
63
+
64
+ self.make_simple_formatter = make_simple_formatter
65
+
66
+
67
+ class Column(Generic[ModelType]):
68
+ def __init__(
69
+ self,
70
+ *,
71
+ # Note - may also be a lazy (translatable) string
72
+ header: str,
73
+ # Note - if you return anything other than an h.Element, in will be wrapped in a <div>
74
+ # 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.)
75
+ # If you do NOT want that to happen, wrap that element in a plain div
76
+ # So you if you want "an <i>apple</i>" to show up on one line, you have to wrap in div AND span
77
+ formatter: "Callable[[ModelType], HtmlGeneratorsContent]|ColumnFormatter[ModelType]",
78
+ # Set to false to make initially-disabled, but still available in column list
79
+ enabled: bool = True,
80
+ # Fixed columns are "sticky" when scrolling horizontally
81
+ # They are "transported" to the left-most part of the table
82
+ # They cannot be sorted/disabled by the user
83
+ # Intended mainly for "main action" column
84
+ fixed: bool = False,
85
+ sort_function: "Optional[SortFunction[ModelType]]" = None,
86
+ # 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)
87
+ prepare: "Callable[[QuerySet[ModelType]], QuerySet[ModelType]]" = lambda qs: qs,
88
+ width: int = 150,
89
+ # Note - may also be a lazy (translatable) string
90
+ help_text: str = "",
91
+ ) -> None:
92
+ self.header = header
93
+ self.formatter = formatter
94
+ self.sort_function = sort_function
95
+ self.prepare = prepare
96
+ self.width = width
97
+ self.help_text = help_text
98
+
99
+ self.enabled = enabled
100
+ self.fixed = fixed
101
+
102
+
103
+ OverflowFormatter = Callable[[int, int], InlineHtmlGeneratorsContent]
104
+
105
+
106
+ def _default_line_overflow_indicator(total: int, overflow_count: int):
107
+ text = (
108
+ (
109
+ # In this case, max_lines is set to 1.
110
+ # There are no "normal" lines showing.
111
+ _("{count} items").format(count=total)
112
+ )
113
+ if total == overflow_count
114
+ # max_lines is more than 1. At least one "normal line" is visible above this.
115
+ else (
116
+ h.template(
117
+ _("{plus_sign}{count} more"),
118
+ count=overflow_count,
119
+ plus_sign=h.B("+"),
120
+ )
121
+ )
122
+ )
123
+
124
+ return h.Span(text, class_="django-data-tables-overflow-indicator")
125
+
126
+
127
+ class LineCapper:
128
+ """
129
+ Utility class for rendering multi-line content that is capped to a certain number of lines.
130
+
131
+ You'll likely want to create one instance per TableManager instance, so that all of your columns are using the same # of max lines.
132
+
133
+ 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.
134
+ """
135
+
136
+ def __init__(self, max_lines: int):
137
+ self.max_lines = max_lines
138
+
139
+ def _unwrapped_line_items(
140
+ self,
141
+ line_items: Iterable[InlineHtmlGeneratorsContent],
142
+ overflow_formatter: OverflowFormatter,
143
+ ):
144
+ items = [item for item in line_items if item]
145
+ total = len(items)
146
+ if total > self.max_lines:
147
+ show_lines = self.max_lines - 1
148
+ return (
149
+ (h.Div(item) for item in items[:show_lines]),
150
+ h.Div(overflow_formatter(total, total - show_lines)),
151
+ )
152
+ return (h.Div(item) for item in items)
153
+
154
+ def line_items(
155
+ self,
156
+ line_items: Iterable[InlineHtmlGeneratorsContent],
157
+ overflow_formatter: OverflowFormatter = _default_line_overflow_indicator,
158
+ ) -> h.Element:
159
+ """
160
+ Each item will be rendered on it's own line.
161
+ If there are more items than max lines, the last line will be used to show an "overflow indicator".
162
+
163
+ 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.
164
+ """
165
+ return h.Div(
166
+ self._unwrapped_line_items(line_items, overflow_formatter),
167
+ )
168
+
169
+ def wrapping_text(self, text: str, newline_indicator: str = "|") -> h.Element:
170
+ """
171
+ Let text wrap, but show "indicator" for explicit newlines.
172
+ Useful for content that was typed into a textarea, or anything else that might have explicit newlines.
173
+
174
+ Default newline indicator is "fullwidth vertical line", which has a little space to either side, unlike plain pipe/bar character.
175
+
176
+ Uses fairly new -webkit-line-clamp.
177
+ Works fairly well IFF the column width is wider than most content words.
178
+
179
+ If a single word is wider than column, it gets broken accross multiple lines (often without hyphenation).
180
+ 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.
181
+ If last line in cell is a single overflowing word (without hyphenation), there is no visual indicator for overflow at all.
182
+ """
183
+ wrapped_content = h.Div(
184
+ newline_indicator.join(text.splitlines()),
185
+ class_="django-data-tables-wrapping-content",
186
+ style=f"-webkit-line-clamp: {self.max_lines}",
187
+ )
188
+ # We need another wrapper element to be used as the cell.
189
+ # 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.
190
+ return h.Div(wrapped_content)
191
+
192
+ def wrapping_content(self, content: InlineHtmlGeneratorsContent) -> h.Element:
193
+ """
194
+ Similar to wrapping_text, but does NOT show "newline indicators".
195
+
196
+ We recommend using wrapping_text for most use cases.
197
+ Use this only if you need to pass html content (formatting), not just plain text.
198
+
199
+ Really intended only basic phrasing content.
200
+ Things like email addresses tend to look better if left on one line (with overflow indicator).
201
+ """
202
+ wrapped_content = h.Div(
203
+ content,
204
+ class_="django-data-tables-wrapping-content",
205
+ style=f"-webkit-line-clamp: {self.max_lines}",
206
+ )
207
+ # We need another wrapper element to be used as the cell.
208
+ # 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.
209
+ return h.Div(wrapped_content)
210
+
211
+ def preserving_text(self, text: str) -> h.Element:
212
+ """
213
+ White space is preserved.
214
+ Each line will show an ellipsis if it overflows.
215
+ The last line will simply show a mid-line ellipsis if there are more lines than max_lines.
216
+ """
217
+ return h.Div(
218
+ self._unwrapped_line_items(
219
+ text.splitlines(),
220
+ lambda total, overflow_count: "⋯",
221
+ ),
222
+ style="white-space: pre;",
223
+ )
224
+
225
+ # Note - we _might_ also want to support prewrap, and/or a variation thereof (preserving line breaks, but collapsing/trimming spaces)
226
+
227
+
228
+ class NotResponsible(Exception):
229
+ pass
230
+
231
+
232
+ class DataError(Exception):
233
+ pass
234
+
235
+
236
+ def make_sort_key(column_key: str, reverse: bool):
237
+ """
238
+ Used by TableManager when interpreting the sort_key passed by the front-end.
239
+ May be useful when configuring a default sort key on your front-end.
240
+ """
241
+ if reverse:
242
+ return f"_column_reverse_{column_key}"
243
+ return f"_column_forward_{column_key}"
244
+
245
+
246
+ @dataclass(kw_only=True)
247
+ class TableManager(Generic[ModelType]):
248
+ """
249
+ Implements the back-end of the "QNC Data Table API V1".
250
+
251
+ 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 create whatever HTML/JS is needed by your front-end.
252
+
253
+ queryset:
254
+ - all of the objects that _may_ be shown in the table
255
+ - if filter_function is used, the displayed result set may be a subset of this
256
+
257
+ filter_function:
258
+ - a function to be called
259
+
260
+ columns:
261
+ - the available columns
262
+
263
+ extra_sort_functions:
264
+ - collection of (key, display, sort_function) tuples
265
+ - if non-None, the front-end should render a "sort widget" somewhere, with these as options
266
+
267
+ fallback_sort_by_pk:
268
+ If the front-end has not specified any sort order, sort queryset by pk.
269
+ Default is True (ensures that we always use a specified/consistent ordering)
270
+ Set to False if queryset is already ordered, and you want to retain that ordering when front-end does not specify ordering.
271
+ 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.
272
+ """
273
+
274
+ queryset: "QuerySet[ModelType]"
275
+
276
+ filter_function: "FilterFunction[ModelType]" = (
277
+ lambda query_dict, query_set: query_set
278
+ )
279
+
280
+ columns: Mapping[str, Column[ModelType]]
281
+
282
+ extra_sort_functions: Collection[
283
+ tuple[str, InlineHtmlGeneratorsContent, "SortFunction[ModelType]"]
284
+ ] = ()
285
+
286
+ fallback_sort_by_pk: bool = True
287
+
288
+ page_limit: int = 100
289
+ table_limit: int = 1000
290
+ key_prefix: str = "table-manager"
291
+
292
+ def handle(self, post_data: QueryDict) -> HttpResponse:
293
+ """
294
+ Return whatever API response django_data_tables.js called for.
295
+ If the request wasn't actually directed at us, return HttpResponseBadRequest.
296
+ """
297
+ try:
298
+ return self.handle_raising(post_data)
299
+ except NotResponsible as e:
300
+ log.warning(e)
301
+ print("not responsible")
302
+ return HttpResponseBadRequest()
303
+ except DataError as e:
304
+ log.warning(e)
305
+ print("data error")
306
+
307
+ return HttpResponseBadRequest(str(e))
308
+
309
+ def handle_if_responsible(self, post_data: QueryDict) -> HttpResponse | None:
310
+ """
311
+ Return whatever API response django_data_tables.js called for, or None if the request wasn't directed at us.
312
+
313
+ Useful on endpoints that handle multiple data tables (ie. multiple TableManagers with different ids). Eg:
314
+ `return table_manager_1.handle_if_responsible(request.POST) or table_manager_2.handle(request.POST)`
315
+ """
316
+ try:
317
+ return self.handle_raising(post_data)
318
+ except NotResponsible:
319
+ return None
320
+ except DataError as e:
321
+ log.warning(e)
322
+ return HttpResponseBadRequest(str(e))
323
+
324
+ def handle_raising(self, post_data: QueryDict) -> HttpResponse:
325
+ """
326
+ Return whatever API response django_data_tables.js called for.
327
+ If the request wasn't actually directed at us, raise NotResponsible.
328
+ If the request is directed at us but doesn't comply with the "data tables API" or contains unexpected column/sort keys, raise DataError.
329
+
330
+ For simple data table pages, handle (and/or handle_if_responsible) should be sufficient.
331
+ """
332
+ action = post_data.get(f"{self.key_prefix}_action")
333
+
334
+ if action == "get_result_count":
335
+ return JsonResponse(
336
+ dict(
337
+ count=self.filter_function(post_data, self.queryset).count(),
338
+ filter_data_understood=True,
339
+ )
340
+ )
341
+ if action == "get_ids":
342
+ qs = self.filter_function(post_data, self.queryset)
343
+ sort_key = post_data.get(f"{self.key_prefix}_sort_key", None)
344
+ qs, sort_key_understood = self._sort(qs, sort_key)
345
+
346
+ return JsonResponse(
347
+ dict(
348
+ filter_data_understood=True,
349
+ sort_key_understood=sort_key_understood,
350
+ ids=list(qs.values_list("pk", flat=True)),
351
+ ),
352
+ )
353
+ if action == "get_table_data":
354
+ ids, ids_understood = self._parse_pks(
355
+ post_data.get(f"{self.key_prefix}_ids", "")
356
+ )
357
+ columns, columns_understood = self._parse_columns(
358
+ post_data.get(f"{self.key_prefix}_columns", "")
359
+ )
360
+
361
+ return JsonResponse(
362
+ dict(
363
+ column_keys_understood=columns_understood,
364
+ ids_understood=ids_understood,
365
+ rows=list(self._get_rows(ids, columns))
366
+ if ids_understood and columns_understood
367
+ else [],
368
+ )
369
+ )
370
+
371
+ raise NotResponsible()
372
+
373
+ def _sort(
374
+ self,
375
+ qs: "QuerySet[ModelType]",
376
+ sort_key: str | None,
377
+ ) -> tuple["QuerySet[ModelType]", bool]:
378
+ """
379
+ Return value is (sorted query set, "sort key was understood")
380
+ """
381
+ if sort_key is None:
382
+ if self.fallback_sort_by_pk:
383
+ qs = qs.order_by("pk")
384
+ return qs, True
385
+
386
+ for key, display_name, sort_function in self.extra_sort_functions:
387
+ del display_name # don't need this
388
+ if key == sort_key:
389
+ return sort_function(qs), True
390
+
391
+ for column_key, column in self.columns.items():
392
+ if column.sort_function:
393
+ if sort_key == make_sort_key(column_key, False):
394
+ return column.sort_function(qs), True
395
+ if sort_key == make_sort_key(column_key, True):
396
+ return column.sort_function(qs).reverse(), True
397
+
398
+ return qs, False
399
+
400
+ def _parse_pks(self, pks: str) -> tuple[list[Any], bool]:
401
+ try:
402
+ data = json.loads(pks)
403
+ except json.JSONDecodeError:
404
+ return [], False
405
+
406
+ if not isinstance(data, list):
407
+ return [], False
408
+
409
+ return cast(list[Any], data), True
410
+
411
+ def _parse_columns(
412
+ self, columns: str
413
+ ) -> tuple[Mapping[str, Column[ModelType]], bool]:
414
+ try :
415
+ data = json.loads(columns)
416
+ except json.JSONDecodeError :
417
+ return {}, False
418
+
419
+ if not isinstance(data, list):
420
+ return {}, False
421
+
422
+ result = dict[str, Column[ModelType]]()
423
+
424
+ for column in data : # type: ignore
425
+ if column not in self.columns :
426
+ return {}, False
427
+ result[column] = self.columns[column]
428
+
429
+ return result, True
430
+
431
+
432
+ def _get_rows(self, ids: list[Any], columns: Mapping[str, Column[ModelType]]) -> Sequence[
433
+ tuple[PK, Mapping[str, str]]
434
+ ]:
435
+ pk_order_map = dict((id, index) for index, id in enumerate(ids))
436
+
437
+ qs = self.queryset.filter(pk__in=ids)
438
+
439
+ for column in columns.values():
440
+ qs = column.prepare(qs)
441
+
442
+ simple_formatters: "dict[str, Callable[[ModelType], HtmlGeneratorsContent]]" = (
443
+ dict()
444
+ )
445
+
446
+ for key, column in columns.items():
447
+ simple_formatters[key] = (
448
+ 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
449
+ if isinstance(column.formatter, ColumnFormatter)
450
+ else column.formatter
451
+ )
452
+
453
+ rows = [
454
+ (
455
+ row.pk,
456
+ {
457
+ key: str(h.Fragment(formatter(row)))
458
+ for key, formatter in simple_formatters.items()
459
+ },
460
+ )
461
+ for row in qs
462
+ ]
463
+ rows.sort(key=lambda row: pk_order_map[row[0]])
464
+ return rows
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: qnc_data_tables
3
+ Version: 0.0.0a0
4
+ Summary: Work-in-progress
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: django<5,>=4.2.19
7
+ Requires-Dist: html_generators<3,>=2.8.0
@@ -0,0 +1,6 @@
1
+ qnc_data_tables/__init__.py,sha256=j6lJz-meAVrXF-yyVM_fYI8mfTB5TuxPAoru2-3WJRk,180
2
+ qnc_data_tables/_core.py,sha256=I8jbt5GGUuYCQqawr_PBuUwXCzRQ1HkljrCHOnV4cZg,18130
3
+ qnc_data_tables-0.0.0a0.dist-info/METADATA,sha256=i9BS0eNfIY_4SamCpUpgZmfFGwyZbqE1s3Sc3Nej6v4,185
4
+ qnc_data_tables-0.0.0a0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
5
+ qnc_data_tables-0.0.0a0.dist-info/top_level.txt,sha256=1tEhOrNQoADPnWAoxPa8nHPc31H_Vb-TiyasxB1KZ8E,16
6
+ qnc_data_tables-0.0.0a0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ qnc_data_tables