feat: Excel-Mehrblattimport als einzelne Dokumentationen ergänzen

This commit is contained in:
2026-07-22 14:22:22 +02:00
parent 882a8d528f
commit 6df5c101c5
8 changed files with 177 additions and 9 deletions
+4 -1
View File
@@ -12,6 +12,7 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit
- Formatierte Word-Inhalte inklusive Tabellen und unterstützten Zwischenablage-Bildern einfügen
- Mehrere Dokumentationen mit Ordnern, Zuordnungen und Anhängen als ZIP exportieren und wieder importieren
- Druckoptimierte A4-Ansicht zum Drucken oder Speichern als PDF
- Excel-Mehrblattimport: je Arbeitsblatt eine Dokumentation in einem gewählten Zielordner
- Eine Dokumentation mehreren Objekten zuordnen und umgekehrt
- Unbegrenzt viele Objektzuordnungen pro Dokumentation; nur identische Doppelzuordnungen werden verhindert
- Unterstützte Standardobjekte: Region, Standort, Location, Rack, Gerät, VM, VM-Cluster und Mandant/Kunde
@@ -22,7 +23,7 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit
## Kompatibilität
Die Version `0.4.2` 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.
Die Version `0.5.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
@@ -121,6 +122,8 @@ Objektbezogene NetBox-Constraints sollten zusätzlich passend zu Mandanten und V
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.
Für große Excel-Dateien kann auf der Importseite **„Excel mit mehreren Arbeitsblättern je Blatt eine Dokumentation“** aktiviert werden. Dazu ist ein Zielordner verpflichtend. Jedes nicht leere Arbeitsblatt wird als eigene Dokumentation mit dem Blattnamen als Titel angelegt. Normale eingebettete PNG-, JPEG-, GIF- und WebP-Bilder werden dem jeweiligen Dokument als Medienanhang hinzugefügt. Diagramme, SmartArt, Steuerelemente und extern verknüpfte Bilder können nicht zuverlässig übernommen werden.
## REST-API
Nach Aktivierung stehen die üblichen NetBox-Plugin-Endpunkte bereit:
+1 -1
View File
@@ -5,7 +5,7 @@ class DocumentationConfig(PluginConfig):
name = "netbox_documentation"
verbose_name = "NetBox Dokumentation"
description = "Wiki und Office-Dokumentation direkt in NetBox"
version = "0.4.2"
version = "0.5.0"
author = "LKE"
base_url = "documentation"
min_version = "4.0.0"
+22
View File
@@ -108,12 +108,34 @@ class ImportForm(forms.Form):
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")
excel_sheets_as_documents = forms.BooleanField(
required=False,
label="Excel mit mehreren Arbeitsblättern je Blatt eine Dokumentation",
)
category = DynamicModelChoiceField(
queryset=DocumentCategory.objects.all(), required=False,
label="Zielordner / Kategorie",
help_text="Für den Mehrblattimport verpflichtend.",
)
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
if user is not None:
self.fields["document"].queryset = Document.objects.restrict(user, "change")
self.fields["category"].queryset = DocumentCategory.objects.restrict(user, "view")
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")
if data.get("excel_sheets_as_documents"):
if upload and not upload.name.lower().endswith((".xlsx", ".xlsm")):
self.add_error("file", "Der Mehrblattimport benötigt eine XLSX- oder XLSM-Datei.")
if not data.get("category"):
self.add_error("category", "Bitte einen Zielordner für die erzeugten Dokumentationen auswählen.")
if data.get("append"):
self.add_error("append", "Mehrblattimport und Anhängen können nicht kombiniert werden.")
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.")
+69 -1
View File
@@ -14,6 +14,21 @@ class ImportResult:
warnings: list[str] = field(default_factory=list)
@dataclass
class ImportedImage:
name: str
content: bytes
content_type: str
cell: str = ""
@dataclass
class ExcelSheetResult:
title: str
markdown: str
images: list[ImportedImage] = field(default_factory=list)
def import_document(upload) -> ImportResult:
suffix = Path(upload.name).suffix.lower()
content = upload.read()
@@ -58,6 +73,60 @@ def _xlsx(content):
return ImportResult("\n\n".join(sections))
def import_excel_sheets(upload) -> list[ExcelSheetResult]:
"""Convert each non-empty worksheet into an individual document payload."""
from openpyxl import load_workbook
from openpyxl.utils import get_column_letter
from tabulate import tabulate
suffix = Path(upload.name).suffix.lower()
if suffix not in {".xlsx", ".xlsm"}:
raise ImportFailure("Der Mehrblattimport unterstützt XLSX- und XLSM-Dateien.")
content = upload.read()
upload.seek(0)
workbook = load_workbook(BytesIO(content), read_only=False, data_only=True)
results = []
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()
images = []
for number, image in enumerate(getattr(sheet, "_images", ()), 1):
image_format = (getattr(image, "format", None) or "png").lower()
if image_format == "jpg":
image_format = "jpeg"
if image_format not in {"png", "jpeg", "gif", "webp"}:
continue
anchor = getattr(image, "anchor", None)
marker = getattr(anchor, "_from", None)
cell = f"{get_column_letter(marker.col + 1)}{marker.row + 1}" if marker else ""
try:
image_content = image._data()
except (AttributeError, OSError, ValueError):
continue
images.append(ImportedImage(
name=f"{slugify_filename(sheet.title)}-{number}.{image_format if image_format != 'jpeg' else 'jpg'}",
content=image_content, content_type=f"image/{image_format}", cell=cell,
))
if not rows and not images:
continue
if rows:
width = max(len(row) for row in rows)
rows = [row + [""] * (width - len(row)) for row in rows]
markdown = tabulate(rows[1:], headers=rows[0], tablefmt="github")
else:
markdown = ""
results.append(ExcelSheetResult(title=sheet.title, markdown=markdown, images=images))
if not results:
raise ImportFailure("Die Arbeitsmappe enthält keine Daten oder unterstützten Bilder.")
return results
def slugify_filename(value):
value = re.sub(r"[^A-Za-z0-9._-]+", "-", value).strip("-.")
return value[:80] or "arbeitsblatt"
def _pdf(content):
from pypdf import PdfReader
reader = PdfReader(BytesIO(content))
@@ -70,4 +139,3 @@ def _pdf(content):
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."])
@@ -5,9 +5,12 @@
<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>
<div class="text-muted mb-3">
DOCX übernimmt Überschriften, Listen und Tabellen. Beim normalen XLSX-Import werden alle Blätter in einer Dokumentation zusammengeführt.
Mit <strong>„Excel mit mehreren Arbeitsblättern“</strong> entsteht dagegen je nicht leerem Blatt eine eigene Dokumentation im verpflichtend gewählten Zielordner; eingebettete Standardbilder werden soweit möglich übernommen.
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 %}
+54 -3
View File
@@ -17,7 +17,7 @@ from .forms import (
DocumentForm, ImportForm,
)
from .archive import ArchiveFailure, documents_for_categories, export_documents, import_archive
from .importers import ImportFailure, import_document
from .importers import ImportFailure, import_document, import_excel_sheets
from .models import Document, DocumentAssignment, DocumentAttachment, DocumentCategory
from .tables import DocumentTable, AssignmentTable, DocumentCategoryTable
@@ -108,13 +108,15 @@ class DocumentImportView(PermissionRequiredMixin, View):
template_name = "netbox_documentation/document_import.html"
def get(self, request):
return render(request, self.template_name, {"form": ImportForm()})
return render(request, self.template_name, {"form": ImportForm(user=request.user)})
def post(self, request):
form = ImportForm(request.POST, request.FILES)
form = ImportForm(request.POST, request.FILES, user=request.user)
if not form.is_valid():
return render(request, self.template_name, {"form": form})
upload = form.cleaned_data["file"]
if form.cleaned_data.get("excel_sheets_as_documents"):
return self._import_excel_sheets(request, form, upload)
try:
result = import_document(upload)
except (ImportFailure, Exception) as exc:
@@ -150,6 +152,55 @@ class DocumentImportView(PermissionRequiredMixin, View):
messages.success(request, f"{upload.name} wurde importiert.")
return redirect(document)
def _import_excel_sheets(self, request, form, upload):
from django.core.files.base import ContentFile
try:
sheets = import_excel_sheets(upload)
except (ImportFailure, Exception) as exc:
form.add_error("file", f"Excel-Mehrblattimport fehlgeschlagen: {exc}")
return render(request, self.template_name, {"form": form})
category = form.cleaned_data["category"]
created_documents = []
with transaction.atomic():
for sheet in sheets:
base_slug = slugify(sheet.title)[:180] or "arbeitsblatt"
document_slug, counter = base_slug, 2
while Document.objects.filter(slug=document_slug).exists():
document_slug = f"{base_slug[:190-len(str(counter))]}-{counter}"
counter += 1
document = Document.objects.create(
title=sheet.title[:200], slug=document_slug, body=sheet.markdown,
body_format="markdown", category=category,
summary=f"Importiert aus {upload.name}",
)
if sheet.images:
image_lines = []
for image in sheet.images:
attachment = DocumentAttachment(
document=document, original_name=image.name,
content_type=image.content_type, size=len(image.content),
)
attachment.file.save(image.name, ContentFile(image.content), save=False)
attachment.save()
location = f" ({image.cell})" if image.cell else ""
image_lines.append(f"![{image.name}{location}]({attachment.file.url})")
document.body += "\n\n## Bilder\n\n" + "\n\n".join(image_lines)
document.save(update_fields=("body", "last_updated"))
created_documents.append(document)
keep = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("keep_imported_file", True)
if keep and created_documents:
upload.seek(0)
DocumentAttachment.objects.create(
document=created_documents[0], file=upload, original_name=upload.name,
content_type=upload.content_type or "", size=upload.size,
)
image_count = sum(len(sheet.images) for sheet in sheets)
messages.success(request, (
f"{len(created_documents)} Arbeitsblätter als Dokumentationen in „{category}“ importiert; "
f"{image_count} Bilder übernommen."
))
return redirect("plugins:netbox_documentation:document_list")
class DocumentMediaUploadView(PermissionRequiredMixin, View):
permission_required = "netbox_documentation.change_document"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "netbox-documentation"
version = "0.4.2"
version = "0.5.0"
description = "Integrated Markdown wiki and office document importer for NetBox"
readme = "README.md"
requires-python = ">=3.10"
+21
View File
@@ -15,6 +15,7 @@ sys.modules[spec.name] = importers
spec.loader.exec_module(importers)
ImportFailure = importers.ImportFailure
import_document = importers.import_document
import_excel_sheets = importers.import_excel_sheets
class Upload(BytesIO):
@@ -44,3 +45,23 @@ def test_xlsx_imports_sheets_as_tables():
def test_rejects_legacy_excel():
with pytest.raises(ImportFailure, match="xlsx"):
import_document(Upload(b"", "legacy.xls"))
def test_excel_multi_sheet_import_creates_one_result_per_sheet():
workbook = Workbook()
first = workbook.active
first.title = "Server"
first.append(["Name", "IP"])
first.append(["web01", "10.0.0.1"])
second = workbook.create_sheet("Kontakte")
second.append(["Name", "Telefon"])
second.append(["Leitstelle", "1234"])
workbook.create_sheet("Leer")
stream = BytesIO()
workbook.save(stream)
results = import_excel_sheets(Upload(stream.getvalue(), "kunde.xlsx"))
assert [result.title for result in results] == ["Server", "Kontakte"]
assert "web01" in results[0].markdown
assert "Leitstelle" in results[1].markdown