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:
@@ -0,0 +1,8 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.venv/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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")
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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")
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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 @@
|
|||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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",)
|
||||||
|
|
||||||
@@ -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")
|
||||||
|
|
||||||
@@ -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>
|
||||||
@@ -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"),
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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"]
|
||||||
@@ -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"))
|
||||||
Reference in New Issue
Block a user