feat: integriertes Dokumentations-Wiki für NetBox hinzufügen

- Markdown-Dokumentationen direkt in NetBox erstellen
- Dokumente Standorten, Racks, Geräten, VMs und Clustern zuordnen
- DOCX-, XLSX-, PDF-, Markdown- und Textimporte unterstützen
- REST-API, Suche, Berechtigungen und Änderungsprotokoll ergänzen
- Installation, Konfiguration und Importgrenzen dokumentieren
This commit is contained in:
2026-07-22 10:23:58 +02:00
commit ab0ed26ad3
24 changed files with 764 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.venv/
build/
dist/
+109
View File
@@ -0,0 +1,109 @@
# NetBox Documentation
Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleitungen.
## Funktionen
- Dokumentationen direkt in NetBox als Markdown schreiben und sicher gerendert anzeigen
- Eine Dokumentation mehreren Objekten zuordnen und umgekehrt
- Unterstützte Standardobjekte: Region, Standort, Location, Rack, Gerät, VM, VM-Cluster und Mandant/Kunde
- DOCX, XLSX/XLSM, textbasierte PDF-, Markdown- und Textdateien importieren
- Originaldatei optional zusammen mit der Dokumentation aufbewahren
- Dokumentationen über die globale NetBox-Suche und per REST-API finden
- NetBox-Berechtigungen, Änderungsprotokoll, Tags und Custom Fields verwenden
## Kompatibilität
Die Version `0.1.0` zielt auf NetBox 4.x (mindestens 4.0). Vor einem produktiven Rollout sollte das Plugin gegen die konkret eingesetzte NetBox-Minor-Version in einer Testinstanz geprüft werden.
## Installation
Im Python-Virtualenv der NetBox-Installation:
```bash
source /opt/netbox/venv/bin/activate
pip install /pfad/zu/Netbox-DokiWiki
```
In `configuration.py`:
```python
PLUGINS = [
"netbox_documentation",
]
PLUGINS_CONFIG = {
"netbox_documentation": {
"max_import_size_mb": 25,
"keep_imported_file": True,
"allowed_object_types": [
"dcim.region",
"dcim.site",
"dcim.location",
"dcim.rack",
"dcim.device",
"virtualization.virtualmachine",
"virtualization.cluster",
"tenancy.tenant",
],
}
}
```
Danach:
```bash
cd /opt/netbox/netbox
python manage.py migrate
python manage.py collectstatic --no-input
sudo systemctl restart netbox netbox-rq
```
Für Docker-Installationen das Paket in das NetBox-Image aufnehmen, Plugin und Konfiguration setzen und anschließend das Image neu bauen. Die hochgeladenen Originaldateien liegen im konfigurierten NetBox-`MEDIA_ROOT`; dieses Verzeichnis muss persistent gespeichert und gesichert werden.
## Berechtigungen
Die benötigten Rechte können in NetBox unter **Admin → Benutzer → Berechtigungen** vergeben werden:
- `netbox_documentation.view_document`
- `netbox_documentation.add_document`, `change_document`, `delete_document`
- `netbox_documentation.view_documentassignment` sowie die entsprechenden Änderungsrechte
- `netbox_documentation.import_document` für Office-/PDF-Importe
Objektbezogene NetBox-Constraints sollten zusätzlich passend zu Mandanten und Verantwortungsbereichen gesetzt werden. Nicht veröffentlichte Dokumente sind als Redaktionsstatus gedacht; sie ersetzen keine Objektberechtigung.
## Importverhalten
| Format | Übernahme |
|---|---|
| DOCX | Überschriften, Absätze, Listen, Links und einfache Tabellen nach Markdown |
| XLSX/XLSM | Jedes Tabellenblatt als eigene Markdown-Tabelle; Formelergebnisse nur, wenn Excel sie zuvor gespeichert hat |
| PDF | Extrahierbarer Text, nach Seiten gegliedert |
| MD/TXT | Direkte Übernahme (UTF-8) |
Alte binäre `.doc`- und `.xls`-Dateien müssen vorher in `.docx` bzw. `.xlsx` konvertiert werden. Gescannte PDFs benötigen OCR, die in dieser Version bewusst noch nicht enthalten ist. Komplexe Word-/PDF-Layouts, eingebettete Bilder und Excel-Formatierungen können nicht verlustfrei nach Markdown übertragen werden.
## REST-API
Nach Aktivierung stehen die üblichen NetBox-Plugin-Endpunkte bereit:
- `/api/plugins/documentation/documents/`
- `/api/plugins/documentation/assignments/`
## Entwicklung und Tests
```bash
pip install -e ".[test]"
pytest
```
Für vollständige UI-/API-Tests muss NetBox im selben Virtualenv verfügbar sein. Die reinen Importtests befinden sich unter `netbox_documentation/tests/`.
## Nächste sinnvolle Ausbaustufen
- OCR für gescannte PDFs (z. B. Tesseract/OCRmyPDF als optionaler Worker)
- eingebettete DOCX-Bilder als NetBox-Medien übernehmen
- echte Dokumentrevisionen mit Vergleich und Freigabeprozess
- asynchroner Massenimport großer Excel-Bestände über NetBox-RQ
- Vorlagen und automatisch vererbte Dokumentation entlang Region → Standort → Gerät
+24
View File
@@ -0,0 +1,24 @@
from netbox.plugins import PluginConfig
class DocumentationConfig(PluginConfig):
name = "netbox_documentation"
verbose_name = "Dokumentation"
description = "Wiki und Office-Dokumentation direkt in NetBox"
version = "0.1.0"
author = "NetBox Documentation Contributors"
base_url = "documentation"
min_version = "4.0.0"
default_settings = {
"allowed_object_types": [
"dcim.region", "dcim.site", "dcim.location", "dcim.rack",
"dcim.device", "virtualization.virtualmachine",
"virtualization.cluster", "tenancy.tenant",
],
"max_import_size_mb": 25,
"keep_imported_file": True,
}
config = DocumentationConfig
+1
View File
@@ -0,0 +1 @@
+20
View File
@@ -0,0 +1,20 @@
from netbox.api.serializers import NetBoxModelSerializer
from rest_framework import serializers
from ..models import Document, DocumentAssignment
class DocumentSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_documentation-api:document-detail")
class Meta:
model = Document
fields = ("id", "url", "display", "title", "slug", "summary", "body", "is_published", "tags", "created", "last_updated")
class DocumentAssignmentSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_documentation-api:documentassignment-detail")
class Meta:
model = DocumentAssignment
fields = ("id", "url", "display", "document", "assigned_object_type", "assigned_object_id", "note", "tags", "created", "last_updated")
+8
View File
@@ -0,0 +1,8 @@
from netbox.api.routers import NetBoxRouter
from . import views
router = NetBoxRouter()
router.register("documents", views.DocumentViewSet)
router.register("assignments", views.DocumentAssignmentViewSet)
urlpatterns = router.urls
+17
View File
@@ -0,0 +1,17 @@
from netbox.api.viewsets import NetBoxModelViewSet
from ..filtersets import DocumentFilterSet, AssignmentFilterSet
from ..models import Document, DocumentAssignment
from .serializers import DocumentSerializer, DocumentAssignmentSerializer
class DocumentViewSet(NetBoxModelViewSet):
queryset = Document.objects.all()
serializer_class = DocumentSerializer
filterset_class = DocumentFilterSet
class DocumentAssignmentViewSet(NetBoxModelViewSet):
queryset = DocumentAssignment.objects.all()
serializer_class = DocumentAssignmentSerializer
filterset_class = AssignmentFilterSet
+19
View File
@@ -0,0 +1,19 @@
from netbox.filtersets import NetBoxModelFilterSet
from .models import Document, DocumentAssignment
class DocumentFilterSet(NetBoxModelFilterSet):
class Meta:
model = Document
fields = ("id", "title", "slug", "is_published")
def search(self, queryset, name, value):
from django.db.models import Q
return queryset.filter(Q(title__icontains=value) | Q(summary__icontains=value) | Q(body__icontains=value))
class AssignmentFilterSet(NetBoxModelFilterSet):
class Meta:
model = DocumentAssignment
fields = ("id", "document_id", "assigned_object_type", "assigned_object_id")
+76
View File
@@ -0,0 +1,76 @@
from django import forms
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from utilities.forms.fields import SlugField
from netbox.forms import NetBoxModelForm
from .models import Document, DocumentAssignment
def allowed_content_types():
labels = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get(
"allowed_object_types", []
)
pairs = [value.split(".", 1) for value in labels if "." in value]
query = Q()
for app_label, model in pairs:
query |= Q(app_label=app_label, model=model)
return ContentType.objects.filter(query).order_by("app_label", "model")
class DocumentForm(NetBoxModelForm):
slug = SlugField(slug_source="title")
body = forms.CharField(required=False, widget=forms.Textarea(attrs={
"rows": 28, "class": "font-monospace", "data-markdown-editor": "true"
}), help_text="Markdown wird unterstützt. HTML wird bei der Ausgabe sicher gefiltert.")
class Meta:
model = Document
fields = ("title", "slug", "summary", "body", "is_published", "tags")
class AssignmentForm(NetBoxModelForm):
assigned_object_type = forms.ModelChoiceField(queryset=ContentType.objects.none(), label="Objekttyp")
assigned_object_id = forms.TypedChoiceField(coerce=int, label="NetBox-Objekt")
class Meta:
model = DocumentAssignment
fields = ("document", "assigned_object_type", "assigned_object_id", "note", "tags")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["assigned_object_type"].queryset = allowed_content_types()
content_type = None
ct_id = (self.data.get("assigned_object_type") or self.initial.get("assigned_object_type")
or getattr(self.instance, "assigned_object_type_id", None))
if ct_id:
content_type = ContentType.objects.filter(pk=ct_id).first()
if content_type:
model = content_type.model_class()
objects = model.objects.all().order_by("pk")
self.fields["assigned_object_id"].choices = [(obj.pk, str(obj)) for obj in objects]
def clean(self):
cleaned = super().clean()
object_id = cleaned.get("assigned_object_id")
ct = cleaned.get("assigned_object_type")
if object_id and ct and not ct.model_class().objects.filter(pk=object_id).exists():
self.add_error("assigned_object_id", "Das Objekt existiert für diesen Objekttyp nicht.")
return cleaned
class ImportForm(forms.Form):
file = forms.FileField(label="Word-, Excel- oder PDF-Datei")
title = forms.CharField(max_length=200, required=False, help_text="Leer lassen, um den Dateinamen zu verwenden")
append = forms.BooleanField(required=False, initial=False, label="An bestehende Dokumentation anhängen")
document = forms.ModelChoiceField(queryset=Document.objects.all(), required=False, label="Bestehende Dokumentation")
def clean(self):
data = super().clean()
if data.get("append") and not data.get("document"):
self.add_error("document", "Zum Anhängen muss eine Dokumentation gewählt werden.")
upload = data.get("file")
limit = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("max_import_size_mb", 25)
if upload and upload.size > limit * 1024 * 1024:
self.add_error("file", f"Die Datei ist größer als {limit} MB.")
return data
+73
View File
@@ -0,0 +1,73 @@
from dataclasses import dataclass, field
from io import BytesIO
from pathlib import Path
import re
class ImportFailure(ValueError):
pass
@dataclass
class ImportResult:
markdown: str
warnings: list[str] = field(default_factory=list)
def import_document(upload) -> ImportResult:
suffix = Path(upload.name).suffix.lower()
content = upload.read()
upload.seek(0)
if suffix == ".docx":
return _docx(content)
if suffix in {".xlsx", ".xlsm"}:
return _xlsx(content)
if suffix == ".pdf":
return _pdf(content)
if suffix in {".md", ".txt"}:
return ImportResult(content.decode("utf-8-sig"))
if suffix in {".doc", ".xls"}:
raise ImportFailure("Alte .doc/.xls-Dateien bitte zuerst als .docx/.xlsx speichern.")
raise ImportFailure("Unterstützt werden DOCX, XLSX, XLSM, PDF, Markdown und Text.")
def _docx(content):
import mammoth
result = mammoth.convert_to_markdown(BytesIO(content))
warnings = [message.message for message in result.messages]
return ImportResult(result.value.strip(), warnings)
def _xlsx(content):
from openpyxl import load_workbook
from tabulate import tabulate
workbook = load_workbook(BytesIO(content), read_only=True, data_only=True)
sections = []
for sheet in workbook.worksheets:
rows = [["" if cell is None else str(cell) for cell in row] for row in sheet.iter_rows(values_only=True)]
while rows and not any(value for value in rows[-1]):
rows.pop()
if not rows:
continue
width = max(len(row) for row in rows)
rows = [row + [""] * (width - len(row)) for row in rows]
header, body = rows[0], rows[1:]
sections.append(f"## {sheet.title}\n\n{tabulate(body, headers=header, tablefmt='github')}")
if not sections:
raise ImportFailure("Die Arbeitsmappe enthält keine Daten.")
return ImportResult("\n\n".join(sections))
def _pdf(content):
from pypdf import PdfReader
reader = PdfReader(BytesIO(content))
pages = []
for number, page in enumerate(reader.pages, 1):
text = (page.extract_text() or "").strip()
if text:
text = re.sub(r"[ \t]+\n", "\n", text)
pages.append(f"## Seite {number}\n\n{text}")
if not pages:
raise ImportFailure("Das PDF enthält keinen extrahierbaren Text. Für Scans ist OCR erforderlich.")
return ImportResult("\n\n".join(pages), ["PDF-Layout und Bilder können nicht vollständig übernommen werden."])
@@ -0,0 +1,42 @@
from django.db import migrations, models
import django.db.models.deletion
import netbox.models.features
import netbox_documentation.models
class Migration(migrations.Migration):
initial = True
dependencies = [("contenttypes", "0002_remove_content_type_name"), ("extras", "0001_squashed")]
operations = [
migrations.CreateModel(name="Document", fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
("created", models.DateTimeField(auto_now_add=True, null=True)),
("last_updated", models.DateTimeField(auto_now=True, null=True)),
("custom_field_data", models.JSONField(blank=True, default=dict, encoder=netbox.models.features.CustomFieldJSONEncoder)),
("title", models.CharField(max_length=200)), ("slug", models.SlugField(max_length=200, unique=True)),
("body", models.TextField(blank=True, help_text="Markdown")), ("summary", models.CharField(blank=True, max_length=500)),
("is_published", models.BooleanField(default=True)),
("tags", models.ManyToManyField(blank=True, related_name="netbox_documentation_document_items", to="extras.tag")),
], options={"ordering": ("title",), "permissions": (("import_document", "Can import office documents"),)}),
migrations.CreateModel(name="DocumentAssignment", fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
("created", models.DateTimeField(auto_now_add=True, null=True)), ("last_updated", models.DateTimeField(auto_now=True, null=True)),
("custom_field_data", models.JSONField(blank=True, default=dict, encoder=netbox.models.features.CustomFieldJSONEncoder)),
("assigned_object_id", models.PositiveBigIntegerField()), ("note", models.CharField(blank=True, max_length=200)),
("assigned_object_type", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype")),
("document", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="assignments", to="netbox_documentation.document")),
("tags", models.ManyToManyField(blank=True, related_name="netbox_documentation_documentassignment_items", to="extras.tag")),
], options={"ordering": ("document", "assigned_object_type", "assigned_object_id")}),
migrations.CreateModel(name="DocumentAttachment", fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
("created", models.DateTimeField(auto_now_add=True, null=True)), ("last_updated", models.DateTimeField(auto_now=True, null=True)),
("custom_field_data", models.JSONField(blank=True, default=dict, encoder=netbox.models.features.CustomFieldJSONEncoder)),
("file", models.FileField(upload_to=netbox_documentation.models.attachment_upload_path)),
("original_name", models.CharField(max_length=255)), ("content_type", models.CharField(blank=True, max_length=100)),
("size", models.PositiveBigIntegerField(default=0)),
("document", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="attachments", to="netbox_documentation.document")),
("tags", models.ManyToManyField(blank=True, related_name="netbox_documentation_documentattachment_items", to="extras.tag")),
], options={"ordering": ("original_name",)}),
migrations.AddConstraint(model_name="documentassignment", constraint=models.UniqueConstraint(fields=("document", "assigned_object_type", "assigned_object_id"), name="netbox_documentation_documentassignment_unique_assignment")),
]
@@ -0,0 +1 @@
+60
View File
@@ -0,0 +1,60 @@
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.urls import reverse
from netbox.models import NetBoxModel
class Document(NetBoxModel):
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=200, unique=True)
body = models.TextField(blank=True, help_text="Markdown")
summary = models.CharField(max_length=500, blank=True)
is_published = models.BooleanField(default=True)
class Meta:
ordering = ("title",)
permissions = (("import_document", "Can import office documents"),)
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("plugins:netbox_documentation:document", args=[self.pk])
class DocumentAssignment(NetBoxModel):
document = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="assignments")
assigned_object_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
assigned_object_id = models.PositiveBigIntegerField()
assigned_object = GenericForeignKey("assigned_object_type", "assigned_object_id")
note = models.CharField(max_length=200, blank=True)
class Meta:
ordering = ("document", "assigned_object_type", "assigned_object_id")
constraints = [models.UniqueConstraint(
fields=("document", "assigned_object_type", "assigned_object_id"),
name="%(app_label)s_%(class)s_unique_assignment",
)]
def __str__(self):
return f"{self.document}{self.assigned_object}"
def attachment_upload_path(instance, filename):
return f"netbox_documentation/{instance.document_id}/{filename}"
class DocumentAttachment(NetBoxModel):
document = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="attachments")
file = models.FileField(upload_to=attachment_upload_path)
original_name = models.CharField(max_length=255)
content_type = models.CharField(max_length=100, blank=True)
size = models.PositiveBigIntegerField(default=0)
class Meta:
ordering = ("original_name",)
def __str__(self):
return self.original_name
+17
View File
@@ -0,0 +1,17 @@
from netbox.plugins import PluginMenu, PluginMenuButton, PluginMenuItem
from utilities.choices import ButtonColorChoices
menu = PluginMenu(
label="Dokumentation",
groups=(("Wiki", (
PluginMenuItem(link="plugins:netbox_documentation:document_list", 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),
)),
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),
)),
)),),
icon_class="mdi mdi-book-open-page-variant",
)
+10
View File
@@ -0,0 +1,10 @@
from netbox.search import SearchIndex, register_search
from .models import Document
@register_search
class DocumentIndex(SearchIndex):
model = Document
fields = (("title", 100), ("summary", 80), ("body", 50))
display_attrs = ("summary",)
+24
View File
@@ -0,0 +1,24 @@
import django_tables2 as tables
from netbox.tables import NetBoxTable, columns
from .models import Document, DocumentAssignment
class DocumentTable(NetBoxTable):
title = tables.Column(linkify=True)
assignments = tables.Column(accessor="assignments.count", verbose_name="Zuordnungen", orderable=False)
actions = columns.ActionsColumn(actions=("edit", "delete"))
class Meta(NetBoxTable.Meta):
model = Document
fields = ("pk", "title", "summary", "is_published", "assignments", "last_updated", "actions")
class AssignmentTable(NetBoxTable):
document = tables.Column(linkify=True)
assigned_object = tables.Column(linkify=True, verbose_name="NetBox-Objekt")
actions = columns.ActionsColumn(actions=("edit", "delete"))
class Meta(NetBoxTable.Meta):
model = DocumentAssignment
fields = ("pk", "document", "assigned_object_type", "assigned_object", "note", "actions")
+26
View File
@@ -0,0 +1,26 @@
from netbox.plugins import PluginTemplateExtension
from django.contrib.contenttypes.models import ContentType
from .models import DocumentAssignment
class ObjectDocumentation(PluginTemplateExtension):
models = [
"dcim.region", "dcim.site", "dcim.location", "dcim.rack", "dcim.device",
"virtualization.virtualmachine", "virtualization.cluster", "tenancy.tenant",
]
def right_page(self):
obj = self.context["object"]
content_type = ContentType.objects.get_for_model(obj)
assignments = DocumentAssignment.objects.filter(
assigned_object_type=content_type, assigned_object_id=obj.pk,
document__is_published=True,
).select_related("document")
return self.render("netbox_documentation/inc/object_documents.html", extra_context={
"documentation_assignments": assignments,
"content_type": content_type,
})
template_extensions = [ObjectDocumentation]
@@ -0,0 +1,19 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% block content %}
<div class="row">
<div class="col col-md-9">
{% if object.summary %}<p class="lead">{{ object.summary }}</p>{% endif %}
<div class="card"><div class="card-body rendered-markdown">{{ object.body|render_markdown }}</div></div>
</div>
<div class="col col-md-3">
<div class="card"><h5 class="card-header">Zuordnungen</h5><div class="list-group list-group-flush">
{% for assignment in object.assignments.all %}<a class="list-group-item" href="{{ assignment.assigned_object.get_absolute_url }}">{{ assignment.assigned_object_type }}: {{ assignment.assigned_object }}</a>{% empty %}<div class="list-group-item text-muted">Noch nicht zugeordnet</div>{% endfor %}
</div></div>
{% if object.attachments.all %}<div class="card mt-3"><h5 class="card-header">Originaldateien</h5><div class="list-group list-group-flush">
{% for attachment in object.attachments.all %}<a class="list-group-item" href="{{ attachment.file.url }}">{{ attachment.original_name }}</a>{% endfor %}
</div></div>{% endif %}
</div>
</div>
{% endblock content %}
@@ -0,0 +1,13 @@
{% extends 'base/layout.html' %}
{% load form_helpers %}
{% block title %}Dokument importieren{% endblock %}
{% block content %}
<div class="row justify-content-center"><div class="col col-md-8"><div class="card">
<h5 class="card-header">Word, Excel oder PDF importieren</h5>
<div class="card-body"><form method="post" enctype="multipart/form-data">{% csrf_token %}{% render_form form %}
<div class="text-muted mb-3">DOCX übernimmt Überschriften, Listen und Tabellen. XLSX wird je Tabellenblatt zu einer Markdown-Tabelle. PDF benötigt eine echte Textebene; Scan-OCR ist nicht enthalten.</div>
<button type="submit" class="btn btn-primary">Importieren</button>
</form></div>
</div></div></div>
{% endblock %}
@@ -0,0 +1,11 @@
<div class="card">
<h5 class="card-header"><i class="mdi mdi-book-open-page-variant"></i> Dokumentation</h5>
<div class="list-group list-group-flush">
{% for assignment in documentation_assignments %}
<a class="list-group-item list-group-item-action" href="{{ assignment.document.get_absolute_url }}">
<strong>{{ assignment.document.title }}</strong>{% if assignment.note %}<br><small>{{ assignment.note }}</small>{% endif %}
</a>
{% empty %}<div class="list-group-item text-muted">Keine Dokumentation zugeordnet.</div>{% endfor %}
</div>
{% if perms.netbox_documentation.add_documentassignment %}<div class="card-footer"><a href="{% url 'plugins:netbox_documentation:documentassignment_add' %}?assigned_object_type={{ content_type.pk }}&assigned_object_id={{ object.pk }}" class="btn btn-sm btn-primary"><i class="mdi mdi-link-plus"></i> Zuordnen</a></div>{% endif %}
</div>
+18
View File
@@ -0,0 +1,18 @@
from django.urls import path
from netbox.views.generic import ObjectChangeLogView
from . import models, views
urlpatterns = (
path("", 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/<int:pk>/", views.DocumentView.as_view(), name="document"),
path("documents/<int:pk>/edit/", views.DocumentEditView.as_view(), name="document_edit"),
path("documents/<int:pk>/delete/", views.DocumentDeleteView.as_view(), name="document_delete"),
path("documents/<int:pk>/changelog/", ObjectChangeLogView.as_view(), name="document_changelog", kwargs={"model": models.Document}),
path("assignments/", views.AssignmentListView.as_view(), name="documentassignment_list"),
path("assignments/add/", views.AssignmentEditView.as_view(), name="documentassignment_add"),
path("assignments/<int:pk>/edit/", views.AssignmentEditView.as_view(), name="documentassignment_edit"),
path("assignments/<int:pk>/delete/", views.AssignmentDeleteView.as_view(), name="documentassignment_delete"),
)
+93
View File
@@ -0,0 +1,93 @@
from pathlib import Path
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db import transaction
from django.shortcuts import redirect, render
from django.utils.text import slugify
from django.views import View
from netbox.views import generic
from .filtersets import DocumentFilterSet, AssignmentFilterSet
from .forms import DocumentForm, AssignmentForm, ImportForm
from .importers import ImportFailure, import_document
from .models import Document, DocumentAssignment, DocumentAttachment
from .tables import DocumentTable, AssignmentTable
class DocumentListView(generic.ObjectListView):
queryset = Document.objects.prefetch_related("assignments")
table = DocumentTable
filterset = DocumentFilterSet
class DocumentView(generic.ObjectView):
queryset = Document.objects.prefetch_related("assignments", "attachments")
class DocumentEditView(generic.ObjectEditView):
queryset = Document.objects.all()
form = DocumentForm
class DocumentDeleteView(generic.ObjectDeleteView):
queryset = Document.objects.all()
class AssignmentListView(generic.ObjectListView):
queryset = DocumentAssignment.objects.select_related("document", "assigned_object_type")
table = AssignmentTable
filterset = AssignmentFilterSet
class AssignmentEditView(generic.ObjectEditView):
queryset = DocumentAssignment.objects.all()
form = AssignmentForm
class AssignmentDeleteView(generic.ObjectDeleteView):
queryset = DocumentAssignment.objects.all()
class DocumentImportView(PermissionRequiredMixin, View):
permission_required = "netbox_documentation.import_document"
template_name = "netbox_documentation/document_import.html"
def get(self, request):
return render(request, self.template_name, {"form": ImportForm()})
def post(self, request):
form = ImportForm(request.POST, request.FILES)
if not form.is_valid():
return render(request, self.template_name, {"form": form})
upload = form.cleaned_data["file"]
try:
result = import_document(upload)
except (ImportFailure, Exception) as exc:
# Known conversion/library errors are presented without exposing a traceback.
form.add_error("file", f"Import fehlgeschlagen: {exc}")
return render(request, self.template_name, {"form": form})
with transaction.atomic():
document = form.cleaned_data.get("document")
if document:
separator = "\n\n---\n\n" if document.body else ""
document.body += separator + result.markdown
document.save()
else:
title = form.cleaned_data.get("title") or Path(upload.name).stem
base_slug = slugify(title)[:180] or "dokumentation"
slug = base_slug
counter = 2
while Document.objects.filter(slug=slug).exists():
slug = f"{base_slug}-{counter}"
counter += 1
document = Document.objects.create(title=title, slug=slug, body=result.markdown)
keep = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("keep_imported_file", True)
if keep:
upload.seek(0)
DocumentAttachment.objects.create(document=document, file=upload,
original_name=upload.name, content_type=upload.content_type or "", size=upload.size)
for warning in result.warnings:
messages.warning(request, warning)
messages.success(request, f"{upload.name} wurde importiert.")
return redirect(document)
+29
View File
@@ -0,0 +1,29 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "netbox-documentation"
version = "0.1.0"
description = "Integrated Markdown wiki and office document importer for NetBox"
readme = "README.md"
requires-python = ">=3.10"
license = {text = "Apache-2.0"}
dependencies = [
"mammoth>=1.8,<2",
"openpyxl>=3.1,<4",
"pypdf>=5,<7",
"tabulate>=0.9,<1",
]
[project.optional-dependencies]
test = ["pytest>=8", "pytest-django>=4.9"]
[tool.setuptools.packages.find]
include = ["netbox_documentation*"]
[tool.setuptools.package-data]
netbox_documentation = ["templates/**/*.html", "static/**/*"]
[tool.pytest.ini_options]
python_files = ["test_*.py"]
+46
View File
@@ -0,0 +1,46 @@
from io import BytesIO
import importlib.util
from pathlib import Path
import sys
import pytest
from openpyxl import Workbook
spec = importlib.util.spec_from_file_location(
"documentation_importers", Path(__file__).parents[1] / "netbox_documentation" / "importers.py"
)
importers = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = importers
spec.loader.exec_module(importers)
ImportFailure = importers.ImportFailure
import_document = importers.import_document
class Upload(BytesIO):
def __init__(self, value, name):
super().__init__(value)
self.name = name
def test_markdown_import():
result = import_document(Upload(b"# Hallo", "test.md"))
assert result.markdown == "# Hallo"
def test_xlsx_imports_sheets_as_tables():
workbook = Workbook()
sheet = workbook.active
sheet.title = "Server"
sheet.append(["Name", "IP"])
sheet.append(["web01", "10.0.0.1"])
stream = BytesIO()
workbook.save(stream)
result = import_document(Upload(stream.getvalue(), "server.xlsx"))
assert "## Server" in result.markdown
assert "web01" in result.markdown
def test_rejects_legacy_excel():
with pytest.raises(ImportFailure, match="xlsx"):
import_document(Upload(b"", "legacy.xls"))