feat: Vorschau und Blattauswahl für Excel-Mehrblattimport ergänzen

This commit is contained in:
2026-07-23 12:58:52 +02:00
parent ae9c39865b
commit df11d0eb05
9 changed files with 320 additions and 48 deletions
+4 -1
View File
@@ -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
+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.6.1"
version = "0.7.0"
author = "LKE"
base_url = "documentation"
min_version = "4.0.0"
+36
View File
@@ -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
)
@@ -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",)},
),
]
+28
View File
@@ -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
@@ -0,0 +1,69 @@
{% extends 'base/layout.html' %}
{% load form_helpers %}
{% block title %}Excel-Importvorschau{% endblock %}
{% block head %}
{{ block.super }}
<style>
.excel-preview-content { max-height: 32rem; overflow: auto; }
.excel-preview-content table { width: max-content; min-width: 100%; border-collapse: collapse; font-size: .825rem; }
.excel-preview-content th, .excel-preview-content td { min-width: 7rem; max-width: 22rem; padding: .3rem .45rem; border: 1px solid var(--tblr-border-color, #adb5bd); vertical-align: top; overflow-wrap: anywhere; }
.excel-preview-content th { background: var(--tblr-bg-surface-secondary, #f1f3f5); }
</style>
{% endblock head %}
{% block content %}
<div class="card mb-4">
<div class="card-body">
<div class="d-flex flex-wrap gap-4">
<div><strong>Datei:</strong> {{ batch.original_name }}</div>
<div><strong>Zielordner:</strong> {{ batch.category|default:"Nicht mehr vorhanden" }}</div>
<div><strong>Glätten:</strong> {% if batch.flatten %}Ja{% else %}Nein{% endif %}</div>
</div>
</div>
</div>
<form method="post">
{% csrf_token %}
{{ formset.management_form }}
{% if formset.non_form_errors %}<div class="alert alert-danger">{{ formset.non_form_errors }}</div>{% endif %}
{% for item in items %}
<div class="card mb-4">
<div class="card-header">
<div class="row align-items-center w-100 g-3">
<div class="col-auto">
{{ item.form.include }}
<label for="{{ item.form.include.id_for_label }}" class="ms-1">Importieren</label>
</div>
<div class="col">
{{ item.form.index }}
<label for="{{ item.form.title.id_for_label }}" class="form-label mb-1">Dokumenttitel</label>
{{ item.form.title }}
{% for error in item.form.title.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
<div class="col-auto text-muted">
{{ item.sheet.images|length }} Bild{{ item.sheet.images|length|pluralize:"er" }}
</div>
</div>
</div>
<div class="card-body p-0">
<details>
<summary class="p-3 fw-bold" style="cursor:pointer">Vorschau der erzeugten Dokumentation anzeigen</summary>
<div class="excel-preview-content border-top p-3 rendered-markdown">
{{ item.preview_html|safe }}
</div>
</details>
</div>
</div>
{% endfor %}
<div class="sticky-actions sticky-actions-footer d-print-none" data-sticky-position="full" data-sticky-when="always">
<div class="btn-list">
<button type="submit" name="action" value="cancel" class="btn btn-outline-secondary">Abbrechen</button>
<button type="submit" name="action" value="confirm" class="btn btn-primary"><i class="mdi mdi-file-import"></i> Ausgewählte Arbeitsblätter importieren</button>
</div>
</div>
</form>
{% endblock content %}
+1
View File
@@ -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/<uuid:token>/", 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/<int:pk>/", views.DocumentView.as_view(), name="document"),
+150 -45
View File
@@ -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"
+1 -1
View File
@@ -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"