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.
treenode/models/proxy.py CHANGED
@@ -13,7 +13,7 @@ Features:
13
13
  - Provides a caching mechanism for performance optimization.
14
14
  - Includes methods for tree traversal, manipulation, and serialization.
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
  """
@@ -131,14 +131,14 @@ class TreeNodeModel(models.Model, metaclass=TreeFactory):
131
131
 
132
132
  def get_ancestors_queryset(self, include_self=True, depth=None):
133
133
  """Get the ancestors queryset (ordered from parent to root)."""
134
- return self.closure_model.get_ancestors_queryset(
135
- self, include_self, depth)
134
+ ancestors_pks = self.get_ancestors_pks(include_self, depth)
135
+ result = self._meta.model.objects.filter(pk__in=ancestors_pks)
136
+ return result
136
137
 
137
138
  def get_ancestors(self, include_self=True, depth=None):
138
139
  """Get a list with all ancestors (ordered from root to self/parent)."""
139
- return list(
140
- self.get_ancestors_queryset(include_self, depth).iterator()
141
- )
140
+ queryset = self.get_ancestors_queryset(include_self, depth)
141
+ return list(queryset.iterator())
142
142
 
143
143
  def get_ancestors_count(self, include_self=True, depth=None):
144
144
  """Get the ancestors count."""
@@ -146,8 +146,8 @@ class TreeNodeModel(models.Model, metaclass=TreeFactory):
146
146
 
147
147
  def get_ancestors_pks(self, include_self=True, depth=None):
148
148
  """Get the ancestors pks list."""
149
- qs = self.get_ancestors_queryset(include_self, depth).only('pk')
150
- return [ch.pk for ch in qs] if qs else []
149
+ pks = self.closure_model.get_ancestors_pks(self, include_self, depth)
150
+ return pks
151
151
 
152
152
  # Children --------------------
153
153
 
@@ -172,15 +172,14 @@ class TreeNodeModel(models.Model, metaclass=TreeFactory):
172
172
 
173
173
  def get_descendants_queryset(self, include_self=False, depth=None):
174
174
  """Get the descendants queryset."""
175
- return self.closure_model.get_descendants_queryset(
176
- self, include_self, depth
177
- )
175
+ descendants_pks = self.get_descendants_pks(include_self, depth)
176
+ result = self._meta.model.objects.filter(pk__in=descendants_pks)
177
+ return result
178
178
 
179
179
  def get_descendants(self, include_self=False, depth=None):
180
180
  """Get a list containing all descendants."""
181
- return list(
182
- self.get_descendants_queryset(include_self, depth).iterator()
183
- )
181
+ queryset = self.get_descendants_queryset(include_self, depth).iterator()
182
+ return list(queryset)
184
183
 
185
184
  def get_descendants_count(self, include_self=False, depth=None):
186
185
  """Get the descendants count."""
@@ -188,8 +187,8 @@ class TreeNodeModel(models.Model, metaclass=TreeFactory):
188
187
 
189
188
  def get_descendants_pks(self, include_self=False, depth=None):
190
189
  """Get the descendants pks list."""
191
- qs = self.get_descendants_queryset(include_self, depth)
192
- return [ch.pk for ch in qs] if qs else []
190
+ pks = self.closure_model.get_descendants_pks(self, include_self, depth)
191
+ return pks
193
192
 
194
193
  # Siblings --------------------
195
194
 
@@ -216,9 +215,16 @@ class TreeNodeModel(models.Model, metaclass=TreeFactory):
216
215
 
217
216
  # -----------------------------
218
217
 
219
- def get_breadcrumbs(self, attr=None):
218
+ def get_breadcrumbs(self, attr='pk'):
220
219
  """Get the breadcrumbs to current node (self, included)."""
221
- return self.closure_model.get_breadcrumbs(self, attr)
220
+ queryset = self.get_ancestors_queryset(include_self=True)
221
+
222
+ breadcrumbs = [
223
+ getattr(item, attr)
224
+ if hasattr(item, attr) else None
225
+ for item in queryset
226
+ ]
227
+ return breadcrumbs
222
228
 
223
229
  def get_depth(self):
224
230
  """Get the node depth (self, how many levels of descendants)."""
@@ -238,7 +244,7 @@ class TreeNodeModel(models.Model, metaclass=TreeFactory):
238
244
 
239
245
  def get_order(self):
240
246
  """Return the materialized path."""
241
- path = self.closure_model.get_breadcrumbs(self, attr='tn_priority')
247
+ path = self.get_breadcrumbs(attr='tn_priority')
242
248
  segments = [to_base36(i).rjust(6, '0') for i in path]
243
249
  return ''.join(segments)
244
250
 
@@ -252,8 +258,18 @@ class TreeNodeModel(models.Model, metaclass=TreeFactory):
252
258
 
253
259
  def get_path(self, prefix='', suffix='', delimiter='.', format_str=''):
254
260
  """Return Materialized Path of node."""
255
- path = self.closure_model.get_path(self, delimiter, format_str)
256
- return prefix+path+suffix
261
+ priorities = self.get_breadcrumbs(attr='tn_priority')
262
+ # Проверяем, что список не пуст
263
+ if not priorities or all(p is None for p in priorities):
264
+ return prefix + suffix
265
+
266
+ str_ = "{%s}" % format_str
267
+ path = delimiter.join([
268
+ str_.format(p)
269
+ for p in priorities
270
+ if p is not None
271
+ ])
272
+ return prefix + path + suffix
257
273
 
258
274
  @cached_method
259
275
  def get_parent(self):
@@ -369,7 +385,9 @@ class TreeNodeModel(models.Model, metaclass=TreeFactory):
369
385
 
370
386
  # --- 2. Check mode _-------------------------------------------------
371
387
  # If the object already exists in the DB, we'll extract its old parent
372
- if not is_new:
388
+ if is_new:
389
+ force_insert = True
390
+ else:
373
391
  ql = model.objects.filter(pk=self.pk).values_list(
374
392
  'tn_parent',
375
393
  'tn_priority').first()
@@ -395,7 +413,8 @@ class TreeNodeModel(models.Model, metaclass=TreeFactory):
395
413
 
396
414
  # If the parent has changed, we move it
397
415
  if (old_parent != self.tn_parent):
398
- closure_model.move_node(self)
416
+ subtree_nodes = self.get_descendants(include_self=True)
417
+ self.closure_model.move_node(subtree_nodes)
399
418
 
400
419
  # --- 5. Update siblings ---------------------------------------------
401
420
  if is_new or is_move:
@@ -582,7 +601,7 @@ class TreeNodeModel(models.Model, metaclass=TreeFactory):
582
601
  # Save changes
583
602
  model = self._meta.model
584
603
  with transaction.atomic():
585
- model.objects.bulk_update(sorted_siblings, ('tn_priority',), 1000)
604
+ model.objects.bulk_update(sorted_siblings, ('tn_priority',))
586
605
  super().save(update_fields=['tn_priority'])
587
606
  model.clear_cache()
588
607
 
@@ -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,7 +1,20 @@
1
1
  {% extends "admin/base_site.html" %}
2
+ {% load static %}
3
+
4
+ {% block extrahead %}
5
+ <link rel="stylesheet" type="text/css" href="{% static 'admin/css/forms.css' %}">
6
+ {% endblock %}
7
+
2
8
  {% block content %}
3
9
  <div class="module">
4
10
  <h2>Import Data</h2>
11
+
12
+ {% if message %}
13
+ <div class="successnote">
14
+ <p>{{ message }}</p>
15
+ </div>
16
+ {% endif %}
17
+
5
18
  {% if errors %}
6
19
  <div class="errornote">
7
20
  <p>Errors occurred while importing the data:</p>
@@ -12,16 +25,21 @@
12
25
  </ul>
13
26
  </div>
14
27
  {% endif %}
15
- <form method="post" enctype="multipart/form-data">
28
+
29
+ <form id="importForm" method="post" enctype="multipart/form-data" style="margin: 20px 0;">
16
30
  {% csrf_token %}
17
- <div>
18
- <label for="file">Choose file:</label>
19
- <input type="file" name="file" id="file" required>
20
- </div>
21
- <div class="form-group" style="margin-top: 35px;">
22
- <button type="submit" class="button">Import</button>
23
- <a href=".." class="button cancel">Cancel</a>
31
+ <fieldset class="module aligned">
32
+ <div class="form-row field-tn_parent">
33
+ <label for="file">Choose file:</label>
34
+ <input type="file" name="file" id="file" required>
35
+ </div>
36
+ </fieldset>
37
+
38
+ <div class="submit-row" style="margin-top: 35px;">
39
+ <input id="importBtn" type="submit" value="Import" name="_save">
40
+ <input type="button" value="Cancel" class="button cancel" onclick="window.location.href='..';">
24
41
  </div>
25
42
  </form>
43
+
26
44
  </div>
27
- {% endblock %}
45
+ {% endblock %}
@@ -0,0 +1,32 @@
1
+ {% extends "admin/base_site.html" %}
2
+ {% block content %}
3
+ <div class="module">
4
+ <h2>Import Results</h2>
5
+
6
+ <ul class="messagelist">
7
+ <li class="success"><strong>Created:</strong> {{ created_count }} records</li>
8
+ <li class="success"><strong>Updated:</strong> {{ updated_count }} records</li>
9
+ </ul>
10
+
11
+ {% if errors %}
12
+ <div class="errornote">
13
+ <p>Errors occurred while importing the data:</p>
14
+ <ul>
15
+ {% for error in errors %}
16
+ <li>{{ error }}</li>
17
+ {% endfor %}
18
+ </ul>
19
+ </div>
20
+ {% endif %}
21
+
22
+ <div style="margin-top: 20px;">
23
+ <button type="button" class="button" onclick="redirectAfterImport()">Finish</button>
24
+ </div>
25
+ </div>
26
+
27
+ <script>
28
+ function redirectAfterImport() {
29
+ window.location.href = window.location.pathname.replace("import/", "") + "?import_done=1";
30
+ }
31
+ </script>
32
+ {% 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.11
16
16
  Author: Timur Kady
17
17
  Email: timurkady@yandex.com
18
18
  """
@@ -21,7 +21,9 @@ 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
26
+ import uuid
25
27
  from io import BytesIO
26
28
  from django.http import HttpResponse
27
29
  import logging
@@ -42,6 +44,7 @@ class TreeNodeExporter:
42
44
  self.queryset = queryset
43
45
  self.filename = filename
44
46
  self.fields = [field.name for field in queryset.model._meta.fields]
47
+ self.fields = self.get_ordered_fields()
45
48
 
46
49
  def export(self, format):
47
50
  """Determine the export format and calls the corresponding method."""
@@ -59,7 +62,9 @@ class TreeNodeExporter:
59
62
  def process_complex_fields(self, record):
60
63
  """Convert complex fields (lists, dictionaries) into JSON strings."""
61
64
  for key, value in record.items():
62
- if isinstance(value, (list, dict)):
65
+ if isinstance(value, uuid.UUID):
66
+ record[key] = str(value)
67
+ elif isinstance(value, (list, dict)):
63
68
  try:
64
69
  record[key] = json.dumps(value, ensure_ascii=False)
65
70
  except Exception as e:
@@ -67,10 +72,29 @@ class TreeNodeExporter:
67
72
  record[key] = None
68
73
  return record
69
74
 
75
+ def get_ordered_fields(self):
76
+ """Return fields in the desired order.
77
+
78
+ Order: id, tn_parent, tn_priority, then the rest.
79
+ """
80
+ required_fields = ["id", "tn_parent", "tn_priority"]
81
+ other_fields = [
82
+ field for field in self.fields if field not in required_fields]
83
+ return required_fields + other_fields
84
+
85
+ def get_sorted_queryset(self):
86
+ """Quick sort queryset by tn_order."""
87
+ queryset = self.queryset
88
+ tn_orders = np.array([obj.tn_order for obj in queryset])
89
+ sorted_indices = np.argsort(tn_orders)
90
+ queryset_list = list(queryset.iterator())
91
+ result = [queryset_list[int(idx)] for idx in sorted_indices]
92
+ return result
93
+
70
94
  def get_data(self):
71
95
  """Return a list of data from QuerySet as dictionaries."""
72
96
  data = []
73
- for obj in self.queryset:
97
+ for obj in self.get_sorted_queryset():
74
98
  record = {}
75
99
  for field in self.fields:
76
100
  value = getattr(obj, field, None)
@@ -83,7 +107,7 @@ class TreeNodeExporter:
83
107
  ensure_ascii=False)
84
108
  elif field_object.many_to_one:
85
109
  # ForeignKey - save as ID
86
- record[field] = value.id if value else None
110
+ record[field] = getattr(value, "id", None)
87
111
  else:
88
112
  record[field] = value
89
113
  else:
@@ -93,49 +117,78 @@ class TreeNodeExporter:
93
117
  return data
94
118
 
95
119
  def to_csv(self):
96
- """Export to CSV with proper attachment handling."""
97
- response = HttpResponse(content_type="text/csv")
120
+ """Export to CSV with proper UTF-8 encoding."""
121
+ response = HttpResponse(content_type="text/csv; charset=utf-8")
98
122
  response["Content-Disposition"] = f'attachment; filename="{self.filename}.csv"'
123
+ response.write("\ufeff") # Добавляем BOM для Excel
124
+
99
125
  writer = csv.DictWriter(response, fieldnames=self.fields)
100
126
  writer.writeheader()
101
- writer.writerows(self.get_data())
127
+ for row in self.get_data():
128
+ writer.writerow({key: str(value)
129
+ for key, value in row.items()}) # Приводим к строкам
130
+
102
131
  return response
103
132
 
104
133
  def to_json(self):
105
- """Export to JSON with UUID serialization handling."""
106
- response = HttpResponse(content_type="application/octet-stream")
134
+ """Export to JSON with proper UTF-8 encoding."""
135
+ response = HttpResponse(content_type="application/json; charset=utf-8")
107
136
  response["Content-Disposition"] = f'attachment; filename="{self.filename}.json"'
108
- json.dump(self.get_data(), response,
109
- ensure_ascii=False, indent=4, default=str)
137
+ json_str = json.dumps(self.get_data(), ensure_ascii=False, indent=4)
138
+ response.write(json_str)
110
139
  return response
111
140
 
112
141
  def to_xlsx(self):
113
- """Export to XLSX."""
142
+ """Export to XLSX with UTF-8 encoding."""
114
143
  response = HttpResponse(
115
- content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
144
+ content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
145
+ )
116
146
  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())
147
+
148
+ output = BytesIO()
149
+ workbook = xlsxwriter.Workbook(output)
150
+ worksheet = workbook.add_worksheet()
151
+
152
+ # Заголовки
153
+ headers = list(self.fields)
154
+ for col_num, header in enumerate(headers):
155
+ worksheet.write(0, col_num, header)
156
+
157
+ # Данные
158
+ for row_num, row in enumerate(self.get_data(), start=1):
159
+ for col_num, key in enumerate(headers):
160
+ worksheet.write(
161
+ row_num,
162
+ col_num,
163
+ str(row[key]) if row[key] is not None else ""
164
+ )
165
+
166
+ workbook.close()
167
+ output.seek(0)
168
+ response.write(output.read())
123
169
  return response
124
170
 
125
171
  def to_yaml(self):
126
- """Export to YAML with proper attachment handling."""
127
- response = HttpResponse(content_type="application/octet-stream")
172
+ """Export to YAML with proper UTF-8 encoding."""
173
+ response = HttpResponse(
174
+ content_type="application/x-yaml; charset=utf-8")
128
175
  response["Content-Disposition"] = f'attachment; filename="{self.filename}.yaml"'
129
- yaml_str = yaml.dump(self.get_data(), allow_unicode=True)
176
+ yaml_str = yaml.dump(
177
+ self.get_data(), allow_unicode=True, default_flow_style=False)
130
178
  response.write(yaml_str)
131
179
  return response
132
180
 
133
181
  def to_tsv(self):
134
- """Export to TSV with proper attachment handling."""
135
- response = HttpResponse(content_type="application/octet-stream")
182
+ """Export to TSV with UTF-8 encoding."""
183
+ response = HttpResponse(
184
+ content_type="text/tab-separated-values; charset=utf-8")
136
185
  response["Content-Disposition"] = f'attachment; filename="{self.filename}.tsv"'
186
+ response.write("\ufeff") # Добавляем BOM
187
+
137
188
  writer = csv.DictWriter(
138
- response, fieldnames=self.fields, delimiter=" ")
189
+ response, fieldnames=self.fields, delimiter="\t")
139
190
  writer.writeheader()
140
- writer.writerows(self.get_data())
191
+ for row in self.get_data():
192
+ writer.writerow({key: str(value) for key, value in row.items()})
193
+
141
194
  return response