django-fast-treenode 2.0.9__py3-none-any.whl → 2.0.11__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.
@@ -12,7 +12,7 @@ Features:
12
12
  - Uses bulk operations for efficient data insertion and updates.
13
13
  - Supports transactional imports to maintain data integrity.
14
14
 
15
- Version: 2.0.0
15
+ Version: 2.0.11
16
16
  Author: Timur Kady
17
17
  Email: timurkady@yandex.com
18
18
  """
@@ -21,10 +21,11 @@ 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
+ import uuid
26
27
  from io import BytesIO, StringIO
27
- from django.db import transaction
28
+
28
29
  import logging
29
30
 
30
31
  logger = logging.getLogger(__name__)
@@ -55,13 +56,13 @@ class TreeNodeImporter:
55
56
  self.file_content = file.read()
56
57
 
57
58
  def get_text_content(self):
58
- """Возвращает содержимое файла в виде строки."""
59
+ """Return the contents of a file as a string."""
59
60
  if isinstance(self.file_content, bytes):
60
61
  return self.file_content.decode("utf-8")
61
62
  return self.file_content
62
63
 
63
64
  def import_data(self):
64
- """Импортирует данные и возвращает список словарей."""
65
+ """Import data and returns a list of dictionaries."""
65
66
  importers = {
66
67
  "csv": self.from_csv,
67
68
  "json": self.from_json,
@@ -73,37 +74,36 @@ class TreeNodeImporter:
73
74
  raise ValueError("Unsupported import format")
74
75
 
75
76
  raw_data = importers[self.format]()
76
- # Обработка: фильтрация полей, упаковка сложных значений и приведение типов
77
- processed_data = self.process_records(raw_data)
78
- return processed_data
79
77
 
80
- def from_csv(self):
81
- """Импорт из CSV."""
82
- text = self.get_text_content()
83
- return list(csv.DictReader(StringIO(text)))
78
+ # Processing: field filtering, complex value packing and type casting
79
+ processed = []
80
+ for record in raw_data:
81
+ filtered = self.filter_fields(record)
82
+ filtered = self.process_complex_fields(filtered)
83
+ filtered = self.cast_record_types(filtered)
84
+ processed.append(filtered)
84
85
 
85
- def from_json(self):
86
- """Импорт из JSON."""
87
- return json.loads(self.get_text_content())
86
+ return processed
88
87
 
89
- def from_xlsx(self):
90
- """Импорт из XLSX (Excel)."""
91
- df = pd.read_excel(BytesIO(self.file_content))
92
- return df.to_dict(orient="records")
88
+ def get_tn_orders(self, rows):
89
+ """Calculate the materialized path without including None parents."""
90
+ # Build a mapping from id to record for quick lookup.
91
+ row_dict = {row["id"]: row for row in rows}
93
92
 
94
- def from_yaml(self):
95
- """Импорт из YAML."""
96
- return yaml.safe_load(self.get_text_content())
93
+ def get_ancestor_path(row):
94
+ parent_field = 'tn_parent' if 'tn_parent' in row else 'tn_parent_id'
95
+ return get_ancestor_path(row_dict[row[parent_field]]) + [row["id"]] if row[parent_field] else [row["id"]]
97
96
 
98
- def from_tsv(self):
99
- """Импорт из TSV."""
100
- text = self.get_text_content()
101
- return list(csv.DictReader(StringIO(text), delimiter="\t"))
97
+ return [
98
+ {"id": row["id"], "path": get_ancestor_path(row)}
99
+ for row in rows
100
+ ]
102
101
 
103
102
  def filter_fields(self, record):
104
103
  """
105
- Фильтрует запись согласно маппингу.
106
- Остаются только нужные ключи, при этом имена переименовываются.
104
+ Filter the record according to the mapping.
105
+
106
+ Only the necessary keys remain, while the names are renamed.
107
107
  """
108
108
  new_record = {}
109
109
  for file_key, model_field in self.mapping.items():
@@ -112,9 +112,13 @@ class TreeNodeImporter:
112
112
 
113
113
  def process_complex_fields(self, record):
114
114
  """
115
- Если значение поля словарь или список, упаковывает его в JSON-строку.
115
+ Pack it into a JSON string.
116
+
117
+ If the field value is a dictionary or list.
116
118
  """
117
119
  for key, value in record.items():
120
+ if isinstance(value, uuid.UUID):
121
+ record[key] = str(value)
118
122
  if isinstance(value, (list, dict)):
119
123
  try:
120
124
  record[key] = json.dumps(value, ensure_ascii=False)
@@ -125,13 +129,12 @@ class TreeNodeImporter:
125
129
 
126
130
  def cast_record_types(self, record):
127
131
  """
128
- Приводит значения полей записи к типам, определённым в модели.
129
-
130
- Для каждого поля вызывается его метод to_python(). Если значение равно nan,
131
- оно заменяется на None.
132
+ Cast the values ​​of the record fields to the types defined in the model.
132
133
 
133
- Для ForeignKey-полей (many-to-one) значение записывается в атрибут <field>_id,
134
- а исходный ключ удаляется.
134
+ For each field, its to_python() method is called. If the value is nan,
135
+ it is replaced with None.
136
+ For ForeignKey fields (many-to-one), the value is written to
137
+ the <field>_id attribute, and the original key is removed.
135
138
  """
136
139
  for field in self.model._meta.fields:
137
140
  field_name = field.name
@@ -145,10 +148,15 @@ class TreeNodeImporter:
145
148
  # Записываем в атрибут, например, tn_parent_id
146
149
  record[field.attname] = converted
147
150
  except Exception as e:
148
- logger.warning("Error converting FK field %s with value %r: %s",
149
- field_name, value, e)
151
+ logger.warning(
152
+ "Error converting FK field %s with value %r: %s",
153
+ field_name,
154
+ value,
155
+ e
156
+ )
150
157
  record[field.attname] = None
151
- # Удаляем оригинальное значение, чтобы Django не пыталась его обработать
158
+ # Удаляем оригинальное значение, чтобы Django не пыталась
159
+ # его обработать
152
160
  del record[field_name]
153
161
  else:
154
162
  if field_name in record:
@@ -159,138 +167,163 @@ class TreeNodeImporter:
159
167
  try:
160
168
  record[field_name] = field.to_python(value)
161
169
  except Exception as e:
162
- logger.warning("Error converting field %s with value %r: %s",
163
- field_name, value, e)
170
+ logger.warning(
171
+ "Error converting field %s with value %r: %s",
172
+ field_name,
173
+ value,
174
+ e
175
+ )
164
176
  record[field_name] = None
165
177
  return record
166
178
 
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
179
+ # ------------------------------------------------------------------------
181
180
 
182
- def clean(self, raw_data):
181
+ def finalize(self, raw_data):
183
182
  """
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' - список ошибок валидации.
183
+ Finalize import.
184
+
185
+ Processes raw_data, creating and updating objects by levels
186
+ (from roots to leaves) using the materialized path to calculate
187
+ the level.
188
+
189
+ Algorithm:
190
+ 1. Build a raw_by_id dictionary for quick access to records by id.
191
+ 2. For each record, calculate the materialized path:
192
+ - If tn_parent is specified and exists in raw_data, recursively get
193
+ the parent's path and add its id.
194
+ - If tn_parent is missing from raw_data, check if the parent is in
195
+ the database.
196
+ If not, generate an error.
197
+ 3. Record level = length of its materialized path.
198
+ 4. Split records into those that need to be created (if the object
199
+ with the given id is not yet in the database), and those that need
200
+ to be updated.
201
+ 5. To create, process groups by levels (sort by increasing level):
202
+ - Validate each record, if there are no errors, add the instance to
203
+ the list.
204
+ - After each level, we perform bulk_create.
205
+ 6. For updates, we collect instances, fixing fields (without id)
206
+ and perform bulk_update.
207
+
208
+ Returns a dictionary:
209
+ {
210
+ "create": [созданные объекты],
211
+ "update": [обновлённые объекты],
212
+ "errors": [список ошибок]
213
+ }
198
214
  """
199
215
  result = {
200
216
  "create": [],
201
217
  "update": [],
202
- "update_fields": [],
203
- "fk_mappings": {},
204
218
  "errors": []
205
219
  }
206
220
 
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
-
221
+ # 1. Calculate the materialized path and level for each entry.
222
+ paths = self.get_tn_orders(raw_data)
223
+ # key: record id, value: уровень (int)
224
+ levels_by_record = {rec["id"]: len(rec["path"])-1 for rec in paths}
225
+
226
+ # 2. Разбиваем записи по уровням
227
+ levels = {}
228
+ for record in raw_data:
229
+ level = levels_by_record.get(record["id"], 0)
230
+ if level not in levels:
231
+ levels[level] = []
232
+ levels[level].append(record)
233
+
234
+ records_by_level = [
235
+ sorted(
236
+ levels[key],
237
+ key=lambda x: (x.get(
238
+ "tn_parent",
239
+ x.get("tn_parent_id", 0)) or -1)
240
+ )
241
+ for key in sorted(levels.keys())
242
+ ]
243
+
244
+ # 4. We split the records into those to create and those to update.
245
+ # The list of records to update
246
+ to_update = []
247
+
248
+ for level in range(len(records_by_level)):
249
+ instances_to_create = []
250
+ for record in records_by_level[level]:
251
+ rec_id = record["id"]
252
+ if self.model.objects.filter(pk=rec_id).exists():
253
+ to_update.append(record)
254
+ else:
255
+ instance = self.model(**record)
256
+ try:
257
+ instance.full_clean()
258
+ instances_to_create.append(instance)
259
+ except Exception as e:
260
+ result["errors"].append(f"Validation error for record \
261
+ {record['id']} on level {level}: {e}")
262
+ try:
263
+ created = self.model.objects.bulk_create(instances_to_create)
264
+ result["create"].extend(created)
265
+ except Exception as e:
266
+ result["errors"].append(f"Create error on level {level}: {e}")
267
+
268
+ # 6. Processing updates: collecting instances and a list of fields
269
+ # for bulk_update
270
+ updated_instances = []
271
+ update_fields_set = set()
272
+ for record in to_update:
273
+ rec_id = record["id"]
226
274
  try:
227
- instance = self.model(**data)
275
+ instance = self.model.objects.get(pk=rec_id)
276
+ for field, value in record.items():
277
+ if field != "id":
278
+ setattr(instance, field, value)
279
+ update_fields_set.add(field)
228
280
  instance.full_clean()
229
- result["create"].append(instance)
230
- # Сохраняем значение родительского ключа для последующего обновления
231
- result["fk_mappings"][instance.id] = fk_value
281
+ updated_instances.append(instance)
232
282
  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
283
+ result["errors"].append(
284
+ f"Validation error updating record {rec_id}: {e}")
285
+ update_fields = list(update_fields_set)
286
+ if updated_instances:
287
+ try:
288
+ self.model.objects.bulk_update(updated_instances, update_fields)
289
+ result["update"].extend(updated_instances)
290
+ except Exception as e:
291
+ result["errors"].append(f"Bulk update error: {e}")
237
292
 
238
- # В данном сценарии обновление происходит только для родительской связи
239
- result["update_fields"] = ['tn_parent']
240
293
  return result
241
294
 
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)
295
+ # ------------------------------------------------------------------------
296
+
297
+ def from_csv(self):
298
+ """Import from CSV."""
299
+ text = self.get_text_content()
300
+ return list(csv.DictReader(StringIO(text)))
301
+
302
+ def from_json(self):
303
+ """Import from JSON."""
304
+ return json.loads(self.get_text_content())
305
+
306
+ def from_xlsx(self):
307
+ """Import from XLSX (Excel)."""
308
+ file_stream = BytesIO(self.file_content)
309
+ rows = []
310
+ wb = openpyxl.load_workbook(file_stream, read_only=True)
311
+ ws = wb.active
312
+ headers = [
313
+ cell.value for cell in next(ws.iter_rows(min_row=1, max_row=1))
314
+ ]
315
+ for row in ws.iter_rows(min_row=2, values_only=True):
316
+ rows.append(dict(zip(headers, row)))
317
+ return rows
318
+
319
+ def from_yaml(self):
320
+ """Import from YAML."""
321
+ return yaml.safe_load(self.get_text_content())
322
+
323
+ def from_tsv(self):
324
+ """Import from TSV."""
325
+ text = self.get_text_content()
326
+ return list(csv.DictReader(StringIO(text), delimiter="\t"))
254
327
 
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
328
 
296
329
  # The End
treenode/views.py CHANGED
@@ -14,7 +14,7 @@ Features:
14
14
  - Uses optimized QuerySets for efficient database queries.
15
15
  - Handles validation and error responses gracefully.
16
16
 
17
- Version: 2.0.0
17
+ Version: 2.0.11
18
18
  Author: Timur Kady
19
19
  Email: timurkady@yandex.com
20
20
  """
@@ -23,8 +23,9 @@ Email: timurkady@yandex.com
23
23
  from django.http import JsonResponse
24
24
  from django.views import View
25
25
  from django.apps import apps
26
- from django.db.models import Case, When, Value, IntegerField
26
+ import numpy as np
27
27
  from django.core.exceptions import ObjectDoesNotExist
28
+ from django.utils.translation import gettext_lazy as _
28
29
 
29
30
 
30
31
  class TreeNodeAutocompleteView(View):
@@ -50,14 +51,11 @@ class TreeNodeAutocompleteView(View):
50
51
  )
51
52
 
52
53
  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]
54
+ # Sorting
55
+ tn_orders = np.array([obj.tn_order for obj in queryset])
56
+ sorted_indices = np.argsort(tn_orders)
57
+ queryset_list = list(queryset.iterator())
58
+ sorted_queryset = [queryset_list[int(idx)] for idx in sorted_indices]
61
59
 
62
60
  results = [
63
61
  {
@@ -66,8 +64,17 @@ class TreeNodeAutocompleteView(View):
66
64
  "level": node.get_level(),
67
65
  "is_leaf": node.is_leaf(),
68
66
  }
69
- for node in nodes
67
+ for node in sorted_queryset
70
68
  ]
69
+
70
+ root_option = {
71
+ "id": "",
72
+ "text": _("Root"),
73
+ "level": 0,
74
+ "is_leaf": True,
75
+ }
76
+ results.insert(0, root_option)
77
+
71
78
  return JsonResponse({"results": results})
72
79
 
73
80
 
@@ -93,7 +100,6 @@ class GetChildrenCountView(View):
93
100
  try:
94
101
  parent_node = model.objects.get(pk=parent_id)
95
102
  children_count = parent_node.get_children_count()
96
- print("parent_id=", parent_id, " children_count=", children_count)
97
103
  except ObjectDoesNotExist:
98
104
  return JsonResponse(
99
105
  {"error": "Parent node not found"},
treenode/widgets.py CHANGED
@@ -13,7 +13,7 @@ Features:
13
13
  - Supports dynamic model binding for reusable implementations.
14
14
  - Integrates with Django’s form system.
15
15
 
16
- Version: 2.0.0
16
+ Version: 2.0.11
17
17
  Author: Timur Kady
18
18
  Email: timurkady@yandex.com
19
19
  """
@@ -48,18 +48,34 @@ class TreeWidget(forms.Select):
48
48
  if "placeholder" in attrs:
49
49
  del attrs["placeholder"]
50
50
 
51
- # Принудительно передаём `model`
51
+ # Force passing `model`
52
52
  if "data-forward" not in attrs:
53
- try:
53
+ model = getattr(self, "model", None)
54
+ if not model and hasattr(self.choices, "queryset"):
54
55
  model = self.choices.queryset.model
56
+ if model is None:
57
+ raise ValueError("TreeWidget: model not passed or not defined")
58
+
59
+ try:
55
60
  label = model._meta.app_label
56
61
  model_name = model._meta.model_name
57
62
  model_label = f"{label}.{model_name}"
58
63
  attrs["data-forward"] = f'{{"model": "{model_label}"}}'
64
+ except AttributeError as e:
65
+ raise ValueError(
66
+ "TreeWidget: model object is not a valid Django model"
67
+ ) from e
68
+
69
+ # Force focus to current value
70
+ if self.choices:
71
+ try:
72
+ current_value = self.value()
73
+ if current_value:
74
+ attrs["data-selected"] = str(current_value)
59
75
  except Exception:
60
- attrs["data-forward"] = '{"model": ""}'
76
+ # In case the value is missing
77
+ pass
61
78
 
62
79
  return attrs
63
80
 
64
-
65
81
  # The End