feat: Excel-Import glätten und Dokumentlistenaktionen korrigieren

This commit is contained in:
2026-07-22 14:34:14 +02:00
parent 6df5c101c5
commit eb503df94b
11 changed files with 129 additions and 36 deletions
+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.5.0"
version = "0.5.1"
author = "LKE"
base_url = "documentation"
min_version = "4.0.0"
+30 -6
View File
@@ -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):
+26 -13
View File
@@ -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; }
+2
View File
@@ -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"),
+21 -8
View File
@@ -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; "