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
|
- 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
|
- Mehrere Dokumentationen mit Ordnern, Zuordnungen und Anhängen als ZIP exportieren und wieder importieren
|
||||||
- Druckoptimierte A4-Ansicht zum Drucken oder Speichern als PDF
|
- 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
|
- Eine Dokumentation mehreren Objekten zuordnen und umgekehrt
|
||||||
- Unbegrenzt viele Objektzuordnungen pro Dokumentation; nur identische Doppelzuordnungen werden verhindert
|
- 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
|
- 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
|
## 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
|
## 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.
|
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
|
## REST-API
|
||||||
|
|
||||||
Nach Aktivierung stehen die üblichen NetBox-Plugin-Endpunkte bereit:
|
Nach Aktivierung stehen die üblichen NetBox-Plugin-Endpunkte bereit:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ class DocumentationConfig(PluginConfig):
|
|||||||
name = "netbox_documentation"
|
name = "netbox_documentation"
|
||||||
verbose_name = "NetBox Dokumentation"
|
verbose_name = "NetBox Dokumentation"
|
||||||
description = "Wiki und Office-Dokumentation direkt in NetBox"
|
description = "Wiki und Office-Dokumentation direkt in NetBox"
|
||||||
version = "0.4.2"
|
version = "0.5.0"
|
||||||
author = "LKE"
|
author = "LKE"
|
||||||
base_url = "documentation"
|
base_url = "documentation"
|
||||||
min_version = "4.0.0"
|
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")
|
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")
|
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")
|
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):
|
def clean(self):
|
||||||
data = super().clean()
|
data = super().clean()
|
||||||
if data.get("append") and not data.get("document"):
|
if data.get("append") and not data.get("document"):
|
||||||
self.add_error("document", "Zum Anhängen muss eine Dokumentation gewählt werden.")
|
self.add_error("document", "Zum Anhängen muss eine Dokumentation gewählt werden.")
|
||||||
upload = data.get("file")
|
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)
|
limit = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("max_import_size_mb", 25)
|
||||||
if upload and upload.size > limit * 1024 * 1024:
|
if upload and upload.size > limit * 1024 * 1024:
|
||||||
self.add_error("file", f"Die Datei ist größer als {limit} MB.")
|
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)
|
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:
|
def import_document(upload) -> ImportResult:
|
||||||
suffix = Path(upload.name).suffix.lower()
|
suffix = Path(upload.name).suffix.lower()
|
||||||
content = upload.read()
|
content = upload.read()
|
||||||
@@ -58,6 +73,60 @@ def _xlsx(content):
|
|||||||
return ImportResult("\n\n".join(sections))
|
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):
|
def _pdf(content):
|
||||||
from pypdf import PdfReader
|
from pypdf import PdfReader
|
||||||
reader = PdfReader(BytesIO(content))
|
reader = PdfReader(BytesIO(content))
|
||||||
@@ -70,4 +139,3 @@ def _pdf(content):
|
|||||||
if not pages:
|
if not pages:
|
||||||
raise ImportFailure("Das PDF enthält keinen extrahierbaren Text. Für Scans ist OCR erforderlich.")
|
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."])
|
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">
|
<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>
|
<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="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>
|
<button type="submit" class="btn btn-primary">Importieren</button>
|
||||||
</form></div>
|
</form></div>
|
||||||
</div></div></div>
|
</div></div></div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from .forms import (
|
|||||||
DocumentForm, ImportForm,
|
DocumentForm, ImportForm,
|
||||||
)
|
)
|
||||||
from .archive import ArchiveFailure, documents_for_categories, export_documents, import_archive
|
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 .models import Document, DocumentAssignment, DocumentAttachment, DocumentCategory
|
||||||
from .tables import DocumentTable, AssignmentTable, DocumentCategoryTable
|
from .tables import DocumentTable, AssignmentTable, DocumentCategoryTable
|
||||||
|
|
||||||
@@ -108,13 +108,15 @@ class DocumentImportView(PermissionRequiredMixin, View):
|
|||||||
template_name = "netbox_documentation/document_import.html"
|
template_name = "netbox_documentation/document_import.html"
|
||||||
|
|
||||||
def get(self, request):
|
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):
|
def post(self, request):
|
||||||
form = ImportForm(request.POST, request.FILES)
|
form = ImportForm(request.POST, request.FILES, user=request.user)
|
||||||
if not form.is_valid():
|
if not form.is_valid():
|
||||||
return render(request, self.template_name, {"form": form})
|
return render(request, self.template_name, {"form": form})
|
||||||
upload = form.cleaned_data["file"]
|
upload = form.cleaned_data["file"]
|
||||||
|
if form.cleaned_data.get("excel_sheets_as_documents"):
|
||||||
|
return self._import_excel_sheets(request, form, upload)
|
||||||
try:
|
try:
|
||||||
result = import_document(upload)
|
result = import_document(upload)
|
||||||
except (ImportFailure, Exception) as exc:
|
except (ImportFailure, Exception) as exc:
|
||||||
@@ -150,6 +152,55 @@ class DocumentImportView(PermissionRequiredMixin, View):
|
|||||||
messages.success(request, f"{upload.name} wurde importiert.")
|
messages.success(request, f"{upload.name} wurde importiert.")
|
||||||
return redirect(document)
|
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):
|
class DocumentMediaUploadView(PermissionRequiredMixin, View):
|
||||||
permission_required = "netbox_documentation.change_document"
|
permission_required = "netbox_documentation.change_document"
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "netbox-documentation"
|
name = "netbox-documentation"
|
||||||
version = "0.4.2"
|
version = "0.5.0"
|
||||||
description = "Integrated Markdown wiki and office document importer for NetBox"
|
description = "Integrated Markdown wiki and office document importer for NetBox"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ sys.modules[spec.name] = importers
|
|||||||
spec.loader.exec_module(importers)
|
spec.loader.exec_module(importers)
|
||||||
ImportFailure = importers.ImportFailure
|
ImportFailure = importers.ImportFailure
|
||||||
import_document = importers.import_document
|
import_document = importers.import_document
|
||||||
|
import_excel_sheets = importers.import_excel_sheets
|
||||||
|
|
||||||
|
|
||||||
class Upload(BytesIO):
|
class Upload(BytesIO):
|
||||||
@@ -44,3 +45,23 @@ def test_xlsx_imports_sheets_as_tables():
|
|||||||
def test_rejects_legacy_excel():
|
def test_rejects_legacy_excel():
|
||||||
with pytest.raises(ImportFailure, match="xlsx"):
|
with pytest.raises(ImportFailure, match="xlsx"):
|
||||||
import_document(Upload(b"", "legacy.xls"))
|
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