django-fast-treenode 2.1.4__py3-none-any.whl → 3.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 (107) hide show
  1. django_fast_treenode-3.0.0.dist-info/METADATA +203 -0
  2. django_fast_treenode-3.0.0.dist-info/RECORD +90 -0
  3. {django_fast_treenode-2.1.4.dist-info → django_fast_treenode-3.0.0.dist-info}/WHEEL +1 -1
  4. treenode/admin/__init__.py +2 -7
  5. treenode/admin/admin.py +138 -209
  6. treenode/admin/changelist.py +21 -39
  7. treenode/admin/exporter.py +170 -0
  8. treenode/admin/importer.py +171 -0
  9. treenode/admin/mixin.py +291 -0
  10. treenode/apps.py +42 -20
  11. treenode/cache.py +192 -303
  12. treenode/forms.py +45 -65
  13. treenode/managers/__init__.py +4 -20
  14. treenode/managers/managers.py +216 -0
  15. treenode/managers/queries.py +233 -0
  16. treenode/managers/tasks.py +167 -0
  17. treenode/models/__init__.py +8 -5
  18. treenode/models/decorators.py +54 -0
  19. treenode/models/factory.py +44 -68
  20. treenode/models/mixins/__init__.py +2 -1
  21. treenode/models/mixins/ancestors.py +44 -20
  22. treenode/models/mixins/children.py +33 -26
  23. treenode/models/mixins/descendants.py +33 -22
  24. treenode/models/mixins/family.py +25 -15
  25. treenode/models/mixins/logical.py +23 -21
  26. treenode/models/mixins/node.py +162 -104
  27. treenode/models/mixins/properties.py +22 -16
  28. treenode/models/mixins/roots.py +59 -15
  29. treenode/models/mixins/siblings.py +46 -43
  30. treenode/models/mixins/tree.py +212 -153
  31. treenode/models/mixins/update.py +154 -0
  32. treenode/models/models.py +365 -0
  33. treenode/settings.py +28 -0
  34. treenode/static/{treenode/css → css}/tree_widget.css +1 -1
  35. treenode/static/{treenode/css → css}/treenode_admin.css +43 -2
  36. treenode/static/css/treenode_tabs.css +51 -0
  37. treenode/static/js/lz-string.min.js +1 -0
  38. treenode/static/{treenode/js → js}/tree_widget.js +9 -23
  39. treenode/static/js/treenode_admin.js +531 -0
  40. treenode/static/vendors/jquery-ui/AUTHORS.txt +384 -0
  41. treenode/static/vendors/jquery-ui/LICENSE.txt +43 -0
  42. treenode/static/vendors/jquery-ui/external/jquery/jquery.js +10716 -0
  43. treenode/static/vendors/jquery-ui/images/ui-icons_444444_256x240.png +0 -0
  44. treenode/static/vendors/jquery-ui/images/ui-icons_555555_256x240.png +0 -0
  45. treenode/static/vendors/jquery-ui/images/ui-icons_777620_256x240.png +0 -0
  46. treenode/static/vendors/jquery-ui/images/ui-icons_777777_256x240.png +0 -0
  47. treenode/static/vendors/jquery-ui/images/ui-icons_cc0000_256x240.png +0 -0
  48. treenode/static/vendors/jquery-ui/images/ui-icons_ffffff_256x240.png +0 -0
  49. treenode/static/vendors/jquery-ui/index.html +297 -0
  50. treenode/static/vendors/jquery-ui/jquery-ui.css +438 -0
  51. treenode/static/vendors/jquery-ui/jquery-ui.js +5223 -0
  52. treenode/static/vendors/jquery-ui/jquery-ui.min.css +7 -0
  53. treenode/static/vendors/jquery-ui/jquery-ui.min.js +6 -0
  54. treenode/static/vendors/jquery-ui/jquery-ui.structure.css +16 -0
  55. treenode/static/vendors/jquery-ui/jquery-ui.structure.min.css +5 -0
  56. treenode/static/vendors/jquery-ui/jquery-ui.theme.css +439 -0
  57. treenode/static/vendors/jquery-ui/jquery-ui.theme.min.css +5 -0
  58. treenode/static/vendors/jquery-ui/package.json +82 -0
  59. treenode/templates/admin/treenode_changelist.html +25 -0
  60. treenode/templates/admin/treenode_import_export.html +85 -0
  61. treenode/templates/admin/treenode_rows.html +57 -0
  62. treenode/tests.py +3 -0
  63. treenode/urls.py +6 -27
  64. treenode/utils/__init__.py +0 -15
  65. treenode/utils/db/__init__.py +7 -0
  66. treenode/utils/db/compiler.py +114 -0
  67. treenode/utils/db/db_vendor.py +50 -0
  68. treenode/utils/db/service.py +84 -0
  69. treenode/utils/db/sqlcompat.py +60 -0
  70. treenode/utils/db/sqlquery.py +70 -0
  71. treenode/version.py +2 -2
  72. treenode/views/__init__.py +5 -0
  73. treenode/views/autoapi.py +91 -0
  74. treenode/views/autocomplete.py +52 -0
  75. treenode/views/children.py +41 -0
  76. treenode/views/common.py +23 -0
  77. treenode/views/crud.py +209 -0
  78. treenode/views/search.py +48 -0
  79. treenode/widgets.py +27 -44
  80. django_fast_treenode-2.1.4.dist-info/METADATA +0 -166
  81. django_fast_treenode-2.1.4.dist-info/RECORD +0 -63
  82. treenode/admin/mixins.py +0 -302
  83. treenode/managers/adjacency.py +0 -205
  84. treenode/managers/closure.py +0 -278
  85. treenode/models/adjacency.py +0 -342
  86. treenode/models/classproperty.py +0 -27
  87. treenode/models/closure.py +0 -122
  88. treenode/static/treenode/js/.gitkeep +0 -1
  89. treenode/static/treenode/js/treenode_admin.js +0 -131
  90. treenode/templates/admin/export_success.html +0 -26
  91. treenode/templates/admin/tree_node_changelist.html +0 -19
  92. treenode/templates/admin/tree_node_export.html +0 -27
  93. treenode/templates/admin/tree_node_import.html +0 -45
  94. treenode/templates/admin/tree_node_import_report.html +0 -32
  95. treenode/templates/widgets/tree_widget.css +0 -23
  96. treenode/utils/aid.py +0 -46
  97. treenode/utils/base16.py +0 -38
  98. treenode/utils/base36.py +0 -37
  99. treenode/utils/db.py +0 -116
  100. treenode/utils/exporter.py +0 -196
  101. treenode/utils/importer.py +0 -328
  102. treenode/utils/radix.py +0 -61
  103. treenode/views.py +0 -184
  104. {django_fast_treenode-2.1.4.dist-info → django_fast_treenode-3.0.0.dist-info/licenses}/LICENSE +0 -0
  105. {django_fast_treenode-2.1.4.dist-info → django_fast_treenode-3.0.0.dist-info}/top_level.txt +0 -0
  106. /treenode/static/{treenode → css}/.gitkeep +0 -0
  107. /treenode/static/{treenode/css → js}/.gitkeep +0 -0
treenode/forms.py CHANGED
@@ -10,12 +10,12 @@ Functions:
10
10
  - __init__: Initializes the form and filters out invalid parent choices.
11
11
  - factory: Dynamically creates a form class for a given TreeNode model.
12
12
 
13
- Version: 2.1.0
13
+ Version: 3.0.0
14
14
  Author: Timur Kady
15
15
  Email: timurkady@yandex.com
16
16
  """
17
17
 
18
- from django import forms
18
+ from django.forms import ModelForm
19
19
  from django.forms.models import ModelChoiceField, ModelChoiceIterator
20
20
  from django.utils.translation import gettext_lazy as _
21
21
 
@@ -27,13 +27,10 @@ class SortedModelChoiceIterator(ModelChoiceIterator):
27
27
 
28
28
  def __iter__(self):
29
29
  """Return sorted choices based on tn_order."""
30
- qs_list = list(self.queryset.all())
31
-
32
- # Sort objects
33
- sorted_objects = self.queryset.model._sort_node_list(qs_list)
30
+ qs_list = list(self.queryset.order_by('_path').all())
34
31
 
35
32
  # Iterate yield (value, label) pairs.
36
- for obj in sorted_objects:
33
+ for obj in qs_list:
37
34
  yield (
38
35
  self.field.prepare_value(obj),
39
36
  self.field.label_from_instance(obj)
@@ -60,68 +57,51 @@ class SortedModelChoiceField(ModelChoiceField):
60
57
  choices = property(_get_choices, _set_choices)
61
58
 
62
59
 
63
- class TreeNodeForm(forms.ModelForm):
64
- """
65
- TreeNode Form Class.
60
+ class TreeNodeForm(ModelForm):
61
+ """TreeNodeModelAdmin Form Class."""
66
62
 
67
- ModelForm for dynamically determined TreeNode model.
68
- Uses TreeWidget and excludes self and descendants from the parent choices.
69
- """
63
+ def __init__(self, *args, **kwargs):
64
+ """Init Form."""
65
+ super(TreeNodeForm, self).__init__(*args, **kwargs)
66
+ self.model = self.instance._meta.model
67
+
68
+ if 'parent' not in self.fields:
69
+ return
70
+
71
+ exclude_pks = []
72
+ if self.instance.pk:
73
+ exclude_pks = self.instance.query(
74
+ objects='descendants',
75
+ include_self=True
76
+ )
70
77
 
71
- class Meta:
72
- """Meta Class."""
78
+ queryset = self.model.objects\
79
+ .exclude(pk__in=exclude_pks)\
80
+ .order_by('_path')\
81
+ .all()
73
82
 
74
- model = None
75
- fields = "__all__"
76
- widgets = {
77
- "tn_parent": TreeWidget()
78
- }
83
+ self.fields['parent'].queryset = queryset
84
+ self.fields["parent"].required = False
85
+ self.fields["parent"].empty_label = _("Root")
79
86
 
80
- def __init__(self, *args, **kwargs):
81
- """Init Method."""
82
- super().__init__(*args, **kwargs)
83
-
84
- model = self._meta.model
85
-
86
- if "tn_parent" in self.fields:
87
- self.fields["tn_parent"].required = False
88
- self.fields["tn_parent"].empty_label = _("Root")
89
- queryset = model.objects.all()
90
-
91
- original_field = self.fields["tn_parent"]
92
- self.fields["tn_parent"] = SortedModelChoiceField(
93
- queryset=queryset,
94
- label=original_field.label,
95
- widget=original_field.widget,
96
- empty_label=original_field.empty_label,
97
- required=False
98
- )
99
- self.fields["tn_parent"].widget.model = queryset.model
100
-
101
- # If there is a current value, set it
102
- if self.instance and self.instance.pk and self.instance.tn_parent:
103
- self.fields["tn_parent"].initial = self.instance.tn_parent
104
-
105
- @classmethod
106
- def factory(cls, model):
107
- """
108
- Create a form class dynamically for the given TreeNode model.
109
-
110
- This ensures that the form works with different concrete models.
111
- """
112
- class Meta:
113
- model = model
114
- fields = "__all__"
115
- widgets = {
116
- "tn_parent": TreeWidget(
117
- attrs={
118
- "data-autocomplete-light": "true",
119
- "data-url": "/tree-autocomplete/",
120
- }
121
- )
122
- }
123
-
124
- return type(f"{model.__name__}Form", (cls,), {"Meta": Meta})
87
+ original_field = self.fields["parent"]
88
+
89
+ self.fields["parent"] = SortedModelChoiceField(
90
+ queryset=queryset,
91
+ label=original_field.label,
92
+ widget=original_field.widget,
93
+ empty_label=original_field.empty_label,
94
+ required=False
95
+ )
96
+ self.fields["parent"].widget.model = self.model
97
+
98
+ # If there is a current value, set it
99
+ if self.instance and self.instance.pk and self.instance.parent:
100
+ self.fields["parent"].initial = self.instance.parent
125
101
 
102
+ class Meta:
103
+ widgets = {
104
+ 'parent': TreeWidget(),
105
+ }
126
106
 
127
107
  # The End
@@ -1,21 +1,5 @@
1
- # -*- coding: utf-8 -*-
2
- """
3
- Managers and QuerySets
1
+ from .managers import TreeNodeManager
2
+ from .queries import TreeQueryManager
3
+ from .tasks import TreeTaskManager
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
- - `ClosureModelManager` for managing closure records.
11
- - `TreeNodeModelManager` for adjacency model operations.
12
-
13
- Version: 2.1.0
14
- Author: Timur Kady
15
- Email: timurkady@yandex.com
16
- """
17
-
18
- from .closure import ClosureModelManager
19
- from .adjacency import TreeNodeModelManager
20
-
21
- __all__ = ["TreeNodeModelManager", "ClosureModelManager"]
5
+ __all__ = ['TreeNodeManager', 'TreeQueryManager', 'TreeTaskManager']
@@ -0,0 +1,216 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Manager and Query Set Customization Module
4
+
5
+ This module defines custom managers and query sets for the TreeNodeModel.
6
+ It includes operations for synchronizing additional fields associated with
7
+ the Materialized Path method implementation.
8
+
9
+ Version: 3.0.0
10
+ Author: Timur Kady
11
+ Email: timurkady@yandex.com
12
+ """
13
+
14
+ from django.db import models # , transaction
15
+ from django.utils.translation import gettext_lazy as _
16
+ import logging
17
+
18
+ from ..cache import treenode_cache as cache
19
+
20
+
21
+ class TreeNodeQuerySet(models.QuerySet):
22
+ """TreeNodeModel QuerySet."""
23
+
24
+ def create(self, **kwargs):
25
+ """Create an object."""
26
+ obj = self.model(**kwargs)
27
+ obj.save()
28
+ return obj
29
+
30
+ def get_or_create(self, defaults=None, **kwargs):
31
+ """Get or create an object."""
32
+ defaults = defaults or {}
33
+ created = False
34
+
35
+ try:
36
+ obj = super().get(**kwargs)
37
+ except models.DoesNotExist:
38
+ params = {k: v for k, v in kwargs.items() if "__" not in k}
39
+ params.update(
40
+ {k: v() if callable(v) else v for k, v in defaults.items()}
41
+ )
42
+ obj = self.model(**params)
43
+ obj.save()
44
+ created = True
45
+ return obj, created
46
+
47
+ def update(self, **kwargs):
48
+ """Update method for TreeNodeQuerySet.
49
+
50
+ If kwargs contains updates for 'parent' or 'priority',
51
+ then specialized bulk_update logic is used, which:
52
+ - Updates allowed fields directly;
53
+ - If parent is updated, calls _bulk_move (updates _path and parent);
54
+ - If priority is updated (without parent), updates sibling order.
55
+ Otherwise, the standard update is called.
56
+ """
57
+ forbidden = {'_path', '_depth'}
58
+ if forbidden.intersection(kwargs.keys()):
59
+ raise ValueError(
60
+ _(f"Fields cannot be updated directly: {', '.join(forbidden)}")
61
+ )
62
+
63
+ result = 0
64
+ excluded_fields = {"parent", "priority", "_path", "_depth"}
65
+ params = {key: value for key,
66
+ value in kwargs.items() if key not in excluded_fields}
67
+
68
+ if params:
69
+ # Normal update
70
+ result = super().update(**params)
71
+
72
+ cache.invalidate(self.model._meta.label)
73
+ return result
74
+
75
+ def update_or_create(self, defaults=None, **kwargs):
76
+ """Update or create an object."""
77
+ params = {**(defaults or {}), **kwargs}
78
+ created = False
79
+ try:
80
+ obj = super().get(**kwargs)
81
+ obj.update(**params)
82
+ except models.DoesNotExist:
83
+ obj = self.model(**params)
84
+ obj.save()
85
+ created = True
86
+ return obj, created
87
+
88
+ def _raw_update(self, **kwargs):
89
+ """
90
+ Bypass custom update() logic (e.g. field protection).
91
+
92
+ WARNING: Unsafe low-level update bypassing all TreeNode protections.
93
+ Use only when bypassing _path/_depth/priority safety checks is
94
+ intentional.
95
+ """
96
+ result = models.QuerySet(self.model, using=self.db).update(**kwargs)
97
+ return result
98
+
99
+ # batch_size=None, **kwargs):
100
+ def _raw_bulk_update(self, objs, fields, *args, **kwargs):
101
+ """
102
+ Bypass custom bulk_update logic (e.g. field protection).
103
+
104
+ WARNING: Unsafe low-level update bypassing all TreeNode protections.
105
+ Use only when bypassing _path/_depth/priority safety checks is
106
+ intentional.
107
+ """
108
+ base_qs = models.QuerySet(self.model, using=self.db)
109
+ result = base_qs.bulk_update(objs, fields, *args, **kwargs)
110
+
111
+ return result
112
+
113
+ def _raw_delete(self, using=None):
114
+ return models.QuerySet(self.model, using=using or self.db)\
115
+ ._raw_delete(using=using or self.db)
116
+
117
+ def __iter__(self):
118
+ """Iterate queryset."""
119
+ try:
120
+ if len(self.model.tasks.queue) > 0:
121
+ # print("🌲 TreeNodeQuerySet: auto-run (iter)")
122
+ self.model.tasks.run()
123
+ except Exception as e:
124
+ logging.error("⚠️ Tree flush failed silently (iter): %s", e)
125
+ return super().__iter__()
126
+
127
+ def _fetch_all(self):
128
+ """Extract data for a queryset from the database."""
129
+ try:
130
+ tasks = self.model.tasks
131
+ if len(tasks.queue) > 0:
132
+ # print("🌲 TreeNodeQuerySet: auto-run (_fetch_all)")
133
+ tasks.run()
134
+ except Exception as e:
135
+ logging.error("⚠️ Tree flush failed silently: %s", e)
136
+ super()._fetch_all()
137
+
138
+ # ------------------------------------------------------------------
139
+ #
140
+ # Managers
141
+ #
142
+ # ------------------------------------------------------------------
143
+
144
+
145
+ class TreeNodeManager(models.Manager):
146
+ """Tree Manager Class."""
147
+
148
+ def get_queryset(self):
149
+ """Get QuerySet."""
150
+ return TreeNodeQuerySet(self.model, using=self._db).order_by(
151
+ "_depth", "priority"
152
+ )
153
+
154
+ def bulk_create(self, objs, *args, **kwargs):
155
+ """Create objects in bulk and schedule tree rebuilds."""
156
+ result = super().bulk_create(objs, *args, **kwargs)
157
+
158
+ # Collect parent_ids, stop at first None
159
+ parent_ids = set()
160
+ for obj in objs:
161
+ pid = obj.parent_id
162
+ if pid is None:
163
+ self.model.tasks.queue.clear()
164
+ self.model.tasks.add("update", None)
165
+ break
166
+ parent_ids.add(pid)
167
+ else:
168
+ for pid in parent_ids:
169
+ self.model.tasks.add("update", pid)
170
+
171
+ # self.model.tasks.run()
172
+ return result
173
+
174
+ def bulk_update(self, objs, fields, batch_size=None):
175
+ """Update objects in bulk and schedule tree rebuilds if needed."""
176
+ result = super().bulk_update(objs, fields, batch_size)
177
+
178
+ parent_ids = set()
179
+ for obj in objs:
180
+ pid = obj.parent_id
181
+ if pid is None:
182
+ self.model.tasks.queue.clear()
183
+ self.model.tasks.add("update", None)
184
+ break
185
+ parent_ids.add(pid)
186
+ else:
187
+ for pid in parent_ids:
188
+ self.model.tasks.add("update", pid)
189
+
190
+ # self.model.tasks.run()
191
+ return result
192
+
193
+ def _raw_update(self, *args, **kwargs):
194
+ """
195
+ Update objects in bulk.
196
+
197
+ WARNING: Unsafe low-level update bypassing all TreeNode protections.
198
+ Use only when bypassing _path/_depth/priority safety checks is
199
+ intentional.
200
+ """
201
+ return models.QuerySet(self.model, using=self.db)\
202
+ .update(*args, **kwargs)
203
+
204
+ def _raw_bulk_update(self, *args, **kwargs):
205
+ """
206
+ Update objects in bulk.
207
+
208
+ WARNING: Unsafe low-level update bypassing all TreeNode protections.
209
+ Use only when bypassing _path/_depth/priority safety checks is
210
+ inte
211
+ """
212
+ return models.QuerySet(self.model, using=self.db)\
213
+ .bulk_update(*args, **kwargs)
214
+
215
+
216
+ # The End
@@ -0,0 +1,233 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Low-level SQL Query Manager.
4
+
5
+ Encapsulates all logic to retrieve related primary keys based on relationships
6
+ (e.g., ancestors, children, descendants, siblings, family, root) using raw SQL.
7
+
8
+ Version: 3.0.0
9
+ Author: Timur Kady
10
+ Email: timurkady@yandex.com
11
+ """
12
+
13
+
14
+ from django.db import connection
15
+
16
+
17
+ class TreeQuery:
18
+ """Class to manage tree-structure SQL queries."""
19
+
20
+ def __init__(self, instance):
21
+ """Initialize with the given model instance."""
22
+ self.node = instance
23
+ self.db_table = instance._meta.db_table
24
+
25
+ def __call__(self, *args, **kwargs):
26
+ """Call function."""
27
+ return self.get_relative_pks(*args, **kwargs)
28
+
29
+ def execute_query(self, sql, params):
30
+ """Execute the given SQL with parameters and fetch all the results."""
31
+ with connection.cursor() as cursor:
32
+ cursor.execute(sql, params)
33
+ return cursor.fetchall()
34
+
35
+ def wrap_union_all(self, queries):
36
+ """
37
+ Combine multiple SQL queries using UNION ALL.
38
+
39
+ Each query is a tuple: (sql, params).
40
+ Returns a tuple: (combined_sql, combined_params).
41
+ """
42
+ union_query = " UNION ALL ".join(f"({q[0]})" for q in queries)
43
+ combined_params = []
44
+ for q in queries:
45
+ combined_params.extend(q[1])
46
+ return union_query, combined_params
47
+
48
+ def order_by(self, sql, order_by_clause):
49
+ """Wrap the SQL in an outer query to enforce ordering."""
50
+ return f"SELECT * FROM ({sql}) AS combined ORDER BY {order_by_clause}"
51
+
52
+ def get_children(self):
53
+ """
54
+ Build SQL for the 'children' relationship.
55
+
56
+ The current node is not included.
57
+ """
58
+ sql = f"SELECT id, priority FROM {self.db_table} WHERE parent_id = %s ORDER BY priority" # noqa: D501
59
+ params = [self.node.pk]
60
+ return sql, params
61
+
62
+ def get_siblings(self, include_self=True):
63
+ """
64
+ Build SQL for the 'siblings' relationship.
65
+
66
+ If include_self is True, the current node is included by performing
67
+ a UNION ALL.
68
+ """
69
+ if self.node.parent_id is None:
70
+ sql1 = f"SELECT id, priority FROM {self.db_table} WHERE parent_id IS NULL AND id <> %s" # noqa: D501
71
+ params1 = [self.node.pk]
72
+ else:
73
+ sql1 = f"SELECT id, priority FROM {self.db_table} WHERE parent_id = %s AND id <> %s" # noqa: D501
74
+ params1 = [self.node.parent_id, self.node.pk]
75
+
76
+ if include_self:
77
+ sql2 = f"SELECT id, priority FROM {self.db_table} WHERE id = %s"
78
+ params2 = [self.node.pk]
79
+ combined_sql, combined_params = self.wrap_union_all(
80
+ [(sql1, params1), (sql2, params2)])
81
+ sql = self.order_by(combined_sql, "priority")
82
+ return sql, combined_params
83
+ else:
84
+ sql = self.order_by(sql1, "priority")
85
+ return sql, params1
86
+
87
+ def get_descendants(self, include_self, depth):
88
+ """
89
+ Build SQL for the 'descendants' relationship.
90
+
91
+ Optionally limits the depth, and includes the current node if requested.
92
+ """
93
+ from_path = self.node._path + "."
94
+ to_path = self.node._path + "/"
95
+ base_sql = f"SELECT id, _depth, priority FROM {self.db_table} WHERE _path >= %s AND _path < %s" # noqa: D501
96
+ params = [from_path, to_path]
97
+
98
+ if depth is not None:
99
+ # Use values_list to fetch _depth without loading the full model
100
+ depth_val = getattr(self.node, "_depth", None)
101
+ if depth_val is None:
102
+ depth_val = type(self.node).objects.values_list(
103
+ "_depth", flat=True).get(pk=self.node.pk)
104
+ base_sql += " AND _depth <= %s"
105
+ params.append(depth_val + depth)
106
+
107
+ if include_self:
108
+ sql_self = f"SELECT id, _depth, priority FROM {self.db_table} WHERE id = %s" # noqa: D501
109
+ union_sql, union_params = self.wrap_union_all(
110
+ [(base_sql, params), (sql_self, [self.node.pk])])
111
+ else:
112
+ union_sql, union_params = base_sql, params
113
+
114
+ union_sql = self.order_by(union_sql, "_depth, priority")
115
+ return union_sql, union_params
116
+
117
+ def get_ancestors(self, include_self):
118
+ """
119
+ Retrieve ancestors using a recursive CTE.
120
+
121
+ Returns the list of IDs in order (from root to immediate parent).
122
+ """
123
+ sql = (
124
+ f"WITH RECURSIVE ancestors_cte(id, lvl) AS ("
125
+ f" SELECT (SELECT parent_id FROM {self.db_table} WHERE id = %s), 1 " # noqa: D501
126
+ f" UNION ALL "
127
+ f" SELECT p.parent_id, lvl + 1 FROM ancestors_cte a "
128
+ f" JOIN {self.db_table} p ON p.id = a.id "
129
+ f" WHERE a.id IS NOT NULL"
130
+ f") "
131
+ f"SELECT id FROM ancestors_cte WHERE id IS NOT NULL"
132
+ )
133
+ params = [self.node.pk]
134
+ rows = self.execute_query(sql, params)
135
+ ancestor_ids = [row[0] for row in rows]
136
+ if include_self:
137
+ ancestor_ids.insert(0, self.node.pk)
138
+ # Reverse the order to get from the root to the immediate parent.
139
+ return ancestor_ids[::-1]
140
+
141
+ def get_family(self, include_self, depth):
142
+ """
143
+ Build SQL for the 'family' relationship.
144
+
145
+ Family is defined as the union of ancestors and descendants.
146
+ The ancestors condition uses _path less than current node’s _path,
147
+ and the descendants condition uses a range on _path.
148
+ """
149
+ ancestors_condition = "_path < %s"
150
+ descendants_condition = "(_path >= %s AND _path < %s)"
151
+ family_sql = f"SELECT id, _depth, priority FROM {self.db_table} WHERE {ancestors_condition} OR {descendants_condition}" # noqa: D501
152
+ params = [self.node._path, self.node._path + ".", self.node._path + "/"]
153
+
154
+ queries = [(family_sql, params)]
155
+ if include_self:
156
+ sql_self = f"SELECT id, _depth, priority FROM {self.db_table} WHERE id = %s" # noqa: D501
157
+ queries.append((sql_self, [self.node.pk]))
158
+ combined_sql, combined_params = self.wrap_union_all(queries)
159
+ combined_sql = self.order_by(combined_sql, "_depth, priority")
160
+ return combined_sql, combined_params
161
+
162
+ def get_root(self):
163
+ """
164
+ Build SQL for the 'root' relationship.
165
+
166
+ Retrieves the root node, identified by the first segment of _path.
167
+ """
168
+ segments = self.node._path.split(".")
169
+ root_segment = segments[0]
170
+ sql = f"SELECT id, priority FROM {self.db_table} WHERE _path = %s ORDER BY priority" # noqa: D501
171
+ params = [root_segment]
172
+ return sql, params
173
+
174
+ def get_relative_pks(self, objects="children", include_self=True, depth=None, mode=None): # noqa: D501
175
+ """
176
+ Get related primary keys from the tree using raw SQL queries.
177
+
178
+ Arguments:
179
+ objects (str): Relationship type. Options are: "ancestors",
180
+ "children", "descendants", "family", "root", or
181
+ "siblings".
182
+ include_self (bool): Whether to include the current node in the
183
+ result (ignored for 'children').
184
+ depth (int|None): Maximum depth to consider (only for 'descendants'
185
+ and 'family').
186
+ mode (str|None): 'count' returns the number of nodes, 'exist'
187
+ returns a boolean, or None returns a list of IDs.
188
+
189
+ Returns:
190
+ list | int | bool: Depending on mode, returns a list of IDs, count,
191
+ or boolean.
192
+ """
193
+ if objects == "children":
194
+ sql, params = self.get_children()
195
+ elif objects == "siblings":
196
+ sql, params = self.get_siblings(include_self=include_self)
197
+ elif objects == "descendants":
198
+ sql, params = self.get_descendants(include_self, depth)
199
+ elif objects == "ancestors":
200
+ result = self.get_ancestors(include_self)
201
+ if mode == "count":
202
+ return len(result)
203
+ elif mode == "exist":
204
+ return bool(result)
205
+ else:
206
+ return result
207
+ elif objects == "family":
208
+ sql, params = self.get_family(include_self, depth)
209
+ elif objects == "root":
210
+ sql, params = self.get_root()
211
+ else:
212
+ raise ValueError(f"Unknown relationship type: {objects}")
213
+
214
+ # Execute the query for all branches except 'ancestors'
215
+ rows = self.execute_query(sql, params)
216
+ result_ids = [row[0] for row in rows]
217
+
218
+ if mode == "count":
219
+ return len(result_ids)
220
+ elif mode == "exist":
221
+ return bool(result_ids)
222
+ else:
223
+ return result_ids
224
+
225
+
226
+ class TreeQueryManager:
227
+ """Desctiptor for TreeQueryManager."""
228
+
229
+ def __get__(self, instance, owner):
230
+ """Get query for instance."""
231
+ if instance is None:
232
+ return self
233
+ return TreeQuery(instance)