django-fast-treenode 2.0.10__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 (70) hide show
  1. {django_fast_treenode-2.0.10.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.10.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 +33 -22
  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 +39 -65
  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/tree_node_import.html +27 -9
  49. treenode/templates/admin/tree_node_import_report.html +32 -0
  50. treenode/templates/admin/treenode_ajax_rows.html +7 -0
  51. treenode/tests/tests.py +488 -0
  52. treenode/urls.py +10 -6
  53. treenode/utils/__init__.py +2 -0
  54. treenode/utils/aid.py +46 -0
  55. treenode/utils/base16.py +38 -0
  56. treenode/utils/base36.py +3 -1
  57. treenode/utils/db.py +116 -0
  58. treenode/utils/exporter.py +63 -36
  59. treenode/utils/importer.py +168 -161
  60. treenode/utils/radix.py +61 -0
  61. treenode/version.py +2 -2
  62. treenode/views.py +119 -38
  63. treenode/widgets.py +104 -40
  64. django_fast_treenode-2.0.10.dist-info/METADATA +0 -698
  65. django_fast_treenode-2.0.10.dist-info/RECORD +0 -41
  66. treenode/admin.py +0 -396
  67. treenode/docs/Documentation +0 -664
  68. treenode/managers.py +0 -281
  69. treenode/models/proxy.py +0 -650
  70. {django_fast_treenode-2.0.10.dist-info → django_fast_treenode-2.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,96 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ TreeNode Roots Mixin
4
+
5
+ Version: 2.1.0
6
+ Author: Timur Kady
7
+ Email: timurkady@yandex.com
8
+ """
9
+
10
+ from django.db import models
11
+ from treenode.cache import cached_method
12
+
13
+
14
+ class TreeNodeRootsMixin(models.Model):
15
+ """TreeNode Roots Mixin."""
16
+
17
+ class Meta:
18
+ """Moxin Meta Class."""
19
+
20
+ abstract = True
21
+
22
+ @classmethod
23
+ def add_root(cls, position=None, **kwargs):
24
+ """
25
+ Add a root node to the tree.
26
+
27
+ Adds a new root node at the specified position. If no position is
28
+ specified, the new node will be the last element in the root.
29
+ Parameters:
30
+ position: can be 'first-root', 'last-root', 'sorted-root' or integer
31
+ value.
32
+ **kwargs – Object creation data that will be passed to the inherited
33
+ Node model
34
+ instance – Instead of passing object creation data, you can pass
35
+ an already-constructed (but not yet saved) model instance to be
36
+ inserted into the tree.
37
+
38
+ Returns:
39
+ The created node object. It will be save()d by this method.
40
+ """
41
+ if isinstance(position, int):
42
+ priority = position
43
+ else:
44
+ if position not in ['first-root', 'last-root', 'sorted-root']:
45
+ raise ValueError(f"Invalid position format: {position}")
46
+
47
+ parent, priority = cls._get_place(None, position)
48
+
49
+ instance = kwargs.get("instance")
50
+ if instance is None:
51
+ instance = cls(**kwargs)
52
+
53
+ parent, priority = cls._get_place(None, position)
54
+ instance.tn_parent = None
55
+ instance.tn_priority = priority
56
+ instance.save()
57
+ return instance
58
+
59
+ @classmethod
60
+ @cached_method
61
+ def get_roots_queryset(cls):
62
+ """Get root nodes queryset with preloaded children."""
63
+ qs = cls.objects.filter(tn_parent=None).prefetch_related('tn_children')
64
+ return qs
65
+
66
+ @classmethod
67
+ @cached_method
68
+ def get_roots_pks(cls):
69
+ """Get a list with all root nodes."""
70
+ pks = cls.objects.filter(tn_parent=None).values_list("id", flat=True)
71
+ return list(pks)
72
+
73
+ @classmethod
74
+ def get_roots(cls):
75
+ """Get a list with all root nodes."""
76
+ qs = cls.get_roots_queryset()
77
+ return list(qs)
78
+
79
+ @classmethod
80
+ def get_roots_count(cls):
81
+ """Get a list with all root nodes."""
82
+ return len(cls.get_roots_pks())
83
+
84
+ @classmethod
85
+ def get_first_root(cls):
86
+ """Return the first root node in the tree or None if it is empty."""
87
+ roots = cls.get_roots_queryset()
88
+ return roots.fiest() if roots.count() > 0 else None
89
+
90
+ @classmethod
91
+ def get_last_root(cls):
92
+ """Return the last root node in the tree or None if it is empty."""
93
+ roots = cls.get_roots_queryset()
94
+ return roots.last() if roots.count() > 0 else None
95
+
96
+ # The End
@@ -0,0 +1,99 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ TreeNode Siblings Mixin
4
+
5
+ Version: 2.1.0
6
+ Author: Timur Kady
7
+ Email: timurkady@yandex.com
8
+ """
9
+
10
+ from django.db import models
11
+ from treenode.cache import cached_method
12
+
13
+
14
+ class TreeNodeSiblingsMixin(models.Model):
15
+ """TreeNode Siblings Mixin."""
16
+
17
+ class Meta:
18
+ """Moxin Meta Class."""
19
+
20
+ abstract = True
21
+
22
+ def add_sibling(self, position=None, **kwargs):
23
+ """
24
+ Add a new node as a sibling to the current node object.
25
+
26
+ Returns the created node object or None if failed. It will be saved
27
+ by this method.
28
+ """
29
+ if isinstance(position, int):
30
+ priority = position
31
+ parent = self.tn_parent
32
+ else:
33
+ if position not in [
34
+ 'first-sibling', 'left-sibling', 'right-sibling',
35
+ 'last-sibling', 'sorted-sibling']:
36
+ raise ValueError(f"Invalid position format: {position}")
37
+ parent, priority = self._meta.model._get_place(self, position)
38
+
39
+ instance = kwargs.get("instance")
40
+ if instance is None:
41
+ instance = self._meta.model(**kwargs)
42
+ instance.tn_priority = priority
43
+ instance.tn_priority = parent
44
+ instance.save()
45
+ return instance
46
+
47
+ @cached_method
48
+ def get_siblings_queryset(self):
49
+ """Get the siblings queryset with prefetch."""
50
+ if self.tn_parent:
51
+ qs = self.tn_parent.tn_children.prefetch_related('tn_children')
52
+ else:
53
+ qs = self._meta.model.objects.filter(tn_parent__isnull=True)
54
+ return qs.exclude(pk=self.pk)
55
+
56
+ def get_siblings(self):
57
+ """Get a list with all the siblings."""
58
+ return list(self.get_siblings_queryset())
59
+
60
+ def get_siblings_count(self):
61
+ """Get the siblings count."""
62
+ return self.get_siblings_queryset().count()
63
+
64
+ def get_siblings_pks(self):
65
+ """Get the siblings pks list."""
66
+ return [item.pk for item in self.get_siblings_queryset()]
67
+
68
+ def get_first_sibling(self):
69
+ """
70
+ Return the fist node’s sibling.
71
+
72
+ Method can return the node itself if it was the leftmost sibling.
73
+ """
74
+ return self.get_siblings_queryset().fist()
75
+
76
+ def get_previous_sibling(self):
77
+ """Return the previous sibling in the tree, or None."""
78
+ priority = self.tn_priority - 1
79
+ if priority < 0:
80
+ return None
81
+ return self.get_siblings_queryset.filter(tn_priority=priority)
82
+
83
+ def get_next_sibling(self):
84
+ """Return the next sibling in the tree, or None."""
85
+ priority = self.tn_priority = 1
86
+ queryset = self.get_siblings_queryset()
87
+ if priority == queryset.count():
88
+ return None
89
+ return queryset.filter(tn_priority=priority)
90
+
91
+ def get_last_sibling(self):
92
+ """
93
+ Return the fist node’s sibling.
94
+
95
+ Method can return the node itself if it was the leftmost sibling.
96
+ """
97
+ return self.get_siblings_queryset().last()
98
+
99
+ # The End
@@ -0,0 +1,344 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ TreeNode Tree Mixin
4
+
5
+ Version: 2.1.0
6
+ Author: Timur Kady
7
+ Email: timurkady@yandex.com
8
+ """
9
+
10
+ import json
11
+ from django.db import models, transaction
12
+ from collections import OrderedDict
13
+ from django.core.serializers.json import DjangoJSONEncoder
14
+
15
+ from ...cache import cached_method
16
+
17
+
18
+ class TreeNodeTreeMixin(models.Model):
19
+ """TreeNode Tree Mixin."""
20
+
21
+ class Meta:
22
+ """Moxin Meta Class."""
23
+
24
+ abstract = True
25
+
26
+ @classmethod
27
+ def dump_tree(cls, instance=None):
28
+ """
29
+ Return an n-dimensional dictionary representing the model tree.
30
+
31
+ Introduced for compatibility with other packages.
32
+ """
33
+ return cls.get_tree(cls, instance)
34
+
35
+ @classmethod
36
+ @cached_method
37
+ def get_tree(cls, instance=None):
38
+ """
39
+ Return an n-dimensional dictionary representing the model tree.
40
+
41
+ If instance is passed, returns a subtree rooted at instance (using
42
+ get_descendants_queryset), if not passed, builds a tree for all nodes
43
+ (loads all records in one query).
44
+ """
45
+ # If instance is passed, we get all its descendants (including itself)
46
+ if instance:
47
+ queryset = instance.get_descendants_queryset(include_self=True)\
48
+ .annotate(depth=models.Max("parents_set__depth"))
49
+ else:
50
+ # Load all records at once
51
+ queryset = cls.objects.all()
52
+
53
+ # Dictionary for quick access to nodes by id and list for iteration
54
+ nodes_by_id = {}
55
+ nodes_list = []
56
+
57
+ # Loop through all nodes using an iterator
58
+ for node in queryset.iterator(chunk_size=1000):
59
+ # Create a dictionary for the node.
60
+ # In Python 3.7+, the standard dict preserves insertion order.
61
+ # We'll stick to the order:
62
+ # id, 'tn_parent', 'tn_priority', 'level', then the rest of
63
+ # the fields. Сlose the dictionary with the fields 'level', 'path',
64
+ # 'children'
65
+ node_dict = OrderedDict()
66
+ node_dict['id'] = node.id
67
+ node_dict['tn_parent'] = node.tn_parent_id
68
+ node_dict['tn_priority'] = node.tn_priority
69
+ node_dict['level'] = node.get_depth()
70
+ node_dict['path'] = node.get_breadcrumbs('tn_priority')
71
+
72
+ # Add the rest of the model fields.
73
+ # Iterate over all the fields obtained via _meta.get_fields()
74
+ for field in node._meta.get_fields():
75
+ # Skipping fields that are already added or not required
76
+ # (e.g. tn_closure or virtual links)
77
+ if field.name in [
78
+ 'id', 'tn_parent', 'tn_priority', 'tn_closure',
79
+ 'children']:
80
+ continue
81
+
82
+ try:
83
+ value = getattr(node, field.name)
84
+ except Exception:
85
+ value = None
86
+
87
+ # If the field is many-to-many, we get a list of IDs of
88
+ # related objects
89
+ if hasattr(value, 'all'):
90
+ value = list(value.all().values_list('id', flat=True))
91
+
92
+ node_dict[field.name] = value
93
+
94
+ # Adding a materialized path
95
+ node_dict['path'] = None
96
+ # Adding a nesting level
97
+ node_dict['level'] = None
98
+ # We initialize the list of children, which we will then fill
99
+ # when assembling the tree
100
+ node_dict['children'] = []
101
+
102
+ # Save the node both in the list and in the dictionary by id
103
+ # for quick access
104
+ nodes_by_id[node.id] = node_dict
105
+ nodes_list.append(node_dict)
106
+
107
+ # Build a tree: assign each node a list of its children
108
+ tree = []
109
+ for node_dict in nodes_list:
110
+ parent_id = node_dict['tn_parent']
111
+ # If there is a parent and it is present in nodes_by_id, then
112
+ # add the current node to the list of its children
113
+ if parent_id and parent_id in nodes_by_id:
114
+ parent_node = nodes_by_id[parent_id]
115
+ parent_node['children'].append(node_dict)
116
+ else:
117
+ # If there is no parent, this is the root node of the tree
118
+ tree.append(node_dict)
119
+
120
+ return tree
121
+
122
+ @classmethod
123
+ def get_tree_json(cls, instance=None):
124
+ """Represent the tree as a JSON-compatible string."""
125
+ tree = cls.dump_tree(instance)
126
+ return DjangoJSONEncoder().encode(tree)
127
+
128
+ @classmethod
129
+ def load_tree(cls, tree_data):
130
+ """
131
+ Load a tree from a list of dictionaries.
132
+
133
+ Loaded nodes are synchronized with the database: existing records
134
+ are updated, new ones are created.
135
+ Each dictionary must contain the 'id' key to identify the record.
136
+ """
137
+
138
+ def flatten_tree(nodes, model_fields):
139
+ """
140
+ Recursively traverse the tree and generate lists of nodes.
141
+
142
+ Each node in the list is a copy of the dictionary without
143
+ the service keys 'children', 'level', 'path'.
144
+ """
145
+ flat = []
146
+ for node in nodes:
147
+ # Create a copy of the dictionary so as not to affect
148
+ # the original tree
149
+ node_copy = node.copy()
150
+ children = node_copy.pop('children', [])
151
+ # Remove temporary/service fields that are not related to
152
+ # the model
153
+ for key in list(node_copy.keys() - model_fields):
154
+ del node_copy[key]
155
+ flat.append(node_copy)
156
+ # Recursively add all children
157
+ flat.extend(flatten_tree(children, model_fields))
158
+ return flat
159
+
160
+ # Get a flat list of nodes (from root to leaf)
161
+ model_fields = [field.name for field in cls._meta.get_fields()]
162
+ flat_nodes = flatten_tree(tree_data, model_fields)
163
+
164
+ # Load all ids for a given model from the database to minimize
165
+ # the number of database requests
166
+ existing_ids = set(cls.objects.values_list('id', flat=True))
167
+
168
+ # Lists for nodes to update and create
169
+ nodes_to_update = []
170
+ nodes_to_create = []
171
+
172
+ # Determine which model fields should be updated (excluding 'id')
173
+ # This assumes that all model fields are used in serialization
174
+ # (excluding service ones, like children)
175
+ field_names = model_fields.remove('id')
176
+
177
+ # Iterate over each node from the flat list
178
+ for node_data in flat_nodes:
179
+ # Collect data for creation/update.
180
+ # There is already an 'id' in node_data, so we can distinguish
181
+ # an existing entry from a new one.
182
+ data = {k: v for k, v in node_data.items()
183
+ if k in field_names or k == 'id'}
184
+
185
+ # Handle the foreign key tn_parent.
186
+ # If the value is None, then there is no parent, otherwise
187
+ # the parent id is expected.
188
+ # Additional checks can be added here if needed.
189
+ if 'tn_parent' in data and data['tn_parent'] is None:
190
+ data['tn_parent'] = None
191
+
192
+ # Если id уже есть в базе, то будем обновлять запись,
193
+ # иначе создаем новую.
194
+ if data['id'] in existing_ids:
195
+ nodes_to_update.append(cls(**data))
196
+ else:
197
+ nodes_to_create.append(cls(**data))
198
+
199
+ # Perform operations in a transaction to ensure data integrity.
200
+ with transaction.atomic():
201
+ # bulk_create – creating new nodes
202
+ if nodes_to_create:
203
+ cls.objects.bulk_create(nodes_to_create, batch_size=1000)
204
+ # bulk_update – updating existing nodes.
205
+ # When bulk_update, we must specify a list of fields to update.
206
+ if nodes_to_update:
207
+ cls.objects.bulk_update(
208
+ nodes_to_update,
209
+ fields=field_names,
210
+ batch_size=1000
211
+ )
212
+ cls.clear_cache()
213
+
214
+ @classmethod
215
+ def load_tree_json(cls, json_str):
216
+ """
217
+ Decode a JSON string into a dictionary.
218
+
219
+ Takes a JSON-compatible string and decodes it into a tree structure.
220
+ """
221
+ try:
222
+ tree_data = json.loads(json_str)
223
+ except json.JSONDecodeError as e:
224
+ raise ValueError(f"Error decoding JSON: {e}")
225
+
226
+ cls.load_tree(tree_data)
227
+
228
+ @classmethod
229
+ @cached_method
230
+ def get_tree_display(cls, instance=None, symbol="&mdash;"):
231
+ """Get a multiline string representing the model tree."""
232
+ # If instance is passed, we get all its descendants (including itself)
233
+ if instance:
234
+ queryset = instance.get_descendants_queryset(include_self=True)\
235
+ .prefetch_related("tn_children")\
236
+ .annotate(depth=models.Max("parents_set__depth"))\
237
+ .order_by("depth", "tn_parent", "tn_priority")
238
+ else:
239
+ queryset = cls.objects.all()\
240
+ .prefetch_related("tn_children")\
241
+ .annotate(depth=models.Max("parents_set__depth"))\
242
+ .order_by("depth", "tn_parent", "tn_priority")
243
+ # Convert queryset to list for indexed access
244
+ nodes = list(queryset)
245
+ sorted_nodes = cls._sort_node_list(nodes)
246
+ result = []
247
+ for node in sorted_nodes:
248
+ # Insert an indent proportional to the depth of the node
249
+ indent = symbol * node.depth
250
+ result.append(indent + str(node))
251
+ return result
252
+
253
+ @classmethod
254
+ @cached_method
255
+ def get_tree_annotated(cls):
256
+ """
257
+ Get an annotated list from a tree branch.
258
+
259
+ Something like this will be returned:
260
+ [
261
+ (a, {'open':True, 'close':[], 'level': 0})
262
+ (ab, {'open':True, 'close':[], 'level': 1})
263
+ (aba, {'open':True, 'close':[], 'level': 2})
264
+ (abb, {'open':False, 'close':[], 'level': 2})
265
+ (abc, {'open':False, 'close':[0,1], 'level': 2})
266
+ (ac, {'open':False, 'close':[0], 'level': 1})
267
+ ]
268
+
269
+ All nodes are ordered by materialized path.
270
+ This can be used with a template like this:
271
+
272
+ {% for item, info in annotated_list %}
273
+ {% if info.open %}
274
+ <ul><li>
275
+ {% else %}
276
+ </li><li>
277
+ {% endif %}
278
+
279
+ {{ item }}
280
+
281
+ {% for close in info.close %}
282
+ </li></ul>
283
+ {% endfor %}
284
+ {% endfor %}
285
+
286
+ """
287
+ # Load the tree with the required preloads and depth annotation.
288
+ queryset = cls.objects.all()\
289
+ .prefetch_related("tn_children")\
290
+ .annotate(depth=models.Max("parents_set__depth"))\
291
+ .order_by("depth", "tn_parent", "tn_priority")
292
+ # Convert queryset to list for indexed access
293
+ nodes = list(queryset)
294
+ total_nodes = len(nodes)
295
+ sorted_nodes = cls._sort_node_list(nodes)
296
+
297
+ result = []
298
+
299
+ for i, node in enumerate(sorted_nodes):
300
+ # Get the value to display the node
301
+ value = str(node)
302
+ # Determine if there are descendants (use prefetch_related to avoid
303
+ # additional queries)
304
+ value_open = len(node.tn_children.all()) > 0
305
+ level = node.depth
306
+
307
+ # Calculate the "close" field
308
+ if i + 1 < total_nodes:
309
+ next_node = nodes[i + 1]
310
+ depth_diff = level - next_node.depth
311
+ # If the next node is at a lower level, then some open
312
+ # levels need to be closed
313
+ value_close = list(range(next_node.depth, level)
314
+ ) if depth_diff > 0 else []
315
+ else:
316
+ # For the last node, close all open levels
317
+ value_close = list(range(0, level + 1))
318
+
319
+ result.append(
320
+ (value, {
321
+ "open": value_open,
322
+ "close": value_close,
323
+ "level": level
324
+ })
325
+ )
326
+ return result
327
+
328
+ @classmethod
329
+ @transaction.atomic
330
+ def update_tree(cls):
331
+ """Rebuilds the closure table."""
332
+ # Clear cache
333
+ cls.clear_cache()
334
+ cls.closure_model.delete_all()
335
+ objs = list(cls.objects.all())
336
+ cls.closure_model.objects.bulk_create(objs, batch_size=1000)
337
+
338
+ @classmethod
339
+ def delete_tree(cls):
340
+ """Delete the whole tree for the current node class."""
341
+ cls.clear_cache()
342
+ cls.objects.all().delete()
343
+
344
+ # The end
treenode/signals.py ADDED
@@ -0,0 +1,26 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ TreeNode Signals Module
4
+
5
+ Version: 2.1.0
6
+ Author: Timur Kady
7
+ Email: timurkady@yandex.com
8
+ """
9
+
10
+ from contextlib import contextmanager
11
+
12
+
13
+ @contextmanager
14
+ def disable_signals(signal, sender):
15
+ """Temporarily disable execution of signal generation."""
16
+ # Save current signal handlers
17
+ old_receivers = signal.receivers[:]
18
+ signal.receivers = []
19
+ try:
20
+ yield
21
+ finally:
22
+ # Restore handlers
23
+ signal.receivers = old_receivers
24
+
25
+
26
+ # Tne End