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/utils/importer.py
CHANGED
@@ -12,7 +12,7 @@ Features:
|
|
12
12
|
- Uses bulk operations for efficient data insertion and updates.
|
13
13
|
- Supports transactional imports to maintain data integrity.
|
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,8 +23,9 @@ import json
|
|
23
23
|
import yaml
|
24
24
|
import openpyxl
|
25
25
|
import math
|
26
|
+
import uuid
|
26
27
|
from io import BytesIO, StringIO
|
27
|
-
|
28
|
+
|
28
29
|
import logging
|
29
30
|
|
30
31
|
logger = logging.getLogger(__name__)
|
@@ -73,40 +74,30 @@ class TreeNodeImporter:
|
|
73
74
|
raise ValueError("Unsupported import format")
|
74
75
|
|
75
76
|
raw_data = importers[self.format]()
|
76
|
-
# Processing: field filtering, complex value packing and type casting
|
77
|
-
processed_data = self.process_records(raw_data)
|
78
|
-
return processed_data
|
79
77
|
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
78
|
+
# Processing: field filtering, complex value packing and type casting
|
79
|
+
processed = []
|
80
|
+
for record in raw_data:
|
81
|
+
filtered = self.filter_fields(record)
|
82
|
+
filtered = self.process_complex_fields(filtered)
|
83
|
+
filtered = self.cast_record_types(filtered)
|
84
|
+
processed.append(filtered)
|
84
85
|
|
85
|
-
|
86
|
-
"""Import from JSON."""
|
87
|
-
return json.loads(self.get_text_content())
|
86
|
+
return processed
|
88
87
|
|
89
|
-
def
|
90
|
-
"""
|
91
|
-
|
92
|
-
|
93
|
-
wb = openpyxl.load_workbook(file_stream, read_only=True)
|
94
|
-
ws = wb.active
|
95
|
-
headers = [
|
96
|
-
cell.value for cell in next(ws.iter_rows(min_row=1, max_row=1))
|
97
|
-
]
|
98
|
-
for row in ws.iter_rows(min_row=2, values_only=True):
|
99
|
-
rows.append(dict(zip(headers, row)))
|
100
|
-
return rows
|
88
|
+
def get_tn_orders(self, rows):
|
89
|
+
"""Calculate the materialized path without including None parents."""
|
90
|
+
# Build a mapping from id to record for quick lookup.
|
91
|
+
row_dict = {row["id"]: row for row in rows}
|
101
92
|
|
102
|
-
|
103
|
-
|
104
|
-
|
93
|
+
def get_ancestor_path(row):
|
94
|
+
parent_field = 'tn_parent' if 'tn_parent' in row else 'tn_parent_id'
|
95
|
+
return get_ancestor_path(row_dict[row[parent_field]]) + [row["id"]] if row[parent_field] else [row["id"]]
|
105
96
|
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
97
|
+
return [
|
98
|
+
{"id": row["id"], "path": get_ancestor_path(row)}
|
99
|
+
for row in rows
|
100
|
+
]
|
110
101
|
|
111
102
|
def filter_fields(self, record):
|
112
103
|
"""
|
@@ -126,6 +117,8 @@ class TreeNodeImporter:
|
|
126
117
|
If the field value is a dictionary or list.
|
127
118
|
"""
|
128
119
|
for key, value in record.items():
|
120
|
+
if isinstance(value, uuid.UUID):
|
121
|
+
record[key] = str(value)
|
129
122
|
if isinstance(value, (list, dict)):
|
130
123
|
try:
|
131
124
|
record[key] = json.dumps(value, ensure_ascii=False)
|
@@ -136,11 +129,11 @@ class TreeNodeImporter:
|
|
136
129
|
|
137
130
|
def cast_record_types(self, record):
|
138
131
|
"""
|
139
|
-
|
132
|
+
Cast the values of the record fields to the types defined in the model.
|
140
133
|
|
141
134
|
For each field, its to_python() method is called. If the value is nan,
|
142
135
|
it is replaced with None.
|
143
|
-
For ForeignKey fields (many-to-one), the value is written to
|
136
|
+
For ForeignKey fields (many-to-one), the value is written to
|
144
137
|
the <field>_id attribute, and the original key is removed.
|
145
138
|
"""
|
146
139
|
for field in self.model._meta.fields:
|
@@ -155,10 +148,15 @@ class TreeNodeImporter:
|
|
155
148
|
# Записываем в атрибут, например, tn_parent_id
|
156
149
|
record[field.attname] = converted
|
157
150
|
except Exception as e:
|
158
|
-
logger.warning(
|
159
|
-
|
151
|
+
logger.warning(
|
152
|
+
"Error converting FK field %s with value %r: %s",
|
153
|
+
field_name,
|
154
|
+
value,
|
155
|
+
e
|
156
|
+
)
|
160
157
|
record[field.attname] = None
|
161
|
-
# Удаляем оригинальное значение, чтобы Django не пыталась
|
158
|
+
# Удаляем оригинальное значение, чтобы Django не пыталась
|
159
|
+
# его обработать
|
162
160
|
del record[field_name]
|
163
161
|
else:
|
164
162
|
if field_name in record:
|
@@ -169,153 +167,163 @@ class TreeNodeImporter:
|
|
169
167
|
try:
|
170
168
|
record[field_name] = field.to_python(value)
|
171
169
|
except Exception as e:
|
172
|
-
logger.warning(
|
173
|
-
|
170
|
+
logger.warning(
|
171
|
+
"Error converting field %s with value %r: %s",
|
172
|
+
field_name,
|
173
|
+
value,
|
174
|
+
e
|
175
|
+
)
|
174
176
|
record[field_name] = None
|
175
177
|
return record
|
176
178
|
|
177
|
-
|
178
|
-
"""
|
179
|
-
Process a list of records.
|
179
|
+
# ------------------------------------------------------------------------
|
180
180
|
|
181
|
-
|
182
|
-
2. Packs complex (nested) data into JSON.
|
183
|
-
3. Converts the values of each field to the types defined in the model.
|
181
|
+
def finalize(self, raw_data):
|
184
182
|
"""
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
183
|
+
Finalize import.
|
184
|
+
|
185
|
+
Processes raw_data, creating and updating objects by levels
|
186
|
+
(from roots to leaves) using the materialized path to calculate
|
187
|
+
the level.
|
188
|
+
|
189
|
+
Algorithm:
|
190
|
+
1. Build a raw_by_id dictionary for quick access to records by id.
|
191
|
+
2. For each record, calculate the materialized path:
|
192
|
+
- If tn_parent is specified and exists in raw_data, recursively get
|
193
|
+
the parent's path and add its id.
|
194
|
+
- If tn_parent is missing from raw_data, check if the parent is in
|
195
|
+
the database.
|
196
|
+
If not, generate an error.
|
197
|
+
3. Record level = length of its materialized path.
|
198
|
+
4. Split records into those that need to be created (if the object
|
199
|
+
with the given id is not yet in the database), and those that need
|
200
|
+
to be updated.
|
201
|
+
5. To create, process groups by levels (sort by increasing level):
|
202
|
+
- Validate each record, if there are no errors, add the instance to
|
203
|
+
the list.
|
204
|
+
- After each level, we perform bulk_create.
|
205
|
+
6. For updates, we collect instances, fixing fields (without id)
|
206
|
+
and perform bulk_update.
|
207
|
+
|
208
|
+
Returns a dictionary:
|
209
|
+
{
|
210
|
+
"create": [созданные объекты],
|
211
|
+
"update": [обновлённые объекты],
|
212
|
+
"errors": [список ошибок]
|
213
|
+
}
|
213
214
|
"""
|
214
215
|
result = {
|
215
216
|
"create": [],
|
216
217
|
"update": [],
|
217
|
-
"update_fields": [],
|
218
|
-
"fk_mappings": {},
|
219
218
|
"errors": []
|
220
219
|
}
|
221
220
|
|
222
|
-
for
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
if
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
221
|
+
# 1. Calculate the materialized path and level for each entry.
|
222
|
+
paths = self.get_tn_orders(raw_data)
|
223
|
+
# key: record id, value: уровень (int)
|
224
|
+
levels_by_record = {rec["id"]: len(rec["path"])-1 for rec in paths}
|
225
|
+
|
226
|
+
# 2. Разбиваем записи по уровням
|
227
|
+
levels = {}
|
228
|
+
for record in raw_data:
|
229
|
+
level = levels_by_record.get(record["id"], 0)
|
230
|
+
if level not in levels:
|
231
|
+
levels[level] = []
|
232
|
+
levels[level].append(record)
|
233
|
+
|
234
|
+
records_by_level = [
|
235
|
+
sorted(
|
236
|
+
levels[key],
|
237
|
+
key=lambda x: (x.get(
|
238
|
+
"tn_parent",
|
239
|
+
x.get("tn_parent_id", 0)) or -1)
|
240
|
+
)
|
241
|
+
for key in sorted(levels.keys())
|
242
|
+
]
|
240
243
|
|
244
|
+
# 4. We split the records into those to create and those to update.
|
245
|
+
# The list of records to update
|
246
|
+
to_update = []
|
247
|
+
|
248
|
+
for level in range(len(records_by_level)):
|
249
|
+
instances_to_create = []
|
250
|
+
for record in records_by_level[level]:
|
251
|
+
rec_id = record["id"]
|
252
|
+
if self.model.objects.filter(pk=rec_id).exists():
|
253
|
+
to_update.append(record)
|
254
|
+
else:
|
255
|
+
instance = self.model(**record)
|
256
|
+
try:
|
257
|
+
instance.full_clean()
|
258
|
+
instances_to_create.append(instance)
|
259
|
+
except Exception as e:
|
260
|
+
result["errors"].append(f"Validation error for record \
|
261
|
+
{record['id']} on level {level}: {e}")
|
262
|
+
try:
|
263
|
+
created = self.model.objects.bulk_create(instances_to_create)
|
264
|
+
result["create"].extend(created)
|
265
|
+
except Exception as e:
|
266
|
+
result["errors"].append(f"Create error on level {level}: {e}")
|
267
|
+
|
268
|
+
# 6. Processing updates: collecting instances and a list of fields
|
269
|
+
# for bulk_update
|
270
|
+
updated_instances = []
|
271
|
+
update_fields_set = set()
|
272
|
+
for record in to_update:
|
273
|
+
rec_id = record["id"]
|
241
274
|
try:
|
242
|
-
instance = self.model(
|
275
|
+
instance = self.model.objects.get(pk=rec_id)
|
276
|
+
for field, value in record.items():
|
277
|
+
if field != "id":
|
278
|
+
setattr(instance, field, value)
|
279
|
+
update_fields_set.add(field)
|
243
280
|
instance.full_clean()
|
244
|
-
|
245
|
-
# Save the parent key value for future update
|
246
|
-
result["fk_mappings"][instance.id] = fk_value
|
281
|
+
updated_instances.append(instance)
|
247
282
|
except Exception as e:
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
283
|
+
result["errors"].append(
|
284
|
+
f"Validation error updating record {rec_id}: {e}")
|
285
|
+
update_fields = list(update_fields_set)
|
286
|
+
if updated_instances:
|
287
|
+
try:
|
288
|
+
self.model.objects.bulk_update(updated_instances, update_fields)
|
289
|
+
result["update"].extend(updated_instances)
|
290
|
+
except Exception as e:
|
291
|
+
result["errors"].append(f"Bulk update error: {e}")
|
252
292
|
|
253
|
-
# In this scenario, the update occurs only for the parent relationship
|
254
|
-
result["update_fields"] = ['tn_parent']
|
255
293
|
return result
|
256
294
|
|
257
|
-
|
258
|
-
"""
|
259
|
-
Save objects to the database as part of an atomic transaction.
|
295
|
+
# ------------------------------------------------------------------------
|
260
296
|
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
with transaction.atomic():
|
266
|
-
if update:
|
267
|
-
self.model.objects.bulk_update(update, fields, batch_size=1000)
|
268
|
-
if create:
|
269
|
-
self.model.objects.bulk_create(create, batch_size=1000)
|
297
|
+
def from_csv(self):
|
298
|
+
"""Import from CSV."""
|
299
|
+
text = self.get_text_content()
|
300
|
+
return list(csv.DictReader(StringIO(text)))
|
270
301
|
|
271
|
-
def
|
272
|
-
"""
|
273
|
-
|
302
|
+
def from_json(self):
|
303
|
+
"""Import from JSON."""
|
304
|
+
return json.loads(self.get_text_content())
|
274
305
|
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
self.model.objects.bulk_update(
|
297
|
-
instances_to_update, update_fields, batch_size=1000)
|
298
|
-
|
299
|
-
# If you want to combine the save and update parent operations,
|
300
|
-
# you can add a method that calls save_data and update_parent_relations
|
301
|
-
# sequentially.
|
302
|
-
|
303
|
-
def finalize_import(self, clean_result):
|
304
|
-
"""
|
305
|
-
Finalize the import: saves new objects and updates parent links.
|
306
|
+
def from_xlsx(self):
|
307
|
+
"""Import from XLSX (Excel)."""
|
308
|
+
file_stream = BytesIO(self.file_content)
|
309
|
+
rows = []
|
310
|
+
wb = openpyxl.load_workbook(file_stream, read_only=True)
|
311
|
+
ws = wb.active
|
312
|
+
headers = [
|
313
|
+
cell.value for cell in next(ws.iter_rows(min_row=1, max_row=1))
|
314
|
+
]
|
315
|
+
for row in ws.iter_rows(min_row=2, values_only=True):
|
316
|
+
rows.append(dict(zip(headers, row)))
|
317
|
+
return rows
|
318
|
+
|
319
|
+
def from_yaml(self):
|
320
|
+
"""Import from YAML."""
|
321
|
+
return yaml.safe_load(self.get_text_content())
|
322
|
+
|
323
|
+
def from_tsv(self):
|
324
|
+
"""Import from TSV."""
|
325
|
+
text = self.get_text_content()
|
326
|
+
return list(csv.DictReader(StringIO(text), delimiter="\t"))
|
306
327
|
|
307
|
-
:param clean_result: dictionary returned by the clean method.
|
308
|
-
"""
|
309
|
-
# If there are errors, you can interrupt the import or return them
|
310
|
-
# for processing
|
311
|
-
if clean_result["errors"]:
|
312
|
-
return clean_result["errors"]
|
313
|
-
|
314
|
-
# First we do a bulk creation
|
315
|
-
self.save_data(
|
316
|
-
clean_result["create"], clean_result["update"], clean_result["update_fields"])
|
317
|
-
# Then we update the parent links
|
318
|
-
self.update_parent_relations(clean_result["fk_mappings"])
|
319
|
-
return None # Or return a success message
|
320
328
|
|
321
329
|
# The End
|
treenode/views.py
CHANGED
@@ -14,7 +14,7 @@ Features:
|
|
14
14
|
- Uses optimized QuerySets for efficient database queries.
|
15
15
|
- Handles validation and error responses gracefully.
|
16
16
|
|
17
|
-
Version: 2.0.
|
17
|
+
Version: 2.0.11
|
18
18
|
Author: Timur Kady
|
19
19
|
Email: timurkady@yandex.com
|
20
20
|
"""
|
@@ -23,8 +23,9 @@ Email: timurkady@yandex.com
|
|
23
23
|
from django.http import JsonResponse
|
24
24
|
from django.views import View
|
25
25
|
from django.apps import apps
|
26
|
-
|
26
|
+
import numpy as np
|
27
27
|
from django.core.exceptions import ObjectDoesNotExist
|
28
|
+
from django.utils.translation import gettext_lazy as _
|
28
29
|
|
29
30
|
|
30
31
|
class TreeNodeAutocompleteView(View):
|
@@ -50,14 +51,11 @@ class TreeNodeAutocompleteView(View):
|
|
50
51
|
)
|
51
52
|
|
52
53
|
queryset = model.objects.filter(name__icontains=q)
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
default=Value(len(pk_list)),
|
59
|
-
output_field=IntegerField())
|
60
|
-
)[:10]
|
54
|
+
# Sorting
|
55
|
+
tn_orders = np.array([obj.tn_order for obj in queryset])
|
56
|
+
sorted_indices = np.argsort(tn_orders)
|
57
|
+
queryset_list = list(queryset.iterator())
|
58
|
+
sorted_queryset = [queryset_list[int(idx)] for idx in sorted_indices]
|
61
59
|
|
62
60
|
results = [
|
63
61
|
{
|
@@ -66,8 +64,17 @@ class TreeNodeAutocompleteView(View):
|
|
66
64
|
"level": node.get_level(),
|
67
65
|
"is_leaf": node.is_leaf(),
|
68
66
|
}
|
69
|
-
for node in
|
67
|
+
for node in sorted_queryset
|
70
68
|
]
|
69
|
+
|
70
|
+
root_option = {
|
71
|
+
"id": "",
|
72
|
+
"text": _("Root"),
|
73
|
+
"level": 0,
|
74
|
+
"is_leaf": True,
|
75
|
+
}
|
76
|
+
results.insert(0, root_option)
|
77
|
+
|
71
78
|
return JsonResponse({"results": results})
|
72
79
|
|
73
80
|
|
@@ -93,7 +100,6 @@ class GetChildrenCountView(View):
|
|
93
100
|
try:
|
94
101
|
parent_node = model.objects.get(pk=parent_id)
|
95
102
|
children_count = parent_node.get_children_count()
|
96
|
-
print("parent_id=", parent_id, " children_count=", children_count)
|
97
103
|
except ObjectDoesNotExist:
|
98
104
|
return JsonResponse(
|
99
105
|
{"error": "Parent node not found"},
|
treenode/widgets.py
CHANGED
@@ -13,7 +13,7 @@ Features:
|
|
13
13
|
- Supports dynamic model binding for reusable implementations.
|
14
14
|
- Integrates with Django’s form system.
|
15
15
|
|
16
|
-
Version: 2.0.
|
16
|
+
Version: 2.0.11
|
17
17
|
Author: Timur Kady
|
18
18
|
Email: timurkady@yandex.com
|
19
19
|
"""
|
@@ -48,18 +48,34 @@ class TreeWidget(forms.Select):
|
|
48
48
|
if "placeholder" in attrs:
|
49
49
|
del attrs["placeholder"]
|
50
50
|
|
51
|
-
#
|
51
|
+
# Force passing `model`
|
52
52
|
if "data-forward" not in attrs:
|
53
|
-
|
53
|
+
model = getattr(self, "model", None)
|
54
|
+
if not model and hasattr(self.choices, "queryset"):
|
54
55
|
model = self.choices.queryset.model
|
56
|
+
if model is None:
|
57
|
+
raise ValueError("TreeWidget: model not passed or not defined")
|
58
|
+
|
59
|
+
try:
|
55
60
|
label = model._meta.app_label
|
56
61
|
model_name = model._meta.model_name
|
57
62
|
model_label = f"{label}.{model_name}"
|
58
63
|
attrs["data-forward"] = f'{{"model": "{model_label}"}}'
|
64
|
+
except AttributeError as e:
|
65
|
+
raise ValueError(
|
66
|
+
"TreeWidget: model object is not a valid Django model"
|
67
|
+
) from e
|
68
|
+
|
69
|
+
# Force focus to current value
|
70
|
+
if self.choices:
|
71
|
+
try:
|
72
|
+
current_value = self.value()
|
73
|
+
if current_value:
|
74
|
+
attrs["data-selected"] = str(current_value)
|
59
75
|
except Exception:
|
60
|
-
|
76
|
+
# In case the value is missing
|
77
|
+
pass
|
61
78
|
|
62
79
|
return attrs
|
63
80
|
|
64
|
-
|
65
81
|
# The End
|
File without changes
|
File without changes
|
{django_fast_treenode-2.0.10.dist-info → django_fast_treenode-2.0.11.dist-info}/top_level.txt
RENAMED
File without changes
|