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
|
- 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
|
- 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
|
- 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
|
||||||
@@ -23,7 +24,7 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit
|
|||||||
|
|
||||||
## Kompatibilität
|
## 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
|
## 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.
|
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
|
## REST-API
|
||||||
|
|
||||||
|
|||||||
@@ -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.5.0"
|
version = "0.5.1"
|
||||||
author = "LKE"
|
author = "LKE"
|
||||||
base_url = "documentation"
|
base_url = "documentation"
|
||||||
min_version = "4.0.0"
|
min_version = "4.0.0"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from django import forms
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django.utils.html import format_html
|
||||||
from utilities.forms import get_field_value
|
from utilities.forms import get_field_value
|
||||||
from utilities.forms.fields import (
|
from utilities.forms.fields import (
|
||||||
ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField,
|
ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField,
|
||||||
@@ -13,6 +14,14 @@ from dcim.models import Device
|
|||||||
from .models import Document, DocumentAssignment, DocumentCategory
|
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():
|
def allowed_content_types():
|
||||||
labels = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get(
|
labels = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get(
|
||||||
"allowed_object_types", []
|
"allowed_object_types", []
|
||||||
@@ -105,17 +114,32 @@ class DocumentCategoryForm(NetBoxModelForm):
|
|||||||
|
|
||||||
class ImportForm(forms.Form):
|
class ImportForm(forms.Form):
|
||||||
file = forms.FileField(label="Word-, Excel- oder PDF-Datei")
|
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")
|
title = forms.CharField(max_length=200, required=False, label=help_label(
|
||||||
append = forms.BooleanField(required=False, initial=False, label="An bestehende Dokumentation anhängen")
|
"Titel", "Leer lassen, um den Dateinamen als Titel zu verwenden. Beim Mehrblattimport werden die Blattnamen verwendet."
|
||||||
document = forms.ModelChoiceField(queryset=Document.objects.all(), required=False, label="Bestehende Dokumentation")
|
))
|
||||||
|
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(
|
excel_sheets_as_documents = forms.BooleanField(
|
||||||
required=False,
|
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(
|
category = DynamicModelChoiceField(
|
||||||
queryset=DocumentCategory.objects.all(), required=False,
|
queryset=DocumentCategory.objects.all(), required=False,
|
||||||
label="Zielordner / Kategorie",
|
label=help_label("Zielordner / Kategorie", "Für den Excel-Mehrblattimport verpflichtend."),
|
||||||
help_text="Für den Mehrblattimport verpflichtend.",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, *args, user=None, **kwargs):
|
def __init__(self, *args, user=None, **kwargs):
|
||||||
|
|||||||
@@ -29,14 +29,14 @@ class ExcelSheetResult:
|
|||||||
images: list[ImportedImage] = field(default_factory=list)
|
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()
|
suffix = Path(upload.name).suffix.lower()
|
||||||
content = upload.read()
|
content = upload.read()
|
||||||
upload.seek(0)
|
upload.seek(0)
|
||||||
if suffix == ".docx":
|
if suffix == ".docx":
|
||||||
return _docx(content)
|
return _docx(content)
|
||||||
if suffix in {".xlsx", ".xlsm"}:
|
if suffix in {".xlsx", ".xlsm"}:
|
||||||
return _xlsx(content)
|
return _xlsx(content, flatten=flatten_excel)
|
||||||
if suffix == ".pdf":
|
if suffix == ".pdf":
|
||||||
return _pdf(content)
|
return _pdf(content)
|
||||||
if suffix in {".md", ".txt"}:
|
if suffix in {".md", ".txt"}:
|
||||||
@@ -53,9 +53,28 @@ def _docx(content):
|
|||||||
return ImportResult(result.value.strip(), warnings)
|
return ImportResult(result.value.strip(), warnings)
|
||||||
|
|
||||||
|
|
||||||
def _xlsx(content):
|
def _render_excel_rows(rows, flatten=False):
|
||||||
from openpyxl import load_workbook
|
|
||||||
from tabulate import tabulate
|
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)
|
workbook = load_workbook(BytesIO(content), read_only=True, data_only=True)
|
||||||
sections = []
|
sections = []
|
||||||
for sheet in workbook.worksheets:
|
for sheet in workbook.worksheets:
|
||||||
@@ -64,20 +83,16 @@ def _xlsx(content):
|
|||||||
rows.pop()
|
rows.pop()
|
||||||
if not rows:
|
if not rows:
|
||||||
continue
|
continue
|
||||||
width = max(len(row) for row in rows)
|
sections.append(f"## {sheet.title}\n\n{_render_excel_rows(rows, flatten)}")
|
||||||
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')}")
|
|
||||||
if not sections:
|
if not sections:
|
||||||
raise ImportFailure("Die Arbeitsmappe enthält keine Daten.")
|
raise ImportFailure("Die Arbeitsmappe enthält keine Daten.")
|
||||||
return ImportResult("\n\n".join(sections))
|
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."""
|
"""Convert each non-empty worksheet into an individual document payload."""
|
||||||
from openpyxl import load_workbook
|
from openpyxl import load_workbook
|
||||||
from openpyxl.utils import get_column_letter
|
from openpyxl.utils import get_column_letter
|
||||||
from tabulate import tabulate
|
|
||||||
|
|
||||||
suffix = Path(upload.name).suffix.lower()
|
suffix = Path(upload.name).suffix.lower()
|
||||||
if suffix not in {".xlsx", ".xlsm"}:
|
if suffix not in {".xlsx", ".xlsm"}:
|
||||||
@@ -111,9 +126,7 @@ def import_excel_sheets(upload) -> list[ExcelSheetResult]:
|
|||||||
if not rows and not images:
|
if not rows and not images:
|
||||||
continue
|
continue
|
||||||
if rows:
|
if rows:
|
||||||
width = max(len(row) for row in rows)
|
markdown = _render_excel_rows(rows, flatten)
|
||||||
rows = [row + [""] * (width - len(row)) for row in rows]
|
|
||||||
markdown = tabulate(rows[1:], headers=rows[0], tablefmt="github")
|
|
||||||
else:
|
else:
|
||||||
markdown = ""
|
markdown = ""
|
||||||
results.append(ExcelSheetResult(title=sheet.title, markdown=markdown, images=images))
|
results.append(ExcelSheetResult(title=sheet.title, markdown=markdown, images=images))
|
||||||
|
|||||||
@@ -1,5 +1,28 @@
|
|||||||
{% extends 'generic/object.html' %}
|
{% extends 'generic/object.html' %}
|
||||||
{% load helpers %}
|
{% 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 %}
|
{% block extra_controls %}
|
||||||
<a href="{% url 'plugins:netbox_documentation:document_print' pk=object.pk %}" target="_blank" class="btn btn-outline-secondary">
|
<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
|
<i class="mdi mdi-printer"></i> Drucken / PDF
|
||||||
@@ -9,7 +32,7 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-md-9">
|
<div class="col col-md-9">
|
||||||
{% if object.summary %}<p class="lead">{{ object.summary }}</p>{% endif %}
|
{% 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>
|
||||||
<div class="col col-md-3">
|
<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 %}
|
{% 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>
|
<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">
|
<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.
|
Importhinweise
|
||||||
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.
|
<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>
|
||||||
PDF benötigt eine echte Textebene; Scan-OCR ist nicht enthalten.
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">Importieren</button>
|
<button type="submit" class="btn btn-primary">Importieren</button>
|
||||||
</form></div>
|
</form></div>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
.summary { color: #4b5563; font-size: 13pt; margin: 0 0 1rem; }
|
.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; }
|
.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 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 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 pre, .content code { white-space: pre-wrap; overflow-wrap: anywhere; font-family: Consolas, monospace; }
|
||||||
.content a { color: #174ea6; overflow-wrap: anywhere; }
|
.content a { color: #174ea6; overflow-wrap: anywhere; }
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ urlpatterns = (
|
|||||||
path("", views.DocumentListView.as_view(), name="document_list"),
|
path("", views.DocumentListView.as_view(), name="document_list"),
|
||||||
path("documents/add/", views.DocumentEditView.as_view(), name="document_add"),
|
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_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("archive/", views.DocumentArchiveView.as_view(), name="document_archive"),
|
||||||
path("documents/<int:pk>/", views.DocumentView.as_view(), name="document"),
|
path("documents/<int:pk>/", views.DocumentView.as_view(), name="document"),
|
||||||
path("documents/<int:pk>/print/", views.DocumentPrintView.as_view(), name="document_print"),
|
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.utils.text import slugify
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
|
from netbox.object_actions import AddObject, BulkDelete, BulkImport
|
||||||
from .filtersets import DocumentFilterSet, AssignmentFilterSet, DocumentCategoryFilterSet
|
from .filtersets import DocumentFilterSet, AssignmentFilterSet, DocumentCategoryFilterSet
|
||||||
from .forms import (
|
from .forms import (
|
||||||
ArchiveExportForm, ArchiveImportForm, AssignmentForm, DocumentCategoryForm,
|
ArchiveExportForm, ArchiveImportForm, AssignmentForm, DocumentCategoryForm,
|
||||||
@@ -41,6 +42,7 @@ class DocumentListView(generic.ObjectListView):
|
|||||||
queryset = Document.objects.prefetch_related("assignments")
|
queryset = Document.objects.prefetch_related("assignments")
|
||||||
table = DocumentTable
|
table = DocumentTable
|
||||||
filterset = DocumentFilterSet
|
filterset = DocumentFilterSet
|
||||||
|
actions = (AddObject, BulkImport, BulkDelete)
|
||||||
|
|
||||||
|
|
||||||
class DocumentView(generic.ObjectView):
|
class DocumentView(generic.ObjectView):
|
||||||
@@ -69,6 +71,12 @@ class DocumentDeleteView(generic.ObjectDeleteView):
|
|||||||
queryset = Document.objects.all()
|
queryset = Document.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentBulkDeleteView(generic.BulkDeleteView):
|
||||||
|
queryset = Document.objects.all()
|
||||||
|
table = DocumentTable
|
||||||
|
filterset = DocumentFilterSet
|
||||||
|
|
||||||
|
|
||||||
class DocumentCategoryListView(generic.ObjectListView):
|
class DocumentCategoryListView(generic.ObjectListView):
|
||||||
queryset = DocumentCategory.objects.select_related("parent").prefetch_related("documents")
|
queryset = DocumentCategory.objects.select_related("parent").prefetch_related("documents")
|
||||||
table = DocumentCategoryTable
|
table = DocumentCategoryTable
|
||||||
@@ -118,7 +126,7 @@ class DocumentImportView(PermissionRequiredMixin, View):
|
|||||||
if form.cleaned_data.get("excel_sheets_as_documents"):
|
if form.cleaned_data.get("excel_sheets_as_documents"):
|
||||||
return self._import_excel_sheets(request, form, upload)
|
return self._import_excel_sheets(request, form, upload)
|
||||||
try:
|
try:
|
||||||
result = import_document(upload)
|
result = import_document(upload, flatten_excel=form.cleaned_data.get("flatten_excel_tables", False))
|
||||||
except (ImportFailure, Exception) as exc:
|
except (ImportFailure, Exception) as exc:
|
||||||
# Known conversion/library errors are presented without exposing a traceback.
|
# Known conversion/library errors are presented without exposing a traceback.
|
||||||
form.add_error("file", f"Import fehlgeschlagen: {exc}")
|
form.add_error("file", f"Import fehlgeschlagen: {exc}")
|
||||||
@@ -155,12 +163,15 @@ class DocumentImportView(PermissionRequiredMixin, View):
|
|||||||
def _import_excel_sheets(self, request, form, upload):
|
def _import_excel_sheets(self, request, form, upload):
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
try:
|
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:
|
except (ImportFailure, Exception) as exc:
|
||||||
form.add_error("file", f"Excel-Mehrblattimport fehlgeschlagen: {exc}")
|
form.add_error("file", f"Excel-Mehrblattimport fehlgeschlagen: {exc}")
|
||||||
return render(request, self.template_name, {"form": form})
|
return render(request, self.template_name, {"form": form})
|
||||||
category = form.cleaned_data["category"]
|
category = form.cleaned_data["category"]
|
||||||
created_documents = []
|
created_documents = []
|
||||||
|
upload.seek(0)
|
||||||
|
original_content = upload.read()
|
||||||
|
upload.seek(0)
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
for sheet in sheets:
|
for sheet in sheets:
|
||||||
base_slug = slugify(sheet.title)[:180] or "arbeitsblatt"
|
base_slug = slugify(sheet.title)[:180] or "arbeitsblatt"
|
||||||
@@ -188,12 +199,14 @@ class DocumentImportView(PermissionRequiredMixin, View):
|
|||||||
document.save(update_fields=("body", "last_updated"))
|
document.save(update_fields=("body", "last_updated"))
|
||||||
created_documents.append(document)
|
created_documents.append(document)
|
||||||
keep = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("keep_imported_file", True)
|
keep = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("keep_imported_file", True)
|
||||||
if keep and created_documents:
|
if keep:
|
||||||
upload.seek(0)
|
for document in created_documents:
|
||||||
DocumentAttachment.objects.create(
|
attachment = DocumentAttachment(
|
||||||
document=created_documents[0], file=upload, original_name=upload.name,
|
document=document, original_name=upload.name,
|
||||||
content_type=upload.content_type or "", size=upload.size,
|
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)
|
image_count = sum(len(sheet.images) for sheet in sheets)
|
||||||
messages.success(request, (
|
messages.success(request, (
|
||||||
f"{len(created_documents)} Arbeitsblätter als Dokumentationen in „{category}“ importiert; "
|
f"{len(created_documents)} Arbeitsblätter als Dokumentationen in „{category}“ importiert; "
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "netbox-documentation"
|
name = "netbox-documentation"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
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"
|
||||||
|
|||||||
@@ -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 [result.title for result in results] == ["Server", "Kontakte"]
|
||||||
assert "web01" in results[0].markdown
|
assert "web01" in results[0].markdown
|
||||||
assert "Leitstelle" in results[1].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