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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. django_fast_treenode-3.0.1.dist-info/METADATA +203 -0
  2. django_fast_treenode-3.0.1.dist-info/RECORD +90 -0
  3. {django_fast_treenode-2.1.4.dist-info → django_fast_treenode-3.0.1.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 +41 -19
  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.1.dist-info/licenses}/LICENSE +0 -0
  105. {django_fast_treenode-2.1.4.dist-info → django_fast_treenode-3.0.1.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,170 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ TreeNode Exporter Module
4
+
5
+ This module provides functionality for stream exporting tree-structured data
6
+ to various formats, including CSV, JSON, TSV, XLSX, YAML.
7
+
8
+ Version: 3.0.0
9
+ Author: Timur Kady
10
+ Email: timurkady@yandex.com
11
+ """
12
+
13
+ import csv
14
+ import json
15
+ import yaml
16
+ from django.core.serializers.json import DjangoJSONEncoder
17
+ from django.http import StreamingHttpResponse
18
+ from io import BytesIO, StringIO
19
+ from openpyxl import Workbook
20
+
21
+
22
+ class TreeNodeExporter:
23
+ """Exporter for tree-structured data to various formats."""
24
+
25
+ def __init__(self, model, filename="tree_nodes", fileformat="csv"):
26
+ """
27
+ Initialize exporter.
28
+
29
+ :param queryset: Django QuerySet to export.
30
+ :param filename: Base name for the output file.
31
+ :param fileformat: Export format (csv, json, xlsx, yaml, tvs).
32
+ """
33
+ self.filename = filename
34
+ self.format = fileformat
35
+ self.model = model
36
+ self.queryset = model.objects.get_queryset()
37
+ self.fields = self.get_ordered_fields()
38
+
39
+ def get_ordered_fields(self):
40
+ """
41
+ Define and return the ordered list of fields for export.
42
+
43
+ Required fields come first, blocked fields are omitted.
44
+ """
45
+ fields = sorted([field.name for field in self.model._meta.fields])
46
+ required_fields = ["id", "parent", "priority"]
47
+ blocked_fields = ["_path", "_depth"]
48
+
49
+ other_fields = [
50
+ field for field in fields
51
+ if field not in required_fields and field not in blocked_fields
52
+ ]
53
+ return required_fields + other_fields
54
+
55
+ def get_obj(self):
56
+ """Yield rows from queryset as row data dict."""
57
+ queryset = self.queryset.order_by('_path').only(*self.fields)
58
+ for obj in queryset.iterator():
59
+ yield obj
60
+
61
+ def get_serializable_row(self, obj):
62
+ """Get serialized object."""
63
+ fields = self.fields
64
+ raw_data = {}
65
+ for field in fields:
66
+ if field == "parent":
67
+ raw_data["parent"] = getattr(obj, "parent_id", None)
68
+ else:
69
+ raw_data[field] = getattr(obj, field, None)
70
+ serialized = json.loads(json.dumps(raw_data, cls=DjangoJSONEncoder))
71
+ return serialized
72
+
73
+ def csv_stream_data(self, delimiter=","):
74
+ """Stream CSV or TSV data."""
75
+ yield "\ufeff" # BOM for Excel
76
+ buffer = StringIO()
77
+ writer = csv.DictWriter(
78
+ buffer,
79
+ fieldnames=self.fields,
80
+ delimiter=delimiter
81
+ )
82
+ writer.writeheader()
83
+ yield buffer.getvalue()
84
+ buffer.seek(0)
85
+ buffer.truncate(0)
86
+
87
+ for obj in self.get_obj():
88
+ row = self.get_serializable_row(obj)
89
+ writer.writerow(row)
90
+ yield buffer.getvalue()
91
+ buffer.seek(0)
92
+ buffer.truncate(0)
93
+
94
+ def json_stream_data(self):
95
+ """Stream JSON data."""
96
+ yield "[\n"
97
+ first = True
98
+ for obj in self.get_obj():
99
+ row = self.get_serializable_row(obj)
100
+ if not first:
101
+ yield ",\n"
102
+ else:
103
+ first = False
104
+ yield json.dumps(row, ensure_ascii=False)
105
+ yield "\n]"
106
+
107
+ def tsv_stream_data(self, chunk_size=1000):
108
+ """Stream TSV (tab-separated values) data."""
109
+ yield from self.csv_stream_data(delimiter="\t")
110
+
111
+ def yaml_stream_data(self):
112
+ """Stream YAML data."""
113
+ yield "---\n"
114
+ for obj in self.get_obj():
115
+ row = self.get_serializable_row(obj)
116
+ yield yaml.safe_dump([row], allow_unicode=True)
117
+
118
+ def xlsx_stream_data(self):
119
+ """Stream XLSX data."""
120
+ wb = Workbook()
121
+ ws = wb.active
122
+ ws.append(self.fields)
123
+
124
+ for obj in self.get_obj():
125
+ row = self.get_serializable_row(obj)
126
+ ws.append([row.get(f, "") for f in self.fields])
127
+
128
+ output = BytesIO()
129
+ wb.save(output)
130
+ output.seek(0)
131
+ yield output.getvalue()
132
+
133
+ def process_record(self):
134
+ """
135
+ Create a StreamingHttpResponse based on selected format.
136
+
137
+ :param chunk_size: Batch size for iteration.
138
+ :return: StreamingHttpResponse object.
139
+ """
140
+ if self.format == 'xlsx':
141
+ response = StreamingHttpResponse(
142
+ streaming_content=self.xlsx_stream_data(),
143
+ content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; charset=utf-8" # noqa: D501
144
+ )
145
+ elif self.format == 'tsv':
146
+ response = StreamingHttpResponse(
147
+ streaming_content=self.csv_stream_data(delimiter="\t"),
148
+ content_type="text/tab-separated-values; charset=utf-8"
149
+ )
150
+ elif self.format == 'csv':
151
+ response = StreamingHttpResponse(
152
+ streaming_content=self.csv_stream_data(delimiter=","),
153
+ content_type="text/csv; charset=utf-8"
154
+ )
155
+ elif self.format == 'yaml':
156
+ response = StreamingHttpResponse(
157
+ streaming_content=self.yaml_stream_data(),
158
+ content_type=f"application/{self.format}; charset=utf-8"
159
+ )
160
+ else:
161
+ response = StreamingHttpResponse(
162
+ streaming_content=self.json_stream_data(),
163
+ content_type=f"application/{self.format}; charset=utf-8"
164
+ )
165
+
166
+ response['Content-Disposition'] = f'attachment; filename="{self.filename}"' # noqa: D501
167
+ return response
168
+
169
+
170
+ # The End
@@ -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
@@ -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