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.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: qnc_data_tables
3
+ Version: 0.0.6a0
4
+ Summary: Work-in-progress
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: django<6,>=4.2.19
7
+ Requires-Dist: html_generators<3,>=2.8.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,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: qnc_data_tables
3
+ Version: 0.0.6a0
4
+ Summary: Work-in-progress
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: django<6,>=4.2.19
7
+ Requires-Dist: html_generators<3,>=2.8.0
@@ -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,2 @@
1
+ django<6,>=4.2.19
2
+ html_generators<3,>=2.8.0
@@ -0,0 +1 @@
1
+ qnc_data_tables
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+