django-fast-treenode 2.1.4__py3-none-any.whl → 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.4.dist-info → django_fast_treenode-3.0.0.dist-info}/WHEEL +1 -1
  4. treenode/admin/__init__.py +2 -7
  5. treenode/admin/admin.py +138 -209
  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.4.dist-info/METADATA +0 -166
  81. django_fast_treenode-2.1.4.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.4.dist-info → django_fast_treenode-3.0.0.dist-info/licenses}/LICENSE +0 -0
  105. {django_fast_treenode-2.1.4.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
treenode/admin/mixins.py DELETED
@@ -1,302 +0,0 @@
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
@@ -1,205 +0,0 @@
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
- from django.db.models import F
18
-
19
-
20
- class TreeNodeQuerySet(models.QuerySet):
21
- """TreeNodeModel QuerySet."""
22
-
23
- def __init__(self, model=None, query=None, using=None, hints=None):
24
- # First we call the parent class constructor
25
- super().__init__(model, query, using, hints)
26
-
27
- def create(self, **kwargs):
28
- """Ensure that the save logic is executed when using create."""
29
- obj = self.model(**kwargs)
30
- obj.save()
31
- return obj
32
-
33
- def update(self, **kwargs):
34
- """Update node with synchronization of tn_parent change."""
35
- tn_parent_changed = 'tn_parent' in kwargs
36
- # Save pks of updated objects
37
- pks = list(self.values_list('pk', flat=True))
38
- # Clone the query and clear the ordering to avoid an aggregation error
39
- qs = self._clone()
40
- qs.query.clear_ordering()
41
- result = super(TreeNodeQuerySet, qs).update(**kwargs)
42
- if tn_parent_changed and pks:
43
- objs = list(self.model.objects.filter(pk__in=pks))
44
- self.model.closure_model.objects.bulk_update(objs, ['tn_parent'])
45
- return result
46
-
47
- def get_or_create(self, defaults=None, **kwargs):
48
- """Ensure that the save logic is executed when using get_or_create."""
49
- defaults = defaults or {}
50
- created = False
51
- obj = self.filter(**kwargs).first()
52
- if obj is None:
53
- params = {k: v for k, v in kwargs.items() if "__" not in k}
54
- params.update(
55
- {k: v() if callable(v) else v for k, v in defaults.items()}
56
- )
57
- obj = self.create(**params)
58
- created = True
59
- return obj, created
60
-
61
- def update_or_create(self, defaults=None, create_defaults=None, **kwargs):
62
- """Update or create."""
63
- defaults = defaults or {}
64
- create_defaults = create_defaults or {}
65
-
66
- with transaction.atomic():
67
- obj = self.filter(**kwargs).first()
68
- params = {k: v for k, v in kwargs.items() if "__" not in k}
69
- if obj is None:
70
- params.update({k: v() if callable(v) else v for k,
71
- v in create_defaults.items()})
72
- obj = self.create(**params)
73
- created = True
74
- else:
75
- params.update(
76
- {k: v() if callable(v) else v for k, v in defaults.items()})
77
- for field, value in params.items():
78
- setattr(obj, field, value)
79
- obj.save(update_fields=params.keys())
80
- created = False
81
- return obj, created
82
-
83
- def bulk_create(self, objs, batch_size=1000, *args, **kwargs):
84
- """
85
- Bulk create.
86
-
87
- Method of bulk creation objects with updating and processing of
88
- the Closuse Model.
89
- """
90
- # 1. Bulk Insertion of Nodes in Adjacency Models
91
- objs = super().bulk_create(objs, batch_size, *args, **kwargs)
92
- # 2. Synchronization of the Closing Model
93
- self.model.closure_model.objects.bulk_create(objs)
94
- # 3. Clear cache and return result
95
- self.model.clear_cache()
96
- return objs
97
-
98
- def bulk_update(self, objs, fields, batch_size=1000):
99
- """Bulk update with synchronization of tn_parent change."""
100
- # Clone the query and clear the ordering to avoid an aggregation error
101
- qs = self._clone()
102
- qs.query.clear_ordering()
103
- # Perform an Adjacency Model Update
104
- result = super(TreeNodeQuerySet, qs).bulk_update(
105
- objs, fields, batch_size
106
- )
107
- # Synchronize data in the Closing Model
108
- if 'tn_parent' in fields:
109
- self.model.closure_model.objects.bulk_update(
110
- objs, ['tn_parent'], batch_size
111
- )
112
- return result
113
-
114
-
115
- class TreeNodeModelManager(models.Manager):
116
- """TreeNodeModel Manager."""
117
-
118
- def bulk_create(self, objs, batch_size=1000, ignore_conflicts=False):
119
- """
120
- Bulk Create.
121
-
122
- Override bulk_create for the adjacency model.
123
- Here we first clear the cache, then delegate the creation via our
124
- custom QuerySet.
125
- """
126
- self.model.clear_cache()
127
- result = self.get_queryset().bulk_create(
128
- objs, batch_size=batch_size, ignore_conflicts=ignore_conflicts
129
- )
130
- transaction.on_commit(lambda: self._update_auto_increment())
131
- return result
132
-
133
- def bulk_update(self, objs, fields=None, batch_size=1000):
134
- """Bulk Update."""
135
- self.model.clear_cache()
136
- result = self.get_queryset().bulk_update(objs, fields, batch_size)
137
- return result
138
-
139
- def get_queryset(self):
140
- """Return a sorted QuerySet."""
141
- return TreeNodeQuerySet(self.model, using=self._db)\
142
- .order_by(
143
- # F('tn_parent').asc(nulls_first=True),
144
- 'tn_parent', 'tn_priority'
145
- )
146
-
147
- # Service methods -------------------
148
-
149
- def _bulk_update_tn_closure(self, objs, fields=None, batch_size=1000):
150
- """Update tn_closure in bulk."""
151
- self.model.clear_cache()
152
- super().bulk_update(objs, fields, batch_size)
153
-
154
- def _get_auto_increment_sequence(self):
155
- """Get auto increment sequence."""
156
- table_name = self.model._meta.db_table
157
- pk_column = self.model._meta.pk.column
158
- with connection.cursor() as cursor:
159
- query = "SELECT pg_get_serial_sequence(%s, %s)"
160
- cursor.execute(query, [table_name, pk_column])
161
- result = cursor.fetchone()
162
- return result[0] if result else None
163
-
164
- def _update_auto_increment(self):
165
- """Update auto increment."""
166
- table_name = self.model._meta.db_table
167
- with connection.cursor() as cursor:
168
- db_engine = connection.vendor
169
-
170
- if db_engine == "postgresql":
171
- sequence_name = self._get_auto_increment_sequence()
172
- # Get the max id from the table
173
- cursor.execute(
174
- f"SELECT COALESCE(MAX(id), 0) FROM {table_name};"
175
- )
176
- max_id = cursor.fetchone()[0]
177
- next_id = max_id + 1
178
- # Directly specify the next value of the sequence
179
- cursor.execute(
180
- f"ALTER SEQUENCE {sequence_name} RESTART WITH {next_id};"
181
- )
182
- elif db_engine == "mysql":
183
- cursor.execute(f"SELECT MAX(id) FROM {table_name};")
184
- max_id = cursor.fetchone()[0] or 0
185
- next_id = max_id + 1
186
- cursor.execute(
187
- f"ALTER TABLE {table_name} AUTO_INCREMENT = {next_id};"
188
- )
189
- elif db_engine == "sqlite":
190
- cursor.execute(
191
- f"UPDATE sqlite_sequence SET seq = (SELECT MAX(id) \
192
- FROM {table_name}) WHERE name='{table_name}';"
193
- )
194
- elif db_engine == "mssql":
195
- cursor.execute(f"SELECT MAX(id) FROM {table_name};")
196
- max_id = cursor.fetchone()[0] or 0
197
- cursor.execute(
198
- f"DBCC CHECKIDENT ('{table_name}', RESEED, {max_id});"
199
- )
200
- else:
201
- raise NotImplementedError(
202
- f"Autoincrement for {db_engine} is not supported."
203
- )
204
-
205
- # The End