feat: Excel-Mehrblattimport als einzelne Dokumentationen ergänzen
This commit is contained in:
@@ -12,6 +12,7 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit
|
||||
- Formatierte Word-Inhalte inklusive Tabellen und unterstützten Zwischenablage-Bildern einfügen
|
||||
- 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
|
||||
- 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
|
||||
@@ -22,7 +23,7 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit
|
||||
|
||||
## Kompatibilität
|
||||
|
||||
Die Version `0.4.2` 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.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
|
||||
|
||||
@@ -121,6 +122,8 @@ 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.
|
||||
|
||||
## REST-API
|
||||
|
||||
Nach Aktivierung stehen die üblichen NetBox-Plugin-Endpunkte bereit:
|
||||
|
||||
@@ -5,7 +5,7 @@ class DocumentationConfig(PluginConfig):
|
||||
name = "netbox_documentation"
|
||||
verbose_name = "NetBox Dokumentation"
|
||||
description = "Wiki und Office-Dokumentation direkt in NetBox"
|
||||
version = "0.4.2"
|
||||
version = "0.5.0"
|
||||
author = "LKE"
|
||||
base_url = "documentation"
|
||||
min_version = "4.0.0"
|
||||
|
||||
@@ -108,12 +108,34 @@ class ImportForm(forms.Form):
|
||||
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")
|
||||
excel_sheets_as_documents = forms.BooleanField(
|
||||
required=False,
|
||||
label="Excel mit mehreren Arbeitsblättern – je Blatt eine Dokumentation",
|
||||
)
|
||||
category = DynamicModelChoiceField(
|
||||
queryset=DocumentCategory.objects.all(), required=False,
|
||||
label="Zielordner / Kategorie",
|
||||
help_text="Für den Mehrblattimport verpflichtend.",
|
||||
)
|
||||
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if user is not None:
|
||||
self.fields["document"].queryset = Document.objects.restrict(user, "change")
|
||||
self.fields["category"].queryset = DocumentCategory.objects.restrict(user, "view")
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
if data.get("append") and not data.get("document"):
|
||||
self.add_error("document", "Zum Anhängen muss eine Dokumentation gewählt werden.")
|
||||
upload = data.get("file")
|
||||
if data.get("excel_sheets_as_documents"):
|
||||
if upload and not upload.name.lower().endswith((".xlsx", ".xlsm")):
|
||||
self.add_error("file", "Der Mehrblattimport benötigt eine XLSX- oder XLSM-Datei.")
|
||||
if not data.get("category"):
|
||||
self.add_error("category", "Bitte einen Zielordner für die erzeugten Dokumentationen auswählen.")
|
||||
if data.get("append"):
|
||||
self.add_error("append", "Mehrblattimport und Anhängen können nicht kombiniert werden.")
|
||||
limit = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("max_import_size_mb", 25)
|
||||
if upload and upload.size > limit * 1024 * 1024:
|
||||
self.add_error("file", f"Die Datei ist größer als {limit} MB.")
|
||||
|
||||
@@ -14,6 +14,21 @@ class ImportResult:
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportedImage:
|
||||
name: str
|
||||
content: bytes
|
||||
content_type: str
|
||||
cell: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExcelSheetResult:
|
||||
title: str
|
||||
markdown: str
|
||||
images: list[ImportedImage] = field(default_factory=list)
|
||||
|
||||
|
||||
def import_document(upload) -> ImportResult:
|
||||
suffix = Path(upload.name).suffix.lower()
|
||||
content = upload.read()
|
||||
@@ -58,6 +73,60 @@ def _xlsx(content):
|
||||
return ImportResult("\n\n".join(sections))
|
||||
|
||||
|
||||
def import_excel_sheets(upload) -> 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"}:
|
||||
raise ImportFailure("Der Mehrblattimport unterstützt XLSX- und XLSM-Dateien.")
|
||||
content = upload.read()
|
||||
upload.seek(0)
|
||||
workbook = load_workbook(BytesIO(content), read_only=False, data_only=True)
|
||||
results = []
|
||||
for sheet in workbook.worksheets:
|
||||
rows = [["" if cell is None else str(cell) for cell in row] for row in sheet.iter_rows(values_only=True)]
|
||||
while rows and not any(value for value in rows[-1]):
|
||||
rows.pop()
|
||||
images = []
|
||||
for number, image in enumerate(getattr(sheet, "_images", ()), 1):
|
||||
image_format = (getattr(image, "format", None) or "png").lower()
|
||||
if image_format == "jpg":
|
||||
image_format = "jpeg"
|
||||
if image_format not in {"png", "jpeg", "gif", "webp"}:
|
||||
continue
|
||||
anchor = getattr(image, "anchor", None)
|
||||
marker = getattr(anchor, "_from", None)
|
||||
cell = f"{get_column_letter(marker.col + 1)}{marker.row + 1}" if marker else ""
|
||||
try:
|
||||
image_content = image._data()
|
||||
except (AttributeError, OSError, ValueError):
|
||||
continue
|
||||
images.append(ImportedImage(
|
||||
name=f"{slugify_filename(sheet.title)}-{number}.{image_format if image_format != 'jpeg' else 'jpg'}",
|
||||
content=image_content, content_type=f"image/{image_format}", cell=cell,
|
||||
))
|
||||
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")
|
||||
else:
|
||||
markdown = ""
|
||||
results.append(ExcelSheetResult(title=sheet.title, markdown=markdown, images=images))
|
||||
if not results:
|
||||
raise ImportFailure("Die Arbeitsmappe enthält keine Daten oder unterstützten Bilder.")
|
||||
return results
|
||||
|
||||
|
||||
def slugify_filename(value):
|
||||
value = re.sub(r"[^A-Za-z0-9._-]+", "-", value).strip("-.")
|
||||
return value[:80] or "arbeitsblatt"
|
||||
|
||||
|
||||
def _pdf(content):
|
||||
from pypdf import PdfReader
|
||||
reader = PdfReader(BytesIO(content))
|
||||
@@ -70,4 +139,3 @@ def _pdf(content):
|
||||
if not pages:
|
||||
raise ImportFailure("Das PDF enthält keinen extrahierbaren Text. Für Scans ist OCR erforderlich.")
|
||||
return ImportResult("\n\n".join(pages), ["PDF-Layout und Bilder können nicht vollständig übernommen werden."])
|
||||
|
||||
|
||||
@@ -5,9 +5,12 @@
|
||||
<div class="row justify-content-center"><div class="col col-md-8"><div class="card">
|
||||
<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. XLSX wird je Tabellenblatt zu einer Markdown-Tabelle. PDF benötigt eine echte Textebene; Scan-OCR ist nicht enthalten.</div>
|
||||
<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.
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Importieren</button>
|
||||
</form></div>
|
||||
</div></div></div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from .forms import (
|
||||
DocumentForm, ImportForm,
|
||||
)
|
||||
from .archive import ArchiveFailure, documents_for_categories, export_documents, import_archive
|
||||
from .importers import ImportFailure, import_document
|
||||
from .importers import ImportFailure, import_document, import_excel_sheets
|
||||
from .models import Document, DocumentAssignment, DocumentAttachment, DocumentCategory
|
||||
from .tables import DocumentTable, AssignmentTable, DocumentCategoryTable
|
||||
|
||||
@@ -108,13 +108,15 @@ class DocumentImportView(PermissionRequiredMixin, View):
|
||||
template_name = "netbox_documentation/document_import.html"
|
||||
|
||||
def get(self, request):
|
||||
return render(request, self.template_name, {"form": ImportForm()})
|
||||
return render(request, self.template_name, {"form": ImportForm(user=request.user)})
|
||||
|
||||
def post(self, request):
|
||||
form = ImportForm(request.POST, request.FILES)
|
||||
form = ImportForm(request.POST, request.FILES, user=request.user)
|
||||
if not form.is_valid():
|
||||
return render(request, self.template_name, {"form": form})
|
||||
upload = form.cleaned_data["file"]
|
||||
if form.cleaned_data.get("excel_sheets_as_documents"):
|
||||
return self._import_excel_sheets(request, form, upload)
|
||||
try:
|
||||
result = import_document(upload)
|
||||
except (ImportFailure, Exception) as exc:
|
||||
@@ -150,6 +152,55 @@ class DocumentImportView(PermissionRequiredMixin, View):
|
||||
messages.success(request, f"{upload.name} wurde importiert.")
|
||||
return redirect(document)
|
||||
|
||||
def _import_excel_sheets(self, request, form, upload):
|
||||
from django.core.files.base import ContentFile
|
||||
try:
|
||||
sheets = import_excel_sheets(upload)
|
||||
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 = []
|
||||
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"")
|
||||
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 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,
|
||||
)
|
||||
image_count = sum(len(sheet.images) for sheet in sheets)
|
||||
messages.success(request, (
|
||||
f"{len(created_documents)} Arbeitsblätter als Dokumentationen in „{category}“ importiert; "
|
||||
f"{image_count} Bilder übernommen."
|
||||
))
|
||||
return redirect("plugins:netbox_documentation:document_list")
|
||||
|
||||
|
||||
class DocumentMediaUploadView(PermissionRequiredMixin, View):
|
||||
permission_required = "netbox_documentation.change_document"
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "netbox-documentation"
|
||||
version = "0.4.2"
|
||||
version = "0.5.0"
|
||||
description = "Integrated Markdown wiki and office document importer for NetBox"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -15,6 +15,7 @@ sys.modules[spec.name] = importers
|
||||
spec.loader.exec_module(importers)
|
||||
ImportFailure = importers.ImportFailure
|
||||
import_document = importers.import_document
|
||||
import_excel_sheets = importers.import_excel_sheets
|
||||
|
||||
|
||||
class Upload(BytesIO):
|
||||
@@ -44,3 +45,23 @@ def test_xlsx_imports_sheets_as_tables():
|
||||
def test_rejects_legacy_excel():
|
||||
with pytest.raises(ImportFailure, match="xlsx"):
|
||||
import_document(Upload(b"", "legacy.xls"))
|
||||
|
||||
|
||||
def test_excel_multi_sheet_import_creates_one_result_per_sheet():
|
||||
workbook = Workbook()
|
||||
first = workbook.active
|
||||
first.title = "Server"
|
||||
first.append(["Name", "IP"])
|
||||
first.append(["web01", "10.0.0.1"])
|
||||
second = workbook.create_sheet("Kontakte")
|
||||
second.append(["Name", "Telefon"])
|
||||
second.append(["Leitstelle", "1234"])
|
||||
workbook.create_sheet("Leer")
|
||||
stream = BytesIO()
|
||||
workbook.save(stream)
|
||||
|
||||
results = import_excel_sheets(Upload(stream.getvalue(), "kunde.xlsx"))
|
||||
|
||||
assert [result.title for result in results] == ["Server", "Kontakte"]
|
||||
assert "web01" in results[0].markdown
|
||||
assert "Leitstelle" in results[1].markdown
|
||||
|
||||
Reference in New Issue
Block a user