django-fast-treenode 2.1.4__py3-none-any.whl → 3.0.1__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-3.0.1.dist-info/METADATA +203 -0
- django_fast_treenode-3.0.1.dist-info/RECORD +90 -0
- {django_fast_treenode-2.1.4.dist-info → django_fast_treenode-3.0.1.dist-info}/WHEEL +1 -1
- treenode/admin/__init__.py +2 -7
- treenode/admin/admin.py +138 -209
- treenode/admin/changelist.py +21 -39
- treenode/admin/exporter.py +170 -0
- treenode/admin/importer.py +171 -0
- treenode/admin/mixin.py +291 -0
- treenode/apps.py +41 -19
- treenode/cache.py +192 -303
- treenode/forms.py +45 -65
- treenode/managers/__init__.py +4 -20
- treenode/managers/managers.py +216 -0
- treenode/managers/queries.py +233 -0
- treenode/managers/tasks.py +167 -0
- treenode/models/__init__.py +8 -5
- treenode/models/decorators.py +54 -0
- treenode/models/factory.py +44 -68
- treenode/models/mixins/__init__.py +2 -1
- treenode/models/mixins/ancestors.py +44 -20
- treenode/models/mixins/children.py +33 -26
- treenode/models/mixins/descendants.py +33 -22
- treenode/models/mixins/family.py +25 -15
- treenode/models/mixins/logical.py +23 -21
- treenode/models/mixins/node.py +162 -104
- treenode/models/mixins/properties.py +22 -16
- treenode/models/mixins/roots.py +59 -15
- treenode/models/mixins/siblings.py +46 -43
- treenode/models/mixins/tree.py +212 -153
- treenode/models/mixins/update.py +154 -0
- treenode/models/models.py +365 -0
- treenode/settings.py +28 -0
- treenode/static/{treenode/css → css}/tree_widget.css +1 -1
- treenode/static/{treenode/css → css}/treenode_admin.css +43 -2
- treenode/static/css/treenode_tabs.css +51 -0
- treenode/static/js/lz-string.min.js +1 -0
- treenode/static/{treenode/js → js}/tree_widget.js +9 -23
- treenode/static/js/treenode_admin.js +531 -0
- treenode/static/vendors/jquery-ui/AUTHORS.txt +384 -0
- treenode/static/vendors/jquery-ui/LICENSE.txt +43 -0
- treenode/static/vendors/jquery-ui/external/jquery/jquery.js +10716 -0
- treenode/static/vendors/jquery-ui/images/ui-icons_444444_256x240.png +0 -0
- treenode/static/vendors/jquery-ui/images/ui-icons_555555_256x240.png +0 -0
- treenode/static/vendors/jquery-ui/images/ui-icons_777620_256x240.png +0 -0
- treenode/static/vendors/jquery-ui/images/ui-icons_777777_256x240.png +0 -0
- treenode/static/vendors/jquery-ui/images/ui-icons_cc0000_256x240.png +0 -0
- treenode/static/vendors/jquery-ui/images/ui-icons_ffffff_256x240.png +0 -0
- treenode/static/vendors/jquery-ui/index.html +297 -0
- treenode/static/vendors/jquery-ui/jquery-ui.css +438 -0
- treenode/static/vendors/jquery-ui/jquery-ui.js +5223 -0
- treenode/static/vendors/jquery-ui/jquery-ui.min.css +7 -0
- treenode/static/vendors/jquery-ui/jquery-ui.min.js +6 -0
- treenode/static/vendors/jquery-ui/jquery-ui.structure.css +16 -0
- treenode/static/vendors/jquery-ui/jquery-ui.structure.min.css +5 -0
- treenode/static/vendors/jquery-ui/jquery-ui.theme.css +439 -0
- treenode/static/vendors/jquery-ui/jquery-ui.theme.min.css +5 -0
- treenode/static/vendors/jquery-ui/package.json +82 -0
- treenode/templates/admin/treenode_changelist.html +25 -0
- treenode/templates/admin/treenode_import_export.html +85 -0
- treenode/templates/admin/treenode_rows.html +57 -0
- treenode/tests.py +3 -0
- treenode/urls.py +6 -27
- treenode/utils/__init__.py +0 -15
- treenode/utils/db/__init__.py +7 -0
- treenode/utils/db/compiler.py +114 -0
- treenode/utils/db/db_vendor.py +50 -0
- treenode/utils/db/service.py +84 -0
- treenode/utils/db/sqlcompat.py +60 -0
- treenode/utils/db/sqlquery.py +70 -0
- treenode/version.py +2 -2
- treenode/views/__init__.py +5 -0
- treenode/views/autoapi.py +91 -0
- treenode/views/autocomplete.py +52 -0
- treenode/views/children.py +41 -0
- treenode/views/common.py +23 -0
- treenode/views/crud.py +209 -0
- treenode/views/search.py +48 -0
- treenode/widgets.py +27 -44
- django_fast_treenode-2.1.4.dist-info/METADATA +0 -166
- django_fast_treenode-2.1.4.dist-info/RECORD +0 -63
- treenode/admin/mixins.py +0 -302
- treenode/managers/adjacency.py +0 -205
- treenode/managers/closure.py +0 -278
- treenode/models/adjacency.py +0 -342
- treenode/models/classproperty.py +0 -27
- treenode/models/closure.py +0 -122
- treenode/static/treenode/js/.gitkeep +0 -1
- treenode/static/treenode/js/treenode_admin.js +0 -131
- treenode/templates/admin/export_success.html +0 -26
- treenode/templates/admin/tree_node_changelist.html +0 -19
- treenode/templates/admin/tree_node_export.html +0 -27
- treenode/templates/admin/tree_node_import.html +0 -45
- treenode/templates/admin/tree_node_import_report.html +0 -32
- treenode/templates/widgets/tree_widget.css +0 -23
- treenode/utils/aid.py +0 -46
- treenode/utils/base16.py +0 -38
- treenode/utils/base36.py +0 -37
- treenode/utils/db.py +0 -116
- treenode/utils/exporter.py +0 -196
- treenode/utils/importer.py +0 -328
- treenode/utils/radix.py +0 -61
- treenode/views.py +0 -184
- {django_fast_treenode-2.1.4.dist-info → django_fast_treenode-3.0.1.dist-info/licenses}/LICENSE +0 -0
- {django_fast_treenode-2.1.4.dist-info → django_fast_treenode-3.0.1.dist-info}/top_level.txt +0 -0
- /treenode/static/{treenode → css}/.gitkeep +0 -0
- /treenode/static/{treenode/css → js}/.gitkeep +0 -0
@@ -0,0 +1,170 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
"""
|
3
|
+
TreeNode Exporter Module
|
4
|
+
|
5
|
+
This module provides functionality for stream exporting tree-structured data
|
6
|
+
to various formats, including CSV, JSON, TSV, XLSX, YAML.
|
7
|
+
|
8
|
+
Version: 3.0.0
|
9
|
+
Author: Timur Kady
|
10
|
+
Email: timurkady@yandex.com
|
11
|
+
"""
|
12
|
+
|
13
|
+
import csv
|
14
|
+
import json
|
15
|
+
import yaml
|
16
|
+
from django.core.serializers.json import DjangoJSONEncoder
|
17
|
+
from django.http import StreamingHttpResponse
|
18
|
+
from io import BytesIO, StringIO
|
19
|
+
from openpyxl import Workbook
|
20
|
+
|
21
|
+
|
22
|
+
class TreeNodeExporter:
|
23
|
+
"""Exporter for tree-structured data to various formats."""
|
24
|
+
|
25
|
+
def __init__(self, model, filename="tree_nodes", fileformat="csv"):
|
26
|
+
"""
|
27
|
+
Initialize exporter.
|
28
|
+
|
29
|
+
:param queryset: Django QuerySet to export.
|
30
|
+
:param filename: Base name for the output file.
|
31
|
+
:param fileformat: Export format (csv, json, xlsx, yaml, tvs).
|
32
|
+
"""
|
33
|
+
self.filename = filename
|
34
|
+
self.format = fileformat
|
35
|
+
self.model = model
|
36
|
+
self.queryset = model.objects.get_queryset()
|
37
|
+
self.fields = self.get_ordered_fields()
|
38
|
+
|
39
|
+
def get_ordered_fields(self):
|
40
|
+
"""
|
41
|
+
Define and return the ordered list of fields for export.
|
42
|
+
|
43
|
+
Required fields come first, blocked fields are omitted.
|
44
|
+
"""
|
45
|
+
fields = sorted([field.name for field in self.model._meta.fields])
|
46
|
+
required_fields = ["id", "parent", "priority"]
|
47
|
+
blocked_fields = ["_path", "_depth"]
|
48
|
+
|
49
|
+
other_fields = [
|
50
|
+
field for field in fields
|
51
|
+
if field not in required_fields and field not in blocked_fields
|
52
|
+
]
|
53
|
+
return required_fields + other_fields
|
54
|
+
|
55
|
+
def get_obj(self):
|
56
|
+
"""Yield rows from queryset as row data dict."""
|
57
|
+
queryset = self.queryset.order_by('_path').only(*self.fields)
|
58
|
+
for obj in queryset.iterator():
|
59
|
+
yield obj
|
60
|
+
|
61
|
+
def get_serializable_row(self, obj):
|
62
|
+
"""Get serialized object."""
|
63
|
+
fields = self.fields
|
64
|
+
raw_data = {}
|
65
|
+
for field in fields:
|
66
|
+
if field == "parent":
|
67
|
+
raw_data["parent"] = getattr(obj, "parent_id", None)
|
68
|
+
else:
|
69
|
+
raw_data[field] = getattr(obj, field, None)
|
70
|
+
serialized = json.loads(json.dumps(raw_data, cls=DjangoJSONEncoder))
|
71
|
+
return serialized
|
72
|
+
|
73
|
+
def csv_stream_data(self, delimiter=","):
|
74
|
+
"""Stream CSV or TSV data."""
|
75
|
+
yield "\ufeff" # BOM for Excel
|
76
|
+
buffer = StringIO()
|
77
|
+
writer = csv.DictWriter(
|
78
|
+
buffer,
|
79
|
+
fieldnames=self.fields,
|
80
|
+
delimiter=delimiter
|
81
|
+
)
|
82
|
+
writer.writeheader()
|
83
|
+
yield buffer.getvalue()
|
84
|
+
buffer.seek(0)
|
85
|
+
buffer.truncate(0)
|
86
|
+
|
87
|
+
for obj in self.get_obj():
|
88
|
+
row = self.get_serializable_row(obj)
|
89
|
+
writer.writerow(row)
|
90
|
+
yield buffer.getvalue()
|
91
|
+
buffer.seek(0)
|
92
|
+
buffer.truncate(0)
|
93
|
+
|
94
|
+
def json_stream_data(self):
|
95
|
+
"""Stream JSON data."""
|
96
|
+
yield "[\n"
|
97
|
+
first = True
|
98
|
+
for obj in self.get_obj():
|
99
|
+
row = self.get_serializable_row(obj)
|
100
|
+
if not first:
|
101
|
+
yield ",\n"
|
102
|
+
else:
|
103
|
+
first = False
|
104
|
+
yield json.dumps(row, ensure_ascii=False)
|
105
|
+
yield "\n]"
|
106
|
+
|
107
|
+
def tsv_stream_data(self, chunk_size=1000):
|
108
|
+
"""Stream TSV (tab-separated values) data."""
|
109
|
+
yield from self.csv_stream_data(delimiter="\t")
|
110
|
+
|
111
|
+
def yaml_stream_data(self):
|
112
|
+
"""Stream YAML data."""
|
113
|
+
yield "---\n"
|
114
|
+
for obj in self.get_obj():
|
115
|
+
row = self.get_serializable_row(obj)
|
116
|
+
yield yaml.safe_dump([row], allow_unicode=True)
|
117
|
+
|
118
|
+
def xlsx_stream_data(self):
|
119
|
+
"""Stream XLSX data."""
|
120
|
+
wb = Workbook()
|
121
|
+
ws = wb.active
|
122
|
+
ws.append(self.fields)
|
123
|
+
|
124
|
+
for obj in self.get_obj():
|
125
|
+
row = self.get_serializable_row(obj)
|
126
|
+
ws.append([row.get(f, "") for f in self.fields])
|
127
|
+
|
128
|
+
output = BytesIO()
|
129
|
+
wb.save(output)
|
130
|
+
output.seek(0)
|
131
|
+
yield output.getvalue()
|
132
|
+
|
133
|
+
def process_record(self):
|
134
|
+
"""
|
135
|
+
Create a StreamingHttpResponse based on selected format.
|
136
|
+
|
137
|
+
:param chunk_size: Batch size for iteration.
|
138
|
+
:return: StreamingHttpResponse object.
|
139
|
+
"""
|
140
|
+
if self.format == 'xlsx':
|
141
|
+
response = StreamingHttpResponse(
|
142
|
+
streaming_content=self.xlsx_stream_data(),
|
143
|
+
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; charset=utf-8" # noqa: D501
|
144
|
+
)
|
145
|
+
elif self.format == 'tsv':
|
146
|
+
response = StreamingHttpResponse(
|
147
|
+
streaming_content=self.csv_stream_data(delimiter="\t"),
|
148
|
+
content_type="text/tab-separated-values; charset=utf-8"
|
149
|
+
)
|
150
|
+
elif self.format == 'csv':
|
151
|
+
response = StreamingHttpResponse(
|
152
|
+
streaming_content=self.csv_stream_data(delimiter=","),
|
153
|
+
content_type="text/csv; charset=utf-8"
|
154
|
+
)
|
155
|
+
elif self.format == 'yaml':
|
156
|
+
response = StreamingHttpResponse(
|
157
|
+
streaming_content=self.yaml_stream_data(),
|
158
|
+
content_type=f"application/{self.format}; charset=utf-8"
|
159
|
+
)
|
160
|
+
else:
|
161
|
+
response = StreamingHttpResponse(
|
162
|
+
streaming_content=self.json_stream_data(),
|
163
|
+
content_type=f"application/{self.format}; charset=utf-8"
|
164
|
+
)
|
165
|
+
|
166
|
+
response['Content-Disposition'] = f'attachment; filename="{self.filename}"' # noqa: D501
|
167
|
+
return response
|
168
|
+
|
169
|
+
|
170
|
+
# The End
|
@@ -0,0 +1,171 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
"""
|
3
|
+
TreeNode Importer Module
|
4
|
+
|
5
|
+
This module provides functionality for importing tree-structured data
|
6
|
+
from various formats, including CSV, JSON, XLSX, YAML, and TSV.
|
7
|
+
|
8
|
+
Features:
|
9
|
+
- Supports field mapping and data type conversion for model compatibility.
|
10
|
+
- Handles ForeignKey relationships and ManyToMany fields.
|
11
|
+
- Validates and processes raw data before saving to the database.
|
12
|
+
- Uses bulk operations for efficient data insertion and updates.
|
13
|
+
- Supports transactional imports to maintain data integrity.
|
14
|
+
|
15
|
+
Version: 3.0.0
|
16
|
+
Author: Timur Kady
|
17
|
+
Email: timurkady@yandex.com
|
18
|
+
"""
|
19
|
+
|
20
|
+
# Новый модуль импорта для древовидных структур в Django
|
21
|
+
|
22
|
+
import csv
|
23
|
+
import json
|
24
|
+
import yaml
|
25
|
+
import openpyxl
|
26
|
+
from io import BytesIO, StringIO
|
27
|
+
from django.db import transaction
|
28
|
+
from django.core.exceptions import ValidationError, ObjectDoesNotExist
|
29
|
+
|
30
|
+
from ..cache import treenode_cache as cache
|
31
|
+
|
32
|
+
|
33
|
+
class TreeNodeImporter:
|
34
|
+
"""Importer of tree data from various formats."""
|
35
|
+
|
36
|
+
def __init__(self, model, file, file_format):
|
37
|
+
"""Init."""
|
38
|
+
self.model = model
|
39
|
+
self.file = file
|
40
|
+
self.format = file_format.lower()
|
41
|
+
self.rows = []
|
42
|
+
self.rows_by_id = {}
|
43
|
+
self.result = {"created": 0, "updated": 0, "errors": []}
|
44
|
+
|
45
|
+
def parse(self):
|
46
|
+
"""Parse a file."""
|
47
|
+
if self.format == "xlsx":
|
48
|
+
content = self.file.read() # binary
|
49
|
+
self.rows = self._parse_xlsx(content)
|
50
|
+
else:
|
51
|
+
text = self.file.read()
|
52
|
+
if isinstance(text, bytes):
|
53
|
+
text = text.decode("utf-8")
|
54
|
+
|
55
|
+
if self.format == "csv":
|
56
|
+
self.rows = list(csv.DictReader(StringIO(text)))
|
57
|
+
elif self.format == "tsv":
|
58
|
+
self.rows = list(csv.DictReader(StringIO(text), delimiter="\t"))
|
59
|
+
elif self.format == "json":
|
60
|
+
self.rows = json.loads(text)
|
61
|
+
elif self.format == "yaml":
|
62
|
+
self.rows = yaml.safe_load(text)
|
63
|
+
else:
|
64
|
+
raise ValueError("Unsupported file format")
|
65
|
+
|
66
|
+
self._build_hierarchy()
|
67
|
+
|
68
|
+
def _parse_xlsx(self, content):
|
69
|
+
"""Parse the xlsx format."""
|
70
|
+
wb = openpyxl.load_workbook(BytesIO(content), read_only=True)
|
71
|
+
ws = wb.active
|
72
|
+
headers = [cell.value for cell in next(
|
73
|
+
ws.iter_rows(min_row=1, max_row=1))]
|
74
|
+
return [
|
75
|
+
dict(zip(headers, row))
|
76
|
+
for row in ws.iter_rows(min_row=2, values_only=True)
|
77
|
+
]
|
78
|
+
|
79
|
+
def _build_hierarchy(self):
|
80
|
+
"""
|
81
|
+
Build and check hierarchy.
|
82
|
+
|
83
|
+
Calculates _depth and _path based on parent and priority. Checks that
|
84
|
+
each parent exists in either the imported set or the base. The _path
|
85
|
+
is built based on priority, as in the main package.
|
86
|
+
"""
|
87
|
+
self.rows_by_id = {str(row.get("id")): row for row in self.rows}
|
88
|
+
|
89
|
+
def build_path_and_depth(row, visited=None):
|
90
|
+
if visited is None:
|
91
|
+
visited = set()
|
92
|
+
row_id = str(row.get("id"))
|
93
|
+
if row_id in visited:
|
94
|
+
raise ValueError(f"Cycle detected at row {row_id}")
|
95
|
+
visited.add(row_id)
|
96
|
+
|
97
|
+
parent_id = str(row.get("parent")) if row.get("parent") else None
|
98
|
+
if not parent_id:
|
99
|
+
row["_depth"] = 0
|
100
|
+
row["_path"] = str(row.get("priority", "0")).zfill(4)
|
101
|
+
return row["_path"]
|
102
|
+
|
103
|
+
parent_row = self.rows_by_id.get(parent_id)
|
104
|
+
if parent_row:
|
105
|
+
parent_path = build_path_and_depth(parent_row, visited)
|
106
|
+
else:
|
107
|
+
try:
|
108
|
+
self.model.objects.get(pk=parent_id)
|
109
|
+
parent_path = "fromdb"
|
110
|
+
except ObjectDoesNotExist:
|
111
|
+
self.result["errors"].append(
|
112
|
+
f"Parent {parent_id} for node {row_id} not found.")
|
113
|
+
parent_path = "invalid"
|
114
|
+
|
115
|
+
row["_depth"] = parent_path.count(
|
116
|
+
".") + 1 if parent_path != "invalid" else 0
|
117
|
+
priority = str(row.get("priority", "0")).zfill(4)
|
118
|
+
row["_path"] = parent_path + "." + \
|
119
|
+
priority if parent_path != "invalid" else priority
|
120
|
+
return row["_path"]
|
121
|
+
|
122
|
+
for row in self.rows:
|
123
|
+
try:
|
124
|
+
build_path_and_depth(row)
|
125
|
+
except Exception as e:
|
126
|
+
self.result["errors"].append(str(e))
|
127
|
+
|
128
|
+
def import_tree(self):
|
129
|
+
"""Import tree nodes level by level."""
|
130
|
+
with transaction.atomic():
|
131
|
+
rows_by_level = {}
|
132
|
+
for row in self.rows:
|
133
|
+
level = row.get("_depth", 0)
|
134
|
+
rows_by_level.setdefault(level, []).append(row)
|
135
|
+
|
136
|
+
id_map = {}
|
137
|
+
for depth in sorted(rows_by_level.keys()):
|
138
|
+
to_create = []
|
139
|
+
for row in rows_by_level[depth]:
|
140
|
+
pk = row.get("id")
|
141
|
+
|
142
|
+
if "parent" in row and (row["parent"] == "" or row["parent"] is None):
|
143
|
+
row["parent"] = None
|
144
|
+
|
145
|
+
if "parent" in row:
|
146
|
+
temp_parent_id = row.pop("parent")
|
147
|
+
if temp_parent_id is not None:
|
148
|
+
# Используем уже созданный ID родителя
|
149
|
+
row["parent_id"] = id_map.get(
|
150
|
+
temp_parent_id, temp_parent_id)
|
151
|
+
|
152
|
+
try:
|
153
|
+
obj = self.model(**row)
|
154
|
+
obj.full_clean()
|
155
|
+
to_create.append(obj)
|
156
|
+
except ValidationError as e:
|
157
|
+
self.result["errors"].append(
|
158
|
+
f"Validation error for {pk}: {e}")
|
159
|
+
|
160
|
+
created = self.model.objects.bulk_create(to_create)
|
161
|
+
for obj in created:
|
162
|
+
id_map[obj.pk] = obj.pk
|
163
|
+
self.result["created"] += len(created)
|
164
|
+
|
165
|
+
self.model.tasks.add("update", None)
|
166
|
+
cache.invalidate(self.model._meta.label)
|
167
|
+
|
168
|
+
return self.result
|
169
|
+
|
170
|
+
|
171
|
+
# The End
|
treenode/admin/mixin.py
ADDED
@@ -0,0 +1,291 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
"""
|
3
|
+
TreeNode Admin Model Class Mixin
|
4
|
+
|
5
|
+
Views for AdminModel
|
6
|
+
|
7
|
+
Version: 3.0.0
|
8
|
+
Author: Timur Kady
|
9
|
+
Email: timurkady@yandex.com
|
10
|
+
"""
|
11
|
+
|
12
|
+
import os
|
13
|
+
import json
|
14
|
+
from datetime import datetime
|
15
|
+
from django.contrib import admin
|
16
|
+
from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
|
17
|
+
from django.contrib.admin.utils import lookup_field
|
18
|
+
from django.db.models import Q
|
19
|
+
from django.http import HttpResponseBadRequest
|
20
|
+
from django.http import JsonResponse
|
21
|
+
from django.shortcuts import render
|
22
|
+
from django.template.loader import render_to_string
|
23
|
+
from django.urls import path
|
24
|
+
from django.utils.decorators import method_decorator
|
25
|
+
from django.utils.safestring import mark_safe
|
26
|
+
from django.utils.translation import gettext_lazy as _
|
27
|
+
from django.views.decorators.cache import never_cache
|
28
|
+
|
29
|
+
|
30
|
+
class AdminMixin(admin.ModelAdmin):
|
31
|
+
"""Admin Mixin for lazy loading and search in tree."""
|
32
|
+
|
33
|
+
def get_search_help_text(self, request):
|
34
|
+
"""Get search help text."""
|
35
|
+
return getattr(super(), 'get_search_help_text', lambda r: '')(request)
|
36
|
+
|
37
|
+
def get_urls(self):
|
38
|
+
"""Add custom URLs for AJAX loading and searching."""
|
39
|
+
default_urls = super().get_urls()
|
40
|
+
|
41
|
+
custom_urls = [
|
42
|
+
path(
|
43
|
+
"change_list/",
|
44
|
+
self.admin_site.admin_view(self.ajax_changelist_view),
|
45
|
+
name="tree_changelist"
|
46
|
+
),
|
47
|
+
path(
|
48
|
+
"move/",
|
49
|
+
self.admin_site.admin_view(self.ajax_move_view),
|
50
|
+
name="tree_changelist_move"
|
51
|
+
),
|
52
|
+
path(
|
53
|
+
"import/",
|
54
|
+
self.admin_site.admin_view(self.import_view),
|
55
|
+
name="tree_node_import"
|
56
|
+
),
|
57
|
+
path("export/",
|
58
|
+
self.admin_site.admin_view(self.export_view),
|
59
|
+
name="tree_node_export"
|
60
|
+
),
|
61
|
+
]
|
62
|
+
return custom_urls + default_urls
|
63
|
+
|
64
|
+
def render_changelist_rows(self, objs: list, request):
|
65
|
+
"""Rander rows for incert to changelist."""
|
66
|
+
list_display = list(self.get_list_display(request))
|
67
|
+
checkbox_field_name = ACTION_CHECKBOX_NAME
|
68
|
+
if checkbox_field_name not in list_display:
|
69
|
+
list_display.insert(0, checkbox_field_name)
|
70
|
+
|
71
|
+
rows = []
|
72
|
+
for obj in objs:
|
73
|
+
row_data = []
|
74
|
+
td_classes = []
|
75
|
+
|
76
|
+
for field in list_display:
|
77
|
+
if field == ACTION_CHECKBOX_NAME:
|
78
|
+
checkbox = f'<input type="checkbox" id="action_{obj.pk}" name="{ACTION_CHECKBOX_NAME}" value="{obj.pk}" class="action-select" />' # noqa: D501
|
79
|
+
row_data.append(mark_safe(checkbox))
|
80
|
+
td_classes.append("action-checkbox")
|
81
|
+
continue
|
82
|
+
|
83
|
+
if callable(field):
|
84
|
+
value = field(obj)
|
85
|
+
field_name = getattr(field, "__name__", "field")
|
86
|
+
else:
|
87
|
+
attr, value = lookup_field(field, obj, self)
|
88
|
+
field_name = field
|
89
|
+
|
90
|
+
row_data.append(value)
|
91
|
+
td_classes.append(f"field-{field_name}")
|
92
|
+
|
93
|
+
rows.append({
|
94
|
+
"node_id": obj.pk,
|
95
|
+
"attrs": f'data-node-id="{obj.pk}" data-parent-of="{obj.parent_id or ""}" class="model-{self.model._meta.model_name} pk-{obj.pk}"', # noqa: D501
|
96
|
+
"parent_id": obj.parent_id,
|
97
|
+
"cells": list(zip(row_data, td_classes)),
|
98
|
+
})
|
99
|
+
return rows
|
100
|
+
|
101
|
+
@method_decorator(never_cache)
|
102
|
+
def ajax_changelist_view(self, request, extra_context=None):
|
103
|
+
"""
|
104
|
+
Create a default changelist or load child elements via AJAX.
|
105
|
+
|
106
|
+
- If `parent_id` is passed, returns only child rows (HTML).
|
107
|
+
- If `q` is passed, returns up to 20 matching rows (HTML).
|
108
|
+
- Otherwise, displays the full changelist.
|
109
|
+
"""
|
110
|
+
extra_context = extra_context or {}
|
111
|
+
extra_context['import_export_enabled'] = self.import_export
|
112
|
+
# Define the show_admin_actions variable in the context
|
113
|
+
extra_context['show_admin_actions'] = extra_context.get(
|
114
|
+
"show_admin_actions", False)
|
115
|
+
parent_id = request.GET.get("parent_id", "")
|
116
|
+
query = request.GET.get("q", "")
|
117
|
+
expanded = request.GET.get("expanded", "[]")
|
118
|
+
|
119
|
+
if parent_id:
|
120
|
+
parent = self.model.objects.filter(pk=parent_id).first()
|
121
|
+
rows_list = parent.get_children() if parent else []
|
122
|
+
|
123
|
+
elif query:
|
124
|
+
name_field = getattr(self.model, "display_field", "id")
|
125
|
+
rows_list = list(self.model.objects.filter(
|
126
|
+
Q(**{f"{name_field}__icontains": query})
|
127
|
+
| Q(pk__icontains=query)
|
128
|
+
).order_by("_path")[:20])
|
129
|
+
|
130
|
+
elif expanded:
|
131
|
+
try:
|
132
|
+
expanded_list = json.loads(expanded)
|
133
|
+
except ValueError:
|
134
|
+
return JsonResponse({"error": _("Bad node list.")}, status=422)
|
135
|
+
if expanded_list:
|
136
|
+
queryset = self.model.objects.filter(
|
137
|
+
Q(parent__isnull=True) |
|
138
|
+
Q(pk__in=expanded_list) |
|
139
|
+
Q(parent_id__in=expanded_list)
|
140
|
+
).distinct()
|
141
|
+
else:
|
142
|
+
queryset = self.model.objects.filter(parent__isnull=True)
|
143
|
+
|
144
|
+
rows_list = list(queryset.order_by("_path"))
|
145
|
+
else:
|
146
|
+
return JsonResponse({"html": ""})
|
147
|
+
|
148
|
+
rows = self.render_changelist_rows(rows_list, request)
|
149
|
+
|
150
|
+
# Take model verbose name
|
151
|
+
verbose_name = getattr(self.model._meta, "verbose_name_plural", None) \
|
152
|
+
or getattr(self.model._meta, "verbose_name", None) \
|
153
|
+
or self.model._meta.object_name
|
154
|
+
|
155
|
+
# Render HTML
|
156
|
+
html = render_to_string(
|
157
|
+
"admin/treenode_ajax_rows.html",
|
158
|
+
{"rows": rows},
|
159
|
+
request=request
|
160
|
+
)
|
161
|
+
return JsonResponse({"html": html, "label": verbose_name})
|
162
|
+
|
163
|
+
@method_decorator(never_cache)
|
164
|
+
def ajax_move_view(self, request, extra_context=None):
|
165
|
+
"""
|
166
|
+
Perform drag-and-drop move operation for a node.
|
167
|
+
|
168
|
+
Moves a node relative to a target node using the specified mode.
|
169
|
+
"""
|
170
|
+
# 1. Extracting parameters
|
171
|
+
node_id = request.POST.get("node_id")
|
172
|
+
target_id = request.POST.get("target_id")
|
173
|
+
mode = request.POST.get("mode")
|
174
|
+
|
175
|
+
if not (node_id and mode):
|
176
|
+
return JsonResponse({"error": _("Missing parameters.")}, status=400)
|
177
|
+
|
178
|
+
# 2. Getting objects
|
179
|
+
node = self.model.objects.filter(pk=node_id).first()
|
180
|
+
if not node or mode not in ("child", "after"):
|
181
|
+
return JsonResponse(
|
182
|
+
{"error": _(f"Invalid node ({node_id}) or mode ({mode}).")},
|
183
|
+
status=422
|
184
|
+
)
|
185
|
+
|
186
|
+
# Prepare the target
|
187
|
+
if not target_id or target_id == 'null':
|
188
|
+
# Insetr like a root
|
189
|
+
target_id = None
|
190
|
+
target = None
|
191
|
+
else:
|
192
|
+
target_id = int(target_id)
|
193
|
+
target = self.model.objects.filter(pk=target_id).first()
|
194
|
+
|
195
|
+
# 3. Protection from moving into your descendants
|
196
|
+
descendants_pks = node.query("descendants", include_self=True)
|
197
|
+
if target_id in descendants_pks:
|
198
|
+
return JsonResponse({
|
199
|
+
"error": _("Cannot move a node into its own descendants.")
|
200
|
+
}, status=409)
|
201
|
+
|
202
|
+
# 4. Positioning and moving
|
203
|
+
sorting_mode = self.model.sorting_field == 'priority'
|
204
|
+
if target:
|
205
|
+
position = {
|
206
|
+
"after": 'right-sibling' if sorting_mode else 'sorted-sibling',
|
207
|
+
"child": 'last-child' if sorting_mode else 'sorted-child'
|
208
|
+
}[mode]
|
209
|
+
else:
|
210
|
+
position = 'first-root' if sorting_mode else 'sorted-root'
|
211
|
+
|
212
|
+
# 5. Adjustments
|
213
|
+
if mode == 'after' and node.parent == target:
|
214
|
+
# User wants to insert a node after a node-parent
|
215
|
+
# print("User wants to insert a node after a node-parent.")
|
216
|
+
pass
|
217
|
+
|
218
|
+
# Debug
|
219
|
+
# print(mode, "-", position)
|
220
|
+
|
221
|
+
# Moving
|
222
|
+
node.move_to(target, position)
|
223
|
+
|
224
|
+
return JsonResponse({
|
225
|
+
"message": _("1 node successfully moved")
|
226
|
+
}, status=200)
|
227
|
+
|
228
|
+
def import_view(self, request):
|
229
|
+
"""
|
230
|
+
Impoern View.
|
231
|
+
|
232
|
+
Handles file upload and initiates import processing via
|
233
|
+
TreeNodeImporter.
|
234
|
+
Renders summary and any errors.
|
235
|
+
"""
|
236
|
+
if request.method == "POST":
|
237
|
+
file = request.FILES.get("file")
|
238
|
+
if not file:
|
239
|
+
return HttpResponseBadRequest("Missing file or format")
|
240
|
+
|
241
|
+
filename = file.name
|
242
|
+
extension = os.path.splitext(filename)[1].lower().lstrip('.')
|
243
|
+
if extension not in ['csv', 'tsv', 'json', 'xlsx', 'yaml']:
|
244
|
+
return JsonResponse(
|
245
|
+
{"error": _(f"Invalid file format ({extension}.")},
|
246
|
+
status=200
|
247
|
+
)
|
248
|
+
importer = self.TreeNodeImporter(self.model, file, extension)
|
249
|
+
importer.parse()
|
250
|
+
result = importer.import_tree()
|
251
|
+
|
252
|
+
return render(request, "admin/treenode_import_export.html", {
|
253
|
+
"created_count": result.get("created", 0),
|
254
|
+
"updated_count": result.get("updated", 0),
|
255
|
+
"errors": result.get("errors", []),
|
256
|
+
"import_active": True
|
257
|
+
})
|
258
|
+
|
259
|
+
return render(
|
260
|
+
request,
|
261
|
+
"admin/treenode_import_export.html",
|
262
|
+
{"import_active": True}
|
263
|
+
)
|
264
|
+
|
265
|
+
def export_view(self, request):
|
266
|
+
"""
|
267
|
+
Export View.
|
268
|
+
|
269
|
+
Handles GET-based export of data in selected format via
|
270
|
+
TreeNodeExporter.
|
271
|
+
Returns a streaming response for large datasets.
|
272
|
+
"""
|
273
|
+
if request.GET.get("download"):
|
274
|
+
fmt = request.GET.get("format", "csv")
|
275
|
+
filename = self.model._meta.object_name
|
276
|
+
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M")
|
277
|
+
filename = f"{filename}-{timestamp}.{fmt}"
|
278
|
+
exporter = self.TreeNodeExporter(
|
279
|
+
self.model,
|
280
|
+
filename=filename,
|
281
|
+
fileformat=fmt
|
282
|
+
)
|
283
|
+
return exporter.process_record()
|
284
|
+
|
285
|
+
return render(
|
286
|
+
request,
|
287
|
+
"admin/treenode_import_export.html",
|
288
|
+
{"import_active": False}
|
289
|
+
)
|
290
|
+
|
291
|
+
# The End
|