django-fast-treenode 1.1.3__py3-none-any.whl → 2.0.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 (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/managers.py CHANGED
@@ -1,69 +1,281 @@
1
1
  # -*- coding: utf-8 -*-
2
2
  """
3
- TreeNode Managers Module
3
+ Managers and QuerySets
4
4
 
5
+ This module defines custom managers and query sets for the TreeNode model.
6
+ It includes optimized bulk operations for handling hierarchical data
7
+ using the Closure Table approach.
8
+
9
+ Features:
10
+ - `ClosureQuerySet` and `ClosureModelManager` for managing closure records.
11
+ - `TreeNodeQuerySet` and `TreeNodeModelManager` for adjacency model operations.
12
+ - Optimized `bulk_create` and `bulk_update` methods with atomic transactions.
13
+
14
+ Version: 2.0.0
15
+ Author: Timur Kady
16
+ Email: timurkady@yandex.com
5
17
  """
6
18
 
7
- from django.db import models
8
- from django.db.models import Case, When, Value
19
+
20
+ from django.db import models, transaction
21
+
22
+
23
+ # ----------------------------------------------------------------------------
24
+ # Closere Model
25
+ # ----------------------------------------------------------------------------
26
+
27
+
28
+ class ClosureQuerySet(models.QuerySet):
29
+ """QuerySet для ClosureModel."""
30
+
31
+ @transaction.atomic
32
+ def bulk_create(self, objs, batch_size=1000):
33
+ """
34
+ Insert new nodes in bulk.
35
+
36
+ For newly created AdjacencyModel objects:
37
+ 1. Create self-referential records (parent=child, depth=0).
38
+ 2. Build ancestors for each node based on tn_parent.
39
+ """
40
+ # --- 1. Create self-referential closure records for each object.
41
+ self_closure_records = []
42
+ for item in objs:
43
+ self_closure_records.append(
44
+ self.model(parent=item, child=item, depth=0)
45
+ )
46
+ super().bulk_create(self_closure_records, batch_size=batch_size)
47
+
48
+ # --- 2. Preparing closure for parents
49
+ parent_ids = {node.tn_parent.pk for node in objs if node.tn_parent}
50
+ parent_closures = list(
51
+ self.filter(child__in=parent_ids)
52
+ .values('child', 'parent', 'depth')
53
+ )
54
+ # Pack them into a dict
55
+ parent_closures_dict = {}
56
+ for pc in parent_closures:
57
+ parent_id = pc['child']
58
+ parent_closures_dict.setdefault(parent_id, []).append(pc)
59
+
60
+ # --- 3. Get closure records for the objs themselves
61
+ node_ids = [node.pk for node in objs]
62
+ child_closures = list(
63
+ self.filter(parent__in=node_ids)
64
+ .values('parent', 'child', 'depth')
65
+ )
66
+ child_closures_dict = {}
67
+ for cc in child_closures:
68
+ parent_id = cc['parent']
69
+ child_closures_dict.setdefault(parent_id, []).append(cc)
70
+
71
+ # --- 4. Collecting new links
72
+ new_records = []
73
+ for node in objs:
74
+ if not node.tn_parent:
75
+ continue
76
+ # parent closure
77
+ parents = parent_closures_dict.get(node.tn_parent.pk, [])
78
+ # closure of descendants
79
+ # (often this is only the node itself, depth=0, but there can be
80
+ # nested ones)
81
+ children = child_closures_dict.get(node.pk, [])
82
+ # Combine parents x children
83
+ for p in parents:
84
+ for c in children:
85
+ new_records.append(self.model(
86
+ parent_id=p['parent'],
87
+ child_id=c['child'],
88
+ depth=p['depth'] + c['depth'] + 1
89
+ ))
90
+ # --- 5. bulk_create
91
+ result = []
92
+ if new_records:
93
+ result = super().bulk_create(new_records, batch_size=batch_size)
94
+ self.model.clear_cache()
95
+ return result
96
+
97
+ @transaction.atomic
98
+ def bulk_update(self, objs, fields=None, batch_size=1000):
99
+ """
100
+ Update the records in the closure table for the list of updated nodes.
101
+
102
+ For each node whose tn_parent has changed, the closure records
103
+ for its entire subtree are recalculated:
104
+ 1. The ancestor chain of the new parent is selected.
105
+ 2. The subtree (with closure records) of the updated node is selected.
106
+ 3. For each combination (ancestor, descendant), a new depth is
107
+ calculated.
108
+ 4. Old "dangling" records (those where the descendant has a link to
109
+ a non-subtree) are removed.
110
+ 5. New records are inserted using the bulk_create method.
111
+ """
112
+ if not objs:
113
+ return
114
+
115
+ # --- 1. We obtain chains of ancestors for new parents.
116
+ parent_ids = {node.tn_parent.pk for node in objs if node.tn_parent}
117
+ parent_closures = list(
118
+ self.filter(child__in=parent_ids).values(
119
+ 'child',
120
+ 'parent',
121
+ 'depth'
122
+ )
123
+ )
124
+ # We collect in a dictionary: key is the parent ID (tn_parent),
125
+ # value is the list of records.
126
+ parent_closures_dict = {}
127
+ for pc in parent_closures:
128
+ parent_closures_dict.setdefault(pc['child'], []).append(pc)
129
+
130
+ # --- 2. Obtain closing records for the subtrees of the
131
+ # nodes being updated.
132
+ updated_ids = [node.pk for node in objs]
133
+ subtree_closures = list(self.filter(parent__in=updated_ids).values(
134
+ 'parent',
135
+ 'child',
136
+ 'depth'
137
+ ))
138
+ # Group by ID of the node being updated (i.e. by parent in the
139
+ # closing record)
140
+ subtree_closures_dict = {}
141
+ for sc in subtree_closures:
142
+ subtree_closures_dict.setdefault(sc['parent'], []).append(sc)
143
+
144
+ # --- 3. Construct new close records for each updated node with
145
+ # a new parent.
146
+ new_records = []
147
+ for node in objs:
148
+ # If the node has become root (tn_parent=None), then there are
149
+ # no additional connections with ancestors.
150
+ if not node.tn_parent:
151
+ continue
152
+ # From the closing chain of the new parent we get a list of its
153
+ # ancestors
154
+ p_closures = parent_closures_dict.get(node.tn_parent.pk, [])
155
+ # From the node subtree we get the closing records
156
+ s_closures = subtree_closures_dict.get(node.pk, [])
157
+ # If for some reason the subtree entries are missing, we will
158
+ # ensure that there is a custom entry.
159
+ if not s_closures:
160
+ s_closures = [{
161
+ 'parent': node.pk,
162
+ 'child': node.pk,
163
+ 'depth': 0
164
+ }]
165
+ # Combine: for each ancestor of the new parent and for each
166
+ # descendant from the subtree
167
+ for p in p_closures:
168
+ for s in s_closures:
169
+ new_depth = p['depth'] + s['depth'] + 1
170
+ new_records.append(
171
+ self.model(
172
+ parent_id=p['parent'],
173
+ child_id=s['child'],
174
+ depth=new_depth
175
+ )
176
+ )
177
+
178
+ # --- 4. Remove old closing records so that there are no "tails".
179
+ # For each updated node, calculate a subset of IDs related to its
180
+ # subtree
181
+ for node in objs:
182
+ subtree_ids = set()
183
+ # Be sure to include the node itself (its self-link should
184
+ # already be there)
185
+ subtree_ids.add(node.pk)
186
+ for sc in subtree_closures_dict.get(node.pk, []):
187
+ subtree_ids.add(sc['child'])
188
+ # Remove records where the child belongs to the subtree, but the
189
+ # parent is not included in it.
190
+ self.filter(child_id__in=subtree_ids).exclude(
191
+ parent_id__in=subtree_ids).delete()
192
+
193
+ # --- 5. Insert new closing records in bulk.
194
+ if new_records:
195
+ super().bulk_create(new_records, batch_size=batch_size)
196
+ self.model.clear_cache()
197
+ return new_records
198
+
199
+
200
+ class ClosureModelManager(models.Manager):
201
+ """ClosureModel Manager."""
202
+
203
+ def get_queryset(self):
204
+ """get_queryset method."""
205
+ return ClosureQuerySet(self.model, using=self._db)
206
+
207
+ def bulk_create(self, objs, batch_size=1000):
208
+ """Create objects in bulk."""
209
+ self.model.clear_cache()
210
+ return self.get_queryset().bulk_create(objs, batch_size=batch_size)
211
+
212
+ def bulk_update(self, objs, fields=None, batch_size=1000):
213
+ """Move nodes in ClosureModel."""
214
+ self.model.clear_cache()
215
+ return self.get_queryset().bulk_update(
216
+ objs, fields, batch_size=batch_size
217
+ )
218
+
219
+ # ----------------------------------------------------------------------------
220
+ # TreeNode Model
221
+ # ----------------------------------------------------------------------------
9
222
 
10
223
 
11
224
  class TreeNodeQuerySet(models.QuerySet):
12
- """TreeNode Manager QuerySet Class"""
225
+ """TreeNodeModel QuerySet."""
13
226
 
14
227
  def __init__(self, model=None, query=None, using=None, hints=None):
228
+ """Init."""
15
229
  self.closure_model = model.closure_model
16
230
  super().__init__(model, query, using, hints)
17
231
 
18
- def bulk_create(self, objs, batch_size=None, ignore_conflicts=False):
232
+ @transaction.atomic
233
+ def bulk_create(self, objs, batch_size=1000, ignore_conflicts=False):
234
+ """
235
+ Bulk create.
236
+
237
+ Method of bulk creation objects with updating and processing of
238
+ the Closuse Model.
239
+ """
240
+ # Regular bulk_create for TreeNodeModel
19
241
  objs = super().bulk_create(objs, batch_size, ignore_conflicts)
242
+ # Call ClosureModel to insert closure records
243
+ self.closure_model.objects.bulk_create(objs, batch_size=batch_size)
244
+ # Возвращаем результат
245
+ self.model.clear_cache()
246
+ return self.filter(pk__in=[obj.pk for obj in objs])
20
247
 
21
- objs = self.model.closure_model.bulk_create([
22
- self.model.closure_model(
23
- parent=item,
24
- child=item,
25
- depth=0
26
- )
27
- ] for item in objs)
248
+ @transaction.atomic
249
+ def bulk_update(self, objs, fields, batch_size=1000, **kwargs):
250
+ """."""
251
+ closure_model = self.model.closure_model
252
+ if 'tn_parent' in fields:
253
+ # Попросим ClosureModel обработать move
254
+ closure_model.objects.bulk_update(objs)
255
+ result = super().bulk_update(objs, fields, batch_size, **kwargs)
256
+ closure_model.clear_cache()
257
+ return result
28
258
 
29
- for node in objs:
30
- qs = self.model.closure_model.objects.all()
31
- parents = qs.filter(child=node.tn_parent).values('parent', 'depth')
32
- children = qs.filter(parent=node).values('child', 'depth')
33
- objects = [
34
- self.model.closure_model(
35
- parent_id=p['parent'],
36
- child_id=c['child'],
37
- depth=p['depth'] + c['depth'] + 1
38
- )
39
- for p in parents
40
- for c in children
41
- ]
42
- node._closure_model.objects.bulk_create(objects)
43
-
44
- self.model._update_orders()
45
- return objs
46
-
47
-
48
- class TreeNodeManager(models.Manager):
49
- """TreeNode Manager Class"""
50
259
 
51
- def get_queryset(self):
52
- """
53
- Forms a QuerySet ordered by the materialized path.
260
+ class TreeNodeModelManager(models.Manager):
261
+ """TreeNodeModel Manager."""
262
+
263
+ def bulk_create(self, objs, batch_size=1000, ignore_conflicts=False):
54
264
  """
265
+ Bulk Create.
55
266
 
56
- qs = TreeNodeQuerySet(self.model, using=self._db)
57
- node_list = sorted([node for node in qs], key=lambda x: x.tn_order)
58
- pk_list = [node.pk for node in node_list]
59
-
60
- # Retrieve the queryset with the desired ordering
61
- return qs.filter(pk__in=pk_list).order_by(
62
- Case(*[When(pk=pk, then=Value(ordering))
63
- for ordering, pk in enumerate(pk_list)],
64
- default=Value(len(pk_list)),
65
- output_field=models.IntegerField(),
66
- )
267
+ Override bulk_create for the adjacency model.
268
+ Here we first clear the cache, then delegate the creation via our
269
+ custom QuerySet.
270
+ """
271
+ self.model.clear_cache()
272
+ return self.get_queryset().bulk_create(
273
+ objs, batch_size=batch_size, ignore_conflicts=ignore_conflicts
67
274
  )
68
275
 
69
- # End
276
+ def get_queryset(self):
277
+ """Return a QuerySet that sorts by 'tn_parent' and 'tn_priority'."""
278
+ queryset = TreeNodeQuerySet(self.model, using=self._db)
279
+ return queryset.order_by('tn_parent', 'tn_priority')
280
+
281
+ # The End
@@ -0,0 +1,7 @@
1
+ from .proxy import TreeNodeModel
2
+
3
+
4
+ __all__ = ["TreeNodeModel",]
5
+
6
+
7
+ # The End
@@ -0,0 +1,24 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Class Property Decorator
4
+
5
+ This module provides a `classproperty` decorator that allows defining
6
+ read-only class-level properties.
7
+
8
+ Features:
9
+ - Enables class-level properties similar to instance properties.
10
+ - Uses a custom descriptor for property-like behavior.
11
+
12
+ """
13
+
14
+
15
+ class classproperty(object):
16
+ """Classproperty class."""
17
+
18
+ def __init__(self, getter):
19
+ """Init."""
20
+ self.getter = getter
21
+
22
+ def __get__(self, instance, owner):
23
+ """Get."""
24
+ return self.getter(owner)
@@ -0,0 +1,168 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ TreeNode Closure Model
4
+
5
+ This module defines the Closure Table implementation for hierarchical
6
+ data storage in the TreeNode model. It supports efficient queries for
7
+ retrieving ancestors, descendants, breadcrumbs, and tree depth.
8
+
9
+ Features:
10
+ - Uses a Closure Table for efficient tree operations.
11
+ - Implements cached queries for improved performance.
12
+ - Provides bulk operations for inserting, moving, and deleting nodes.
13
+
14
+ Version: 2.0.0
15
+ Author: Timur Kady
16
+ Email: timurkady@yandex.com
17
+ """
18
+
19
+
20
+ from django.db import models, transaction
21
+
22
+ from ..managers import ClosureModelManager
23
+ from ..cache import cached_method, treenode_cache
24
+
25
+
26
+ class ClosureModel(models.Model):
27
+ """
28
+ Model for Closure Table.
29
+
30
+ Implements hierarchy storage using the Closure Table method.
31
+ """
32
+
33
+ parent = models.ForeignKey(
34
+ 'TreeNodeModel',
35
+ related_name='children_set',
36
+ on_delete=models.CASCADE,
37
+ )
38
+
39
+ child = models.ForeignKey(
40
+ 'TreeNodeModel',
41
+ related_name='parents_set',
42
+ on_delete=models.CASCADE,
43
+ )
44
+
45
+ depth = models.PositiveIntegerField()
46
+
47
+ objects = ClosureModelManager()
48
+
49
+ class Meta:
50
+ """Meta Class."""
51
+
52
+ abstract = True
53
+ unique_together = (("parent", "child"),)
54
+ indexes = [
55
+ models.Index(fields=["parent", "child"]),
56
+ ]
57
+
58
+ def __str__(self):
59
+ """Display information about a class object."""
60
+ return f"{self.parent} — {self.child} — {self.depth}"
61
+
62
+ # ----------- Methods of working with tree structure ----------- #
63
+
64
+ @classmethod
65
+ def clear_cache(cls):
66
+ """Clear cache for this model only."""
67
+ treenode_cache.invalidate(cls._meta.label)
68
+
69
+ @classmethod
70
+ @cached_method
71
+ def get_ancestors_queryset(cls, node, include_self=True, depth=None):
72
+ """Get the ancestors QuerySet (ordered from root to parent)."""
73
+ filters = {"child__pk": node.pk}
74
+ if depth is not None:
75
+ filters["depth__lte"] = depth
76
+ qs = cls.objects.all().filter(**filters)
77
+ if not include_self:
78
+ qs = qs.exclude(parent=node, child=node)
79
+ return qs
80
+
81
+ @classmethod
82
+ @cached_method
83
+ def get_breadcrumbs(cls, node, attr=None):
84
+ """Get the breadcrumbs to current node (included)."""
85
+ qs = cls.get_ancestors_queryset(
86
+ node, include_self=True).order_by('-depth')
87
+ if attr:
88
+ return [getattr(item.parent, attr)
89
+ if hasattr(item.parent, attr) else None for item in qs]
90
+ else:
91
+ return [item.parent.pk for item in qs]
92
+
93
+ @classmethod
94
+ @cached_method
95
+ def get_descendants_queryset(cls, node, include_self=False, depth=None):
96
+ """Get the descendants QuerySet (ordered from parent to leaf)."""
97
+ filters = {"parent__pk": node.pk}
98
+ if depth is not None:
99
+ filters["depth__lte"] = depth
100
+ qs = cls.objects.all().filter(**filters).select_related('parent')
101
+ if not include_self:
102
+ qs = qs.exclude(parent=node, child=node)
103
+ return qs
104
+
105
+ @classmethod
106
+ @cached_method
107
+ def get_root(cls, node):
108
+ """Get the root node for the given node."""
109
+ return cls.objects.filter(child=node).values_list(
110
+ "parent", flat=True).order_by('-depth').first()
111
+
112
+ @classmethod
113
+ @cached_method
114
+ def get_depth(cls, node):
115
+ """Get the node depth (how deep the node is in the tree)."""
116
+ result = cls.objects.filter(child__pk=node.pk).aggregate(
117
+ models.Max("depth")
118
+ )["depth__max"]
119
+ return result if result is not None else 0
120
+
121
+ @classmethod
122
+ @cached_method
123
+ def get_level(cls, node):
124
+ """Get the node level (starting from 1)."""
125
+ return cls.objects.filter(child__pk=node.pk).aggregate(
126
+ models.Max("depth"))["depth__max"] + 1
127
+
128
+ @classmethod
129
+ @cached_method
130
+ def get_path(cls, node, delimiter='.', format_str=""):
131
+ """Return Materialized Path of node."""
132
+ str_ = "{%s}" % format_str
133
+ priorities = cls.get_breadcrumbs(node, attr='tn_priority')
134
+ path = delimiter.join([str_.format(p) for p in priorities])
135
+ return path
136
+
137
+ @classmethod
138
+ @transaction.atomic
139
+ def insert_node(cls, node):
140
+ """Add a node to a Closure table."""
141
+ # Call bulk_create passing a single object
142
+ cls.objects.bulk_create([node], batch_size=1000)
143
+ # Clear cache
144
+ cls.clear_cache()
145
+
146
+ @classmethod
147
+ @transaction.atomic
148
+ def move_node(cls, node):
149
+ """Move a node (and its subtree) to a new parent."""
150
+ # Call bulk_update passing a single object
151
+ cls.objects.bulk_update([node], batch_size=1000)
152
+ # Clear cache
153
+ cls.clear_cache()
154
+
155
+ @classmethod
156
+ @transaction.atomic
157
+ def delete_all(cls):
158
+ """Clear the Closure Table."""
159
+ # Clear cache
160
+ cls.clear_cache()
161
+ cls.objects.all().delete()
162
+
163
+ def save(self, force_insert=False, *args, **kwargs):
164
+ """Save method."""
165
+ super().save(force_insert, *args, **kwargs)
166
+ self._meta.model.clear_cache()
167
+
168
+ # The End
@@ -0,0 +1,71 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ TreeNode Factory for Closure Table
4
+
5
+ This module provides a metaclass `TreeFactory` that automatically binds
6
+ a model to a Closure Table for hierarchical data storage.
7
+
8
+ Features:
9
+ - Ensures non-abstract, non-proxy models get a corresponding Closure Table.
10
+ - Dynamically creates and assigns a Closure Model for each TreeNodeModel.
11
+ - Facilitates the management of hierarchical relationships.
12
+
13
+ Version: 2.0.0
14
+ Author: Timur Kady
15
+ Email: timurkady@yandex.com
16
+ """
17
+
18
+
19
+ import sys
20
+ from django.db import models
21
+ from .closure import ClosureModel # Используем готовый ClosureModel
22
+
23
+
24
+ class TreeFactory(models.base.ModelBase):
25
+ """
26
+ Metaclass for binding a model to a Closure Table.
27
+
28
+ For each non-abstract, non-proxy, and "top" (without parents) model,
29
+ assigns the `ClosureModel` as the closure table.
30
+ """
31
+
32
+ def __init__(cls, name, bases, dct):
33
+ """Class initialization.
34
+
35
+ We check that the model:
36
+ - is not abstract
37
+ - is not a proxy
38
+ - is not a child
39
+ and only then assign the ClosureModel.
40
+ """
41
+ super().__init__(name, bases, dct)
42
+
43
+ if (cls._meta.abstract or cls._meta.proxy or
44
+ cls._meta.get_parent_list()):
45
+ return
46
+
47
+ closure_name = f"{cls._meta.object_name}ClosureModel"
48
+ if getattr(cls, "closure_model", None) is not None:
49
+ return
50
+
51
+ fields = {
52
+ "parent": models.ForeignKey(
53
+ cls._meta.model,
54
+ related_name="children_set",
55
+ on_delete=models.CASCADE
56
+ ),
57
+
58
+ "child": models.ForeignKey(
59
+ cls._meta.model,
60
+ related_name="parents_set",
61
+ on_delete=models.CASCADE,
62
+ ),
63
+
64
+ "__module__": cls.__module__
65
+ }
66
+ closure_model = type(closure_name, (ClosureModel,), fields)
67
+ setattr(sys.modules[cls.__module__], closure_name, closure_model)
68
+
69
+ cls.closure_model = closure_model
70
+
71
+ # The End