commit ab0ed26ad3f5df4b2f86d7de3356f9f86cdcca7c Author: Louis Date: Wed Jul 22 10:23:58 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a3e0b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.venv/ +build/ +dist/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..038a459 --- /dev/null +++ b/README.md @@ -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 + diff --git a/netbox_documentation/__init__.py b/netbox_documentation/__init__.py new file mode 100644 index 0000000..71d3b52 --- /dev/null +++ b/netbox_documentation/__init__.py @@ -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 + diff --git a/netbox_documentation/api/__init__.py b/netbox_documentation/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/netbox_documentation/api/__init__.py @@ -0,0 +1 @@ + diff --git a/netbox_documentation/api/serializers.py b/netbox_documentation/api/serializers.py new file mode 100644 index 0000000..a55035a --- /dev/null +++ b/netbox_documentation/api/serializers.py @@ -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") + diff --git a/netbox_documentation/api/urls.py b/netbox_documentation/api/urls.py new file mode 100644 index 0000000..655a1b0 --- /dev/null +++ b/netbox_documentation/api/urls.py @@ -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 + diff --git a/netbox_documentation/api/views.py b/netbox_documentation/api/views.py new file mode 100644 index 0000000..81af47b --- /dev/null +++ b/netbox_documentation/api/views.py @@ -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 + diff --git a/netbox_documentation/filtersets.py b/netbox_documentation/filtersets.py new file mode 100644 index 0000000..e85d0ab --- /dev/null +++ b/netbox_documentation/filtersets.py @@ -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") + diff --git a/netbox_documentation/forms.py b/netbox_documentation/forms.py new file mode 100644 index 0000000..a795f3a --- /dev/null +++ b/netbox_documentation/forms.py @@ -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 diff --git a/netbox_documentation/importers.py b/netbox_documentation/importers.py new file mode 100644 index 0000000..7168370 --- /dev/null +++ b/netbox_documentation/importers.py @@ -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."]) + diff --git a/netbox_documentation/migrations/0001_initial.py b/netbox_documentation/migrations/0001_initial.py new file mode 100644 index 0000000..38687bf --- /dev/null +++ b/netbox_documentation/migrations/0001_initial.py @@ -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")), + ] + diff --git a/netbox_documentation/migrations/__init__.py b/netbox_documentation/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/netbox_documentation/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/netbox_documentation/models.py b/netbox_documentation/models.py new file mode 100644 index 0000000..a033f96 --- /dev/null +++ b/netbox_documentation/models.py @@ -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 + diff --git a/netbox_documentation/navigation.py b/netbox_documentation/navigation.py new file mode 100644 index 0000000..4bd66a2 --- /dev/null +++ b/netbox_documentation/navigation.py @@ -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", +) + diff --git a/netbox_documentation/search.py b/netbox_documentation/search.py new file mode 100644 index 0000000..8e296f6 --- /dev/null +++ b/netbox_documentation/search.py @@ -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",) + diff --git a/netbox_documentation/tables.py b/netbox_documentation/tables.py new file mode 100644 index 0000000..6982afd --- /dev/null +++ b/netbox_documentation/tables.py @@ -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") + diff --git a/netbox_documentation/template_content.py b/netbox_documentation/template_content.py new file mode 100644 index 0000000..3196453 --- /dev/null +++ b/netbox_documentation/template_content.py @@ -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] + diff --git a/netbox_documentation/templates/netbox_documentation/document.html b/netbox_documentation/templates/netbox_documentation/document.html new file mode 100644 index 0000000..2231c41 --- /dev/null +++ b/netbox_documentation/templates/netbox_documentation/document.html @@ -0,0 +1,19 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% block content %} +
+
+ {% if object.summary %}

{{ object.summary }}

{% endif %} +
{{ object.body|render_markdown }}
+
+
+
Zuordnungen
+ {% for assignment in object.assignments.all %}{{ assignment.assigned_object_type }}: {{ assignment.assigned_object }}{% empty %}
Noch nicht zugeordnet
{% endfor %} +
+ {% if object.attachments.all %}
Originaldateien
+ {% for attachment in object.attachments.all %}{{ attachment.original_name }}{% endfor %} +
{% endif %} +
+
+{% endblock content %} + diff --git a/netbox_documentation/templates/netbox_documentation/document_import.html b/netbox_documentation/templates/netbox_documentation/document_import.html new file mode 100644 index 0000000..e2b443d --- /dev/null +++ b/netbox_documentation/templates/netbox_documentation/document_import.html @@ -0,0 +1,13 @@ +{% extends 'base/layout.html' %} +{% load form_helpers %} +{% block title %}Dokument importieren{% endblock %} +{% block content %} +
+
Word, Excel oder PDF importieren
+
{% csrf_token %}{% render_form form %} +
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.
+ +
+
+{% endblock %} + diff --git a/netbox_documentation/templates/netbox_documentation/inc/object_documents.html b/netbox_documentation/templates/netbox_documentation/inc/object_documents.html new file mode 100644 index 0000000..d52ec70 --- /dev/null +++ b/netbox_documentation/templates/netbox_documentation/inc/object_documents.html @@ -0,0 +1,11 @@ +
+
Dokumentation
+
+ {% for assignment in documentation_assignments %} + + {{ assignment.document.title }}{% if assignment.note %}
{{ assignment.note }}{% endif %} +
+ {% empty %}
Keine Dokumentation zugeordnet.
{% endfor %} +
+ {% if perms.netbox_documentation.add_documentassignment %}{% endif %} +
diff --git a/netbox_documentation/urls.py b/netbox_documentation/urls.py new file mode 100644 index 0000000..90aabea --- /dev/null +++ b/netbox_documentation/urls.py @@ -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//", views.DocumentView.as_view(), name="document"), + path("documents//edit/", views.DocumentEditView.as_view(), name="document_edit"), + path("documents//delete/", views.DocumentDeleteView.as_view(), name="document_delete"), + path("documents//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//edit/", views.AssignmentEditView.as_view(), name="documentassignment_edit"), + path("assignments//delete/", views.AssignmentDeleteView.as_view(), name="documentassignment_delete"), +) + diff --git a/netbox_documentation/views.py b/netbox_documentation/views.py new file mode 100644 index 0000000..e762975 --- /dev/null +++ b/netbox_documentation/views.py @@ -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) + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2babedd --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/tests/test_importers.py b/tests/test_importers.py new file mode 100644 index 0000000..cd0f360 --- /dev/null +++ b/tests/test_importers.py @@ -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"))