diff --git a/README.md b/README.md index 1407d84..e06f2a2 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/netbox_documentation/__init__.py b/netbox_documentation/__init__.py index 6949f3c..8a4df34 100644 --- a/netbox_documentation/__init__.py +++ b/netbox_documentation/__init__.py @@ -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" diff --git a/netbox_documentation/forms.py b/netbox_documentation/forms.py index 78f3793..0e7f8c4 100644 --- a/netbox_documentation/forms.py +++ b/netbox_documentation/forms.py @@ -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.") diff --git a/netbox_documentation/importers.py b/netbox_documentation/importers.py index 7168370..a75b73b 100644 --- a/netbox_documentation/importers.py +++ b/netbox_documentation/importers.py @@ -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."]) - diff --git a/netbox_documentation/templates/netbox_documentation/document_import.html b/netbox_documentation/templates/netbox_documentation/document_import.html index e2b443d..e528ddc 100644 --- a/netbox_documentation/templates/netbox_documentation/document_import.html +++ b/netbox_documentation/templates/netbox_documentation/document_import.html @@ -5,9 +5,12 @@
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.
+
+ DOCX übernimmt Überschriften, Listen und Tabellen. Beim normalen XLSX-Import werden alle Blätter in einer Dokumentation zusammengeführt. + Mit „Excel mit mehreren Arbeitsblättern“ 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. +
{% endblock %} - diff --git a/netbox_documentation/views.py b/netbox_documentation/views.py index 398e49d..eaf42d5 100644 --- a/netbox_documentation/views.py +++ b/netbox_documentation/views.py @@ -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" diff --git a/pyproject.toml b/pyproject.toml index 57fcc4a..b5a9dc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_importers.py b/tests/test_importers.py index cd0f360..fb43b2b 100644 --- a/tests/test_importers.py +++ b/tests/test_importers.py @@ -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