django-fast-treenode 2.0.11__py3-none-any.whl → 2.1.0__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 (68) hide show
  1. {django_fast_treenode-2.0.11.dist-info → django_fast_treenode-2.1.0.dist-info}/LICENSE +2 -2
  2. django_fast_treenode-2.1.0.dist-info/METADATA +161 -0
  3. django_fast_treenode-2.1.0.dist-info/RECORD +75 -0
  4. {django_fast_treenode-2.0.11.dist-info → django_fast_treenode-2.1.0.dist-info}/WHEEL +1 -1
  5. treenode/admin/__init__.py +9 -0
  6. treenode/admin/admin.py +295 -0
  7. treenode/admin/changelist.py +65 -0
  8. treenode/admin/mixins.py +302 -0
  9. treenode/apps.py +12 -1
  10. treenode/cache.py +2 -2
  11. treenode/docs/.gitignore +0 -0
  12. treenode/docs/about.md +36 -0
  13. treenode/docs/admin.md +104 -0
  14. treenode/docs/api.md +739 -0
  15. treenode/docs/cache.md +187 -0
  16. treenode/docs/import_export.md +35 -0
  17. treenode/docs/index.md +30 -0
  18. treenode/docs/installation.md +74 -0
  19. treenode/docs/migration.md +145 -0
  20. treenode/docs/models.md +128 -0
  21. treenode/docs/roadmap.md +45 -0
  22. treenode/forms.py +8 -10
  23. treenode/managers/__init__.py +21 -0
  24. treenode/managers/adjacency.py +203 -0
  25. treenode/managers/closure.py +278 -0
  26. treenode/models/__init__.py +2 -1
  27. treenode/models/adjacency.py +343 -0
  28. treenode/models/classproperty.py +3 -0
  29. treenode/models/closure.py +23 -24
  30. treenode/models/factory.py +12 -2
  31. treenode/models/mixins/__init__.py +23 -0
  32. treenode/models/mixins/ancestors.py +65 -0
  33. treenode/models/mixins/children.py +81 -0
  34. treenode/models/mixins/descendants.py +66 -0
  35. treenode/models/mixins/family.py +63 -0
  36. treenode/models/mixins/logical.py +68 -0
  37. treenode/models/mixins/node.py +210 -0
  38. treenode/models/mixins/properties.py +156 -0
  39. treenode/models/mixins/roots.py +96 -0
  40. treenode/models/mixins/siblings.py +99 -0
  41. treenode/models/mixins/tree.py +344 -0
  42. treenode/signals.py +26 -0
  43. treenode/static/treenode/css/tree_widget.css +201 -31
  44. treenode/static/treenode/css/treenode_admin.css +48 -41
  45. treenode/static/treenode/js/tree_widget.js +269 -131
  46. treenode/static/treenode/js/treenode_admin.js +131 -171
  47. treenode/templates/admin/tree_node_changelist.html +6 -0
  48. treenode/templates/admin/treenode_ajax_rows.html +7 -0
  49. treenode/tests/tests.py +488 -0
  50. treenode/urls.py +10 -6
  51. treenode/utils/__init__.py +2 -0
  52. treenode/utils/aid.py +46 -0
  53. treenode/utils/base16.py +38 -0
  54. treenode/utils/base36.py +3 -1
  55. treenode/utils/db.py +116 -0
  56. treenode/utils/exporter.py +2 -0
  57. treenode/utils/importer.py +0 -1
  58. treenode/utils/radix.py +61 -0
  59. treenode/version.py +2 -2
  60. treenode/views.py +118 -43
  61. treenode/widgets.py +91 -43
  62. django_fast_treenode-2.0.11.dist-info/METADATA +0 -698
  63. django_fast_treenode-2.0.11.dist-info/RECORD +0 -42
  64. treenode/admin.py +0 -439
  65. treenode/docs/Documentation +0 -636
  66. treenode/managers.py +0 -419
  67. treenode/models/proxy.py +0 -669
  68. {django_fast_treenode-2.0.11.dist-info → django_fast_treenode-2.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,302 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Views Mixin for TreeNodeAdminModel
4
+
5
+ Version: 2.1.0
6
+ Author: Timur Kady
7
+ Email: kaduevtr@gmail.com
8
+ """
9
+
10
+ import os
11
+ from datetime import datetime
12
+ from django.contrib import admin
13
+ from django.contrib import messages
14
+ from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
15
+ from django.db import models
16
+ from django.http import JsonResponse
17
+ from django.shortcuts import render, redirect
18
+ from django.shortcuts import resolve_url
19
+ from django.template.loader import render_to_string
20
+ from django.urls import path
21
+ from django.utils.encoding import force_str
22
+ from django.utils.safestring import mark_safe
23
+
24
+ import logging
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class AdminMixin(admin.ModelAdmin):
30
+ """Admin Mixin with views."""
31
+
32
+ def change_list_view(self, request):
33
+ """
34
+ View for lazy loading of child nodes.
35
+
36
+ Clicking the expand button sends an AJAX request to this view, which
37
+ returns JSON with the data of the selected node's immediate children.
38
+ All returned nodes are collapsed by default.
39
+ """
40
+ parent_id = request.GET.get("tn_parent_id")
41
+ if not parent_id:
42
+ # If there is no AJAX parameter, then it's normal work: show roots
43
+ return super().change_list_view(request)
44
+
45
+ parent = self.model.objects.filter(pk=parent_id).first()
46
+ if not parent:
47
+ # If there is no parent, return an empty response
48
+ return JsonResponse({'html': ''})
49
+
50
+ children = parent.get_children_queryset()
51
+
52
+ # We take list_display to understand which "columns" are needed
53
+ list_display = self.get_list_display(request)
54
+
55
+ # We collect future "rows"; each as a list of cells
56
+ rows = []
57
+ td_classes = []
58
+ for obj in children:
59
+ row_data = []
60
+ checkbox = mark_safe(
61
+ f'<input type="checkbox" name="{ACTION_CHECKBOX_NAME}"'
62
+ f' value="{obj.pk}" class="action-select" />'
63
+ )
64
+ row_data.append(checkbox)
65
+ td_classes.append("action-checkbox")
66
+ for field in list_display:
67
+ if callable(field):
68
+ # If it is a method (drag, toggle, etc.), call
69
+ row_data.append(field(obj))
70
+ field_name = field.__name__
71
+ else:
72
+ # If it is a string, we try to get the attribute from
73
+ # the object
74
+ value = getattr(obj, field, '')
75
+ if callable(value):
76
+ # If suddenly this is also a method
77
+ # (for example, property), then call it
78
+ value = value()
79
+ row_data.append(value)
80
+ field_name = field
81
+ td_classes.append(f"field-{field_name}")
82
+
83
+ row_info = {
84
+ "node_id": obj.pk,
85
+ "parent_id": parent_id,
86
+ "cells": zip(row_data, td_classes),
87
+ }
88
+ rows.append(row_info)
89
+
90
+ # Pass rows to the template
91
+ html = render_to_string(
92
+ 'admin/treenode_ajax_rows.html',
93
+ {"rows": rows},
94
+ request=request
95
+ )
96
+
97
+ return JsonResponse({'html': html})
98
+
99
+ def search_view(self, request):
100
+ """View for finding nodes using get_list_display."""
101
+ # Get a search query
102
+ q = request.GET.get("q", "")
103
+
104
+ # Perform filtering with annotation to calculate the level
105
+ queryset = self.model.objects\
106
+ .annotate(cl_depth=models.Max("parents_set__depth"))\
107
+ .filter(name__icontains=q)
108
+ queryset_list = list(queryset)[:20]
109
+ sorted_list = self.model._sort_node_list(queryset_list)
110
+
111
+ # Get the set of columns as it is formed in change_list.
112
+ # The first two columns (drag and toggle) are not needed for searching,
113
+ # and the main display column is at index 2.
114
+ list_display = self.get_list_display(request)
115
+ display_func = list_display[2]
116
+
117
+ results = []
118
+ for node in sorted_list:
119
+ # Get the HTML display of the node via the function generated by
120
+ # get_list_display
121
+ display_html = display_func(node)
122
+ results.append({
123
+ "id": node.pk,
124
+ "text": display_html,
125
+ "level": node.cl_depth,
126
+ "is_leaf": node.is_leaf(),
127
+ })
128
+
129
+ return JsonResponse({"results": results})
130
+
131
+ def import_view(self, request):
132
+ """
133
+ Import View.
134
+
135
+ File upload processing, auto-detection of format, validation and data
136
+ import.
137
+ """
138
+ if not self.import_export:
139
+ self.message_user(
140
+ request,
141
+ "Import functionality is disabled because required \
142
+ packages are not installed."
143
+ )
144
+ return redirect("..")
145
+
146
+ if request.method == 'POST':
147
+ if 'file' not in request.FILES:
148
+ return render(
149
+ request,
150
+ "admin/tree_node_import.html",
151
+ {"errors": ["No file uploaded."]}
152
+ )
153
+
154
+ file = request.FILES['file']
155
+ ext = os.path.splitext(file.name)[-1].lower().strip(".")
156
+
157
+ allowed_formats = {"csv", "json", "xlsx", "yaml", "tsv"}
158
+ if ext not in allowed_formats:
159
+ return render(
160
+ request,
161
+ "admin/tree_node_import.html",
162
+ {"errors": [f"Unsupported file format: {ext}"]}
163
+ )
164
+
165
+ # Import data from file
166
+ importer = self.TreeNodeImporter(self.model, file, ext)
167
+ raw_data = importer.import_data()
168
+ clean_result = importer.finalize(raw_data)
169
+
170
+ errors = clean_result.get("errors", [])
171
+ created_count = len(clean_result.get("create", []))
172
+ updated_count = len(clean_result.get("update", []))
173
+
174
+ if errors:
175
+ return render(
176
+ request,
177
+ "admin/tree_node_import_report.html",
178
+ {
179
+ "errors": errors,
180
+ "created_count": created_count,
181
+ "updated_count": updated_count,
182
+ }
183
+ )
184
+
185
+ # If there are no errors, redirect to the list of objects with
186
+ # a message
187
+ messages.success(
188
+ request,
189
+ f"Successfully imported {created_count} records. "
190
+ f"Successfully updated {updated_count} records."
191
+ )
192
+
193
+ app_label = self.model._meta.app_label
194
+ model_name = self.model._meta.model_name
195
+ admin_changelist_url = f"admin:{app_label}_{model_name}_changelist"
196
+ path = resolve_url(admin_changelist_url) + "?import_done=1"
197
+ return redirect(path)
198
+
199
+ # If the request is not POST, simply display the import form
200
+ return render(request, "admin/tree_node_import.html")
201
+
202
+ def export_view(self, request):
203
+ """
204
+ Export view.
205
+
206
+ - If the GET parameters include download, we send the file directly.
207
+ - If the format parameter is missing, we render the format selection
208
+ page.
209
+ - If the format is specified, we perform a test export to catch errors.
210
+
211
+ If there are no errors, we render the success page with a message, a
212
+ link for manual download,
213
+ and a button to go to the model page.
214
+ """
215
+ if not self.import_export:
216
+ self.message_user(
217
+ request,
218
+ "Export functionality is disabled because required \
219
+ packages are not installed."
220
+ )
221
+ return redirect("..")
222
+
223
+ # If the download parameter is present, we give the file directly
224
+ if 'download' in request.GET:
225
+ # Get file format
226
+ export_format = request.GET.get('format', 'csv')
227
+ # Filename
228
+ now = force_str(datetime.now().strftime("%Y-%m-%d %H-%M"))
229
+ filename = self.model._meta.label + " " + now
230
+ # Init
231
+ exporter = self.TreeNodeExporter(
232
+ self.get_queryset(request),
233
+ filename=filename
234
+ )
235
+ # Export working
236
+ response = exporter.export(export_format)
237
+ logger.debug("DEBUG: File response generated.")
238
+ return response
239
+
240
+ # If the format parameter is not passed, we show the format
241
+ # selection page
242
+ if 'format' not in request.GET:
243
+ return render(request, "admin/tree_node_export.html")
244
+
245
+ # If the format is specified, we try to perform a test export
246
+ # (without returning the file)
247
+ export_format = request.GET['format']
248
+ exporter = self.TreeNodeExporter(
249
+ self.model.objects.all(),
250
+ filename=self.model._meta.model_name
251
+ )
252
+ try:
253
+ # Test call to check for export errors (result not used)
254
+ exporter.export(export_format)
255
+ except Exception as e:
256
+ logger.error("Error during test export: %s", e)
257
+ errors = [str(e)]
258
+ return render(
259
+ request,
260
+ "admin/tree_node_export.html",
261
+ {"errors": errors}
262
+ )
263
+
264
+ # Form the correct download URL. If the URL already contains
265
+ # parameters, add them via &download=1, otherwise via ?download=1
266
+ current_url = request.build_absolute_uri()
267
+ if "?" in current_url:
268
+ download_url = current_url + "&download=1"
269
+ else:
270
+ download_url = current_url + "?download=1"
271
+
272
+ context = {
273
+ "download_url": download_url,
274
+ "message": "Your file is ready for export. \
275
+ The download should start automatically.",
276
+ "manual_download_label": "If the download does not start, \
277
+ click this link.",
278
+ # Can be replaced with the desired URL to return to the model
279
+ "redirect_url": "../",
280
+ "button_text": "Return to model"
281
+ }
282
+ return render(request, "admin/export_success.html", context)
283
+
284
+ def get_urls(self):
285
+ """
286
+ Extend admin URLs with custom import/export routes.
287
+
288
+ Register these URLs only if all the required packages are installed.
289
+ """
290
+ urls = super().get_urls()
291
+ if self.import_export:
292
+ custom_urls = [
293
+ path('change_list/', self.change_list_view, name='change_list'),
294
+ path('search/', self.search_view, name='search'),
295
+ path('import/', self.import_view, name='tree_node_import'),
296
+ path('export/', self.export_view, name='tree_node_export'),
297
+ ]
298
+ else:
299
+ custom_urls = []
300
+ return custom_urls + urls
301
+
302
+ # The End
treenode/apps.py CHANGED
@@ -5,13 +5,17 @@ TreeNode Application Configuration
5
5
  This module defines the application configuration for the TreeNode app.
6
6
  It sets the default auto field and specifies the app's name.
7
7
 
8
- Version: 2.0.0
8
+ Version: 2.1.0
9
9
  Author: Timur Kady
10
10
  Email: timurkady@yandex.com
11
11
  """
12
12
 
13
13
 
14
+ import logging
14
15
  from django.apps import AppConfig
16
+ from django.db.models.signals import post_migrate
17
+
18
+ logger = logging.getLogger(__name__)
15
19
 
16
20
 
17
21
  class TreeNodeConfig(AppConfig):
@@ -20,4 +24,11 @@ class TreeNodeConfig(AppConfig):
20
24
  default_auto_field = "django.db.models.BigAutoField"
21
25
  name = "treenode"
22
26
 
27
+ def ready(self):
28
+ """
29
+ Attach a post_migrate handler.
23
30
 
31
+ This allows you to perform operations after the migration is complete.
32
+ """
33
+ from .utils.db import post_migrate_update
34
+ post_migrate.connect(post_migrate_update, sender=self)
treenode/cache.py CHANGED
@@ -212,8 +212,8 @@ def cached_method(func):
212
212
  label,
213
213
  func.__name__,
214
214
  unique_id,
215
- args,
216
- kwargs
215
+ *args,
216
+ **kwargs
217
217
  )
218
218
 
219
219
  # Retrieving from cache
File without changes
treenode/docs/about.md ADDED
@@ -0,0 +1,36 @@
1
+ ## About the project
2
+ ### The Debut Idea
3
+ The idea of ​​this package belongs to **[Fabio Caccamo](https://github.com/fabiocaccamo)**. His idea was to use the **Adjacency List** method to store the data tree. The most probable and time-consuming requests are calculated in advance and stored in the database. Also, most requests are cached. As a result, query processing is carried out in one call to the database or without it at all.
4
+
5
+ The original application **[django-treenode](https://github.com/fabiocaccamo/django-treenode)** has significant advantages over other analogues, and indeed, is one of the best implementations of support for hierarchical structures for Django.
6
+
7
+ However, this application has a number of undeniable shortcomings:
8
+ * the selected pre-calculations scheme entails high costs for adding a new element;
9
+ * inserting new elements uses signals, which leads to failures when using bulk-operations;
10
+ * the problem of ordering elements by priority inside the parent node has not been resolved.
11
+
12
+ That is, an excellent debut idea, in my humble opinion, should have been improved.
13
+
14
+ ### The Development of the Idea
15
+ My idea was to solve these problems by combining the **Adjacency List** with the **Closure Table**. Main advantages:
16
+ * the Closure Model is generated automatically;
17
+ * maintained compatibility with the original package at the level of documented functions;
18
+ * most requests are satisfied in one call to the database;
19
+ * inserting a new element takes two calls to the database without signals usage;
20
+ * bulk-operations are supported;
21
+ * the cost of creating a new dependency is reduced many times;
22
+ * useful functionality added for some methods (e.g. the `include_self=False` and `depth` parameters has been added to functions that return lists/querysets);
23
+ * additionally, the package includes a tree view widget for the `tn_parent` field in the change form.
24
+
25
+ Of course, at large levels of nesting, the use of the Closure Table leads to an increase in resource costs. However, the combined approach still outperforms both the original application and other available Django solutions in terms of performance, especially in large trees with over 100k nodes.
26
+
27
+ ### The Theory
28
+ You can get a basic understanding of what is a **Closure Table** from:
29
+ * [presentation](https://www.slideshare.net/billkarwin/models-for-hierarchical-data) by **Bill Karwin**;
30
+ * [article](https://dirtsimple.org/2010/11/simplest-way-to-do-tree-based-queries.html) by blogger **Dirt Simple**;
31
+ * [article](https://towardsdatascience.com/closure-table-pattern-to-model-hierarchies-in-nosql-c1be6a87e05b) by **Andriy Zabavskyy**.
32
+
33
+ You can easily find additional information on your own on the Internet.
34
+
35
+ ### Our days
36
+ Over the course of development, the package has undergone significant improvements, with a strong emphasis on performance optimization, database efficiency, and seamless integration with Django’s admin interface. The introduction of a hybrid model combining **Adjacency List** and **Closure Table** has substantially reduced query overhead, improved scalability, and enhanced flexibility when managing hierarchical data. These advancements have made the package not only a powerful but also a practical solution for working with large tree structures. Moving forward, the project will continue to evolve, focusing on refining caching mechanisms, expanding compatibility with Django’s ecosystem, and introducing further optimizations to ensure maximum efficiency and ease of use.
treenode/docs/admin.md ADDED
@@ -0,0 +1,104 @@
1
+ ## Working with Admin Classes
2
+
3
+ ### Using `TreeNodeModelAdmin`
4
+ The easiest way to integrate tree structures into Django’s admin panel is by inheriting from `TreeNodeModelAdmin`. This base class provides all the necessary functionality for managing hierarchical data.
5
+
6
+ ##### admin.py:
7
+ ```python
8
+ from django.contrib import admin
9
+ from treenode.admin import TreeNodeModelAdmin
10
+
11
+ from .models import Category
12
+
13
+ @admin.register(Category)
14
+ class CategoryAdmin(TreeNodeModelAdmin):
15
+
16
+ # Set the display mode: 'accordion', 'breadcrumbs', or 'indentation'
17
+ treenode_display_mode = TreeNodeModelAdmin.TREENODE_DISPLAY_MODE_ACCORDION
18
+ # treenode_display_mode = TreeNodeModelAdmin.TREENODE_DISPLAY_MODE_BREADCRUMBS
19
+ # treenode_display_mode = TreeNodeModelAdmin.TREENODE_DISPLAY_MODE_INDENTATION
20
+
21
+ list_display = ("name",)
22
+ search_fields = ("name",)
23
+ ```
24
+
25
+ The tree structure in the admin panel **loads dynamically as nodes are expanded**. This allows handling **large datasets** efficiently, preventing performance issues.
26
+
27
+ You can choose from three display modes:
28
+ - **`TREENODE_DISPLAY_MODE_ACCORDION` (default)**
29
+ Expands/collapses nodes dynamically.
30
+ - **`TREENODE_DISPLAY_MODE_BREADCRUMBS`**
31
+ Displays the tree as a sequence of **breadcrumbs**, making it easy to navigate.
32
+ - **`TREENODE_DISPLAY_MODE_INDENTATION`**
33
+ Uses a **long dash** (`———`) to indicate nesting levels, providing a simple visual structure.
34
+
35
+ The accordion mode is **always active**, and the setting only affects how nodes are displayed.
36
+
37
+ **Why Dynamic Loading**: Traditional pagination does not work well for **deep hierarchical trees**, as collapsed trees may contain a **huge number of nodes**, which is in the hundreds of thousands. The dynamic approach allows efficient loading, reducing database load while keeping large trees manageable.
38
+
39
+ #### Search Functionality
40
+ The search bar helps quickly locate nodes within large trees. As you type, **an AJAX request retrieves up to 20 results** based on relevance. If you don’t find the desired node, keep typing to refine the search until fewer than 20 results remain.
41
+
42
+ ### Working with Forms
43
+
44
+ #### Using TreeNodeForm
45
+ If you need to customize forms for tree-based models, inherit from `TreeNodeForm`. It provides:
46
+ - A **custom tree widget** for selecting parent nodes.
47
+ - Automatic **exclusion of self and descendants** from the parent selection to prevent circular references.
48
+
49
+ ##### `forms.py`:
50
+ ```python
51
+ from treenode.forms import TreeNodeForm
52
+ from .models import Category
53
+
54
+ class CategoryForm(TreeNodeForm):
55
+ """Form for Category model with hierarchical selection."""
56
+
57
+ class Meta(TreeNodeForm.Meta):
58
+ model = Category
59
+ ```
60
+
61
+ Key Considerations:
62
+ - This form automatically ensures that **a node cannot be its own parent**.
63
+ - It uses **`TreeWidget`**, a custom hierarchical dropdown for selecting parent nodes.
64
+ - If you need a form for another tree-based model, use the **dynamic factory method**:
65
+
66
+ ```python
67
+ CategoryForm = TreeNodeForm.factory(Category)
68
+ ```
69
+
70
+ This method ensures that the form correctly associates with different tree models dynamically.
71
+
72
+
73
+ ### Using TreeWidget Widget
74
+
75
+ #### The TreeWidget Class
76
+ The `TreeWidget` class is a **custom Select2-like widget** that enables hierarchical selection in forms. While it is used inside the Django admin panel by default, it can **also be used in regular forms** outside the admin panel.
77
+
78
+ ##### `widgets.py`
79
+
80
+ ```python
81
+ from django import forms
82
+ from treenode.widgets import TreeWidget
83
+ from .models import Category
84
+
85
+ class CategorySelectionForm(forms.Form):
86
+ parent = forms.ModelChoiceField(
87
+ queryset=Category.objects.all(),
88
+ widget=TreeWidget(),
89
+ required=False
90
+ )
91
+ ```
92
+
93
+ Important Notes:
94
+ - **Requires jQuery**: The widget relies on AJAX requests, so ensure jQuery is available when using it outside Django’s admin.
95
+ - **Dynamically Fetches Data**: It loads the tree structure asynchronously, preventing performance issues with large datasets.
96
+ - **Customizable Data Source**: The `data-url` attribute can be adjusted to fetch tree data from a custom endpoint.
97
+
98
+ If you plan to use this widget in non-admin templates, make sure the necessary **JavaScript and CSS files** are included:
99
+ ```html
100
+ <link rel="stylesheet" href="/static/treenode/tree_widget.css">
101
+ <script src="/static/treenode/js/tree_widget.js"></script>
102
+ ```
103
+
104
+ By following these guidelines, you can seamlessly integrate `TreeNodeModelAdmin`, `TreeNodeForm`, and `TreeWidget` into your Django project, ensuring efficient management of hierarchical data.