feat: Excel-Import glätten und Dokumentlistenaktionen korrigieren
This commit is contained in:
@@ -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; "
|
||||
|
||||
Reference in New Issue
Block a user