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