django-fast-treenode 2.1.4__py3-none-any.whl → 3.0.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-3.0.1.dist-info/METADATA +203 -0
- django_fast_treenode-3.0.1.dist-info/RECORD +90 -0
- {django_fast_treenode-2.1.4.dist-info → django_fast_treenode-3.0.1.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 +41 -19
- 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.1.dist-info/licenses}/LICENSE +0 -0
- {django_fast_treenode-2.1.4.dist-info → django_fast_treenode-3.0.1.dist-info}/top_level.txt +0 -0
- /treenode/static/{treenode → css}/.gitkeep +0 -0
- /treenode/static/{treenode/css → js}/.gitkeep +0 -0
@@ -0,0 +1,57 @@
|
|
1
|
+
{% load i18n %}
|
2
|
+
|
3
|
+
<div class="results">
|
4
|
+
<table id="result_list">
|
5
|
+
|
6
|
+
|
7
|
+
<thead>
|
8
|
+
<tr>
|
9
|
+
{% for h in headers %}
|
10
|
+
<th scope="col"{{ h.class_attrib|safe }}>
|
11
|
+
{% if "action-checkbox-column" in h.class_attrib %}
|
12
|
+
<div class="text">
|
13
|
+
<span>
|
14
|
+
<input type="checkbox" id="action-toggle" aria-label="{% translate 'Select all objects on this page for an action' %}">
|
15
|
+
</span>
|
16
|
+
</div>
|
17
|
+
<div class="clear"></div>
|
18
|
+
{% else %}
|
19
|
+
{% if h.sortable %}
|
20
|
+
<div class="sortoptions">
|
21
|
+
{% if h.url_remove %}
|
22
|
+
<a class="sortremove" href="{{ h.url_remove }}" title="{% translate 'Remove from sorting' %}"></a>
|
23
|
+
{% endif %}
|
24
|
+
{% if h.url_toggle %}
|
25
|
+
<a href="{{ h.url_toggle }}" class="toggle {% if h.ascending %}ascending{% else %}descending{% endif %}" title="{% translate 'Toggle sorting' %}"></a>
|
26
|
+
{% endif %}
|
27
|
+
</div>
|
28
|
+
{% else %}
|
29
|
+
<div class="sortoptions"></div>
|
30
|
+
{% endif %}
|
31
|
+
|
32
|
+
<div class="text">
|
33
|
+
{% if h.sortable %}
|
34
|
+
<a href="{{ h.url_primary }}">{{ h.text|safe }}</a>
|
35
|
+
{% else %}
|
36
|
+
<a href="#">{{ h.text|safe }}</a>
|
37
|
+
{% endif %}
|
38
|
+
</div>
|
39
|
+
<div class="clear"></div>
|
40
|
+
{% endif %}
|
41
|
+
</th>
|
42
|
+
{% endfor %}
|
43
|
+
</tr>
|
44
|
+
</thead>
|
45
|
+
|
46
|
+
|
47
|
+
<tbody>
|
48
|
+
{% for row in rows %}
|
49
|
+
<tr {{ row.attrs|safe }}>
|
50
|
+
{% for cell in row.cells %}
|
51
|
+
{{ cell }}
|
52
|
+
{% endfor %}
|
53
|
+
</tr>
|
54
|
+
{% endfor %}
|
55
|
+
</tbody>
|
56
|
+
</table>
|
57
|
+
</div>
|
treenode/tests.py
ADDED
treenode/urls.py
CHANGED
@@ -1,38 +1,17 @@
|
|
1
1
|
# -*- coding: utf-8 -*-
|
2
2
|
"""
|
3
|
-
TreeNode
|
3
|
+
TreeNode URLs Module.
|
4
4
|
|
5
|
-
|
6
|
-
to tree-structured data in Django. It includes endpoints for Select2
|
7
|
-
autocomplete and retrieving child node counts.
|
8
|
-
|
9
|
-
Routes:
|
10
|
-
- `tree-autocomplete/`: Returns JSON data for Select2 hierarchical selection.
|
11
|
-
- `get-children-count/`: Retrieves the number of children for a given
|
12
|
-
parent node.
|
13
|
-
|
14
|
-
Version: 2.1.0
|
5
|
+
Version: 3.0.0
|
15
6
|
Author: Timur Kady
|
16
7
|
Email: timurkady@yandex.com
|
17
8
|
"""
|
18
9
|
|
10
|
+
from .views import AutoTreeAPI
|
19
11
|
|
20
|
-
|
21
|
-
from .views import TreeNodeAutocompleteView, ChildrenView
|
12
|
+
app_name = "treenode"
|
22
13
|
|
23
|
-
urlpatterns = [
|
24
|
-
path(
|
25
|
-
"tree-autocomplete/",
|
26
|
-
TreeNodeAutocompleteView.as_view(),
|
27
|
-
name="tree_autocomplete"
|
28
|
-
),
|
29
14
|
|
30
|
-
|
31
|
-
|
32
|
-
ChildrenView.as_view(),
|
33
|
-
name="tree_children"
|
34
|
-
),
|
15
|
+
urlpatterns = [
|
16
|
+
*AutoTreeAPI().discover(),
|
35
17
|
]
|
36
|
-
|
37
|
-
|
38
|
-
# The End
|
treenode/utils/__init__.py
CHANGED
@@ -1,15 +0,0 @@
|
|
1
|
-
import importlib
|
2
|
-
|
3
|
-
extra = all([
|
4
|
-
importlib.util.find_spec(pkg) is not None
|
5
|
-
for pkg in ["openpyxl", "yaml", "xlsxwriter"]
|
6
|
-
])
|
7
|
-
|
8
|
-
if extra:
|
9
|
-
from .exporter import TreeNodeExporter
|
10
|
-
from .importer import TreeNodeImporter
|
11
|
-
__all__ = ["TreeNodeExporter", "TreeNodeImporter"]
|
12
|
-
else:
|
13
|
-
__all__ = []
|
14
|
-
|
15
|
-
# The End
|
@@ -0,0 +1,114 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
"""
|
3
|
+
Tree update task compiler class.
|
4
|
+
|
5
|
+
Compiles tasks to low-level SQL to update the materialized path (_path), depth
|
6
|
+
(_depth), and node order (priority) when they are shifted or moved.
|
7
|
+
|
8
|
+
Version: 3.0.0
|
9
|
+
Author: Timur Kady
|
10
|
+
Email: timurkady@yandex.com
|
11
|
+
"""
|
12
|
+
|
13
|
+
from django.db import connection
|
14
|
+
|
15
|
+
from ...settings import SEGMENT_LENGTH
|
16
|
+
from .sqlcompat import SQLCompat
|
17
|
+
|
18
|
+
|
19
|
+
class TreePathCompiler:
|
20
|
+
"""
|
21
|
+
Tree Task compiler class.
|
22
|
+
|
23
|
+
Efficient, ORM-free computation of _path, _depth and priority
|
24
|
+
for tree structures based on Materialized Path.
|
25
|
+
"""
|
26
|
+
|
27
|
+
@classmethod
|
28
|
+
def update_path(cls, model, parent_id=None):
|
29
|
+
"""
|
30
|
+
Rebuild subtree starting from parent_id.
|
31
|
+
|
32
|
+
If parent_id=None, then the whole tree is rebuilt.
|
33
|
+
Uses only fields: parent_id and id. All others (priority, _path,
|
34
|
+
_depth) are recalculated.
|
35
|
+
"""
|
36
|
+
db_table = model._meta.db_table
|
37
|
+
|
38
|
+
sorting_field = model.sorting_field
|
39
|
+
sorting_fields = ["priority", "id"] if sorting_field == "priority" else [sorting_field] # noqa: D501
|
40
|
+
sort_expr = ", ".join([
|
41
|
+
f"c.{field}" if "." not in field else field
|
42
|
+
for field in sorting_fields
|
43
|
+
])
|
44
|
+
|
45
|
+
cte_header = "(id, parent_id, new_priority, new_path, new_depth)"
|
46
|
+
|
47
|
+
row_number_expr = "ROW_NUMBER() OVER (ORDER BY {sort_expr}) - 1"
|
48
|
+
hex_expr = SQLCompat.to_hex(row_number_expr)
|
49
|
+
lpad_expr = SQLCompat.lpad(hex_expr, SEGMENT_LENGTH, "'0'")
|
50
|
+
|
51
|
+
if parent_id is None:
|
52
|
+
new_path_expr = lpad_expr
|
53
|
+
base_sql = f"""
|
54
|
+
SELECT
|
55
|
+
c.id,
|
56
|
+
c.parent_id,
|
57
|
+
{row_number_expr} AS new_priority,
|
58
|
+
{new_path_expr} AS new_path,
|
59
|
+
0 AS new_depth
|
60
|
+
FROM {db_table} AS c
|
61
|
+
WHERE c.parent_id IS NULL
|
62
|
+
"""
|
63
|
+
params = []
|
64
|
+
else:
|
65
|
+
path_expr = SQLCompat.concat("p._path", "'.'", lpad_expr)
|
66
|
+
base_sql = f"""
|
67
|
+
SELECT
|
68
|
+
c.id,
|
69
|
+
c.parent_id,
|
70
|
+
{row_number_expr} AS new_priority,
|
71
|
+
{path_expr} AS new_path,
|
72
|
+
p._depth + 1 AS new_depth
|
73
|
+
FROM {db_table} c
|
74
|
+
JOIN {db_table} p ON c.parent_id = p.id
|
75
|
+
WHERE p.id = %s
|
76
|
+
"""
|
77
|
+
params = [parent_id]
|
78
|
+
|
79
|
+
recursive_row_number_expr = "ROW_NUMBER() OVER (PARTITION BY c.parent_id ORDER BY {sort_expr}) - 1" # noqa: D501
|
80
|
+
recursive_hex_expr = SQLCompat.to_hex(recursive_row_number_expr)
|
81
|
+
recursive_lpad_expr = SQLCompat.lpad(recursive_hex_expr, SEGMENT_LENGTH, "'0'") # noqa: D501
|
82
|
+
recursive_path_expr = SQLCompat.concat("t.new_path", "'.'", recursive_lpad_expr) # noqa: D501
|
83
|
+
|
84
|
+
recursive_sql = f"""
|
85
|
+
SELECT
|
86
|
+
c.id,
|
87
|
+
c.parent_id,
|
88
|
+
{recursive_row_number_expr} AS new_priority,
|
89
|
+
{recursive_path_expr} AS new_path,
|
90
|
+
t.new_depth + 1 AS new_depth
|
91
|
+
FROM {db_table} c
|
92
|
+
JOIN tree_cte t ON c.parent_id = t.id
|
93
|
+
"""
|
94
|
+
|
95
|
+
final_sql = f"""
|
96
|
+
WITH RECURSIVE tree_cte {cte_header} AS (
|
97
|
+
{base_sql}
|
98
|
+
UNION ALL
|
99
|
+
{recursive_sql}
|
100
|
+
)
|
101
|
+
UPDATE {db_table} AS orig
|
102
|
+
SET
|
103
|
+
priority = t.new_priority,
|
104
|
+
_path = t.new_path,
|
105
|
+
_depth = t.new_depth
|
106
|
+
FROM tree_cte t
|
107
|
+
WHERE orig.id = t.id;
|
108
|
+
"""
|
109
|
+
|
110
|
+
with connection.cursor() as cursor:
|
111
|
+
cursor.execute(final_sql.format(sort_expr=sort_expr), params)
|
112
|
+
|
113
|
+
|
114
|
+
# The End
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
"""
|
3
|
+
API-First Support Module.
|
4
|
+
|
5
|
+
CRUD and Tree Operations for TreeNode models.
|
6
|
+
|
7
|
+
Version: 3.0.0
|
8
|
+
Author: Timur Kady
|
9
|
+
Email: timurkady@yandex.com
|
10
|
+
"""
|
11
|
+
|
12
|
+
|
13
|
+
from django.db import connection
|
14
|
+
|
15
|
+
_vendor = connection.vendor.lower()
|
16
|
+
|
17
|
+
|
18
|
+
def is_postgresql():
|
19
|
+
"""Return True if DB is PostgreSQL."""
|
20
|
+
return _vendor == "postgresql"
|
21
|
+
|
22
|
+
|
23
|
+
def is_mysql():
|
24
|
+
"""Return True if DB is MySQL."""
|
25
|
+
return _vendor == "mysql"
|
26
|
+
|
27
|
+
|
28
|
+
def is_mariadb():
|
29
|
+
"""Return True if DB is MariaDB."""
|
30
|
+
return _vendor == "mariadb"
|
31
|
+
|
32
|
+
|
33
|
+
def is_sqlite():
|
34
|
+
"""Return True if DB is SQLite."""
|
35
|
+
return _vendor == "sqlite"
|
36
|
+
|
37
|
+
|
38
|
+
def is_oracle():
|
39
|
+
"""Return True if DB is Oracle."""
|
40
|
+
return _vendor == "oracle"
|
41
|
+
|
42
|
+
|
43
|
+
def is_mssql():
|
44
|
+
"""Return True if DB is Microsoft SQL Server."""
|
45
|
+
return _vendor in ("microsoft", "mssql")
|
46
|
+
|
47
|
+
|
48
|
+
def get_vendor():
|
49
|
+
"""Return DB vendor."""
|
50
|
+
return _vendor
|
@@ -0,0 +1,84 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
"""
|
3
|
+
DB Vendor Utility Module
|
4
|
+
|
5
|
+
The module contains utilities related to optimizing the application's operation
|
6
|
+
with various types of Databases.
|
7
|
+
|
8
|
+
Version: 3.0.0
|
9
|
+
Author: Timur Kady
|
10
|
+
Email: timurkady@yandex.com
|
11
|
+
"""
|
12
|
+
|
13
|
+
from __future__ import annotations
|
14
|
+
from django.db import models, connection
|
15
|
+
|
16
|
+
|
17
|
+
class SQLService:
|
18
|
+
"""SQL utility class bound to a specific model."""
|
19
|
+
|
20
|
+
def __init__(self, model):
|
21
|
+
"""Init."""
|
22
|
+
self.db_vendor = connection.vendor
|
23
|
+
self.model = model
|
24
|
+
self.table = model._meta.db_table
|
25
|
+
|
26
|
+
def get_next_id(self):
|
27
|
+
"""Reliably get the next ID for this model on different DBMS."""
|
28
|
+
if self.db_vendor == 'postgresql':
|
29
|
+
seq_name = f"{self.table}_id_seq"
|
30
|
+
with connection.cursor() as cursor:
|
31
|
+
cursor.execute(f"SELECT nextval('{seq_name}')")
|
32
|
+
return cursor.fetchone()[0]
|
33
|
+
|
34
|
+
elif self.db_vendor == 'oracle':
|
35
|
+
seq_name = f"{self.table}_SEQ"
|
36
|
+
with connection.cursor() as cursor:
|
37
|
+
try:
|
38
|
+
cursor.execute(f"SELECT {seq_name}.NEXTVAL FROM DUAL")
|
39
|
+
return cursor.fetchone()[0]
|
40
|
+
except Exception:
|
41
|
+
cursor.execute(
|
42
|
+
f"CREATE SEQUENCE {seq_name} START WITH 1 INCREMENT BY 1"
|
43
|
+
)
|
44
|
+
cursor.execute(f"SELECT {seq_name}.NEXTVAL FROM DUAL")
|
45
|
+
return cursor.fetchone()[0]
|
46
|
+
|
47
|
+
elif self.db_vendor in ('sqlite', 'mysql'):
|
48
|
+
with connection.cursor() as cursor:
|
49
|
+
cursor.execute(f"SELECT MAX(id) FROM {self.table}")
|
50
|
+
row = cursor.fetchone()
|
51
|
+
return (row[0] or 0) + 1
|
52
|
+
|
53
|
+
else:
|
54
|
+
raise NotImplementedError(
|
55
|
+
f"get_next_id() not supported for DB vendor '{self.db_vendor}'")
|
56
|
+
|
57
|
+
def reassign_children(self, old_parent_id, new_parent_id):
|
58
|
+
"""Set new parent to children."""
|
59
|
+
sql = f"""
|
60
|
+
UPDATE {self.table}
|
61
|
+
SET parent_id = %s
|
62
|
+
WHERE parent_id = %s
|
63
|
+
"""
|
64
|
+
with connection.cursor() as cursor:
|
65
|
+
cursor.execute(sql, [new_parent_id, old_parent_id])
|
66
|
+
|
67
|
+
|
68
|
+
class ModelSQLService:
|
69
|
+
"""
|
70
|
+
Decorate SQLService.
|
71
|
+
|
72
|
+
Descriptor to bind SQLService to Django models via
|
73
|
+
`db = ModelSQLService()`.
|
74
|
+
"""
|
75
|
+
|
76
|
+
def __set_name__(self, owner, name):
|
77
|
+
"""Set name."""
|
78
|
+
self.model = owner
|
79
|
+
|
80
|
+
def __get__(self, instance, owner):
|
81
|
+
"""Get SQLService."""
|
82
|
+
return SQLService(owner)
|
83
|
+
|
84
|
+
# The End
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
"""
|
3
|
+
Database compatibility extension module.
|
4
|
+
|
5
|
+
Adapts SQL code to the specific features of SQL syntax of various
|
6
|
+
Database vendors.
|
7
|
+
|
8
|
+
Instead of direct concatenation:
|
9
|
+
old: p._path || '.' || LPAD(...)
|
10
|
+
new: SQLCompat.concat("p._path", "'.'", SQLCompat.lpad(...))
|
11
|
+
|
12
|
+
Instead of TO_HEX(...)
|
13
|
+
old: TO_HEX(...)
|
14
|
+
new: SQLCompat.to_hex(...)
|
15
|
+
|
16
|
+
Instead of LPAD(...)
|
17
|
+
old: LPAD(...)
|
18
|
+
new: SQLCompat.lpad(...)
|
19
|
+
|
20
|
+
Version: 3.0.0
|
21
|
+
Author: Timur Kady
|
22
|
+
Email: timurkady@yandex.com
|
23
|
+
"""
|
24
|
+
|
25
|
+
from .db_vendor import is_mysql, is_mariadb, is_sqlite, is_mssql
|
26
|
+
from ...settings import TREENODE_PAD_CHAR
|
27
|
+
|
28
|
+
|
29
|
+
class SQLCompat:
|
30
|
+
"""Vendor-Specific SQL Compatibility Layer."""
|
31
|
+
|
32
|
+
@staticmethod
|
33
|
+
def concat(*args):
|
34
|
+
"""Adapt string concatenation to the vendor-specific syntax."""
|
35
|
+
args_joined = ", ".join(args)
|
36
|
+
if is_mysql() or is_mariadb():
|
37
|
+
return f"CONCAT({args_joined})"
|
38
|
+
elif is_mssql():
|
39
|
+
return " + ".join(args)
|
40
|
+
else:
|
41
|
+
return " || ".join(args)
|
42
|
+
|
43
|
+
@staticmethod
|
44
|
+
def to_hex(value):
|
45
|
+
"""Convert integer to hexadecimal string."""
|
46
|
+
if is_sqlite():
|
47
|
+
return f"printf('%x', {value})"
|
48
|
+
else:
|
49
|
+
return f"TO_HEX({value})"
|
50
|
+
|
51
|
+
@staticmethod
|
52
|
+
def lpad(value, length, char=TREENODE_PAD_CHAR):
|
53
|
+
"""Pad string to the specified length."""
|
54
|
+
if is_sqlite():
|
55
|
+
return (f"substr(replace(hex(zeroblob({length})), '00', {char}), "
|
56
|
+
f"1, {length} - length({value})) || {value}")
|
57
|
+
else:
|
58
|
+
return f"LPAD({value}, {length}, {char})"
|
59
|
+
|
60
|
+
# The End
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
"""
|
3
|
+
SQL Queue Class.
|
4
|
+
|
5
|
+
SQL query queue supporting Query and (sql, params).
|
6
|
+
|
7
|
+
Version: 3.0.0
|
8
|
+
Author: Timur Kady
|
9
|
+
Email: timurkady@yandex.com
|
10
|
+
"""
|
11
|
+
|
12
|
+
|
13
|
+
from __future__ import annotations
|
14
|
+
from django.db import connections, connection, DEFAULT_DB_ALIAS
|
15
|
+
from typing import Union, Tuple, List
|
16
|
+
from django.db.models.sql import Query
|
17
|
+
|
18
|
+
|
19
|
+
class SQLQueue:
|
20
|
+
"""SQL Queue Class."""
|
21
|
+
|
22
|
+
def __init__(self, using: str = DEFAULT_DB_ALIAS):
|
23
|
+
"""Init Queue."""
|
24
|
+
self.using = using
|
25
|
+
self._items: List[Tuple[str, list]] = []
|
26
|
+
|
27
|
+
def append(self, item: Union[Query, Tuple[str, list]]):
|
28
|
+
"""
|
29
|
+
Add a query to the queue.
|
30
|
+
|
31
|
+
Supports:
|
32
|
+
- Django Query: model.objects.filter(...).query
|
33
|
+
- tuple (sql, params)
|
34
|
+
"""
|
35
|
+
if isinstance(item, tuple):
|
36
|
+
sql, params = item
|
37
|
+
if not isinstance(sql, str) or not isinstance(params, (list, tuple)): # noqa: D501
|
38
|
+
raise TypeError("Ожидается (sql: str, params: list | tuple)")
|
39
|
+
self._items.append((sql, list(params)))
|
40
|
+
|
41
|
+
elif isinstance(item, Query):
|
42
|
+
sql, params = item.as_sql(connection)
|
43
|
+
self._items.append((sql, params))
|
44
|
+
|
45
|
+
else:
|
46
|
+
raise TypeError("Expected either Query or (sql, params)")
|
47
|
+
|
48
|
+
def flush(self):
|
49
|
+
"""
|
50
|
+
Execute all requests from the queue and clear it.
|
51
|
+
|
52
|
+
flush() is called manually.
|
53
|
+
"""
|
54
|
+
if not self._items:
|
55
|
+
return
|
56
|
+
|
57
|
+
# print("sqlq: ", self._items)
|
58
|
+
|
59
|
+
conn = connections[self.using]
|
60
|
+
with conn.cursor() as cursor:
|
61
|
+
for sql, params in self._items:
|
62
|
+
try:
|
63
|
+
cursor.execute(sql, params)
|
64
|
+
except Exception as e:
|
65
|
+
print(">>> SQLQueue error:", e)
|
66
|
+
raise
|
67
|
+
self._items.clear()
|
68
|
+
|
69
|
+
|
70
|
+
# The End
|
treenode/version.py
CHANGED
@@ -0,0 +1,91 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
"""
|
3
|
+
Route generator for all models inherited from TreeNodeModel
|
4
|
+
|
5
|
+
Version: 3.0.0
|
6
|
+
Author: Timur Kady
|
7
|
+
Email: timurkady@yandex.com
|
8
|
+
"""
|
9
|
+
|
10
|
+
|
11
|
+
from django.apps import apps
|
12
|
+
from django.urls import path
|
13
|
+
from django.conf import settings
|
14
|
+
from django.contrib.auth.decorators import login_required
|
15
|
+
|
16
|
+
from ..models import TreeNodeModel
|
17
|
+
from .autocomplete import TreeNodeAutocompleteView
|
18
|
+
from .children import TreeChildrenView
|
19
|
+
from .search import TreeSearchView
|
20
|
+
from .crud import TreeNodeBaseAPIView
|
21
|
+
|
22
|
+
|
23
|
+
class AutoTreeAPI:
|
24
|
+
"""Auto-discover and expose TreeNode-based APIs."""
|
25
|
+
|
26
|
+
def __init__(self, base_view=TreeNodeBaseAPIView, base_url="api"):
|
27
|
+
"""Init auto-discover."""
|
28
|
+
self.base_view = base_view
|
29
|
+
self.base_url = base_url
|
30
|
+
|
31
|
+
def protect_view(self, view, model):
|
32
|
+
"""
|
33
|
+
Protect view.
|
34
|
+
|
35
|
+
Protects view with login_required if needed, based on model attribute
|
36
|
+
or global settings.
|
37
|
+
"""
|
38
|
+
if getattr(model, 'api_login_required', None) is True:
|
39
|
+
return login_required(view)
|
40
|
+
if getattr(settings, 'TREENODE_API_LOGIN_REQUIRED', False):
|
41
|
+
return login_required(view)
|
42
|
+
return view
|
43
|
+
|
44
|
+
def discover(self):
|
45
|
+
"""Scan models and generate API urls."""
|
46
|
+
urls = [
|
47
|
+
# Admin and Widget end-points
|
48
|
+
path("widget/autocomplete/", TreeNodeAutocompleteView.as_view(), name="tree_autocomplete"), # noqa: D501
|
49
|
+
path("widget/children/", TreeChildrenView.as_view(), name="tree_children"), # noqa: D501
|
50
|
+
path("widget/search/", TreeSearchView.as_view(), name="tree_search"), # noqa: D501
|
51
|
+
]
|
52
|
+
for model in apps.get_models():
|
53
|
+
if issubclass(model, TreeNodeModel) and model is not TreeNodeModel:
|
54
|
+
model_name = model._meta.model_name
|
55
|
+
|
56
|
+
# Dynamically create an API view class for the model
|
57
|
+
api_view_class = type(
|
58
|
+
f"{model_name.capitalize()}APIView",
|
59
|
+
(self.base_view,),
|
60
|
+
{"model": model}
|
61
|
+
)
|
62
|
+
|
63
|
+
# List of API actions and their corresponding URL patterns
|
64
|
+
action_patterns = [
|
65
|
+
# List / Create
|
66
|
+
("", None, f"{model_name}-list"),
|
67
|
+
# Retrieve / Update / Delete
|
68
|
+
("<int:pk>/", None, f"{model_name}-detail"),
|
69
|
+
("tree/", {'action': 'tree'}, f"{model_name}-tree"),
|
70
|
+
# Direct children
|
71
|
+
("<int:pk>/children/", {'action': 'children'}, f"{model_name}-children"), # noqa: D501
|
72
|
+
# All descendants
|
73
|
+
("<int:pk>/descendants/", {'action': 'descendants'}, f"{model_name}-descendants"), # noqa: D501
|
74
|
+
# Ancestors + Self + Descendants
|
75
|
+
("<int:pk>/family/", {'action': 'family'}, f"{model_name}-family"), # noqa: D501
|
76
|
+
]
|
77
|
+
|
78
|
+
# Create secured view instance once
|
79
|
+
view = self.protect_view(api_view_class.as_view(), model)
|
80
|
+
|
81
|
+
# Automatically build all paths for this model
|
82
|
+
for url_suffix, extra_kwargs, route_name in action_patterns:
|
83
|
+
urls.append(
|
84
|
+
path(
|
85
|
+
f"{self.base_url}/{model_name}/{url_suffix}",
|
86
|
+
view,
|
87
|
+
extra_kwargs or {},
|
88
|
+
name=route_name
|
89
|
+
)
|
90
|
+
)
|
91
|
+
return urls
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
"""
|
3
|
+
|
4
|
+
|
5
|
+
Handles autocomplete suggestions for TreeNode models.
|
6
|
+
|
7
|
+
Version: 3.0.0
|
8
|
+
Author: Timur Kady
|
9
|
+
Email: timurkady@yandex.com
|
10
|
+
"""
|
11
|
+
|
12
|
+
# autocomplete.py
|
13
|
+
from django.http import JsonResponse
|
14
|
+
from django.views import View
|
15
|
+
from django.contrib.admin.views.decorators import staff_member_required
|
16
|
+
from django.utils.decorators import method_decorator
|
17
|
+
|
18
|
+
from .common import get_model_from_request
|
19
|
+
|
20
|
+
|
21
|
+
@method_decorator(staff_member_required, name='dispatch')
|
22
|
+
class TreeNodeAutocompleteView(View):
|
23
|
+
"""Widget Autocomplete View."""
|
24
|
+
|
25
|
+
def get(self, request, *args, **kwargs):
|
26
|
+
"""Get request."""
|
27
|
+
select_id = request.GET.get("select_id", "").strip()
|
28
|
+
q = request.GET.get("q", "").strip()
|
29
|
+
|
30
|
+
model = get_model_from_request(request)
|
31
|
+
results = []
|
32
|
+
|
33
|
+
if q:
|
34
|
+
field = getattr(model, "display_field", "id")
|
35
|
+
queryset = model.objects.filter(**{f"{field}__icontains": q})
|
36
|
+
elif select_id:
|
37
|
+
pk = int(select_id)
|
38
|
+
queryset = model.objects.filter(pk=pk)
|
39
|
+
else:
|
40
|
+
queryset = model.objects.filter(parent__isnull=True)
|
41
|
+
|
42
|
+
results = [
|
43
|
+
{
|
44
|
+
"id": obj.pk,
|
45
|
+
"text": str(obj),
|
46
|
+
"level": obj.get_depth(),
|
47
|
+
"is_leaf": obj.is_leaf(),
|
48
|
+
}
|
49
|
+
for obj in queryset[:20]
|
50
|
+
]
|
51
|
+
|
52
|
+
return JsonResponse({"results": results})
|