From df11d0eb050a4f20cdd5922a2cb31305f10467b0 Mon Sep 17 00:00:00 2001 From: Louis Date: Thu, 23 Jul 2026 12:58:52 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Vorschau=20und=20Blattauswahl=20f=C3=BC?= =?UTF-8?q?r=20Excel-Mehrblattimport=20erg=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +- netbox_documentation/__init__.py | 2 +- netbox_documentation/forms.py | 36 ++++ .../migrations/0004_excelimportpreview.py | 30 +++ netbox_documentation/models.py | 28 +++ .../excel_import_preview.html | 69 +++++++ netbox_documentation/urls.py | 1 + netbox_documentation/views.py | 195 ++++++++++++++---- pyproject.toml | 2 +- 9 files changed, 320 insertions(+), 48 deletions(-) create mode 100644 netbox_documentation/migrations/0004_excelimportpreview.py create mode 100644 netbox_documentation/templates/netbox_documentation/excel_import_preview.html diff --git a/README.md b/README.md index 70b902e..f87b650 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit - Lesbare Tabellen mit vollständigen Zellrahmen und horizontalem Inhalts-Scrolling - Tabellen-Presets, farbige Kopfzeilen und Zellhervorhebungen im WYSIWYG-Editor - Excel-Mehrblattimport: je Arbeitsblatt eine Dokumentation in einem gewählten Zielordner +- Zweistufige Excel-Vorschau mit Blattauswahl und frei änderbaren Dokumenttiteln - 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 @@ -27,7 +28,7 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit ## Kompatibilität -Die Version `0.6.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. +Die Version `0.7.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 @@ -128,6 +129,8 @@ Alte binäre `.doc`- und `.xls`-Dateien müssen vorher in `.docx` bzw. `.xlsx` k 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. +Vor dem Anlegen erscheint eine Vorschauseite mit allen erkannten Arbeitsblättern. Dort können einzelne Blätter abgewählt, die zukünftigen Dokumenttitel geändert und die jeweils erzeugte Dokumentation aufgeklappt werden. Erst die abschließende Bestätigung schreibt Dokumentationen und Anhänge in die Datenbank. Nicht bestätigte Vorschauen sind an den Benutzer gebunden und werden nach 24 Stunden bereinigt. + Mit **„Excel-Tabellen glätten“** werden normale Zellen ohne künstliche Datensatz- oder Feldbezeichnungen in ihrer Lesereihenfolge übernommen. Zellen einer Excel-Zeile erscheinen als einzelne Textzeilen eines Absatzes; zwischen Excel-Zeilen wird ein Absatz eingefügt. Bereiche, die in Excel ausdrücklich über **Einfügen → Tabelle** als strukturierte Tabelle gekennzeichnet wurden, bleiben dagegen als Tabelle erhalten. Rein optisch mit Rahmen oder Farben formatierte Bereiche gelten nicht als Tabelle. 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 1c28ed6..58c5b99 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.6.1" + version = "0.7.0" author = "LKE" base_url = "documentation" min_version = "4.0.0" diff --git a/netbox_documentation/forms.py b/netbox_documentation/forms.py index b599bde..8d8ea7e 100644 --- a/netbox_documentation/forms.py +++ b/netbox_documentation/forms.py @@ -201,3 +201,39 @@ class ArchiveImportForm(forms.Form): if upload.size > limit * 1024 * 1024: raise forms.ValidationError(f"Das Archiv ist größer als {limit} MB.") return upload + + +class ExcelSheetSelectionForm(forms.Form): + include = forms.BooleanField( + required=False, initial=True, label="Importieren", + widget=forms.CheckboxInput(attrs={"class": "form-check-input"}), + ) + index = forms.IntegerField(widget=forms.HiddenInput()) + title = forms.CharField( + max_length=200, required=False, label="Dokumenttitel", + widget=forms.TextInput(attrs={"class": "form-control"}), + ) + + def clean(self): + data = super().clean() + if data.get("include") and not (data.get("title") or "").strip(): + self.add_error("title", "Für ein ausgewähltes Arbeitsblatt ist ein Titel erforderlich.") + return data + + +class BaseExcelSheetSelectionFormSet(forms.BaseFormSet): + def clean(self): + super().clean() + if any(form.errors for form in self.forms): + return + selected = [form.cleaned_data for form in self.forms if form.cleaned_data.get("include")] + if not selected: + raise forms.ValidationError("Bitte mindestens ein Arbeitsblatt auswählen.") + indices = [item["index"] for item in selected] + if len(indices) != len(set(indices)): + raise forms.ValidationError("Die Arbeitsblattauswahl ist ungültig.") + + +ExcelSheetSelectionFormSet = forms.formset_factory( + ExcelSheetSelectionForm, formset=BaseExcelSheetSelectionFormSet, extra=0 +) diff --git a/netbox_documentation/migrations/0004_excelimportpreview.py b/netbox_documentation/migrations/0004_excelimportpreview.py new file mode 100644 index 0000000..b1b6c81 --- /dev/null +++ b/netbox_documentation/migrations/0004_excelimportpreview.py @@ -0,0 +1,30 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import netbox_documentation.models +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("netbox_documentation", "0003_documentcategory_document_category"), + ] + + operations = [ + migrations.CreateModel( + name="ExcelImportPreview", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("file", models.FileField(upload_to=netbox_documentation.models.preview_upload_path)), + ("original_name", models.CharField(max_length=255)), + ("content_type", models.CharField(blank=True, max_length=100)), + ("flatten", models.BooleanField(default=False)), + ("sheet_metadata", models.JSONField(default=list)), + ("created", models.DateTimeField(auto_now_add=True)), + ("category", models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to="netbox_documentation.documentcategory")), + ("user", models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={"ordering": ("-created",)}, + ), + ] diff --git a/netbox_documentation/models.py b/netbox_documentation/models.py index 1fa362b..64bd137 100644 --- a/netbox_documentation/models.py +++ b/netbox_documentation/models.py @@ -1,7 +1,10 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models +from django.conf import settings from django.urls import reverse +from pathlib import Path +import uuid from netbox.models import NetBoxModel @@ -141,3 +144,28 @@ class DocumentAttachment(NetBoxModel): # have no standalone detail view. This is also used by NetBox's delete # dependency collector when rendering the confirmation dialog. return self.document.get_absolute_url() + + +def preview_upload_path(instance, filename): + return f"netbox_documentation/import-previews/{instance.pk}/{Path(filename).name}" + + +class ExcelImportPreview(models.Model): + """Short-lived, user-bound state for the two-step Excel import workflow.""" + _netbox_private = True + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) + category = models.ForeignKey(DocumentCategory, on_delete=models.SET_NULL, null=True) + file = models.FileField(upload_to=preview_upload_path) + original_name = models.CharField(max_length=255) + content_type = models.CharField(max_length=100, blank=True) + flatten = models.BooleanField(default=False) + sheet_metadata = models.JSONField(default=list) + created = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ("-created",) + + def __str__(self): + return self.original_name diff --git a/netbox_documentation/templates/netbox_documentation/excel_import_preview.html b/netbox_documentation/templates/netbox_documentation/excel_import_preview.html new file mode 100644 index 0000000..d7da7a8 --- /dev/null +++ b/netbox_documentation/templates/netbox_documentation/excel_import_preview.html @@ -0,0 +1,69 @@ +{% extends 'base/layout.html' %} +{% load form_helpers %} + +{% block title %}Excel-Importvorschau{% endblock %} + +{% block head %} + {{ block.super }} + +{% endblock head %} + +{% block content %} +
+
+
+
Datei: {{ batch.original_name }}
+
Zielordner: {{ batch.category|default:"Nicht mehr vorhanden" }}
+
Glätten: {% if batch.flatten %}Ja{% else %}Nein{% endif %}
+
+
+
+ +
+ {% csrf_token %} + {{ formset.management_form }} + {% if formset.non_form_errors %}
{{ formset.non_form_errors }}
{% endif %} + + {% for item in items %} +
+
+
+
+ {{ item.form.include }} + +
+
+ {{ item.form.index }} + + {{ item.form.title }} + {% for error in item.form.title.errors %}
{{ error }}
{% endfor %} +
+
+ {{ item.sheet.images|length }} Bild{{ item.sheet.images|length|pluralize:"er" }} +
+
+
+
+
+ Vorschau der erzeugten Dokumentation anzeigen +
+ {{ item.preview_html|safe }} +
+
+
+
+ {% endfor %} + + +
+{% endblock content %} diff --git a/netbox_documentation/urls.py b/netbox_documentation/urls.py index 33d7b15..4bf154e 100644 --- a/netbox_documentation/urls.py +++ b/netbox_documentation/urls.py @@ -8,6 +8,7 @@ urlpatterns = ( 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/import/excel//", views.ExcelImportPreviewView.as_view(), name="excel_import_preview"), 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"), diff --git a/netbox_documentation/views.py b/netbox_documentation/views.py index 803dd30..f721ba9 100644 --- a/netbox_documentation/views.py +++ b/netbox_documentation/views.py @@ -1,4 +1,5 @@ from pathlib import Path +from datetime import timedelta import mimetypes import tinymce from django.conf import settings @@ -9,17 +10,21 @@ from django.db import transaction from django.http import FileResponse, Http404, JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils.text import slugify +from django.utils import timezone 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, - DocumentForm, ImportForm, + DocumentForm, ExcelSheetSelectionFormSet, ImportForm, ) from .archive import ArchiveFailure, documents_for_categories, export_documents, import_archive from .importers import ImportFailure, import_document, import_excel_sheets -from .models import Document, DocumentAssignment, DocumentAttachment, DocumentCategory +from .models import ( + Document, DocumentAssignment, DocumentAttachment, DocumentCategory, + ExcelImportPreview, +) from .tables import DocumentTable, AssignmentTable, DocumentCategoryTable @@ -175,59 +180,159 @@ class DocumentImportView(PermissionRequiredMixin, View): return redirect(document) def _import_excel_sheets(self, request, form, upload): - from django.core.files.base import ContentFile try: 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 = [] + # Remove abandoned previews owned by this user before creating a new one. + stale = ExcelImportPreview.objects.filter(created__lt=timezone.now() - timedelta(hours=24)) + for preview in stale: + preview.file.delete(save=False) + preview.delete() 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" - 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: - 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) + preview = ExcelImportPreview.objects.create( + user=request.user, category=form.cleaned_data["category"], file=upload, + original_name=upload.name, content_type=upload.content_type or "", + flatten=form.cleaned_data.get("flatten_excel_tables", False), + sheet_metadata=[{"title": sheet.title, "image_count": len(sheet.images)} for sheet in sheets], + ) + return redirect("plugins:netbox_documentation:excel_import_preview", token=preview.pk) + + +class ExcelImportPreviewView(PermissionRequiredMixin, View): + permission_required = "netbox_documentation.import_document" + template_name = "netbox_documentation/excel_import_preview.html" + + def get_preview(self, request, token): + return get_object_or_404(ExcelImportPreview, pk=token, user=request.user) + + def read_sheets(self, preview): + preview.file.open("rb") + try: + return import_excel_sheets(preview.file, flatten=preview.flatten) + finally: + preview.file.close() + + def render_preview(self, request, preview, sheets, formset): + items = [] + for form, sheet in zip(formset.forms, sheets): + sample = sheet.markdown + if sheet.images: + sample += f"\n\n## Bilder\n\n_{len(sheet.images)} eingebettete Bilder werden beim Import angefügt._" + truncated = len(sample) > 100000 + if truncated: + sample = sample[:100000] + "\n\n_… Vorschau gekürzt …_" + preview_document = Document(body=sample, body_format="markdown") + items.append({ + "form": form, "sheet": sheet, + "preview_html": preview_document.rendered_body(), "truncated": truncated, + }) + return render(request, self.template_name, { + "batch": preview, "formset": formset, "items": items, + }) + + def get(self, request, token): + preview = self.get_preview(request, token) + if preview.created < timezone.now() - timedelta(hours=24): + self.delete_preview(preview) + messages.error(request, "Diese Importvorschau ist abgelaufen. Bitte die Exceldatei erneut hochladen.") + return redirect("plugins:netbox_documentation:document_import") + try: + sheets = self.read_sheets(preview) + except (ImportFailure, Exception) as exc: + self.delete_preview(preview) + messages.error(request, f"Die Excel-Vorschau konnte nicht erzeugt werden: {exc}") + return redirect("plugins:netbox_documentation:document_import") + initial = [{"include": True, "index": index, "title": sheet.title} for index, sheet in enumerate(sheets)] + return self.render_preview(request, preview, sheets, ExcelSheetSelectionFormSet(initial=initial)) + + def post(self, request, token): + preview = self.get_preview(request, token) + if request.POST.get("action") == "cancel": + self.delete_preview(preview) + messages.info(request, "Excel-Import wurde abgebrochen.") + return redirect("plugins:netbox_documentation:document_import") + try: + sheets = self.read_sheets(preview) + except (ImportFailure, Exception) as exc: + self.delete_preview(preview) + messages.error(request, f"Die Exceldatei konnte nicht erneut gelesen werden: {exc}") + return redirect("plugins:netbox_documentation:document_import") + formset = ExcelSheetSelectionFormSet(request.POST) + if not formset.is_valid(): + return self.render_preview(request, preview, sheets, formset) + if not preview.category: + self.delete_preview(preview) + messages.error(request, "Der gewählte Zielordner existiert nicht mehr. Bitte den Import erneut starten.") + return redirect("plugins:netbox_documentation:document_import") + selections = {} + for form in formset.forms: + data = form.cleaned_data + if data.get("include"): + if data["index"] < 0 or data["index"] >= len(sheets): + raise PermissionDenied + selections[data["index"]] = data["title"].strip() + result = self.create_documents(preview, sheets, selections) + self.delete_preview(preview) messages.success(request, ( - f"{len(created_documents)} Arbeitsblätter als Dokumentationen in „{category}“ importiert; " - f"{image_count} Bilder übernommen." + f"{result['documents']} Arbeitsblätter als Dokumentationen in „{result['category']}“ importiert; " + f"{result['images']} Bilder übernommen." )) return redirect("plugins:netbox_documentation:document_list") + @staticmethod + @transaction.atomic + def create_documents(preview, sheets, selections): + from django.core.files.base import ContentFile + preview.file.open("rb") + try: + original_content = preview.file.read() + finally: + preview.file.close() + created_documents, image_count = [], 0 + for index, title in selections.items(): + sheet = sheets[index] + base_slug = slugify(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=title[:200], slug=document_slug, body=sheet.markdown, + body_format="markdown", category=preview.category, + summary=f"Importiert aus {preview.original_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")) + image_count += len(sheet.images) + created_documents.append(document) + if settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("keep_imported_file", True): + for document in created_documents: + attachment = DocumentAttachment( + document=document, original_name=preview.original_name, + content_type=preview.content_type, size=len(original_content), + ) + attachment.file.save(preview.original_name, ContentFile(original_content), save=False) + attachment.save() + return {"documents": len(created_documents), "images": image_count, "category": preview.category} + + @staticmethod + def delete_preview(preview): + preview.file.delete(save=False) + preview.delete() + class DocumentMediaUploadView(PermissionRequiredMixin, View): permission_required = "netbox_documentation.change_document" diff --git a/pyproject.toml b/pyproject.toml index 2edda99..8b5c1b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netbox-documentation" -version = "0.6.1" +version = "0.7.0" description = "Integrated Markdown wiki and office document importer for NetBox" readme = "README.md" requires-python = ">=3.10"