django-fast-treenode 2.0.11__py3-none-any.whl → 2.1.1__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-2.0.11.dist-info → django_fast_treenode-2.1.1.dist-info}/LICENSE +2 -2
- django_fast_treenode-2.1.1.dist-info/METADATA +158 -0
- django_fast_treenode-2.1.1.dist-info/RECORD +64 -0
- {django_fast_treenode-2.0.11.dist-info → django_fast_treenode-2.1.1.dist-info}/WHEEL +1 -1
- treenode/admin/__init__.py +9 -0
- treenode/admin/admin.py +295 -0
- treenode/admin/changelist.py +65 -0
- treenode/admin/mixins.py +302 -0
- treenode/apps.py +12 -1
- treenode/cache.py +2 -2
- treenode/forms.py +8 -10
- treenode/managers/__init__.py +21 -0
- treenode/managers/adjacency.py +203 -0
- treenode/managers/closure.py +278 -0
- treenode/models/__init__.py +2 -1
- treenode/models/adjacency.py +343 -0
- treenode/models/classproperty.py +3 -0
- treenode/models/closure.py +23 -24
- treenode/models/factory.py +12 -2
- treenode/models/mixins/__init__.py +23 -0
- treenode/models/mixins/ancestors.py +65 -0
- treenode/models/mixins/children.py +81 -0
- treenode/models/mixins/descendants.py +66 -0
- treenode/models/mixins/family.py +63 -0
- treenode/models/mixins/logical.py +68 -0
- treenode/models/mixins/node.py +210 -0
- treenode/models/mixins/properties.py +156 -0
- treenode/models/mixins/roots.py +96 -0
- treenode/models/mixins/siblings.py +99 -0
- treenode/models/mixins/tree.py +344 -0
- treenode/signals.py +26 -0
- treenode/static/treenode/css/tree_widget.css +201 -31
- treenode/static/treenode/css/treenode_admin.css +48 -41
- treenode/static/treenode/js/tree_widget.js +269 -131
- treenode/static/treenode/js/treenode_admin.js +131 -171
- treenode/templates/admin/tree_node_changelist.html +6 -0
- treenode/templates/admin/treenode_ajax_rows.html +7 -0
- treenode/tests/tests.py +488 -0
- treenode/urls.py +10 -6
- treenode/utils/__init__.py +2 -0
- treenode/utils/aid.py +46 -0
- treenode/utils/base16.py +38 -0
- treenode/utils/base36.py +3 -1
- treenode/utils/db.py +116 -0
- treenode/utils/exporter.py +2 -0
- treenode/utils/importer.py +0 -1
- treenode/utils/radix.py +61 -0
- treenode/version.py +2 -2
- treenode/views.py +118 -43
- treenode/widgets.py +91 -43
- django_fast_treenode-2.0.11.dist-info/METADATA +0 -698
- django_fast_treenode-2.0.11.dist-info/RECORD +0 -42
- treenode/admin.py +0 -439
- treenode/docs/Documentation +0 -636
- treenode/managers.py +0 -419
- treenode/models/proxy.py +0 -669
- {django_fast_treenode-2.0.11.dist-info → django_fast_treenode-2.1.1.dist-info}/top_level.txt +0 -0
treenode/utils/db.py
ADDED
@@ -0,0 +1,116 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
"""
|
3
|
+
DB Vendor Utility Module
|
4
|
+
|
5
|
+
This module provides a utility function for converting integers
|
6
|
+
to Base36 string representation.
|
7
|
+
|
8
|
+
Features:
|
9
|
+
- Converts integers into a more compact Base36 format.
|
10
|
+
- Maintains lexicographic order when padded with leading zeros.
|
11
|
+
- Supports negative numbers.
|
12
|
+
|
13
|
+
Version: 2.1.0
|
14
|
+
Author: Timur Kady
|
15
|
+
Email: timurkady@yandex.com
|
16
|
+
"""
|
17
|
+
|
18
|
+
import logging
|
19
|
+
from django.apps import apps
|
20
|
+
from django.db import connection
|
21
|
+
|
22
|
+
from ..models import TreeNodeModel
|
23
|
+
|
24
|
+
logger = logging.getLogger(__name__)
|
25
|
+
|
26
|
+
|
27
|
+
def create_indexes(model):
|
28
|
+
"""Create indexes for the descendants of TreeNodeModel."""
|
29
|
+
vendor = connection.vendor
|
30
|
+
sender = "Django Fast TeeNode"
|
31
|
+
table = model._meta.db_table
|
32
|
+
|
33
|
+
with connection.cursor() as cursor:
|
34
|
+
if vendor == "postgresql":
|
35
|
+
cursor.execute(
|
36
|
+
"SELECT indexname FROM pg_indexes WHERE tablename = %s AND indexname = %s;",
|
37
|
+
[table, f"idx_{table}_btree"]
|
38
|
+
)
|
39
|
+
if not cursor.fetchone():
|
40
|
+
cursor.execute(
|
41
|
+
f"CREATE INDEX idx_{table}_btree ON {table} USING BTREE (id);"
|
42
|
+
)
|
43
|
+
logger.info(f"{sender}: GIN index for table {table} created.")
|
44
|
+
|
45
|
+
# Если существует первичный ключ, выполняем кластеризацию
|
46
|
+
cursor.execute(
|
47
|
+
"SELECT relname FROM pg_class WHERE relname = %s;",
|
48
|
+
[f"{table}_pkey"]
|
49
|
+
)
|
50
|
+
if cursor.fetchone():
|
51
|
+
cursor.execute(f"CLUSTER {table} USING {table}_pkey;")
|
52
|
+
logger.info(f"{sender}: Table {table} is clustered.")
|
53
|
+
|
54
|
+
elif vendor == "mysql":
|
55
|
+
cursor.execute("SHOW TABLE STATUS WHERE Name = %s;", [table])
|
56
|
+
columns = [col[0] for col in cursor.description]
|
57
|
+
row = cursor.fetchone()
|
58
|
+
if row:
|
59
|
+
table_status = dict(zip(columns, row))
|
60
|
+
engine = table_status.get("Engine", "").lower()
|
61
|
+
if engine != "innodb":
|
62
|
+
cursor.execute(f"ALTER TABLE {table} ENGINE = InnoDB;")
|
63
|
+
logger.info(
|
64
|
+
f"{sender}: Table {table} has been converted to InnoDB."
|
65
|
+
)
|
66
|
+
|
67
|
+
elif vendor in ["microsoft", "oracle"]:
|
68
|
+
if vendor == "microsoft":
|
69
|
+
cursor.execute(
|
70
|
+
"SELECT name FROM sys.indexes WHERE name = %s AND object_id = OBJECT_ID(%s);",
|
71
|
+
[f"idx_{table}_cluster", table]
|
72
|
+
)
|
73
|
+
else:
|
74
|
+
cursor.execute(
|
75
|
+
"SELECT index_name FROM user_indexes WHERE index_name = %s;",
|
76
|
+
[f"IDX_{table.upper()}_CLUSTER"]
|
77
|
+
)
|
78
|
+
if not cursor.fetchone():
|
79
|
+
cursor.execute(
|
80
|
+
f"CREATE CLUSTERED INDEX idx_{table}_cluster ON {table} (id);")
|
81
|
+
logger.info(
|
82
|
+
f"{sender}: CLUSTERED index for table {table} created."
|
83
|
+
)
|
84
|
+
|
85
|
+
elif vendor == "sqlite":
|
86
|
+
# Kick those on SQLite
|
87
|
+
logger.warning(
|
88
|
+
f"{sender} Unable to create GIN and CLUSTER indexes for SQLite."
|
89
|
+
)
|
90
|
+
else:
|
91
|
+
logger.warning(
|
92
|
+
f"{sender}: Unknown vendor. Index creation cancelled."
|
93
|
+
)
|
94
|
+
|
95
|
+
|
96
|
+
def post_migrate_update(sender, **kwargs):
|
97
|
+
"""Update indexes and tn_closure field only when necessary."""
|
98
|
+
# Перебираем все зарегистрированные модели
|
99
|
+
for model in apps.get_models():
|
100
|
+
# Check that the model inherits from TreeNodeModel and
|
101
|
+
# is not abstract
|
102
|
+
if issubclass(model, TreeNodeModel) and not model._meta.abstract:
|
103
|
+
# Create GIN and CLUSTER indexrs
|
104
|
+
create_indexes(model)
|
105
|
+
# Get ClosureModel
|
106
|
+
closure_model = model.closure_model
|
107
|
+
# Check node counts
|
108
|
+
al_count = model.objects.exists()
|
109
|
+
cl_counts = closure_model.objects.exclude(node=None).exists()
|
110
|
+
|
111
|
+
if al_count and not cl_counts:
|
112
|
+
# Call update_tree()
|
113
|
+
model.update_tree()
|
114
|
+
|
115
|
+
|
116
|
+
# The End
|
treenode/utils/exporter.py
CHANGED
treenode/utils/importer.py
CHANGED
treenode/utils/radix.py
ADDED
@@ -0,0 +1,61 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
"""
|
3
|
+
Implementation of the Radix Sort algorithm.
|
4
|
+
|
5
|
+
Radix Sort is a non-comparative sorting algorithm. It avoids comparisons by
|
6
|
+
creating and distributing elements into buckets according to their radix.
|
7
|
+
|
8
|
+
It is used as a replacement for numpy when sorting materialized paths and
|
9
|
+
tree node indices.
|
10
|
+
|
11
|
+
Version: 2.1.0
|
12
|
+
Author: Timur Kady
|
13
|
+
Email: timurkady@yandex.com
|
14
|
+
"""
|
15
|
+
|
16
|
+
from collections import defaultdict
|
17
|
+
|
18
|
+
|
19
|
+
def counting_sort(pairs, index):
|
20
|
+
"""Sort pairs (key, string) by character at position index."""
|
21
|
+
count = defaultdict(list)
|
22
|
+
|
23
|
+
# Distribution of pairs into baskets
|
24
|
+
for key, s in pairs:
|
25
|
+
key_char = s[index] if index < len(s) else ''
|
26
|
+
count[key_char].append((key, s))
|
27
|
+
|
28
|
+
# Collect sorted pairs
|
29
|
+
sorted_pairs = []
|
30
|
+
for key_char in sorted(count.keys()):
|
31
|
+
sorted_pairs.extend(count[key_char])
|
32
|
+
|
33
|
+
return sorted_pairs
|
34
|
+
|
35
|
+
|
36
|
+
def radix_sort_pairs(pairs, max_length):
|
37
|
+
"""Radical sorting of pairs (key, string) by string."""
|
38
|
+
for i in range(max_length - 1, -1, -1):
|
39
|
+
pairs = counting_sort(pairs, i)
|
40
|
+
return pairs
|
41
|
+
|
42
|
+
|
43
|
+
def quick_sort(pairs):
|
44
|
+
"""
|
45
|
+
Sort tree objects by materialized path.
|
46
|
+
|
47
|
+
pairs = [{obj.id: obj.path} for obj in objs]
|
48
|
+
Returns a list of id (pk) objects sorted by their materialized path.
|
49
|
+
"""
|
50
|
+
# Get the maximum length of the string
|
51
|
+
max_length = max(len(s) for _, s in pairs)
|
52
|
+
|
53
|
+
# Sort pairs by rows
|
54
|
+
sorted_pairs = radix_sort_pairs(pairs, max_length)
|
55
|
+
|
56
|
+
# Access keys in sorted order
|
57
|
+
sorted_keys = [key for key, _ in sorted_pairs]
|
58
|
+
return sorted_keys
|
59
|
+
|
60
|
+
|
61
|
+
# The End
|
treenode/version.py
CHANGED
treenode/views.py
CHANGED
@@ -14,34 +14,41 @@ Features:
|
|
14
14
|
- Uses optimized QuerySets for efficient database queries.
|
15
15
|
- Handles validation and error responses gracefully.
|
16
16
|
|
17
|
-
Version: 2.0
|
17
|
+
Version: 2.1.0
|
18
18
|
Author: Timur Kady
|
19
19
|
Email: timurkady@yandex.com
|
20
20
|
"""
|
21
21
|
|
22
|
-
|
23
|
-
from django.http import JsonResponse
|
24
|
-
from django.views import View
|
25
22
|
from django.apps import apps
|
26
|
-
|
27
|
-
from django.core.exceptions import ObjectDoesNotExist
|
23
|
+
from django.http import JsonResponse
|
28
24
|
from django.utils.translation import gettext_lazy as _
|
25
|
+
from django.views import View
|
26
|
+
|
27
|
+
import logging
|
28
|
+
|
29
|
+
logger = logging.getLogger(__name__)
|
29
30
|
|
30
31
|
|
31
32
|
class TreeNodeAutocompleteView(View):
|
32
|
-
"""
|
33
|
+
"""
|
34
|
+
Return JSON data for Select2 with tree structure.
|
35
|
+
|
36
|
+
Lazy load tree nodes for Select2 scrolling with reference node support.
|
37
|
+
"""
|
33
38
|
|
34
39
|
def get(self, request):
|
35
|
-
"""
|
36
|
-
|
37
|
-
model_label = request.GET.get("model") # Получаем модель
|
40
|
+
"""
|
41
|
+
Process an AJAX request to lazily load tree nodes.
|
38
42
|
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
43
|
+
Operation logic:
|
44
|
+
"""
|
45
|
+
# Search Processing
|
46
|
+
q = request.GET.get("q", "")
|
47
|
+
if q:
|
48
|
+
return self.search(request)
|
44
49
|
|
50
|
+
# Model extracting
|
51
|
+
model_label = request.GET.get("model")
|
45
52
|
try:
|
46
53
|
model = apps.get_model(model_label)
|
47
54
|
except LookupError:
|
@@ -50,23 +57,37 @@ class TreeNodeAutocompleteView(View):
|
|
50
57
|
status=400
|
51
58
|
)
|
52
59
|
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
+
# select_id not specified
|
61
|
+
queryset_list = model.get_roots()
|
62
|
+
|
63
|
+
select_id = request.GET.get("select_id", "")
|
64
|
+
if select_id:
|
65
|
+
select = model.objects.filter(pk=select_id).first()
|
66
|
+
if not select:
|
67
|
+
return JsonResponse(
|
68
|
+
{"error": f"Invalid select_id: {select_id}"},
|
69
|
+
status=400
|
70
|
+
)
|
71
|
+
breadcrumbs = select.get_breadcrumbs()
|
72
|
+
# Delete self
|
73
|
+
del breadcrumbs[-1]
|
74
|
+
for pk in breadcrumbs:
|
75
|
+
parent = model.model.objects.filter(pk=pk).first()
|
76
|
+
children = parent.get_children()
|
77
|
+
queryset_list.extend(children)
|
78
|
+
|
79
|
+
nodes = model._sort_node_list(queryset_list)
|
80
|
+
# Generate a response
|
60
81
|
results = [
|
61
82
|
{
|
62
83
|
"id": node.pk,
|
63
|
-
"text": node
|
64
|
-
"level": node.
|
84
|
+
"text": str(node),
|
85
|
+
"level": node.get_depth(),
|
65
86
|
"is_leaf": node.is_leaf(),
|
66
87
|
}
|
67
|
-
for node in
|
88
|
+
for node in nodes
|
68
89
|
]
|
69
|
-
|
90
|
+
# Add the "Root" option to the top of the list
|
70
91
|
root_option = {
|
71
92
|
"id": "",
|
72
93
|
"text": _("Root"),
|
@@ -75,20 +96,60 @@ class TreeNodeAutocompleteView(View):
|
|
75
96
|
}
|
76
97
|
results.insert(0, root_option)
|
77
98
|
|
78
|
-
|
99
|
+
response_data = {"results": results}
|
100
|
+
return JsonResponse(response_data)
|
79
101
|
|
102
|
+
def search(self, request):
|
103
|
+
"""Search processing."""
|
104
|
+
# Chack search query
|
105
|
+
q = request.GET.get("q", "")
|
106
|
+
if not q:
|
107
|
+
return JsonResponse({"results": []})
|
80
108
|
|
81
|
-
|
82
|
-
|
109
|
+
# Model extracting
|
110
|
+
model_label = request.GET.get("model")
|
111
|
+
try:
|
112
|
+
model = apps.get_model(model_label)
|
113
|
+
except LookupError:
|
114
|
+
return JsonResponse(
|
115
|
+
{"error": f"Invalid model: {model_label}"},
|
116
|
+
status=400
|
117
|
+
)
|
83
118
|
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
119
|
+
# Search
|
120
|
+
params = {}
|
121
|
+
treenode_field = model.treenode_display_field
|
122
|
+
if not treenode_field:
|
123
|
+
return {"results": ""}
|
88
124
|
|
89
|
-
|
90
|
-
|
125
|
+
params[f"{treenode_field}__icontains"] = q
|
126
|
+
queryset = model.objects.filter(**params)[:15]
|
127
|
+
queryset_list = list(queryset)
|
128
|
+
nodes = model._sort_node_list(queryset_list)
|
129
|
+
results = [
|
130
|
+
{
|
131
|
+
"id": node.pk,
|
132
|
+
"text": node.name,
|
133
|
+
"level": node.get_depth(),
|
134
|
+
"is_leaf": node.is_leaf(),
|
135
|
+
}
|
136
|
+
for node in nodes
|
137
|
+
]
|
138
|
+
return JsonResponse({"results": results})
|
139
|
+
|
140
|
+
|
141
|
+
class ChildrenView(View):
|
142
|
+
"""Return JSON data for Select2 with node children."""
|
91
143
|
|
144
|
+
def get(self, request):
|
145
|
+
"""Process an AJAX request to load node children."""
|
146
|
+
# Get reference_id
|
147
|
+
reference_id = request.GET.get("reference_id", "")
|
148
|
+
if not reference_id:
|
149
|
+
return JsonResponse({"results": []})
|
150
|
+
|
151
|
+
# Model extracting
|
152
|
+
model_label = request.GET.get("model")
|
92
153
|
try:
|
93
154
|
model = apps.get_model(model_label)
|
94
155
|
except LookupError:
|
@@ -96,14 +157,28 @@ class GetChildrenCountView(View):
|
|
96
157
|
{"error": f"Invalid model: {model_label}"},
|
97
158
|
status=400
|
98
159
|
)
|
99
|
-
|
100
|
-
|
101
|
-
parent_node = model.objects.get(pk=parent_id)
|
102
|
-
children_count = parent_node.get_children_count()
|
103
|
-
except ObjectDoesNotExist:
|
160
|
+
obj = model.objects.filter(pk=reference_id).first()
|
161
|
+
if not obj:
|
104
162
|
return JsonResponse(
|
105
|
-
{"error": "
|
106
|
-
status=
|
163
|
+
{"error": f"Invalid reference_id: {reference_id}"},
|
164
|
+
status=400
|
107
165
|
)
|
108
166
|
|
109
|
-
|
167
|
+
if obj.is_leaf():
|
168
|
+
return JsonResponse({"results": []})
|
169
|
+
|
170
|
+
queryset_list = obj.get_children()
|
171
|
+
nodes = model._sort_node_list(queryset_list)
|
172
|
+
results = [
|
173
|
+
{
|
174
|
+
"id": node.pk,
|
175
|
+
"text": node.name,
|
176
|
+
"level": node.get_depth(),
|
177
|
+
"is_leaf": node.is_leaf(),
|
178
|
+
}
|
179
|
+
for node in nodes
|
180
|
+
]
|
181
|
+
return JsonResponse({"results": results})
|
182
|
+
|
183
|
+
|
184
|
+
# The End
|
treenode/widgets.py
CHANGED
@@ -2,80 +2,128 @@
|
|
2
2
|
"""
|
3
3
|
TreeNode Widgets Module
|
4
4
|
|
5
|
-
This module defines custom form
|
6
|
-
within Django's admin interface. It
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
- `TreeWidget`: A custom Select2 widget that enhances usability for
|
11
|
-
hierarchical models.
|
12
|
-
- Automatically fetches hierarchical data via AJAX.
|
13
|
-
- Supports dynamic model binding for reusable implementations.
|
14
|
-
- Integrates with Django’s form system.
|
15
|
-
|
16
|
-
Version: 2.0.11
|
5
|
+
This module defines a custom form widget for handling hierarchical data
|
6
|
+
within Django's admin interface. It replaces the standard <select> dropdown
|
7
|
+
with a fully customizable tree selection UI.
|
8
|
+
|
9
|
+
Version: 2.1.0
|
17
10
|
Author: Timur Kady
|
18
11
|
Email: timurkady@yandex.com
|
19
12
|
"""
|
20
13
|
|
21
|
-
|
14
|
+
import json
|
22
15
|
from django import forms
|
16
|
+
from django.urls import reverse
|
17
|
+
from django.utils.safestring import mark_safe
|
18
|
+
from django.utils.translation import gettext_lazy as _
|
23
19
|
|
24
20
|
|
25
|
-
class TreeWidget(forms.
|
26
|
-
"""Custom
|
21
|
+
class TreeWidget(forms.Widget):
|
22
|
+
"""Custom widget for hierarchical tree selection."""
|
27
23
|
|
28
24
|
class Media:
|
29
|
-
"""
|
30
|
-
|
31
|
-
css = {
|
32
|
-
|
33
|
-
"https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/css/select2.min.css",
|
34
|
-
"treenode/css/tree_widget.css",
|
35
|
-
)
|
36
|
-
}
|
37
|
-
js = (
|
38
|
-
"https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/js/select2.min.js",
|
39
|
-
"treenode/js/tree_widget.js",
|
40
|
-
)
|
25
|
+
"""Meta class to define required CSS and JS files."""
|
26
|
+
|
27
|
+
css = {"all": ("treenode/css/tree_widget.css",)}
|
28
|
+
js = ("treenode/js/tree_widget.js",)
|
41
29
|
|
42
30
|
def build_attrs(self, base_attrs, extra_attrs=None):
|
43
|
-
"""
|
31
|
+
"""Build attributes for the widget."""
|
44
32
|
attrs = super().build_attrs(base_attrs, extra_attrs)
|
45
|
-
attrs.setdefault("data-url", "
|
46
|
-
|
47
|
-
attrs["class"] = f"{
|
48
|
-
if "placeholder" in attrs:
|
49
|
-
del attrs["placeholder"]
|
33
|
+
attrs.setdefault("data-url", reverse("tree_autocomplete"))
|
34
|
+
attrs.setdefault("data-url-children", reverse("tree_children"))
|
35
|
+
attrs["class"] = f"{attrs.get('class', '')} tree-widget".strip()
|
50
36
|
|
51
|
-
# Force passing `model`
|
52
37
|
if "data-forward" not in attrs:
|
53
38
|
model = getattr(self, "model", None)
|
39
|
+
|
54
40
|
if not model and hasattr(self.choices, "queryset"):
|
55
41
|
model = self.choices.queryset.model
|
56
42
|
if model is None:
|
57
43
|
raise ValueError("TreeWidget: model not passed or not defined")
|
58
44
|
|
59
45
|
try:
|
60
|
-
|
61
|
-
|
62
|
-
model_label = f"{label}.{model_name}"
|
63
|
-
attrs["data-forward"] = f'{{"model": "{model_label}"}}'
|
46
|
+
forward_data = json.dumps({"model": model._meta.label})
|
47
|
+
attrs["data-forward"] = forward_data.replace('"', """)
|
64
48
|
except AttributeError as e:
|
65
|
-
raise ValueError(
|
66
|
-
"TreeWidget: model object is not a valid Django model"
|
67
|
-
) from e
|
49
|
+
raise ValueError("TreeWidget: invalid Django model") from e
|
68
50
|
|
69
|
-
# Force focus to current value
|
70
51
|
if self.choices:
|
71
52
|
try:
|
72
53
|
current_value = self.value()
|
73
54
|
if current_value:
|
74
55
|
attrs["data-selected"] = str(current_value)
|
75
56
|
except Exception:
|
76
|
-
# In case the value is missing
|
77
57
|
pass
|
78
58
|
|
79
59
|
return attrs
|
80
60
|
|
61
|
+
def optgroups(self, name, value, attrs=None):
|
62
|
+
"""
|
63
|
+
Override optgroups to return an empty structure.
|
64
|
+
|
65
|
+
The dropdown will be rendered dynamically via JS.
|
66
|
+
"""
|
67
|
+
return []
|
68
|
+
|
69
|
+
def render(self, name, value, attrs=None, renderer=None):
|
70
|
+
"""Render widget as a hidden input + tree container structure."""
|
71
|
+
|
72
|
+
attrs = self.build_attrs(attrs)
|
73
|
+
attrs["name"] = name
|
74
|
+
attrs["type"] = "hidden"
|
75
|
+
if value:
|
76
|
+
attrs["value"] = str(value)
|
77
|
+
|
78
|
+
# If value is set, try to get string representation of instance,
|
79
|
+
# otherwise print "Root"
|
80
|
+
if value not in [None, "", "None"]:
|
81
|
+
try:
|
82
|
+
from django.apps import apps
|
83
|
+
# Define the model: first self.model if it is defined,
|
84
|
+
# otherwise via choices
|
85
|
+
model = getattr(self, "model", None)
|
86
|
+
if model is None and hasattr(self, "choices") and getattr(self.choices, "queryset", None):
|
87
|
+
model = self.choices.queryset.model
|
88
|
+
if model is not None:
|
89
|
+
instance = model.objects.get(pk=value)
|
90
|
+
selected_value = str(instance)
|
91
|
+
else:
|
92
|
+
selected_value = str(value)
|
93
|
+
except Exception:
|
94
|
+
selected_value = str(value)
|
95
|
+
else:
|
96
|
+
selected_value = _("Root")
|
97
|
+
|
98
|
+
# Remove the 'tree-widget' class from the input so it doesn't interfere
|
99
|
+
# with container initialization
|
100
|
+
if "class" in attrs:
|
101
|
+
attrs["class"] = " ".join(
|
102
|
+
[cl for cl in attrs["class"].split() if cl != "tree-widget"])
|
103
|
+
|
104
|
+
html = """
|
105
|
+
<div class="tree-widget">
|
106
|
+
<div class="tree-widget-display">
|
107
|
+
<span class="selected-node">{selected_value}</span>
|
108
|
+
<span class="tree-dropdown-arrow">▼</span>
|
109
|
+
</div>
|
110
|
+
<input {attrs} />
|
111
|
+
<div class="tree-widget-dropdown">
|
112
|
+
<div class="tree-search-wrapper">
|
113
|
+
<span class="tree-search-icon">🔎︎</span>
|
114
|
+
<input type="text" class="tree-search" placeholder="{search_placeholder}" />
|
115
|
+
<button type="button" class="tree-search-clear">×</button>
|
116
|
+
</div>
|
117
|
+
<ul class="tree-list"></ul>
|
118
|
+
</div>
|
119
|
+
</div>
|
120
|
+
""".format(
|
121
|
+
attrs=" ".join(f'{key}="{val}"' for key, val in attrs.items()),
|
122
|
+
search_placeholder=_("Search node..."),
|
123
|
+
selected_value=selected_value
|
124
|
+
)
|
125
|
+
|
126
|
+
return mark_safe(html)
|
127
|
+
|
128
|
+
|
81
129
|
# The End
|