feat: Excel-Import glätten und Dokumentlistenaktionen korrigieren
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
'{} <span class="mdi mdi-help-circle-outline text-muted" title="{}" '
|
||||
'data-bs-toggle="tooltip" aria-label="{}"></span>',
|
||||
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):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% block head %}
|
||||
{{ block.super }}
|
||||
<style>
|
||||
.documentation-content { overflow-x: auto; }
|
||||
.documentation-content table {
|
||||
width: auto !important;
|
||||
max-width: 100%;
|
||||
table-layout: auto;
|
||||
font-size: .875rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.documentation-content th,
|
||||
.documentation-content td {
|
||||
min-width: 3rem;
|
||||
max-width: 22rem;
|
||||
padding: .25rem .4rem !important;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: normal;
|
||||
vertical-align: top;
|
||||
}
|
||||
.documentation-content p { max-width: 90rem; }
|
||||
</style>
|
||||
{% endblock head %}
|
||||
{% block extra_controls %}
|
||||
<a href="{% url 'plugins:netbox_documentation:document_print' pk=object.pk %}" target="_blank" class="btn btn-outline-secondary">
|
||||
<i class="mdi mdi-printer"></i> Drucken / PDF
|
||||
@@ -9,7 +32,7 @@
|
||||
<div class="row">
|
||||
<div class="col col-md-9">
|
||||
{% if object.summary %}<p class="lead">{{ object.summary }}</p>{% endif %}
|
||||
<div class="card"><div class="card-body rendered-markdown">{{ object.rendered_body|safe }}</div></div>
|
||||
<div class="card"><div class="card-body rendered-markdown documentation-content">{{ object.rendered_body|safe }}</div></div>
|
||||
</div>
|
||||
<div class="col col-md-3">
|
||||
{% if object.category %}<div class="card mb-3"><h5 class="card-header">Ordner</h5><a class="list-group-item list-group-item-action" href="{{ object.category.get_absolute_url }}"><i class="mdi mdi-folder"></i> {{ object.category }}</a></div>{% endif %}
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
<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. 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.
|
||||
Importhinweise
|
||||
<span class="mdi mdi-help-circle-outline" data-bs-toggle="tooltip" title="DOCX übernimmt Überschriften, Listen und Tabellen. Beim normalen XLSX-Import werden alle Blätter zusammengeführt. Der Mehrblattimport erzeugt je Blatt eine Dokumentation im Zielordner. PDF benötigt eine echte Textebene; Scan-OCR ist nicht enthalten." aria-label="Importhinweise"></span>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Importieren</button>
|
||||
</form></div>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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/<int:pk>/", views.DocumentView.as_view(), name="document"),
|
||||
path("documents/<int:pk>/print/", views.DocumentPrintView.as_view(), name="document_print"),
|
||||
|
||||
@@ -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; "
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user