feat: Dokumentationen als Explorer-artigen Ordnerbaum anzeigen

Neue Standardansicht fuer "Dokumentationen" im Windows-Explorer/
Nextcloud-Stil: Ordner und Unterordner als aufklappbarer Baum mit
den zugehoerigen Dokumenten als Dateien darin, inkl. Live-Suche.
Die bisherige Tabellenansicht bleibt als "Listenansicht" erreichbar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 10:07:04 +02:00
co-authored by Claude Sonnet 5
parent 69f3d0af18
commit 359f752031
6 changed files with 138 additions and 2 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ from netbox.choices import ButtonColorChoices
menu = PluginMenu(
label="Dokumentation",
groups=(("Wiki", (
PluginMenuItem(link="plugins:netbox_documentation:document_list", link_text="Dokumentationen", buttons=(
PluginMenuItem(link="plugins:netbox_documentation:document_tree", link_text="Dokumentationen", buttons=(
PluginMenuButton(link="plugins:netbox_documentation:document_add", title="Neu", icon_class="mdi mdi-plus-thick", color=ButtonColorChoices.GREEN),
PluginMenuButton(link="plugins:netbox_documentation:document_import", title="Import", icon_class="mdi mdi-file-import", color=ButtonColorChoices.BLUE),
)),
@@ -0,0 +1,70 @@
{% extends 'base/layout.html' %}
{% block title %}Dokumentationen{% 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="doc-tree-search" class="form-control" style="max-width: 20rem;" placeholder="Dokumente & Ordner durchsuchen…">
<div class="d-flex gap-2">
{% if perms.netbox_documentation.add_document %}
<a href="{% url 'plugins:netbox_documentation:document_add' %}" class="btn btn-success"><i class="mdi mdi-plus-thick"></i> Hinzufügen</a>
{% endif %}
<a href="{% url 'plugins:netbox_documentation:document_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 or uncategorized %}
<ul class="netbox-doc-tree list-unstyled mb-0">
{% for node in tree %}
{% include 'netbox_documentation/inc/document_tree_node.html' with node=node %}
{% endfor %}
{% for document in uncategorized %}
{% include 'netbox_documentation/inc/document_tree_file.html' with document=document %}
{% endfor %}
</ul>
{% else %}
<p class="text-muted mb-0">Noch keine Dokumentationen 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; }
.netbox-doc-tree-empty { margin-left: 1.35rem; font-size: .875rem; }
</style>
<script>
document.addEventListener('DOMContentLoaded', function () {
var input = document.getElementById('doc-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,8 @@
<li class="netbox-doc-tree-item netbox-doc-tree-file" data-name="{{ document.title|lower }}">
<div class="netbox-doc-tree-summary netbox-doc-tree-leaf">
<i class="mdi mdi-file-document-outline text-secondary"></i>
<a href="{{ document.get_absolute_url }}">{{ document.title }}</a>
{% if not document.is_published %}<span class="badge text-bg-warning">Entwurf</span>{% endif %}
{% if document.tenant %}<span class="badge text-bg-light"><i class="mdi mdi-domain"></i> {{ document.tenant }}</span>{% endif %}
</div>
</li>
@@ -0,0 +1,20 @@
<li class="netbox-doc-tree-item" data-name="{{ node.object.name|lower }}">
<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>
{% if node.object.tenant %}<span class="badge text-bg-light"><i class="mdi mdi-domain"></i> {{ node.object.tenant }}</span>{% endif %}
</summary>
<ul class="netbox-doc-tree list-unstyled">
{% for child in node.children %}
{% include 'netbox_documentation/inc/document_tree_node.html' with node=child %}
{% endfor %}
{% for document in node.documents %}
{% include 'netbox_documentation/inc/document_tree_file.html' with document=document %}
{% endfor %}
{% if not node.children and not node.documents %}
<li class="netbox-doc-tree-empty text-muted">Leer</li>
{% endif %}
</ul>
</details>
</li>
+2 -1
View File
@@ -4,7 +4,8 @@ from . import models, views
urlpatterns = (
path("editor-assets/<path:asset_path>", views.EditorAssetView.as_view(), name="editor_asset"),
path("", views.DocumentListView.as_view(), name="document_list"),
path("", views.DocumentTreeView.as_view(), name="document_tree"),
path("list/", views.DocumentListView.as_view(), name="document_list"),
path("documents/add/", views.DocumentEditView.as_view(), name="document_add"),
path("documents/import/", views.DocumentImportView.as_view(), name="document_import"),
path("documents/import/", views.DocumentImportView.as_view(), name="document_bulk_import"),
+37
View File
@@ -52,6 +52,43 @@ class DocumentListView(generic.ObjectListView):
actions = (AddObject, BulkImport, BulkDelete)
class DocumentTreeView(PermissionRequiredMixin, View):
"""Windows-Explorer/Nextcloud-style folder browser: categories as folders, documents as files."""
permission_required = "netbox_documentation.view_document"
template_name = "netbox_documentation/document_tree.html"
def get(self, request):
categories = list(
DocumentCategory.objects.restrict(request.user, "view")
.select_related("tenant_group", "tenant")
.order_by("name")
)
documents = list(
Document.objects.restrict(request.user, "view")
.select_related("category", "tenant_group", "tenant")
.order_by("title")
)
nodes = {category.pk: {"object": category, "children": [], "documents": []} 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)
uncategorized = []
for document in documents:
node = nodes.get(document.category_id)
(node["documents"] if node else uncategorized).append(document)
return render(request, self.template_name, {
"tree": roots,
"uncategorized": uncategorized,
"document_count": len(documents),
})
class DocumentView(generic.ObjectView):
queryset = Document.objects.select_related("category", "tenant_group", "tenant").prefetch_related("assignments", "attachments")