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.
- django_fast_treenode-3.0.0.dist-info/METADATA +203 -0
- django_fast_treenode-3.0.0.dist-info/RECORD +90 -0
- {django_fast_treenode-2.1.4.dist-info → django_fast_treenode-3.0.0.dist-info}/WHEEL +1 -1
- treenode/admin/__init__.py +2 -7
- treenode/admin/admin.py +138 -209
- treenode/admin/changelist.py +21 -39
- treenode/admin/exporter.py +170 -0
- treenode/admin/importer.py +171 -0
- treenode/admin/mixin.py +291 -0
- treenode/apps.py +42 -20
- treenode/cache.py +192 -303
- treenode/forms.py +45 -65
- treenode/managers/__init__.py +4 -20
- treenode/managers/managers.py +216 -0
- treenode/managers/queries.py +233 -0
- treenode/managers/tasks.py +167 -0
- treenode/models/__init__.py +8 -5
- treenode/models/decorators.py +54 -0
- treenode/models/factory.py +44 -68
- treenode/models/mixins/__init__.py +2 -1
- treenode/models/mixins/ancestors.py +44 -20
- treenode/models/mixins/children.py +33 -26
- treenode/models/mixins/descendants.py +33 -22
- treenode/models/mixins/family.py +25 -15
- treenode/models/mixins/logical.py +23 -21
- treenode/models/mixins/node.py +162 -104
- treenode/models/mixins/properties.py +22 -16
- treenode/models/mixins/roots.py +59 -15
- treenode/models/mixins/siblings.py +46 -43
- treenode/models/mixins/tree.py +212 -153
- treenode/models/mixins/update.py +154 -0
- treenode/models/models.py +365 -0
- treenode/settings.py +28 -0
- treenode/static/{treenode/css → css}/tree_widget.css +1 -1
- treenode/static/{treenode/css → css}/treenode_admin.css +43 -2
- treenode/static/css/treenode_tabs.css +51 -0
- treenode/static/js/lz-string.min.js +1 -0
- treenode/static/{treenode/js → js}/tree_widget.js +9 -23
- treenode/static/js/treenode_admin.js +531 -0
- treenode/static/vendors/jquery-ui/AUTHORS.txt +384 -0
- treenode/static/vendors/jquery-ui/LICENSE.txt +43 -0
- treenode/static/vendors/jquery-ui/external/jquery/jquery.js +10716 -0
- treenode/static/vendors/jquery-ui/images/ui-icons_444444_256x240.png +0 -0
- treenode/static/vendors/jquery-ui/images/ui-icons_555555_256x240.png +0 -0
- treenode/static/vendors/jquery-ui/images/ui-icons_777620_256x240.png +0 -0
- treenode/static/vendors/jquery-ui/images/ui-icons_777777_256x240.png +0 -0
- treenode/static/vendors/jquery-ui/images/ui-icons_cc0000_256x240.png +0 -0
- treenode/static/vendors/jquery-ui/images/ui-icons_ffffff_256x240.png +0 -0
- treenode/static/vendors/jquery-ui/index.html +297 -0
- treenode/static/vendors/jquery-ui/jquery-ui.css +438 -0
- treenode/static/vendors/jquery-ui/jquery-ui.js +5223 -0
- treenode/static/vendors/jquery-ui/jquery-ui.min.css +7 -0
- treenode/static/vendors/jquery-ui/jquery-ui.min.js +6 -0
- treenode/static/vendors/jquery-ui/jquery-ui.structure.css +16 -0
- treenode/static/vendors/jquery-ui/jquery-ui.structure.min.css +5 -0
- treenode/static/vendors/jquery-ui/jquery-ui.theme.css +439 -0
- treenode/static/vendors/jquery-ui/jquery-ui.theme.min.css +5 -0
- treenode/static/vendors/jquery-ui/package.json +82 -0
- treenode/templates/admin/treenode_changelist.html +25 -0
- treenode/templates/admin/treenode_import_export.html +85 -0
- treenode/templates/admin/treenode_rows.html +57 -0
- treenode/tests.py +3 -0
- treenode/urls.py +6 -27
- treenode/utils/__init__.py +0 -15
- treenode/utils/db/__init__.py +7 -0
- treenode/utils/db/compiler.py +114 -0
- treenode/utils/db/db_vendor.py +50 -0
- treenode/utils/db/service.py +84 -0
- treenode/utils/db/sqlcompat.py +60 -0
- treenode/utils/db/sqlquery.py +70 -0
- treenode/version.py +2 -2
- treenode/views/__init__.py +5 -0
- treenode/views/autoapi.py +91 -0
- treenode/views/autocomplete.py +52 -0
- treenode/views/children.py +41 -0
- treenode/views/common.py +23 -0
- treenode/views/crud.py +209 -0
- treenode/views/search.py +48 -0
- treenode/widgets.py +27 -44
- django_fast_treenode-2.1.4.dist-info/METADATA +0 -166
- django_fast_treenode-2.1.4.dist-info/RECORD +0 -63
- treenode/admin/mixins.py +0 -302
- treenode/managers/adjacency.py +0 -205
- treenode/managers/closure.py +0 -278
- treenode/models/adjacency.py +0 -342
- treenode/models/classproperty.py +0 -27
- treenode/models/closure.py +0 -122
- treenode/static/treenode/js/.gitkeep +0 -1
- treenode/static/treenode/js/treenode_admin.js +0 -131
- treenode/templates/admin/export_success.html +0 -26
- treenode/templates/admin/tree_node_changelist.html +0 -19
- treenode/templates/admin/tree_node_export.html +0 -27
- treenode/templates/admin/tree_node_import.html +0 -45
- treenode/templates/admin/tree_node_import_report.html +0 -32
- treenode/templates/widgets/tree_widget.css +0 -23
- treenode/utils/aid.py +0 -46
- treenode/utils/base16.py +0 -38
- treenode/utils/base36.py +0 -37
- treenode/utils/db.py +0 -116
- treenode/utils/exporter.py +0 -196
- treenode/utils/importer.py +0 -328
- treenode/utils/radix.py +0 -61
- treenode/views.py +0 -184
- {django_fast_treenode-2.1.4.dist-info → django_fast_treenode-3.0.0.dist-info/licenses}/LICENSE +0 -0
- {django_fast_treenode-2.1.4.dist-info → django_fast_treenode-3.0.0.dist-info}/top_level.txt +0 -0
- /treenode/static/{treenode → css}/.gitkeep +0 -0
- /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:
|
13
|
+
Version: 3.0.0
|
14
14
|
Author: Timur Kady
|
15
15
|
Email: timurkady@yandex.com
|
16
16
|
"""
|
17
17
|
|
18
|
-
from django import
|
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
|
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(
|
64
|
-
"""
|
65
|
-
TreeNode Form Class.
|
60
|
+
class TreeNodeForm(ModelForm):
|
61
|
+
"""TreeNodeModelAdmin Form Class."""
|
66
62
|
|
67
|
-
|
68
|
-
|
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
|
-
|
72
|
-
|
78
|
+
queryset = self.model.objects\
|
79
|
+
.exclude(pk__in=exclude_pks)\
|
80
|
+
.order_by('_path')\
|
81
|
+
.all()
|
73
82
|
|
74
|
-
|
75
|
-
fields =
|
76
|
-
|
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
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
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
|
treenode/managers/__init__.py
CHANGED
@@ -1,21 +1,5 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
1
|
+
from .managers import TreeNodeManager
|
2
|
+
from .queries import TreeQueryManager
|
3
|
+
from .tasks import TreeTaskManager
|
4
4
|
|
5
|
-
|
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)
|