django-fast-treenode 2.0.9__tar.gz → 2.0.10__tar.gz
Sign up to get free protection for your applications and to get access to all the features.
- {django_fast_treenode-2.0.9/django_fast_treenode.egg-info → django_fast_treenode-2.0.10}/PKG-INFO +3 -2
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10/django_fast_treenode.egg-info}/PKG-INFO +3 -2
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/django_fast_treenode.egg-info/requires.txt +2 -1
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/pyproject.toml +3 -2
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/setup.cfg +3 -2
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/admin.py +42 -61
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/forms.py +46 -18
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/models/closure.py +2 -1
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/templates/admin/tree_node_changelist.html +2 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/utils/__init__.py +4 -5
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/utils/exporter.py +43 -15
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/utils/importer.py +83 -58
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/LICENSE +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/MANIFEST.in +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/README.md +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/django_fast_treenode.egg-info/SOURCES.txt +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/django_fast_treenode.egg-info/dependency_links.txt +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/django_fast_treenode.egg-info/top_level.txt +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/setup.py +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/__init__.py +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/apps.py +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/cache.py +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/docs/Documentation +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/managers.py +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/models/__init__.py +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/models/classproperty.py +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/models/factory.py +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/models/proxy.py +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/static/.gitkeep +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/static/treenode/.gitkeep +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/static/treenode/css/.gitkeep +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/static/treenode/css/tree_widget.css +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/static/treenode/css/treenode_admin.css +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/static/treenode/js/.gitkeep +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/static/treenode/js/tree_widget.js +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/static/treenode/js/treenode_admin.js +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/templates/.gitkeep +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/templates/admin/.gitkeep +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/templates/admin/export_success.html +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/templates/admin/tree_node_export.html +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/templates/admin/tree_node_import.html +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/templates/widgets/tree_widget.css +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/templates/widgets/tree_widget.html +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/urls.py +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/utils/base36.py +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/version.py +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/views.py +0 -0
- {django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/widgets.py +0 -0
{django_fast_treenode-2.0.9/django_fast_treenode.egg-info → django_fast_treenode-2.0.10}/PKG-INFO
RENAMED
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.2
|
2
2
|
Name: django-fast-treenode
|
3
|
-
Version: 2.0.
|
3
|
+
Version: 2.0.10
|
4
4
|
Summary: Application for supporting tree (hierarchical) data structure in Django projects
|
5
5
|
Home-page: https://github.com/TimurKady/django-fast-treenode
|
6
6
|
Author: Timur Kady
|
@@ -53,11 +53,12 @@ Description-Content-Type: text/markdown
|
|
53
53
|
License-File: LICENSE
|
54
54
|
Requires-Dist: Django>=4.0
|
55
55
|
Requires-Dist: pympler>=1.0
|
56
|
+
Requires-Dist: numpy>=2.0
|
56
57
|
Requires-Dist: django-widget-tweaks>=1.5
|
57
58
|
Provides-Extra: import-export
|
58
59
|
Requires-Dist: openpyxl; extra == "import-export"
|
59
60
|
Requires-Dist: pyyaml; extra == "import-export"
|
60
|
-
Requires-Dist:
|
61
|
+
Requires-Dist: xlsxwriter; extra == "import-export"
|
61
62
|
|
62
63
|
# Django-fast-treenode
|
63
64
|
__Combination of Adjacency List and Closure Table__
|
{django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10/django_fast_treenode.egg-info}/PKG-INFO
RENAMED
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.2
|
2
2
|
Name: django-fast-treenode
|
3
|
-
Version: 2.0.
|
3
|
+
Version: 2.0.10
|
4
4
|
Summary: Application for supporting tree (hierarchical) data structure in Django projects
|
5
5
|
Home-page: https://github.com/TimurKady/django-fast-treenode
|
6
6
|
Author: Timur Kady
|
@@ -53,11 +53,12 @@ Description-Content-Type: text/markdown
|
|
53
53
|
License-File: LICENSE
|
54
54
|
Requires-Dist: Django>=4.0
|
55
55
|
Requires-Dist: pympler>=1.0
|
56
|
+
Requires-Dist: numpy>=2.0
|
56
57
|
Requires-Dist: django-widget-tweaks>=1.5
|
57
58
|
Provides-Extra: import-export
|
58
59
|
Requires-Dist: openpyxl; extra == "import-export"
|
59
60
|
Requires-Dist: pyyaml; extra == "import-export"
|
60
|
-
Requires-Dist:
|
61
|
+
Requires-Dist: xlsxwriter; extra == "import-export"
|
61
62
|
|
62
63
|
# Django-fast-treenode
|
63
64
|
__Combination of Adjacency List and Closure Table__
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
4
4
|
|
5
5
|
[project]
|
6
6
|
name = "django-fast-treenode"
|
7
|
-
version = "2.0.
|
7
|
+
version = "2.0.10"
|
8
8
|
description = "Application for supporting tree (hierarchical) data structure in Django projects"
|
9
9
|
readme = "README.md"
|
10
10
|
authors = [{ name = "Timur Kady", email = "timurkady@yandex.com" }]
|
@@ -13,6 +13,7 @@ requires-python = ">=3.9"
|
|
13
13
|
dependencies = [
|
14
14
|
"Django >=4.0",
|
15
15
|
"pympler >=1.0",
|
16
|
+
"numpy >=2.0",
|
16
17
|
"django-widget-tweaks >= 1.5"
|
17
18
|
]
|
18
19
|
classifiers = [
|
@@ -40,7 +41,7 @@ classifiers = [
|
|
40
41
|
import_export = [
|
41
42
|
"openpyxl",
|
42
43
|
"pyyaml",
|
43
|
-
"
|
44
|
+
"xlsxwriter"
|
44
45
|
]
|
45
46
|
|
46
47
|
[project.urls]
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[metadata]
|
2
2
|
name = django-fast-treenode
|
3
|
-
version = 2.0.
|
3
|
+
version = 2.0.10
|
4
4
|
description = Application for supporting tree (hierarchical) data structure in Django projects
|
5
5
|
long_description = file: README.md
|
6
6
|
long_description_content_type = text/markdown
|
@@ -34,13 +34,14 @@ python_requires = >=3.9
|
|
34
34
|
install_requires =
|
35
35
|
Django >=4.0
|
36
36
|
pympler >=1.0
|
37
|
+
numpy >=2.0
|
37
38
|
django-widget-tweaks >= 1.5
|
38
39
|
|
39
40
|
[options.extras_require]
|
40
41
|
import_export =
|
41
42
|
openpyxl
|
42
43
|
pyyaml
|
43
|
-
|
44
|
+
xlsxwriter
|
44
45
|
|
45
46
|
[egg_info]
|
46
47
|
tag_build =
|
@@ -6,7 +6,7 @@ This module provides Django admin integration for the TreeNode model.
|
|
6
6
|
It includes custom tree-based sorting, optimized queries, and
|
7
7
|
import/export functionality for hierarchical data structures.
|
8
8
|
|
9
|
-
Version: 2.0.
|
9
|
+
Version: 2.0.10
|
10
10
|
Author: Timur Kady
|
11
11
|
Email: kaduevtr@gmail.com
|
12
12
|
"""
|
@@ -14,11 +14,11 @@ Email: kaduevtr@gmail.com
|
|
14
14
|
|
15
15
|
import os
|
16
16
|
import importlib
|
17
|
+
import numpy as np
|
17
18
|
from datetime import datetime
|
18
19
|
from django.contrib import admin
|
19
20
|
from django.contrib.admin.views.main import ChangeList
|
20
21
|
from django.db import models
|
21
|
-
from django.db.models import Case, When, Value, IntegerField
|
22
22
|
from django.shortcuts import render, redirect
|
23
23
|
from django.urls import path
|
24
24
|
from django.utils.encoding import force_str
|
@@ -33,8 +33,8 @@ import logging
|
|
33
33
|
logger = logging.getLogger(__name__)
|
34
34
|
|
35
35
|
|
36
|
-
class
|
37
|
-
"""Custom ChangeList
|
36
|
+
class SortedChangeList(ChangeList):
|
37
|
+
"""Custom ChangeList that sorts results in Python (after DB query)."""
|
38
38
|
|
39
39
|
def get_ordering(self, request, queryset):
|
40
40
|
"""
|
@@ -44,28 +44,26 @@ class NoPkDescOrderedChangeList(ChangeList):
|
|
44
44
|
Django Admin sorts by `-pk` (descending) by default.
|
45
45
|
This method removes `-pk` so that objects are not sorted by ID.
|
46
46
|
"""
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
47
|
+
# Remove the default '-pk' ordering if present.
|
48
|
+
ordering = list(super().get_ordering(request, queryset))
|
49
|
+
if '-pk' in ordering:
|
50
|
+
ordering.remove('-pk')
|
51
|
+
return tuple(ordering)
|
51
52
|
|
52
53
|
def get_queryset(self, request):
|
53
|
-
"""
|
54
|
-
|
55
|
-
|
56
|
-
Overrides data selection to optimize queries. Also adds
|
57
|
-
`select_related('tn_parent')` to avoid N+1 queries.
|
58
|
-
"""
|
59
|
-
queryset = super(NoPkDescOrderedChangeList, self).get_queryset(request)
|
60
|
-
node_list = sorted(queryset, key=lambda x: x.tn_order)
|
61
|
-
pk_list = [node.pk for node in node_list]
|
54
|
+
"""Get QuerySet with select_related."""
|
55
|
+
return super().get_queryset(request).select_related('tn_parent')
|
62
56
|
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
57
|
+
def get_results(self, request):
|
58
|
+
"""Return sorted results for ChangeList rendering."""
|
59
|
+
# Populate self.result_list with objects from the DB.
|
60
|
+
super().get_results(request)
|
61
|
+
result_list = self.result_list
|
62
|
+
# Extract tn_order values from each object.
|
63
|
+
tn_orders = np.array([obj.tn_order for obj in result_list])
|
64
|
+
# Get sorted indices based on tn_order (ascending order).
|
65
|
+
# Reorder the original result_list based on the sorted indices.
|
66
|
+
self.result_list = [result_list[int(i)] for i in np.argsort(tn_orders)]
|
69
67
|
|
70
68
|
|
71
69
|
class TreeNodeAdminModel(admin.ModelAdmin):
|
@@ -111,10 +109,16 @@ class TreeNodeAdminModel(admin.ModelAdmin):
|
|
111
109
|
self.list_display = [field.name for field in model._meta.fields]
|
112
110
|
|
113
111
|
# Check for necessary dependencies
|
114
|
-
self.import_export = all(
|
112
|
+
self.import_export = all([
|
115
113
|
importlib.util.find_spec(pkg) is not None
|
116
|
-
for pkg in ["openpyxl", "
|
117
|
-
)
|
114
|
+
for pkg in ["openpyxl", "yaml", "xlsxwriter"]
|
115
|
+
])
|
116
|
+
if not self.import_export:
|
117
|
+
check_results = [
|
118
|
+
pkg for pkg in ["openpyxl", "pyyaml", "xlsxwriter"] if importlib.util.find_spec(pkg) is not None
|
119
|
+
]
|
120
|
+
logger.info("Packages" + ", ".join(check_results) + " are \
|
121
|
+
not installed. Export and import functions are disabled.")
|
118
122
|
|
119
123
|
if self.import_export:
|
120
124
|
from .utils import TreeNodeImporter, TreeNodeExporter
|
@@ -126,35 +130,12 @@ class TreeNodeAdminModel(admin.ModelAdmin):
|
|
126
130
|
self.TreeNodeExporter = None
|
127
131
|
|
128
132
|
def get_queryset(self, request):
|
129
|
-
"""Override
|
133
|
+
"""Override get_queryset to simply return an optimized queryset."""
|
130
134
|
queryset = super().get_queryset(request)
|
131
|
-
|
132
|
-
|
133
|
-
if search_term:
|
134
|
-
"""
|
135
|
-
print(f"Поиск: {search_term}")
|
136
|
-
search_fields = self.get_search_fields(request)
|
137
|
-
print(f"Поиск по полям: {search_fields}")
|
138
|
-
|
139
|
-
q_objects = Q()
|
140
|
-
|
141
|
-
for field in search_fields:
|
142
|
-
q_objects |= Q(**{f"{field}__icontains": search_term})
|
143
|
-
|
144
|
-
|
145
|
-
queryset = queryset.filter(q_objects)
|
146
|
-
print(f"Найдено записей: {queryset.count()}")
|
147
|
-
"""
|
135
|
+
# If a search term is present, leave the queryset as is.
|
136
|
+
if request.GET.get("q"):
|
148
137
|
return queryset
|
149
|
-
|
150
|
-
node_list = sorted(queryset, key=lambda x: x.tn_order)
|
151
|
-
pk_list = [node.pk for node in node_list]
|
152
|
-
return queryset.filter(pk__in=pk_list).order_by(
|
153
|
-
Case(*[When(pk=pk, then=Value(index))
|
154
|
-
for index, pk in enumerate(pk_list)],
|
155
|
-
default=Value(len(pk_list)),
|
156
|
-
output_field=IntegerField())
|
157
|
-
)
|
138
|
+
return queryset.select_related('tn_parent')
|
158
139
|
|
159
140
|
def get_search_fields(self, request):
|
160
141
|
"""Return the correct search field."""
|
@@ -190,8 +171,8 @@ class TreeNodeAdminModel(admin.ModelAdmin):
|
|
190
171
|
return (treenode_field_display,) + tuple(base_list_display)
|
191
172
|
|
192
173
|
def get_changelist(self, request):
|
193
|
-
"""
|
194
|
-
return
|
174
|
+
"""Use SortedChangeList to sort the results at render time."""
|
175
|
+
return SortedChangeList
|
195
176
|
|
196
177
|
def changelist_view(self, request, extra_context=None):
|
197
178
|
"""Changelist View."""
|
@@ -311,7 +292,7 @@ packages are not installed."
|
|
311
292
|
)
|
312
293
|
|
313
294
|
# Import data from file
|
314
|
-
importer = TreeNodeImporter(self.model, file, ext)
|
295
|
+
importer = self.TreeNodeImporter(self.model, file, ext)
|
315
296
|
raw_data = importer.import_data()
|
316
297
|
clean_result = importer.clean(raw_data)
|
317
298
|
errors = importer.finalize_import(clean_result)
|
@@ -355,14 +336,14 @@ packages are not installed."
|
|
355
336
|
if 'download' in request.GET:
|
356
337
|
# Get file format
|
357
338
|
export_format = request.GET.get('format', 'csv')
|
358
|
-
# Important: This QuerySet provides a convenient ("friendly") order
|
359
|
-
# of tree node output during export/import.
|
360
|
-
queryset = self.get_queryset()
|
361
339
|
# Filename
|
362
340
|
now = force_str(datetime.now().strftime("%Y-%m-%d %H-%M"))
|
363
341
|
filename = self.model._meta.label + " " + now
|
364
342
|
# Init
|
365
|
-
exporter = TreeNodeExporter(
|
343
|
+
exporter = self.TreeNodeExporter(
|
344
|
+
self.get_queryset(),
|
345
|
+
filename=filename
|
346
|
+
)
|
366
347
|
# Export working
|
367
348
|
response = exporter.export(export_format)
|
368
349
|
logger.debug("DEBUG: File response generated.")
|
@@ -376,7 +357,7 @@ packages are not installed."
|
|
376
357
|
# If the format is specified, we try to perform a test export
|
377
358
|
# (without returning the file)
|
378
359
|
export_format = request.GET['format']
|
379
|
-
exporter = TreeNodeExporter(
|
360
|
+
exporter = self.TreeNodeExporter(
|
380
361
|
self.model.objects.all(),
|
381
362
|
filename=self.model._meta.model_name
|
382
363
|
)
|
@@ -9,14 +9,51 @@ Functions:
|
|
9
9
|
- __init__: Initializes the form and filters out invalid parent choices.
|
10
10
|
- factory: Dynamically creates a form class for a given TreeNode model.
|
11
11
|
|
12
|
-
Version: 2.0.
|
12
|
+
Version: 2.0.10
|
13
13
|
Author: Timur Kady
|
14
14
|
Email: timurkady@yandex.com
|
15
15
|
"""
|
16
16
|
|
17
17
|
from django import forms
|
18
|
+
import numpy as np
|
19
|
+
from django.forms.models import ModelChoiceField, ModelChoiceIterator
|
20
|
+
|
18
21
|
from .widgets import TreeWidget
|
19
|
-
|
22
|
+
|
23
|
+
|
24
|
+
class SortedModelChoiceIterator(ModelChoiceIterator):
|
25
|
+
"""Iterator Class for ModelChoiceField."""
|
26
|
+
|
27
|
+
def __iter__(self):
|
28
|
+
"""Return sorted choices based on tn_order."""
|
29
|
+
qs_list = list(self.queryset.all())
|
30
|
+
# Sort objects by their tn_order using NumPy.
|
31
|
+
tn_orders = np.array([obj.tn_order for obj in qs_list])
|
32
|
+
sorted_indices = np.argsort(tn_orders)
|
33
|
+
# Iterate over sorted indices and yield (value, label) pairs.
|
34
|
+
for idx in sorted_indices:
|
35
|
+
# Cast the index to int if it is numpy.int64.
|
36
|
+
obj = qs_list[int(idx)]
|
37
|
+
yield (
|
38
|
+
self.field.prepare_value(obj),
|
39
|
+
self.field.label_from_instance(obj)
|
40
|
+
)
|
41
|
+
|
42
|
+
|
43
|
+
class SortedModelChoiceField(ModelChoiceField):
|
44
|
+
"""ModelChoiceField Class for tn_paret field."""
|
45
|
+
|
46
|
+
def _get_choices(self):
|
47
|
+
"""Get sorted choices."""
|
48
|
+
if hasattr(self, '_choices'):
|
49
|
+
return self._choices
|
50
|
+
return SortedModelChoiceIterator(self)
|
51
|
+
|
52
|
+
def _set_choices(self, value):
|
53
|
+
"""Set choices."""
|
54
|
+
self._choices = value
|
55
|
+
|
56
|
+
choices = property(_get_choices, _set_choices)
|
20
57
|
|
21
58
|
|
22
59
|
class TreeNodeForm(forms.ModelForm):
|
@@ -40,29 +77,20 @@ class TreeNodeForm(forms.ModelForm):
|
|
40
77
|
"""Init Method."""
|
41
78
|
super().__init__(*args, **kwargs)
|
42
79
|
|
43
|
-
# Get the model from the form instance
|
44
80
|
# Use a model bound to a form
|
45
81
|
model = self._meta.model
|
46
82
|
|
47
|
-
# Проверяем наличие tn_parent и исключаем текущий узел и его потомков
|
48
83
|
if "tn_parent" in self.fields and self.instance.pk:
|
49
|
-
excluded_ids = [self.instance.pk] +
|
50
|
-
self.instance.get_descendants_pks())
|
51
|
-
|
52
|
-
# Sort by tn_order
|
84
|
+
excluded_ids = [self.instance.pk] + \
|
85
|
+
list(self.instance.get_descendants_pks())
|
53
86
|
queryset = model.objects.exclude(pk__in=excluded_ids)
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
default=Value(len(pk_list)),
|
60
|
-
output_field=IntegerField())
|
87
|
+
original_field = self.fields["tn_parent"]
|
88
|
+
self.fields["tn_parent"] = SortedModelChoiceField(
|
89
|
+
queryset=queryset,
|
90
|
+
label=self.fields["tn_parent"].label,
|
91
|
+
widget=original_field.widget
|
61
92
|
)
|
62
93
|
|
63
|
-
# Set QuerySet
|
64
|
-
self.fields["tn_parent"].queryset = queryset
|
65
|
-
|
66
94
|
@classmethod
|
67
95
|
def factory(cls, model):
|
68
96
|
"""
|
@@ -11,7 +11,7 @@ Features:
|
|
11
11
|
- Implements cached queries for improved performance.
|
12
12
|
- Provides bulk operations for inserting, moving, and deleting nodes.
|
13
13
|
|
14
|
-
Version: 2.0.
|
14
|
+
Version: 2.0.1
|
15
15
|
Author: Timur Kady
|
16
16
|
Email: timurkady@yandex.com
|
17
17
|
"""
|
@@ -53,6 +53,7 @@ class ClosureModel(models.Model):
|
|
53
53
|
unique_together = (("parent", "child"),)
|
54
54
|
indexes = [
|
55
55
|
models.Index(fields=["parent", "child"]),
|
56
|
+
models.Index(fields=["parent", "child", "depth"]),
|
56
57
|
]
|
57
58
|
|
58
59
|
def __str__(self):
|
@@ -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
|
-
|
@@ -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.10
|
16
16
|
Author: Timur Kady
|
17
17
|
Email: timurkady@yandex.com
|
18
18
|
"""
|
@@ -21,7 +21,8 @@ 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
|
25
26
|
from io import BytesIO
|
26
27
|
from django.http import HttpResponse
|
27
28
|
import logging
|
@@ -67,10 +68,16 @@ class TreeNodeExporter:
|
|
67
68
|
record[key] = None
|
68
69
|
return record
|
69
70
|
|
71
|
+
def get_sorted_queryset(self):
|
72
|
+
"""Sort queryset by tn_order."""
|
73
|
+
queryset_list = list(self.queryset)
|
74
|
+
tn_orders = np.array([obj.tn_order for obj in queryset_list])
|
75
|
+
return [queryset_list[int(i)] for i in np.argsort(tn_orders)]
|
76
|
+
|
70
77
|
def get_data(self):
|
71
78
|
"""Return a list of data from QuerySet as dictionaries."""
|
72
79
|
data = []
|
73
|
-
for obj in self.
|
80
|
+
for obj in self.get_sorted_queryset():
|
74
81
|
record = {}
|
75
82
|
for field in self.fields:
|
76
83
|
value = getattr(obj, field, None)
|
@@ -83,7 +90,7 @@ class TreeNodeExporter:
|
|
83
90
|
ensure_ascii=False)
|
84
91
|
elif field_object.many_to_one:
|
85
92
|
# ForeignKey - save as ID
|
86
|
-
record[field] = value
|
93
|
+
record[field] = getattr(value, "id", None)
|
87
94
|
else:
|
88
95
|
record[field] = value
|
89
96
|
else:
|
@@ -105,22 +112,40 @@ class TreeNodeExporter:
|
|
105
112
|
"""Export to JSON with UUID serialization handling."""
|
106
113
|
response = HttpResponse(content_type="application/octet-stream")
|
107
114
|
response["Content-Disposition"] = f'attachment; filename="{self.filename}.json"'
|
108
|
-
json.dump(
|
109
|
-
|
115
|
+
json.dump(
|
116
|
+
self.get_data(),
|
117
|
+
response,
|
118
|
+
ensure_ascii=False,
|
119
|
+
indent=4,
|
120
|
+
default=str
|
121
|
+
)
|
110
122
|
return response
|
111
123
|
|
112
124
|
def to_xlsx(self):
|
113
125
|
"""Export to XLSX."""
|
114
126
|
response = HttpResponse(
|
115
|
-
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
127
|
+
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
128
|
+
)
|
116
129
|
response["Content-Disposition"] = f'attachment; filename="{self.filename}.xlsx"'
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
130
|
+
|
131
|
+
data = self.get_data()
|
132
|
+
output = BytesIO()
|
133
|
+
workbook = xlsxwriter.Workbook(output)
|
134
|
+
worksheet = workbook.add_worksheet()
|
135
|
+
|
136
|
+
# Записываем заголовки
|
137
|
+
headers = list(data[0].keys()) if data else []
|
138
|
+
for col_num, header in enumerate(headers):
|
139
|
+
worksheet.write(0, col_num, header)
|
140
|
+
|
141
|
+
# Записываем строки данных
|
142
|
+
for row_num, row in enumerate(data, start=1):
|
143
|
+
for col_num, key in enumerate(headers):
|
144
|
+
worksheet.write(row_num, col_num, row[key])
|
145
|
+
|
146
|
+
workbook.close()
|
147
|
+
output.seek(0)
|
148
|
+
return response.write(output.read())
|
124
149
|
|
125
150
|
def to_yaml(self):
|
126
151
|
"""Export to YAML with proper attachment handling."""
|
@@ -135,7 +160,10 @@ class TreeNodeExporter:
|
|
135
160
|
response = HttpResponse(content_type="application/octet-stream")
|
136
161
|
response["Content-Disposition"] = f'attachment; filename="{self.filename}.tsv"'
|
137
162
|
writer = csv.DictWriter(
|
138
|
-
response,
|
163
|
+
response,
|
164
|
+
fieldnames=self.fields,
|
165
|
+
delimiter=" "
|
166
|
+
)
|
139
167
|
writer.writeheader()
|
140
168
|
writer.writerows(self.get_data())
|
141
169
|
return response
|
@@ -21,8 +21,8 @@ Email: timurkady@yandex.com
|
|
21
21
|
import csv
|
22
22
|
import json
|
23
23
|
import yaml
|
24
|
+
import openpyxl
|
24
25
|
import math
|
25
|
-
import pandas as pd
|
26
26
|
from io import BytesIO, StringIO
|
27
27
|
from django.db import transaction
|
28
28
|
import logging
|
@@ -55,13 +55,13 @@ class TreeNodeImporter:
|
|
55
55
|
self.file_content = file.read()
|
56
56
|
|
57
57
|
def get_text_content(self):
|
58
|
-
"""
|
58
|
+
"""Return the contents of a file as a string."""
|
59
59
|
if isinstance(self.file_content, bytes):
|
60
60
|
return self.file_content.decode("utf-8")
|
61
61
|
return self.file_content
|
62
62
|
|
63
63
|
def import_data(self):
|
64
|
-
"""
|
64
|
+
"""Import data and returns a list of dictionaries."""
|
65
65
|
importers = {
|
66
66
|
"csv": self.from_csv,
|
67
67
|
"json": self.from_json,
|
@@ -73,37 +73,46 @@ class TreeNodeImporter:
|
|
73
73
|
raise ValueError("Unsupported import format")
|
74
74
|
|
75
75
|
raw_data = importers[self.format]()
|
76
|
-
#
|
76
|
+
# Processing: field filtering, complex value packing and type casting
|
77
77
|
processed_data = self.process_records(raw_data)
|
78
78
|
return processed_data
|
79
79
|
|
80
80
|
def from_csv(self):
|
81
|
-
"""
|
81
|
+
"""Import from CSV."""
|
82
82
|
text = self.get_text_content()
|
83
83
|
return list(csv.DictReader(StringIO(text)))
|
84
84
|
|
85
85
|
def from_json(self):
|
86
|
-
"""
|
86
|
+
"""Import from JSON."""
|
87
87
|
return json.loads(self.get_text_content())
|
88
88
|
|
89
89
|
def from_xlsx(self):
|
90
|
-
"""
|
91
|
-
|
92
|
-
|
90
|
+
"""Import from XLSX (Excel)."""
|
91
|
+
file_stream = BytesIO(self.file_content)
|
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
101
|
|
94
102
|
def from_yaml(self):
|
95
|
-
"""
|
103
|
+
"""Import from YAML."""
|
96
104
|
return yaml.safe_load(self.get_text_content())
|
97
105
|
|
98
106
|
def from_tsv(self):
|
99
|
-
"""
|
107
|
+
"""Import from TSV."""
|
100
108
|
text = self.get_text_content()
|
101
109
|
return list(csv.DictReader(StringIO(text), delimiter="\t"))
|
102
110
|
|
103
111
|
def filter_fields(self, record):
|
104
112
|
"""
|
105
|
-
|
106
|
-
|
113
|
+
Filter the record according to the mapping.
|
114
|
+
|
115
|
+
Only the necessary keys remain, while the names are renamed.
|
107
116
|
"""
|
108
117
|
new_record = {}
|
109
118
|
for file_key, model_field in self.mapping.items():
|
@@ -112,7 +121,9 @@ class TreeNodeImporter:
|
|
112
121
|
|
113
122
|
def process_complex_fields(self, record):
|
114
123
|
"""
|
115
|
-
|
124
|
+
Pack it into a JSON string.
|
125
|
+
|
126
|
+
If the field value is a dictionary or list.
|
116
127
|
"""
|
117
128
|
for key, value in record.items():
|
118
129
|
if isinstance(value, (list, dict)):
|
@@ -125,13 +136,12 @@ class TreeNodeImporter:
|
|
125
136
|
|
126
137
|
def cast_record_types(self, record):
|
127
138
|
"""
|
128
|
-
|
139
|
+
Casts the values of the record fields to the types defined in the model.
|
129
140
|
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
а исходный ключ удаляется.
|
141
|
+
For each field, its to_python() method is called. If the value is nan,
|
142
|
+
it is replaced with None.
|
143
|
+
For ForeignKey fields (many-to-one), the value is written to
|
144
|
+
the <field>_id attribute, and the original key is removed.
|
135
145
|
"""
|
136
146
|
for field in self.model._meta.fields:
|
137
147
|
field_name = field.name
|
@@ -166,10 +176,11 @@ class TreeNodeImporter:
|
|
166
176
|
|
167
177
|
def process_records(self, records):
|
168
178
|
"""
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
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.
|
173
184
|
"""
|
174
185
|
processed = []
|
175
186
|
for record in records:
|
@@ -181,20 +192,24 @@ class TreeNodeImporter:
|
|
181
192
|
|
182
193
|
def clean(self, raw_data):
|
183
194
|
"""
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
195
|
+
Validat and prepare data for bulk saving of objects.
|
196
|
+
|
197
|
+
For each record:
|
198
|
+
- The presence of a unique field 'id' is checked.
|
199
|
+
- The value of the parent relationship (tn_parent or tn_parent_id)
|
200
|
+
is saved separately and removed from the data.
|
201
|
+
- Casts the data to model types.
|
202
|
+
- Attempts to create a model instance with validation via full_clean().
|
203
|
+
|
204
|
+
Returns a dictionary with the following keys:
|
205
|
+
'create' - a list of objects to create,
|
206
|
+
'update' - a list of objects to update (in this case, we leave
|
207
|
+
it empty),
|
208
|
+
'update_fields' - a list of fields to update (for example,
|
209
|
+
['tn_parent']),
|
210
|
+
'fk_mappings' - a dictionary of {object_id: parent key value from
|
211
|
+
the source data},
|
212
|
+
'errors' - a list of validation errors.
|
198
213
|
"""
|
199
214
|
result = {
|
200
215
|
"create": [],
|
@@ -211,7 +226,7 @@ class TreeNodeImporter:
|
|
211
226
|
logger.warning(error_message)
|
212
227
|
continue
|
213
228
|
|
214
|
-
#
|
229
|
+
# Save the parent relationship value and remove it from the data
|
215
230
|
fk_value = None
|
216
231
|
if 'tn_parent' in data:
|
217
232
|
fk_value = data['tn_parent']
|
@@ -220,14 +235,14 @@ class TreeNodeImporter:
|
|
220
235
|
fk_value = data['tn_parent_id']
|
221
236
|
del data['tn_parent_id']
|
222
237
|
|
223
|
-
#
|
238
|
+
# Convert values to model types
|
224
239
|
data = self.cast_record_types(data)
|
225
240
|
|
226
241
|
try:
|
227
242
|
instance = self.model(**data)
|
228
243
|
instance.full_clean()
|
229
244
|
result["create"].append(instance)
|
230
|
-
#
|
245
|
+
# Save the parent key value for future update
|
231
246
|
result["fk_mappings"][instance.id] = fk_value
|
232
247
|
except Exception as e:
|
233
248
|
error_message = f"Validation error creating {data}: {e}"
|
@@ -235,16 +250,17 @@ class TreeNodeImporter:
|
|
235
250
|
logger.warning(error_message)
|
236
251
|
continue
|
237
252
|
|
238
|
-
#
|
253
|
+
# In this scenario, the update occurs only for the parent relationship
|
239
254
|
result["update_fields"] = ['tn_parent']
|
240
255
|
return result
|
241
256
|
|
242
257
|
def save_data(self, create, update, fields):
|
243
258
|
"""
|
244
|
-
|
245
|
-
|
246
|
-
:param
|
247
|
-
:param
|
259
|
+
Save objects to the database as part of an atomic transaction.
|
260
|
+
|
261
|
+
:param create: list of objects to create.
|
262
|
+
:param update: list of objects to update.
|
263
|
+
:param fields: list of fields to update (for bulk_update).
|
248
264
|
"""
|
249
265
|
with transaction.atomic():
|
250
266
|
if update:
|
@@ -254,12 +270,14 @@ class TreeNodeImporter:
|
|
254
270
|
|
255
271
|
def update_parent_relations(self, fk_mappings):
|
256
272
|
"""
|
257
|
-
|
258
|
-
|
273
|
+
Update the tn_parent field for objects using the saved fk_mappings.
|
274
|
+
|
275
|
+
:param fk_mappings: dictionary {object_id: parent key value from
|
276
|
+
the source data}
|
259
277
|
"""
|
260
278
|
instances_to_update = []
|
261
279
|
for obj_id, parent_id in fk_mappings.items():
|
262
|
-
#
|
280
|
+
# If parent is not specified, skip
|
263
281
|
if not parent_id:
|
264
282
|
continue
|
265
283
|
try:
|
@@ -269,28 +287,35 @@ class TreeNodeImporter:
|
|
269
287
|
instances_to_update.append(instance)
|
270
288
|
except self.model.DoesNotExist:
|
271
289
|
logger.warning(
|
272
|
-
"Parent with id %s not found for instance %s",
|
290
|
+
"Parent with id %s not found for instance %s",
|
291
|
+
parent_id,
|
292
|
+
obj_id
|
293
|
+
)
|
273
294
|
if instances_to_update:
|
274
295
|
update_fields = ['tn_parent']
|
275
296
|
self.model.objects.bulk_update(
|
276
297
|
instances_to_update, update_fields, batch_size=1000)
|
277
298
|
|
278
|
-
|
279
|
-
|
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
|
+
|
280
303
|
def finalize_import(self, clean_result):
|
281
304
|
"""
|
282
|
-
|
283
|
-
|
305
|
+
Finalize the import: saves new objects and updates parent links.
|
306
|
+
|
307
|
+
:param clean_result: dictionary returned by the clean method.
|
284
308
|
"""
|
285
|
-
#
|
309
|
+
# If there are errors, you can interrupt the import or return them
|
310
|
+
# for processing
|
286
311
|
if clean_result["errors"]:
|
287
312
|
return clean_result["errors"]
|
288
313
|
|
289
|
-
#
|
314
|
+
# First we do a bulk creation
|
290
315
|
self.save_data(
|
291
316
|
clean_result["create"], clean_result["update"], clean_result["update_fields"])
|
292
|
-
#
|
317
|
+
# Then we update the parent links
|
293
318
|
self.update_parent_relations(clean_result["fk_mappings"])
|
294
|
-
return None #
|
319
|
+
return None # Or return a success message
|
295
320
|
|
296
321
|
# The End
|
File without changes
|
File without changes
|
File without changes
|
{django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/django_fast_treenode.egg-info/SOURCES.txt
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/static/treenode/.gitkeep
RENAMED
File without changes
|
{django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/static/treenode/css/.gitkeep
RENAMED
File without changes
|
File without changes
|
File without changes
|
{django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/static/treenode/js/.gitkeep
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{django_fast_treenode-2.0.9 → django_fast_treenode-2.0.10}/treenode/templates/admin/.gitkeep
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|