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:
2026-09-17 10:00:42 +02:00
co-authored by Claude Sonnet 5
parent e54917a34c
commit 69f3d0af18
9 changed files with 147 additions and 5 deletions
+1 -1
View File
@@ -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"
+12
View File
@@ -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
+1 -1
View File
@@ -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>
+1
View File
@@ -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"),
+26 -2
View File
@@ -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
View File
@@ -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"