django-datashow 0.1.0__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.
- django_datashow-0.1.0/.gitignore +13 -0
- django_datashow-0.1.0/LICENSE +21 -0
- django_datashow-0.1.0/PKG-INFO +60 -0
- django_datashow-0.1.0/README.md +49 -0
- django_datashow-0.1.0/datashow/__init__.py +0 -0
- django_datashow-0.1.0/datashow/admin.py +159 -0
- django_datashow-0.1.0/datashow/apps.py +6 -0
- django_datashow-0.1.0/datashow/db.py +85 -0
- django_datashow-0.1.0/datashow/fields.py +83 -0
- django_datashow-0.1.0/datashow/formatters.py +113 -0
- django_datashow-0.1.0/datashow/forms.py +82 -0
- django_datashow-0.1.0/datashow/locale/de/LC_MESSAGES/django.po +491 -0
- django_datashow-0.1.0/datashow/migrations/0001_initial.py +402 -0
- django_datashow-0.1.0/datashow/migrations/0002_column_facet_count.py +18 -0
- django_datashow-0.1.0/datashow/migrations/__init__.py +0 -0
- django_datashow-0.1.0/datashow/models.py +368 -0
- django_datashow-0.1.0/datashow/query.py +154 -0
- django_datashow-0.1.0/datashow/settings.py +4 -0
- django_datashow-0.1.0/datashow/static/datashow/css/datashow.css +28 -0
- django_datashow-0.1.0/datashow/static/datashow/js/htmx.min.js +1 -0
- django_datashow-0.1.0/datashow/table.py +295 -0
- django_datashow-0.1.0/datashow/templates/datashow/_filterform.html +1 -0
- django_datashow-0.1.0/datashow/templates/datashow/_table.html +116 -0
- django_datashow-0.1.0/datashow/templates/datashow/base.html +27 -0
- django_datashow-0.1.0/datashow/templates/datashow/dataset_index.html +17 -0
- django_datashow-0.1.0/datashow/templates/datashow/dataset_row.html +20 -0
- django_datashow-0.1.0/datashow/templates/datashow/dataset_sql.html +34 -0
- django_datashow-0.1.0/datashow/templates/datashow/dataset_table.html +13 -0
- django_datashow-0.1.0/datashow/templates/datashow/pagination.html +100 -0
- django_datashow-0.1.0/datashow/templates/datashow/widgets/range.html +7 -0
- django_datashow-0.1.0/datashow/templatetags/__init__.py +0 -0
- django_datashow-0.1.0/datashow/templatetags/datashow_tags.py +32 -0
- django_datashow-0.1.0/datashow/urls.py +42 -0
- django_datashow-0.1.0/datashow/views.py +147 -0
- django_datashow-0.1.0/pyproject.toml +20 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025, Open Knowledge Foundation Deutschland e.V. / Stefan Wehrmeyer
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-datashow
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Present your datasets on your django site
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: django-admin-sortable2>=2
|
|
8
|
+
Requires-Dist: django>=4.2
|
|
9
|
+
Requires-Dist: markdown>=3.7
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# django-datashow
|
|
13
|
+
|
|
14
|
+
A Django app to show SQLite datasets as tables with configurable features:
|
|
15
|
+
|
|
16
|
+
- column value formatting
|
|
17
|
+
- column sorting
|
|
18
|
+
- column facets and filters
|
|
19
|
+
- searching via SQLite's FTS5
|
|
20
|
+
- pagination
|
|
21
|
+
- exporting to CSV
|
|
22
|
+
- row-level detail view.
|
|
23
|
+
|
|
24
|
+
This app is designed for read-only (or rarely updated) datasets that you want to publish on the web and where you control and trust the content.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
pip install django-datashow
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Add `datashow` to your `INSTALLED_APPS` in `settings.py`:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
INSTALLED_APPS = [
|
|
36
|
+
...
|
|
37
|
+
'datashow',
|
|
38
|
+
...
|
|
39
|
+
]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Hook up the `datashow` URLs in your project's `urls.py`:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from django.urls import path, include
|
|
46
|
+
|
|
47
|
+
urlpatterns = [
|
|
48
|
+
...
|
|
49
|
+
path('data/', include('datashow.urls')),
|
|
50
|
+
...
|
|
51
|
+
]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
## Settings
|
|
56
|
+
|
|
57
|
+
You can configure the following settings in your `settings.py`:
|
|
58
|
+
|
|
59
|
+
- `DATASHOW_DB_CACHE_PATH`: Path to the SQLite database cache directory. If not set, uses a temporary directory. When the SQLite file needs to be read, it gets copied from your MEDIA storage to this cache path. A new dataset version invalidates the cache.
|
|
60
|
+
- `DATASHOW_STORAGE_BACKEND`: The Django storage backend name to use for storing the SQLite files. Default is `"default"`.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# django-datashow
|
|
2
|
+
|
|
3
|
+
A Django app to show SQLite datasets as tables with configurable features:
|
|
4
|
+
|
|
5
|
+
- column value formatting
|
|
6
|
+
- column sorting
|
|
7
|
+
- column facets and filters
|
|
8
|
+
- searching via SQLite's FTS5
|
|
9
|
+
- pagination
|
|
10
|
+
- exporting to CSV
|
|
11
|
+
- row-level detail view.
|
|
12
|
+
|
|
13
|
+
This app is designed for read-only (or rarely updated) datasets that you want to publish on the web and where you control and trust the content.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
pip install django-datashow
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Add `datashow` to your `INSTALLED_APPS` in `settings.py`:
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
INSTALLED_APPS = [
|
|
25
|
+
...
|
|
26
|
+
'datashow',
|
|
27
|
+
...
|
|
28
|
+
]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Hook up the `datashow` URLs in your project's `urls.py`:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from django.urls import path, include
|
|
35
|
+
|
|
36
|
+
urlpatterns = [
|
|
37
|
+
...
|
|
38
|
+
path('data/', include('datashow.urls')),
|
|
39
|
+
...
|
|
40
|
+
]
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
## Settings
|
|
45
|
+
|
|
46
|
+
You can configure the following settings in your `settings.py`:
|
|
47
|
+
|
|
48
|
+
- `DATASHOW_DB_CACHE_PATH`: Path to the SQLite database cache directory. If not set, uses a temporary directory. When the SQLite file needs to be read, it gets copied from your MEDIA storage to this cache path. A new dataset version invalidates the cache.
|
|
49
|
+
- `DATASHOW_STORAGE_BACKEND`: The Django storage backend name to use for storing the SQLite files. Default is `"default"`.
|
|
File without changes
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from adminsortable2.admin import SortableAdminMixin
|
|
2
|
+
from django.contrib import admin
|
|
3
|
+
from django.forms import ModelForm
|
|
4
|
+
from django.http import HttpRequest
|
|
5
|
+
from django.utils.translation import gettext_lazy as _
|
|
6
|
+
|
|
7
|
+
from .models import Column, Dataset, Table
|
|
8
|
+
from .table import initialize_dataset, setup_fts
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@admin.register(Dataset)
|
|
12
|
+
class DatasetAdmin(admin.ModelAdmin):
|
|
13
|
+
list_display = ("name", "created_at", "updated_at", "version", "public", "listed")
|
|
14
|
+
list_filter = ("public", "listed")
|
|
15
|
+
search_fields = ("name", "slug")
|
|
16
|
+
readonly_fields = (
|
|
17
|
+
"created_at",
|
|
18
|
+
"updated_at",
|
|
19
|
+
)
|
|
20
|
+
prepopulated_fields = {"slug": ("name",)}
|
|
21
|
+
raw_id_fields = ("default_table",)
|
|
22
|
+
actions = ["initialize_dataset"]
|
|
23
|
+
|
|
24
|
+
def save_model(
|
|
25
|
+
self, request: HttpRequest, obj: Dataset, form: ModelForm, change: bool
|
|
26
|
+
) -> None:
|
|
27
|
+
super().save_model(request, obj, form, change)
|
|
28
|
+
if not obj.tables.exists():
|
|
29
|
+
initialize_dataset(obj)
|
|
30
|
+
|
|
31
|
+
@admin.action(description=_("Initialize datasets"))
|
|
32
|
+
def initialize_dataset(self, request: HttpRequest, queryset):
|
|
33
|
+
for dataset in queryset:
|
|
34
|
+
initialize_dataset(dataset)
|
|
35
|
+
self.message_user(request, _("Datasets initialized."))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ColumnInline(admin.StackedInline):
|
|
39
|
+
model = Column
|
|
40
|
+
extra = 0
|
|
41
|
+
fields = (
|
|
42
|
+
"label",
|
|
43
|
+
"visible",
|
|
44
|
+
"visible_detail",
|
|
45
|
+
"sortable",
|
|
46
|
+
"searchable",
|
|
47
|
+
"facet_count",
|
|
48
|
+
"prefix",
|
|
49
|
+
"postfix",
|
|
50
|
+
"formatter",
|
|
51
|
+
"formatter_arguments",
|
|
52
|
+
"filter",
|
|
53
|
+
"filter_arguments",
|
|
54
|
+
)
|
|
55
|
+
ordering = ("order",)
|
|
56
|
+
show_change_link = True
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@admin.register(Table)
|
|
60
|
+
class TableAdmin(admin.ModelAdmin):
|
|
61
|
+
inlines = [ColumnInline]
|
|
62
|
+
list_display = ("name", "dataset", "row_count", "visible")
|
|
63
|
+
search_fields = ("name", "slug")
|
|
64
|
+
raw_id_fields = ("primary_key",)
|
|
65
|
+
save_on_top = True
|
|
66
|
+
list_filter = ("dataset", "visible")
|
|
67
|
+
actions = ["generate_fts"]
|
|
68
|
+
fieldsets = (
|
|
69
|
+
(
|
|
70
|
+
None,
|
|
71
|
+
{
|
|
72
|
+
"fields": (
|
|
73
|
+
"slug",
|
|
74
|
+
"label",
|
|
75
|
+
"description",
|
|
76
|
+
"visible",
|
|
77
|
+
"row_label_template",
|
|
78
|
+
"primary_key",
|
|
79
|
+
"pagination_size",
|
|
80
|
+
)
|
|
81
|
+
},
|
|
82
|
+
),
|
|
83
|
+
(
|
|
84
|
+
_("Advanced"),
|
|
85
|
+
{
|
|
86
|
+
"classes": ["collapse"],
|
|
87
|
+
"fields": [
|
|
88
|
+
"name",
|
|
89
|
+
"sql",
|
|
90
|
+
"dataset",
|
|
91
|
+
"row_count",
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
@admin.action(description=_("Generate FTS tables"))
|
|
98
|
+
def generate_fts(self, request: HttpRequest, queryset):
|
|
99
|
+
for table in queryset:
|
|
100
|
+
setup_fts(table)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@admin.register(Column)
|
|
104
|
+
class ColumnAdmin(SortableAdminMixin, admin.ModelAdmin):
|
|
105
|
+
search_fields = ("name", "label")
|
|
106
|
+
list_filter = ("table", "visible", "visible_detail", "sortable", "searchable")
|
|
107
|
+
list_display = (
|
|
108
|
+
"name",
|
|
109
|
+
"label",
|
|
110
|
+
"table",
|
|
111
|
+
"visible",
|
|
112
|
+
"visible_detail",
|
|
113
|
+
"sortable",
|
|
114
|
+
"searchable",
|
|
115
|
+
"facet_count",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
actions = [
|
|
119
|
+
"make_visible",
|
|
120
|
+
"make_invisible",
|
|
121
|
+
"make_visible_detail",
|
|
122
|
+
"make_invisible_detail",
|
|
123
|
+
"make_sortable",
|
|
124
|
+
"make_unsortable",
|
|
125
|
+
"make_searchable",
|
|
126
|
+
"make_unsearchable",
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
@admin.action(description=_("Make visible"))
|
|
130
|
+
def make_visible(self, request: HttpRequest, queryset):
|
|
131
|
+
queryset.update(visible=True)
|
|
132
|
+
|
|
133
|
+
@admin.action(description=_("Make invisible"))
|
|
134
|
+
def make_invisible(self, request: HttpRequest, queryset):
|
|
135
|
+
queryset.update(visible=False)
|
|
136
|
+
|
|
137
|
+
@admin.action(description=_("Make visible_detail"))
|
|
138
|
+
def make_visible_detail(self, request: HttpRequest, queryset):
|
|
139
|
+
queryset.update(visible_detail=True)
|
|
140
|
+
|
|
141
|
+
@admin.action(description=_("Make invisible_detail"))
|
|
142
|
+
def make_invisible_detail(self, request: HttpRequest, queryset):
|
|
143
|
+
queryset.update(visible_detail=False)
|
|
144
|
+
|
|
145
|
+
@admin.action(description=_("Make sortable"))
|
|
146
|
+
def make_sortable(self, request: HttpRequest, queryset):
|
|
147
|
+
queryset.update(sortable=True)
|
|
148
|
+
|
|
149
|
+
@admin.action(description=_("Make unsortable"))
|
|
150
|
+
def make_unsortable(self, request: HttpRequest, queryset):
|
|
151
|
+
queryset.update(sortable=False)
|
|
152
|
+
|
|
153
|
+
@admin.action(description=_("Make searchable"))
|
|
154
|
+
def make_searchable(self, request: HttpRequest, queryset):
|
|
155
|
+
queryset.update(searchable=True)
|
|
156
|
+
|
|
157
|
+
@admin.action(description=_("Make unsearchable"))
|
|
158
|
+
def make_unsearchable(self, request: HttpRequest, queryset):
|
|
159
|
+
queryset.update(searchable=False)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
import tempfile
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from django.core.files.base import ContentFile
|
|
7
|
+
|
|
8
|
+
from .models import Dataset
|
|
9
|
+
from .settings import DATASHOW_DB_CACHE_PATH
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_database_filename(dataset: Dataset) -> str:
|
|
13
|
+
return "datashow-dataset-{}-{}.sqlite".format(dataset.id, dataset.version)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_database_file(dataset: Dataset) -> Path:
|
|
17
|
+
filename = get_database_filename(dataset)
|
|
18
|
+
if DATASHOW_DB_CACHE_PATH is None:
|
|
19
|
+
base_path = Path(tempfile.gettempdir())
|
|
20
|
+
else:
|
|
21
|
+
base_path = Path(DATASHOW_DB_CACHE_PATH)
|
|
22
|
+
|
|
23
|
+
filepath = base_path / filename
|
|
24
|
+
if filepath.exists():
|
|
25
|
+
return filepath
|
|
26
|
+
|
|
27
|
+
base_path.mkdir(exist_ok=True)
|
|
28
|
+
|
|
29
|
+
with dataset.sqlite_file.open("rb") as sqlite_file:
|
|
30
|
+
with open(filepath, "wb") as cache_file:
|
|
31
|
+
cache_file.write(sqlite_file.read())
|
|
32
|
+
|
|
33
|
+
return filepath
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_database_connection(dataset: Dataset):
|
|
37
|
+
filepath = get_database_file(dataset)
|
|
38
|
+
return sqlite3.connect("file:{}?mode=ro".format(filepath), uri=True)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@contextmanager
|
|
42
|
+
def open_db(dataset):
|
|
43
|
+
connection = get_database_connection(dataset)
|
|
44
|
+
try:
|
|
45
|
+
yield connection
|
|
46
|
+
finally:
|
|
47
|
+
connection.close()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@contextmanager
|
|
51
|
+
def open_cursor(dataset):
|
|
52
|
+
with open_db(dataset) as connection:
|
|
53
|
+
cursor = connection.cursor()
|
|
54
|
+
try:
|
|
55
|
+
yield cursor
|
|
56
|
+
finally:
|
|
57
|
+
cursor.close()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@contextmanager
|
|
61
|
+
def open_write_cursor(dataset: Dataset):
|
|
62
|
+
filepath = get_database_file(dataset)
|
|
63
|
+
connection = sqlite3.connect(
|
|
64
|
+
"file:{}".format(filepath), uri=True, isolation_level="EXCLUSIVE"
|
|
65
|
+
)
|
|
66
|
+
assert not connection.in_transaction
|
|
67
|
+
try:
|
|
68
|
+
cursor = connection.cursor()
|
|
69
|
+
|
|
70
|
+
cursor.execute("PRAGMA JOURNAL_MODE = DELETE;")
|
|
71
|
+
# Smaller page size for better performance when using HTTP range requests
|
|
72
|
+
cursor.execute("PRAGMA page_size = 1024;")
|
|
73
|
+
yield cursor
|
|
74
|
+
finally:
|
|
75
|
+
cursor.execute("COMMIT;")
|
|
76
|
+
cursor.execute("VACUUM;")
|
|
77
|
+
connection.commit()
|
|
78
|
+
connection.close()
|
|
79
|
+
|
|
80
|
+
# Write database back to origin and update version
|
|
81
|
+
with open(filepath, "rb") as cache_file:
|
|
82
|
+
new_sqlite = ContentFile(cache_file.read())
|
|
83
|
+
dataset.version += 1
|
|
84
|
+
filename = get_database_filename(dataset)
|
|
85
|
+
dataset.sqlite_file.save(filename, new_sqlite, save=True)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from django import forms
|
|
2
|
+
|
|
3
|
+
# from django.template.loader import render_to_string
|
|
4
|
+
from django.utils.translation import gettext_lazy as _
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RangeWidget(forms.MultiWidget):
|
|
8
|
+
template_name = "datashow/widgets/range.html"
|
|
9
|
+
|
|
10
|
+
def __init__(self, widget, *args, **kwargs):
|
|
11
|
+
self.prefix = kwargs.pop("prefix", "")
|
|
12
|
+
self.postfix = kwargs.pop("postfix", "")
|
|
13
|
+
super().__init__(widgets=(widget, widget), *args, **kwargs)
|
|
14
|
+
self.widgets_names = ["_min", "_max"]
|
|
15
|
+
|
|
16
|
+
def decompress(self, value):
|
|
17
|
+
return value
|
|
18
|
+
|
|
19
|
+
def get_context(self, name, value, attrs):
|
|
20
|
+
ctx = super().get_context(name, value, attrs)
|
|
21
|
+
ctx["prefix"] = self.prefix
|
|
22
|
+
ctx["postfix"] = self.postfix
|
|
23
|
+
return ctx
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RangeField(forms.MultiValueField):
|
|
27
|
+
default_error_messages = {
|
|
28
|
+
"invalid_start": _("Enter a valid start value."),
|
|
29
|
+
"invalid_end": _("Enter a valid end value."),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
field_class,
|
|
35
|
+
min_value=None,
|
|
36
|
+
max_value=None,
|
|
37
|
+
prefix="",
|
|
38
|
+
postfix="",
|
|
39
|
+
widget=forms.TextInput,
|
|
40
|
+
*args,
|
|
41
|
+
**kwargs,
|
|
42
|
+
):
|
|
43
|
+
if "initial" not in kwargs:
|
|
44
|
+
kwargs["initial"] = ["", ""]
|
|
45
|
+
|
|
46
|
+
fields = (
|
|
47
|
+
field_class(
|
|
48
|
+
min_value=min_value,
|
|
49
|
+
max_value=max_value,
|
|
50
|
+
),
|
|
51
|
+
field_class(
|
|
52
|
+
min_value=min_value,
|
|
53
|
+
max_value=max_value,
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
super().__init__(
|
|
58
|
+
fields=fields,
|
|
59
|
+
widget=RangeWidget(widget, prefix=prefix, postfix=postfix),
|
|
60
|
+
*args,
|
|
61
|
+
**kwargs,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def compress(self, data_list):
|
|
65
|
+
if data_list:
|
|
66
|
+
return [
|
|
67
|
+
self.fields[0].clean(data_list[0]),
|
|
68
|
+
self.fields[1].clean(data_list[1]),
|
|
69
|
+
]
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class CommaSeparatedMultiChoiceField(forms.MultipleChoiceField):
|
|
74
|
+
default_error_messages = {
|
|
75
|
+
"invalid_start": _("Enter a valid start value."),
|
|
76
|
+
"invalid_end": _("Enter a valid end value."),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
def to_python(self, value):
|
|
80
|
+
if value is None:
|
|
81
|
+
return []
|
|
82
|
+
value = value.split(",")
|
|
83
|
+
return [str(val) for val in value]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from django.contrib.humanize.templatetags.humanize import intcomma
|
|
5
|
+
from django.utils import formats
|
|
6
|
+
from django.utils.html import format_html
|
|
7
|
+
from django.utils.safestring import mark_safe
|
|
8
|
+
from django.utils.translation import gettext_lazy as _
|
|
9
|
+
|
|
10
|
+
from .models import FormaterChoices
|
|
11
|
+
|
|
12
|
+
TRAILING_ZERO = re.compile(r"[,\.]0+$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def render_value(column, value):
|
|
16
|
+
return format_html(
|
|
17
|
+
"{prefix}{value}{postfix}",
|
|
18
|
+
prefix=column.prefix,
|
|
19
|
+
value=value,
|
|
20
|
+
postfix=column.postfix,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def try_format(args, key, row_data, default=""):
|
|
25
|
+
try:
|
|
26
|
+
value = args[key]
|
|
27
|
+
return value.format(**row_data)
|
|
28
|
+
except (KeyError, ValueError):
|
|
29
|
+
return default
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
ALIGN_RIGHT = "text-end"
|
|
33
|
+
ALIGN_CENTER = "text-center"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def format_column(column):
|
|
37
|
+
css = ""
|
|
38
|
+
formatter = column.formatter
|
|
39
|
+
if formatter == FormaterChoices.FLOAT:
|
|
40
|
+
css = ALIGN_RIGHT
|
|
41
|
+
elif formatter == FormaterChoices.INTEGER:
|
|
42
|
+
css = ALIGN_RIGHT
|
|
43
|
+
elif formatter == FormaterChoices.DATE:
|
|
44
|
+
css = ALIGN_RIGHT
|
|
45
|
+
elif formatter == FormaterChoices.DATETIME:
|
|
46
|
+
css = ALIGN_RIGHT
|
|
47
|
+
elif formatter == FormaterChoices.BOOLEAN:
|
|
48
|
+
css = ALIGN_CENTER
|
|
49
|
+
return css
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def format_value(column, value, row_data, detail=False):
|
|
53
|
+
css = ""
|
|
54
|
+
formatter = column.formatter
|
|
55
|
+
if value is None:
|
|
56
|
+
if formatter == FormaterChoices.BOOLEAN:
|
|
57
|
+
css = ALIGN_CENTER
|
|
58
|
+
if formatter in (
|
|
59
|
+
FormaterChoices.FLOAT,
|
|
60
|
+
FormaterChoices.INTEGER,
|
|
61
|
+
FormaterChoices.DATE,
|
|
62
|
+
FormaterChoices.DATETIME,
|
|
63
|
+
):
|
|
64
|
+
css = ALIGN_RIGHT
|
|
65
|
+
return css, mark_safe('<span class="text-secondary">–</span>')
|
|
66
|
+
|
|
67
|
+
args = column.formatter_arguments
|
|
68
|
+
if formatter == FormaterChoices.FLOAT:
|
|
69
|
+
value = intcomma(value)
|
|
70
|
+
css = ALIGN_RIGHT
|
|
71
|
+
elif formatter == FormaterChoices.INTEGER:
|
|
72
|
+
value = TRAILING_ZERO.sub("", intcomma(value))
|
|
73
|
+
css = ALIGN_RIGHT
|
|
74
|
+
elif formatter == FormaterChoices.DATE:
|
|
75
|
+
value = formats.date_format(datetime.fromisoformat(value), "SHORT_DATE_FORMAT")
|
|
76
|
+
css = ALIGN_RIGHT
|
|
77
|
+
elif formatter == FormaterChoices.DATETIME:
|
|
78
|
+
value = formats.date_format(
|
|
79
|
+
datetime.fromisoformat(value), "SHORT_DATETIME_FORMAT"
|
|
80
|
+
)
|
|
81
|
+
css = ALIGN_RIGHT
|
|
82
|
+
elif formatter == FormaterChoices.BOOLEAN:
|
|
83
|
+
if value:
|
|
84
|
+
value = mark_safe('<span class="text-success">✅</span>')
|
|
85
|
+
else:
|
|
86
|
+
value = mark_safe('<span class="text-danger">❌</span>')
|
|
87
|
+
css = ALIGN_CENTER
|
|
88
|
+
elif formatter == FormaterChoices.LINK:
|
|
89
|
+
url = try_format(args, "url", row_data, "")
|
|
90
|
+
value = format_html(
|
|
91
|
+
'<a href="{href}">{link}</a>',
|
|
92
|
+
href=url,
|
|
93
|
+
link=value,
|
|
94
|
+
)
|
|
95
|
+
elif formatter == FormaterChoices.SUMMARY:
|
|
96
|
+
if not detail:
|
|
97
|
+
summary = try_format(args, "summary", row_data, _("Details"))
|
|
98
|
+
value = format_html(
|
|
99
|
+
'<details class="datashow-summary"><summary><span>{summary}</span></summary>{details}</details>',
|
|
100
|
+
summary=summary,
|
|
101
|
+
details=value,
|
|
102
|
+
)
|
|
103
|
+
elif formatter == FormaterChoices.ABBREVIATION:
|
|
104
|
+
title = try_format(args, "title", row_data, None)
|
|
105
|
+
if title is None:
|
|
106
|
+
value = format_html("<abbr>{value}</abbr>", value=value)
|
|
107
|
+
else:
|
|
108
|
+
value = format_html(
|
|
109
|
+
'<abbr title="{title}">{value}</abbr>',
|
|
110
|
+
title=title,
|
|
111
|
+
value=value,
|
|
112
|
+
)
|
|
113
|
+
return css, render_value(column, value)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from typing import NamedTuple
|
|
2
|
+
|
|
3
|
+
from django import forms
|
|
4
|
+
from django.utils.translation import gettext_lazy as _
|
|
5
|
+
|
|
6
|
+
from .fields import CommaSeparatedMultiChoiceField, RangeField
|
|
7
|
+
from .models import FilterChoices
|
|
8
|
+
|
|
9
|
+
SEARCH_PARAM = "q"
|
|
10
|
+
SORT_PARAM = "sort"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SqlParts(NamedTuple):
|
|
14
|
+
from_sql: list[str]
|
|
15
|
+
where_sql: list[str]
|
|
16
|
+
where_params: list
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FilterForm(forms.Form):
|
|
20
|
+
def __init__(self, table, *args, **kwargs):
|
|
21
|
+
self.table = table
|
|
22
|
+
super().__init__(*args, **kwargs)
|
|
23
|
+
sortable_columns = [col.name for col in self.table.get_sortable_columns()]
|
|
24
|
+
if sortable_columns:
|
|
25
|
+
self.fields[SORT_PARAM] = CommaSeparatedMultiChoiceField(
|
|
26
|
+
label=_("Sort by"),
|
|
27
|
+
choices=[("", "0")]
|
|
28
|
+
+ [(col, col) for col in sortable_columns]
|
|
29
|
+
+ [("-" + col, "-" + col) for col in sortable_columns],
|
|
30
|
+
required=False,
|
|
31
|
+
widget=forms.HiddenInput,
|
|
32
|
+
)
|
|
33
|
+
self.set_facet_fields()
|
|
34
|
+
self.set_filter_fields()
|
|
35
|
+
|
|
36
|
+
def set_facet_fields(self):
|
|
37
|
+
facet_columns = self.table.get_facet_columns()
|
|
38
|
+
for column in facet_columns:
|
|
39
|
+
self.fields[column.name] = forms.CharField(
|
|
40
|
+
label=column.label,
|
|
41
|
+
required=False,
|
|
42
|
+
widget=forms.HiddenInput(),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def set_filter_fields(self):
|
|
46
|
+
if self.table.has_fts():
|
|
47
|
+
self.fields[SEARCH_PARAM] = forms.CharField(
|
|
48
|
+
label=_("Search term"),
|
|
49
|
+
required=False,
|
|
50
|
+
widget=forms.TextInput(attrs={"class": "form-control"}),
|
|
51
|
+
)
|
|
52
|
+
filter_columns = self.table.get_filter_columns()
|
|
53
|
+
for column in filter_columns:
|
|
54
|
+
column_filter = column.filter
|
|
55
|
+
if column_filter == FilterChoices.INTEGER_RANGE:
|
|
56
|
+
field_name = column.name + "__range"
|
|
57
|
+
self.fields[field_name] = RangeField(
|
|
58
|
+
forms.IntegerField,
|
|
59
|
+
label=column.label,
|
|
60
|
+
min_value=column.filter_arguments.get("min"),
|
|
61
|
+
max_value=column.filter_arguments.get("max"),
|
|
62
|
+
prefix=column.prefix,
|
|
63
|
+
postfix=column.postfix,
|
|
64
|
+
required=False,
|
|
65
|
+
widget=forms.TextInput(
|
|
66
|
+
attrs={
|
|
67
|
+
"class": "form-control text-end",
|
|
68
|
+
"inputmode": "numeric",
|
|
69
|
+
"type": "number",
|
|
70
|
+
"min": column.filter_arguments.get("min", ""),
|
|
71
|
+
"max": column.filter_arguments.get("max", ""),
|
|
72
|
+
}
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def has_filter(self):
|
|
77
|
+
return any(k for k in self.fields.keys() if k != SORT_PARAM)
|
|
78
|
+
|
|
79
|
+
def is_filtered(self):
|
|
80
|
+
if not self.is_valid():
|
|
81
|
+
return False
|
|
82
|
+
return any(v for k, v in self.cleaned_data.items() if k != SORT_PARAM)
|