django-fast-treenode 1.1.3__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.
Files changed (50) hide show
  1. {django_fast_treenode-1.1.3.dist-info → django_fast_treenode-2.0.0.dist-info}/METADATA +156 -46
  2. django_fast_treenode-2.0.0.dist-info/RECORD +41 -0
  3. {django_fast_treenode-1.1.3.dist-info → django_fast_treenode-2.0.0.dist-info}/WHEEL +1 -1
  4. treenode/__init__.py +0 -7
  5. treenode/admin.py +327 -82
  6. treenode/apps.py +20 -3
  7. treenode/cache.py +231 -0
  8. treenode/docs/Documentation +130 -54
  9. treenode/forms.py +75 -19
  10. treenode/managers.py +260 -48
  11. treenode/models/__init__.py +7 -0
  12. treenode/models/classproperty.py +24 -0
  13. treenode/models/closure.py +168 -0
  14. treenode/models/factory.py +71 -0
  15. treenode/models/proxy.py +650 -0
  16. treenode/static/treenode/css/tree_widget.css +62 -0
  17. treenode/static/treenode/css/treenode_admin.css +106 -0
  18. treenode/static/treenode/js/tree_widget.js +161 -0
  19. treenode/static/treenode/js/treenode_admin.js +171 -0
  20. treenode/templates/admin/export_success.html +26 -0
  21. treenode/templates/admin/tree_node_changelist.html +11 -0
  22. treenode/templates/admin/tree_node_export.html +27 -0
  23. treenode/templates/admin/tree_node_import.html +27 -0
  24. treenode/templates/widgets/tree_widget.css +23 -0
  25. treenode/templates/widgets/tree_widget.html +21 -0
  26. treenode/urls.py +34 -0
  27. treenode/utils/__init__.py +4 -0
  28. treenode/utils/base36.py +35 -0
  29. treenode/utils/exporter.py +141 -0
  30. treenode/utils/importer.py +296 -0
  31. treenode/version.py +11 -1
  32. treenode/views.py +102 -2
  33. treenode/widgets.py +49 -27
  34. django_fast_treenode-1.1.3.dist-info/RECORD +0 -33
  35. treenode/compat.py +0 -8
  36. treenode/factory.py +0 -68
  37. treenode/models.py +0 -668
  38. treenode/static/select2tree/.gitkeep +0 -1
  39. treenode/static/select2tree/select2tree.css +0 -176
  40. treenode/static/select2tree/select2tree.js +0 -181
  41. treenode/static/treenode/css/treenode.css +0 -85
  42. treenode/static/treenode/js/treenode.js +0 -201
  43. treenode/templates/widgets/.gitkeep +0 -1
  44. treenode/templates/widgets/attrs.html +0 -7
  45. treenode/templates/widgets/options.html +0 -1
  46. treenode/templates/widgets/select2tree.html +0 -22
  47. treenode/tests.py +0 -3
  48. {django_fast_treenode-1.1.3.dist-info → django_fast_treenode-2.0.0.dist-info}/LICENSE +0 -0
  49. {django_fast_treenode-1.1.3.dist-info → django_fast_treenode-2.0.0.dist-info}/top_level.txt +0 -0
  50. /treenode/{docs → templates/admin}/.gitkeep +0 -0
treenode/admin.py CHANGED
@@ -2,135 +2,380 @@
2
2
  """
3
3
  TreeNode Admin Module
4
4
 
5
+ This module provides Django admin integration for the TreeNode model.
6
+ It includes custom tree-based sorting, optimized queries, and
7
+ import/export functionality for hierarchical data structures.
8
+
9
+ Version: 2.0.0
10
+ Author: Timur Kady
11
+ Email: kaduevtr@gmail.com
5
12
  """
6
13
 
14
+
15
+ import os
16
+ import importlib
17
+ from datetime import datetime
7
18
  from django.contrib import admin
8
- from django.utils.safestring import mark_safe
9
19
  from django.contrib.admin.views.main import ChangeList
20
+ from django.db import models
21
+ from django.db.models import Case, When, Value, IntegerField
22
+ from django.shortcuts import render, redirect
23
+ from django.urls import path
24
+ from django.utils.encoding import force_str
25
+ from django.utils.safestring import mark_safe
26
+ from django.utils.translation import gettext_lazy as _
27
+
28
+ from .utils import TreeNodeImporter, TreeNodeExporter
10
29
  from .forms import TreeNodeForm
30
+ from .widgets import TreeWidget
31
+
32
+ import logging
33
+
34
+ logger = logging.getLogger(__name__)
11
35
 
12
36
 
13
37
  class NoPkDescOrderedChangeList(ChangeList):
38
+ """Custom ChangeList to remove descending sorting `pk` (default)."""
39
+
14
40
  def get_ordering(self, request, queryset):
15
- rv = super().get_ordering(request, queryset)
16
- rv = list(rv)
17
- rv.remove('-pk') if '-pk' in rv else None
18
- return tuple()
41
+ """
42
+ Override ordering.
43
+
44
+ Overrides the sort order of objects in the list.
45
+ Django Admin sorts by `-pk` (descending) by default.
46
+ This method removes `-pk` so that objects are not sorted by ID.
47
+ """
48
+ rv = list(super().get_ordering(request, queryset))
49
+ if '-pk' in rv:
50
+ rv.remove('-pk')
51
+ return tuple(rv)
19
52
 
20
53
  def get_queryset(self, request):
21
- qs = self.model.objects.all()
22
- return qs.select_related('tn_parent')
54
+ """
55
+ Override QuerySet.
56
+
57
+ Overrides data selection to optimize queries. Also adds
58
+ `select_related('tn_parent')` to avoid N+1 queries.
59
+ """
60
+ queryset = super(NoPkDescOrderedChangeList, self).get_queryset(request)
61
+ node_list = sorted(queryset, key=lambda x: x.tn_order)
62
+ pk_list = [node.pk for node in node_list]
23
63
 
64
+ return queryset.filter(pk__in=pk_list).order_by(
65
+ Case(*[When(pk=pk, then=Value(index))
66
+ for index, pk in enumerate(pk_list)],
67
+ default=Value(len(pk_list)),
68
+ output_field=IntegerField())
69
+ ).select_related('tn_parent')
24
70
 
25
- class TreeNodeModelAdmin(admin.ModelAdmin):
71
+
72
+ class TreeNodeAdminModel(admin.ModelAdmin):
73
+ """
74
+ TreeNodeAdmin class.
75
+
76
+ Admin configuration for TreeNodeModel with import/export functionality.
77
+ """
26
78
 
27
79
  TREENODE_DISPLAY_MODE_ACCORDION = 'accordion'
28
80
  TREENODE_DISPLAY_MODE_BREADCRUMBS = 'breadcrumbs'
29
81
  TREENODE_DISPLAY_MODE_INDENTATION = 'indentation'
30
82
 
31
- treenode_display_mode = TREENODE_DISPLAY_MODE_INDENTATION
83
+ treenode_display_mode = TREENODE_DISPLAY_MODE_ACCORDION
32
84
 
33
- form = TreeNodeForm
85
+ change_list_template = "admin/tree_node_changelist.html"
86
+ ordering = []
34
87
  list_per_page = 1000
88
+ list_sorting_mode_session_key = "treenode_sorting_mode"
89
+
90
+ form = TreeNodeForm
91
+ formfield_overrides = {
92
+ models.ForeignKey: {"widget": TreeWidget()},
93
+ }
94
+
95
+ class Media:
96
+ """Include custom CSS and JavaScript for admin interface."""
97
+
98
+ css = {"all": (
99
+ "treenode/css/treenode_admin.css",
100
+ )}
101
+ js = (
102
+ 'admin/js/jquery.init.js',
103
+ 'treenode/js/treenode_admin.js',
104
+ )
105
+
106
+ def __init__(self, model, admin_site):
107
+ """Динамически добавляем поле `tn_order` в `list_display`."""
108
+ super().__init__(model, admin_site)
109
+
110
+ # Если `list_display` пустой, берем все `fields`
111
+ if not self.list_display:
112
+ self.list_display = [field.name for field in model._meta.fields]
113
+
114
+ def get_queryset(self, request):
115
+ """Override get_ueryset()."""
116
+ queryset = super().get_queryset(request)
117
+
118
+ search_term = request.GET.get("q")
119
+ if search_term:
120
+ """
121
+ print(f"Поиск: {search_term}")
122
+ search_fields = self.get_search_fields(request)
123
+ print(f"Поиск по полям: {search_fields}")
124
+
125
+ q_objects = Q()
126
+
127
+ for field in search_fields:
128
+ q_objects |= Q(**{f"{field}__icontains": search_term})
129
+
130
+
131
+ queryset = queryset.filter(q_objects)
132
+ print(f"Найдено записей: {queryset.count()}")
133
+ """
134
+ return queryset
135
+
136
+ node_list = sorted(queryset, key=lambda x: x.tn_order)
137
+ pk_list = [node.pk for node in node_list]
138
+ return queryset.filter(pk__in=pk_list).order_by(
139
+ Case(*[When(pk=pk, then=Value(index))
140
+ for index, pk in enumerate(pk_list)],
141
+ default=Value(len(pk_list)),
142
+ output_field=IntegerField())
143
+ )
144
+
145
+ def get_search_fields(self, request):
146
+ """Return the correct search field."""
147
+ return [self.model.treenode_display_field]
35
148
 
36
149
  def get_list_display(self, request):
37
- base_list_display = super(
38
- TreeNodeModelAdmin, self).get_list_display(request)
150
+ """
151
+ Generate list_display dynamically.
152
+
153
+ Return list or tuple of field names that will be displayed in the
154
+ change list view.
155
+ """
156
+ base_list_display = super().get_list_display(request)
39
157
  base_list_display = list(base_list_display)
40
158
 
41
159
  def treenode_field_display(obj):
42
160
  return self._get_treenode_field_display(request, obj)
43
161
 
44
- treenode_field_display.short_description = self.model._meta.verbose_name
162
+ verbose_name = self.model._meta.verbose_name
163
+ treenode_field_display.short_description = verbose_name
45
164
  treenode_field_display.allow_tags = True
46
165
 
47
166
  if len(base_list_display) == 1 and base_list_display[0] == '__str__':
48
- return (treenode_field_display, )
167
+ return (treenode_field_display,)
49
168
  else:
50
169
  treenode_display_field = getattr(
51
- self.model, 'treenode_display_field')
52
- if len(base_list_display) >= 1 and base_list_display[0] == treenode_display_field:
170
+ self.model,
171
+ 'treenode_display_field',
172
+ '__str__'
173
+ )
174
+ if base_list_display[0] == treenode_display_field:
53
175
  base_list_display.pop(0)
54
- return (treenode_field_display, ) + tuple(base_list_display)
55
-
56
- return base_list_display
176
+ return (treenode_field_display,) + tuple(base_list_display)
57
177
 
58
178
  def get_changelist(self, request):
179
+ """Get ChangeList."""
59
180
  return NoPkDescOrderedChangeList
60
181
 
182
+ def changelist_view(self, request, extra_context=None):
183
+ """Changelist View."""
184
+ extra_context = extra_context or {}
185
+ extra_context['import_export_enabled'] = all(
186
+ importlib.util.find_spec(pkg)
187
+ is not None for pkg in ["openpyxl", "pyyaml", "pandas"]
188
+ )
189
+ return super().changelist_view(request, extra_context=extra_context)
190
+
61
191
  def get_ordering(self, request):
192
+ """Get Ordering."""
62
193
  return None
63
194
 
64
- def list_to_queryset(self, model, data):
65
- from django.db.models.base import ModelBase
195
+ def _get_row_display(self, obj):
196
+ """Return row display for accordion mode."""
197
+ field = getattr(self.model, 'treenode_display_field')
198
+ return force_str(getattr(obj, field, obj.pk))
66
199
 
67
- if not isinstance(model, ModelBase):
68
- raise ValueError(
69
- "%s must be Model" % model
200
+ def _get_treenode_field_display(self, request, obj):
201
+ """Define how to display nodes depending on the mode."""
202
+ display_mode = self.treenode_display_mode
203
+ if display_mode == self.TREENODE_DISPLAY_MODE_ACCORDION:
204
+ return self._display_with_accordion(obj)
205
+ elif display_mode == self.TREENODE_DISPLAY_MODE_BREADCRUMBS:
206
+ return self._display_with_breadcrumbs(obj)
207
+ elif display_mode == self.TREENODE_DISPLAY_MODE_INDENTATION:
208
+ return self._display_with_indentation(obj)
209
+ else:
210
+ return self._display_with_breadcrumbs(obj)
211
+
212
+ def _display_with_accordion(self, obj):
213
+ """Display a tree in accordion style."""
214
+ parent = str(obj.tn_parent_id or '')
215
+ text = self._get_row_display(obj)
216
+ html = (
217
+ f'<div class="treenode-wrapper" '
218
+ f'data-treenode-pk="{obj.pk}" '
219
+ f'data-treenode-depth="{obj.depth}" '
220
+ f'data-treenode-parent="{parent}">'
221
+ f'<span class="treenode-content">{text}</span>'
222
+ f'</div>'
223
+ )
224
+ return mark_safe(html)
225
+
226
+ def _display_with_breadcrumbs(self, obj):
227
+ """Display a tree as breadcrumbs."""
228
+ field = getattr(self.model, 'treenode_display_field')
229
+ if field is not None:
230
+ obj_display = " / ".join(obj.get_breadcrumbs(attr=field))
231
+ else:
232
+ obj_display = obj.get_path(
233
+ prefix=_("Node "),
234
+ suffix=" / " + obj.__str__()
70
235
  )
71
- if not isinstance(data, list):
72
- raise ValueError(
73
- "%s must be List Object" % data
236
+ display = f'<span class="treenode-breadcrumbs">{obj_display}</span>'
237
+ return mark_safe(display)
238
+
239
+ def _display_with_indentation(self, obj):
240
+ """Display tree with indents."""
241
+ indent = '&mdash;' * obj.get_depth()
242
+ display = f'<span class="treenode-indentation">{indent}</span> {obj}'
243
+ return mark_safe(display)
244
+
245
+ def get_form(self, request, obj=None, **kwargs):
246
+ """Get Form method."""
247
+ form = super().get_form(request, obj, **kwargs)
248
+ if "tn_parent" in form.base_fields:
249
+ form.base_fields["tn_parent"].widget = TreeWidget()
250
+ return form
251
+
252
+ def get_urls(self):
253
+ """Extend admin URLs with custom import/export routes."""
254
+ urls = super().get_urls()
255
+ custom_urls = [
256
+ path('import/', self.import_view, name='tree_node_import'),
257
+ path('export/', self.export_view, name='tree_node_export'),
258
+ ]
259
+ return custom_urls + urls
260
+
261
+ def import_view(self, request):
262
+ """
263
+ Import View.
264
+
265
+ File upload processing, auto-detection of format, validation and data
266
+ import.
267
+ """
268
+ if request.method == 'POST':
269
+ if 'file' not in request.FILES:
270
+ return render(
271
+ request,
272
+ "admin/tree_node_import.html",
273
+ {"errors": ["No file uploaded."]}
274
+ )
275
+
276
+ file = request.FILES['file']
277
+ ext = os.path.splitext(file.name)[-1].lower().strip(".")
278
+
279
+ allowed_formats = {"csv", "json", "xlsx", "yaml", "tsv"}
280
+ if ext not in allowed_formats:
281
+ return render(
282
+ request,
283
+ "admin/tree_node_import.html",
284
+ {"errors": [f"Unsupported file format: {ext}"]}
285
+ )
286
+
287
+ # Import data from file
288
+ importer = TreeNodeImporter(self.model, file, ext)
289
+ raw_data = importer.import_data()
290
+ clean_result = importer.clean(raw_data)
291
+ errors = importer.finalize_import(clean_result)
292
+ if errors:
293
+ return render(
294
+ request,
295
+ "admin/tree_node_import.html",
296
+ {"errors": errors}
297
+ )
298
+ self.message_user(
299
+ request,
300
+ f"Successfully imported {len(clean_result['create'])} records."
74
301
  )
302
+ return redirect("..")
75
303
 
76
- pk_list = [obj.pk for obj in data]
77
- return model.objects.filter(pk__in=pk_list)
304
+ # If the request is not POST, simply display the import form
305
+ return render(request, "admin/tree_node_import.html")
78
306
 
79
- def _use_treenode_display_mode(self, request, obj):
80
- querystring = (request.GET.urlencode() or '')
81
- return len(querystring) <= 2
307
+ def export_view(self, request):
308
+ """
309
+ Export view.
82
310
 
83
- def _get_treenode_display_mode(self, request, obj):
84
- return self.treenode_display_mode
311
+ - If the GET parameters include download, we send the file directly.
312
+ - If the format parameter is missing, we render the format selection
313
+ page.
314
+ - If the format is specified, we perform a test export to catch errors.
85
315
 
86
- def _get_treenode_field_default_display(self, obj):
87
- return self._get_treenode_field_display_with_breadcrumbs(obj)
316
+ If there are no errors, we render the success page with a message, a
317
+ link for manual download,
318
+ and a button to go to the model page.
319
+ """
320
+ # If the download parameter is present, we give the file directly
321
+ if 'download' in request.GET:
322
+ # Get file format
323
+ export_format = request.GET.get('format', 'csv')
324
+ # Important: This QuerySet provides a convenient ("friendly") order
325
+ # of tree node output during export/import.
326
+ queryset = self.get_queryset()
327
+ # Filename
328
+ now = force_str(datetime.now().strftime("%Y-%m-%d %H-%M"))
329
+ filename = self.model._meta.label + " " + now
330
+ # Init
331
+ exporter = TreeNodeExporter(queryset, filename=filename)
332
+ # Export working
333
+ response = exporter.export(export_format)
334
+ logger.debug("DEBUG: File response generated.")
335
+ return response
88
336
 
89
- def _get_treenode_field_display(self, request, obj):
90
- if not self._use_treenode_display_mode(request, obj):
91
- return self._get_treenode_field_default_display(obj)
92
- display_mode = self._get_treenode_display_mode(request, obj)
93
- if display_mode == TreeNodeModelAdmin.TREENODE_DISPLAY_MODE_ACCORDION:
94
- return self._get_treenode_field_display_with_accordion(obj)
95
- elif display_mode == TreeNodeModelAdmin.TREENODE_DISPLAY_MODE_BREADCRUMBS:
96
- return self._get_treenode_field_display_with_breadcrumbs(obj)
97
- elif display_mode == TreeNodeModelAdmin.TREENODE_DISPLAY_MODE_INDENTATION:
98
- return self._get_treenode_field_display_with_indentation(obj)
337
+ # If the format parameter is not passed, we show the format
338
+ # selection page
339
+ if 'format' not in request.GET:
340
+ return render(request, "admin/tree_node_export.html")
341
+
342
+ # If the format is specified, we try to perform a test export
343
+ # (without returning the file)
344
+ export_format = request.GET['format']
345
+ exporter = TreeNodeExporter(
346
+ self.model.objects.all(),
347
+ filename=self.model._meta.model_name
348
+ )
349
+ try:
350
+ # Test call to check for export errors (result not used)
351
+ exporter.export(export_format)
352
+ except Exception as e:
353
+ logger.error("Error during test export: %s", e)
354
+ errors = [str(e)]
355
+ return render(
356
+ request,
357
+ "admin/tree_node_export.html",
358
+ {"errors": errors}
359
+ )
360
+
361
+ # Form the correct download URL. If the URL already contains
362
+ # parameters, add them via &download=1, otherwise via ?download=1
363
+ current_url = request.build_absolute_uri()
364
+ if "?" in current_url:
365
+ download_url = current_url + "&download=1"
99
366
  else:
100
- return self._get_treenode_field_default_display(obj)
101
-
102
- def _get_treenode_field_display_with_accordion(self, obj):
103
- tn_namespace = '%s.%s' % (obj.__module__, obj.__class__.__name__, )
104
- tn_namespace_key = tn_namespace.lower().replace('.', '_')
105
- return mark_safe(''
106
- '<span class="treenode"'
107
- ' data-treenode-type="%s"'
108
- ' data-treenode-pk="%s"'
109
- ' data-treenode-accordion="1"'
110
- ' data-treenode-depth="%s"'
111
- ' data-treenode-level="%s"'
112
- ' data-treenode-parent="%s">%s</span>' % (
113
- tn_namespace_key,
114
- str(obj.pk),
115
- str(obj.depth),
116
- str(obj.level),
117
- str(obj.tn_parent_id or ''),
118
- obj.get_display(indent=False), ))
119
-
120
- def _get_treenode_field_display_with_breadcrumbs(self, obj):
121
- obj_display = ''
122
- for obj_ancestor in obj.get_ancestors():
123
- obj_ancestor_display = obj_ancestor.get_display(indent=False)
124
- obj_display += '<span class="treenode-breadcrumbs">%s</span>' % (
125
- obj_ancestor_display, )
126
- obj_display += obj.get_display(indent=False)
127
- return mark_safe('<span class="treenode">%s</span>' % (obj_display, ))
128
-
129
- def _get_treenode_field_display_with_indentation(self, obj):
130
- obj_display = '<span class="treenode-indentation">&mdash;</span>' * obj.ancestors_count
131
- obj_display += obj.get_display(indent=False)
132
- return mark_safe('<span class="treenode">%s</span>' % (obj_display, ))
367
+ download_url = current_url + "?download=1"
133
368
 
134
- class Media:
135
- css = {'all': ('treenode/css/treenode.css',)}
136
- js = ['treenode/js/treenode.js']
369
+ context = {
370
+ "download_url": download_url,
371
+ "message": "Your file is ready for export. \
372
+ The download should start automatically.",
373
+ "manual_download_label": "If the download does not start, \
374
+ click this link.",
375
+ # Can be replaced with the desired URL to return to the model
376
+ "redirect_url": "../",
377
+ "button_text": "Return to model"
378
+ }
379
+ return render(request, "admin/export_success.html", context)
380
+
381
+ # The End
treenode/apps.py CHANGED
@@ -1,6 +1,23 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ TreeNode Application Configuration
4
+
5
+ This module defines the application configuration for the TreeNode app.
6
+ It sets the default auto field and specifies the app's name.
7
+
8
+ Version: 2.0.0
9
+ Author: Timur Kady
10
+ Email: timurkady@yandex.com
11
+ """
12
+
13
+
1
14
  from django.apps import AppConfig
2
15
 
3
16
 
4
- class TreenodeConfig(AppConfig):
5
- default_auto_field = 'django.db.models.BigAutoField'
6
- name = 'treenode'
17
+ class TreeNodeConfig(AppConfig):
18
+ """TreeNodeConfig Class."""
19
+
20
+ default_auto_field = "django.db.models.BigAutoField"
21
+ name = "treenode"
22
+
23
+