django-fast-treenode 2.0.9__py3-none-any.whl → 2.0.11__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-2.0.9.dist-info → django_fast_treenode-2.0.11.dist-info}/METADATA +4 -3
- {django_fast_treenode-2.0.9.dist-info → django_fast_treenode-2.0.11.dist-info}/RECORD +19 -18
- treenode/admin.py +95 -71
- treenode/docs/Documentation +7 -35
- treenode/forms.py +64 -23
- treenode/managers.py +306 -168
- treenode/models/closure.py +22 -46
- treenode/models/proxy.py +43 -24
- treenode/templates/admin/tree_node_changelist.html +2 -0
- treenode/templates/admin/tree_node_import.html +27 -9
- treenode/templates/admin/tree_node_import_report.html +32 -0
- treenode/utils/__init__.py +4 -5
- treenode/utils/exporter.py +80 -27
- treenode/utils/importer.py +185 -152
- treenode/views.py +18 -12
- treenode/widgets.py +21 -5
- {django_fast_treenode-2.0.9.dist-info → django_fast_treenode-2.0.11.dist-info}/LICENSE +0 -0
- {django_fast_treenode-2.0.9.dist-info → django_fast_treenode-2.0.11.dist-info}/WHEEL +0 -0
- {django_fast_treenode-2.0.9.dist-info → django_fast_treenode-2.0.11.dist-info}/top_level.txt +0 -0
treenode/models/proxy.py
CHANGED
@@ -13,7 +13,7 @@ Features:
|
|
13
13
|
- Provides a caching mechanism for performance optimization.
|
14
14
|
- Includes methods for tree traversal, manipulation, and serialization.
|
15
15
|
|
16
|
-
Version: 2.0.
|
16
|
+
Version: 2.0.11
|
17
17
|
Author: Timur Kady
|
18
18
|
Email: timurkady@yandex.com
|
19
19
|
"""
|
@@ -131,14 +131,14 @@ class TreeNodeModel(models.Model, metaclass=TreeFactory):
|
|
131
131
|
|
132
132
|
def get_ancestors_queryset(self, include_self=True, depth=None):
|
133
133
|
"""Get the ancestors queryset (ordered from parent to root)."""
|
134
|
-
|
135
|
-
|
134
|
+
ancestors_pks = self.get_ancestors_pks(include_self, depth)
|
135
|
+
result = self._meta.model.objects.filter(pk__in=ancestors_pks)
|
136
|
+
return result
|
136
137
|
|
137
138
|
def get_ancestors(self, include_self=True, depth=None):
|
138
139
|
"""Get a list with all ancestors (ordered from root to self/parent)."""
|
139
|
-
|
140
|
-
|
141
|
-
)
|
140
|
+
queryset = self.get_ancestors_queryset(include_self, depth)
|
141
|
+
return list(queryset.iterator())
|
142
142
|
|
143
143
|
def get_ancestors_count(self, include_self=True, depth=None):
|
144
144
|
"""Get the ancestors count."""
|
@@ -146,8 +146,8 @@ class TreeNodeModel(models.Model, metaclass=TreeFactory):
|
|
146
146
|
|
147
147
|
def get_ancestors_pks(self, include_self=True, depth=None):
|
148
148
|
"""Get the ancestors pks list."""
|
149
|
-
|
150
|
-
return
|
149
|
+
pks = self.closure_model.get_ancestors_pks(self, include_self, depth)
|
150
|
+
return pks
|
151
151
|
|
152
152
|
# Children --------------------
|
153
153
|
|
@@ -172,15 +172,14 @@ class TreeNodeModel(models.Model, metaclass=TreeFactory):
|
|
172
172
|
|
173
173
|
def get_descendants_queryset(self, include_self=False, depth=None):
|
174
174
|
"""Get the descendants queryset."""
|
175
|
-
|
176
|
-
|
177
|
-
|
175
|
+
descendants_pks = self.get_descendants_pks(include_self, depth)
|
176
|
+
result = self._meta.model.objects.filter(pk__in=descendants_pks)
|
177
|
+
return result
|
178
178
|
|
179
179
|
def get_descendants(self, include_self=False, depth=None):
|
180
180
|
"""Get a list containing all descendants."""
|
181
|
-
|
182
|
-
|
183
|
-
)
|
181
|
+
queryset = self.get_descendants_queryset(include_self, depth).iterator()
|
182
|
+
return list(queryset)
|
184
183
|
|
185
184
|
def get_descendants_count(self, include_self=False, depth=None):
|
186
185
|
"""Get the descendants count."""
|
@@ -188,8 +187,8 @@ class TreeNodeModel(models.Model, metaclass=TreeFactory):
|
|
188
187
|
|
189
188
|
def get_descendants_pks(self, include_self=False, depth=None):
|
190
189
|
"""Get the descendants pks list."""
|
191
|
-
|
192
|
-
return
|
190
|
+
pks = self.closure_model.get_descendants_pks(self, include_self, depth)
|
191
|
+
return pks
|
193
192
|
|
194
193
|
# Siblings --------------------
|
195
194
|
|
@@ -216,9 +215,16 @@ class TreeNodeModel(models.Model, metaclass=TreeFactory):
|
|
216
215
|
|
217
216
|
# -----------------------------
|
218
217
|
|
219
|
-
def get_breadcrumbs(self, attr=
|
218
|
+
def get_breadcrumbs(self, attr='pk'):
|
220
219
|
"""Get the breadcrumbs to current node (self, included)."""
|
221
|
-
|
220
|
+
queryset = self.get_ancestors_queryset(include_self=True)
|
221
|
+
|
222
|
+
breadcrumbs = [
|
223
|
+
getattr(item, attr)
|
224
|
+
if hasattr(item, attr) else None
|
225
|
+
for item in queryset
|
226
|
+
]
|
227
|
+
return breadcrumbs
|
222
228
|
|
223
229
|
def get_depth(self):
|
224
230
|
"""Get the node depth (self, how many levels of descendants)."""
|
@@ -238,7 +244,7 @@ class TreeNodeModel(models.Model, metaclass=TreeFactory):
|
|
238
244
|
|
239
245
|
def get_order(self):
|
240
246
|
"""Return the materialized path."""
|
241
|
-
path = self.
|
247
|
+
path = self.get_breadcrumbs(attr='tn_priority')
|
242
248
|
segments = [to_base36(i).rjust(6, '0') for i in path]
|
243
249
|
return ''.join(segments)
|
244
250
|
|
@@ -252,8 +258,18 @@ class TreeNodeModel(models.Model, metaclass=TreeFactory):
|
|
252
258
|
|
253
259
|
def get_path(self, prefix='', suffix='', delimiter='.', format_str=''):
|
254
260
|
"""Return Materialized Path of node."""
|
255
|
-
|
256
|
-
|
261
|
+
priorities = self.get_breadcrumbs(attr='tn_priority')
|
262
|
+
# Проверяем, что список не пуст
|
263
|
+
if not priorities or all(p is None for p in priorities):
|
264
|
+
return prefix + suffix
|
265
|
+
|
266
|
+
str_ = "{%s}" % format_str
|
267
|
+
path = delimiter.join([
|
268
|
+
str_.format(p)
|
269
|
+
for p in priorities
|
270
|
+
if p is not None
|
271
|
+
])
|
272
|
+
return prefix + path + suffix
|
257
273
|
|
258
274
|
@cached_method
|
259
275
|
def get_parent(self):
|
@@ -369,7 +385,9 @@ class TreeNodeModel(models.Model, metaclass=TreeFactory):
|
|
369
385
|
|
370
386
|
# --- 2. Check mode _-------------------------------------------------
|
371
387
|
# If the object already exists in the DB, we'll extract its old parent
|
372
|
-
if
|
388
|
+
if is_new:
|
389
|
+
force_insert = True
|
390
|
+
else:
|
373
391
|
ql = model.objects.filter(pk=self.pk).values_list(
|
374
392
|
'tn_parent',
|
375
393
|
'tn_priority').first()
|
@@ -395,7 +413,8 @@ class TreeNodeModel(models.Model, metaclass=TreeFactory):
|
|
395
413
|
|
396
414
|
# If the parent has changed, we move it
|
397
415
|
if (old_parent != self.tn_parent):
|
398
|
-
|
416
|
+
subtree_nodes = self.get_descendants(include_self=True)
|
417
|
+
self.closure_model.move_node(subtree_nodes)
|
399
418
|
|
400
419
|
# --- 5. Update siblings ---------------------------------------------
|
401
420
|
if is_new or is_move:
|
@@ -582,7 +601,7 @@ class TreeNodeModel(models.Model, metaclass=TreeFactory):
|
|
582
601
|
# Save changes
|
583
602
|
model = self._meta.model
|
584
603
|
with transaction.atomic():
|
585
|
-
model.objects.bulk_update(sorted_siblings, ('tn_priority',)
|
604
|
+
model.objects.bulk_update(sorted_siblings, ('tn_priority',))
|
586
605
|
super().save(update_fields=['tn_priority'])
|
587
606
|
model.clear_cache()
|
588
607
|
|
@@ -1,7 +1,20 @@
|
|
1
1
|
{% extends "admin/base_site.html" %}
|
2
|
+
{% load static %}
|
3
|
+
|
4
|
+
{% block extrahead %}
|
5
|
+
<link rel="stylesheet" type="text/css" href="{% static 'admin/css/forms.css' %}">
|
6
|
+
{% endblock %}
|
7
|
+
|
2
8
|
{% block content %}
|
3
9
|
<div class="module">
|
4
10
|
<h2>Import Data</h2>
|
11
|
+
|
12
|
+
{% if message %}
|
13
|
+
<div class="successnote">
|
14
|
+
<p>{{ message }}</p>
|
15
|
+
</div>
|
16
|
+
{% endif %}
|
17
|
+
|
5
18
|
{% if errors %}
|
6
19
|
<div class="errornote">
|
7
20
|
<p>Errors occurred while importing the data:</p>
|
@@ -12,16 +25,21 @@
|
|
12
25
|
</ul>
|
13
26
|
</div>
|
14
27
|
{% endif %}
|
15
|
-
|
28
|
+
|
29
|
+
<form id="importForm" method="post" enctype="multipart/form-data" style="margin: 20px 0;">
|
16
30
|
{% csrf_token %}
|
17
|
-
<
|
18
|
-
<
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
31
|
+
<fieldset class="module aligned">
|
32
|
+
<div class="form-row field-tn_parent">
|
33
|
+
<label for="file">Choose file:</label>
|
34
|
+
<input type="file" name="file" id="file" required>
|
35
|
+
</div>
|
36
|
+
</fieldset>
|
37
|
+
|
38
|
+
<div class="submit-row" style="margin-top: 35px;">
|
39
|
+
<input id="importBtn" type="submit" value="Import" name="_save">
|
40
|
+
<input type="button" value="Cancel" class="button cancel" onclick="window.location.href='..';">
|
24
41
|
</div>
|
25
42
|
</form>
|
43
|
+
|
26
44
|
</div>
|
27
|
-
{% endblock %}
|
45
|
+
{% endblock %}
|
@@ -0,0 +1,32 @@
|
|
1
|
+
{% extends "admin/base_site.html" %}
|
2
|
+
{% block content %}
|
3
|
+
<div class="module">
|
4
|
+
<h2>Import Results</h2>
|
5
|
+
|
6
|
+
<ul class="messagelist">
|
7
|
+
<li class="success"><strong>Created:</strong> {{ created_count }} records</li>
|
8
|
+
<li class="success"><strong>Updated:</strong> {{ updated_count }} records</li>
|
9
|
+
</ul>
|
10
|
+
|
11
|
+
{% if errors %}
|
12
|
+
<div class="errornote">
|
13
|
+
<p>Errors occurred while importing the data:</p>
|
14
|
+
<ul>
|
15
|
+
{% for error in errors %}
|
16
|
+
<li>{{ error }}</li>
|
17
|
+
{% endfor %}
|
18
|
+
</ul>
|
19
|
+
</div>
|
20
|
+
{% endif %}
|
21
|
+
|
22
|
+
<div style="margin-top: 20px;">
|
23
|
+
<button type="button" class="button" onclick="redirectAfterImport()">Finish</button>
|
24
|
+
</div>
|
25
|
+
</div>
|
26
|
+
|
27
|
+
<script>
|
28
|
+
function redirectAfterImport() {
|
29
|
+
window.location.href = window.location.pathname.replace("import/", "") + "?import_done=1";
|
30
|
+
}
|
31
|
+
</script>
|
32
|
+
{% endblock %}
|
treenode/utils/__init__.py
CHANGED
@@ -1,9 +1,9 @@
|
|
1
1
|
import importlib
|
2
2
|
|
3
|
-
extra = all(
|
4
|
-
|
5
|
-
|
6
|
-
)
|
3
|
+
extra = all([
|
4
|
+
importlib.util.find_spec(pkg) is not None
|
5
|
+
for pkg in ["openpyxl", "yaml", "xlsxwriter"]
|
6
|
+
])
|
7
7
|
|
8
8
|
if extra:
|
9
9
|
from .exporter import TreeNodeExporter
|
@@ -11,4 +11,3 @@ if extra:
|
|
11
11
|
__all__ = ["TreeNodeExporter", "TreeNodeImporter"]
|
12
12
|
else:
|
13
13
|
__all__ = []
|
14
|
-
|
treenode/utils/exporter.py
CHANGED
@@ -12,7 +12,7 @@ Features:
|
|
12
12
|
- Provides optimized data extraction for QuerySets.
|
13
13
|
- Generates downloadable files with appropriate HTTP responses.
|
14
14
|
|
15
|
-
Version: 2.0.
|
15
|
+
Version: 2.0.11
|
16
16
|
Author: Timur Kady
|
17
17
|
Email: timurkady@yandex.com
|
18
18
|
"""
|
@@ -21,7 +21,9 @@ Email: timurkady@yandex.com
|
|
21
21
|
import csv
|
22
22
|
import json
|
23
23
|
import yaml
|
24
|
-
import
|
24
|
+
import xlsxwriter
|
25
|
+
import numpy as np
|
26
|
+
import uuid
|
25
27
|
from io import BytesIO
|
26
28
|
from django.http import HttpResponse
|
27
29
|
import logging
|
@@ -42,6 +44,7 @@ class TreeNodeExporter:
|
|
42
44
|
self.queryset = queryset
|
43
45
|
self.filename = filename
|
44
46
|
self.fields = [field.name for field in queryset.model._meta.fields]
|
47
|
+
self.fields = self.get_ordered_fields()
|
45
48
|
|
46
49
|
def export(self, format):
|
47
50
|
"""Determine the export format and calls the corresponding method."""
|
@@ -59,7 +62,9 @@ class TreeNodeExporter:
|
|
59
62
|
def process_complex_fields(self, record):
|
60
63
|
"""Convert complex fields (lists, dictionaries) into JSON strings."""
|
61
64
|
for key, value in record.items():
|
62
|
-
if isinstance(value,
|
65
|
+
if isinstance(value, uuid.UUID):
|
66
|
+
record[key] = str(value)
|
67
|
+
elif isinstance(value, (list, dict)):
|
63
68
|
try:
|
64
69
|
record[key] = json.dumps(value, ensure_ascii=False)
|
65
70
|
except Exception as e:
|
@@ -67,10 +72,29 @@ class TreeNodeExporter:
|
|
67
72
|
record[key] = None
|
68
73
|
return record
|
69
74
|
|
75
|
+
def get_ordered_fields(self):
|
76
|
+
"""Return fields in the desired order.
|
77
|
+
|
78
|
+
Order: id, tn_parent, tn_priority, then the rest.
|
79
|
+
"""
|
80
|
+
required_fields = ["id", "tn_parent", "tn_priority"]
|
81
|
+
other_fields = [
|
82
|
+
field for field in self.fields if field not in required_fields]
|
83
|
+
return required_fields + other_fields
|
84
|
+
|
85
|
+
def get_sorted_queryset(self):
|
86
|
+
"""Quick sort queryset by tn_order."""
|
87
|
+
queryset = self.queryset
|
88
|
+
tn_orders = np.array([obj.tn_order for obj in queryset])
|
89
|
+
sorted_indices = np.argsort(tn_orders)
|
90
|
+
queryset_list = list(queryset.iterator())
|
91
|
+
result = [queryset_list[int(idx)] for idx in sorted_indices]
|
92
|
+
return result
|
93
|
+
|
70
94
|
def get_data(self):
|
71
95
|
"""Return a list of data from QuerySet as dictionaries."""
|
72
96
|
data = []
|
73
|
-
for obj in self.
|
97
|
+
for obj in self.get_sorted_queryset():
|
74
98
|
record = {}
|
75
99
|
for field in self.fields:
|
76
100
|
value = getattr(obj, field, None)
|
@@ -83,7 +107,7 @@ class TreeNodeExporter:
|
|
83
107
|
ensure_ascii=False)
|
84
108
|
elif field_object.many_to_one:
|
85
109
|
# ForeignKey - save as ID
|
86
|
-
record[field] = value
|
110
|
+
record[field] = getattr(value, "id", None)
|
87
111
|
else:
|
88
112
|
record[field] = value
|
89
113
|
else:
|
@@ -93,49 +117,78 @@ class TreeNodeExporter:
|
|
93
117
|
return data
|
94
118
|
|
95
119
|
def to_csv(self):
|
96
|
-
"""Export to CSV with proper
|
97
|
-
response = HttpResponse(content_type="text/csv")
|
120
|
+
"""Export to CSV with proper UTF-8 encoding."""
|
121
|
+
response = HttpResponse(content_type="text/csv; charset=utf-8")
|
98
122
|
response["Content-Disposition"] = f'attachment; filename="{self.filename}.csv"'
|
123
|
+
response.write("\ufeff") # Добавляем BOM для Excel
|
124
|
+
|
99
125
|
writer = csv.DictWriter(response, fieldnames=self.fields)
|
100
126
|
writer.writeheader()
|
101
|
-
|
127
|
+
for row in self.get_data():
|
128
|
+
writer.writerow({key: str(value)
|
129
|
+
for key, value in row.items()}) # Приводим к строкам
|
130
|
+
|
102
131
|
return response
|
103
132
|
|
104
133
|
def to_json(self):
|
105
|
-
"""Export to JSON with
|
106
|
-
response = HttpResponse(content_type="application/
|
134
|
+
"""Export to JSON with proper UTF-8 encoding."""
|
135
|
+
response = HttpResponse(content_type="application/json; charset=utf-8")
|
107
136
|
response["Content-Disposition"] = f'attachment; filename="{self.filename}.json"'
|
108
|
-
json.
|
109
|
-
|
137
|
+
json_str = json.dumps(self.get_data(), ensure_ascii=False, indent=4)
|
138
|
+
response.write(json_str)
|
110
139
|
return response
|
111
140
|
|
112
141
|
def to_xlsx(self):
|
113
|
-
"""Export to XLSX."""
|
142
|
+
"""Export to XLSX with UTF-8 encoding."""
|
114
143
|
response = HttpResponse(
|
115
|
-
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
144
|
+
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
145
|
+
)
|
116
146
|
response["Content-Disposition"] = f'attachment; filename="{self.filename}.xlsx"'
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
147
|
+
|
148
|
+
output = BytesIO()
|
149
|
+
workbook = xlsxwriter.Workbook(output)
|
150
|
+
worksheet = workbook.add_worksheet()
|
151
|
+
|
152
|
+
# Заголовки
|
153
|
+
headers = list(self.fields)
|
154
|
+
for col_num, header in enumerate(headers):
|
155
|
+
worksheet.write(0, col_num, header)
|
156
|
+
|
157
|
+
# Данные
|
158
|
+
for row_num, row in enumerate(self.get_data(), start=1):
|
159
|
+
for col_num, key in enumerate(headers):
|
160
|
+
worksheet.write(
|
161
|
+
row_num,
|
162
|
+
col_num,
|
163
|
+
str(row[key]) if row[key] is not None else ""
|
164
|
+
)
|
165
|
+
|
166
|
+
workbook.close()
|
167
|
+
output.seek(0)
|
168
|
+
response.write(output.read())
|
123
169
|
return response
|
124
170
|
|
125
171
|
def to_yaml(self):
|
126
|
-
"""Export to YAML with proper
|
127
|
-
response = HttpResponse(
|
172
|
+
"""Export to YAML with proper UTF-8 encoding."""
|
173
|
+
response = HttpResponse(
|
174
|
+
content_type="application/x-yaml; charset=utf-8")
|
128
175
|
response["Content-Disposition"] = f'attachment; filename="{self.filename}.yaml"'
|
129
|
-
yaml_str = yaml.dump(
|
176
|
+
yaml_str = yaml.dump(
|
177
|
+
self.get_data(), allow_unicode=True, default_flow_style=False)
|
130
178
|
response.write(yaml_str)
|
131
179
|
return response
|
132
180
|
|
133
181
|
def to_tsv(self):
|
134
|
-
"""Export to TSV with
|
135
|
-
response = HttpResponse(
|
182
|
+
"""Export to TSV with UTF-8 encoding."""
|
183
|
+
response = HttpResponse(
|
184
|
+
content_type="text/tab-separated-values; charset=utf-8")
|
136
185
|
response["Content-Disposition"] = f'attachment; filename="{self.filename}.tsv"'
|
186
|
+
response.write("\ufeff") # Добавляем BOM
|
187
|
+
|
137
188
|
writer = csv.DictWriter(
|
138
|
-
response, fieldnames=self.fields, delimiter="
|
189
|
+
response, fieldnames=self.fields, delimiter="\t")
|
139
190
|
writer.writeheader()
|
140
|
-
|
191
|
+
for row in self.get_data():
|
192
|
+
writer.writerow({key: str(value) for key, value in row.items()})
|
193
|
+
|
141
194
|
return response
|