django-fast-treenode 1.1.3__py3-none-any.whl → 2.0.1__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.
Files changed (50) hide show
  1. {django_fast_treenode-1.1.3.dist-info → django_fast_treenode-2.0.1.dist-info}/METADATA +127 -46
  2. django_fast_treenode-2.0.1.dist-info/RECORD +41 -0
  3. {django_fast_treenode-1.1.3.dist-info → django_fast_treenode-2.0.1.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 +101 -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.1.dist-info}/LICENSE +0 -0
  49. {django_fast_treenode-1.1.3.dist-info → django_fast_treenode-2.0.1.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
+