django-fast-treenode 2.0.9__py3-none-any.whl → 2.0.10__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: django-fast-treenode
3
- Version: 2.0.9
3
+ Version: 2.0.10
4
4
  Summary: Application for supporting tree (hierarchical) data structure in Django projects
5
5
  Home-page: https://github.com/TimurKady/django-fast-treenode
6
6
  Author: Timur Kady
@@ -53,11 +53,12 @@ Description-Content-Type: text/markdown
53
53
  License-File: LICENSE
54
54
  Requires-Dist: Django>=4.0
55
55
  Requires-Dist: pympler>=1.0
56
+ Requires-Dist: numpy>=2.0
56
57
  Requires-Dist: django-widget-tweaks>=1.5
57
58
  Provides-Extra: import-export
58
59
  Requires-Dist: openpyxl; extra == "import-export"
59
60
  Requires-Dist: pyyaml; extra == "import-export"
60
- Requires-Dist: pandas; extra == "import-export"
61
+ Requires-Dist: xlsxwriter; extra == "import-export"
61
62
 
62
63
  # Django-fast-treenode
63
64
  __Combination of Adjacency List and Closure Table__
@@ -1,8 +1,8 @@
1
1
  treenode/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- treenode/admin.py,sha256=N7WdbFT2hTM3mp4YrfgSVkLGFHXruUqATn-OPMKDajg,15154
2
+ treenode/admin.py,sha256=VkGuqc8KI1_SPgvM6yynyfYLEmZRfgMm2M5d9OoDxZE,14720
3
3
  treenode/apps.py,sha256=M0O9IKEnJZFfhfz12v4wksYJ-0ECyj1Cy3qXrfywos8,472
4
4
  treenode/cache.py,sha256=Z_FpaS0vTKXqAI4n1QkZ7A_ILsLU3Q8rLgerA6pYyAA,7210
5
- treenode/forms.py,sha256=imoLzr7qFlOBdeMQK12rAFp6hINpe0PS_U4-V8RRcDU,2802
5
+ treenode/forms.py,sha256=KJOVqIhMt8Z3J38LTl1exO2JyS46mZKzkQJZI-Uem9Q,3573
6
6
  treenode/managers.py,sha256=7z8GU64A2_jEonJyQDTyIpdOocaBbM352DkwZTHjdQk,10828
7
7
  treenode/urls.py,sha256=7N0d4XiI6880sc8P89eWGr-ZjmOqPorA-fWfcnviqAM,876
8
8
  treenode/version.py,sha256=-zaHoXRvTvJ0QzwA9ocYp7O38iBtIarACZbCNzwyc4s,222
@@ -11,7 +11,7 @@ treenode/widgets.py,sha256=4Q6WlPPT5fggEuTXiZ_Z40pjb46CylSp28pa0xBT_Ps,2079
11
11
  treenode/docs/Documentation,sha256=6USAESU8MuY1vlj95yY8S6T0o_0RsktGv6S_0SdSkwk,21030
12
12
  treenode/models/__init__.py,sha256=gjDwVai0jf-l0hMaeeEBTYLR-DXkxUZMLUMGGs_tnuo,83
13
13
  treenode/models/classproperty.py,sha256=IrwBWpmyjsAXpkpfDSOIMsnX6EMcbXql3mZjurHgRcw,556
14
- treenode/models/closure.py,sha256=chTa9T9yC3H_2LaZXR7KLdNh1pc50iUolut5GB4Bm-Q,5297
14
+ treenode/models/closure.py,sha256=N026nzqY62FfP7DsCH-I4j_HLGbbsrJQrQ_-rAUUboA,5361
15
15
  treenode/models/factory.py,sha256=Wt1szWhbeICPwm0-RUy9p4VovcxltHECVxTSRyCQHc8,2100
16
16
  treenode/models/proxy.py,sha256=6BFElk_NL1ARTEAikOOfMneUK5wEjofNnfXQWFSZUsA,21766
17
17
  treenode/static/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -25,17 +25,17 @@ treenode/static/treenode/js/treenode_admin.js,sha256=3fdvy1VoHb3rmzI19YXw4JPt6ZG
25
25
  treenode/templates/.gitkeep,sha256=frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN_XKdLCPjaYaY,2
26
26
  treenode/templates/admin/.gitkeep,sha256=frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN_XKdLCPjaYaY,2
27
27
  treenode/templates/admin/export_success.html,sha256=xN2D-BCH249CJB10fo_vHYUyFenQ9mFKqq7UTWcrXS4,747
28
- treenode/templates/admin/tree_node_changelist.html,sha256=ykksipIBMy2LvH6UqPuM-y00UlXAjsypMHqiMm_KlTg,261
28
+ treenode/templates/admin/tree_node_changelist.html,sha256=NudAsaO6di_cDWQDewBe-1Bay61FdlGiEFzdvfP_Wk8,314
29
29
  treenode/templates/admin/tree_node_export.html,sha256=vJxEoGI-US6VdFddxAFgL5r3MgGt6mgA43vltCsbA2k,1043
30
30
  treenode/templates/admin/tree_node_import.html,sha256=ZFoveJ08X99EGiwFCfQowXI9oS9VgcFtRLYVDIWq-Fg,969
31
31
  treenode/templates/widgets/tree_widget.css,sha256=2bEaxu1x7QJZ7erbs2SLMaxeaiMkjQXadfcDEW8wfok,551
32
32
  treenode/templates/widgets/tree_widget.html,sha256=GKcCU-B2FkkJ2BSOuXOw9e_PdYTtADcvyITEXqOlZ9Y,723
33
- treenode/utils/__init__.py,sha256=CWXGaFcNI7nEApZeMzLxx8P7qT8G2EterlV-j6ivNbc,317
33
+ treenode/utils/__init__.py,sha256=_eKk3iiiyyk4GB5dupwJxl3RPWDEHZ1DW5vHteDrbVI,343
34
34
  treenode/utils/base36.py,sha256=ydgu9hqDaK-WyS8zG-mtSWo7hJqbB4iHqkGz4-IVrb4,834
35
- treenode/utils/exporter.py,sha256=l-IEsk2fK8xe-E-Hi7SMOV7ZIwxM6mZih5-0rhvsx9E,5396
36
- treenode/utils/importer.py,sha256=n23PBFPXN-6S_Dl8Qmx56sp0eLy_lWfpxrzJqwygQf0,14190
37
- django_fast_treenode-2.0.9.dist-info/LICENSE,sha256=GiiEe4Y9oOCbn9eGuNew1mMYHU_bJWaCK9zOusnKvvU,1091
38
- django_fast_treenode-2.0.9.dist-info/METADATA,sha256=8VpCVo5aaG7VirWvqv0cflloIZaSM2xGYQhb6C9VCCQ,23242
39
- django_fast_treenode-2.0.9.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
40
- django_fast_treenode-2.0.9.dist-info/top_level.txt,sha256=fmgxHbXyx1O2MPi_9kjx8aL9L-8TmV0gre4Go8XgqFk,9
41
- django_fast_treenode-2.0.9.dist-info/RECORD,,
35
+ treenode/utils/exporter.py,sha256=hCTPnSdFdoE_s7s_iW1Xy6598fWQY5htJzPA0DpnG5s,6199
36
+ treenode/utils/importer.py,sha256=RYtl0CHfH1tZjCbhrFf0-Qp-wm4QJp-DD5Y5nyLK7yQ,12643
37
+ django_fast_treenode-2.0.10.dist-info/LICENSE,sha256=GiiEe4Y9oOCbn9eGuNew1mMYHU_bJWaCK9zOusnKvvU,1091
38
+ django_fast_treenode-2.0.10.dist-info/METADATA,sha256=kQgDgrx9hH6bdz0wwbUZ4VxUQIxZLxFd3CPbvRmmhi0,23274
39
+ django_fast_treenode-2.0.10.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
40
+ django_fast_treenode-2.0.10.dist-info/top_level.txt,sha256=fmgxHbXyx1O2MPi_9kjx8aL9L-8TmV0gre4Go8XgqFk,9
41
+ django_fast_treenode-2.0.10.dist-info/RECORD,,
treenode/admin.py CHANGED
@@ -6,7 +6,7 @@ This module provides Django admin integration for the TreeNode model.
6
6
  It includes custom tree-based sorting, optimized queries, and
7
7
  import/export functionality for hierarchical data structures.
8
8
 
9
- Version: 2.0.6
9
+ Version: 2.0.10
10
10
  Author: Timur Kady
11
11
  Email: kaduevtr@gmail.com
12
12
  """
@@ -14,11 +14,11 @@ Email: kaduevtr@gmail.com
14
14
 
15
15
  import os
16
16
  import importlib
17
+ import numpy as np
17
18
  from datetime import datetime
18
19
  from django.contrib import admin
19
20
  from django.contrib.admin.views.main import ChangeList
20
21
  from django.db import models
21
- from django.db.models import Case, When, Value, IntegerField
22
22
  from django.shortcuts import render, redirect
23
23
  from django.urls import path
24
24
  from django.utils.encoding import force_str
@@ -33,8 +33,8 @@ import logging
33
33
  logger = logging.getLogger(__name__)
34
34
 
35
35
 
36
- class NoPkDescOrderedChangeList(ChangeList):
37
- """Custom ChangeList to remove descending sorting `pk` (default)."""
36
+ class SortedChangeList(ChangeList):
37
+ """Custom ChangeList that sorts results in Python (after DB query)."""
38
38
 
39
39
  def get_ordering(self, request, queryset):
40
40
  """
@@ -44,28 +44,26 @@ class NoPkDescOrderedChangeList(ChangeList):
44
44
  Django Admin sorts by `-pk` (descending) by default.
45
45
  This method removes `-pk` so that objects are not sorted by ID.
46
46
  """
47
- rv = list(super().get_ordering(request, queryset))
48
- if '-pk' in rv:
49
- rv.remove('-pk')
50
- return tuple(rv)
47
+ # Remove the default '-pk' ordering if present.
48
+ ordering = list(super().get_ordering(request, queryset))
49
+ if '-pk' in ordering:
50
+ ordering.remove('-pk')
51
+ return tuple(ordering)
51
52
 
52
53
  def get_queryset(self, request):
53
- """
54
- Override QuerySet.
55
-
56
- Overrides data selection to optimize queries. Also adds
57
- `select_related('tn_parent')` to avoid N+1 queries.
58
- """
59
- queryset = super(NoPkDescOrderedChangeList, self).get_queryset(request)
60
- node_list = sorted(queryset, key=lambda x: x.tn_order)
61
- pk_list = [node.pk for node in node_list]
54
+ """Get QuerySet with select_related."""
55
+ return super().get_queryset(request).select_related('tn_parent')
62
56
 
63
- return queryset.filter(pk__in=pk_list).order_by(
64
- Case(*[When(pk=pk, then=Value(index))
65
- for index, pk in enumerate(pk_list)],
66
- default=Value(len(pk_list)),
67
- output_field=IntegerField())
68
- ).select_related('tn_parent')
57
+ def get_results(self, request):
58
+ """Return sorted results for ChangeList rendering."""
59
+ # Populate self.result_list with objects from the DB.
60
+ super().get_results(request)
61
+ result_list = self.result_list
62
+ # Extract tn_order values from each object.
63
+ tn_orders = np.array([obj.tn_order for obj in result_list])
64
+ # Get sorted indices based on tn_order (ascending order).
65
+ # Reorder the original result_list based on the sorted indices.
66
+ self.result_list = [result_list[int(i)] for i in np.argsort(tn_orders)]
69
67
 
70
68
 
71
69
  class TreeNodeAdminModel(admin.ModelAdmin):
@@ -111,10 +109,16 @@ class TreeNodeAdminModel(admin.ModelAdmin):
111
109
  self.list_display = [field.name for field in model._meta.fields]
112
110
 
113
111
  # Check for necessary dependencies
114
- self.import_export = all(
112
+ self.import_export = all([
115
113
  importlib.util.find_spec(pkg) is not None
116
- for pkg in ["openpyxl", "pyyaml", "pandas"]
117
- )
114
+ for pkg in ["openpyxl", "yaml", "xlsxwriter"]
115
+ ])
116
+ if not self.import_export:
117
+ check_results = [
118
+ pkg for pkg in ["openpyxl", "pyyaml", "xlsxwriter"] if importlib.util.find_spec(pkg) is not None
119
+ ]
120
+ logger.info("Packages" + ", ".join(check_results) + " are \
121
+ not installed. Export and import functions are disabled.")
118
122
 
119
123
  if self.import_export:
120
124
  from .utils import TreeNodeImporter, TreeNodeExporter
@@ -126,35 +130,12 @@ class TreeNodeAdminModel(admin.ModelAdmin):
126
130
  self.TreeNodeExporter = None
127
131
 
128
132
  def get_queryset(self, request):
129
- """Override get_ueryset()."""
133
+ """Override get_queryset to simply return an optimized queryset."""
130
134
  queryset = super().get_queryset(request)
131
-
132
- search_term = request.GET.get("q")
133
- if search_term:
134
- """
135
- print(f"Поиск: {search_term}")
136
- search_fields = self.get_search_fields(request)
137
- print(f"Поиск по полям: {search_fields}")
138
-
139
- q_objects = Q()
140
-
141
- for field in search_fields:
142
- q_objects |= Q(**{f"{field}__icontains": search_term})
143
-
144
-
145
- queryset = queryset.filter(q_objects)
146
- print(f"Найдено записей: {queryset.count()}")
147
- """
135
+ # If a search term is present, leave the queryset as is.
136
+ if request.GET.get("q"):
148
137
  return queryset
149
-
150
- node_list = sorted(queryset, key=lambda x: x.tn_order)
151
- pk_list = [node.pk for node in node_list]
152
- return queryset.filter(pk__in=pk_list).order_by(
153
- Case(*[When(pk=pk, then=Value(index))
154
- for index, pk in enumerate(pk_list)],
155
- default=Value(len(pk_list)),
156
- output_field=IntegerField())
157
- )
138
+ return queryset.select_related('tn_parent')
158
139
 
159
140
  def get_search_fields(self, request):
160
141
  """Return the correct search field."""
@@ -190,8 +171,8 @@ class TreeNodeAdminModel(admin.ModelAdmin):
190
171
  return (treenode_field_display,) + tuple(base_list_display)
191
172
 
192
173
  def get_changelist(self, request):
193
- """Get ChangeList."""
194
- return NoPkDescOrderedChangeList
174
+ """Use SortedChangeList to sort the results at render time."""
175
+ return SortedChangeList
195
176
 
196
177
  def changelist_view(self, request, extra_context=None):
197
178
  """Changelist View."""
@@ -311,7 +292,7 @@ packages are not installed."
311
292
  )
312
293
 
313
294
  # Import data from file
314
- importer = TreeNodeImporter(self.model, file, ext)
295
+ importer = self.TreeNodeImporter(self.model, file, ext)
315
296
  raw_data = importer.import_data()
316
297
  clean_result = importer.clean(raw_data)
317
298
  errors = importer.finalize_import(clean_result)
@@ -355,14 +336,14 @@ packages are not installed."
355
336
  if 'download' in request.GET:
356
337
  # Get file format
357
338
  export_format = request.GET.get('format', 'csv')
358
- # Important: This QuerySet provides a convenient ("friendly") order
359
- # of tree node output during export/import.
360
- queryset = self.get_queryset()
361
339
  # Filename
362
340
  now = force_str(datetime.now().strftime("%Y-%m-%d %H-%M"))
363
341
  filename = self.model._meta.label + " " + now
364
342
  # Init
365
- exporter = TreeNodeExporter(queryset, filename=filename)
343
+ exporter = self.TreeNodeExporter(
344
+ self.get_queryset(),
345
+ filename=filename
346
+ )
366
347
  # Export working
367
348
  response = exporter.export(export_format)
368
349
  logger.debug("DEBUG: File response generated.")
@@ -376,7 +357,7 @@ packages are not installed."
376
357
  # If the format is specified, we try to perform a test export
377
358
  # (without returning the file)
378
359
  export_format = request.GET['format']
379
- exporter = TreeNodeExporter(
360
+ exporter = self.TreeNodeExporter(
380
361
  self.model.objects.all(),
381
362
  filename=self.model._meta.model_name
382
363
  )
treenode/forms.py CHANGED
@@ -9,14 +9,51 @@ Functions:
9
9
  - __init__: Initializes the form and filters out invalid parent choices.
10
10
  - factory: Dynamically creates a form class for a given TreeNode model.
11
11
 
12
- Version: 2.0.0
12
+ Version: 2.0.10
13
13
  Author: Timur Kady
14
14
  Email: timurkady@yandex.com
15
15
  """
16
16
 
17
17
  from django import forms
18
+ import numpy as np
19
+ from django.forms.models import ModelChoiceField, ModelChoiceIterator
20
+
18
21
  from .widgets import TreeWidget
19
- from django.db.models import Case, When, Value, IntegerField
22
+
23
+
24
+ class SortedModelChoiceIterator(ModelChoiceIterator):
25
+ """Iterator Class for ModelChoiceField."""
26
+
27
+ def __iter__(self):
28
+ """Return sorted choices based on tn_order."""
29
+ qs_list = list(self.queryset.all())
30
+ # Sort objects by their tn_order using NumPy.
31
+ tn_orders = np.array([obj.tn_order for obj in qs_list])
32
+ sorted_indices = np.argsort(tn_orders)
33
+ # Iterate over sorted indices and yield (value, label) pairs.
34
+ for idx in sorted_indices:
35
+ # Cast the index to int if it is numpy.int64.
36
+ obj = qs_list[int(idx)]
37
+ yield (
38
+ self.field.prepare_value(obj),
39
+ self.field.label_from_instance(obj)
40
+ )
41
+
42
+
43
+ class SortedModelChoiceField(ModelChoiceField):
44
+ """ModelChoiceField Class for tn_paret field."""
45
+
46
+ def _get_choices(self):
47
+ """Get sorted choices."""
48
+ if hasattr(self, '_choices'):
49
+ return self._choices
50
+ return SortedModelChoiceIterator(self)
51
+
52
+ def _set_choices(self, value):
53
+ """Set choices."""
54
+ self._choices = value
55
+
56
+ choices = property(_get_choices, _set_choices)
20
57
 
21
58
 
22
59
  class TreeNodeForm(forms.ModelForm):
@@ -40,29 +77,20 @@ class TreeNodeForm(forms.ModelForm):
40
77
  """Init Method."""
41
78
  super().__init__(*args, **kwargs)
42
79
 
43
- # Get the model from the form instance
44
80
  # Use a model bound to a form
45
81
  model = self._meta.model
46
82
 
47
- # Проверяем наличие tn_parent и исключаем текущий узел и его потомков
48
83
  if "tn_parent" in self.fields and self.instance.pk:
49
- excluded_ids = [self.instance.pk] + list(
50
- self.instance.get_descendants_pks())
51
-
52
- # Sort by tn_order
84
+ excluded_ids = [self.instance.pk] + \
85
+ list(self.instance.get_descendants_pks())
53
86
  queryset = model.objects.exclude(pk__in=excluded_ids)
54
- node_list = sorted(queryset, key=lambda x: x.tn_order)
55
- pk_list = [node.pk for node in node_list]
56
- queryset = queryset.filter(pk__in=pk_list).order_by(
57
- Case(*[When(pk=pk, then=Value(index))
58
- for index, pk in enumerate(pk_list)],
59
- default=Value(len(pk_list)),
60
- output_field=IntegerField())
87
+ original_field = self.fields["tn_parent"]
88
+ self.fields["tn_parent"] = SortedModelChoiceField(
89
+ queryset=queryset,
90
+ label=self.fields["tn_parent"].label,
91
+ widget=original_field.widget
61
92
  )
62
93
 
63
- # Set QuerySet
64
- self.fields["tn_parent"].queryset = queryset
65
-
66
94
  @classmethod
67
95
  def factory(cls, model):
68
96
  """
@@ -11,7 +11,7 @@ Features:
11
11
  - Implements cached queries for improved performance.
12
12
  - Provides bulk operations for inserting, moving, and deleting nodes.
13
13
 
14
- Version: 2.0.0
14
+ Version: 2.0.1
15
15
  Author: Timur Kady
16
16
  Email: timurkady@yandex.com
17
17
  """
@@ -53,6 +53,7 @@ class ClosureModel(models.Model):
53
53
  unique_together = (("parent", "child"),)
54
54
  indexes = [
55
55
  models.Index(fields=["parent", "child"]),
56
+ models.Index(fields=["parent", "child", "depth"]),
56
57
  ]
57
58
 
58
59
  def __str__(self):
@@ -2,10 +2,12 @@
2
2
 
3
3
  {% block object-tools-items %}
4
4
  {{ block.super }}
5
+ {% if import_export_enabled %}
5
6
  <li>
6
7
  <a href="import/" class="button">Import</a>
7
8
  </li>
8
9
  <li>
9
10
  <a href="export/" class="button">Export</a>
10
11
  </li>
12
+ {% endif %}
11
13
  {% endblock %}
@@ -1,9 +1,9 @@
1
1
  import importlib
2
2
 
3
- extra = all(
4
- importlib.util.find_spec(pkg) is not None
5
- for pkg in ["openpyxl", "pyyaml", "pandas"]
6
- )
3
+ extra = all([
4
+ importlib.util.find_spec(pkg) is not None
5
+ for pkg in ["openpyxl", "yaml", "xlsxwriter"]
6
+ ])
7
7
 
8
8
  if extra:
9
9
  from .exporter import TreeNodeExporter
@@ -11,4 +11,3 @@ if extra:
11
11
  __all__ = ["TreeNodeExporter", "TreeNodeImporter"]
12
12
  else:
13
13
  __all__ = []
14
-
@@ -12,7 +12,7 @@ Features:
12
12
  - Provides optimized data extraction for QuerySets.
13
13
  - Generates downloadable files with appropriate HTTP responses.
14
14
 
15
- Version: 2.0.0
15
+ Version: 2.0.10
16
16
  Author: Timur Kady
17
17
  Email: timurkady@yandex.com
18
18
  """
@@ -21,7 +21,8 @@ Email: timurkady@yandex.com
21
21
  import csv
22
22
  import json
23
23
  import yaml
24
- import pandas as pd
24
+ import xlsxwriter
25
+ import numpy as np
25
26
  from io import BytesIO
26
27
  from django.http import HttpResponse
27
28
  import logging
@@ -67,10 +68,16 @@ class TreeNodeExporter:
67
68
  record[key] = None
68
69
  return record
69
70
 
71
+ def get_sorted_queryset(self):
72
+ """Sort queryset by tn_order."""
73
+ queryset_list = list(self.queryset)
74
+ tn_orders = np.array([obj.tn_order for obj in queryset_list])
75
+ return [queryset_list[int(i)] for i in np.argsort(tn_orders)]
76
+
70
77
  def get_data(self):
71
78
  """Return a list of data from QuerySet as dictionaries."""
72
79
  data = []
73
- for obj in self.queryset:
80
+ for obj in self.get_sorted_queryset():
74
81
  record = {}
75
82
  for field in self.fields:
76
83
  value = getattr(obj, field, None)
@@ -83,7 +90,7 @@ class TreeNodeExporter:
83
90
  ensure_ascii=False)
84
91
  elif field_object.many_to_one:
85
92
  # ForeignKey - save as ID
86
- record[field] = value.id if value else None
93
+ record[field] = getattr(value, "id", None)
87
94
  else:
88
95
  record[field] = value
89
96
  else:
@@ -105,22 +112,40 @@ class TreeNodeExporter:
105
112
  """Export to JSON with UUID serialization handling."""
106
113
  response = HttpResponse(content_type="application/octet-stream")
107
114
  response["Content-Disposition"] = f'attachment; filename="{self.filename}.json"'
108
- json.dump(self.get_data(), response,
109
- ensure_ascii=False, indent=4, default=str)
115
+ json.dump(
116
+ self.get_data(),
117
+ response,
118
+ ensure_ascii=False,
119
+ indent=4,
120
+ default=str
121
+ )
110
122
  return response
111
123
 
112
124
  def to_xlsx(self):
113
125
  """Export to XLSX."""
114
126
  response = HttpResponse(
115
- content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
127
+ content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
128
+ )
116
129
  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
130
+
131
+ data = self.get_data()
132
+ output = BytesIO()
133
+ workbook = xlsxwriter.Workbook(output)
134
+ worksheet = workbook.add_worksheet()
135
+
136
+ # Записываем заголовки
137
+ headers = list(data[0].keys()) if data else []
138
+ for col_num, header in enumerate(headers):
139
+ worksheet.write(0, col_num, header)
140
+
141
+ # Записываем строки данных
142
+ for row_num, row in enumerate(data, start=1):
143
+ for col_num, key in enumerate(headers):
144
+ worksheet.write(row_num, col_num, row[key])
145
+
146
+ workbook.close()
147
+ output.seek(0)
148
+ return response.write(output.read())
124
149
 
125
150
  def to_yaml(self):
126
151
  """Export to YAML with proper attachment handling."""
@@ -135,7 +160,10 @@ class TreeNodeExporter:
135
160
  response = HttpResponse(content_type="application/octet-stream")
136
161
  response["Content-Disposition"] = f'attachment; filename="{self.filename}.tsv"'
137
162
  writer = csv.DictWriter(
138
- response, fieldnames=self.fields, delimiter=" ")
163
+ response,
164
+ fieldnames=self.fields,
165
+ delimiter=" "
166
+ )
139
167
  writer.writeheader()
140
168
  writer.writerows(self.get_data())
141
169
  return response
@@ -21,8 +21,8 @@ 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
26
  from io import BytesIO, StringIO
27
27
  from django.db import transaction
28
28
  import logging
@@ -55,13 +55,13 @@ class TreeNodeImporter:
55
55
  self.file_content = file.read()
56
56
 
57
57
  def get_text_content(self):
58
- """Возвращает содержимое файла в виде строки."""
58
+ """Return the contents of a file as a string."""
59
59
  if isinstance(self.file_content, bytes):
60
60
  return self.file_content.decode("utf-8")
61
61
  return self.file_content
62
62
 
63
63
  def import_data(self):
64
- """Импортирует данные и возвращает список словарей."""
64
+ """Import data and returns a list of dictionaries."""
65
65
  importers = {
66
66
  "csv": self.from_csv,
67
67
  "json": self.from_json,
@@ -73,37 +73,46 @@ class TreeNodeImporter:
73
73
  raise ValueError("Unsupported import format")
74
74
 
75
75
  raw_data = importers[self.format]()
76
- # Обработка: фильтрация полей, упаковка сложных значений и приведение типов
76
+ # Processing: field filtering, complex value packing and type casting
77
77
  processed_data = self.process_records(raw_data)
78
78
  return processed_data
79
79
 
80
80
  def from_csv(self):
81
- """Импорт из CSV."""
81
+ """Import from CSV."""
82
82
  text = self.get_text_content()
83
83
  return list(csv.DictReader(StringIO(text)))
84
84
 
85
85
  def from_json(self):
86
- """Импорт из JSON."""
86
+ """Import from JSON."""
87
87
  return json.loads(self.get_text_content())
88
88
 
89
89
  def from_xlsx(self):
90
- """Импорт из XLSX (Excel)."""
91
- df = pd.read_excel(BytesIO(self.file_content))
92
- return df.to_dict(orient="records")
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
93
101
 
94
102
  def from_yaml(self):
95
- """Импорт из YAML."""
103
+ """Import from YAML."""
96
104
  return yaml.safe_load(self.get_text_content())
97
105
 
98
106
  def from_tsv(self):
99
- """Импорт из TSV."""
107
+ """Import from TSV."""
100
108
  text = self.get_text_content()
101
109
  return list(csv.DictReader(StringIO(text), delimiter="\t"))
102
110
 
103
111
  def filter_fields(self, record):
104
112
  """
105
- Фильтрует запись согласно маппингу.
106
- Остаются только нужные ключи, при этом имена переименовываются.
113
+ Filter the record according to the mapping.
114
+
115
+ Only the necessary keys remain, while the names are renamed.
107
116
  """
108
117
  new_record = {}
109
118
  for file_key, model_field in self.mapping.items():
@@ -112,7 +121,9 @@ class TreeNodeImporter:
112
121
 
113
122
  def process_complex_fields(self, record):
114
123
  """
115
- Если значение поля словарь или список, упаковывает его в JSON-строку.
124
+ Pack it into a JSON string.
125
+
126
+ If the field value is a dictionary or list.
116
127
  """
117
128
  for key, value in record.items():
118
129
  if isinstance(value, (list, dict)):
@@ -125,13 +136,12 @@ class TreeNodeImporter:
125
136
 
126
137
  def cast_record_types(self, record):
127
138
  """
128
- Приводит значения полей записи к типам, определённым в модели.
139
+ Casts the values ​​of the record fields to the types defined in the model.
129
140
 
130
- Для каждого поля вызывается его метод to_python(). Если значение равно nan,
131
- оно заменяется на None.
132
-
133
- Для ForeignKey-полей (many-to-one) значение записывается в атрибут <field>_id,
134
- а исходный ключ удаляется.
141
+ For each field, its to_python() method is called. If the value is nan,
142
+ it is replaced with None.
143
+ For ForeignKey fields (many-to-one), the value is written to
144
+ the <field>_id attribute, and the original key is removed.
135
145
  """
136
146
  for field in self.model._meta.fields:
137
147
  field_name = field.name
@@ -166,10 +176,11 @@ class TreeNodeImporter:
166
176
 
167
177
  def process_records(self, records):
168
178
  """
169
- Обрабатывает список записей:
170
- 1. Фильтрует поля по маппингу.
171
- 2. Упаковывает сложные (вложенные) данные в JSON.
172
- 3. Приводит значения каждого поля к типам, определённым в модели.
179
+ Process a list of records.
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.
173
184
  """
174
185
  processed = []
175
186
  for record in records:
@@ -181,20 +192,24 @@ class TreeNodeImporter:
181
192
 
182
193
  def clean(self, raw_data):
183
194
  """
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' - список ошибок валидации.
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.
198
213
  """
199
214
  result = {
200
215
  "create": [],
@@ -211,7 +226,7 @@ class TreeNodeImporter:
211
226
  logger.warning(error_message)
212
227
  continue
213
228
 
214
- # Сохраняем значение родительской связи и удаляем его из данных
229
+ # Save the parent relationship value and remove it from the data
215
230
  fk_value = None
216
231
  if 'tn_parent' in data:
217
232
  fk_value = data['tn_parent']
@@ -220,14 +235,14 @@ class TreeNodeImporter:
220
235
  fk_value = data['tn_parent_id']
221
236
  del data['tn_parent_id']
222
237
 
223
- # Приводим значения к типам модели
238
+ # Convert values ​​to model types
224
239
  data = self.cast_record_types(data)
225
240
 
226
241
  try:
227
242
  instance = self.model(**data)
228
243
  instance.full_clean()
229
244
  result["create"].append(instance)
230
- # Сохраняем значение родительского ключа для последующего обновления
245
+ # Save the parent key value for future update
231
246
  result["fk_mappings"][instance.id] = fk_value
232
247
  except Exception as e:
233
248
  error_message = f"Validation error creating {data}: {e}"
@@ -235,16 +250,17 @@ class TreeNodeImporter:
235
250
  logger.warning(error_message)
236
251
  continue
237
252
 
238
- # В данном сценарии обновление происходит только для родительской связи
253
+ # In this scenario, the update occurs only for the parent relationship
239
254
  result["update_fields"] = ['tn_parent']
240
255
  return result
241
256
 
242
257
  def save_data(self, create, update, fields):
243
258
  """
244
- Сохраняет объекты в базу в рамках атомарной транзакции.
245
- :param create: список объектов для создания.
246
- :param update: список объектов для обновления.
247
- :param fields: список полей, которые обновляются (для bulk_update).
259
+ Save objects to the database as part of an atomic transaction.
260
+
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).
248
264
  """
249
265
  with transaction.atomic():
250
266
  if update:
@@ -254,12 +270,14 @@ class TreeNodeImporter:
254
270
 
255
271
  def update_parent_relations(self, fk_mappings):
256
272
  """
257
- Обновляет поле tn_parent для объектов, используя сохранённые fk_mappings.
258
- :param fk_mappings: словарь {id_объекта: значение родительского ключа из исходных данных}
273
+ Update the tn_parent field for objects using the saved fk_mappings.
274
+
275
+ :param fk_mappings: dictionary {object_id: parent key value from
276
+ the source data}
259
277
  """
260
278
  instances_to_update = []
261
279
  for obj_id, parent_id in fk_mappings.items():
262
- # Если родитель не указан, пропускаем
280
+ # If parent is not specified, skip
263
281
  if not parent_id:
264
282
  continue
265
283
  try:
@@ -269,28 +287,35 @@ class TreeNodeImporter:
269
287
  instances_to_update.append(instance)
270
288
  except self.model.DoesNotExist:
271
289
  logger.warning(
272
- "Parent with id %s not found for instance %s", parent_id, obj_id)
290
+ "Parent with id %s not found for instance %s",
291
+ parent_id,
292
+ obj_id
293
+ )
273
294
  if instances_to_update:
274
295
  update_fields = ['tn_parent']
275
296
  self.model.objects.bulk_update(
276
297
  instances_to_update, update_fields, batch_size=1000)
277
298
 
278
- # Если захочешь объединить операции сохранения и обновления родителей,
279
- # можно добавить метод, который вызовет save_data и update_parent_relations последовательно.
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
+
280
303
  def finalize_import(self, clean_result):
281
304
  """
282
- Финализирует импорт: сохраняет новые объекты и обновляет родительские связи.
283
- :param clean_result: словарь, возвращённый методом clean.
305
+ Finalize the import: saves new objects and updates parent links.
306
+
307
+ :param clean_result: dictionary returned by the clean method.
284
308
  """
285
- # Если есть ошибки можно прервать импорт или вернуть их для обработки
309
+ # If there are errors, you can interrupt the import or return them
310
+ # for processing
286
311
  if clean_result["errors"]:
287
312
  return clean_result["errors"]
288
313
 
289
- # Сначала выполняем массовое создание
314
+ # First we do a bulk creation
290
315
  self.save_data(
291
316
  clean_result["create"], clean_result["update"], clean_result["update_fields"])
292
- # Затем обновляем родительские связи
317
+ # Then we update the parent links
293
318
  self.update_parent_relations(clean_result["fk_mappings"])
294
- return None # Или вернуть успешное сообщение
319
+ return None # Or return a success message
295
320
 
296
321
  # The End