From eb503df94b9258fc2c67b1e0deda9a5c20bef5d9 Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 22 Jul 2026 14:34:14 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Excel-Import=20gl=C3=A4tten=20und=20Dok?= =?UTF-8?q?umentlistenaktionen=20korrigieren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 +++- netbox_documentation/__init__.py | 2 +- netbox_documentation/forms.py | 36 ++++++++++++++--- netbox_documentation/importers.py | 39 ++++++++++++------- .../netbox_documentation/document.html | 25 +++++++++++- .../netbox_documentation/document_import.html | 5 +-- .../netbox_documentation/document_print.html | 2 +- netbox_documentation/urls.py | 2 + netbox_documentation/views.py | 29 ++++++++++---- pyproject.toml | 2 +- tests/test_importers.py | 16 ++++++++ 11 files changed, 129 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index e06f2a2..8fe33d5 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit - 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 +- Optionales Glätten von Excel-Tabellen in kompakte Feld-/Wert-Textblöcke - 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 @@ -23,7 +24,7 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit ## Kompatibilität -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. +Die Version `0.5.1` 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 @@ -122,7 +123,9 @@ 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. +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 und erhält eine eigene Kopie der Original-Exceldatei als Anhang. 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. + +Mit **„Excel-Tabellen glätten“** wird jede Datenzeile statt als breite Tabelle als kompakter Textblock aus Feldnamen und Wert importiert. Nicht geglättete Tabellen werden in der Dokumentansicht automatisch kompakt dargestellt und bei Bedarf innerhalb des Inhalts horizontal scrollbar, ohne das NetBox-Layout zu verbreitern. ## REST-API diff --git a/netbox_documentation/__init__.py b/netbox_documentation/__init__.py index 8a4df34..19cec8b 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.5.0" + version = "0.5.1" author = "LKE" base_url = "documentation" min_version = "4.0.0" diff --git a/netbox_documentation/forms.py b/netbox_documentation/forms.py index 0e7f8c4..f8e1ace 100644 --- a/netbox_documentation/forms.py +++ b/netbox_documentation/forms.py @@ -2,6 +2,7 @@ from django import forms from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.db.models import Q +from django.utils.html import format_html from utilities.forms import get_field_value from utilities.forms.fields import ( ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, @@ -13,6 +14,14 @@ from dcim.models import Device from .models import Document, DocumentAssignment, DocumentCategory +def help_label(label, description): + return format_html( + '{} ', + label, description, description, + ) + + def allowed_content_types(): labels = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get( "allowed_object_types", [] @@ -105,17 +114,32 @@ class DocumentCategoryForm(NetBoxModelForm): 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") + title = forms.CharField(max_length=200, required=False, label=help_label( + "Titel", "Leer lassen, um den Dateinamen als Titel zu verwenden. Beim Mehrblattimport werden die Blattnamen verwendet." + )) + append = forms.BooleanField(required=False, initial=False, label=help_label( + "An bestehende Dokumentation anhängen", "Fügt den importierten Inhalt am Ende einer vorhandenen Dokumentation an." + )) + document = forms.ModelChoiceField(queryset=Document.objects.all(), required=False, label=help_label( + "Bestehende Dokumentation", "Zieldokumentation für die Funktion Anhängen." + )) excel_sheets_as_documents = forms.BooleanField( required=False, - label="Excel mit mehreren Arbeitsblättern – je Blatt eine Dokumentation", + label=help_label( + "Excel mit mehreren Arbeitsblättern – je Blatt eine Dokumentation", + "Erstellt aus jedem nicht leeren Excel-Arbeitsblatt eine eigene Dokumentation im gewählten Zielordner.", + ), + ) + flatten_excel_tables = forms.BooleanField( + required=False, + label=help_label( + "Excel-Tabellen glätten", + "Wandelt jede Tabellenzeile in einen lesbaren Textblock aus Feldnamen und Wert um, statt eine Tabelle zu erzeugen.", + ), ) category = DynamicModelChoiceField( queryset=DocumentCategory.objects.all(), required=False, - label="Zielordner / Kategorie", - help_text="Für den Mehrblattimport verpflichtend.", + label=help_label("Zielordner / Kategorie", "Für den Excel-Mehrblattimport verpflichtend."), ) def __init__(self, *args, user=None, **kwargs): diff --git a/netbox_documentation/importers.py b/netbox_documentation/importers.py index a75b73b..79c9bcb 100644 --- a/netbox_documentation/importers.py +++ b/netbox_documentation/importers.py @@ -29,14 +29,14 @@ class ExcelSheetResult: images: list[ImportedImage] = field(default_factory=list) -def import_document(upload) -> ImportResult: +def import_document(upload, flatten_excel=False) -> 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) + return _xlsx(content, flatten=flatten_excel) if suffix == ".pdf": return _pdf(content) if suffix in {".md", ".txt"}: @@ -53,9 +53,28 @@ def _docx(content): return ImportResult(result.value.strip(), warnings) -def _xlsx(content): - from openpyxl import load_workbook +def _render_excel_rows(rows, flatten=False): from tabulate import tabulate + width = max(len(row) for row in rows) + rows = [row + [""] * (width - len(row)) for row in rows] + header, body = rows[0], rows[1:] + if not flatten: + return tabulate(body, headers=header, tablefmt="github") + blocks = [] + for number, row in enumerate(body, 1): + values = [] + for column, value in zip(header, row): + if value == "": + continue + label = column or "Feld" + values.append(f"**{label}:** {value}") + if values: + blocks.append(f"### Datensatz {number}\n\n" + " \n".join(values)) + return "\n\n".join(blocks) or "_Keine Datensätze_" + + +def _xlsx(content, flatten=False): + from openpyxl import load_workbook workbook = load_workbook(BytesIO(content), read_only=True, data_only=True) sections = [] for sheet in workbook.worksheets: @@ -64,20 +83,16 @@ def _xlsx(content): 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')}") + sections.append(f"## {sheet.title}\n\n{_render_excel_rows(rows, flatten)}") if not sections: raise ImportFailure("Die Arbeitsmappe enthält keine Daten.") return ImportResult("\n\n".join(sections)) -def import_excel_sheets(upload) -> list[ExcelSheetResult]: +def import_excel_sheets(upload, flatten=False) -> 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"}: @@ -111,9 +126,7 @@ def import_excel_sheets(upload) -> list[ExcelSheetResult]: 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") + markdown = _render_excel_rows(rows, flatten) else: markdown = "" results.append(ExcelSheetResult(title=sheet.title, markdown=markdown, images=images)) diff --git a/netbox_documentation/templates/netbox_documentation/document.html b/netbox_documentation/templates/netbox_documentation/document.html index 7886bc4..0b8cc74 100644 --- a/netbox_documentation/templates/netbox_documentation/document.html +++ b/netbox_documentation/templates/netbox_documentation/document.html @@ -1,5 +1,28 @@ {% extends 'generic/object.html' %} {% load helpers %} +{% block head %} + {{ block.super }} + +{% endblock head %} {% block extra_controls %} Drucken / PDF @@ -9,7 +32,7 @@
{% if object.summary %}

{{ object.summary }}

{% endif %} -
{{ object.rendered_body|safe }}
+
{{ object.rendered_body|safe }}
{% if object.category %}{% endif %} diff --git a/netbox_documentation/templates/netbox_documentation/document_import.html b/netbox_documentation/templates/netbox_documentation/document_import.html index e528ddc..91c9d1e 100644 --- a/netbox_documentation/templates/netbox_documentation/document_import.html +++ b/netbox_documentation/templates/netbox_documentation/document_import.html @@ -6,9 +6,8 @@
Word, Excel oder PDF importieren
{% csrf_token %}{% render_form form %}
- 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. + Importhinweise +
diff --git a/netbox_documentation/templates/netbox_documentation/document_print.html b/netbox_documentation/templates/netbox_documentation/document_print.html index c9103e1..236fbe7 100644 --- a/netbox_documentation/templates/netbox_documentation/document_print.html +++ b/netbox_documentation/templates/netbox_documentation/document_print.html @@ -16,7 +16,7 @@ .summary { color: #4b5563; font-size: 13pt; margin: 0 0 1rem; } .meta { display: flex; flex-wrap: wrap; gap: .4rem 1.25rem; padding: .65rem 0; margin-bottom: 1.4rem; color: #6b7280; border-top: 1px solid #ddd; border-bottom: 1px solid #ddd; font-size: 9pt; } .content img { max-width: 100%; height: auto; } - .content table { width: 100%; border-collapse: collapse; margin: 1em 0; } + .content table { width: auto; max-width: 100%; border-collapse: collapse; margin: 1em 0; font-size: 9pt; line-height: 1.25; } .content th, .content td { border: 1px solid #777; padding: .35rem .5rem; vertical-align: top; } .content pre, .content code { white-space: pre-wrap; overflow-wrap: anywhere; font-family: Consolas, monospace; } .content a { color: #174ea6; overflow-wrap: anywhere; } diff --git a/netbox_documentation/urls.py b/netbox_documentation/urls.py index e2b8dc6..e1a1162 100644 --- a/netbox_documentation/urls.py +++ b/netbox_documentation/urls.py @@ -7,6 +7,8 @@ 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/import/", views.DocumentImportView.as_view(), name="document_bulk_import"), + path("documents/delete/", views.DocumentBulkDeleteView.as_view(), name="document_bulk_delete"), path("archive/", views.DocumentArchiveView.as_view(), name="document_archive"), path("documents//", views.DocumentView.as_view(), name="document"), path("documents//print/", views.DocumentPrintView.as_view(), name="document_print"), diff --git a/netbox_documentation/views.py b/netbox_documentation/views.py index eaf42d5..583c752 100644 --- a/netbox_documentation/views.py +++ b/netbox_documentation/views.py @@ -11,6 +11,7 @@ from django.shortcuts import get_object_or_404, redirect, render from django.utils.text import slugify from django.views import View from netbox.views import generic +from netbox.object_actions import AddObject, BulkDelete, BulkImport from .filtersets import DocumentFilterSet, AssignmentFilterSet, DocumentCategoryFilterSet from .forms import ( ArchiveExportForm, ArchiveImportForm, AssignmentForm, DocumentCategoryForm, @@ -41,6 +42,7 @@ class DocumentListView(generic.ObjectListView): queryset = Document.objects.prefetch_related("assignments") table = DocumentTable filterset = DocumentFilterSet + actions = (AddObject, BulkImport, BulkDelete) class DocumentView(generic.ObjectView): @@ -69,6 +71,12 @@ class DocumentDeleteView(generic.ObjectDeleteView): queryset = Document.objects.all() +class DocumentBulkDeleteView(generic.BulkDeleteView): + queryset = Document.objects.all() + table = DocumentTable + filterset = DocumentFilterSet + + class DocumentCategoryListView(generic.ObjectListView): queryset = DocumentCategory.objects.select_related("parent").prefetch_related("documents") table = DocumentCategoryTable @@ -118,7 +126,7 @@ class DocumentImportView(PermissionRequiredMixin, View): if form.cleaned_data.get("excel_sheets_as_documents"): return self._import_excel_sheets(request, form, upload) try: - result = import_document(upload) + result = import_document(upload, flatten_excel=form.cleaned_data.get("flatten_excel_tables", False)) except (ImportFailure, Exception) as exc: # Known conversion/library errors are presented without exposing a traceback. form.add_error("file", f"Import fehlgeschlagen: {exc}") @@ -155,12 +163,15 @@ class DocumentImportView(PermissionRequiredMixin, View): def _import_excel_sheets(self, request, form, upload): from django.core.files.base import ContentFile try: - sheets = import_excel_sheets(upload) + sheets = import_excel_sheets(upload, flatten=form.cleaned_data.get("flatten_excel_tables", False)) 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 = [] + upload.seek(0) + original_content = upload.read() + upload.seek(0) with transaction.atomic(): for sheet in sheets: base_slug = slugify(sheet.title)[:180] or "arbeitsblatt" @@ -188,12 +199,14 @@ class DocumentImportView(PermissionRequiredMixin, View): 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, - ) + if keep: + for document in created_documents: + attachment = DocumentAttachment( + document=document, original_name=upload.name, + content_type=upload.content_type or "", size=len(original_content), + ) + attachment.file.save(upload.name, ContentFile(original_content), save=False) + attachment.save() image_count = sum(len(sheet.images) for sheet in sheets) messages.success(request, ( f"{len(created_documents)} Arbeitsblätter als Dokumentationen in „{category}“ importiert; " diff --git a/pyproject.toml b/pyproject.toml index b5a9dc0..d0b6ba2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netbox-documentation" -version = "0.5.0" +version = "0.5.1" 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 fb43b2b..d43fea9 100644 --- a/tests/test_importers.py +++ b/tests/test_importers.py @@ -65,3 +65,19 @@ def test_excel_multi_sheet_import_creates_one_result_per_sheet(): assert [result.title for result in results] == ["Server", "Kontakte"] assert "web01" in results[0].markdown assert "Leitstelle" in results[1].markdown + + +def test_excel_flatten_turns_rows_into_field_value_blocks(): + workbook = Workbook() + sheet = workbook.active + sheet.append(["Name", "IP"]) + sheet.append(["db01", "10.0.0.2"]) + stream = BytesIO() + workbook.save(stream) + + results = import_excel_sheets(Upload(stream.getvalue(), "server.xlsx"), flatten=True) + + assert "### Datensatz 1" in results[0].markdown + assert "**Name:** db01" in results[0].markdown + assert "**IP:** 10.0.0.2" in results[0].markdown + assert "|" not in results[0].markdown