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.
- django_fast_treenode-3.0.0.dist-info/METADATA +203 -0
- django_fast_treenode-3.0.0.dist-info/RECORD +90 -0
- {django_fast_treenode-2.1.5.dist-info → django_fast_treenode-3.0.0.dist-info}/WHEEL +1 -1
- treenode/admin/__init__.py +0 -5
- treenode/admin/admin.py +137 -208
- 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 +42 -20
- 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.5.dist-info/METADATA +0 -165
- django_fast_treenode-2.1.5.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.5.dist-info → django_fast_treenode-3.0.0.dist-info}/licenses/LICENSE +0 -0
- {django_fast_treenode-2.1.5.dist-info → django_fast_treenode-3.0.0.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,171 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
"""
|
3
|
+
TreeNode Importer Module
|
4
|
+
|
5
|
+
This module provides functionality for importing tree-structured data
|
6
|
+
from various formats, including CSV, JSON, XLSX, YAML, and TSV.
|
7
|
+
|
8
|
+
Features:
|
9
|
+
- Supports field mapping and data type conversion for model compatibility.
|
10
|
+
- Handles ForeignKey relationships and ManyToMany fields.
|
11
|
+
- Validates and processes raw data before saving to the database.
|
12
|
+
- Uses bulk operations for efficient data insertion and updates.
|
13
|
+
- Supports transactional imports to maintain data integrity.
|
14
|
+
|
15
|
+
Version: 3.0.0
|
16
|
+
Author: Timur Kady
|
17
|
+
Email: timurkady@yandex.com
|
18
|
+
"""
|
19
|
+
|
20
|
+
# Новый модуль импорта для древовидных структур в Django
|
21
|
+
|
22
|
+
import csv
|
23
|
+
import json
|
24
|
+
import yaml
|
25
|
+
import openpyxl
|
26
|
+
from io import BytesIO, StringIO
|
27
|
+
from django.db import transaction
|
28
|
+
from django.core.exceptions import ValidationError, ObjectDoesNotExist
|
29
|
+
|
30
|
+
from ..cache import treenode_cache as cache
|
31
|
+
|
32
|
+
|
33
|
+
class TreeNodeImporter:
|
34
|
+
"""Importer of tree data from various formats."""
|
35
|
+
|
36
|
+
def __init__(self, model, file, file_format):
|
37
|
+
"""Init."""
|
38
|
+
self.model = model
|
39
|
+
self.file = file
|
40
|
+
self.format = file_format.lower()
|
41
|
+
self.rows = []
|
42
|
+
self.rows_by_id = {}
|
43
|
+
self.result = {"created": 0, "updated": 0, "errors": []}
|
44
|
+
|
45
|
+
def parse(self):
|
46
|
+
"""Parse a file."""
|
47
|
+
if self.format == "xlsx":
|
48
|
+
content = self.file.read() # binary
|
49
|
+
self.rows = self._parse_xlsx(content)
|
50
|
+
else:
|
51
|
+
text = self.file.read()
|
52
|
+
if isinstance(text, bytes):
|
53
|
+
text = text.decode("utf-8")
|
54
|
+
|
55
|
+
if self.format == "csv":
|
56
|
+
self.rows = list(csv.DictReader(StringIO(text)))
|
57
|
+
elif self.format == "tsv":
|
58
|
+
self.rows = list(csv.DictReader(StringIO(text), delimiter="\t"))
|
59
|
+
elif self.format == "json":
|
60
|
+
self.rows = json.loads(text)
|
61
|
+
elif self.format == "yaml":
|
62
|
+
self.rows = yaml.safe_load(text)
|
63
|
+
else:
|
64
|
+
raise ValueError("Unsupported file format")
|
65
|
+
|
66
|
+
self._build_hierarchy()
|
67
|
+
|
68
|
+
def _parse_xlsx(self, content):
|
69
|
+
"""Parse the xlsx format."""
|
70
|
+
wb = openpyxl.load_workbook(BytesIO(content), read_only=True)
|
71
|
+
ws = wb.active
|
72
|
+
headers = [cell.value for cell in next(
|
73
|
+
ws.iter_rows(min_row=1, max_row=1))]
|
74
|
+
return [
|
75
|
+
dict(zip(headers, row))
|
76
|
+
for row in ws.iter_rows(min_row=2, values_only=True)
|
77
|
+
]
|
78
|
+
|
79
|
+
def _build_hierarchy(self):
|
80
|
+
"""
|
81
|
+
Build and check hierarchy.
|
82
|
+
|
83
|
+
Calculates _depth and _path based on parent and priority. Checks that
|
84
|
+
each parent exists in either the imported set or the base. The _path
|
85
|
+
is built based on priority, as in the main package.
|
86
|
+
"""
|
87
|
+
self.rows_by_id = {str(row.get("id")): row for row in self.rows}
|
88
|
+
|
89
|
+
def build_path_and_depth(row, visited=None):
|
90
|
+
if visited is None:
|
91
|
+
visited = set()
|
92
|
+
row_id = str(row.get("id"))
|
93
|
+
if row_id in visited:
|
94
|
+
raise ValueError(f"Cycle detected at row {row_id}")
|
95
|
+
visited.add(row_id)
|
96
|
+
|
97
|
+
parent_id = str(row.get("parent")) if row.get("parent") else None
|
98
|
+
if not parent_id:
|
99
|
+
row["_depth"] = 0
|
100
|
+
row["_path"] = str(row.get("priority", "0")).zfill(4)
|
101
|
+
return row["_path"]
|
102
|
+
|
103
|
+
parent_row = self.rows_by_id.get(parent_id)
|
104
|
+
if parent_row:
|
105
|
+
parent_path = build_path_and_depth(parent_row, visited)
|
106
|
+
else:
|
107
|
+
try:
|
108
|
+
self.model.objects.get(pk=parent_id)
|
109
|
+
parent_path = "fromdb"
|
110
|
+
except ObjectDoesNotExist:
|
111
|
+
self.result["errors"].append(
|
112
|
+
f"Parent {parent_id} for node {row_id} not found.")
|
113
|
+
parent_path = "invalid"
|
114
|
+
|
115
|
+
row["_depth"] = parent_path.count(
|
116
|
+
".") + 1 if parent_path != "invalid" else 0
|
117
|
+
priority = str(row.get("priority", "0")).zfill(4)
|
118
|
+
row["_path"] = parent_path + "." + \
|
119
|
+
priority if parent_path != "invalid" else priority
|
120
|
+
return row["_path"]
|
121
|
+
|
122
|
+
for row in self.rows:
|
123
|
+
try:
|
124
|
+
build_path_and_depth(row)
|
125
|
+
except Exception as e:
|
126
|
+
self.result["errors"].append(str(e))
|
127
|
+
|
128
|
+
def import_tree(self):
|
129
|
+
"""Import tree nodes level by level."""
|
130
|
+
with transaction.atomic():
|
131
|
+
rows_by_level = {}
|
132
|
+
for row in self.rows:
|
133
|
+
level = row.get("_depth", 0)
|
134
|
+
rows_by_level.setdefault(level, []).append(row)
|
135
|
+
|
136
|
+
id_map = {}
|
137
|
+
for depth in sorted(rows_by_level.keys()):
|
138
|
+
to_create = []
|
139
|
+
for row in rows_by_level[depth]:
|
140
|
+
pk = row.get("id")
|
141
|
+
|
142
|
+
if "parent" in row and (row["parent"] == "" or row["parent"] is None):
|
143
|
+
row["parent"] = None
|
144
|
+
|
145
|
+
if "parent" in row:
|
146
|
+
temp_parent_id = row.pop("parent")
|
147
|
+
if temp_parent_id is not None:
|
148
|
+
# Используем уже созданный ID родителя
|
149
|
+
row["parent_id"] = id_map.get(
|
150
|
+
temp_parent_id, temp_parent_id)
|
151
|
+
|
152
|
+
try:
|
153
|
+
obj = self.model(**row)
|
154
|
+
obj.full_clean()
|
155
|
+
to_create.append(obj)
|
156
|
+
except ValidationError as e:
|
157
|
+
self.result["errors"].append(
|
158
|
+
f"Validation error for {pk}: {e}")
|
159
|
+
|
160
|
+
created = self.model.objects.bulk_create(to_create)
|
161
|
+
for obj in created:
|
162
|
+
id_map[obj.pk] = obj.pk
|
163
|
+
self.result["created"] += len(created)
|
164
|
+
|
165
|
+
self.model.tasks.add("update", None)
|
166
|
+
cache.invalidate(self.model._meta.label)
|
167
|
+
|
168
|
+
return self.result
|
169
|
+
|
170
|
+
|
171
|
+
# The End
|
treenode/admin/mixin.py
ADDED
@@ -0,0 +1,291 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
"""
|
3
|
+
TreeNode Admin Model Class Mixin
|
4
|
+
|
5
|
+
Views for AdminModel
|
6
|
+
|
7
|
+
Version: 3.0.0
|
8
|
+
Author: Timur Kady
|
9
|
+
Email: timurkady@yandex.com
|
10
|
+
"""
|
11
|
+
|
12
|
+
import os
|
13
|
+
import json
|
14
|
+
from datetime import datetime
|
15
|
+
from django.contrib import admin
|
16
|
+
from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
|
17
|
+
from django.contrib.admin.utils import lookup_field
|
18
|
+
from django.db.models import Q
|
19
|
+
from django.http import HttpResponseBadRequest
|
20
|
+
from django.http import JsonResponse
|
21
|
+
from django.shortcuts import render
|
22
|
+
from django.template.loader import render_to_string
|
23
|
+
from django.urls import path
|
24
|
+
from django.utils.decorators import method_decorator
|
25
|
+
from django.utils.safestring import mark_safe
|
26
|
+
from django.utils.translation import gettext_lazy as _
|
27
|
+
from django.views.decorators.cache import never_cache
|
28
|
+
|
29
|
+
|
30
|
+
class AdminMixin(admin.ModelAdmin):
|
31
|
+
"""Admin Mixin for lazy loading and search in tree."""
|
32
|
+
|
33
|
+
def get_search_help_text(self, request):
|
34
|
+
"""Get search help text."""
|
35
|
+
return getattr(super(), 'get_search_help_text', lambda r: '')(request)
|
36
|
+
|
37
|
+
def get_urls(self):
|
38
|
+
"""Add custom URLs for AJAX loading and searching."""
|
39
|
+
default_urls = super().get_urls()
|
40
|
+
|
41
|
+
custom_urls = [
|
42
|
+
path(
|
43
|
+
"change_list/",
|
44
|
+
self.admin_site.admin_view(self.ajax_changelist_view),
|
45
|
+
name="tree_changelist"
|
46
|
+
),
|
47
|
+
path(
|
48
|
+
"move/",
|
49
|
+
self.admin_site.admin_view(self.ajax_move_view),
|
50
|
+
name="tree_changelist_move"
|
51
|
+
),
|
52
|
+
path(
|
53
|
+
"import/",
|
54
|
+
self.admin_site.admin_view(self.import_view),
|
55
|
+
name="tree_node_import"
|
56
|
+
),
|
57
|
+
path("export/",
|
58
|
+
self.admin_site.admin_view(self.export_view),
|
59
|
+
name="tree_node_export"
|
60
|
+
),
|
61
|
+
]
|
62
|
+
return custom_urls + default_urls
|
63
|
+
|
64
|
+
def render_changelist_rows(self, objs: list, request):
|
65
|
+
"""Rander rows for incert to changelist."""
|
66
|
+
list_display = list(self.get_list_display(request))
|
67
|
+
checkbox_field_name = ACTION_CHECKBOX_NAME
|
68
|
+
if checkbox_field_name not in list_display:
|
69
|
+
list_display.insert(0, checkbox_field_name)
|
70
|
+
|
71
|
+
rows = []
|
72
|
+
for obj in objs:
|
73
|
+
row_data = []
|
74
|
+
td_classes = []
|
75
|
+
|
76
|
+
for field in list_display:
|
77
|
+
if field == ACTION_CHECKBOX_NAME:
|
78
|
+
checkbox = f'<input type="checkbox" id="action_{obj.pk}" name="{ACTION_CHECKBOX_NAME}" value="{obj.pk}" class="action-select" />' # noqa: D501
|
79
|
+
row_data.append(mark_safe(checkbox))
|
80
|
+
td_classes.append("action-checkbox")
|
81
|
+
continue
|
82
|
+
|
83
|
+
if callable(field):
|
84
|
+
value = field(obj)
|
85
|
+
field_name = getattr(field, "__name__", "field")
|
86
|
+
else:
|
87
|
+
attr, value = lookup_field(field, obj, self)
|
88
|
+
field_name = field
|
89
|
+
|
90
|
+
row_data.append(value)
|
91
|
+
td_classes.append(f"field-{field_name}")
|
92
|
+
|
93
|
+
rows.append({
|
94
|
+
"node_id": obj.pk,
|
95
|
+
"attrs": f'data-node-id="{obj.pk}" data-parent-of="{obj.parent_id or ""}" class="model-{self.model._meta.model_name} pk-{obj.pk}"', # noqa: D501
|
96
|
+
"parent_id": obj.parent_id,
|
97
|
+
"cells": list(zip(row_data, td_classes)),
|
98
|
+
})
|
99
|
+
return rows
|
100
|
+
|
101
|
+
@method_decorator(never_cache)
|
102
|
+
def ajax_changelist_view(self, request, extra_context=None):
|
103
|
+
"""
|
104
|
+
Create a default changelist or load child elements via AJAX.
|
105
|
+
|
106
|
+
- If `parent_id` is passed, returns only child rows (HTML).
|
107
|
+
- If `q` is passed, returns up to 20 matching rows (HTML).
|
108
|
+
- Otherwise, displays the full changelist.
|
109
|
+
"""
|
110
|
+
extra_context = extra_context or {}
|
111
|
+
extra_context['import_export_enabled'] = self.import_export
|
112
|
+
# Define the show_admin_actions variable in the context
|
113
|
+
extra_context['show_admin_actions'] = extra_context.get(
|
114
|
+
"show_admin_actions", False)
|
115
|
+
parent_id = request.GET.get("parent_id", "")
|
116
|
+
query = request.GET.get("q", "")
|
117
|
+
expanded = request.GET.get("expanded", "[]")
|
118
|
+
|
119
|
+
if parent_id:
|
120
|
+
parent = self.model.objects.filter(pk=parent_id).first()
|
121
|
+
rows_list = parent.get_children() if parent else []
|
122
|
+
|
123
|
+
elif query:
|
124
|
+
name_field = getattr(self.model, "display_field", "id")
|
125
|
+
rows_list = list(self.model.objects.filter(
|
126
|
+
Q(**{f"{name_field}__icontains": query})
|
127
|
+
| Q(pk__icontains=query)
|
128
|
+
).order_by("_path")[:20])
|
129
|
+
|
130
|
+
elif expanded:
|
131
|
+
try:
|
132
|
+
expanded_list = json.loads(expanded)
|
133
|
+
except ValueError:
|
134
|
+
return JsonResponse({"error": _("Bad node list.")}, status=422)
|
135
|
+
if expanded_list:
|
136
|
+
queryset = self.model.objects.filter(
|
137
|
+
Q(parent__isnull=True) |
|
138
|
+
Q(pk__in=expanded_list) |
|
139
|
+
Q(parent_id__in=expanded_list)
|
140
|
+
).distinct()
|
141
|
+
else:
|
142
|
+
queryset = self.model.objects.filter(parent__isnull=True)
|
143
|
+
|
144
|
+
rows_list = list(queryset.order_by("_path"))
|
145
|
+
else:
|
146
|
+
return JsonResponse({"html": ""})
|
147
|
+
|
148
|
+
rows = self.render_changelist_rows(rows_list, request)
|
149
|
+
|
150
|
+
# Take model verbose name
|
151
|
+
verbose_name = getattr(self.model._meta, "verbose_name_plural", None) \
|
152
|
+
or getattr(self.model._meta, "verbose_name", None) \
|
153
|
+
or self.model._meta.object_name
|
154
|
+
|
155
|
+
# Render HTML
|
156
|
+
html = render_to_string(
|
157
|
+
"admin/treenode_ajax_rows.html",
|
158
|
+
{"rows": rows},
|
159
|
+
request=request
|
160
|
+
)
|
161
|
+
return JsonResponse({"html": html, "label": verbose_name})
|
162
|
+
|
163
|
+
@method_decorator(never_cache)
|
164
|
+
def ajax_move_view(self, request, extra_context=None):
|
165
|
+
"""
|
166
|
+
Perform drag-and-drop move operation for a node.
|
167
|
+
|
168
|
+
Moves a node relative to a target node using the specified mode.
|
169
|
+
"""
|
170
|
+
# 1. Extracting parameters
|
171
|
+
node_id = request.POST.get("node_id")
|
172
|
+
target_id = request.POST.get("target_id")
|
173
|
+
mode = request.POST.get("mode")
|
174
|
+
|
175
|
+
if not (node_id and mode):
|
176
|
+
return JsonResponse({"error": _("Missing parameters.")}, status=400)
|
177
|
+
|
178
|
+
# 2. Getting objects
|
179
|
+
node = self.model.objects.filter(pk=node_id).first()
|
180
|
+
if not node or mode not in ("child", "after"):
|
181
|
+
return JsonResponse(
|
182
|
+
{"error": _(f"Invalid node ({node_id}) or mode ({mode}).")},
|
183
|
+
status=422
|
184
|
+
)
|
185
|
+
|
186
|
+
# Prepare the target
|
187
|
+
if not target_id or target_id == 'null':
|
188
|
+
# Insetr like a root
|
189
|
+
target_id = None
|
190
|
+
target = None
|
191
|
+
else:
|
192
|
+
target_id = int(target_id)
|
193
|
+
target = self.model.objects.filter(pk=target_id).first()
|
194
|
+
|
195
|
+
# 3. Protection from moving into your descendants
|
196
|
+
descendants_pks = node.query("descendants", include_self=True)
|
197
|
+
if target_id in descendants_pks:
|
198
|
+
return JsonResponse({
|
199
|
+
"error": _("Cannot move a node into its own descendants.")
|
200
|
+
}, status=409)
|
201
|
+
|
202
|
+
# 4. Positioning and moving
|
203
|
+
sorting_mode = self.model.sorting_field == 'priority'
|
204
|
+
if target:
|
205
|
+
position = {
|
206
|
+
"after": 'right-sibling' if sorting_mode else 'sorted-sibling',
|
207
|
+
"child": 'last-child' if sorting_mode else 'sorted-child'
|
208
|
+
}[mode]
|
209
|
+
else:
|
210
|
+
position = 'first-root' if sorting_mode else 'sorted-root'
|
211
|
+
|
212
|
+
# 5. Adjustments
|
213
|
+
if mode == 'after' and node.parent == target:
|
214
|
+
# User wants to insert a node after a node-parent
|
215
|
+
# print("User wants to insert a node after a node-parent.")
|
216
|
+
pass
|
217
|
+
|
218
|
+
# Debug
|
219
|
+
# print(mode, "-", position)
|
220
|
+
|
221
|
+
# Moving
|
222
|
+
node.move_to(target, position)
|
223
|
+
|
224
|
+
return JsonResponse({
|
225
|
+
"message": _("1 node successfully moved")
|
226
|
+
}, status=200)
|
227
|
+
|
228
|
+
def import_view(self, request):
|
229
|
+
"""
|
230
|
+
Impoern View.
|
231
|
+
|
232
|
+
Handles file upload and initiates import processing via
|
233
|
+
TreeNodeImporter.
|
234
|
+
Renders summary and any errors.
|
235
|
+
"""
|
236
|
+
if request.method == "POST":
|
237
|
+
file = request.FILES.get("file")
|
238
|
+
if not file:
|
239
|
+
return HttpResponseBadRequest("Missing file or format")
|
240
|
+
|
241
|
+
filename = file.name
|
242
|
+
extension = os.path.splitext(filename)[1].lower().lstrip('.')
|
243
|
+
if extension not in ['csv', 'tsv', 'json', 'xlsx', 'yaml']:
|
244
|
+
return JsonResponse(
|
245
|
+
{"error": _(f"Invalid file format ({extension}.")},
|
246
|
+
status=200
|
247
|
+
)
|
248
|
+
importer = self.TreeNodeImporter(self.model, file, extension)
|
249
|
+
importer.parse()
|
250
|
+
result = importer.import_tree()
|
251
|
+
|
252
|
+
return render(request, "admin/treenode_import_export.html", {
|
253
|
+
"created_count": result.get("created", 0),
|
254
|
+
"updated_count": result.get("updated", 0),
|
255
|
+
"errors": result.get("errors", []),
|
256
|
+
"import_active": True
|
257
|
+
})
|
258
|
+
|
259
|
+
return render(
|
260
|
+
request,
|
261
|
+
"admin/treenode_import_export.html",
|
262
|
+
{"import_active": True}
|
263
|
+
)
|
264
|
+
|
265
|
+
def export_view(self, request):
|
266
|
+
"""
|
267
|
+
Export View.
|
268
|
+
|
269
|
+
Handles GET-based export of data in selected format via
|
270
|
+
TreeNodeExporter.
|
271
|
+
Returns a streaming response for large datasets.
|
272
|
+
"""
|
273
|
+
if request.GET.get("download"):
|
274
|
+
fmt = request.GET.get("format", "csv")
|
275
|
+
filename = self.model._meta.object_name
|
276
|
+
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M")
|
277
|
+
filename = f"{filename}-{timestamp}.{fmt}"
|
278
|
+
exporter = self.TreeNodeExporter(
|
279
|
+
self.model,
|
280
|
+
filename=filename,
|
281
|
+
fileformat=fmt
|
282
|
+
)
|
283
|
+
return exporter.process_record()
|
284
|
+
|
285
|
+
return render(
|
286
|
+
request,
|
287
|
+
"admin/treenode_import_export.html",
|
288
|
+
{"import_active": False}
|
289
|
+
)
|
290
|
+
|
291
|
+
# The End
|
treenode/apps.py
CHANGED
@@ -1,34 +1,56 @@
|
|
1
|
-
# -*- coding: utf-8 -*-
|
2
1
|
"""
|
3
|
-
TreeNode
|
2
|
+
TreeNode configuration definition module.
|
4
3
|
|
5
|
-
|
6
|
-
|
4
|
+
Customization:
|
5
|
+
- checks the correctness of the sorting fields
|
6
|
+
- checks the correctness of model inheritance
|
7
|
+
- starts asynchronous loading of node data into the cache
|
7
8
|
|
8
|
-
Version:
|
9
|
+
Version: 3.0.0
|
9
10
|
Author: Timur Kady
|
10
11
|
Email: timurkady@yandex.com
|
11
12
|
"""
|
12
13
|
|
13
|
-
|
14
|
-
import logging
|
15
|
-
from django.apps import AppConfig
|
16
|
-
from django.db.models.signals import post_migrate
|
17
|
-
|
18
|
-
logger = logging.getLogger(__name__)
|
14
|
+
from django.apps import apps, AppConfig
|
19
15
|
|
20
16
|
|
21
17
|
class TreeNodeConfig(AppConfig):
|
22
|
-
"""
|
18
|
+
"""Config Class."""
|
23
19
|
|
24
20
|
default_auto_field = "django.db.models.BigAutoField"
|
25
|
-
name = "
|
21
|
+
name = "supertree"
|
26
22
|
|
27
23
|
def ready(self):
|
28
|
-
"""
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
24
|
+
"""Ready method."""
|
25
|
+
from .models import TreeNodeModel
|
26
|
+
|
27
|
+
# Models checking
|
28
|
+
subclasses = [
|
29
|
+
m for m in apps.get_models()
|
30
|
+
if issubclass(m, TreeNodeModel) and m is not TreeNodeModel
|
31
|
+
]
|
32
|
+
|
33
|
+
for model in subclasses:
|
34
|
+
|
35
|
+
field_names = {f.name for f in model._meta.get_fields()}
|
36
|
+
|
37
|
+
# Check display_field is correct
|
38
|
+
if model.display_field is not None:
|
39
|
+
if model.display_field not in field_names:
|
40
|
+
raise ValueError(
|
41
|
+
f'Invalid display_field "{model.display_field}. "'
|
42
|
+
f'Available fields: {field_names}')
|
43
|
+
|
44
|
+
# Check sorting_field is correct
|
45
|
+
if model.sorting_field is not None:
|
46
|
+
if model.sorting_field not in field_names:
|
47
|
+
raise ValueError(
|
48
|
+
f'Invalid sorting_field "{model.sorting_field}. "'
|
49
|
+
f'Available fields: {field_names}')
|
50
|
+
|
51
|
+
# Check if Meta is a descendant of TreeNodeModel.Meta
|
52
|
+
if not issubclass(model.Meta, TreeNodeModel.Meta):
|
53
|
+
raise ValueError(
|
54
|
+
f'{model.__name__} must inherit Meta class ' +
|
55
|
+
'from TreeNodeModel.Meta.'
|
56
|
+
)
|