django-fast-treenode 2.0.10__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
  """
@@ -23,8 +23,9 @@ import json
23
23
  import yaml
24
24
  import openpyxl
25
25
  import math
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__)
@@ -73,40 +74,30 @@ class TreeNodeImporter:
73
74
  raise ValueError("Unsupported import format")
74
75
 
75
76
  raw_data = importers[self.format]()
76
- # Processing: field filtering, complex value packing and type casting
77
- processed_data = self.process_records(raw_data)
78
- return processed_data
79
77
 
80
- def from_csv(self):
81
- """Import from 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
- """Import from JSON."""
87
- return json.loads(self.get_text_content())
86
+ return processed
88
87
 
89
- def from_xlsx(self):
90
- """Import from XLSX (Excel)."""
91
- file_stream = BytesIO(self.file_content)
92
- rows = []
93
- wb = openpyxl.load_workbook(file_stream, read_only=True)
94
- ws = wb.active
95
- headers = [
96
- cell.value for cell in next(ws.iter_rows(min_row=1, max_row=1))
97
- ]
98
- for row in ws.iter_rows(min_row=2, values_only=True):
99
- rows.append(dict(zip(headers, row)))
100
- return rows
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}
101
92
 
102
- def from_yaml(self):
103
- """Import from YAML."""
104
- 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"]]
105
96
 
106
- def from_tsv(self):
107
- """Import from TSV."""
108
- text = self.get_text_content()
109
- 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
+ ]
110
101
 
111
102
  def filter_fields(self, record):
112
103
  """
@@ -126,6 +117,8 @@ class TreeNodeImporter:
126
117
  If the field value is a dictionary or list.
127
118
  """
128
119
  for key, value in record.items():
120
+ if isinstance(value, uuid.UUID):
121
+ record[key] = str(value)
129
122
  if isinstance(value, (list, dict)):
130
123
  try:
131
124
  record[key] = json.dumps(value, ensure_ascii=False)
@@ -136,11 +129,11 @@ class TreeNodeImporter:
136
129
 
137
130
  def cast_record_types(self, record):
138
131
  """
139
- Casts the values ​​of the record fields to the types defined in the model.
132
+ Cast the values ​​of the record fields to the types defined in the model.
140
133
 
141
134
  For each field, its to_python() method is called. If the value is nan,
142
135
  it is replaced with None.
143
- For ForeignKey fields (many-to-one), the value is written to
136
+ For ForeignKey fields (many-to-one), the value is written to
144
137
  the <field>_id attribute, and the original key is removed.
145
138
  """
146
139
  for field in self.model._meta.fields:
@@ -155,10 +148,15 @@ class TreeNodeImporter:
155
148
  # Записываем в атрибут, например, tn_parent_id
156
149
  record[field.attname] = converted
157
150
  except Exception as e:
158
- logger.warning("Error converting FK field %s with value %r: %s",
159
- 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
+ )
160
157
  record[field.attname] = None
161
- # Удаляем оригинальное значение, чтобы Django не пыталась его обработать
158
+ # Удаляем оригинальное значение, чтобы Django не пыталась
159
+ # его обработать
162
160
  del record[field_name]
163
161
  else:
164
162
  if field_name in record:
@@ -169,153 +167,163 @@ class TreeNodeImporter:
169
167
  try:
170
168
  record[field_name] = field.to_python(value)
171
169
  except Exception as e:
172
- logger.warning("Error converting field %s with value %r: %s",
173
- field_name, value, e)
170
+ logger.warning(
171
+ "Error converting field %s with value %r: %s",
172
+ field_name,
173
+ value,
174
+ e
175
+ )
174
176
  record[field_name] = None
175
177
  return record
176
178
 
177
- def process_records(self, records):
178
- """
179
- Process a list of records.
179
+ # ------------------------------------------------------------------------
180
180
 
181
- 1. Filters fields by mapping.
182
- 2. Packs complex (nested) data into JSON.
183
- 3. Converts the values ​​of each field to the types defined in the model.
181
+ def finalize(self, raw_data):
184
182
  """
185
- processed = []
186
- for record in records:
187
- filtered = self.filter_fields(record)
188
- filtered = self.process_complex_fields(filtered)
189
- filtered = self.cast_record_types(filtered)
190
- processed.append(filtered)
191
- return processed
192
-
193
- def clean(self, raw_data):
194
- """
195
- Validat and prepare data for bulk saving of objects.
196
-
197
- For each record:
198
- - The presence of a unique field 'id' is checked.
199
- - The value of the parent relationship (tn_parent or tn_parent_id)
200
- is saved separately and removed from the data.
201
- - Casts the data to model types.
202
- - Attempts to create a model instance with validation via full_clean().
203
-
204
- Returns a dictionary with the following keys:
205
- 'create' - a list of objects to create,
206
- 'update' - a list of objects to update (in this case, we leave
207
- it empty),
208
- 'update_fields' - a list of fields to update (for example,
209
- ['tn_parent']),
210
- 'fk_mappings' - a dictionary of {object_id: parent key value from
211
- the source data},
212
- 'errors' - a list of validation errors.
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
+ }
213
214
  """
214
215
  result = {
215
216
  "create": [],
216
217
  "update": [],
217
- "update_fields": [],
218
- "fk_mappings": {},
219
218
  "errors": []
220
219
  }
221
220
 
222
- for data in raw_data:
223
- if 'id' not in data:
224
- error_message = f"Missing unique field 'id' in record: {data}"
225
- result["errors"].append(error_message)
226
- logger.warning(error_message)
227
- continue
228
-
229
- # Save the parent relationship value and remove it from the data
230
- fk_value = None
231
- if 'tn_parent' in data:
232
- fk_value = data['tn_parent']
233
- del data['tn_parent']
234
- elif 'tn_parent_id' in data:
235
- fk_value = data['tn_parent_id']
236
- del data['tn_parent_id']
237
-
238
- # Convert values ​​to model types
239
- data = self.cast_record_types(data)
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
+ ]
240
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"]
241
274
  try:
242
- 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)
243
280
  instance.full_clean()
244
- result["create"].append(instance)
245
- # Save the parent key value for future update
246
- result["fk_mappings"][instance.id] = fk_value
281
+ updated_instances.append(instance)
247
282
  except Exception as e:
248
- error_message = f"Validation error creating {data}: {e}"
249
- result["errors"].append(error_message)
250
- logger.warning(error_message)
251
- 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}")
252
292
 
253
- # In this scenario, the update occurs only for the parent relationship
254
- result["update_fields"] = ['tn_parent']
255
293
  return result
256
294
 
257
- def save_data(self, create, update, fields):
258
- """
259
- Save objects to the database as part of an atomic transaction.
295
+ # ------------------------------------------------------------------------
260
296
 
261
- :param create: list of objects to create.
262
- :param update: list of objects to update.
263
- :param fields: list of fields to update (for bulk_update).
264
- """
265
- with transaction.atomic():
266
- if update:
267
- self.model.objects.bulk_update(update, fields, batch_size=1000)
268
- if create:
269
- self.model.objects.bulk_create(create, batch_size=1000)
297
+ def from_csv(self):
298
+ """Import from CSV."""
299
+ text = self.get_text_content()
300
+ return list(csv.DictReader(StringIO(text)))
270
301
 
271
- def update_parent_relations(self, fk_mappings):
272
- """
273
- Update the tn_parent field for objects using the saved fk_mappings.
302
+ def from_json(self):
303
+ """Import from JSON."""
304
+ return json.loads(self.get_text_content())
274
305
 
275
- :param fk_mappings: dictionary {object_id: parent key value from
276
- the source data}
277
- """
278
- instances_to_update = []
279
- for obj_id, parent_id in fk_mappings.items():
280
- # If parent is not specified, skip
281
- if not parent_id:
282
- continue
283
- try:
284
- instance = self.model.objects.get(pk=obj_id)
285
- parent_instance = self.model.objects.get(pk=parent_id)
286
- instance.tn_parent = parent_instance
287
- instances_to_update.append(instance)
288
- except self.model.DoesNotExist:
289
- logger.warning(
290
- "Parent with id %s not found for instance %s",
291
- parent_id,
292
- obj_id
293
- )
294
- if instances_to_update:
295
- update_fields = ['tn_parent']
296
- self.model.objects.bulk_update(
297
- instances_to_update, update_fields, batch_size=1000)
298
-
299
- # If you want to combine the save and update parent operations,
300
- # you can add a method that calls save_data and update_parent_relations
301
- # sequentially.
302
-
303
- def finalize_import(self, clean_result):
304
- """
305
- Finalize the import: saves new objects and updates parent links.
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"))
306
327
 
307
- :param clean_result: dictionary returned by the clean method.
308
- """
309
- # If there are errors, you can interrupt the import or return them
310
- # for processing
311
- if clean_result["errors"]:
312
- return clean_result["errors"]
313
-
314
- # First we do a bulk creation
315
- self.save_data(
316
- clean_result["create"], clean_result["update"], clean_result["update_fields"])
317
- # Then we update the parent links
318
- self.update_parent_relations(clean_result["fk_mappings"])
319
- return None # Or return a success message
320
328
 
321
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