Compare commits

...
15 Commits
Author SHA1 Message Date
MrBlake 117ffac210 feat: Mandantenauswahl nach Mandantengruppe filtern 2026-07-29 10:28:52 +02:00
MrBlake 2af5ea997d fix: Mandantenvalidierung mit NetBox 4.6 kompatibel machen 2026-07-29 10:24:44 +02:00
MrBlake 791b40b3c4 feat: Mandantenzuordnung für Dokumente und Ordner ergänzen 2026-07-29 10:17:18 +02:00
MrBlake d20baa3ea3 feat: lokale Bildauswahl im Editor ergänzen 2026-07-24 11:30:33 +02:00
MrBlake b632a5bb04 fix: eingefügte Bilder beim ersten Speichern dauerhaft ablegen 2026-07-23 16:36:07 +02:00
MrBlake 8d6b6a46db feat: gleichnamige Dokumentationen nach Bestätigung aktualisieren 2026-07-23 16:20:14 +02:00
MrBlake 83cac4447a feat: Querformat für Druck- und PDF-Ansicht ergänzen 2026-07-23 16:15:01 +02:00
MrBlake dbf3e7a62a fix: Farben freier Excel-Zellen beim Glätten bewahren 2026-07-23 16:13:38 +02:00
MrBlake 4690e67b32 perf: nur ausgewählte Excel-Blätter vollständig importieren 2026-07-23 16:03:56 +02:00
MrBlake 214502277f fix: Tabellenfarben beim geglätteten Excel-Import erhalten 2026-07-23 15:57:28 +02:00
MrBlake 5e17e9ecdb fix: Excel-Tabellenfarben in Importvorschau darstellen 2026-07-23 15:53:15 +02:00
MrBlake 3f2e7ca667 perf: Mehrblattimport großer Excel-Dateien optimieren 2026-07-23 15:48:07 +02:00
MrBlake e4f4cd7c14 fix: Excel-Zellfarben beim Dokumentimport erhalten 2026-07-23 15:44:58 +02:00
MrBlake 8a2ccfd516 refactor: Fokusmodus aus Dokumenteditor entfernen 2026-07-23 14:39:51 +02:00
MrBlake 6ef3a593b9 feat: Farbeimer für Tabellenzellen ergänzen 2026-07-23 14:38:57 +02:00
20 changed files with 918 additions and 127 deletions
+3 -2
View File
@@ -7,7 +7,8 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit
- Dokumentationen direkt in NetBox schreiben und als sicher bereinigten Rich Text anzeigen - Dokumentationen direkt in NetBox schreiben und als sicher bereinigten Rich Text anzeigen
- Word-ähnlicher WYSIWYG-Editor mit Schriftarten, Schriftgrößen, Farben, Ausrichtung und Tabellen - Word-ähnlicher WYSIWYG-Editor mit Schriftarten, Schriftgrößen, Farben, Ausrichtung und Tabellen
- Hierarchische Ordner und Unterordner für eine BookStack-ähnliche Struktur - Hierarchische Ordner und Unterordner für eine BookStack-ähnliche Struktur
- Breite Editoransicht und Fokusmodus innerhalb des NetBox-Layouts - Breite Editoransicht und TinyMCE-Vollbildmodus
- Dokumentationen und Ordner optional Mandanten und Mandantengruppen zuweisen
- Bilder direkt vom eigenen Gerät in die Dokumentation hochladen - Bilder direkt vom eigenen Gerät in die Dokumentation hochladen
- 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
@@ -28,7 +29,7 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit
## Kompatibilität ## Kompatibilität
Die Version `0.7.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.9.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.
## Installation ## Installation
+1 -1
View File
@@ -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.7.2" version = "0.9.2"
author = "LKE" author = "LKE"
base_url = "documentation" base_url = "documentation"
min_version = "4.0.0" min_version = "4.0.0"
+2 -2
View File
@@ -8,7 +8,7 @@ class DocumentSerializer(NetBoxModelSerializer):
class Meta: class Meta:
model = Document model = Document
fields = ("id", "url", "display", "title", "slug", "category", "summary", "body", "body_format", "is_published", "tags", "created", "last_updated") fields = ("id", "url", "display", "title", "slug", "category", "tenant_group", "tenant", "summary", "body", "body_format", "is_published", "tags", "created", "last_updated")
class DocumentAssignmentSerializer(NetBoxModelSerializer): class DocumentAssignmentSerializer(NetBoxModelSerializer):
@@ -24,7 +24,7 @@ class DocumentCategorySerializer(NetBoxModelSerializer):
class Meta: class Meta:
model = DocumentCategory model = DocumentCategory
fields = ("id", "url", "display", "name", "slug", "parent", "description", "tags", "created", "last_updated") fields = ("id", "url", "display", "name", "slug", "parent", "tenant_group", "tenant", "description", "tags", "created", "last_updated")
class DocumentAttachmentSerializer(NetBoxModelSerializer): class DocumentAttachmentSerializer(NetBoxModelSerializer):
+32 -4
View File
@@ -7,6 +7,7 @@ from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import transaction from django.db import transaction
from django.utils.text import slugify from django.utils.text import slugify
from tenancy.models import Tenant, TenantGroup
from .models import Document, DocumentAssignment, DocumentAttachment, DocumentCategory from .models import Document, DocumentAssignment, DocumentAttachment, DocumentCategory
@@ -43,11 +44,25 @@ def _category_path(category):
return list(reversed(path)) return list(reversed(path))
def _category_tenancy_path(category):
path, seen = [], set()
while category and category.pk not in seen:
path.append({
"tenant": category.tenant.slug if category.tenant else None,
"tenant_group": category.tenant_group.slug if category.tenant_group else None,
})
seen.add(category.pk)
category = category.parent
return list(reversed(path))
def export_documents(documents): def export_documents(documents):
stream = SpooledTemporaryFile(max_size=10 * 1024 * 1024, mode="w+b") stream = SpooledTemporaryFile(max_size=10 * 1024 * 1024, mode="w+b")
manifest = {"format": ARCHIVE_FORMAT, "version": ARCHIVE_VERSION, "documents": []} manifest = {"format": ARCHIVE_FORMAT, "version": ARCHIVE_VERSION, "documents": []}
with ZipFile(stream, "w", compression=ZIP_DEFLATED, compresslevel=6) as archive: with ZipFile(stream, "w", compression=ZIP_DEFLATED, compresslevel=6) as archive:
for document in documents.prefetch_related("assignments__assigned_object_type", "attachments").select_related("category"): for document in documents.prefetch_related("assignments__assigned_object_type", "attachments").select_related(
"category", "tenant", "tenant_group", "category__tenant", "category__tenant_group",
):
root = f"documents/{document.pk}" root = f"documents/{document.pk}"
extension = "html" if document.body_format == "html" else "md" extension = "html" if document.body_format == "html" else "md"
body_path = f"{root}/content.{extension}" body_path = f"{root}/content.{extension}"
@@ -76,6 +91,9 @@ def export_documents(documents):
"title": document.title, "slug": document.slug, "summary": document.summary, "title": document.title, "slug": document.slug, "summary": document.summary,
"body_format": document.body_format, "is_published": document.is_published, "body_format": document.body_format, "is_published": document.is_published,
"category_path": _category_path(document.category), "body_path": body_path, "category_path": _category_path(document.category), "body_path": body_path,
"category_tenancy": _category_tenancy_path(document.category),
"tenant": document.tenant.slug if document.tenant else None,
"tenant_group": document.tenant_group.slug if document.tenant_group else None,
"assignments": assignments, "attachments": attachments, "assignments": assignments, "attachments": attachments,
}) })
archive.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8")) archive.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8"))
@@ -105,9 +123,10 @@ def _read_json(archive, name):
raise ArchiveFailure("Das Archiv enthält kein gültiges manifest.json.") from exc raise ArchiveFailure("Das Archiv enthält kein gültiges manifest.json.") from exc
def _category_from_path(names): def _category_from_path(names, tenancy_path=None):
parent = None parent = None
for name in names: tenancy_path = tenancy_path or []
for index, name in enumerate(names):
name = str(name).strip()[:100] name = str(name).strip()[:100]
if not name: if not name:
continue continue
@@ -115,6 +134,11 @@ def _category_from_path(names):
category = DocumentCategory.objects.filter(parent=parent, slug=slug).first() category = DocumentCategory.objects.filter(parent=parent, slug=slug).first()
if not category: if not category:
category = DocumentCategory.objects.create(name=name, slug=slug, parent=parent) category = DocumentCategory.objects.create(name=name, slug=slug, parent=parent)
tenancy = tenancy_path[index] if index < len(tenancy_path) and isinstance(tenancy_path[index], dict) else {}
if tenancy:
category.tenant_group = TenantGroup.objects.filter(slug=tenancy.get("tenant_group")).first()
category.tenant = Tenant.objects.filter(slug=tenancy.get("tenant")).first()
category.save(update_fields=("tenant_group", "tenant", "last_updated"))
parent = category parent = category
return parent return parent
@@ -153,7 +177,9 @@ def import_archive(upload, update_existing=False):
raise ArchiveFailure("Ein Dokumentinhalt fehlt oder ist nicht UTF-8-kodiert.") from exc raise ArchiveFailure("Ein Dokumentinhalt fehlt oder ist nicht UTF-8-kodiert.") from exc
source_slug = str(record.get("slug") or record["title"]) source_slug = str(record.get("slug") or record["title"])
document = Document.objects.filter(slug=source_slug).first() if update_existing else None document = Document.objects.filter(slug=source_slug).first() if update_existing else None
category = _category_from_path(record.get("category_path") or []) category = _category_from_path(
record.get("category_path") or [], record.get("category_tenancy") or [],
)
if document: if document:
updated += 1 updated += 1
else: else:
@@ -165,6 +191,8 @@ def import_archive(upload, update_existing=False):
document.body_format = record.get("body_format") if record.get("body_format") in {"html", "markdown"} else "html" document.body_format = record.get("body_format") if record.get("body_format") in {"html", "markdown"} else "html"
document.is_published = bool(record.get("is_published", True)) document.is_published = bool(record.get("is_published", True))
document.category = category document.category = category
document.tenant_group = TenantGroup.objects.filter(slug=record.get("tenant_group")).first()
document.tenant = Tenant.objects.filter(slug=record.get("tenant")).first()
document.save() document.save()
for assignment in record.get("assignments") or []: for assignment in record.get("assignments") or []:
label = f"{assignment.get('app_label')}.{assignment.get('model')}" label = f"{assignment.get('app_label')}.{assignment.get('model')}"
+54
View File
@@ -0,0 +1,54 @@
import base64
from io import BytesIO
import re
class EmbeddedImageError(ValueError):
pass
DATA_IMAGE_RE = re.compile(
r"(?P<prefix>src\s*=\s*(?P<quote>['\"]))"
r"data:(?P<mime>image/(?:png|jpeg|gif|webp));base64,(?P<data>[^'\"]+)"
r"(?P=quote)",
re.IGNORECASE,
)
def _decode(match):
payload = re.sub(r"\s+", "", match.group("data"))
try:
content = base64.b64decode(payload, validate=True)
except (ValueError, TypeError) as exc:
raise EmbeddedImageError("Ein eingefügtes Bild enthält ungültige Base64-Daten.") from exc
try:
from PIL import Image
image = Image.open(BytesIO(content))
image.verify()
except Exception as exc:
raise EmbeddedImageError("Ein eingefügter Inhalt ist keine gültige Bilddatei.") from exc
return match.group("mime").lower(), content
def validate_embedded_images(html, max_total_bytes):
total = 0
for match in DATA_IMAGE_RE.finditer(html or ""):
_, content = _decode(match)
total += len(content)
if total > max_total_bytes:
raise EmbeddedImageError("Die eingefügten Bilder überschreiten die erlaubte Gesamtgröße.")
def store_embedded_images(html, max_total_bytes, store):
total = 0
def replace(match):
nonlocal total
mime, content = _decode(match)
total += len(content)
if total > max_total_bytes:
raise EmbeddedImageError("Die eingefügten Bilder überschreiten die erlaubte Gesamtgröße.")
url = store(mime, content)
return f'{match.group("prefix")}{url}{match.group("quote")}'
return DATA_IMAGE_RE.sub(replace, html or "")
+2 -2
View File
@@ -5,7 +5,7 @@ from .models import Document, DocumentAssignment, DocumentCategory
class DocumentFilterSet(NetBoxModelFilterSet): class DocumentFilterSet(NetBoxModelFilterSet):
class Meta: class Meta:
model = Document model = Document
fields = ("id", "title", "slug", "category_id", "is_published") fields = ("id", "title", "slug", "category_id", "tenant_group_id", "tenant_id", "is_published")
def search(self, queryset, name, value): def search(self, queryset, name, value):
from django.db.models import Q from django.db.models import Q
@@ -21,7 +21,7 @@ class AssignmentFilterSet(NetBoxModelFilterSet):
class DocumentCategoryFilterSet(NetBoxModelFilterSet): class DocumentCategoryFilterSet(NetBoxModelFilterSet):
class Meta: class Meta:
model = DocumentCategory model = DocumentCategory
fields = ("id", "name", "slug", "parent_id") fields = ("id", "name", "slug", "parent_id", "tenant_group_id", "tenant_id")
def search(self, queryset, name, value): def search(self, queryset, name, value):
return queryset.filter(name__icontains=value) return queryset.filter(name__icontains=value)
+74 -5
View File
@@ -1,6 +1,7 @@
from django import forms 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.core.files.base import ContentFile
from django.db.models import Q from django.db.models import Q
from django.utils.html import format_html from django.utils.html import format_html
from utilities.forms import get_field_value from utilities.forms import get_field_value
@@ -11,7 +12,9 @@ from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import HTMXSelect from utilities.forms.widgets import HTMXSelect
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from dcim.models import Device from dcim.models import Device
from .models import Document, DocumentAssignment, DocumentCategory from tenancy.models import Tenant, TenantGroup
from .embedded_images import EmbeddedImageError, store_embedded_images, validate_embedded_images
from .models import Document, DocumentAssignment, DocumentAttachment, DocumentCategory
def help_label(label, description): def help_label(label, description):
@@ -40,15 +43,22 @@ class DocumentForm(NetBoxModelForm):
}), help_text="Formatierter Text mit Tabellen, Farben, Schriftarten und Größen.") }), help_text="Formatierter Text mit Tabellen, Farben, Schriftarten und Größen.")
body_format = forms.CharField(widget=forms.HiddenInput(), initial="html") body_format = forms.CharField(widget=forms.HiddenInput(), initial="html")
category = DynamicModelChoiceField(queryset=DocumentCategory.objects.all(), required=False, label="Ordner") category = DynamicModelChoiceField(queryset=DocumentCategory.objects.all(), required=False, label="Ordner")
tenant_group = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(), required=False, selector=True, label="Mandantengruppe",
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(), required=False, selector=True, label="Mandant",
query_params={"group_id": "$tenant_group"},
)
fieldsets = ( fieldsets = (
FieldSet("title", "slug", "category", "summary", "is_published", "tags", name="Dokumentation"), FieldSet("title", "slug", "category", "tenant_group", "tenant", "summary", "is_published", "tags", name="Dokumentation"),
FieldSet("body", "body_format", name="Inhalt"), FieldSet("body", "body_format", name="Inhalt"),
) )
class Meta: class Meta:
model = Document model = Document
fields = ("title", "slug", "category", "summary", "body", "body_format", "is_published", "tags") fields = ("title", "slug", "category", "tenant_group", "tenant", "summary", "body", "body_format", "is_published", "tags")
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -59,6 +69,47 @@ class DocumentForm(NetBoxModelForm):
self.initial["body"] = markdown(self.instance.body, extensions=("extra", "sane_lists")) self.initial["body"] = markdown(self.instance.body, extensions=("extra", "sane_lists"))
self.initial["body_format"] = "html" self.initial["body_format"] = "html"
def clean_body(self):
body = self.cleaned_data.get("body", "")
limit = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("max_import_size_mb", 25)
try:
validate_embedded_images(body, limit * 1024 * 1024)
except EmbeddedImageError as exc:
raise forms.ValidationError(str(exc)) from exc
return body
def clean(self):
super().clean()
cleaned = self.cleaned_data
tenant, group = cleaned.get("tenant"), cleaned.get("tenant_group")
if tenant and group and tenant.group_id != group.pk:
self.add_error("tenant", "Der Mandant gehört nicht zur ausgewählten Mandantengruppe.")
return cleaned
def save(self, commit=True):
document = super().save(commit=commit)
if not commit or not document.pk or "data:image/" not in (document.body or "").lower():
return document
limit = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("max_import_size_mb", 25)
counter = 0
def save_image(mime, content):
nonlocal counter
counter += 1
extension = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif", "image/webp": "webp"}[mime]
name = f"editor-bild-{document.pk}-{counter}.{extension}"
attachment = DocumentAttachment(
document=document, original_name=name, content_type=mime, size=len(content),
)
attachment.file.save(name, ContentFile(content), save=False)
attachment.save()
return attachment.file.url
document.body = store_embedded_images(document.body, limit * 1024 * 1024, save_image)
document.save(update_fields=("body", "last_updated"))
return document
class AssignmentForm(NetBoxModelForm): class AssignmentForm(NetBoxModelForm):
assigned_object_type = ContentTypeChoiceField(queryset=ContentType.objects.none(), label="Objekttyp", widget=HTMXSelect()) assigned_object_type = ContentTypeChoiceField(queryset=ContentType.objects.none(), label="Objekttyp", widget=HTMXSelect())
@@ -99,18 +150,33 @@ class AssignmentForm(NetBoxModelForm):
class DocumentCategoryForm(NetBoxModelForm): class DocumentCategoryForm(NetBoxModelForm):
slug = SlugField(slug_source="name") slug = SlugField(slug_source="name")
parent = DynamicModelChoiceField(queryset=DocumentCategory.objects.all(), required=False, label="Übergeordneter Ordner") parent = DynamicModelChoiceField(queryset=DocumentCategory.objects.all(), required=False, label="Übergeordneter Ordner")
tenant_group = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(), required=False, selector=True, label="Mandantengruppe",
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(), required=False, selector=True, label="Mandant",
query_params={"group_id": "$tenant_group"},
)
fieldsets = (FieldSet("name", "slug", "parent", "description", "tags", name="Ordner"),) fieldsets = (FieldSet("name", "slug", "parent", "tenant_group", "tenant", "description", "tags", name="Ordner"),)
class Meta: class Meta:
model = DocumentCategory model = DocumentCategory
fields = ("name", "slug", "parent", "description", "tags") fields = ("name", "slug", "parent", "tenant_group", "tenant", "description", "tags")
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if self.instance.pk: if self.instance.pk:
self.fields["parent"].queryset = DocumentCategory.objects.exclude(pk=self.instance.pk) self.fields["parent"].queryset = DocumentCategory.objects.exclude(pk=self.instance.pk)
def clean(self):
super().clean()
cleaned = self.cleaned_data
tenant, group = cleaned.get("tenant"), cleaned.get("tenant_group")
if tenant and group and tenant.group_id != group.pk:
self.add_error("tenant", "Der Mandant gehört nicht zur ausgewählten Mandantengruppe.")
return cleaned
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")
@@ -232,6 +298,9 @@ class BaseExcelSheetSelectionFormSet(forms.BaseFormSet):
indices = [item["index"] for item in selected] indices = [item["index"] for item in selected]
if len(indices) != len(set(indices)): if len(indices) != len(set(indices)):
raise forms.ValidationError("Die Arbeitsblattauswahl ist ungültig.") raise forms.ValidationError("Die Arbeitsblattauswahl ist ungültig.")
titles = [item["title"].strip().casefold() for item in selected]
if len(titles) != len(set(titles)):
raise forms.ValidationError("Ausgewählte Arbeitsblätter dürfen nicht denselben Dokumenttitel haben.")
ExcelSheetSelectionFormSet = forms.formset_factory( ExcelSheetSelectionFormSet = forms.formset_factory(
+243 -32
View File
@@ -1,6 +1,7 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from html import escape
import re import re
@@ -12,6 +13,7 @@ class ImportFailure(ValueError):
class ImportResult: class ImportResult:
markdown: str markdown: str
warnings: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list)
body_format: str = "markdown"
@dataclass @dataclass
@@ -27,6 +29,8 @@ class ExcelSheetResult:
title: str title: str
markdown: str markdown: str
images: list[ImportedImage] = field(default_factory=list) images: list[ImportedImage] = field(default_factory=list)
body_format: str = "markdown"
source_index: int = 0
def import_document(upload, flatten_excel=False) -> ImportResult: def import_document(upload, flatten_excel=False) -> ImportResult:
@@ -80,7 +84,7 @@ def _escape_markdown_text(value):
def _xlsx(content, flatten=False): def _xlsx(content, flatten=False):
from openpyxl import load_workbook from openpyxl import load_workbook
workbook = load_workbook(BytesIO(content), read_only=not flatten, data_only=True) workbook = load_workbook(BytesIO(content), read_only=False, data_only=True)
sections = [] sections = []
for sheet in workbook.worksheets: 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)] rows = [["" if cell is None else str(cell) for cell in row] for row in sheet.iter_rows(values_only=True)]
@@ -88,14 +92,17 @@ def _xlsx(content, flatten=False):
rows.pop() rows.pop()
if not rows: if not rows:
continue continue
rendered = _render_flattened_worksheet(sheet) if flatten else _render_excel_rows(rows) rendered = _render_flattened_worksheet(sheet, workbook) if flatten else _render_excel_worksheet_html(sheet, workbook)
sections.append(f"## {sheet.title}\n\n{rendered}") sections.append(f"<h2>{escape(sheet.title)}</h2>\n{rendered}")
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), body_format="html")
def import_excel_sheets(upload, flatten=False) -> list[ExcelSheetResult]: def import_excel_sheets(
upload, flatten=False, *, metadata_only=False, preview_max_rows=None,
include_image_data=True, selected_indexes=None,
) -> 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
@@ -103,14 +110,13 @@ def import_excel_sheets(upload, flatten=False) -> list[ExcelSheetResult]:
suffix = Path(upload.name).suffix.lower() suffix = Path(upload.name).suffix.lower()
if suffix not in {".xlsx", ".xlsm"}: if suffix not in {".xlsx", ".xlsm"}:
raise ImportFailure("Der Mehrblattimport unterstützt XLSX- und XLSM-Dateien.") raise ImportFailure("Der Mehrblattimport unterstützt XLSX- und XLSM-Dateien.")
content = upload.read()
upload.seek(0) upload.seek(0)
workbook = load_workbook(BytesIO(content), read_only=False, data_only=True) workbook = load_workbook(upload, read_only=False, data_only=True)
results = [] results = []
for sheet in workbook.worksheets: selected_indexes = set(selected_indexes) if selected_indexes is not None else None
rows = [["" if cell is None else str(cell) for cell in row] for row in sheet.iter_rows(values_only=True)] for source_index, sheet in enumerate(workbook.worksheets):
while rows and not any(value for value in rows[-1]): if selected_indexes is not None and source_index not in selected_indexes:
rows.pop() continue
images = [] images = []
for number, image in enumerate(getattr(sheet, "_images", ()), 1): for number, image in enumerate(getattr(sheet, "_images", ()), 1):
image_format = (getattr(image, "format", None) or "png").lower() image_format = (getattr(image, "format", None) or "png").lower()
@@ -121,32 +127,232 @@ def import_excel_sheets(upload, flatten=False) -> list[ExcelSheetResult]:
anchor = getattr(image, "anchor", None) anchor = getattr(image, "anchor", None)
marker = getattr(anchor, "_from", None) marker = getattr(anchor, "_from", None)
cell = f"{get_column_letter(marker.col + 1)}{marker.row + 1}" if marker else "" cell = f"{get_column_letter(marker.col + 1)}{marker.row + 1}" if marker else ""
try: image_content = b""
image_content = image._data() if include_image_data and not metadata_only:
except (AttributeError, OSError, ValueError): try:
continue image_content = image._data()
except (AttributeError, OSError, ValueError):
continue
images.append(ImportedImage( images.append(ImportedImage(
name=f"{slugify_filename(sheet.title)}-{number}.{image_format if image_format != 'jpeg' else 'jpg'}", 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, content=image_content, content_type=f"image/{image_format}", cell=cell,
)) ))
if not rows and not images: has_content = _worksheet_has_content(sheet)
if not has_content and not images:
continue continue
if rows: if metadata_only:
markdown = _render_flattened_worksheet(sheet) if flatten else _render_excel_rows(rows) markdown = ""
elif has_content:
markdown = (
_render_flattened_worksheet(sheet, workbook, max_rows=preview_max_rows) if flatten else
_render_excel_worksheet_html(sheet, workbook, max_rows=preview_max_rows)
)
else: else:
markdown = "" markdown = ""
results.append(ExcelSheetResult(title=sheet.title, markdown=markdown, images=images)) results.append(ExcelSheetResult(
title=sheet.title, markdown=markdown, images=images,
body_format="html", source_index=source_index,
))
workbook.close()
upload.seek(0)
if not results: if not results:
raise ImportFailure("Die Arbeitsmappe enthält keine Daten oder unterstützten Bilder.") raise ImportFailure("Die Arbeitsmappe enthält keine Daten oder unterstützten Bilder.")
return results return results
def _worksheet_has_content(sheet):
"""Check for a value without materializing the complete worksheet in memory."""
return any(
cell.value not in (None, "")
for row in sheet.iter_rows()
for cell in row
)
def slugify_filename(value): def slugify_filename(value):
value = re.sub(r"[^A-Za-z0-9._-]+", "-", value).strip("-.") value = re.sub(r"[^A-Za-z0-9._-]+", "-", value).strip("-.")
return value[:80] or "arbeitsblatt" return value[:80] or "arbeitsblatt"
def _render_flattened_worksheet(sheet): def _excel_color(color, workbook):
"""Resolve RGB, indexed and theme-based openpyxl colors to a CSS hex value."""
if not color or not getattr(color, "type", None):
return None
value = None
if color.type == "rgb" and color.rgb:
value = str(color.rgb)[-6:]
elif color.type == "indexed" and color.indexed is not None:
from openpyxl.styles.colors import COLOR_INDEX
index = int(color.indexed)
if 0 <= index < len(COLOR_INDEX):
value = COLOR_INDEX[index][-6:]
elif color.type == "theme" and color.theme is not None and workbook.loaded_theme:
from xml.etree import ElementTree
try:
root = ElementTree.fromstring(workbook.loaded_theme)
scheme = root.find(".//{http://schemas.openxmlformats.org/drawingml/2006/main}clrScheme")
entries = list(scheme) if scheme is not None else []
entry = entries[int(color.theme)]
color_node = next(iter(entry))
value = color_node.attrib.get("val") or color_node.attrib.get("lastClr")
except (ElementTree.ParseError, IndexError, StopIteration, TypeError, ValueError):
value = None
if not value or not re.fullmatch(r"[0-9A-Fa-f]{6}", value):
return None
rgb = [int(value[index:index + 2], 16) for index in (0, 2, 4)]
tint = float(getattr(color, "tint", 0) or 0)
if tint:
rgb = [round(component * (1 + tint) if tint < 0 else component + (255 - component) * tint) for component in rgb]
return "#" + "".join(f"{max(0, min(255, component)):02x}" for component in rgb)
def _excel_cell_style(cell, workbook):
declarations = []
if cell.fill and cell.fill.fill_type:
fill = (
_excel_color(getattr(cell.fill, "fgColor", None), workbook) or
_excel_color(getattr(cell.fill, "bgColor", None), workbook)
)
if not fill and cell.fill.fill_type == "linear":
stops = getattr(cell.fill, "stop", ())
fill = _excel_color(stops[0].color, workbook) if stops else None
if fill:
declarations.append(f"background-color: {fill}")
font_color = _excel_color(cell.font.color, workbook)
if font_color:
declarations.append(f"color: {font_color}")
if cell.font.bold:
declarations.append("font-weight: bold")
if cell.font.italic:
declarations.append("font-style: italic")
if cell.font.sz:
declarations.append(f"font-size: {float(cell.font.sz):g}pt")
if cell.alignment.horizontal in {"left", "center", "right", "justify"}:
declarations.append(f"text-align: {cell.alignment.horizontal}")
if cell.alignment.vertical in {"top", "center", "bottom"}:
declarations.append(f"vertical-align: {'middle' if cell.alignment.vertical == 'center' else cell.alignment.vertical}")
if cell.alignment.wrap_text:
declarations.append("white-space: normal")
return "; ".join(declarations)
def _tint_hex(value, tint):
import colorsys
rgb = tuple(int(value[index:index + 2], 16) / 255 for index in (1, 3, 5))
hue, lightness, saturation = colorsys.rgb_to_hls(*rgb)
lightness = lightness * (1 + tint) if tint < 0 else lightness + (1 - lightness) * tint
tinted = colorsys.hls_to_rgb(hue, max(0, min(1, lightness)), saturation)
return "#" + "".join(f"{round(component * 255):02x}" for component in tinted)
def _excel_table_cell_styles(sheet, workbook, max_row, max_column):
"""Create visual fallbacks for Excel's built-in 'Format as Table' styles."""
from openpyxl.styles import Color
from openpyxl.utils.cell import range_boundaries
styles = {}
for table in sheet.tables.values():
info = table.tableStyleInfo
match = re.fullmatch(r"TableStyle(Light|Medium|Dark)(\d+)", info.name or "") if info else None
if not match:
continue
family, number = match.group(1), int(match.group(2))
palette_slot = (number - 1) % 7
theme_index = 0 if palette_slot == 0 else 3 + palette_slot
base = _excel_color(Color(theme=theme_index), workbook) or "#4472c4"
min_col, min_row, table_max_col, table_max_row = range_boundaries(table.ref)
table_max_col, table_max_row = min(table_max_col, max_column), min(table_max_row, max_row)
if min_col > table_max_col or min_row > table_max_row:
continue
header_fill = _tint_hex(base, 0.55) if family == "Light" else base
header_color = "#000000" if family == "Light" else "#ffffff"
stripe_fill = _tint_hex(base, 0.88 if family == "Light" else 0.82)
for column in range(min_col, table_max_col + 1):
styles[(min_row, column)] = f"background-color: {header_fill}; color: {header_color}; font-weight: bold"
data_start = min_row + (1 if table.headerRowCount else 0)
data_end = table_max_row - (1 if table.totalsRowShown else 0)
if info.showRowStripes:
for row in range(data_start, data_end + 1):
if (row - data_start) % 2 == 1:
for column in range(min_col, table_max_col + 1):
styles[(row, column)] = f"background-color: {stripe_fill}"
if table.totalsRowShown and table_max_row >= min_row:
for column in range(min_col, table_max_col + 1):
styles[(table_max_row, column)] = f"font-weight: bold; border-top: 2px solid {base}"
return styles
def _render_excel_worksheet_html(sheet, workbook, max_rows=None, bounds=None):
"""Render worksheet values and the most relevant visual Excel cell formatting."""
from openpyxl.utils import get_column_letter
if bounds:
start_column, start_row, bound_max_column, bound_max_row = bounds
else:
start_column, start_row, bound_max_column, bound_max_row = 1, 1, sheet.max_column, sheet.max_row
last_row = last_column = 0
for row in sheet.iter_rows(
min_row=start_row, max_row=bound_max_row,
min_col=start_column, max_col=bound_max_column,
):
for cell in row:
if cell.value not in (None, ""):
last_row = max(last_row, cell.row)
last_column = max(last_column, cell.column)
if not last_row:
return "<p><em>Keine Inhalte</em></p>"
max_row = min(last_row, start_row + max_rows - 1) if max_rows else last_row
max_column = last_column
table_styles = _excel_table_cell_styles(sheet, workbook, max_row, max_column)
merged_starts, merged_children = {}, set()
for merged in sheet.merged_cells.ranges:
if merged.min_row > max_row or merged.min_col > max_column:
continue
merged_starts[(merged.min_row, merged.min_col)] = (
min(merged.max_row, max_row) - merged.min_row + 1,
min(merged.max_col, max_column) - merged.min_col + 1,
)
for row in range(merged.min_row, min(merged.max_row, max_row) + 1):
for column in range(merged.min_col, min(merged.max_col, max_column) + 1):
if (row, column) != (merged.min_row, merged.min_col):
merged_children.add((row, column))
columns = []
for column in range(start_column, max_column + 1):
width = sheet.column_dimensions[get_column_letter(column)].width
columns.append(f'<col style="width: {max(3, min(float(width or 13), 80)):.2f}ch">')
output = ['<table class="doc-table-bordered"><colgroup>', *columns, "</colgroup><tbody>"]
for row in range(start_row, max_row + 1):
row_style = ""
if sheet.row_dimensions[row].height:
row_style = f' style="height: {float(sheet.row_dimensions[row].height):g}pt"'
output.append(f"<tr{row_style}>")
for column in range(start_column, max_column + 1):
if (row, column) in merged_children:
continue
cell = sheet.cell(row=row, column=column)
attributes = []
rowspan, colspan = merged_starts.get((row, column), (1, 1))
if rowspan > 1:
attributes.append(f'rowspan="{rowspan}"')
if colspan > 1:
attributes.append(f'colspan="{colspan}"')
style = _excel_cell_style(cell, workbook)
if not style and (row, column) in table_styles:
style = table_styles[(row, column)]
elif (row, column) in table_styles and "background-color" not in style:
style = "; ".join(filter(None, (style, table_styles[(row, column)])))
if style:
attributes.append(f'style="{style}"')
value = "" if cell.value is None else escape(str(cell.value)).replace("\n", "<br>")
output.append(f"<td{' ' if attributes else ''}{' '.join(attributes)}>{value}</td>")
output.append("</tr>")
output.append("</tbody></table>")
if max_rows and last_row > start_row + max_rows - 1:
output.append(f"<p><em>Vorschau auf {max_rows} Zeilen begrenzt.</em></p>")
return "".join(output)
def _render_flattened_worksheet(sheet, workbook, max_rows=None):
"""Flatten ordinary cells while preserving explicitly defined Excel tables.""" """Flatten ordinary cells while preserving explicitly defined Excel tables."""
from openpyxl.utils.cell import range_boundaries from openpyxl.utils.cell import range_boundaries
@@ -165,7 +371,8 @@ def _render_flattened_worksheet(sheet):
blocks = [] blocks = []
rendered_tables = set() rendered_tables = set()
for row_number in range(1, sheet.max_row + 1): rendered_max_row = min(sheet.max_row, max_rows) if max_rows else sheet.max_row
for row_number in range(1, rendered_max_row + 1):
ordinary_values = [] ordinary_values = []
tables_starting_here = [] tables_starting_here = []
for column_number in range(1, sheet.max_column + 1): for column_number in range(1, sheet.max_column + 1):
@@ -175,20 +382,24 @@ def _render_flattened_worksheet(sheet):
tables_starting_here.append(bounds) tables_starting_here.append(bounds)
rendered_tables.add(bounds) rendered_tables.add(bounds)
continue continue
value = sheet.cell(row=row_number, column=column_number).value cell = sheet.cell(row=row_number, column=column_number)
value = cell.value
if value not in (None, ""): if value not in (None, ""):
ordinary_values.append(_escape_markdown_text(value)) rendered_value = escape(str(value)).replace("\n", "<br>")
style = _excel_cell_style(cell, workbook) if cell.has_style else ""
if style:
rendered_value = f'<span style="{style}">{rendered_value}</span>'
ordinary_values.append(rendered_value)
if ordinary_values: if ordinary_values:
blocks.append(" \n".join(ordinary_values)) blocks.append("<p>" + "<br>".join(ordinary_values) + "</p>")
for min_col, min_row, max_col, max_row in tables_starting_here: for min_col, min_row, max_col, max_row in tables_starting_here:
rows = [] blocks.append(_render_excel_worksheet_html(
for table_row in sheet.iter_rows( sheet, workbook,
min_row=min_row, max_row=max_row, min_col=min_col, max_col=max_col, values_only=True bounds=(min_col, min_row, max_col, min(max_row, rendered_max_row)),
): ))
rows.append(["" if value is None else str(value) for value in table_row]) if max_rows and sheet.max_row > max_rows:
if rows: blocks.append(f"<p><em>Vorschau auf {max_rows} von {sheet.max_row} Zeilen begrenzt.</em></p>")
blocks.append(_render_excel_rows(rows, flatten=False)) return "\n".join(blocks) or "<p><em>Keine Inhalte</em></p>"
return "\n\n".join(blocks) or "_Keine Inhalte_"
def _pdf(content): def _pdf(content):
@@ -0,0 +1,44 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("netbox_documentation", "0004_excelimportpreview"),
("tenancy", "0001_squashed_0012"),
]
operations = [
migrations.AddField(
model_name="document",
name="tenant_group",
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
related_name="documentation_documents", to="tenancy.tenantgroup",
),
),
migrations.AddField(
model_name="document",
name="tenant",
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
related_name="documentation_documents", to="tenancy.tenant",
),
),
migrations.AddField(
model_name="documentcategory",
name="tenant_group",
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
related_name="documentation_categories", to="tenancy.tenantgroup",
),
),
migrations.AddField(
model_name="documentcategory",
name="tenant",
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
related_name="documentation_categories", to="tenancy.tenant",
),
),
]
+16
View File
@@ -19,6 +19,14 @@ class Document(NetBoxModel):
summary = models.CharField(max_length=500, blank=True) summary = models.CharField(max_length=500, blank=True)
is_published = models.BooleanField(default=True) is_published = models.BooleanField(default=True)
category = models.ForeignKey("DocumentCategory", on_delete=models.SET_NULL, null=True, blank=True, related_name="documents") category = models.ForeignKey("DocumentCategory", on_delete=models.SET_NULL, null=True, blank=True, related_name="documents")
tenant_group = models.ForeignKey(
"tenancy.TenantGroup", on_delete=models.SET_NULL, null=True, blank=True,
related_name="documentation_documents",
)
tenant = models.ForeignKey(
"tenancy.Tenant", on_delete=models.SET_NULL, null=True, blank=True,
related_name="documentation_documents",
)
class Meta: class Meta:
ordering = ("title",) ordering = ("title",)
@@ -68,6 +76,14 @@ class DocumentCategory(NetBoxModel):
slug = models.SlugField(max_length=100) slug = models.SlugField(max_length=100)
parent = models.ForeignKey("self", on_delete=models.CASCADE, null=True, blank=True, related_name="children") parent = models.ForeignKey("self", on_delete=models.CASCADE, null=True, blank=True, related_name="children")
description = models.CharField(max_length=500, blank=True) description = models.CharField(max_length=500, blank=True)
tenant_group = models.ForeignKey(
"tenancy.TenantGroup", on_delete=models.SET_NULL, null=True, blank=True,
related_name="documentation_categories",
)
tenant = models.ForeignKey(
"tenancy.Tenant", on_delete=models.SET_NULL, null=True, blank=True,
related_name="documentation_categories",
)
class Meta: class Meta:
ordering = ("name",) ordering = ("name",)
+2 -2
View File
@@ -10,7 +10,7 @@ class DocumentTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = Document model = Document
fields = ("pk", "title", "category", "summary", "is_published", "assignments", "last_updated", "actions") fields = ("pk", "title", "category", "tenant_group", "tenant", "summary", "is_published", "assignments", "last_updated", "actions")
class DocumentCategoryTable(NetBoxTable): class DocumentCategoryTable(NetBoxTable):
@@ -20,7 +20,7 @@ class DocumentCategoryTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = DocumentCategory model = DocumentCategory
fields = ("pk", "name", "parent", "description", "document_count", "actions") fields = ("pk", "name", "parent", "tenant_group", "tenant", "description", "document_count", "actions")
class AssignmentTable(NetBoxTable): class AssignmentTable(NetBoxTable):
@@ -78,6 +78,10 @@
<div class="card"><div class="card-body rendered-markdown documentation-content">{{ 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.tenant_group or object.tenant %}<div class="card mb-3"><h5 class="card-header">Mandantenzuordnung</h5><div class="list-group list-group-flush">
{% if object.tenant_group %}<a class="list-group-item list-group-item-action" href="{{ object.tenant_group.get_absolute_url }}"><i class="mdi mdi-account-group"></i> {{ object.tenant_group }}</a>{% endif %}
{% if object.tenant %}<a class="list-group-item list-group-item-action" href="{{ object.tenant.get_absolute_url }}"><i class="mdi mdi-domain"></i> {{ object.tenant }}</a>{% endif %}
</div></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 %} {% 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 %}
<div class="card"><h5 class="card-header">Zuordnungen</h5><div class="list-group list-group-flush"> <div class="card"><h5 class="card-header">Zuordnungen</h5><div class="list-group list-group-flush">
{% for assignment in object.assignments.all %}<a class="list-group-item" href="{{ assignment.assigned_object.get_absolute_url }}">{{ assignment.assigned_object_type }}: {{ assignment.assigned_object }}</a>{% empty %}<div class="list-group-item text-muted">Noch nicht zugeordnet</div>{% endfor %} {% for assignment in object.assignments.all %}<a class="list-group-item" href="{{ assignment.assigned_object.get_absolute_url }}">{{ assignment.assigned_object_type }}: {{ assignment.assigned_object }}</a>{% empty %}<div class="list-group-item text-muted">Noch nicht zugeordnet</div>{% endfor %}
@@ -3,8 +3,6 @@
{{ block.super }} {{ block.super }}
<style> <style>
body.documentation-editor-page .container-xl { max-width: none !important; } body.documentation-editor-page .container-xl { max-width: none !important; }
body.documentation-editor-focus #form_fields .field-group:not(.documentation-content-group) { display: none; }
body.documentation-editor-focus #form_fields .documentation-content-group { margin-bottom: 0 !important; }
</style> </style>
<script src="{% url 'plugins:netbox_documentation:editor_asset' asset_path='tinymce.min.js' %}"></script> <script src="{% url 'plugins:netbox_documentation:editor_asset' asset_path='tinymce.min.js' %}"></script>
<script> <script>
@@ -36,10 +34,10 @@
plugins: 'advlist anchor autolink charmap code fullscreen image link lists media preview searchreplace table visualblocks wordcount', plugins: 'advlist anchor autolink charmap code fullscreen image link lists media preview searchreplace table visualblocks wordcount',
toolbar: [ toolbar: [
'undo redo | blocks styles fontfamily fontsize | bold italic underline strikethrough | forecolor backcolor', 'undo redo | blocks styles fontfamily fontsize | bold italic underline strikethrough | forecolor backcolor',
'alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table link image media | removeformat code netboxfocus fullscreen' 'alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table tablecellcolor link image media | removeformat code fullscreen'
], ],
toolbar_mode: 'sliding', toolbar_mode: 'sliding',
table_toolbar: 'tableprops tablecellprops | tableinsertrowbefore tableinsertrowafter tabledeleterow | tableinsertcolbefore tableinsertcolafter tabledeletecol | tablemergecells tablesplitcells | tabledelete', table_toolbar: 'tableprops tablecellprops tablecellcolor | tableinsertrowbefore tableinsertrowafter tabledeleterow | tableinsertcolbefore tableinsertcolafter tabledeletecol | tablemergecells tablesplitcells | tabledelete',
table_appearance_options: true, table_appearance_options: true,
table_advtab: true, table_advtab: true,
table_cell_advtab: true, table_cell_advtab: true,
@@ -103,7 +101,79 @@
paste_convert_word_fake_lists: true, paste_convert_word_fake_lists: true,
smart_paste: true, smart_paste: true,
images_reuse_filename: true, images_reuse_filename: true,
relative_urls: false file_picker_types: 'image',
file_picker_callback: (callback, value, meta) => {
if (meta.filetype !== 'image') return;
const picker = document.createElement('input');
picker.type = 'file';
picker.accept = 'image/png,image/jpeg,image/gif,image/webp';
picker.addEventListener('change', () => {
const file = picker.files && picker.files[0];
if (!file) return;
const allowedTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
window.alert('Unterstützt werden PNG, JPEG, GIF und WebP.');
return;
}
const reader = new FileReader();
reader.addEventListener('load', () => callback(reader.result, {
alt: file.name,
title: file.name
}));
reader.addEventListener('error', () => window.alert('Das Bild konnte nicht gelesen werden.'));
reader.readAsDataURL(file);
}, {once: true});
picker.click();
},
relative_urls: false,
setup: editor => {
const selectedTableCells = () => {
const markedCells = Array.from(editor.getBody().querySelectorAll(
'td[data-mce-selected="1"], th[data-mce-selected="1"]'
));
if (markedCells.length) return markedCells;
const currentCell = editor.dom.getParent(editor.selection.getNode(), 'td,th');
return currentCell ? [currentCell] : [];
};
const applyCellColor = color => {
const cells = selectedTableCells();
if (!cells.length) return;
editor.undoManager.transact(() => {
cells.forEach(cell => editor.dom.setStyle(cell, 'background-color', color));
});
editor.nodeChanged();
};
const chooseCellColor = () => {
const picker = document.createElement('input');
picker.type = 'color';
picker.value = '#fff3bf';
picker.style.position = 'fixed';
picker.style.opacity = '0';
picker.addEventListener('input', () => applyCellColor(picker.value), {once: true});
picker.addEventListener('change', () => picker.remove(), {once: true});
document.body.appendChild(picker);
picker.click();
};
const palette = [
['Gelb', '#fff3bf'], ['Grün', '#d3f9d8'], ['Blau', '#dbeafe'],
['Rot', '#ffe3e3'], ['Orange', '#ffe8cc'], ['Grau', '#e9ecef'],
['Dunkelgrün', '#a5bf60'], ['Dunkelblau', '#206bc4'], ['Dunkelgrau', '#343a40']
];
editor.ui.registry.addMenuButton('tablecellcolor', {
icon: 'highlight-bg-color',
tooltip: 'Farbeimer für Tabellenzellen',
fetch: callback => callback([
...palette.map(([name, color]) => ({
type: 'menuitem',
text: name,
onAction: () => applyCellColor(color)
})),
{type: 'separator'},
{type: 'menuitem', text: 'Eigene Farbe …', onAction: chooseCellColor},
{type: 'menuitem', text: 'Zellfarbe entfernen', onAction: () => applyCellColor('')}
])
});
}
{% if object.pk %}, {% if object.pk %},
images_file_types: 'jpg,jpeg,png,gif,webp', images_file_types: 'jpg,jpeg,png,gif,webp',
automatic_uploads: true, automatic_uploads: true,
@@ -121,19 +191,7 @@
resolve(payload.location); resolve(payload.location);
}).catch(error => reject(error.message)); }).catch(error => reject(error.message));
}) })
{% endif %}, {% endif %}
setup: (editor) => {
editor.ui.registry.addToggleButton('netboxfocus', {
icon: 'expand',
tooltip: 'Fokusmodus im NetBox-Fenster',
onAction: api => {
const active = document.body.classList.toggle('documentation-editor-focus');
const container = editor.getContainer();
container.style.height = active ? 'calc(100vh - 190px)' : '650px';
api.setActive(active);
}
});
}
}); });
}); });
</script> </script>
@@ -141,6 +199,6 @@
{% block pre_form_fields %} {% block pre_form_fields %}
{% if not object.pk %} {% if not object.pk %}
<div class="alert alert-info">Speichere die neue Dokumentation einmal. Danach können Bilder direkt über die Editor-Werkzeugleiste hochgeladen werden.</div> <div class="alert alert-info">Eingefügte oder hineinkopierte Bilder werden beim ersten Speichern automatisch als Medien der neuen Dokumentation abgelegt. Nach dem ersten Speichern ist zusätzlich der direkte Bildupload verfügbar.</div>
{% endif %} {% endif %}
{% endblock pre_form_fields %} {% endblock pre_form_fields %}
@@ -11,7 +11,9 @@
.toolbar { position: sticky; top: 0; z-index: 2; display: flex; justify-content: center; gap: .75rem; padding: .8rem; background: #fff; border-bottom: 1px solid #ccd2d8; } .toolbar { position: sticky; top: 0; z-index: 2; display: flex; justify-content: center; gap: .75rem; padding: .8rem; background: #fff; border-bottom: 1px solid #ccd2d8; }
.toolbar button, .toolbar a { border: 1px solid #6c757d; border-radius: .3rem; padding: .55rem .9rem; background: #fff; color: #212529; text-decoration: none; cursor: pointer; font: inherit; } .toolbar button, .toolbar a { border: 1px solid #6c757d; border-radius: .3rem; padding: .55rem .9rem; background: #fff; color: #212529; text-decoration: none; cursor: pointer; font: inherit; }
.toolbar button { color: #fff; background: #4263eb; border-color: #4263eb; } .toolbar button { color: #fff; background: #4263eb; border-color: #4263eb; }
.toolbar a.active { color: #fff; background: #495057; border-color: #495057; }
article { width: min(210mm, calc(100% - 2rem)); min-height: 297mm; margin: 1.5rem auto; padding: 18mm 17mm; background: #fff; box-shadow: 0 .2rem 1rem rgba(0,0,0,.12); } article { width: min(210mm, calc(100% - 2rem)); min-height: 297mm; margin: 1.5rem auto; padding: 18mm 17mm; background: #fff; box-shadow: 0 .2rem 1rem rgba(0,0,0,.12); }
article.landscape { width: min(297mm, calc(100% - 2rem)); min-height: 210mm; }
h1 { margin: 0 0 .25rem; font-size: 24pt; line-height: 1.2; } h1 { margin: 0 0 .25rem; font-size: 24pt; line-height: 1.2; }
.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; }
@@ -32,7 +34,7 @@
.content .doc-cell-middle { vertical-align: middle; } .content .doc-cell-middle { vertical-align: middle; }
.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; }
@page { size: A4; margin: 15mm; } @page { size: A4 {% if request.GET.orientation == 'landscape' %}landscape{% else %}portrait{% endif %}; margin: 15mm; }
@media print { @media print {
body { background: #fff; } body { background: #fff; }
.toolbar { display: none !important; } .toolbar { display: none !important; }
@@ -46,9 +48,11 @@
<body> <body>
<div class="toolbar"> <div class="toolbar">
<button type="button" onclick="window.print()">Drucken / Als PDF speichern</button> <button type="button" onclick="window.print()">Drucken / Als PDF speichern</button>
<a href="?orientation=portrait"{% if request.GET.orientation != 'landscape' %} class="active"{% endif %}>Hochformat</a>
<a href="?orientation=landscape"{% if request.GET.orientation == 'landscape' %} class="active"{% endif %}>Querformat</a>
<a href="{{ object.get_absolute_url }}">Zurück zur Dokumentation</a> <a href="{{ object.get_absolute_url }}">Zurück zur Dokumentation</a>
</div> </div>
<article> <article{% if request.GET.orientation == 'landscape' %} class="landscape"{% endif %}>
<header> <header>
<h1>{{ object.title }}</h1> <h1>{{ object.title }}</h1>
{% if object.summary %}<p class="summary">{{ object.summary }}</p>{% endif %} {% if object.summary %}<p class="summary">{{ object.summary }}</p>{% endif %}
@@ -3,6 +3,15 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-4"> <div class="col col-md-4">
{% if object.tenant_group or object.tenant %}
<div class="card mb-3">
<h5 class="card-header">Mandantenzuordnung</h5>
<div class="list-group list-group-flush">
{% if object.tenant_group %}<a class="list-group-item list-group-item-action" href="{{ object.tenant_group.get_absolute_url }}"><i class="mdi mdi-account-group"></i> {{ object.tenant_group }}</a>{% endif %}
{% if object.tenant %}<a class="list-group-item list-group-item-action" href="{{ object.tenant.get_absolute_url }}"><i class="mdi mdi-domain"></i> {{ object.tenant }}</a>{% endif %}
</div>
</div>
{% endif %}
<div class="card"> <div class="card">
<h5 class="card-header">Unterordner</h5> <h5 class="card-header">Unterordner</h5>
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
@@ -9,7 +9,7 @@
.excel-preview-content { max-height: 32rem; overflow: auto; } .excel-preview-content { max-height: 32rem; overflow: auto; }
.excel-preview-content table { width: max-content; min-width: 100%; border-collapse: collapse; font-size: .825rem; } .excel-preview-content table { width: max-content; min-width: 100%; border-collapse: collapse; font-size: .825rem; }
.excel-preview-content th, .excel-preview-content td { min-width: 7rem; max-width: 22rem; padding: .3rem .45rem; border: 1px solid var(--tblr-border-color, #adb5bd); vertical-align: top; overflow-wrap: anywhere; } .excel-preview-content th, .excel-preview-content td { min-width: 7rem; max-width: 22rem; padding: .3rem .45rem; border: 1px solid var(--tblr-border-color, #adb5bd); vertical-align: top; overflow-wrap: anywhere; }
.excel-preview-content th { background: var(--tblr-bg-surface-secondary, #f1f3f5); } .excel-preview-content th:not([style*="background"]) { background: var(--tblr-bg-surface-secondary, #f1f3f5); }
</style> </style>
{% endblock head %} {% endblock head %}
@@ -26,6 +26,16 @@
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% if overwrite_conflicts %}
<input type="hidden" name="confirm_overwrite" value="1">
<div class="alert alert-warning mx-0" role="alert">
<h3 class="alert-title">Bestehende Dokumentationen aktualisieren?</h3>
<p>Die folgenden Dokumentationen existieren bereits im Zielordner. Ihr Inhalt wird überschrieben; bestehende NetBox-Zuordnungen bleiben erhalten:</p>
<ul class="mb-0">
{% for document in overwrite_conflicts %}<li>{{ document.title }}</li>{% endfor %}
</ul>
</div>
{% endif %}
{{ formset.management_form }} {{ formset.management_form }}
{% if formset.non_form_errors %}<div class="alert alert-danger">{{ formset.non_form_errors }}</div>{% endif %} {% if formset.non_form_errors %}<div class="alert alert-danger">{{ formset.non_form_errors }}</div>{% endif %}
@@ -62,7 +72,10 @@
<div class="sticky-actions sticky-actions-footer d-print-none" data-sticky-position="full" data-sticky-when="always"> <div class="sticky-actions sticky-actions-footer d-print-none" data-sticky-position="full" data-sticky-when="always">
<div class="btn-list"> <div class="btn-list">
<button type="submit" name="action" value="cancel" class="btn btn-outline-secondary">Abbrechen</button> <button type="submit" name="action" value="cancel" class="btn btn-outline-secondary">Abbrechen</button>
<button type="submit" name="action" value="confirm" class="btn btn-primary"><i class="mdi mdi-file-import"></i> Ausgewählte Arbeitsblätter importieren</button> <button type="submit" name="action" value="confirm" class="btn {% if overwrite_conflicts %}btn-warning{% else %}btn-primary{% endif %}">
<i class="mdi mdi-file-import"></i>
{% if overwrite_conflicts %}Überschreiben und importieren{% else %}Ausgewählte Arbeitsblätter importieren{% endif %}
</button>
</div> </div>
</div> </div>
</form> </form>
+133 -47
View File
@@ -1,5 +1,6 @@
from pathlib import Path from pathlib import Path
from datetime import timedelta from datetime import timedelta
from html import escape
import mimetypes import mimetypes
import tinymce import tinymce
from django.conf import settings from django.conf import settings
@@ -7,6 +8,7 @@ from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import transaction from django.db import transaction
from django.db.models import Q
from django.http import FileResponse, Http404, JsonResponse from django.http import FileResponse, Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.utils.text import slugify from django.utils.text import slugify
@@ -44,14 +46,14 @@ class EditorAssetView(View):
class DocumentListView(generic.ObjectListView): class DocumentListView(generic.ObjectListView):
queryset = Document.objects.prefetch_related("assignments") queryset = Document.objects.select_related("category", "tenant_group", "tenant").prefetch_related("assignments")
table = DocumentTable table = DocumentTable
filterset = DocumentFilterSet filterset = DocumentFilterSet
actions = (AddObject, BulkImport, BulkDelete) actions = (AddObject, BulkImport, BulkDelete)
class DocumentView(generic.ObjectView): class DocumentView(generic.ObjectView):
queryset = Document.objects.prefetch_related("assignments", "attachments") queryset = Document.objects.select_related("category", "tenant_group", "tenant").prefetch_related("assignments", "attachments")
class DocumentPrintView(PermissionRequiredMixin, View): class DocumentPrintView(PermissionRequiredMixin, View):
@@ -83,14 +85,14 @@ class DocumentBulkDeleteView(generic.BulkDeleteView):
class DocumentCategoryListView(generic.ObjectListView): class DocumentCategoryListView(generic.ObjectListView):
queryset = DocumentCategory.objects.select_related("parent").prefetch_related("documents") queryset = DocumentCategory.objects.select_related("parent", "tenant_group", "tenant").prefetch_related("documents")
table = DocumentCategoryTable table = DocumentCategoryTable
filterset = DocumentCategoryFilterSet filterset = DocumentCategoryFilterSet
actions = (AddObject, BulkImport, BulkDelete) actions = (AddObject, BulkImport, BulkDelete)
class DocumentCategoryView(generic.ObjectView): class DocumentCategoryView(generic.ObjectView):
queryset = DocumentCategory.objects.prefetch_related("children", "documents") queryset = DocumentCategory.objects.select_related("tenant_group", "tenant").prefetch_related("children", "documents")
class DocumentCategoryEditView(generic.ObjectEditView): class DocumentCategoryEditView(generic.ObjectEditView):
@@ -168,7 +170,13 @@ class DocumentImportView(PermissionRequiredMixin, View):
while Document.objects.filter(slug=slug).exists(): while Document.objects.filter(slug=slug).exists():
slug = f"{base_slug}-{counter}" slug = f"{base_slug}-{counter}"
counter += 1 counter += 1
document = Document.objects.create(title=title, slug=slug, body=result.markdown, body_format="markdown") category = form.cleaned_data.get("category")
document = Document.objects.create(
title=title, slug=slug, body=result.markdown, body_format=result.body_format,
category=category,
tenant_group=category.tenant_group if category else None,
tenant=category.tenant if category else None,
)
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: if keep:
upload.seek(0) upload.seek(0)
@@ -181,7 +189,10 @@ class DocumentImportView(PermissionRequiredMixin, View):
def _import_excel_sheets(self, request, form, upload): def _import_excel_sheets(self, request, form, upload):
try: try:
sheets = import_excel_sheets(upload, flatten=form.cleaned_data.get("flatten_excel_tables", False)) sheets = import_excel_sheets(
upload, flatten=form.cleaned_data.get("flatten_excel_tables", False),
metadata_only=True, include_image_data=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})
@@ -195,7 +206,10 @@ class DocumentImportView(PermissionRequiredMixin, View):
user=request.user, category=form.cleaned_data["category"], file=upload, user=request.user, category=form.cleaned_data["category"], file=upload,
original_name=upload.name, content_type=upload.content_type or "", original_name=upload.name, content_type=upload.content_type or "",
flatten=form.cleaned_data.get("flatten_excel_tables", False), flatten=form.cleaned_data.get("flatten_excel_tables", False),
sheet_metadata=[{"title": sheet.title, "image_count": len(sheet.images)} for sheet in sheets], sheet_metadata=[{
"title": sheet.title, "image_count": len(sheet.images),
"source_index": sheet.source_index,
} for sheet in sheets],
) )
return redirect("plugins:netbox_documentation:excel_import_preview", token=preview.pk) return redirect("plugins:netbox_documentation:excel_import_preview", token=preview.pk)
@@ -207,29 +221,42 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
def get_preview(self, request, token): def get_preview(self, request, token):
return get_object_or_404(ExcelImportPreview, pk=token, user=request.user) return get_object_or_404(ExcelImportPreview, pk=token, user=request.user)
def read_sheets(self, preview): def read_sheets(self, preview, *, for_preview=False, selected_indexes=None):
preview.file.open("rb") preview.file.open("rb")
try: try:
return import_excel_sheets(preview.file, flatten=preview.flatten) return import_excel_sheets(
preview.file, flatten=preview.flatten,
preview_max_rows=100 if for_preview else None,
include_image_data=not for_preview,
selected_indexes=selected_indexes,
)
finally: finally:
preview.file.close() preview.file.close()
def render_preview(self, request, preview, sheets, formset): def render_preview(self, request, preview, sheets, formset, overwrite_conflicts=None):
items = [] items = []
for form, sheet in zip(formset.forms, sheets): for form, sheet in zip(formset.forms, sheets):
sample = sheet.markdown sample = sheet.markdown
if sheet.images: if sheet.images:
sample += f"\n\n## Bilder\n\n_{len(sheet.images)} eingebettete Bilder werden beim Import angefügt._" image_notice = f"{len(sheet.images)} eingebettete Bilder werden beim Import angefügt."
if sheet.body_format == "html":
sample += f"\n<h2>Bilder</h2><p><em>{image_notice}</em></p>"
else:
sample += f"\n\n## Bilder\n\n_{image_notice}_"
truncated = len(sample) > 100000 truncated = len(sample) > 100000
if truncated: if truncated:
sample = sample[:100000] + "\n\n_… Vorschau gekürzt …_" notice = "… Vorschau gekürzt …"
preview_document = Document(body=sample, body_format="markdown") sample = sample[:100000] + (
f"<p><em>{notice}</em></p>" if sheet.body_format == "html" else f"\n\n_{notice}_"
)
preview_document = Document(body=sample, body_format=sheet.body_format)
items.append({ items.append({
"form": form, "sheet": sheet, "form": form, "sheet": sheet,
"preview_html": preview_document.rendered_body(), "truncated": truncated, "preview_html": preview_document.rendered_body(), "truncated": truncated,
}) })
return render(request, self.template_name, { return render(request, self.template_name, {
"batch": preview, "formset": formset, "items": items, "batch": preview, "formset": formset, "items": items,
"overwrite_conflicts": overwrite_conflicts or [],
}) })
def get(self, request, token): def get(self, request, token):
@@ -239,7 +266,7 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
messages.error(request, "Diese Importvorschau ist abgelaufen. Bitte die Exceldatei erneut hochladen.") messages.error(request, "Diese Importvorschau ist abgelaufen. Bitte die Exceldatei erneut hochladen.")
return redirect("plugins:netbox_documentation:document_import") return redirect("plugins:netbox_documentation:document_import")
try: try:
sheets = self.read_sheets(preview) sheets = self.read_sheets(preview, for_preview=True)
except (ImportFailure, Exception) as exc: except (ImportFailure, Exception) as exc:
self.delete_preview(preview) self.delete_preview(preview)
messages.error(request, f"Die Excel-Vorschau konnte nicht erzeugt werden: {exc}") messages.error(request, f"Die Excel-Vorschau konnte nicht erzeugt werden: {exc}")
@@ -253,15 +280,15 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
self.delete_preview(preview) self.delete_preview(preview)
messages.info(request, "Excel-Import wurde abgebrochen.") messages.info(request, "Excel-Import wurde abgebrochen.")
return redirect("plugins:netbox_documentation:document_import") return redirect("plugins:netbox_documentation:document_import")
try:
sheets = self.read_sheets(preview)
except (ImportFailure, Exception) as exc:
self.delete_preview(preview)
messages.error(request, f"Die Exceldatei konnte nicht erneut gelesen werden: {exc}")
return redirect("plugins:netbox_documentation:document_import")
formset = ExcelSheetSelectionFormSet(request.POST) formset = ExcelSheetSelectionFormSet(request.POST)
if not formset.is_valid(): if not formset.is_valid():
return self.render_preview(request, preview, sheets, formset) try:
preview_sheets = self.read_sheets(preview, for_preview=True)
except (ImportFailure, Exception) as exc:
self.delete_preview(preview)
messages.error(request, f"Die Exceldatei konnte nicht erneut gelesen werden: {exc}")
return redirect("plugins:netbox_documentation:document_import")
return self.render_preview(request, preview, preview_sheets, formset)
if not preview.category: if not preview.category:
self.delete_preview(preview) self.delete_preview(preview)
messages.error(request, "Der gewählte Zielordner existiert nicht mehr. Bitte den Import erneut starten.") messages.error(request, "Der gewählte Zielordner existiert nicht mehr. Bitte den Import erneut starten.")
@@ -270,13 +297,45 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
for form in formset.forms: for form in formset.forms:
data = form.cleaned_data data = form.cleaned_data
if data.get("include"): if data.get("include"):
if data["index"] < 0 or data["index"] >= len(sheets): if data["index"] < 0 or data["index"] >= len(preview.sheet_metadata):
raise PermissionDenied raise PermissionDenied
selections[data["index"]] = data["title"].strip() metadata = preview.sheet_metadata[data["index"]]
result = self.create_documents(preview, sheets, selections) source_index = metadata.get("source_index", data["index"])
selections[source_index] = data["title"].strip()
title_query = Q()
for title in selections.values():
title_query |= Q(title__iexact=title)
conflicts = list(Document.objects.filter(
title_query, category=preview.category,
).order_by("title"))
if conflicts:
allowed_ids = set(Document.objects.restrict(request.user, "change").filter(
pk__in=[document.pk for document in conflicts]
).values_list("pk", flat=True))
if any(document.pk not in allowed_ids for document in conflicts):
messages.error(request, "Mindestens eine gleichnamige Dokumentation darf nicht aktualisiert werden.")
return redirect("plugins:netbox_documentation:excel_import_preview", token=preview.pk)
if request.POST.get("confirm_overwrite") != "1":
try:
preview_sheets = self.read_sheets(preview, for_preview=True)
except (ImportFailure, Exception) as exc:
messages.error(request, f"Die Bestätigung konnte nicht angezeigt werden: {exc}")
return redirect("plugins:netbox_documentation:excel_import_preview", token=preview.pk)
return self.render_preview(
request, preview, preview_sheets, formset,
overwrite_conflicts=conflicts,
)
try:
selected_sheets = self.read_sheets(preview, selected_indexes=selections)
except (ImportFailure, Exception) as exc:
messages.error(request, f"Die ausgewählten Excel-Blätter konnten nicht importiert werden: {exc}")
return redirect("plugins:netbox_documentation:excel_import_preview", token=preview.pk)
sheets_by_index = {sheet.source_index: sheet for sheet in selected_sheets}
result = self.create_documents(preview, sheets_by_index, selections)
self.delete_preview(preview) self.delete_preview(preview)
messages.success(request, ( messages.success(request, (
f"{result['documents']} Arbeitsblätter als Dokumentationen in „{result['category']}“ importiert; " f"{result['documents']} Arbeitsblätter verarbeitet "
f"({result['created']} neu, {result['updated']} aktualisiert) in „{result['category']}“; "
f"{result['images']} Bilder übernommen." f"{result['images']} Bilder übernommen."
)) ))
return redirect("plugins:netbox_documentation:document_list") return redirect("plugins:netbox_documentation:document_list")
@@ -285,24 +344,36 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
@transaction.atomic @transaction.atomic
def create_documents(preview, sheets, selections): def create_documents(preview, sheets, selections):
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
preview.file.open("rb") created_documents, image_count, created_count, updated_count = [], 0, 0, 0
try:
original_content = preview.file.read()
finally:
preview.file.close()
created_documents, image_count = [], 0
for index, title in selections.items(): for index, title in selections.items():
sheet = sheets[index] sheet = sheets[index]
base_slug = slugify(title)[:180] or "arbeitsblatt" document = Document.objects.select_for_update().filter(
document_slug, counter = base_slug, 2 category=preview.category, title__iexact=title,
while Document.objects.filter(slug=document_slug).exists(): ).order_by("pk").first()
document_slug = f"{base_slug[:190-len(str(counter))]}-{counter}" if document:
counter += 1 document.title = title[:200]
document = Document.objects.create( document.body = sheet.markdown
title=title[:200], slug=document_slug, body=sheet.markdown, document.body_format = sheet.body_format
body_format="markdown", category=preview.category, document.summary = f"Importiert aus {preview.original_name}"
summary=f"Importiert aus {preview.original_name}", document.tenant_group = preview.category.tenant_group
) document.tenant = preview.category.tenant
document.save(update_fields=(
"title", "body", "body_format", "summary", "tenant_group", "tenant", "last_updated",
))
updated_count += 1
else:
base_slug = slugify(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=title[:200], slug=document_slug, body=sheet.markdown,
body_format=sheet.body_format, category=preview.category,
tenant_group=preview.category.tenant_group, tenant=preview.category.tenant,
summary=f"Importiert aus {preview.original_name}",
)
created_count += 1
if sheet.images: if sheet.images:
image_lines = [] image_lines = []
for image in sheet.images: for image in sheet.images:
@@ -313,20 +384,35 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
attachment.file.save(image.name, ContentFile(image.content), save=False) attachment.file.save(image.name, ContentFile(image.content), save=False)
attachment.save() attachment.save()
location = f" ({image.cell})" if image.cell else "" location = f" ({image.cell})" if image.cell else ""
image_lines.append(f"![{image.name}{location}]({attachment.file.url})") if document.body_format == "html":
document.body += "\n\n## Bilder\n\n" + "\n\n".join(image_lines) image_lines.append(
f'<p><img src="{escape(attachment.file.url)}" alt="{escape(image.name + location)}"></p>'
)
else:
image_lines.append(f"![{image.name}{location}]({attachment.file.url})")
if document.body_format == "html":
document.body += "\n<h2>Bilder</h2>\n" + "\n".join(image_lines)
else:
document.body += "\n\n## Bilder\n\n" + "\n\n".join(image_lines)
document.save(update_fields=("body", "last_updated")) document.save(update_fields=("body", "last_updated"))
image_count += len(sheet.images) image_count += len(sheet.images)
created_documents.append(document) created_documents.append(document)
if settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("keep_imported_file", True): if settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("keep_imported_file", True):
for document in created_documents: for document in created_documents:
preview.file.open("rb")
attachment = DocumentAttachment( attachment = DocumentAttachment(
document=document, original_name=preview.original_name, document=document, original_name=preview.original_name,
content_type=preview.content_type, size=len(original_content), content_type=preview.content_type, size=preview.file.size,
) )
attachment.file.save(preview.original_name, ContentFile(original_content), save=False) try:
attachment.save() attachment.file.save(preview.original_name, preview.file, save=False)
return {"documents": len(created_documents), "images": image_count, "category": preview.category} attachment.save()
finally:
preview.file.close()
return {
"documents": len(created_documents), "created": created_count,
"updated": updated_count, "images": image_count, "category": preview.category,
}
@staticmethod @staticmethod
def delete_preview(preview): def delete_preview(preview):
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "netbox-documentation" name = "netbox-documentation"
version = "0.7.2" version = "0.9.2"
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"
+48
View File
@@ -0,0 +1,48 @@
import base64
from io import BytesIO
import importlib.util
from pathlib import Path
import sys
from PIL import Image
spec = importlib.util.spec_from_file_location(
"documentation_embedded_images",
Path(__file__).parents[1] / "netbox_documentation" / "embedded_images.py",
)
embedded = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = embedded
spec.loader.exec_module(embedded)
def image_data_url():
stream = BytesIO()
Image.new("RGB", (2, 2), "red").save(stream, format="PNG")
return "data:image/png;base64," + base64.b64encode(stream.getvalue()).decode()
def test_embedded_image_is_replaced_with_stored_url():
html = f'<p><img alt="Test" src="{image_data_url()}"></p>'
stored = []
def store(mime, content):
stored.append((mime, content))
return "/media/documentation/bild.png"
result = embedded.store_embedded_images(html, 1024 * 1024, store)
assert 'src="/media/documentation/bild.png"' in result
assert "data:image/" not in result
assert stored[0][0] == "image/png"
assert stored[0][1]
def test_embedded_images_respect_total_size_limit():
html = f'<img src="{image_data_url()}">'
try:
embedded.validate_embedded_images(html, 1)
except embedded.EmbeddedImageError as exc:
assert "Gesamtgröße" in str(exc)
else:
raise AssertionError("Größenlimit wurde nicht angewendet")
+152 -6
View File
@@ -5,7 +5,8 @@ import sys
import pytest import pytest
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.worksheet.table import Table from openpyxl.styles import PatternFill, Font
from openpyxl.worksheet.table import Table, TableStyleInfo
spec = importlib.util.spec_from_file_location( spec = importlib.util.spec_from_file_location(
@@ -39,10 +40,54 @@ def test_xlsx_imports_sheets_as_tables():
stream = BytesIO() stream = BytesIO()
workbook.save(stream) workbook.save(stream)
result = import_document(Upload(stream.getvalue(), "server.xlsx")) result = import_document(Upload(stream.getvalue(), "server.xlsx"))
assert "## Server" in result.markdown assert "<h2>Server</h2>" in result.markdown
assert result.body_format == "html"
assert "web01" in result.markdown assert "web01" in result.markdown
def test_xlsx_preserves_cell_fill_and_font_colors_as_html():
workbook = Workbook()
sheet = workbook.active
sheet["A1"] = "Standort"
sheet["A1"].fill = PatternFill(fill_type="solid", fgColor="A5BF60")
sheet["A1"].font = Font(color="FFFFFF", bold=True)
sheet["A2"] = "Verwaltung"
sheet["A2"].fill = PatternFill(fill_type="solid", fgColor="EAF0DC")
stream = BytesIO()
workbook.save(stream)
result = import_document(Upload(stream.getvalue(), "farben.xlsx"))
assert result.body_format == "html"
assert "background-color: #a5bf60" in result.markdown
assert "color: #ffffff" in result.markdown
assert "font-weight: bold" in result.markdown
assert "background-color: #eaf0dc" in result.markdown
def test_xlsx_renders_builtin_excel_table_style_colors_in_preview_html():
workbook = Workbook()
sheet = workbook.active
sheet.append(["Standort", "Adresse"])
sheet.append(["Verwaltung", "10.1.2.17"])
sheet.append(["Büro", "10.1.2.18"])
table = Table(displayName="Standorte", ref="A1:B3")
table.tableStyleInfo = TableStyleInfo(
name="TableStyleMedium4", showFirstColumn=False,
showLastColumn=False, showRowStripes=True, showColumnStripes=False,
)
sheet.add_table(table)
stream = BytesIO()
workbook.save(stream)
result = import_document(Upload(stream.getvalue(), "tabelle.xlsx"))
assert result.body_format == "html"
assert "Standort" in result.markdown
assert result.markdown.count("background-color:") >= 4
assert "color: #ffffff" in result.markdown
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"))
@@ -68,6 +113,66 @@ def test_excel_multi_sheet_import_creates_one_result_per_sheet():
assert "Leitstelle" in results[1].markdown assert "Leitstelle" in results[1].markdown
def test_excel_import_renders_only_selected_source_sheets():
workbook = Workbook()
first = workbook.active
first.title = "Nicht gewählt"
first.append(["Sehr viele Daten"])
second = workbook.create_sheet("Gewählt")
second.append(["Nur dieses Blatt"])
third = workbook.create_sheet("Auch nicht gewählt")
third.append(["Weitere Daten"])
stream = BytesIO()
workbook.save(stream)
results = import_excel_sheets(
Upload(stream.getvalue(), "auswahl.xlsx"), selected_indexes={1},
)
assert len(results) == 1
assert results[0].title == "Gewählt"
assert results[0].source_index == 1
assert "Nur dieses Blatt" in results[0].markdown
assert "Sehr viele Daten" not in results[0].markdown
def test_excel_metadata_phase_skips_document_rendering():
workbook = Workbook()
sheet = workbook.active
sheet.title = "Großes Blatt"
sheet.append(["Name", "IP"])
sheet.append(["web01", "10.0.0.1"])
stream = BytesIO()
workbook.save(stream)
results = import_excel_sheets(
Upload(stream.getvalue(), "kunde.xlsx"), metadata_only=True,
include_image_data=False,
)
assert [result.title for result in results] == ["Großes Blatt"]
assert results[0].markdown == ""
def test_excel_preview_limits_rows_without_affecting_sheet_detection():
workbook = Workbook()
sheet = workbook.active
sheet.append(["Nummer"])
for number in range(1, 151):
sheet.append([number])
stream = BytesIO()
workbook.save(stream)
results = import_excel_sheets(
Upload(stream.getvalue(), "gross.xlsx"), preview_max_rows=100,
include_image_data=False,
)
assert "Vorschau auf 100 Zeilen begrenzt" in results[0].markdown
assert ">99<" in results[0].markdown
assert ">100<" not in results[0].markdown
def test_excel_flatten_turns_cells_into_document_flow(): def test_excel_flatten_turns_cells_into_document_flow():
workbook = Workbook() workbook = Workbook()
sheet = workbook.active sheet = workbook.active
@@ -78,10 +183,11 @@ def test_excel_flatten_turns_cells_into_document_flow():
results = import_excel_sheets(Upload(stream.getvalue(), "server.xlsx"), flatten=True) results = import_excel_sheets(Upload(stream.getvalue(), "server.xlsx"), flatten=True)
assert results[0].markdown == "Name \nIP\n\ndb01 \n10.0.0.2" assert results[0].body_format == "html"
assert results[0].markdown == "<p>Name<br>IP</p>\n<p>db01<br>10.0.0.2</p>"
assert "Datensatz" not in results[0].markdown assert "Datensatz" not in results[0].markdown
assert "Feld" not in results[0].markdown assert "Feld" not in results[0].markdown
assert "|" not in results[0].markdown assert "<table" not in results[0].markdown
def test_excel_flatten_preserves_explicit_excel_tables_only(): def test_excel_flatten_preserves_explicit_excel_tables_only():
@@ -100,6 +206,46 @@ def test_excel_flatten_preserves_explicit_excel_tables_only():
markdown = results[0].markdown markdown = results[0].markdown
assert "Normaler Hinweis" in markdown assert "Normaler Hinweis" in markdown
assert "| Name" in markdown assert "<table" in markdown
assert "| web01" in markdown assert "Name" in markdown
assert "web01" in markdown
assert "Datensatz" not in markdown assert "Datensatz" not in markdown
def test_excel_flatten_preserves_colors_of_explicit_excel_tables():
workbook = Workbook()
sheet = workbook.active
sheet["A1"] = "Standort"
sheet["B1"] = "Adresse"
sheet["A2"] = "Verwaltung"
sheet["B2"] = "10.1.2.17"
for cell in (sheet["A1"], sheet["B1"]):
cell.fill = PatternFill(fill_type="solid", fgColor="A5BF60")
cell.font = Font(color="FFFFFF", bold=True)
sheet.add_table(Table(displayName="Standorte", ref="A1:B2"))
stream = BytesIO()
workbook.save(stream)
results = import_excel_sheets(Upload(stream.getvalue(), "farben.xlsx"), flatten=True)
assert results[0].body_format == "html"
assert results[0].markdown.count("background-color: #a5bf60") == 2
assert results[0].markdown.count("color: #ffffff") == 2
def test_excel_flatten_preserves_colored_cells_outside_tables():
workbook = Workbook()
sheet = workbook.active
sheet["A1"] = "Wichtiger Hinweis"
sheet["A1"].fill = PatternFill(fill_type="solid", fgColor="FFF3BF")
sheet["A1"].font = Font(color="9C2A2A", bold=True)
sheet["A3"] = "Normaler Text"
stream = BytesIO()
workbook.save(stream)
results = import_excel_sheets(Upload(stream.getvalue(), "hinweis.xlsx"), flatten=True)
assert results[0].body_format == "html"
assert '<span style="background-color: #fff3bf; color: #9c2a2a; font-weight: bold' in results[0].markdown
assert "Wichtiger Hinweis</span>" in results[0].markdown
assert "<table" not in results[0].markdown