django-fast-treenode 2.1.5__py3-none-any.whl → 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_fast_treenode-3.0.0.dist-info/METADATA +203 -0
- django_fast_treenode-3.0.0.dist-info/RECORD +90 -0
- {django_fast_treenode-2.1.5.dist-info → django_fast_treenode-3.0.0.dist-info}/WHEEL +1 -1
- treenode/admin/__init__.py +0 -5
- treenode/admin/admin.py +137 -208
- treenode/admin/changelist.py +21 -39
- treenode/admin/exporter.py +170 -0
- treenode/admin/importer.py +171 -0
- treenode/admin/mixin.py +291 -0
- treenode/apps.py +42 -20
- treenode/cache.py +192 -303
- treenode/forms.py +45 -65
- treenode/managers/__init__.py +4 -20
- treenode/managers/managers.py +216 -0
- treenode/managers/queries.py +233 -0
- treenode/managers/tasks.py +167 -0
- treenode/models/__init__.py +8 -5
- treenode/models/decorators.py +54 -0
- treenode/models/factory.py +44 -68
- treenode/models/mixins/__init__.py +2 -1
- treenode/models/mixins/ancestors.py +44 -20
- treenode/models/mixins/children.py +33 -26
- treenode/models/mixins/descendants.py +33 -22
- treenode/models/mixins/family.py +25 -15
- treenode/models/mixins/logical.py +23 -21
- treenode/models/mixins/node.py +162 -104
- treenode/models/mixins/properties.py +22 -16
- treenode/models/mixins/roots.py +59 -15
- treenode/models/mixins/siblings.py +46 -43
- treenode/models/mixins/tree.py +212 -153
- treenode/models/mixins/update.py +154 -0
- treenode/models/models.py +365 -0
- treenode/settings.py +28 -0
- treenode/static/{treenode/css → css}/tree_widget.css +1 -1
- treenode/static/{treenode/css → css}/treenode_admin.css +43 -2
- treenode/static/css/treenode_tabs.css +51 -0
- treenode/static/js/lz-string.min.js +1 -0
- treenode/static/{treenode/js → js}/tree_widget.js +9 -23
- treenode/static/js/treenode_admin.js +531 -0
- treenode/static/vendors/jquery-ui/AUTHORS.txt +384 -0
- treenode/static/vendors/jquery-ui/LICENSE.txt +43 -0
- treenode/static/vendors/jquery-ui/external/jquery/jquery.js +10716 -0
- treenode/static/vendors/jquery-ui/images/ui-icons_444444_256x240.png +0 -0
- treenode/static/vendors/jquery-ui/images/ui-icons_555555_256x240.png +0 -0
- treenode/static/vendors/jquery-ui/images/ui-icons_777620_256x240.png +0 -0
- treenode/static/vendors/jquery-ui/images/ui-icons_777777_256x240.png +0 -0
- treenode/static/vendors/jquery-ui/images/ui-icons_cc0000_256x240.png +0 -0
- treenode/static/vendors/jquery-ui/images/ui-icons_ffffff_256x240.png +0 -0
- treenode/static/vendors/jquery-ui/index.html +297 -0
- treenode/static/vendors/jquery-ui/jquery-ui.css +438 -0
- treenode/static/vendors/jquery-ui/jquery-ui.js +5223 -0
- treenode/static/vendors/jquery-ui/jquery-ui.min.css +7 -0
- treenode/static/vendors/jquery-ui/jquery-ui.min.js +6 -0
- treenode/static/vendors/jquery-ui/jquery-ui.structure.css +16 -0
- treenode/static/vendors/jquery-ui/jquery-ui.structure.min.css +5 -0
- treenode/static/vendors/jquery-ui/jquery-ui.theme.css +439 -0
- treenode/static/vendors/jquery-ui/jquery-ui.theme.min.css +5 -0
- treenode/static/vendors/jquery-ui/package.json +82 -0
- treenode/templates/admin/treenode_changelist.html +25 -0
- treenode/templates/admin/treenode_import_export.html +85 -0
- treenode/templates/admin/treenode_rows.html +57 -0
- treenode/tests.py +3 -0
- treenode/urls.py +6 -27
- treenode/utils/__init__.py +0 -15
- treenode/utils/db/__init__.py +7 -0
- treenode/utils/db/compiler.py +114 -0
- treenode/utils/db/db_vendor.py +50 -0
- treenode/utils/db/service.py +84 -0
- treenode/utils/db/sqlcompat.py +60 -0
- treenode/utils/db/sqlquery.py +70 -0
- treenode/version.py +2 -2
- treenode/views/__init__.py +5 -0
- treenode/views/autoapi.py +91 -0
- treenode/views/autocomplete.py +52 -0
- treenode/views/children.py +41 -0
- treenode/views/common.py +23 -0
- treenode/views/crud.py +209 -0
- treenode/views/search.py +48 -0
- treenode/widgets.py +27 -44
- django_fast_treenode-2.1.5.dist-info/METADATA +0 -165
- django_fast_treenode-2.1.5.dist-info/RECORD +0 -63
- treenode/admin/mixins.py +0 -302
- treenode/managers/adjacency.py +0 -205
- treenode/managers/closure.py +0 -278
- treenode/models/adjacency.py +0 -342
- treenode/models/classproperty.py +0 -27
- treenode/models/closure.py +0 -122
- treenode/static/treenode/js/.gitkeep +0 -1
- treenode/static/treenode/js/treenode_admin.js +0 -131
- treenode/templates/admin/export_success.html +0 -26
- treenode/templates/admin/tree_node_changelist.html +0 -19
- treenode/templates/admin/tree_node_export.html +0 -27
- treenode/templates/admin/tree_node_import.html +0 -45
- treenode/templates/admin/tree_node_import_report.html +0 -32
- treenode/templates/widgets/tree_widget.css +0 -23
- treenode/utils/aid.py +0 -46
- treenode/utils/base16.py +0 -38
- treenode/utils/base36.py +0 -37
- treenode/utils/db.py +0 -116
- treenode/utils/exporter.py +0 -196
- treenode/utils/importer.py +0 -328
- treenode/utils/radix.py +0 -61
- treenode/views.py +0 -184
- {django_fast_treenode-2.1.5.dist-info → django_fast_treenode-3.0.0.dist-info}/licenses/LICENSE +0 -0
- {django_fast_treenode-2.1.5.dist-info → django_fast_treenode-3.0.0.dist-info}/top_level.txt +0 -0
- /treenode/static/{treenode → css}/.gitkeep +0 -0
- /treenode/static/{treenode/css → js}/.gitkeep +0 -0
treenode/admin/admin.py
CHANGED
@@ -1,28 +1,26 @@
|
|
1
1
|
# -*- coding: utf-8 -*-
|
2
2
|
"""
|
3
|
-
TreeNode Admin
|
3
|
+
TreeNode Admin Model Class
|
4
4
|
|
5
|
-
|
6
|
-
It includes custom tree-based sorting, optimized queries, and
|
7
|
-
import/export functionality for hierarchical data structures.
|
8
|
-
|
9
|
-
Version: 2.1.0
|
5
|
+
Version: 3.0.0
|
10
6
|
Author: Timur Kady
|
11
|
-
Email:
|
7
|
+
Email: timurkady@yandex.com
|
12
8
|
"""
|
13
9
|
|
14
10
|
|
15
|
-
import importlib
|
16
11
|
from django.contrib import admin
|
17
12
|
from django.db import models
|
18
13
|
from django.http import HttpResponseRedirect
|
14
|
+
from django.urls import reverse
|
19
15
|
from django.utils.safestring import mark_safe
|
20
|
-
from django.
|
16
|
+
from django.utils.translation import gettext_lazy as _
|
21
17
|
|
22
|
-
from .changelist import
|
23
|
-
from .
|
18
|
+
from .changelist import TreeNodeChangeList
|
19
|
+
from .mixin import AdminMixin
|
24
20
|
from ..forms import TreeNodeForm
|
25
21
|
from ..widgets import TreeWidget
|
22
|
+
from .importer import TreeNodeImporter
|
23
|
+
from .exporter import TreeNodeExporter
|
26
24
|
|
27
25
|
import logging
|
28
26
|
|
@@ -30,173 +28,163 @@ logger = logging.getLogger(__name__)
|
|
30
28
|
|
31
29
|
|
32
30
|
class TreeNodeModelAdmin(AdminMixin, admin.ModelAdmin):
|
33
|
-
"""
|
34
|
-
TreeNodeAdmin class.
|
35
|
-
|
36
|
-
Admin configuration for TreeNodeModel with import/export functionality.
|
37
|
-
"""
|
31
|
+
"""Admin for TreeNodeModel."""
|
38
32
|
|
33
|
+
# Режимы отображения
|
39
34
|
TREENODE_DISPLAY_MODE_ACCORDION = 'accordion'
|
40
35
|
TREENODE_DISPLAY_MODE_BREADCRUMBS = 'breadcrumbs'
|
41
36
|
TREENODE_DISPLAY_MODE_INDENTATION = 'indentation'
|
42
|
-
|
43
37
|
treenode_display_mode = TREENODE_DISPLAY_MODE_ACCORDION
|
44
|
-
|
45
|
-
|
38
|
+
|
39
|
+
form = TreeNodeForm
|
40
|
+
importer_class = None
|
41
|
+
exporter_class = None
|
46
42
|
ordering = []
|
47
43
|
list_per_page = 1000
|
48
|
-
list_sorting_mode_session_key = "treenode_sorting_mode"
|
49
44
|
|
50
|
-
form = TreeNodeForm
|
51
45
|
formfield_overrides = {
|
52
|
-
models.ForeignKey: {
|
46
|
+
models.ForeignKey: {'widget': TreeWidget()},
|
53
47
|
}
|
54
48
|
|
49
|
+
change_list_template = "admin/treenode_changelist.html"
|
50
|
+
|
55
51
|
class Media:
|
56
|
-
"""
|
52
|
+
"""Meta Class."""
|
57
53
|
|
58
54
|
css = {"all": (
|
59
|
-
"
|
55
|
+
"css/treenode_admin.css",
|
56
|
+
"vendors/jquery-ui/jquery-ui.css",
|
60
57
|
)}
|
61
58
|
js = (
|
62
|
-
|
63
|
-
|
59
|
+
"vendors/jquery-ui/jquery-ui.js",
|
60
|
+
# "js/lz-string.min.js",
|
61
|
+
"js/treenode_admin.js",
|
64
62
|
)
|
65
63
|
|
64
|
+
def __init__(self, model, admin_site):
|
65
|
+
"""Init method."""
|
66
|
+
super().__init__(model, admin_site)
|
67
|
+
|
68
|
+
if not self.list_display:
|
69
|
+
self.list_display = [field.name for field in model._meta.fields]
|
70
|
+
|
71
|
+
self.TreeNodeImporter = self.importer_class or TreeNodeImporter
|
72
|
+
self.TreeNodeExporter = self.exporter_class or TreeNodeExporter
|
73
|
+
|
66
74
|
def drag(self, obj):
|
67
|
-
"""
|
68
|
-
|
69
|
-
return mark_safe(f'<span class="treenode-drag-handle">{icon}</span>')
|
75
|
+
"""Drag and drop сolumn."""
|
76
|
+
return mark_safe('<span class="treenode-drag-handle">☰</span>')
|
70
77
|
|
71
|
-
drag.short_description = ""
|
78
|
+
drag.short_description = _("Move")
|
72
79
|
|
73
80
|
def toggle(self, obj):
|
74
|
-
"""
|
75
|
-
icon = "►" # ➕➖
|
81
|
+
"""Toggle column."""
|
76
82
|
if obj.get_children_count() > 0:
|
77
83
|
return mark_safe(
|
78
|
-
f'<button class="treenode-toggle" '
|
79
|
-
|
80
|
-
f'{icon}'
|
81
|
-
f'</button>')
|
84
|
+
f'<button class="treenode-toggle" data-node-id="{obj.pk}">►</button>' # noqa
|
85
|
+
)
|
82
86
|
return mark_safe('<div class="treenode-space"> </div>')
|
83
87
|
|
84
|
-
toggle.short_description = ""
|
88
|
+
toggle.short_description = _("Expand")
|
85
89
|
|
86
|
-
def
|
87
|
-
"""
|
88
|
-
|
90
|
+
def _get_treenode_field_display(self, request, obj):
|
91
|
+
"""Return HTML for the tree node in list view."""
|
92
|
+
level = obj.get_depth()
|
93
|
+
edit_url = reverse(
|
94
|
+
f"admin:{obj._meta.app_label}_{obj._meta.model_name}_change",
|
95
|
+
args=[obj.pk]
|
96
|
+
)
|
89
97
|
|
90
|
-
|
91
|
-
|
92
|
-
|
98
|
+
if self.treenode_display_mode == self.TREENODE_DISPLAY_MODE_ACCORDION:
|
99
|
+
icon = "📄 " if obj.is_leaf() else "📁 "
|
100
|
+
content = (
|
101
|
+
f'<span style="padding-left: {level * 1.5}em;">'
|
102
|
+
f'{icon}<a href="{edit_url}">{str(obj)}</a></span>'
|
103
|
+
)
|
104
|
+
elif self.treenode_display_mode == self.TREENODE_DISPLAY_MODE_BREADCRUMBS: # noqa
|
105
|
+
breadcrumbs = obj.get_breadcrumbs(
|
106
|
+
attr=getattr(obj, 'treenode_display_field', 'id'))
|
107
|
+
content = " / ".join(map(str, breadcrumbs))
|
108
|
+
elif self.treenode_display_mode == self.TREENODE_DISPLAY_MODE_INDENTATION: # noqa
|
109
|
+
indent = "—" * level
|
110
|
+
content = f'{indent}<a href="{edit_url}">{str(obj)}</a>'
|
111
|
+
else:
|
112
|
+
content = f'<a href="{edit_url}">{str(obj)}</a>'
|
113
|
+
|
114
|
+
html = (
|
115
|
+
f'<div class="treenode-wrapper" '
|
116
|
+
f'data-treenode-pk="{obj.pk}" '
|
117
|
+
f'data-treenode-depth="{level}" '
|
118
|
+
f'data-treenode-parent="{obj.parent_id or ""}">'
|
119
|
+
f'<span class="treenode-content">{content}</span>'
|
120
|
+
f'</div>'
|
121
|
+
)
|
122
|
+
return mark_safe(html)
|
123
|
+
|
124
|
+
def get_list_display(self, request):
|
125
|
+
"""Generate list_display dynamically with tree-aware columns."""
|
126
|
+
# Define callable that replaces display field with tree field
|
127
|
+
def treenode_field(obj):
|
128
|
+
return self._get_treenode_field_display(request, obj)
|
129
|
+
treenode_field.short_description = self.model._meta.verbose_name
|
130
|
+
|
131
|
+
display_field = getattr(self.model, 'display_field', '__str__')
|
93
132
|
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
])
|
99
|
-
if not self.import_export:
|
100
|
-
check_results = [
|
101
|
-
pkg for pkg in ["openpyxl", "pyyaml", "xlsxwriter"]
|
102
|
-
if importlib.util.find_spec(pkg) is not None
|
103
|
-
]
|
104
|
-
logger.info("Packages" + ", ".join(check_results) + " are \
|
105
|
-
not installed. Export and import functions are disabled.")
|
106
|
-
|
107
|
-
if self.import_export:
|
108
|
-
from ..utils import TreeNodeImporter, TreeNodeExporter
|
109
|
-
|
110
|
-
self.TreeNodeImporter = TreeNodeImporter
|
111
|
-
self.TreeNodeExporter = TreeNodeExporter
|
133
|
+
user_list_display = list(super().get_list_display(request))
|
134
|
+
# If the list is empty or only contains __str__, replace it entirely
|
135
|
+
if not user_list_display or user_list_display == ['__str__']:
|
136
|
+
user_list_display = [treenode_field]
|
112
137
|
else:
|
113
|
-
|
114
|
-
|
138
|
+
try:
|
139
|
+
pos = user_list_display.index(display_field)
|
140
|
+
user_list_display.pop(pos)
|
141
|
+
user_list_display.insert(pos, treenode_field)
|
142
|
+
except ValueError:
|
143
|
+
user_list_display.insert(0, treenode_field)
|
115
144
|
|
116
|
-
|
117
|
-
|
118
|
-
|
145
|
+
return (self.drag, self.toggle) + tuple(user_list_display)
|
146
|
+
|
147
|
+
def get_list_display_links(self, request, list_display):
|
148
|
+
"""Get display list links."""
|
149
|
+
return ('treenode_field',)
|
119
150
|
|
120
|
-
|
121
|
-
|
122
|
-
return the full list.
|
123
|
-
"""
|
151
|
+
def get_queryset(self, request):
|
152
|
+
"""By default: only root nodes, unless searching or editing."""
|
124
153
|
qs = super().get_queryset(request)
|
125
154
|
|
126
|
-
|
127
|
-
app_label = self.model._meta.app_label
|
128
|
-
model_name = self.model._meta.model_name
|
129
|
-
if resolved_match.url_name == f"{app_label}_{model_name}_change":
|
155
|
+
if request.GET.get("q"):
|
130
156
|
return qs
|
131
157
|
|
132
|
-
|
133
|
-
|
158
|
+
resolved = request.resolver_match
|
159
|
+
if resolved and resolved.url_name.endswith("_change"):
|
160
|
+
return qs
|
134
161
|
|
135
|
-
|
136
|
-
q = request.GET.get("q", "")
|
137
|
-
if not field_name:
|
138
|
-
return qs.none()
|
139
|
-
return qs.select_related('tn_parent')\
|
140
|
-
.filter(**{f"{field_name}__icontains": q})
|
162
|
+
return qs.filter(parent__isnull=True)
|
141
163
|
|
142
164
|
def get_form(self, request, obj=None, **kwargs):
|
143
165
|
"""Get Form method."""
|
144
166
|
form = super().get_form(request, obj, **kwargs)
|
145
|
-
if "
|
146
|
-
form.base_fields["
|
167
|
+
if "parent" in form.base_fields:
|
168
|
+
form.base_fields["parent"].widget = TreeWidget()
|
147
169
|
return form
|
148
170
|
|
149
171
|
def get_search_fields(self, request):
|
150
|
-
"""
|
151
|
-
return [self.model
|
152
|
-
|
153
|
-
def get_list_display(self, request):
|
154
|
-
"""Generate list_display dynamically with user-defined preferences."""
|
155
|
-
change_view_cols = (self.drag, self.toggle)
|
156
|
-
user_list_display = list(super().get_list_display(request))
|
157
|
-
|
158
|
-
treenode_display_field = getattr(
|
159
|
-
self.model,
|
160
|
-
'treenode_display_field',
|
161
|
-
'__str__'
|
162
|
-
)
|
163
|
-
|
164
|
-
def treenode_field(obj):
|
165
|
-
return self._get_treenode_field_display(request, obj)
|
166
|
-
|
167
|
-
treenode_field.short_description = self.model._meta.verbose_name
|
172
|
+
"""Get search fields."""
|
173
|
+
return [getattr(self.model, 'treenode_display_field', 'id')]
|
168
174
|
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
else:
|
173
|
-
# Remove `treenode_display_field` if it is the first one and
|
174
|
-
# insert `treenode_field`
|
175
|
-
if user_list_display[0] == treenode_display_field:
|
176
|
-
clean_list = user_list_display[1:]
|
177
|
-
else:
|
178
|
-
clean_list = user_list_display
|
179
|
-
|
180
|
-
# Гарантируем, что treenode_field есть в списке
|
181
|
-
if treenode_field not in clean_list:
|
182
|
-
clean_list.insert(0, treenode_field)
|
183
|
-
|
184
|
-
result = tuple(clean_list)
|
175
|
+
def get_changelist(self, request, **kwargs):
|
176
|
+
"""Get ChangeList Class."""
|
177
|
+
return TreeNodeChangeList
|
185
178
|
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
"""Specify that only `treenode_field` should be clickable."""
|
190
|
-
return ('treenode_field',)
|
191
|
-
|
192
|
-
def get_changelist(self, request):
|
193
|
-
"""Use SortedChangeList to sort the results at render time."""
|
194
|
-
return SortedChangeList
|
179
|
+
def get_ordering(self, request):
|
180
|
+
"""Get ordering."""
|
181
|
+
return None
|
195
182
|
|
196
183
|
def changelist_view(self, request, extra_context=None):
|
197
184
|
"""Changelist View."""
|
198
185
|
extra_context = extra_context or {}
|
199
186
|
extra_context['import_export_enabled'] = self.import_export
|
187
|
+
extra_context['num_sorted_fields'] = len(self.get_ordering(request) or []) # noqa: D501
|
200
188
|
|
201
189
|
response = super().changelist_view(request, extra_context=extra_context)
|
202
190
|
|
@@ -205,91 +193,32 @@ not installed. Export and import functions are disabled.")
|
|
205
193
|
if isinstance(response, HttpResponseRedirect):
|
206
194
|
return response
|
207
195
|
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
196
|
+
ChangeListClass = self.get_changelist(request)
|
197
|
+
cl = ChangeListClass(
|
198
|
+
request,
|
199
|
+
self.model,
|
200
|
+
self.list_display,
|
201
|
+
self.list_display_links,
|
202
|
+
self.list_filter,
|
203
|
+
self.date_hierarchy,
|
204
|
+
self.search_fields,
|
205
|
+
self.list_select_related,
|
206
|
+
self.list_per_page,
|
207
|
+
self.list_max_show_all,
|
208
|
+
self.list_editable,
|
209
|
+
self,
|
210
|
+
self.get_sortable_by(request),
|
211
|
+
self.get_search_help_text(request),
|
212
|
+
)
|
222
213
|
|
223
|
-
|
224
|
-
|
214
|
+
cl.get_results(request)
|
215
|
+
cl.result_list = self.render_changelist_rows(cl.result_list, request)
|
225
216
|
|
226
217
|
return response
|
227
218
|
|
228
|
-
def
|
229
|
-
"""Get
|
230
|
-
return
|
231
|
-
|
232
|
-
# ------------------------------------------------------------------------
|
233
|
-
|
234
|
-
def _get_treenode_field_display(self, request, obj):
|
235
|
-
"""
|
236
|
-
Return the HTML display of the accordion node.
|
237
|
-
|
238
|
-
Modes:
|
239
|
-
- ACCORDION: ' ' * level + icon + str(node),
|
240
|
-
where icon = "📄" if obj.is_leaf() returns True, otherwise "📁".
|
241
|
-
- BREADCRUMBS: " / ".join(obj.get_breadcrumbs(attr=field)),
|
242
|
-
where field = getattr(self.model, 'treenode_display_field', None)
|
243
|
-
or "tn_priority" if None.
|
244
|
-
- INDENTATION: '—' * level + str(node)
|
245
|
-
"""
|
246
|
-
# Get a link to edit the object
|
247
|
-
meta = self.model._meta
|
248
|
-
edit_url = reverse(
|
249
|
-
f'admin:{meta.app_label}_{meta.model_name}_change', args=[obj.pk]
|
250
|
-
)
|
251
|
-
|
252
|
-
# Determine the node level
|
253
|
-
level = obj.get_depth()
|
254
|
-
|
255
|
-
mode = self.treenode_display_mode
|
256
|
-
if mode == self.TREENODE_DISPLAY_MODE_ACCORDION:
|
257
|
-
icon = "📄 " if obj.is_leaf() else "📁 "
|
258
|
-
obj_str = str(obj)
|
259
|
-
content = (
|
260
|
-
f'<span style="padding-left: {level * 1.5}em;">'
|
261
|
-
f'{icon}<a href="{edit_url}">{obj_str}</a>'
|
262
|
-
f'</span>'
|
263
|
-
)
|
264
|
-
elif mode == self.TREENODE_DISPLAY_MODE_BREADCRUMBS:
|
265
|
-
field = getattr(
|
266
|
-
self.model,
|
267
|
-
'treenode_display_field',
|
268
|
-
None) or "tn_priority"
|
269
|
-
content = " / ".join(obj.get_breadcrumbs(attr=field))
|
270
|
-
elif mode == self.TREENODE_DISPLAY_MODE_INDENTATION:
|
271
|
-
indent = "—" * level
|
272
|
-
obj_str = str(obj)
|
273
|
-
content = f'{indent}<a href="{edit_url}">{obj_str}</a>'
|
274
|
-
else:
|
275
|
-
# Just in case mode is not recognized, then use breadcrumbs
|
276
|
-
field = getattr(
|
277
|
-
self.model,
|
278
|
-
'treenode_display_field',
|
279
|
-
None) or "tn_priority"
|
280
|
-
content = " / ".join(obj.get_breadcrumbs(attr=field))
|
281
|
-
content = f'<a href="{edit_url}"">{content}</a>'
|
282
|
-
|
283
|
-
parent = str(getattr(obj, "tn_parent_id", "") or "")
|
284
|
-
html = (
|
285
|
-
f'<div class="treenode-wrapper" '
|
286
|
-
f'data-treenode-pk="{obj.pk}" '
|
287
|
-
f'data-treenode-depth="{level}" '
|
288
|
-
f'data-treenode-parent="{parent}">'
|
289
|
-
f'<span class="treenode-content">{content}</span>'
|
290
|
-
f'</div>'
|
291
|
-
)
|
292
|
-
return mark_safe(html)
|
219
|
+
def get_list_per_page(self, request):
|
220
|
+
"""Get list per page."""
|
221
|
+
return 999999
|
293
222
|
|
294
223
|
|
295
224
|
# The End
|
treenode/admin/changelist.py
CHANGED
@@ -1,20 +1,20 @@
|
|
1
1
|
# -*- coding: utf-8 -*-
|
2
2
|
"""
|
3
|
-
TreeNode Sorted ChangeList Class for
|
3
|
+
TreeNode Sorted ChangeList Class for TreeNodeModelAdmin.
|
4
4
|
|
5
|
-
Version:
|
5
|
+
Version: 3.0.0
|
6
6
|
Author: Timur Kady
|
7
|
-
Email:
|
7
|
+
Email: timurkady@yandex.com
|
8
8
|
"""
|
9
9
|
|
10
10
|
from django.contrib.admin.views.main import ChangeList
|
11
|
-
from django.
|
11
|
+
from django.forms.models import modelformset_factory
|
12
|
+
from django.db.models import Q
|
12
13
|
|
13
|
-
from ..cache import treenode_cache
|
14
14
|
|
15
|
-
|
16
|
-
|
17
|
-
|
15
|
+
class TreeNodeChangeList(ChangeList):
|
16
|
+
def __init__(self, *args, **kwargs):
|
17
|
+
super().__init__(*args, **kwargs)
|
18
18
|
|
19
19
|
def get_ordering(self, request, queryset):
|
20
20
|
"""
|
@@ -30,36 +30,18 @@ class SortedChangeList(ChangeList):
|
|
30
30
|
ordering.remove('-pk')
|
31
31
|
return tuple(ordering)
|
32
32
|
|
33
|
-
def get_queryset(self, request):
|
34
|
-
"""Get QuerySet with select_related."""
|
35
|
-
return super().get_queryset(request).select_related('tn_parent')
|
36
|
-
|
37
33
|
def get_results(self, request):
|
38
|
-
"""Return sorted results for ChangeList rendering."""
|
39
|
-
# Populate self.result_list with objects from the DB.
|
40
34
|
super().get_results(request)
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
obj.object for obj in deserialize("json", json_str)
|
55
|
-
]
|
56
|
-
self.result_list = sorted_results
|
57
|
-
return
|
58
|
-
|
59
|
-
sorted_result = self.model._sort_node_list(result_list)
|
60
|
-
json_str = serialize("json", sorted_result)
|
61
|
-
treenode_cache.set(cache_key, json_str)
|
62
|
-
|
63
|
-
self.result_list = sorted_result
|
64
|
-
|
65
|
-
# The End
|
35
|
+
model_name = self.model._meta.model_name
|
36
|
+
|
37
|
+
# Добавляем атрибуты к результатам
|
38
|
+
object_ids = [r.pk for r in self.result_list]
|
39
|
+
objects_dict = {
|
40
|
+
obj.pk: obj
|
41
|
+
for obj in self.model_admin.model.objects.filter(pk__in=object_ids)
|
42
|
+
}
|
43
|
+
|
44
|
+
for result in self.result_list:
|
45
|
+
result.obj = objects_dict.get(result.pk)
|
46
|
+
# Добавляем атрибуты строк
|
47
|
+
result.row_attrs = f'data-node-id="{result.pk}" data-parent-of="{result.obj.parent_id or ""}" class="model-{model_name} pk-{result.pk}"'
|
@@ -0,0 +1,170 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
"""
|
3
|
+
TreeNode Exporter Module
|
4
|
+
|
5
|
+
This module provides functionality for stream exporting tree-structured data
|
6
|
+
to various formats, including CSV, JSON, TSV, XLSX, YAML.
|
7
|
+
|
8
|
+
Version: 3.0.0
|
9
|
+
Author: Timur Kady
|
10
|
+
Email: timurkady@yandex.com
|
11
|
+
"""
|
12
|
+
|
13
|
+
import csv
|
14
|
+
import json
|
15
|
+
import yaml
|
16
|
+
from django.core.serializers.json import DjangoJSONEncoder
|
17
|
+
from django.http import StreamingHttpResponse
|
18
|
+
from io import BytesIO, StringIO
|
19
|
+
from openpyxl import Workbook
|
20
|
+
|
21
|
+
|
22
|
+
class TreeNodeExporter:
|
23
|
+
"""Exporter for tree-structured data to various formats."""
|
24
|
+
|
25
|
+
def __init__(self, model, filename="tree_nodes", fileformat="csv"):
|
26
|
+
"""
|
27
|
+
Initialize exporter.
|
28
|
+
|
29
|
+
:param queryset: Django QuerySet to export.
|
30
|
+
:param filename: Base name for the output file.
|
31
|
+
:param fileformat: Export format (csv, json, xlsx, yaml, tvs).
|
32
|
+
"""
|
33
|
+
self.filename = filename
|
34
|
+
self.format = fileformat
|
35
|
+
self.model = model
|
36
|
+
self.queryset = model.objects.get_queryset()
|
37
|
+
self.fields = self.get_ordered_fields()
|
38
|
+
|
39
|
+
def get_ordered_fields(self):
|
40
|
+
"""
|
41
|
+
Define and return the ordered list of fields for export.
|
42
|
+
|
43
|
+
Required fields come first, blocked fields are omitted.
|
44
|
+
"""
|
45
|
+
fields = sorted([field.name for field in self.model._meta.fields])
|
46
|
+
required_fields = ["id", "parent", "priority"]
|
47
|
+
blocked_fields = ["_path", "_depth"]
|
48
|
+
|
49
|
+
other_fields = [
|
50
|
+
field for field in fields
|
51
|
+
if field not in required_fields and field not in blocked_fields
|
52
|
+
]
|
53
|
+
return required_fields + other_fields
|
54
|
+
|
55
|
+
def get_obj(self):
|
56
|
+
"""Yield rows from queryset as row data dict."""
|
57
|
+
queryset = self.queryset.order_by('_path').only(*self.fields)
|
58
|
+
for obj in queryset.iterator():
|
59
|
+
yield obj
|
60
|
+
|
61
|
+
def get_serializable_row(self, obj):
|
62
|
+
"""Get serialized object."""
|
63
|
+
fields = self.fields
|
64
|
+
raw_data = {}
|
65
|
+
for field in fields:
|
66
|
+
if field == "parent":
|
67
|
+
raw_data["parent"] = getattr(obj, "parent_id", None)
|
68
|
+
else:
|
69
|
+
raw_data[field] = getattr(obj, field, None)
|
70
|
+
serialized = json.loads(json.dumps(raw_data, cls=DjangoJSONEncoder))
|
71
|
+
return serialized
|
72
|
+
|
73
|
+
def csv_stream_data(self, delimiter=","):
|
74
|
+
"""Stream CSV or TSV data."""
|
75
|
+
yield "\ufeff" # BOM for Excel
|
76
|
+
buffer = StringIO()
|
77
|
+
writer = csv.DictWriter(
|
78
|
+
buffer,
|
79
|
+
fieldnames=self.fields,
|
80
|
+
delimiter=delimiter
|
81
|
+
)
|
82
|
+
writer.writeheader()
|
83
|
+
yield buffer.getvalue()
|
84
|
+
buffer.seek(0)
|
85
|
+
buffer.truncate(0)
|
86
|
+
|
87
|
+
for obj in self.get_obj():
|
88
|
+
row = self.get_serializable_row(obj)
|
89
|
+
writer.writerow(row)
|
90
|
+
yield buffer.getvalue()
|
91
|
+
buffer.seek(0)
|
92
|
+
buffer.truncate(0)
|
93
|
+
|
94
|
+
def json_stream_data(self):
|
95
|
+
"""Stream JSON data."""
|
96
|
+
yield "[\n"
|
97
|
+
first = True
|
98
|
+
for obj in self.get_obj():
|
99
|
+
row = self.get_serializable_row(obj)
|
100
|
+
if not first:
|
101
|
+
yield ",\n"
|
102
|
+
else:
|
103
|
+
first = False
|
104
|
+
yield json.dumps(row, ensure_ascii=False)
|
105
|
+
yield "\n]"
|
106
|
+
|
107
|
+
def tsv_stream_data(self, chunk_size=1000):
|
108
|
+
"""Stream TSV (tab-separated values) data."""
|
109
|
+
yield from self.csv_stream_data(delimiter="\t")
|
110
|
+
|
111
|
+
def yaml_stream_data(self):
|
112
|
+
"""Stream YAML data."""
|
113
|
+
yield "---\n"
|
114
|
+
for obj in self.get_obj():
|
115
|
+
row = self.get_serializable_row(obj)
|
116
|
+
yield yaml.safe_dump([row], allow_unicode=True)
|
117
|
+
|
118
|
+
def xlsx_stream_data(self):
|
119
|
+
"""Stream XLSX data."""
|
120
|
+
wb = Workbook()
|
121
|
+
ws = wb.active
|
122
|
+
ws.append(self.fields)
|
123
|
+
|
124
|
+
for obj in self.get_obj():
|
125
|
+
row = self.get_serializable_row(obj)
|
126
|
+
ws.append([row.get(f, "") for f in self.fields])
|
127
|
+
|
128
|
+
output = BytesIO()
|
129
|
+
wb.save(output)
|
130
|
+
output.seek(0)
|
131
|
+
yield output.getvalue()
|
132
|
+
|
133
|
+
def process_record(self):
|
134
|
+
"""
|
135
|
+
Create a StreamingHttpResponse based on selected format.
|
136
|
+
|
137
|
+
:param chunk_size: Batch size for iteration.
|
138
|
+
:return: StreamingHttpResponse object.
|
139
|
+
"""
|
140
|
+
if self.format == 'xlsx':
|
141
|
+
response = StreamingHttpResponse(
|
142
|
+
streaming_content=self.xlsx_stream_data(),
|
143
|
+
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; charset=utf-8" # noqa: D501
|
144
|
+
)
|
145
|
+
elif self.format == 'tsv':
|
146
|
+
response = StreamingHttpResponse(
|
147
|
+
streaming_content=self.csv_stream_data(delimiter="\t"),
|
148
|
+
content_type="text/tab-separated-values; charset=utf-8"
|
149
|
+
)
|
150
|
+
elif self.format == 'csv':
|
151
|
+
response = StreamingHttpResponse(
|
152
|
+
streaming_content=self.csv_stream_data(delimiter=","),
|
153
|
+
content_type="text/csv; charset=utf-8"
|
154
|
+
)
|
155
|
+
elif self.format == 'yaml':
|
156
|
+
response = StreamingHttpResponse(
|
157
|
+
streaming_content=self.yaml_stream_data(),
|
158
|
+
content_type=f"application/{self.format}; charset=utf-8"
|
159
|
+
)
|
160
|
+
else:
|
161
|
+
response = StreamingHttpResponse(
|
162
|
+
streaming_content=self.json_stream_data(),
|
163
|
+
content_type=f"application/{self.format}; charset=utf-8"
|
164
|
+
)
|
165
|
+
|
166
|
+
response['Content-Disposition'] = f'attachment; filename="{self.filename}"' # noqa: D501
|
167
|
+
return response
|
168
|
+
|
169
|
+
|
170
|
+
# The End
|