django-fast-treenode 2.1.5__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.5.dist-info → django_fast_treenode-3.0.0.dist-info}/WHEEL +1 -1
  4. treenode/admin/__init__.py +0 -5
  5. treenode/admin/admin.py +137 -208
  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.5.dist-info/METADATA +0 -165
  81. django_fast_treenode-2.1.5.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.5.dist-info → django_fast_treenode-3.0.0.dist-info}/licenses/LICENSE +0 -0
  105. {django_fast_treenode-2.1.5.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
@@ -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
@@ -0,0 +1,3 @@
1
+ from django.test import TestCase
2
+
3
+ # Create your tests here.
treenode/urls.py CHANGED
@@ -1,38 +1,17 @@
1
1
  # -*- coding: utf-8 -*-
2
2
  """
3
- TreeNode URL Configuration
3
+ TreeNode URLs Module.
4
4
 
5
- This module defines URL patterns for handling AJAX requests related
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
- from django.urls import path
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
- path(
31
- "tree-children/",
32
- ChildrenView.as_view(),
33
- name="tree_children"
34
- ),
15
+ urlpatterns = [
16
+ *AutoTreeAPI().discover(),
35
17
  ]
36
-
37
-
38
- # The End
@@ -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,7 @@
1
+ # -*- coding: utf-8 -*-
2
+ from .service import ModelSQLService
3
+ from .sqlquery import SQLQueue
4
+ from .sqlcompat import SQLCompat
5
+ from .compiler import TreePathCompiler
6
+
7
+ __all__ = ['ModelSQLService', 'SQLQueue', 'SQLCompat', 'TreePathCompiler']
@@ -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
@@ -4,10 +4,10 @@ TreeNode Version Module
4
4
 
5
5
  This module defines the current version of the TreeNode package.
6
6
 
7
- Version: 2.1.5
7
+ Version: 3.0.0
8
8
  Author: Timur Kady
9
9
  Email: timurkady@yandex.com
10
10
  """
11
11
 
12
12
 
13
- __version__ = '2.1.5'
13
+ __version__ = '3.0.0'
@@ -0,0 +1,5 @@
1
+ # -*- coding: utf-8 -*-
2
+ # from .crud import *
3
+ from .autoapi import AutoTreeAPI
4
+
5
+ __all__ = ['AutoTreeAPI']
@@ -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})