django-fast-treenode 1.1.3__py3-none-any.whl → 2.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-1.1.3.dist-info → django_fast_treenode-2.0.1.dist-info}/METADATA +127 -46
- django_fast_treenode-2.0.1.dist-info/RECORD +41 -0
- {django_fast_treenode-1.1.3.dist-info → django_fast_treenode-2.0.1.dist-info}/WHEEL +1 -1
- treenode/__init__.py +0 -7
- treenode/admin.py +327 -82
- treenode/apps.py +20 -3
- treenode/cache.py +231 -0
- treenode/docs/Documentation +101 -54
- treenode/forms.py +75 -19
- treenode/managers.py +260 -48
- treenode/models/__init__.py +7 -0
- treenode/models/classproperty.py +24 -0
- treenode/models/closure.py +168 -0
- treenode/models/factory.py +71 -0
- treenode/models/proxy.py +650 -0
- treenode/static/treenode/css/tree_widget.css +62 -0
- treenode/static/treenode/css/treenode_admin.css +106 -0
- treenode/static/treenode/js/tree_widget.js +161 -0
- treenode/static/treenode/js/treenode_admin.js +171 -0
- treenode/templates/admin/export_success.html +26 -0
- treenode/templates/admin/tree_node_changelist.html +11 -0
- treenode/templates/admin/tree_node_export.html +27 -0
- treenode/templates/admin/tree_node_import.html +27 -0
- treenode/templates/widgets/tree_widget.css +23 -0
- treenode/templates/widgets/tree_widget.html +21 -0
- treenode/urls.py +34 -0
- treenode/utils/__init__.py +4 -0
- treenode/utils/base36.py +35 -0
- treenode/utils/exporter.py +141 -0
- treenode/utils/importer.py +296 -0
- treenode/version.py +11 -1
- treenode/views.py +102 -2
- treenode/widgets.py +49 -27
- django_fast_treenode-1.1.3.dist-info/RECORD +0 -33
- treenode/compat.py +0 -8
- treenode/factory.py +0 -68
- treenode/models.py +0 -668
- treenode/static/select2tree/.gitkeep +0 -1
- treenode/static/select2tree/select2tree.css +0 -176
- treenode/static/select2tree/select2tree.js +0 -181
- treenode/static/treenode/css/treenode.css +0 -85
- treenode/static/treenode/js/treenode.js +0 -201
- treenode/templates/widgets/.gitkeep +0 -1
- treenode/templates/widgets/attrs.html +0 -7
- treenode/templates/widgets/options.html +0 -1
- treenode/templates/widgets/select2tree.html +0 -22
- treenode/tests.py +0 -3
- {django_fast_treenode-1.1.3.dist-info → django_fast_treenode-2.0.1.dist-info}/LICENSE +0 -0
- {django_fast_treenode-1.1.3.dist-info → django_fast_treenode-2.0.1.dist-info}/top_level.txt +0 -0
- /treenode/{docs → templates/admin}/.gitkeep +0 -0
@@ -0,0 +1,141 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
"""
|
3
|
+
TreeNode Exporter Module
|
4
|
+
|
5
|
+
This module provides functionality for exporting tree-structured data
|
6
|
+
to various formats, including CSV, JSON, XLSX, YAML, and TSV.
|
7
|
+
|
8
|
+
Features:
|
9
|
+
- Supports exporting ForeignKey fields as IDs and ManyToMany fields as JSON
|
10
|
+
lists.
|
11
|
+
- Handles complex field types (lists, dictionaries) with proper serialization.
|
12
|
+
- Provides optimized data extraction for QuerySets.
|
13
|
+
- Generates downloadable files with appropriate HTTP responses.
|
14
|
+
|
15
|
+
Version: 2.0.0
|
16
|
+
Author: Timur Kady
|
17
|
+
Email: timurkady@yandex.com
|
18
|
+
"""
|
19
|
+
|
20
|
+
|
21
|
+
import csv
|
22
|
+
import json
|
23
|
+
import yaml
|
24
|
+
import pandas as pd
|
25
|
+
from io import BytesIO
|
26
|
+
from django.http import HttpResponse
|
27
|
+
import logging
|
28
|
+
|
29
|
+
logger = logging.getLogger(__name__)
|
30
|
+
|
31
|
+
|
32
|
+
class TreeNodeExporter:
|
33
|
+
"""Exporter for tree-structured data to various formats."""
|
34
|
+
|
35
|
+
def __init__(self, queryset, filename="tree_nodes"):
|
36
|
+
"""
|
37
|
+
Init.
|
38
|
+
|
39
|
+
:param queryset: QuerySet of objects to export.
|
40
|
+
:param filename: Filename without extension.
|
41
|
+
"""
|
42
|
+
self.queryset = queryset
|
43
|
+
self.filename = filename
|
44
|
+
self.fields = [field.name for field in queryset.model._meta.fields]
|
45
|
+
|
46
|
+
def export(self, format):
|
47
|
+
"""Determine the export format and calls the corresponding method."""
|
48
|
+
exporters = {
|
49
|
+
"csv": self.to_csv,
|
50
|
+
"json": self.to_json,
|
51
|
+
"xlsx": self.to_xlsx,
|
52
|
+
"yaml": self.to_yaml,
|
53
|
+
"tsv": self.to_tsv,
|
54
|
+
}
|
55
|
+
if format not in exporters:
|
56
|
+
raise ValueError("Unsupported export format")
|
57
|
+
return exporters[format]()
|
58
|
+
|
59
|
+
def process_complex_fields(self, record):
|
60
|
+
"""Convert complex fields (lists, dictionaries) into JSON strings."""
|
61
|
+
for key, value in record.items():
|
62
|
+
if isinstance(value, (list, dict)):
|
63
|
+
try:
|
64
|
+
record[key] = json.dumps(value, ensure_ascii=False)
|
65
|
+
except Exception as e:
|
66
|
+
logger.warning("Error serializing field %s: %s", key, e)
|
67
|
+
record[key] = None
|
68
|
+
return record
|
69
|
+
|
70
|
+
def get_data(self):
|
71
|
+
"""Return a list of data from QuerySet as dictionaries."""
|
72
|
+
data = []
|
73
|
+
for obj in self.queryset:
|
74
|
+
record = {}
|
75
|
+
for field in self.fields:
|
76
|
+
value = getattr(obj, field, None)
|
77
|
+
field_object = obj._meta.get_field(field)
|
78
|
+
if field_object.is_relation:
|
79
|
+
if field_object.many_to_many:
|
80
|
+
# ManyToMany - save as a JSON string
|
81
|
+
record[field] = json.dumps(
|
82
|
+
list(value.values_list('id', flat=True)),
|
83
|
+
ensure_ascii=False)
|
84
|
+
elif field_object.many_to_one:
|
85
|
+
# ForeignKey - save as ID
|
86
|
+
record[field] = value.id if value else None
|
87
|
+
else:
|
88
|
+
record[field] = value
|
89
|
+
else:
|
90
|
+
record[field] = value
|
91
|
+
record = self.process_complex_fields(record)
|
92
|
+
data.append(record)
|
93
|
+
return data
|
94
|
+
|
95
|
+
def to_csv(self):
|
96
|
+
"""Export to CSV with proper attachment handling."""
|
97
|
+
response = HttpResponse(content_type="text/csv")
|
98
|
+
response["Content-Disposition"] = f'attachment; filename="{self.filename}.csv"'
|
99
|
+
writer = csv.DictWriter(response, fieldnames=self.fields)
|
100
|
+
writer.writeheader()
|
101
|
+
writer.writerows(self.get_data())
|
102
|
+
return response
|
103
|
+
|
104
|
+
def to_json(self):
|
105
|
+
"""Export to JSON with UUID serialization handling."""
|
106
|
+
response = HttpResponse(content_type="application/octet-stream")
|
107
|
+
response["Content-Disposition"] = f'attachment; filename="{self.filename}.json"'
|
108
|
+
json.dump(self.get_data(), response,
|
109
|
+
ensure_ascii=False, indent=4, default=str)
|
110
|
+
return response
|
111
|
+
|
112
|
+
def to_xlsx(self):
|
113
|
+
"""Export to XLSX."""
|
114
|
+
response = HttpResponse(
|
115
|
+
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
116
|
+
response["Content-Disposition"] = f'attachment; filename="{self.filename}.xlsx"'
|
117
|
+
df = pd.DataFrame(self.get_data())
|
118
|
+
with BytesIO() as buffer:
|
119
|
+
writer = pd.ExcelWriter(buffer, engine="xlsxwriter")
|
120
|
+
df.to_excel(writer, index=False)
|
121
|
+
writer.close()
|
122
|
+
response.write(buffer.getvalue())
|
123
|
+
return response
|
124
|
+
|
125
|
+
def to_yaml(self):
|
126
|
+
"""Export to YAML with proper attachment handling."""
|
127
|
+
response = HttpResponse(content_type="application/octet-stream")
|
128
|
+
response["Content-Disposition"] = f'attachment; filename="{self.filename}.yaml"'
|
129
|
+
yaml_str = yaml.dump(self.get_data(), allow_unicode=True)
|
130
|
+
response.write(yaml_str)
|
131
|
+
return response
|
132
|
+
|
133
|
+
def to_tsv(self):
|
134
|
+
"""Export to TSV with proper attachment handling."""
|
135
|
+
response = HttpResponse(content_type="application/octet-stream")
|
136
|
+
response["Content-Disposition"] = f'attachment; filename="{self.filename}.tsv"'
|
137
|
+
writer = csv.DictWriter(
|
138
|
+
response, fieldnames=self.fields, delimiter=" ")
|
139
|
+
writer.writeheader()
|
140
|
+
writer.writerows(self.get_data())
|
141
|
+
return response
|
@@ -0,0 +1,296 @@
|
|
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: 2.0.0
|
16
|
+
Author: Timur Kady
|
17
|
+
Email: timurkady@yandex.com
|
18
|
+
"""
|
19
|
+
|
20
|
+
|
21
|
+
import csv
|
22
|
+
import json
|
23
|
+
import yaml
|
24
|
+
import math
|
25
|
+
import pandas as pd
|
26
|
+
from io import BytesIO, StringIO
|
27
|
+
from django.db import transaction
|
28
|
+
import logging
|
29
|
+
|
30
|
+
logger = logging.getLogger(__name__)
|
31
|
+
|
32
|
+
|
33
|
+
class TreeNodeImporter:
|
34
|
+
"""Импортер древовидных данных из различных форматов."""
|
35
|
+
|
36
|
+
def __init__(self, model, file, format, fields=None, mapping=None):
|
37
|
+
"""
|
38
|
+
Init method.
|
39
|
+
|
40
|
+
:param model: Django model where the data will be imported.
|
41
|
+
:param file: File object.
|
42
|
+
:param format: File format ('csv', 'json', 'xlsx', 'yaml', 'tsv').
|
43
|
+
:param fields: List of model fields to import.
|
44
|
+
:param mapping: Dictionary for mapping keys from file to model
|
45
|
+
field names.
|
46
|
+
For example: {"Name": "title", "Description": "desc"}
|
47
|
+
"""
|
48
|
+
self.model = model
|
49
|
+
self.format = format
|
50
|
+
# Если поля не заданы, используем все поля модели
|
51
|
+
self.fields = fields or [field.name for field in model._meta.fields]
|
52
|
+
# По умолчанию маппинг идентичен: ключи совпадают с именами полей
|
53
|
+
self.mapping = mapping or {field: field for field in self.fields}
|
54
|
+
# Считываем содержимое файла один раз, чтобы избежать проблем с курсором
|
55
|
+
self.file_content = file.read()
|
56
|
+
|
57
|
+
def get_text_content(self):
|
58
|
+
"""Возвращает содержимое файла в виде строки."""
|
59
|
+
if isinstance(self.file_content, bytes):
|
60
|
+
return self.file_content.decode("utf-8")
|
61
|
+
return self.file_content
|
62
|
+
|
63
|
+
def import_data(self):
|
64
|
+
"""Импортирует данные и возвращает список словарей."""
|
65
|
+
importers = {
|
66
|
+
"csv": self.from_csv,
|
67
|
+
"json": self.from_json,
|
68
|
+
"xlsx": self.from_xlsx,
|
69
|
+
"yaml": self.from_yaml,
|
70
|
+
"tsv": self.from_tsv,
|
71
|
+
}
|
72
|
+
if self.format not in importers:
|
73
|
+
raise ValueError("Unsupported import format")
|
74
|
+
|
75
|
+
raw_data = importers[self.format]()
|
76
|
+
# Обработка: фильтрация полей, упаковка сложных значений и приведение типов
|
77
|
+
processed_data = self.process_records(raw_data)
|
78
|
+
return processed_data
|
79
|
+
|
80
|
+
def from_csv(self):
|
81
|
+
"""Импорт из CSV."""
|
82
|
+
text = self.get_text_content()
|
83
|
+
return list(csv.DictReader(StringIO(text)))
|
84
|
+
|
85
|
+
def from_json(self):
|
86
|
+
"""Импорт из JSON."""
|
87
|
+
return json.loads(self.get_text_content())
|
88
|
+
|
89
|
+
def from_xlsx(self):
|
90
|
+
"""Импорт из XLSX (Excel)."""
|
91
|
+
df = pd.read_excel(BytesIO(self.file_content))
|
92
|
+
return df.to_dict(orient="records")
|
93
|
+
|
94
|
+
def from_yaml(self):
|
95
|
+
"""Импорт из YAML."""
|
96
|
+
return yaml.safe_load(self.get_text_content())
|
97
|
+
|
98
|
+
def from_tsv(self):
|
99
|
+
"""Импорт из TSV."""
|
100
|
+
text = self.get_text_content()
|
101
|
+
return list(csv.DictReader(StringIO(text), delimiter="\t"))
|
102
|
+
|
103
|
+
def filter_fields(self, record):
|
104
|
+
"""
|
105
|
+
Фильтрует запись согласно маппингу.
|
106
|
+
Остаются только нужные ключи, при этом имена переименовываются.
|
107
|
+
"""
|
108
|
+
new_record = {}
|
109
|
+
for file_key, model_field in self.mapping.items():
|
110
|
+
new_record[model_field] = record.get(file_key)
|
111
|
+
return new_record
|
112
|
+
|
113
|
+
def process_complex_fields(self, record):
|
114
|
+
"""
|
115
|
+
Если значение поля — словарь или список, упаковывает его в JSON-строку.
|
116
|
+
"""
|
117
|
+
for key, value in record.items():
|
118
|
+
if isinstance(value, (list, dict)):
|
119
|
+
try:
|
120
|
+
record[key] = json.dumps(value, ensure_ascii=False)
|
121
|
+
except Exception as e:
|
122
|
+
logger.warning("Error serializing field %s: %s", key, e)
|
123
|
+
record[key] = None
|
124
|
+
return record
|
125
|
+
|
126
|
+
def cast_record_types(self, record):
|
127
|
+
"""
|
128
|
+
Приводит значения полей записи к типам, определённым в модели.
|
129
|
+
|
130
|
+
Для каждого поля вызывается его метод to_python(). Если значение равно nan,
|
131
|
+
оно заменяется на None.
|
132
|
+
|
133
|
+
Для ForeignKey-полей (many-to-one) значение записывается в атрибут <field>_id,
|
134
|
+
а исходный ключ удаляется.
|
135
|
+
"""
|
136
|
+
for field in self.model._meta.fields:
|
137
|
+
field_name = field.name
|
138
|
+
if field.is_relation and field.many_to_one:
|
139
|
+
if field_name in record:
|
140
|
+
value = record[field_name]
|
141
|
+
if isinstance(value, float) and math.isnan(value):
|
142
|
+
value = None
|
143
|
+
try:
|
144
|
+
converted = None if value is None else int(value)
|
145
|
+
# Записываем в атрибут, например, tn_parent_id
|
146
|
+
record[field.attname] = converted
|
147
|
+
except Exception as e:
|
148
|
+
logger.warning("Error converting FK field %s with value %r: %s",
|
149
|
+
field_name, value, e)
|
150
|
+
record[field.attname] = None
|
151
|
+
# Удаляем оригинальное значение, чтобы Django не пыталась его обработать
|
152
|
+
del record[field_name]
|
153
|
+
else:
|
154
|
+
if field_name in record:
|
155
|
+
value = record[field_name]
|
156
|
+
if isinstance(value, float) and math.isnan(value):
|
157
|
+
record[field_name] = None
|
158
|
+
else:
|
159
|
+
try:
|
160
|
+
record[field_name] = field.to_python(value)
|
161
|
+
except Exception as e:
|
162
|
+
logger.warning("Error converting field %s with value %r: %s",
|
163
|
+
field_name, value, e)
|
164
|
+
record[field_name] = None
|
165
|
+
return record
|
166
|
+
|
167
|
+
def process_records(self, records):
|
168
|
+
"""
|
169
|
+
Обрабатывает список записей:
|
170
|
+
1. Фильтрует поля по маппингу.
|
171
|
+
2. Упаковывает сложные (вложенные) данные в JSON.
|
172
|
+
3. Приводит значения каждого поля к типам, определённым в модели.
|
173
|
+
"""
|
174
|
+
processed = []
|
175
|
+
for record in records:
|
176
|
+
filtered = self.filter_fields(record)
|
177
|
+
filtered = self.process_complex_fields(filtered)
|
178
|
+
filtered = self.cast_record_types(filtered)
|
179
|
+
processed.append(filtered)
|
180
|
+
return processed
|
181
|
+
|
182
|
+
def clean(self, raw_data):
|
183
|
+
"""
|
184
|
+
Валидирует и подготавливает данные для массового сохранения объектов.
|
185
|
+
|
186
|
+
Для каждой записи:
|
187
|
+
- Проверяется наличие уникального поля 'id'.
|
188
|
+
- Значение родительской связи (tn_parent или tn_parent_id) сохраняется отдельно и удаляется из данных.
|
189
|
+
- Приводит данные к типам модели.
|
190
|
+
- Пытается создать экземпляр модели с валидацией через full_clean().
|
191
|
+
|
192
|
+
Возвращает словарь со следующими ключами:
|
193
|
+
'create' - список объектов для создания,
|
194
|
+
'update' - список объектов для обновления (в данном случае оставим пустым),
|
195
|
+
'update_fields' - список полей, подлежащих обновлению (например, ['tn_parent']),
|
196
|
+
'fk_mappings' - словарь {id_объекта: значение родительского ключа из исходных данных},
|
197
|
+
'errors' - список ошибок валидации.
|
198
|
+
"""
|
199
|
+
result = {
|
200
|
+
"create": [],
|
201
|
+
"update": [],
|
202
|
+
"update_fields": [],
|
203
|
+
"fk_mappings": {},
|
204
|
+
"errors": []
|
205
|
+
}
|
206
|
+
|
207
|
+
for data in raw_data:
|
208
|
+
if 'id' not in data:
|
209
|
+
error_message = f"Missing unique field 'id' in record: {data}"
|
210
|
+
result["errors"].append(error_message)
|
211
|
+
logger.warning(error_message)
|
212
|
+
continue
|
213
|
+
|
214
|
+
# Сохраняем значение родительской связи и удаляем его из данных
|
215
|
+
fk_value = None
|
216
|
+
if 'tn_parent' in data:
|
217
|
+
fk_value = data['tn_parent']
|
218
|
+
del data['tn_parent']
|
219
|
+
elif 'tn_parent_id' in data:
|
220
|
+
fk_value = data['tn_parent_id']
|
221
|
+
del data['tn_parent_id']
|
222
|
+
|
223
|
+
# Приводим значения к типам модели
|
224
|
+
data = self.cast_record_types(data)
|
225
|
+
|
226
|
+
try:
|
227
|
+
instance = self.model(**data)
|
228
|
+
instance.full_clean()
|
229
|
+
result["create"].append(instance)
|
230
|
+
# Сохраняем значение родительского ключа для последующего обновления
|
231
|
+
result["fk_mappings"][instance.id] = fk_value
|
232
|
+
except Exception as e:
|
233
|
+
error_message = f"Validation error creating {data}: {e}"
|
234
|
+
result["errors"].append(error_message)
|
235
|
+
logger.warning(error_message)
|
236
|
+
continue
|
237
|
+
|
238
|
+
# В данном сценарии обновление происходит только для родительской связи
|
239
|
+
result["update_fields"] = ['tn_parent']
|
240
|
+
return result
|
241
|
+
|
242
|
+
def save_data(self, create, update, fields):
|
243
|
+
"""
|
244
|
+
Сохраняет объекты в базу в рамках атомарной транзакции.
|
245
|
+
:param create: список объектов для создания.
|
246
|
+
:param update: список объектов для обновления.
|
247
|
+
:param fields: список полей, которые обновляются (для bulk_update).
|
248
|
+
"""
|
249
|
+
with transaction.atomic():
|
250
|
+
if update:
|
251
|
+
self.model.objects.bulk_update(update, fields, batch_size=1000)
|
252
|
+
if create:
|
253
|
+
self.model.objects.bulk_create(create, batch_size=1000)
|
254
|
+
|
255
|
+
def update_parent_relations(self, fk_mappings):
|
256
|
+
"""
|
257
|
+
Обновляет поле tn_parent для объектов, используя сохранённые fk_mappings.
|
258
|
+
:param fk_mappings: словарь {id_объекта: значение родительского ключа из исходных данных}
|
259
|
+
"""
|
260
|
+
instances_to_update = []
|
261
|
+
for obj_id, parent_id in fk_mappings.items():
|
262
|
+
# Если родитель не указан, пропускаем
|
263
|
+
if not parent_id:
|
264
|
+
continue
|
265
|
+
try:
|
266
|
+
instance = self.model.objects.get(pk=obj_id)
|
267
|
+
parent_instance = self.model.objects.get(pk=parent_id)
|
268
|
+
instance.tn_parent = parent_instance
|
269
|
+
instances_to_update.append(instance)
|
270
|
+
except self.model.DoesNotExist:
|
271
|
+
logger.warning(
|
272
|
+
"Parent with id %s not found for instance %s", parent_id, obj_id)
|
273
|
+
if instances_to_update:
|
274
|
+
update_fields = ['tn_parent']
|
275
|
+
self.model.objects.bulk_update(
|
276
|
+
instances_to_update, update_fields, batch_size=1000)
|
277
|
+
|
278
|
+
# Если захочешь объединить операции сохранения и обновления родителей,
|
279
|
+
# можно добавить метод, который вызовет save_data и update_parent_relations последовательно.
|
280
|
+
def finalize_import(self, clean_result):
|
281
|
+
"""
|
282
|
+
Финализирует импорт: сохраняет новые объекты и обновляет родительские связи.
|
283
|
+
:param clean_result: словарь, возвращённый методом clean.
|
284
|
+
"""
|
285
|
+
# Если есть ошибки – можно прервать импорт или вернуть их для обработки
|
286
|
+
if clean_result["errors"]:
|
287
|
+
return clean_result["errors"]
|
288
|
+
|
289
|
+
# Сначала выполняем массовое создание
|
290
|
+
self.save_data(
|
291
|
+
clean_result["create"], clean_result["update"], clean_result["update_fields"])
|
292
|
+
# Затем обновляем родительские связи
|
293
|
+
self.update_parent_relations(clean_result["fk_mappings"])
|
294
|
+
return None # Или вернуть успешное сообщение
|
295
|
+
|
296
|
+
# The End
|
treenode/version.py
CHANGED
@@ -1,3 +1,13 @@
|
|
1
1
|
# -*- coding: utf-8 -*-
|
2
|
+
"""
|
3
|
+
TreeNode Version Module
|
2
4
|
|
3
|
-
|
5
|
+
This module defines the current version of the TreeNode package.
|
6
|
+
|
7
|
+
Version: 2.0.0
|
8
|
+
Author: Timur Kady
|
9
|
+
Email: timurkady@yandex.com
|
10
|
+
"""
|
11
|
+
|
12
|
+
|
13
|
+
__version__ = '2.0.0'
|
treenode/views.py
CHANGED
@@ -1,3 +1,103 @@
|
|
1
|
-
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
"""
|
3
|
+
TreeNode Views Module
|
2
4
|
|
3
|
-
|
5
|
+
This module provides API views for handling AJAX requests related to
|
6
|
+
tree-structured data in Django. It supports Select2 autocomplete
|
7
|
+
and retrieving child node counts.
|
8
|
+
|
9
|
+
Features:
|
10
|
+
- `TreeNodeAutocompleteView`: Returns JSON data for Select2 with hierarchical
|
11
|
+
structure.
|
12
|
+
- `GetChildrenCountView`: Retrieves the number of children for a given
|
13
|
+
parent node.
|
14
|
+
- Uses optimized QuerySets for efficient database queries.
|
15
|
+
- Handles validation and error responses gracefully.
|
16
|
+
|
17
|
+
Version: 2.0.0
|
18
|
+
Author: Timur Kady
|
19
|
+
Email: timurkady@yandex.com
|
20
|
+
"""
|
21
|
+
|
22
|
+
|
23
|
+
from django.http import JsonResponse
|
24
|
+
from django.views import View
|
25
|
+
from django.apps import apps
|
26
|
+
from django.db.models import Case, When, Value, IntegerField
|
27
|
+
from django.core.exceptions import ObjectDoesNotExist
|
28
|
+
|
29
|
+
|
30
|
+
class TreeNodeAutocompleteView(View):
|
31
|
+
"""Returns JSON data for Select2 with tree structure."""
|
32
|
+
|
33
|
+
def get(self, request):
|
34
|
+
"""Get method."""
|
35
|
+
q = request.GET.get("q", "")
|
36
|
+
model_label = request.GET.get("model") # Получаем модель
|
37
|
+
|
38
|
+
if not model_label:
|
39
|
+
return JsonResponse(
|
40
|
+
{"error": "Missing model parameter"},
|
41
|
+
status=400
|
42
|
+
)
|
43
|
+
|
44
|
+
try:
|
45
|
+
model = apps.get_model(model_label)
|
46
|
+
except LookupError:
|
47
|
+
return JsonResponse(
|
48
|
+
{"error": f"Invalid model: {model_label}"},
|
49
|
+
status=400
|
50
|
+
)
|
51
|
+
|
52
|
+
queryset = model.objects.filter(name__icontains=q)
|
53
|
+
node_list = sorted(queryset, key=lambda x: x.tn_order)
|
54
|
+
pk_list = [node.pk for node in node_list]
|
55
|
+
nodes = queryset.filter(pk__in=pk_list).order_by(
|
56
|
+
Case(*[When(pk=pk, then=Value(index))
|
57
|
+
for index, pk in enumerate(pk_list)],
|
58
|
+
default=Value(len(pk_list)),
|
59
|
+
output_field=IntegerField())
|
60
|
+
)[:10]
|
61
|
+
|
62
|
+
results = [
|
63
|
+
{
|
64
|
+
"id": node.pk,
|
65
|
+
"text": node.name,
|
66
|
+
"level": node.get_level(),
|
67
|
+
"is_leaf": node.is_leaf(),
|
68
|
+
}
|
69
|
+
for node in nodes
|
70
|
+
]
|
71
|
+
return JsonResponse({"results": results})
|
72
|
+
|
73
|
+
|
74
|
+
class GetChildrenCountView(View):
|
75
|
+
"""Return the number of children for a given parent node."""
|
76
|
+
|
77
|
+
def get(self, request):
|
78
|
+
"""Get method."""
|
79
|
+
parent_id = request.GET.get("parent_id")
|
80
|
+
model_label = request.GET.get("model") # Получаем модель
|
81
|
+
|
82
|
+
if not model_label or not parent_id:
|
83
|
+
return JsonResponse({"error": "Missing parameters"}, status=400)
|
84
|
+
|
85
|
+
try:
|
86
|
+
model = apps.get_model(model_label)
|
87
|
+
except LookupError:
|
88
|
+
return JsonResponse(
|
89
|
+
{"error": f"Invalid model: {model_label}"},
|
90
|
+
status=400
|
91
|
+
)
|
92
|
+
|
93
|
+
try:
|
94
|
+
parent_node = model.objects.get(pk=parent_id)
|
95
|
+
children_count = parent_node.get_children_count()
|
96
|
+
print("parent_id=", parent_id, " children_count=", children_count)
|
97
|
+
except ObjectDoesNotExist:
|
98
|
+
return JsonResponse(
|
99
|
+
{"error": "Parent node not found"},
|
100
|
+
status=404
|
101
|
+
)
|
102
|
+
|
103
|
+
return JsonResponse({"children_count": children_count})
|
treenode/widgets.py
CHANGED
@@ -1,43 +1,65 @@
|
|
1
1
|
# -*- coding: utf-8 -*-
|
2
2
|
"""
|
3
|
+
TreeNode Widgets Module
|
3
4
|
|
4
|
-
|
5
|
-
|
5
|
+
This module defines custom form widgets for handling hierarchical data
|
6
|
+
within Django's admin interface. It includes a Select2-based widget
|
7
|
+
for tree-structured data selection.
|
6
8
|
|
7
|
-
|
8
|
-
|
9
|
+
Features:
|
10
|
+
- `TreeWidget`: A custom Select2 widget that enhances usability for
|
11
|
+
hierarchical models.
|
12
|
+
- Automatically fetches hierarchical data via AJAX.
|
13
|
+
- Supports dynamic model binding for reusable implementations.
|
14
|
+
- Integrates with Django’s form system.
|
9
15
|
|
16
|
+
Version: 2.0.0
|
17
|
+
Author: Timur Kady
|
18
|
+
Email: timurkady@yandex.com
|
10
19
|
"""
|
20
|
+
|
21
|
+
|
11
22
|
from django import forms
|
12
23
|
|
13
24
|
|
14
25
|
class TreeWidget(forms.Select):
|
15
|
-
|
16
|
-
template_name = 'widgets/select2tree.html'
|
17
|
-
option_template_name = 'widgets/options.html'
|
26
|
+
"""Custom Select2 widget for hierarchical data."""
|
18
27
|
|
19
28
|
class Media:
|
29
|
+
"""Mrta class."""
|
30
|
+
|
20
31
|
css = {
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
)
|
32
|
+
"all": (
|
33
|
+
"https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/css/select2.min.css",
|
34
|
+
"treenode/css/tree_widget.css",
|
35
|
+
)
|
36
|
+
}
|
25
37
|
js = (
|
26
|
-
|
27
|
-
|
28
|
-
|
38
|
+
"https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/js/select2.min.js",
|
39
|
+
"treenode/js/tree_widget.js",
|
29
40
|
)
|
30
41
|
|
31
|
-
def
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
42
|
+
def build_attrs(self, base_attrs, extra_attrs=None):
|
43
|
+
"""Add attributes for Select2 integration."""
|
44
|
+
attrs = super().build_attrs(base_attrs, extra_attrs)
|
45
|
+
attrs.setdefault("data-url", "/treenode/tree-autocomplete/")
|
46
|
+
existing_class = attrs.get("class", "")
|
47
|
+
attrs["class"] = f"{existing_class} tree-widget".strip()
|
48
|
+
if "placeholder" in attrs:
|
49
|
+
del attrs["placeholder"]
|
50
|
+
|
51
|
+
# Принудительно передаём `model`
|
52
|
+
if "data-forward" not in attrs:
|
53
|
+
try:
|
54
|
+
model = self.choices.queryset.model
|
55
|
+
label = model._meta.app_label
|
56
|
+
model_name = model._meta.model_name
|
57
|
+
model_label = f"{label}.{model_name}"
|
58
|
+
attrs["data-forward"] = f'{{"model": "{model_label}"}}'
|
59
|
+
except Exception:
|
60
|
+
attrs["data-forward"] = '{"model": ""}'
|
61
|
+
|
62
|
+
return attrs
|
63
|
+
|
64
|
+
|
65
|
+
# The End
|