django-fast-treenode 2.0.10__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.10.dist-info → django_fast_treenode-2.0.11.dist-info}/METADATA +2 -2
- {django_fast_treenode-2.0.10.dist-info → django_fast_treenode-2.0.11.dist-info}/RECORD +17 -16
- treenode/admin.py +56 -13
- treenode/docs/Documentation +7 -35
- treenode/forms.py +27 -14
- treenode/managers.py +306 -168
- treenode/models/closure.py +21 -46
- treenode/models/proxy.py +43 -24
- treenode/templates/admin/tree_node_import.html +27 -9
- treenode/templates/admin/tree_node_import_report.html +32 -0
- treenode/utils/exporter.py +61 -36
- treenode/utils/importer.py +169 -161
- treenode/views.py +18 -12
- treenode/widgets.py +21 -5
- {django_fast_treenode-2.0.10.dist-info → django_fast_treenode-2.0.11.dist-info}/LICENSE +0 -0
- {django_fast_treenode-2.0.10.dist-info → django_fast_treenode-2.0.11.dist-info}/WHEEL +0 -0
- {django_fast_treenode-2.0.10.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/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
|
"""
|
@@ -23,6 +23,7 @@ import json
|
|
23
23
|
import yaml
|
24
24
|
import xlsxwriter
|
25
25
|
import numpy as np
|
26
|
+
import uuid
|
26
27
|
from io import BytesIO
|
27
28
|
from django.http import HttpResponse
|
28
29
|
import logging
|
@@ -43,6 +44,7 @@ class TreeNodeExporter:
|
|
43
44
|
self.queryset = queryset
|
44
45
|
self.filename = filename
|
45
46
|
self.fields = [field.name for field in queryset.model._meta.fields]
|
47
|
+
self.fields = self.get_ordered_fields()
|
46
48
|
|
47
49
|
def export(self, format):
|
48
50
|
"""Determine the export format and calls the corresponding method."""
|
@@ -60,7 +62,9 @@ class TreeNodeExporter:
|
|
60
62
|
def process_complex_fields(self, record):
|
61
63
|
"""Convert complex fields (lists, dictionaries) into JSON strings."""
|
62
64
|
for key, value in record.items():
|
63
|
-
if isinstance(value,
|
65
|
+
if isinstance(value, uuid.UUID):
|
66
|
+
record[key] = str(value)
|
67
|
+
elif isinstance(value, (list, dict)):
|
64
68
|
try:
|
65
69
|
record[key] = json.dumps(value, ensure_ascii=False)
|
66
70
|
except Exception as e:
|
@@ -68,11 +72,24 @@ class TreeNodeExporter:
|
|
68
72
|
record[key] = None
|
69
73
|
return record
|
70
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
|
+
|
71
85
|
def get_sorted_queryset(self):
|
72
|
-
"""
|
73
|
-
|
74
|
-
tn_orders = np.array([obj.tn_order for obj in
|
75
|
-
|
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
|
76
93
|
|
77
94
|
def get_data(self):
|
78
95
|
"""Return a list of data from QuerySet as dictionaries."""
|
@@ -100,70 +117,78 @@ class TreeNodeExporter:
|
|
100
117
|
return data
|
101
118
|
|
102
119
|
def to_csv(self):
|
103
|
-
"""Export to CSV with proper
|
104
|
-
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")
|
105
122
|
response["Content-Disposition"] = f'attachment; filename="{self.filename}.csv"'
|
123
|
+
response.write("\ufeff") # Добавляем BOM для Excel
|
124
|
+
|
106
125
|
writer = csv.DictWriter(response, fieldnames=self.fields)
|
107
126
|
writer.writeheader()
|
108
|
-
|
127
|
+
for row in self.get_data():
|
128
|
+
writer.writerow({key: str(value)
|
129
|
+
for key, value in row.items()}) # Приводим к строкам
|
130
|
+
|
109
131
|
return response
|
110
132
|
|
111
133
|
def to_json(self):
|
112
|
-
"""Export to JSON with
|
113
|
-
response = HttpResponse(content_type="application/
|
134
|
+
"""Export to JSON with proper UTF-8 encoding."""
|
135
|
+
response = HttpResponse(content_type="application/json; charset=utf-8")
|
114
136
|
response["Content-Disposition"] = f'attachment; filename="{self.filename}.json"'
|
115
|
-
json.
|
116
|
-
|
117
|
-
response,
|
118
|
-
ensure_ascii=False,
|
119
|
-
indent=4,
|
120
|
-
default=str
|
121
|
-
)
|
137
|
+
json_str = json.dumps(self.get_data(), ensure_ascii=False, indent=4)
|
138
|
+
response.write(json_str)
|
122
139
|
return response
|
123
140
|
|
124
141
|
def to_xlsx(self):
|
125
|
-
"""Export to XLSX."""
|
142
|
+
"""Export to XLSX with UTF-8 encoding."""
|
126
143
|
response = HttpResponse(
|
127
144
|
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
128
145
|
)
|
129
146
|
response["Content-Disposition"] = f'attachment; filename="{self.filename}.xlsx"'
|
130
147
|
|
131
|
-
data = self.get_data()
|
132
148
|
output = BytesIO()
|
133
149
|
workbook = xlsxwriter.Workbook(output)
|
134
150
|
worksheet = workbook.add_worksheet()
|
135
151
|
|
136
|
-
#
|
137
|
-
headers = list(
|
152
|
+
# Заголовки
|
153
|
+
headers = list(self.fields)
|
138
154
|
for col_num, header in enumerate(headers):
|
139
155
|
worksheet.write(0, col_num, header)
|
140
156
|
|
141
|
-
#
|
142
|
-
for row_num, row in enumerate(
|
157
|
+
# Данные
|
158
|
+
for row_num, row in enumerate(self.get_data(), start=1):
|
143
159
|
for col_num, key in enumerate(headers):
|
144
|
-
worksheet.write(
|
160
|
+
worksheet.write(
|
161
|
+
row_num,
|
162
|
+
col_num,
|
163
|
+
str(row[key]) if row[key] is not None else ""
|
164
|
+
)
|
145
165
|
|
146
166
|
workbook.close()
|
147
167
|
output.seek(0)
|
148
|
-
|
168
|
+
response.write(output.read())
|
169
|
+
return response
|
149
170
|
|
150
171
|
def to_yaml(self):
|
151
|
-
"""Export to YAML with proper
|
152
|
-
response = HttpResponse(
|
172
|
+
"""Export to YAML with proper UTF-8 encoding."""
|
173
|
+
response = HttpResponse(
|
174
|
+
content_type="application/x-yaml; charset=utf-8")
|
153
175
|
response["Content-Disposition"] = f'attachment; filename="{self.filename}.yaml"'
|
154
|
-
yaml_str = yaml.dump(
|
176
|
+
yaml_str = yaml.dump(
|
177
|
+
self.get_data(), allow_unicode=True, default_flow_style=False)
|
155
178
|
response.write(yaml_str)
|
156
179
|
return response
|
157
180
|
|
158
181
|
def to_tsv(self):
|
159
|
-
"""Export to TSV with
|
160
|
-
response = HttpResponse(
|
182
|
+
"""Export to TSV with UTF-8 encoding."""
|
183
|
+
response = HttpResponse(
|
184
|
+
content_type="text/tab-separated-values; charset=utf-8")
|
161
185
|
response["Content-Disposition"] = f'attachment; filename="{self.filename}.tsv"'
|
186
|
+
response.write("\ufeff") # Добавляем BOM
|
187
|
+
|
162
188
|
writer = csv.DictWriter(
|
163
|
-
response,
|
164
|
-
fieldnames=self.fields,
|
165
|
-
delimiter=" "
|
166
|
-
)
|
189
|
+
response, fieldnames=self.fields, delimiter="\t")
|
167
190
|
writer.writeheader()
|
168
|
-
|
191
|
+
for row in self.get_data():
|
192
|
+
writer.writerow({key: str(value) for key, value in row.items()})
|
193
|
+
|
169
194
|
return response
|