feat: Ordner als echten verschachtelten Baum statt Flachliste anzeigen
Neue Baumansicht fuer Ordner & Kategorien im Nextcloud/Bookstack-Stil mit Klapp-Navigation, Live-Suche und Breadcrumb-Pfad auf der Ordner-Detailseite. Die bisherige Tabellenansicht bleibt fuer Bulk-Aktionen als "Listenansicht" erhalten. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@ class DocumentationConfig(PluginConfig):
|
|||||||
name = "netbox_documentation"
|
name = "netbox_documentation"
|
||||||
verbose_name = "NetBox Dokumentation"
|
verbose_name = "NetBox Dokumentation"
|
||||||
description = "Wiki und Office-Dokumentation direkt in NetBox"
|
description = "Wiki und Office-Dokumentation direkt in NetBox"
|
||||||
version = "0.9.3"
|
version = "0.10.0"
|
||||||
author = "LKE"
|
author = "LKE"
|
||||||
base_url = "documentation"
|
base_url = "documentation"
|
||||||
min_version = "4.0.0"
|
min_version = "4.0.0"
|
||||||
|
|||||||
@@ -103,6 +103,18 @@ class DocumentCategory(NetBoxModel):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("plugins:netbox_documentation:documentcategory", args=[self.pk])
|
return reverse("plugins:netbox_documentation:documentcategory", args=[self.pk])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ancestors(self):
|
||||||
|
"""Ancestor folders from the root down to (excluding) this one, for breadcrumbs."""
|
||||||
|
result = []
|
||||||
|
parent = self.parent
|
||||||
|
seen = {self.pk}
|
||||||
|
while parent and parent.pk not in seen:
|
||||||
|
result.append(parent)
|
||||||
|
seen.add(parent.pk)
|
||||||
|
parent = parent.parent
|
||||||
|
return list(reversed(result))
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ menu = PluginMenu(
|
|||||||
PluginMenuItem(link="plugins:netbox_documentation:documentassignment_list", link_text="Zuordnungen", buttons=(
|
PluginMenuItem(link="plugins:netbox_documentation:documentassignment_list", link_text="Zuordnungen", buttons=(
|
||||||
PluginMenuButton(link="plugins:netbox_documentation:documentassignment_add", title="Zuordnen", icon_class="mdi mdi-link-plus", color=ButtonColorChoices.GREEN),
|
PluginMenuButton(link="plugins:netbox_documentation:documentassignment_add", title="Zuordnen", icon_class="mdi mdi-link-plus", color=ButtonColorChoices.GREEN),
|
||||||
)),
|
)),
|
||||||
PluginMenuItem(link="plugins:netbox_documentation:documentcategory_list", link_text="Ordner & Kategorien", buttons=(
|
PluginMenuItem(link="plugins:netbox_documentation:documentcategory_tree", link_text="Ordner & Kategorien", buttons=(
|
||||||
PluginMenuButton(link="plugins:netbox_documentation:documentcategory_add", title="Ordner erstellen", icon_class="mdi mdi-folder-plus", color=ButtonColorChoices.GREEN),
|
PluginMenuButton(link="plugins:netbox_documentation:documentcategory_add", title="Ordner erstellen", icon_class="mdi mdi-folder-plus", color=ButtonColorChoices.GREEN),
|
||||||
)),
|
)),
|
||||||
PluginMenuItem(link="plugins:netbox_documentation:document_archive", link_text="ZIP Export & Import"),
|
PluginMenuItem(link="plugins:netbox_documentation:document_archive", link_text="ZIP Export & Import"),
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
{% extends 'generic/object.html' %}
|
{% extends 'generic/object.html' %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
|
<ol class="breadcrumb mb-0">
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'plugins:netbox_documentation:documentcategory_tree' %}"><i class="mdi mdi-folder-home"></i> Ordner</a></li>
|
||||||
|
{% for ancestor in object.ancestors %}
|
||||||
|
<li class="breadcrumb-item"><a href="{{ ancestor.get_absolute_url }}">{{ ancestor.name }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{{ object.name }}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-md-4">
|
<div class="col col-md-4">
|
||||||
{% if object.tenant_group or object.tenant %}
|
{% if object.tenant_group or object.tenant %}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{% extends 'base/layout.html' %}
|
||||||
|
|
||||||
|
{% block title %}Ordner & Kategorien{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
|
<input type="search" id="folder-tree-search" class="form-control" style="max-width: 20rem;" placeholder="Ordner durchsuchen…">
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
{% if perms.netbox_documentation.add_documentcategory %}
|
||||||
|
<a href="{% url 'plugins:netbox_documentation:documentcategory_add' %}" class="btn btn-success"><i class="mdi mdi-folder-plus"></i> Ordner erstellen</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{% url 'plugins:netbox_documentation:documentcategory_list' %}" class="btn btn-outline-secondary"><i class="mdi mdi-format-list-bulleted"></i> Listenansicht</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
{% if tree %}
|
||||||
|
<ul class="netbox-doc-tree list-unstyled mb-0">
|
||||||
|
{% for node in tree %}
|
||||||
|
{% include 'netbox_documentation/inc/category_tree_node.html' with node=node %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted mb-0">Noch keine Ordner vorhanden.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.netbox-doc-tree { margin: 0; }
|
||||||
|
.netbox-doc-tree .netbox-doc-tree { margin-left: 1.5rem; margin-top: .35rem; border-left: 1px dashed var(--tblr-border-color, #dee2e6); padding-left: 1rem; }
|
||||||
|
.netbox-doc-tree-item { margin-bottom: .35rem; }
|
||||||
|
.netbox-doc-tree-item > details > summary { list-style: none; cursor: pointer; }
|
||||||
|
.netbox-doc-tree-item > details > summary::-webkit-details-marker { display: none; }
|
||||||
|
.netbox-doc-tree-item > details > summary::before { content: "\f0142"; font-family: "Material Design Icons"; margin-right: .35rem; display: inline-block; transition: transform .15s ease; }
|
||||||
|
.netbox-doc-tree-item > details[open] > summary::before { transform: rotate(90deg); }
|
||||||
|
.netbox-doc-tree-summary { display: flex; align-items: center; gap: .5rem; padding: .25rem .25rem; border-radius: .25rem; }
|
||||||
|
.netbox-doc-tree-summary:hover { background-color: var(--tblr-bg-surface-secondary, rgba(0,0,0,.03)); }
|
||||||
|
.netbox-doc-tree-leaf { margin-left: 1.35rem; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var input = document.getElementById('folder-tree-search');
|
||||||
|
if (!input) return;
|
||||||
|
input.addEventListener('input', function () {
|
||||||
|
var term = input.value.trim().toLowerCase();
|
||||||
|
document.querySelectorAll('.netbox-doc-tree-item').forEach(function (li) {
|
||||||
|
var match = !term || li.dataset.name.indexOf(term) !== -1;
|
||||||
|
li.classList.toggle('d-none', !match);
|
||||||
|
if (match) {
|
||||||
|
var parent = li.parentElement.closest('.netbox-doc-tree-item');
|
||||||
|
while (parent) {
|
||||||
|
parent.classList.remove('d-none');
|
||||||
|
var details = parent.querySelector(':scope > details');
|
||||||
|
if (details) details.open = true;
|
||||||
|
parent = parent.parentElement.closest('.netbox-doc-tree-item');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<li class="netbox-doc-tree-item" data-name="{{ node.object.name|lower }}">
|
||||||
|
{% if node.children %}
|
||||||
|
<details open>
|
||||||
|
<summary class="netbox-doc-tree-summary">
|
||||||
|
<i class="mdi mdi-folder text-warning"></i>
|
||||||
|
<a href="{{ node.object.get_absolute_url }}">{{ node.object.name }}</a>
|
||||||
|
<span class="badge text-bg-secondary">{{ node.object.document_total }}</span>
|
||||||
|
{% if node.object.tenant %}<span class="badge text-bg-light"><i class="mdi mdi-domain"></i> {{ node.object.tenant }}</span>{% endif %}
|
||||||
|
{% if perms.netbox_documentation.change_documentcategory %}
|
||||||
|
<a href="{% url 'plugins:netbox_documentation:documentcategory_edit' node.object.pk %}" class="btn btn-sm btn-ghost-secondary" title="Bearbeiten"><i class="mdi mdi-pencil"></i></a>
|
||||||
|
{% endif %}
|
||||||
|
</summary>
|
||||||
|
<ul class="netbox-doc-tree list-unstyled">
|
||||||
|
{% for child in node.children %}
|
||||||
|
{% include 'netbox_documentation/inc/category_tree_node.html' with node=child %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{% else %}
|
||||||
|
<div class="netbox-doc-tree-summary netbox-doc-tree-leaf">
|
||||||
|
<i class="mdi mdi-folder-outline text-warning"></i>
|
||||||
|
<a href="{{ node.object.get_absolute_url }}">{{ node.object.name }}</a>
|
||||||
|
<span class="badge text-bg-secondary">{{ node.object.document_total }}</span>
|
||||||
|
{% if node.object.tenant %}<span class="badge text-bg-light"><i class="mdi mdi-domain"></i> {{ node.object.tenant }}</span>{% endif %}
|
||||||
|
{% if perms.netbox_documentation.change_documentcategory %}
|
||||||
|
<a href="{% url 'plugins:netbox_documentation:documentcategory_edit' node.object.pk %}" class="btn btn-sm btn-ghost-secondary" title="Bearbeiten"><i class="mdi mdi-pencil"></i></a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
@@ -24,6 +24,7 @@ urlpatterns = (
|
|||||||
path("assignments/<int:pk>/edit/", views.AssignmentEditView.as_view(), name="documentassignment_edit"),
|
path("assignments/<int:pk>/edit/", views.AssignmentEditView.as_view(), name="documentassignment_edit"),
|
||||||
path("assignments/<int:pk>/delete/", views.AssignmentDeleteView.as_view(), name="documentassignment_delete"),
|
path("assignments/<int:pk>/delete/", views.AssignmentDeleteView.as_view(), name="documentassignment_delete"),
|
||||||
path("folders/", views.DocumentCategoryListView.as_view(), name="documentcategory_list"),
|
path("folders/", views.DocumentCategoryListView.as_view(), name="documentcategory_list"),
|
||||||
|
path("folders/tree/", views.DocumentCategoryTreeView.as_view(), name="documentcategory_tree"),
|
||||||
path("folders/add/", views.DocumentCategoryEditView.as_view(), name="documentcategory_add"),
|
path("folders/add/", views.DocumentCategoryEditView.as_view(), name="documentcategory_add"),
|
||||||
path("folders/import/", views.DocumentImportView.as_view(), name="documentcategory_bulk_import"),
|
path("folders/import/", views.DocumentImportView.as_view(), name="documentcategory_bulk_import"),
|
||||||
path("folders/delete/", views.DocumentCategoryBulkDeleteView.as_view(), name="documentcategory_bulk_delete"),
|
path("folders/delete/", views.DocumentCategoryBulkDeleteView.as_view(), name="documentcategory_bulk_delete"),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from django.contrib import messages
|
|||||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Count, Q
|
||||||
from django.http import FileResponse, Http404, JsonResponse
|
from django.http import FileResponse, Http404, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
@@ -91,8 +91,32 @@ class DocumentCategoryListView(generic.ObjectListView):
|
|||||||
actions = (AddObject, BulkImport, BulkDelete)
|
actions = (AddObject, BulkImport, BulkDelete)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentCategoryTreeView(PermissionRequiredMixin, View):
|
||||||
|
"""Nextcloud/Bookstack-style nested folder browser for document categories."""
|
||||||
|
|
||||||
|
permission_required = "netbox_documentation.view_documentcategory"
|
||||||
|
template_name = "netbox_documentation/documentcategory_tree.html"
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
categories = list(
|
||||||
|
DocumentCategory.objects.restrict(request.user, "view")
|
||||||
|
.select_related("tenant_group", "tenant")
|
||||||
|
.annotate(document_total=Count("documents", distinct=True))
|
||||||
|
.order_by("name")
|
||||||
|
)
|
||||||
|
nodes = {category.pk: {"object": category, "children": []} for category in categories}
|
||||||
|
roots = []
|
||||||
|
for category in categories:
|
||||||
|
node = nodes[category.pk]
|
||||||
|
parent_node = nodes.get(category.parent_id)
|
||||||
|
(parent_node["children"] if parent_node else roots).append(node)
|
||||||
|
return render(request, self.template_name, {"tree": roots, "category_count": len(categories)})
|
||||||
|
|
||||||
|
|
||||||
class DocumentCategoryView(generic.ObjectView):
|
class DocumentCategoryView(generic.ObjectView):
|
||||||
queryset = DocumentCategory.objects.select_related("tenant_group", "tenant").prefetch_related("children", "documents")
|
queryset = DocumentCategory.objects.select_related("parent", "tenant_group", "tenant").prefetch_related(
|
||||||
|
"parent__parent", "children", "documents",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DocumentCategoryEditView(generic.ObjectEditView):
|
class DocumentCategoryEditView(generic.ObjectEditView):
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "netbox-documentation"
|
name = "netbox-documentation"
|
||||||
version = "0.9.3"
|
version = "0.10.0"
|
||||||
description = "Integrated Markdown wiki and office document importer for NetBox"
|
description = "Integrated Markdown wiki and office document importer for NetBox"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
Reference in New Issue
Block a user