django-fast-treenode 2.0.10__py3-none-any.whl → 2.1.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-2.0.10.dist-info → django_fast_treenode-2.1.0.dist-info}/LICENSE +2 -2
- django_fast_treenode-2.1.0.dist-info/METADATA +161 -0
- django_fast_treenode-2.1.0.dist-info/RECORD +75 -0
- {django_fast_treenode-2.0.10.dist-info → django_fast_treenode-2.1.0.dist-info}/WHEEL +1 -1
- treenode/admin/__init__.py +9 -0
- treenode/admin/admin.py +295 -0
- treenode/admin/changelist.py +65 -0
- treenode/admin/mixins.py +302 -0
- treenode/apps.py +12 -1
- treenode/cache.py +2 -2
- treenode/docs/.gitignore +0 -0
- treenode/docs/about.md +36 -0
- treenode/docs/admin.md +104 -0
- treenode/docs/api.md +739 -0
- treenode/docs/cache.md +187 -0
- treenode/docs/import_export.md +35 -0
- treenode/docs/index.md +30 -0
- treenode/docs/installation.md +74 -0
- treenode/docs/migration.md +145 -0
- treenode/docs/models.md +128 -0
- treenode/docs/roadmap.md +45 -0
- treenode/forms.py +33 -22
- treenode/managers/__init__.py +21 -0
- treenode/managers/adjacency.py +203 -0
- treenode/managers/closure.py +278 -0
- treenode/models/__init__.py +2 -1
- treenode/models/adjacency.py +343 -0
- treenode/models/classproperty.py +3 -0
- treenode/models/closure.py +39 -65
- treenode/models/factory.py +12 -2
- treenode/models/mixins/__init__.py +23 -0
- treenode/models/mixins/ancestors.py +65 -0
- treenode/models/mixins/children.py +81 -0
- treenode/models/mixins/descendants.py +66 -0
- treenode/models/mixins/family.py +63 -0
- treenode/models/mixins/logical.py +68 -0
- treenode/models/mixins/node.py +210 -0
- treenode/models/mixins/properties.py +156 -0
- treenode/models/mixins/roots.py +96 -0
- treenode/models/mixins/siblings.py +99 -0
- treenode/models/mixins/tree.py +344 -0
- treenode/signals.py +26 -0
- treenode/static/treenode/css/tree_widget.css +201 -31
- treenode/static/treenode/css/treenode_admin.css +48 -41
- treenode/static/treenode/js/tree_widget.js +269 -131
- treenode/static/treenode/js/treenode_admin.js +131 -171
- treenode/templates/admin/tree_node_changelist.html +6 -0
- treenode/templates/admin/tree_node_import.html +27 -9
- treenode/templates/admin/tree_node_import_report.html +32 -0
- treenode/templates/admin/treenode_ajax_rows.html +7 -0
- treenode/tests/tests.py +488 -0
- treenode/urls.py +10 -6
- treenode/utils/__init__.py +2 -0
- treenode/utils/aid.py +46 -0
- treenode/utils/base16.py +38 -0
- treenode/utils/base36.py +3 -1
- treenode/utils/db.py +116 -0
- treenode/utils/exporter.py +63 -36
- treenode/utils/importer.py +168 -161
- treenode/utils/radix.py +61 -0
- treenode/version.py +2 -2
- treenode/views.py +119 -38
- treenode/widgets.py +104 -40
- django_fast_treenode-2.0.10.dist-info/METADATA +0 -698
- django_fast_treenode-2.0.10.dist-info/RECORD +0 -41
- treenode/admin.py +0 -396
- treenode/docs/Documentation +0 -664
- treenode/managers.py +0 -281
- treenode/models/proxy.py +0 -650
- {django_fast_treenode-2.0.10.dist-info → django_fast_treenode-2.1.0.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]()
|
77
|
+
|
76
78
|
# Processing: field filtering, complex value packing and type casting
|
77
|
-
|
78
|
-
|
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)
|
79
85
|
|
80
|
-
|
81
|
-
"""Import from CSV."""
|
82
|
-
text = self.get_text_content()
|
83
|
-
return list(csv.DictReader(StringIO(text)))
|
86
|
+
return processed
|
84
87
|
|
85
|
-
def
|
86
|
-
"""
|
87
|
-
|
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}
|
88
92
|
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
rows = []
|
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
|
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"]]
|
101
96
|
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
def from_tsv(self):
|
107
|
-
"""Import from TSV."""
|
108
|
-
text = self.get_text_content()
|
109
|
-
return list(csv.DictReader(StringIO(text), delimiter="\t"))
|
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,162 @@ 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.
|
180
|
-
|
181
|
-
1. Filters fields by mapping.
|
182
|
-
2. Packs complex (nested) data into JSON.
|
183
|
-
3. Converts the values of each field to the types defined in the model.
|
184
|
-
"""
|
185
|
-
processed = []
|
186
|
-
for record in records:
|
187
|
-
filtered = self.filter_fields(record)
|
188
|
-
filtered = self.process_complex_fields(filtered)
|
189
|
-
filtered = self.cast_record_types(filtered)
|
190
|
-
processed.append(filtered)
|
191
|
-
return processed
|
179
|
+
# ------------------------------------------------------------------------
|
192
180
|
|
193
|
-
def
|
181
|
+
def finalize(self, raw_data):
|
194
182
|
"""
|
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}")
|
241
262
|
try:
|
242
|
-
|
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"]
|
274
|
+
try:
|
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
|
-
|
246
|
-
result["
|
281
|
+
updated_instances.append(instance)
|
282
|
+
except Exception as e:
|
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)
|
247
290
|
except Exception as e:
|
248
|
-
|
249
|
-
result["errors"].append(error_message)
|
250
|
-
logger.warning(error_message)
|
251
|
-
continue
|
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
|
-
instances_to_update.append(instance)
|
288
|
-
except self.model.DoesNotExist:
|
289
|
-
logger.warning(
|
290
|
-
"Parent with id %s not found for instance %s",
|
291
|
-
parent_id,
|
292
|
-
obj_id
|
293
|
-
)
|
294
|
-
if instances_to_update:
|
295
|
-
update_fields = ['tn_parent']
|
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
|
306
318
|
|
307
|
-
|
308
|
-
"""
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
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
|
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"))
|
320
327
|
|
321
328
|
# The End
|
treenode/utils/radix.py
ADDED
@@ -0,0 +1,61 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
"""
|
3
|
+
Implementation of the Radix Sort algorithm.
|
4
|
+
|
5
|
+
Radix Sort is a non-comparative sorting algorithm. It avoids comparisons by
|
6
|
+
creating and distributing elements into buckets according to their radix.
|
7
|
+
|
8
|
+
It is used as a replacement for numpy when sorting materialized paths and
|
9
|
+
tree node indices.
|
10
|
+
|
11
|
+
Version: 2.1.0
|
12
|
+
Author: Timur Kady
|
13
|
+
Email: timurkady@yandex.com
|
14
|
+
"""
|
15
|
+
|
16
|
+
from collections import defaultdict
|
17
|
+
|
18
|
+
|
19
|
+
def counting_sort(pairs, index):
|
20
|
+
"""Sort pairs (key, string) by character at position index."""
|
21
|
+
count = defaultdict(list)
|
22
|
+
|
23
|
+
# Distribution of pairs into baskets
|
24
|
+
for key, s in pairs:
|
25
|
+
key_char = s[index] if index < len(s) else ''
|
26
|
+
count[key_char].append((key, s))
|
27
|
+
|
28
|
+
# Collect sorted pairs
|
29
|
+
sorted_pairs = []
|
30
|
+
for key_char in sorted(count.keys()):
|
31
|
+
sorted_pairs.extend(count[key_char])
|
32
|
+
|
33
|
+
return sorted_pairs
|
34
|
+
|
35
|
+
|
36
|
+
def radix_sort_pairs(pairs, max_length):
|
37
|
+
"""Radical sorting of pairs (key, string) by string."""
|
38
|
+
for i in range(max_length - 1, -1, -1):
|
39
|
+
pairs = counting_sort(pairs, i)
|
40
|
+
return pairs
|
41
|
+
|
42
|
+
|
43
|
+
def quick_sort(pairs):
|
44
|
+
"""
|
45
|
+
Sort tree objects by materialized path.
|
46
|
+
|
47
|
+
pairs = [{obj.id: obj.path} for obj in objs]
|
48
|
+
Returns a list of id (pk) objects sorted by their materialized path.
|
49
|
+
"""
|
50
|
+
# Get the maximum length of the string
|
51
|
+
max_length = max(len(s) for _, s in pairs)
|
52
|
+
|
53
|
+
# Sort pairs by rows
|
54
|
+
sorted_pairs = radix_sort_pairs(pairs, max_length)
|
55
|
+
|
56
|
+
# Access keys in sorted order
|
57
|
+
sorted_keys = [key for key, _ in sorted_pairs]
|
58
|
+
return sorted_keys
|
59
|
+
|
60
|
+
|
61
|
+
# The End
|
treenode/version.py
CHANGED