django-fast-treenode 1.1.2__py3-none-any.whl → 2.0.0__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- {django_fast_treenode-1.1.2.dist-info → django_fast_treenode-2.0.0.dist-info}/METADATA +156 -44
- django_fast_treenode-2.0.0.dist-info/RECORD +41 -0
- {django_fast_treenode-1.1.2.dist-info → django_fast_treenode-2.0.0.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 +130 -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.2.dist-info/RECORD +0 -33
- treenode/compat.py +0 -8
- treenode/factory.py +0 -68
- treenode/models.py +0 -669
- treenode/static/select2tree/.gitkeep +0 -1
- treenode/static/select2tree/select2tree.css +0 -176
- treenode/static/select2tree/select2tree.js +0 -171
- 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.2.dist-info → django_fast_treenode-2.0.0.dist-info}/LICENSE +0 -0
- {django_fast_treenode-1.1.2.dist-info → django_fast_treenode-2.0.0.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
|