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/admin/mixins.py
ADDED
@@ -0,0 +1,302 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
"""
|
3
|
+
Views Mixin for TreeNodeAdminModel
|
4
|
+
|
5
|
+
Version: 2.1.0
|
6
|
+
Author: Timur Kady
|
7
|
+
Email: kaduevtr@gmail.com
|
8
|
+
"""
|
9
|
+
|
10
|
+
import os
|
11
|
+
from datetime import datetime
|
12
|
+
from django.contrib import admin
|
13
|
+
from django.contrib import messages
|
14
|
+
from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
|
15
|
+
from django.db import models
|
16
|
+
from django.http import JsonResponse
|
17
|
+
from django.shortcuts import render, redirect
|
18
|
+
from django.shortcuts import resolve_url
|
19
|
+
from django.template.loader import render_to_string
|
20
|
+
from django.urls import path
|
21
|
+
from django.utils.encoding import force_str
|
22
|
+
from django.utils.safestring import mark_safe
|
23
|
+
|
24
|
+
import logging
|
25
|
+
|
26
|
+
logger = logging.getLogger(__name__)
|
27
|
+
|
28
|
+
|
29
|
+
class AdminMixin(admin.ModelAdmin):
|
30
|
+
"""Admin Mixin with views."""
|
31
|
+
|
32
|
+
def change_list_view(self, request):
|
33
|
+
"""
|
34
|
+
View for lazy loading of child nodes.
|
35
|
+
|
36
|
+
Clicking the expand button sends an AJAX request to this view, which
|
37
|
+
returns JSON with the data of the selected node's immediate children.
|
38
|
+
All returned nodes are collapsed by default.
|
39
|
+
"""
|
40
|
+
parent_id = request.GET.get("tn_parent_id")
|
41
|
+
if not parent_id:
|
42
|
+
# If there is no AJAX parameter, then it's normal work: show roots
|
43
|
+
return super().change_list_view(request)
|
44
|
+
|
45
|
+
parent = self.model.objects.filter(pk=parent_id).first()
|
46
|
+
if not parent:
|
47
|
+
# If there is no parent, return an empty response
|
48
|
+
return JsonResponse({'html': ''})
|
49
|
+
|
50
|
+
children = parent.get_children_queryset()
|
51
|
+
|
52
|
+
# We take list_display to understand which "columns" are needed
|
53
|
+
list_display = self.get_list_display(request)
|
54
|
+
|
55
|
+
# We collect future "rows"; each as a list of cells
|
56
|
+
rows = []
|
57
|
+
td_classes = []
|
58
|
+
for obj in children:
|
59
|
+
row_data = []
|
60
|
+
checkbox = mark_safe(
|
61
|
+
f'<input type="checkbox" name="{ACTION_CHECKBOX_NAME}"'
|
62
|
+
f' value="{obj.pk}" class="action-select" />'
|
63
|
+
)
|
64
|
+
row_data.append(checkbox)
|
65
|
+
td_classes.append("action-checkbox")
|
66
|
+
for field in list_display:
|
67
|
+
if callable(field):
|
68
|
+
# If it is a method (drag, toggle, etc.), call
|
69
|
+
row_data.append(field(obj))
|
70
|
+
field_name = field.__name__
|
71
|
+
else:
|
72
|
+
# If it is a string, we try to get the attribute from
|
73
|
+
# the object
|
74
|
+
value = getattr(obj, field, '')
|
75
|
+
if callable(value):
|
76
|
+
# If suddenly this is also a method
|
77
|
+
# (for example, property), then call it
|
78
|
+
value = value()
|
79
|
+
row_data.append(value)
|
80
|
+
field_name = field
|
81
|
+
td_classes.append(f"field-{field_name}")
|
82
|
+
|
83
|
+
row_info = {
|
84
|
+
"node_id": obj.pk,
|
85
|
+
"parent_id": parent_id,
|
86
|
+
"cells": zip(row_data, td_classes),
|
87
|
+
}
|
88
|
+
rows.append(row_info)
|
89
|
+
|
90
|
+
# Pass rows to the template
|
91
|
+
html = render_to_string(
|
92
|
+
'admin/treenode_ajax_rows.html',
|
93
|
+
{"rows": rows},
|
94
|
+
request=request
|
95
|
+
)
|
96
|
+
|
97
|
+
return JsonResponse({'html': html})
|
98
|
+
|
99
|
+
def search_view(self, request):
|
100
|
+
"""View for finding nodes using get_list_display."""
|
101
|
+
# Get a search query
|
102
|
+
q = request.GET.get("q", "")
|
103
|
+
|
104
|
+
# Perform filtering with annotation to calculate the level
|
105
|
+
queryset = self.model.objects\
|
106
|
+
.annotate(cl_depth=models.Max("parents_set__depth"))\
|
107
|
+
.filter(name__icontains=q)
|
108
|
+
queryset_list = list(queryset)[:20]
|
109
|
+
sorted_list = self.model._sort_node_list(queryset_list)
|
110
|
+
|
111
|
+
# Get the set of columns as it is formed in change_list.
|
112
|
+
# The first two columns (drag and toggle) are not needed for searching,
|
113
|
+
# and the main display column is at index 2.
|
114
|
+
list_display = self.get_list_display(request)
|
115
|
+
display_func = list_display[2]
|
116
|
+
|
117
|
+
results = []
|
118
|
+
for node in sorted_list:
|
119
|
+
# Get the HTML display of the node via the function generated by
|
120
|
+
# get_list_display
|
121
|
+
display_html = display_func(node)
|
122
|
+
results.append({
|
123
|
+
"id": node.pk,
|
124
|
+
"text": display_html,
|
125
|
+
"level": node.cl_depth,
|
126
|
+
"is_leaf": node.is_leaf(),
|
127
|
+
})
|
128
|
+
|
129
|
+
return JsonResponse({"results": results})
|
130
|
+
|
131
|
+
def import_view(self, request):
|
132
|
+
"""
|
133
|
+
Import View.
|
134
|
+
|
135
|
+
File upload processing, auto-detection of format, validation and data
|
136
|
+
import.
|
137
|
+
"""
|
138
|
+
if not self.import_export:
|
139
|
+
self.message_user(
|
140
|
+
request,
|
141
|
+
"Import functionality is disabled because required \
|
142
|
+
packages are not installed."
|
143
|
+
)
|
144
|
+
return redirect("..")
|
145
|
+
|
146
|
+
if request.method == 'POST':
|
147
|
+
if 'file' not in request.FILES:
|
148
|
+
return render(
|
149
|
+
request,
|
150
|
+
"admin/tree_node_import.html",
|
151
|
+
{"errors": ["No file uploaded."]}
|
152
|
+
)
|
153
|
+
|
154
|
+
file = request.FILES['file']
|
155
|
+
ext = os.path.splitext(file.name)[-1].lower().strip(".")
|
156
|
+
|
157
|
+
allowed_formats = {"csv", "json", "xlsx", "yaml", "tsv"}
|
158
|
+
if ext not in allowed_formats:
|
159
|
+
return render(
|
160
|
+
request,
|
161
|
+
"admin/tree_node_import.html",
|
162
|
+
{"errors": [f"Unsupported file format: {ext}"]}
|
163
|
+
)
|
164
|
+
|
165
|
+
# Import data from file
|
166
|
+
importer = self.TreeNodeImporter(self.model, file, ext)
|
167
|
+
raw_data = importer.import_data()
|
168
|
+
clean_result = importer.finalize(raw_data)
|
169
|
+
|
170
|
+
errors = clean_result.get("errors", [])
|
171
|
+
created_count = len(clean_result.get("create", []))
|
172
|
+
updated_count = len(clean_result.get("update", []))
|
173
|
+
|
174
|
+
if errors:
|
175
|
+
return render(
|
176
|
+
request,
|
177
|
+
"admin/tree_node_import_report.html",
|
178
|
+
{
|
179
|
+
"errors": errors,
|
180
|
+
"created_count": created_count,
|
181
|
+
"updated_count": updated_count,
|
182
|
+
}
|
183
|
+
)
|
184
|
+
|
185
|
+
# If there are no errors, redirect to the list of objects with
|
186
|
+
# a message
|
187
|
+
messages.success(
|
188
|
+
request,
|
189
|
+
f"Successfully imported {created_count} records. "
|
190
|
+
f"Successfully updated {updated_count} records."
|
191
|
+
)
|
192
|
+
|
193
|
+
app_label = self.model._meta.app_label
|
194
|
+
model_name = self.model._meta.model_name
|
195
|
+
admin_changelist_url = f"admin:{app_label}_{model_name}_changelist"
|
196
|
+
path = resolve_url(admin_changelist_url) + "?import_done=1"
|
197
|
+
return redirect(path)
|
198
|
+
|
199
|
+
# If the request is not POST, simply display the import form
|
200
|
+
return render(request, "admin/tree_node_import.html")
|
201
|
+
|
202
|
+
def export_view(self, request):
|
203
|
+
"""
|
204
|
+
Export view.
|
205
|
+
|
206
|
+
- If the GET parameters include download, we send the file directly.
|
207
|
+
- If the format parameter is missing, we render the format selection
|
208
|
+
page.
|
209
|
+
- If the format is specified, we perform a test export to catch errors.
|
210
|
+
|
211
|
+
If there are no errors, we render the success page with a message, a
|
212
|
+
link for manual download,
|
213
|
+
and a button to go to the model page.
|
214
|
+
"""
|
215
|
+
if not self.import_export:
|
216
|
+
self.message_user(
|
217
|
+
request,
|
218
|
+
"Export functionality is disabled because required \
|
219
|
+
packages are not installed."
|
220
|
+
)
|
221
|
+
return redirect("..")
|
222
|
+
|
223
|
+
# If the download parameter is present, we give the file directly
|
224
|
+
if 'download' in request.GET:
|
225
|
+
# Get file format
|
226
|
+
export_format = request.GET.get('format', 'csv')
|
227
|
+
# Filename
|
228
|
+
now = force_str(datetime.now().strftime("%Y-%m-%d %H-%M"))
|
229
|
+
filename = self.model._meta.label + " " + now
|
230
|
+
# Init
|
231
|
+
exporter = self.TreeNodeExporter(
|
232
|
+
self.get_queryset(request),
|
233
|
+
filename=filename
|
234
|
+
)
|
235
|
+
# Export working
|
236
|
+
response = exporter.export(export_format)
|
237
|
+
logger.debug("DEBUG: File response generated.")
|
238
|
+
return response
|
239
|
+
|
240
|
+
# If the format parameter is not passed, we show the format
|
241
|
+
# selection page
|
242
|
+
if 'format' not in request.GET:
|
243
|
+
return render(request, "admin/tree_node_export.html")
|
244
|
+
|
245
|
+
# If the format is specified, we try to perform a test export
|
246
|
+
# (without returning the file)
|
247
|
+
export_format = request.GET['format']
|
248
|
+
exporter = self.TreeNodeExporter(
|
249
|
+
self.model.objects.all(),
|
250
|
+
filename=self.model._meta.model_name
|
251
|
+
)
|
252
|
+
try:
|
253
|
+
# Test call to check for export errors (result not used)
|
254
|
+
exporter.export(export_format)
|
255
|
+
except Exception as e:
|
256
|
+
logger.error("Error during test export: %s", e)
|
257
|
+
errors = [str(e)]
|
258
|
+
return render(
|
259
|
+
request,
|
260
|
+
"admin/tree_node_export.html",
|
261
|
+
{"errors": errors}
|
262
|
+
)
|
263
|
+
|
264
|
+
# Form the correct download URL. If the URL already contains
|
265
|
+
# parameters, add them via &download=1, otherwise via ?download=1
|
266
|
+
current_url = request.build_absolute_uri()
|
267
|
+
if "?" in current_url:
|
268
|
+
download_url = current_url + "&download=1"
|
269
|
+
else:
|
270
|
+
download_url = current_url + "?download=1"
|
271
|
+
|
272
|
+
context = {
|
273
|
+
"download_url": download_url,
|
274
|
+
"message": "Your file is ready for export. \
|
275
|
+
The download should start automatically.",
|
276
|
+
"manual_download_label": "If the download does not start, \
|
277
|
+
click this link.",
|
278
|
+
# Can be replaced with the desired URL to return to the model
|
279
|
+
"redirect_url": "../",
|
280
|
+
"button_text": "Return to model"
|
281
|
+
}
|
282
|
+
return render(request, "admin/export_success.html", context)
|
283
|
+
|
284
|
+
def get_urls(self):
|
285
|
+
"""
|
286
|
+
Extend admin URLs with custom import/export routes.
|
287
|
+
|
288
|
+
Register these URLs only if all the required packages are installed.
|
289
|
+
"""
|
290
|
+
urls = super().get_urls()
|
291
|
+
if self.import_export:
|
292
|
+
custom_urls = [
|
293
|
+
path('change_list/', self.change_list_view, name='change_list'),
|
294
|
+
path('search/', self.search_view, name='search'),
|
295
|
+
path('import/', self.import_view, name='tree_node_import'),
|
296
|
+
path('export/', self.export_view, name='tree_node_export'),
|
297
|
+
]
|
298
|
+
else:
|
299
|
+
custom_urls = []
|
300
|
+
return custom_urls + urls
|
301
|
+
|
302
|
+
# The End
|
treenode/apps.py
CHANGED
@@ -5,13 +5,17 @@ TreeNode Application Configuration
|
|
5
5
|
This module defines the application configuration for the TreeNode app.
|
6
6
|
It sets the default auto field and specifies the app's name.
|
7
7
|
|
8
|
-
Version: 2.
|
8
|
+
Version: 2.1.0
|
9
9
|
Author: Timur Kady
|
10
10
|
Email: timurkady@yandex.com
|
11
11
|
"""
|
12
12
|
|
13
13
|
|
14
|
+
import logging
|
14
15
|
from django.apps import AppConfig
|
16
|
+
from django.db.models.signals import post_migrate
|
17
|
+
|
18
|
+
logger = logging.getLogger(__name__)
|
15
19
|
|
16
20
|
|
17
21
|
class TreeNodeConfig(AppConfig):
|
@@ -20,4 +24,11 @@ class TreeNodeConfig(AppConfig):
|
|
20
24
|
default_auto_field = "django.db.models.BigAutoField"
|
21
25
|
name = "treenode"
|
22
26
|
|
27
|
+
def ready(self):
|
28
|
+
"""
|
29
|
+
Attach a post_migrate handler.
|
23
30
|
|
31
|
+
This allows you to perform operations after the migration is complete.
|
32
|
+
"""
|
33
|
+
from .utils.db import post_migrate_update
|
34
|
+
post_migrate.connect(post_migrate_update, sender=self)
|
treenode/cache.py
CHANGED
treenode/forms.py
CHANGED
@@ -10,13 +10,12 @@ Functions:
|
|
10
10
|
- __init__: Initializes the form and filters out invalid parent choices.
|
11
11
|
- factory: Dynamically creates a form class for a given TreeNode model.
|
12
12
|
|
13
|
-
Version: 2.0
|
13
|
+
Version: 2.1.0
|
14
14
|
Author: Timur Kady
|
15
15
|
Email: timurkady@yandex.com
|
16
16
|
"""
|
17
17
|
|
18
18
|
from django import forms
|
19
|
-
import numpy as np
|
20
19
|
from django.forms.models import ModelChoiceField, ModelChoiceIterator
|
21
20
|
from django.utils.translation import gettext_lazy as _
|
22
21
|
|
@@ -29,13 +28,12 @@ class SortedModelChoiceIterator(ModelChoiceIterator):
|
|
29
28
|
def __iter__(self):
|
30
29
|
"""Return sorted choices based on tn_order."""
|
31
30
|
qs_list = list(self.queryset.all())
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
obj = qs_list[int(idx)]
|
31
|
+
|
32
|
+
# Sort objects
|
33
|
+
sorted_objects = self.queryset.model._sort_node_list(qs_list)
|
34
|
+
|
35
|
+
# Iterate yield (value, label) pairs.
|
36
|
+
for obj in sorted_objects:
|
39
37
|
yield (
|
40
38
|
self.field.prepare_value(obj),
|
41
39
|
self.field.label_from_instance(obj)
|
@@ -100,7 +98,7 @@ class TreeNodeForm(forms.ModelForm):
|
|
100
98
|
)
|
101
99
|
self.fields["tn_parent"].widget.model = queryset.model
|
102
100
|
|
103
|
-
#
|
101
|
+
# If there is a current value, set it
|
104
102
|
if self.instance and self.instance.pk and self.instance.tn_parent:
|
105
103
|
self.fields["tn_parent"].initial = self.instance.tn_parent
|
106
104
|
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
"""
|
3
|
+
Managers and QuerySets
|
4
|
+
|
5
|
+
This module defines custom managers and query sets for the TreeNode model.
|
6
|
+
It includes optimized bulk operations for handling hierarchical data
|
7
|
+
using the Closure Table approach.
|
8
|
+
|
9
|
+
Features:
|
10
|
+
- `ClosureModelManager` for managing closure records.
|
11
|
+
- `TreeNodeModelManager` for adjacency model operations.
|
12
|
+
|
13
|
+
Version: 2.1.0
|
14
|
+
Author: Timur Kady
|
15
|
+
Email: timurkady@yandex.com
|
16
|
+
"""
|
17
|
+
|
18
|
+
from .closure import ClosureModelManager
|
19
|
+
from .adjacency import TreeNodeModelManager
|
20
|
+
|
21
|
+
__all__ = ["TreeNodeModelManager", "ClosureModelManager"]
|
@@ -0,0 +1,203 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
"""
|
3
|
+
Adjacency List Manager and QuerySet
|
4
|
+
|
5
|
+
This module defines custom managers and query sets for the Adjacency List.
|
6
|
+
It includes operations for synchronizing with the model implementing
|
7
|
+
the Closure Table.
|
8
|
+
|
9
|
+
Version: 2.1.0
|
10
|
+
Author: Timur Kady
|
11
|
+
Email: timurkady@yandex.com
|
12
|
+
"""
|
13
|
+
|
14
|
+
from collections import deque, defaultdict
|
15
|
+
from django.db import models, transaction
|
16
|
+
from django.db import connection
|
17
|
+
|
18
|
+
|
19
|
+
class TreeNodeQuerySet(models.QuerySet):
|
20
|
+
"""TreeNodeModel QuerySet."""
|
21
|
+
|
22
|
+
def __init__(self, model=None, query=None, using=None, hints=None):
|
23
|
+
# First we call the parent class constructor
|
24
|
+
super().__init__(model, query, using, hints)
|
25
|
+
|
26
|
+
def create(self, **kwargs):
|
27
|
+
"""Ensure that the save logic is executed when using create."""
|
28
|
+
obj = self.model(**kwargs)
|
29
|
+
obj.save()
|
30
|
+
return obj
|
31
|
+
|
32
|
+
def update(self, **kwargs):
|
33
|
+
"""Update node with synchronization of tn_parent change."""
|
34
|
+
tn_parent_changed = 'tn_parent' in kwargs
|
35
|
+
# Save pks of updated objects
|
36
|
+
pks = list(self.values_list('pk', flat=True))
|
37
|
+
# Clone the query and clear the ordering to avoid an aggregation error
|
38
|
+
qs = self._clone()
|
39
|
+
qs.query.clear_ordering()
|
40
|
+
result = super(TreeNodeQuerySet, qs).update(**kwargs)
|
41
|
+
if tn_parent_changed and pks:
|
42
|
+
objs = list(self.model.objects.filter(pk__in=pks))
|
43
|
+
self.model.closure_model.objects.bulk_update(objs, ['tn_parent'])
|
44
|
+
return result
|
45
|
+
|
46
|
+
def get_or_create(self, defaults=None, **kwargs):
|
47
|
+
"""Ensure that the save logic is executed when using get_or_create."""
|
48
|
+
defaults = defaults or {}
|
49
|
+
created = False
|
50
|
+
obj = self.filter(**kwargs).first()
|
51
|
+
if obj is None:
|
52
|
+
params = {k: v for k, v in kwargs.items() if "__" not in k}
|
53
|
+
params.update(
|
54
|
+
{k: v() if callable(v) else v for k, v in defaults.items()}
|
55
|
+
)
|
56
|
+
obj = self.create(**params)
|
57
|
+
created = True
|
58
|
+
return obj, created
|
59
|
+
|
60
|
+
def update_or_create(self, defaults=None, create_defaults=None, **kwargs):
|
61
|
+
"""Update or create."""
|
62
|
+
defaults = defaults or {}
|
63
|
+
create_defaults = create_defaults or {}
|
64
|
+
|
65
|
+
with transaction.atomic():
|
66
|
+
obj = self.filter(**kwargs).first()
|
67
|
+
params = {k: v for k, v in kwargs.items() if "__" not in k}
|
68
|
+
if obj is None:
|
69
|
+
params.update({k: v() if callable(v) else v for k,
|
70
|
+
v in create_defaults.items()})
|
71
|
+
obj = self.create(**params)
|
72
|
+
created = True
|
73
|
+
else:
|
74
|
+
params.update(
|
75
|
+
{k: v() if callable(v) else v for k, v in defaults.items()})
|
76
|
+
for field, value in params.items():
|
77
|
+
setattr(obj, field, value)
|
78
|
+
obj.save(update_fields=params.keys())
|
79
|
+
created = False
|
80
|
+
return obj, created
|
81
|
+
|
82
|
+
def bulk_create(self, objs, batch_size=1000, *args, **kwargs):
|
83
|
+
"""
|
84
|
+
Bulk create.
|
85
|
+
|
86
|
+
Method of bulk creation objects with updating and processing of
|
87
|
+
the Closuse Model.
|
88
|
+
"""
|
89
|
+
# 1. Bulk Insertion of Nodes in Adjacency Models
|
90
|
+
objs = super().bulk_create(objs, batch_size, *args, **kwargs)
|
91
|
+
# 2. Synchronization of the Closing Model
|
92
|
+
self.model.closure_model.objects.bulk_create(objs)
|
93
|
+
# 3. Clear cache and return result
|
94
|
+
self.model.clear_cache()
|
95
|
+
return objs
|
96
|
+
|
97
|
+
def bulk_update(self, objs, fields, batch_size=1000):
|
98
|
+
"""Bulk update with synchronization of tn_parent change."""
|
99
|
+
# Clone the query and clear the ordering to avoid an aggregation error
|
100
|
+
qs = self._clone()
|
101
|
+
qs.query.clear_ordering()
|
102
|
+
# Perform an Adjacency Model Update
|
103
|
+
result = super(TreeNodeQuerySet, qs).bulk_update(
|
104
|
+
objs, fields, batch_size
|
105
|
+
)
|
106
|
+
# Synchronize data in the Closing Model
|
107
|
+
if 'tn_parent' in fields:
|
108
|
+
self.model.closure_model.objects.bulk_update(
|
109
|
+
objs, ['tn_parent'], batch_size
|
110
|
+
)
|
111
|
+
return result
|
112
|
+
|
113
|
+
|
114
|
+
class TreeNodeModelManager(models.Manager):
|
115
|
+
"""TreeNodeModel Manager."""
|
116
|
+
|
117
|
+
def bulk_create(self, objs, batch_size=1000, ignore_conflicts=False):
|
118
|
+
"""
|
119
|
+
Bulk Create.
|
120
|
+
|
121
|
+
Override bulk_create for the adjacency model.
|
122
|
+
Here we first clear the cache, then delegate the creation via our
|
123
|
+
custom QuerySet.
|
124
|
+
"""
|
125
|
+
self.model.clear_cache()
|
126
|
+
result = self.get_queryset().bulk_create(
|
127
|
+
objs, batch_size=batch_size, ignore_conflicts=ignore_conflicts
|
128
|
+
)
|
129
|
+
transaction.on_commit(lambda: self._update_auto_increment())
|
130
|
+
return result
|
131
|
+
|
132
|
+
def bulk_update(self, objs, fields=None, batch_size=1000):
|
133
|
+
"""Bulk Update."""
|
134
|
+
self.model.clear_cache()
|
135
|
+
result = self.get_queryset().bulk_update(objs, fields, batch_size)
|
136
|
+
return result
|
137
|
+
|
138
|
+
def get_queryset(self):
|
139
|
+
"""Return a sorted QuerySet."""
|
140
|
+
queryset = TreeNodeQuerySet(self.model, using=self._db)\
|
141
|
+
.annotate(_depth_db=models.Max("parents_set__depth"))\
|
142
|
+
.order_by("_depth_db", "tn_parent", "tn_priority")
|
143
|
+
return queryset
|
144
|
+
|
145
|
+
# Service methods -------------------
|
146
|
+
|
147
|
+
def _bulk_update_tn_closure(self, objs, fields=None, batch_size=1000):
|
148
|
+
"""Update tn_closure in bulk."""
|
149
|
+
self.model.clear_cache()
|
150
|
+
super().bulk_update(objs, fields, batch_size)
|
151
|
+
|
152
|
+
def _get_auto_increment_sequence(self):
|
153
|
+
"""Get auto increment sequence."""
|
154
|
+
table_name = self.model._meta.db_table
|
155
|
+
pk_column = self.model._meta.pk.column
|
156
|
+
with connection.cursor() as cursor:
|
157
|
+
query = "SELECT pg_get_serial_sequence(%s, %s)"
|
158
|
+
cursor.execute(query, [table_name, pk_column])
|
159
|
+
result = cursor.fetchone()
|
160
|
+
return result[0] if result else None
|
161
|
+
|
162
|
+
def _update_auto_increment(self):
|
163
|
+
"""Update auto increment."""
|
164
|
+
table_name = self.model._meta.db_table
|
165
|
+
with connection.cursor() as cursor:
|
166
|
+
db_engine = connection.vendor
|
167
|
+
|
168
|
+
if db_engine == "postgresql":
|
169
|
+
sequence_name = self._get_auto_increment_sequence()
|
170
|
+
# Get the max id from the table
|
171
|
+
cursor.execute(
|
172
|
+
f"SELECT COALESCE(MAX(id), 0) FROM {table_name};"
|
173
|
+
)
|
174
|
+
max_id = cursor.fetchone()[0]
|
175
|
+
next_id = max_id + 1
|
176
|
+
# Directly specify the next value of the sequence
|
177
|
+
cursor.execute(
|
178
|
+
f"ALTER SEQUENCE {sequence_name} RESTART WITH {next_id};"
|
179
|
+
)
|
180
|
+
elif db_engine == "mysql":
|
181
|
+
cursor.execute(f"SELECT MAX(id) FROM {table_name};")
|
182
|
+
max_id = cursor.fetchone()[0] or 0
|
183
|
+
next_id = max_id + 1
|
184
|
+
cursor.execute(
|
185
|
+
f"ALTER TABLE {table_name} AUTO_INCREMENT = {next_id};"
|
186
|
+
)
|
187
|
+
elif db_engine == "sqlite":
|
188
|
+
cursor.execute(
|
189
|
+
f"UPDATE sqlite_sequence SET seq = (SELECT MAX(id) \
|
190
|
+
FROM {table_name}) WHERE name='{table_name}';"
|
191
|
+
)
|
192
|
+
elif db_engine == "mssql":
|
193
|
+
cursor.execute(f"SELECT MAX(id) FROM {table_name};")
|
194
|
+
max_id = cursor.fetchone()[0] or 0
|
195
|
+
cursor.execute(
|
196
|
+
f"DBCC CHECKIDENT ('{table_name}', RESEED, {max_id});"
|
197
|
+
)
|
198
|
+
else:
|
199
|
+
raise NotImplementedError(
|
200
|
+
f"Autoincrement for {db_engine} is not supported."
|
201
|
+
)
|
202
|
+
|
203
|
+
# The End
|