Compare commits

..
23 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
MrBlake d9ded6287f fix: Tabellenfarben und Originalbreiten in Ansicht erhalten 2026-07-23 14:33:32 +02:00
MrBlake bf68c9c9ed fix: Tabellenformatierung in Leseansicht beibehalten 2026-07-23 14:30:07 +02:00
MrBlake 58637e1200 Revert "feat: Bearbeitungssperren und Versionskonfliktschutz ergänzen"
This reverts commit 601c7308ce.
2026-07-23 13:44:08 +02:00
MrBlake 601c7308ce feat: Bearbeitungssperren und Versionskonfliktschutz ergänzen 2026-07-23 13:13:34 +02:00
MrBlake df11d0eb05 feat: Vorschau und Blattauswahl für Excel-Mehrblattimport ergänzen 2026-07-23 12:58:52 +02:00
MrBlake ae9c39865b fix: Vollbildmodus durch seitenfüllende Dokumentansicht ersetzen 2026-07-23 12:44:58 +02:00
MrBlake 631ce4d13a feat: erweiterte Tabellenformatierung im WYSIWYG-Editor ergänzen 2026-07-23 12:41:17 +02:00
MrBlake 523e03d26b feat: lesbare Tabellen und Vollbild-Lesemodus ergänzen 2026-07-23 12:38:08 +02:00
22 changed files with 1341 additions and 138 deletions
+9 -2
View File
@@ -7,12 +7,17 @@ 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
- Druckoptimierte A4-Ansicht zum Drucken oder Speichern als PDF - Druckoptimierte A4-Ansicht zum Drucken oder Speichern als PDF
- Seitenfüllende Großansicht per Schaltfläche oder Klick auf eine Tabelle
- Lesbare Tabellen mit vollständigen Zellrahmen und horizontalem Inhalts-Scrolling
- Tabellen-Presets, farbige Kopfzeilen und Zellhervorhebungen im WYSIWYG-Editor
- Excel-Mehrblattimport: je Arbeitsblatt eine Dokumentation in einem gewählten Zielordner - Excel-Mehrblattimport: je Arbeitsblatt eine Dokumentation in einem gewählten Zielordner
- Zweistufige Excel-Vorschau mit Blattauswahl und frei änderbaren Dokumenttiteln
- Optionales Glätten von Excel-Tabellen in kompakte Feld-/Wert-Textblöcke - 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
@@ -24,7 +29,7 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit
## Kompatibilität ## Kompatibilität
Die Version `0.5.5` 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
@@ -125,6 +130,8 @@ Alte binäre `.doc`- und `.xls`-Dateien müssen vorher in `.docx` bzw. `.xlsx` k
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. 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.
Vor dem Anlegen erscheint eine Vorschauseite mit allen erkannten Arbeitsblättern. Dort können einzelne Blätter abgewählt, die zukünftigen Dokumenttitel geändert und die jeweils erzeugte Dokumentation aufgeklappt werden. Erst die abschließende Bestätigung schreibt Dokumentationen und Anhänge in die Datenbank. Nicht bestätigte Vorschauen sind an den Benutzer gebunden und werden nach 24 Stunden bereinigt.
Mit **„Excel-Tabellen glätten“** werden normale Zellen ohne künstliche Datensatz- oder Feldbezeichnungen in ihrer Lesereihenfolge übernommen. Zellen einer Excel-Zeile erscheinen als einzelne Textzeilen eines Absatzes; zwischen Excel-Zeilen wird ein Absatz eingefügt. Bereiche, die in Excel ausdrücklich über **Einfügen → Tabelle** als strukturierte Tabelle gekennzeichnet wurden, bleiben dagegen als Tabelle erhalten. Rein optisch mit Rahmen oder Farben formatierte Bereiche gelten nicht als Tabelle. 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. Mit **„Excel-Tabellen glätten“** werden normale Zellen ohne künstliche Datensatz- oder Feldbezeichnungen in ihrer Lesereihenfolge übernommen. Zellen einer Excel-Zeile erscheinen als einzelne Textzeilen eines Absatzes; zwischen Excel-Zeilen wird ein Absatz eingefügt. Bereiche, die in Excel ausdrücklich über **Einfügen → Tabelle** als strukturierte Tabelle gekennzeichnet wurden, bleiben dagegen als Tabelle erhalten. Rein optisch mit Rahmen oder Farben formatierte Bereiche gelten nicht als Tabelle. 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
+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.5.5" 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)
+110 -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")
@@ -201,3 +267,42 @@ class ArchiveImportForm(forms.Form):
if upload.size > limit * 1024 * 1024: if upload.size > limit * 1024 * 1024:
raise forms.ValidationError(f"Das Archiv ist größer als {limit} MB.") raise forms.ValidationError(f"Das Archiv ist größer als {limit} MB.")
return upload return upload
class ExcelSheetSelectionForm(forms.Form):
include = forms.BooleanField(
required=False, initial=True, label="Importieren",
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
)
index = forms.IntegerField(widget=forms.HiddenInput())
title = forms.CharField(
max_length=200, required=False, label="Dokumenttitel",
widget=forms.TextInput(attrs={"class": "form-control"}),
)
def clean(self):
data = super().clean()
if data.get("include") and not (data.get("title") or "").strip():
self.add_error("title", "Für ein ausgewähltes Arbeitsblatt ist ein Titel erforderlich.")
return data
class BaseExcelSheetSelectionFormSet(forms.BaseFormSet):
def clean(self):
super().clean()
if any(form.errors for form in self.forms):
return
selected = [form.cleaned_data for form in self.forms if form.cleaned_data.get("include")]
if not selected:
raise forms.ValidationError("Bitte mindestens ein Arbeitsblatt auswählen.")
indices = [item["index"] for item in selected]
if len(indices) != len(set(indices)):
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(
ExcelSheetSelectionForm, formset=BaseExcelSheetSelectionFormSet, extra=0
)
+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,30 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import netbox_documentation.models
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("netbox_documentation", "0003_documentcategory_document_category"),
]
operations = [
migrations.CreateModel(
name="ExcelImportPreview",
fields=[
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
("file", models.FileField(upload_to=netbox_documentation.models.preview_upload_path)),
("original_name", models.CharField(max_length=255)),
("content_type", models.CharField(blank=True, max_length=100)),
("flatten", models.BooleanField(default=False)),
("sheet_metadata", models.JSONField(default=list)),
("created", models.DateTimeField(auto_now_add=True)),
("category", models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to="netbox_documentation.documentcategory")),
("user", models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
options={"ordering": ("-created",)},
),
]
@@ -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",
),
),
]
+45 -1
View File
@@ -1,7 +1,10 @@
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import models from django.db import models
from django.conf import settings
from django.urls import reverse from django.urls import reverse
from pathlib import Path
import uuid
from netbox.models import NetBoxModel from netbox.models import NetBoxModel
@@ -16,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",)
@@ -50,7 +61,7 @@ class Document(NetBoxModel):
"*": ("class", "style"), "*": ("class", "style"),
} }
css = CSSSanitizer(allowed_css_properties={ css = CSSSanitizer(allowed_css_properties={
"background-color", "border", "border-color", "border-style", "border-width", "background", "background-color", "border", "border-color", "border-style", "border-width",
"color", "font-family", "font-size", "font-style", "font-weight", "height", "color", "font-family", "font-size", "font-style", "font-weight", "height",
"border-collapse", "line-height", "list-style-type", "margin", "margin-bottom", "border-collapse", "line-height", "list-style-type", "margin", "margin-bottom",
"margin-left", "margin-right", "margin-top", "padding", "padding-bottom", "margin-left", "margin-right", "margin-top", "padding", "padding-bottom",
@@ -65,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",)
@@ -141,3 +160,28 @@ class DocumentAttachment(NetBoxModel):
# have no standalone detail view. This is also used by NetBox's delete # have no standalone detail view. This is also used by NetBox's delete
# dependency collector when rendering the confirmation dialog. # dependency collector when rendering the confirmation dialog.
return self.document.get_absolute_url() return self.document.get_absolute_url()
def preview_upload_path(instance, filename):
return f"netbox_documentation/import-previews/{instance.pk}/{Path(filename).name}"
class ExcelImportPreview(models.Model):
"""Short-lived, user-bound state for the two-step Excel import workflow."""
_netbox_private = True
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
category = models.ForeignKey(DocumentCategory, on_delete=models.SET_NULL, null=True)
file = models.FileField(upload_to=preview_upload_path)
original_name = models.CharField(max_length=255)
content_type = models.CharField(max_length=100, blank=True)
flatten = models.BooleanField(default=False)
sheet_metadata = models.JSONField(default=list)
created = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ("-created",)
def __str__(self):
return self.original_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):
@@ -3,38 +3,85 @@
{% block head %} {% block head %}
{{ block.super }} {{ block.super }}
<style> <style>
.documentation-content { overflow-x: auto; } .documentation-content {
overflow-x: auto;
font-family: Arial, sans-serif;
font-size: 11pt;
}
.documentation-content table { .documentation-content table {
width: auto !important; width: auto;
max-width: 100%; max-width: none;
table-layout: auto; table-layout: auto;
font-size: .875rem; border-collapse: collapse;
line-height: 1.25;
} }
.documentation-content th, .documentation-content th,
.documentation-content td { .documentation-content td {
min-width: 3rem; padding: .45rem;
max-width: 22rem; border: 1px solid #adb5bd;
padding: .25rem .4rem !important;
overflow-wrap: anywhere;
word-break: normal; word-break: normal;
vertical-align: top; vertical-align: top;
} }
.documentation-content th {
font-weight: 700;
}
.documentation-content table.doc-table-full-width { width: 100% !important; }
.documentation-content table.doc-table-compact th,
.documentation-content table.doc-table-compact td { min-width: 5rem; padding: .2rem .35rem !important; font-size: .8rem; }
.documentation-content table.doc-table-striped tbody tr:nth-child(even) td { background: var(--tblr-bg-surface-secondary, #f1f3f5); }
.documentation-content table.doc-table-header-blue thead th { background: #206bc4 !important; color: #fff; }
.documentation-content table.doc-table-header-green thead th { background: #2b8a3e !important; color: #fff; }
.documentation-content table.doc-table-header-dark thead th { background: #343a40 !important; color: #fff; }
.documentation-content table.doc-table-borderless,
.documentation-content table.doc-table-borderless th,
.documentation-content table.doc-table-borderless td { border: 0 !important; }
.documentation-content .doc-cell-yellow { background: #fff3bf !important; color: #212529; }
.documentation-content .doc-cell-green { background: #d3f9d8 !important; color: #212529; }
.documentation-content .doc-cell-red { background: #ffe3e3 !important; color: #212529; }
.documentation-content .doc-cell-blue { background: #dbeafe !important; color: #212529; }
.documentation-content .doc-cell-middle { vertical-align: middle !important; }
.documentation-content p { max-width: 90rem; } .documentation-content p { max-width: 90rem; }
.documentation-content table { cursor: zoom-in; }
#documentation-viewer.documentation-page-focus {
position: fixed;
inset: 0;
z-index: 1055;
width: 100vw;
max-width: none;
height: 100vh;
padding: 1.5rem;
overflow: auto;
background: var(--tblr-body-bg, #fff);
}
#documentation-viewer.documentation-page-focus .card { min-height: calc(100vh - 5rem); }
#documentation-viewer.documentation-page-focus .fullscreen-document-title,
#documentation-viewer.documentation-page-focus .fullscreen-exit { display: block !important; }
#documentation-viewer.documentation-page-focus .documentation-content table { cursor: default; }
body.documentation-page-focus-open { overflow: hidden; }
.fullscreen-document-title { display: none; margin: 0 4rem 1rem 0; }
.fullscreen-exit { display: none; position: fixed; top: 1rem; right: 1rem; z-index: 10; }
</style> </style>
{% endblock head %} {% endblock head %}
{% block extra_controls %} {% block extra_controls %}
<button type="button" id="documentation-fullscreen" class="btn btn-outline-secondary">
<i class="mdi mdi-arrow-expand-all"></i> Großansicht
</button>
<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
</a> </a>
{% endblock extra_controls %} {% endblock extra_controls %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-9"> <div class="col col-md-9" id="documentation-viewer">
<h1 class="fullscreen-document-title">{{ object.title }}</h1>
<button type="button" class="btn btn-secondary fullscreen-exit" id="documentation-fullscreen-exit"><i class="mdi mdi-arrow-collapse-all"></i> Großansicht beenden</button>
{% 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 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 %}
@@ -45,3 +92,37 @@
</div> </div>
</div> </div>
{% endblock content %} {% endblock content %}
{% block javascript %}
{{ block.super }}
<script>
document.addEventListener('DOMContentLoaded', () => {
const viewer = document.getElementById('documentation-viewer');
const openButton = document.getElementById('documentation-fullscreen');
const closeButton = document.getElementById('documentation-fullscreen-exit');
if (!viewer || !openButton || !closeButton) return;
const openViewer = () => {
viewer.classList.add('documentation-page-focus');
document.body.classList.add('documentation-page-focus-open');
viewer.scrollTop = 0;
};
const closeViewer = () => {
viewer.classList.remove('documentation-page-focus');
document.body.classList.remove('documentation-page-focus-open');
};
openButton.addEventListener('click', openViewer);
closeButton.addEventListener('click', closeViewer);
viewer.querySelectorAll('.documentation-content table').forEach(table => {
table.title = 'Tabelle in Großansicht öffnen';
table.addEventListener('click', event => {
if (viewer.classList.contains('documentation-page-focus')) return;
if (event.target.closest('a, button, input, select, textarea')) return;
openViewer();
});
});
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && viewer.classList.contains('documentation-page-focus')) closeViewer();
});
});
</script>
{% endblock javascript %}
@@ -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>
@@ -35,14 +33,62 @@
menubar: 'file edit view insert format tools table help', menubar: 'file edit view insert format tools table help',
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 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_advtab: true,
table_cell_advtab: true,
table_row_advtab: true,
table_sizing_mode: 'responsive',
table_default_attributes: {border: '1'},
table_default_styles: {'border-collapse': 'collapse'},
table_class_list: [
{title: 'Standard mit Rahmen', value: 'doc-table-bordered'},
{title: 'Kompakt', value: 'doc-table-compact'},
{title: 'Gestreifte Zeilen', value: 'doc-table-striped'},
{title: 'Blaue Kopfzeile', value: 'doc-table-header-blue'},
{title: 'Grüne Kopfzeile', value: 'doc-table-header-green'},
{title: 'Dunkle Kopfzeile', value: 'doc-table-header-dark'},
{title: 'Ohne Rahmen', value: 'doc-table-borderless'},
{title: 'Volle Breite', value: 'doc-table-full-width'}
],
table_cell_class_list: [
{title: 'Normal', value: ''},
{title: 'Gelb hervorheben', value: 'doc-cell-yellow'},
{title: 'Grün hervorheben', value: 'doc-cell-green'},
{title: 'Rot hervorheben', value: 'doc-cell-red'},
{title: 'Blau hervorheben', value: 'doc-cell-blue'},
{title: 'Vertikal mittig', value: 'doc-cell-middle'}
],
style_formats_merge: true,
style_formats: [
{title: 'Tabellenzelle: Gelb', selector: 'td,th', classes: 'doc-cell-yellow'},
{title: 'Tabellenzelle: Grün', selector: 'td,th', classes: 'doc-cell-green'},
{title: 'Tabellenzelle: Rot', selector: 'td,th', classes: 'doc-cell-red'},
{title: 'Tabellenzelle: Blau', selector: 'td,th', classes: 'doc-cell-blue'}
],
font_family_formats: 'Arial=arial,helvetica,sans-serif; Calibri=calibri,arial,sans-serif; Courier New=courier new,courier,monospace; Georgia=georgia,palatino,serif; Times New Roman=times new roman,times,serif; Verdana=verdana,geneva,sans-serif', font_family_formats: 'Arial=arial,helvetica,sans-serif; Calibri=calibri,arial,sans-serif; Courier New=courier new,courier,monospace; Georgia=georgia,palatino,serif; Times New Roman=times new roman,times,serif; Verdana=verdana,geneva,sans-serif',
font_size_formats: '8pt 9pt 10pt 11pt 12pt 14pt 16pt 18pt 20pt 24pt 28pt 32pt 36pt 48pt', font_size_formats: '8pt 9pt 10pt 11pt 12pt 14pt 16pt 18pt 20pt 24pt 28pt 32pt 36pt 48pt',
content_style: 'body { font-family: Arial, sans-serif; font-size: 11pt; padding: 1rem; } table { border-collapse: collapse; width: 100%; } th, td { border: 1px solid #adb5bd; padding: .45rem; }', content_style: `
body { font-family: Arial, sans-serif; font-size: 11pt; padding: 1rem; }
table { border-collapse: collapse; width: auto; }
th, td { border: 1px solid #adb5bd; padding: .45rem; }
table.doc-table-full-width { width: 100%; }
table.doc-table-compact th, table.doc-table-compact td { padding: .2rem .35rem; font-size: .875rem; }
table.doc-table-striped tbody tr:nth-child(even) td { background: #f1f3f5; }
table.doc-table-header-blue thead th { background: #206bc4; color: #fff; }
table.doc-table-header-green thead th { background: #2b8a3e; color: #fff; }
table.doc-table-header-dark thead th { background: #343a40; color: #fff; }
table.doc-table-borderless, table.doc-table-borderless th, table.doc-table-borderless td { border: 0; }
.doc-cell-yellow { background: #fff3bf !important; }
.doc-cell-green { background: #d3f9d8 !important; }
.doc-cell-red { background: #ffe3e3 !important; }
.doc-cell-blue { background: #dbeafe !important; }
.doc-cell-middle { vertical-align: middle !important; }
`,
browser_spellcheck: true, browser_spellcheck: true,
contextmenu: 'link image table', contextmenu: 'link image table',
promotion: false, promotion: false,
@@ -55,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,
@@ -73,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>
@@ -93,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,16 +11,30 @@
.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; }
.content img { max-width: 100%; height: auto; } .content img { max-width: 100%; height: auto; }
.content table { width: auto; max-width: 100%; border-collapse: collapse; margin: 1em 0; font-size: 9pt; line-height: 1.25; } .content table { width: auto; max-width: 100%; border-collapse: collapse; margin: 1em 0; }
.content th, .content td { border: 1px solid #777; padding: .35rem .5rem; vertical-align: top; } .content th, .content td { border: 1px solid #adb5bd; padding: .45rem; vertical-align: top; overflow-wrap: anywhere; }
.content table.doc-table-full-width { width: 100%; }
.content table.doc-table-compact th, .content table.doc-table-compact td { padding: .15rem .25rem; font-size: 8pt; }
.content table.doc-table-striped tbody tr:nth-child(even) td { background: #f1f3f5; }
.content table.doc-table-header-blue thead th { background: #206bc4; color: #fff; }
.content table.doc-table-header-green thead th { background: #2b8a3e; color: #fff; }
.content table.doc-table-header-dark thead th { background: #343a40; color: #fff; }
.content table.doc-table-borderless, .content table.doc-table-borderless th, .content table.doc-table-borderless td { border: 0; }
.content .doc-cell-yellow { background: #fff3bf; }
.content .doc-cell-green { background: #d3f9d8; }
.content .doc-cell-red { background: #ffe3e3; }
.content .doc-cell-blue { background: #dbeafe; }
.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; }
@@ -34,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">
@@ -0,0 +1,82 @@
{% extends 'base/layout.html' %}
{% load form_helpers %}
{% block title %}Excel-Importvorschau{% endblock %}
{% block head %}
{{ block.super }}
<style>
.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 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:not([style*="background"]) { background: var(--tblr-bg-surface-secondary, #f1f3f5); }
</style>
{% endblock head %}
{% block content %}
<div class="card mb-4">
<div class="card-body">
<div class="d-flex flex-wrap gap-4">
<div><strong>Datei:</strong> {{ batch.original_name }}</div>
<div><strong>Zielordner:</strong> {{ batch.category|default:"Nicht mehr vorhanden" }}</div>
<div><strong>Glätten:</strong> {% if batch.flatten %}Ja{% else %}Nein{% endif %}</div>
</div>
</div>
</div>
<form method="post">
{% 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 }}
{% if formset.non_form_errors %}<div class="alert alert-danger">{{ formset.non_form_errors }}</div>{% endif %}
{% for item in items %}
<div class="card mb-4">
<div class="card-header">
<div class="row align-items-center w-100 g-3">
<div class="col-auto">
{{ item.form.include }}
<label for="{{ item.form.include.id_for_label }}" class="ms-1">Importieren</label>
</div>
<div class="col">
{{ item.form.index }}
<label for="{{ item.form.title.id_for_label }}" class="form-label mb-1">Dokumenttitel</label>
{{ item.form.title }}
{% for error in item.form.title.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
<div class="col-auto text-muted">
{{ item.sheet.images|length }} Bild{{ item.sheet.images|length|pluralize:"er" }}
</div>
</div>
</div>
<div class="card-body p-0">
<details>
<summary class="p-3 fw-bold" style="cursor:pointer">Vorschau der erzeugten Dokumentation anzeigen</summary>
<div class="excel-preview-content border-top p-3 rendered-markdown">
{{ item.preview_html|safe }}
</div>
</details>
</div>
</div>
{% endfor %}
<div class="sticky-actions sticky-actions-footer d-print-none" data-sticky-position="full" data-sticky-when="always">
<div class="btn-list">
<button type="submit" name="action" value="cancel" class="btn btn-outline-secondary">Abbrechen</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>
</form>
{% endblock content %}
+1
View File
@@ -8,6 +8,7 @@ urlpatterns = (
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/import/", views.DocumentImportView.as_view(), name="document_bulk_import"),
path("documents/import/excel/<uuid:token>/", views.ExcelImportPreviewView.as_view(), name="excel_import_preview"),
path("documents/delete/", views.DocumentBulkDeleteView.as_view(), name="document_bulk_delete"), 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"),
+236 -45
View File
@@ -1,4 +1,6 @@
from pathlib import Path from pathlib import Path
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
@@ -6,20 +8,25 @@ 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
from django.utils import timezone
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 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,
DocumentForm, ImportForm, DocumentForm, ExcelSheetSelectionFormSet, ImportForm,
) )
from .archive import ArchiveFailure, documents_for_categories, export_documents, import_archive from .archive import ArchiveFailure, documents_for_categories, export_documents, import_archive
from .importers import ImportFailure, import_document, import_excel_sheets from .importers import ImportFailure, import_document, import_excel_sheets
from .models import Document, DocumentAssignment, DocumentAttachment, DocumentCategory from .models import (
Document, DocumentAssignment, DocumentAttachment, DocumentCategory,
ExcelImportPreview,
)
from .tables import DocumentTable, AssignmentTable, DocumentCategoryTable from .tables import DocumentTable, AssignmentTable, DocumentCategoryTable
@@ -39,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):
@@ -78,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):
@@ -163,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)
@@ -175,58 +188,236 @@ class DocumentImportView(PermissionRequiredMixin, View):
return redirect(document) return redirect(document)
def _import_excel_sheets(self, request, form, upload): def _import_excel_sheets(self, request, form, upload):
from django.core.files.base import ContentFile
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})
category = form.cleaned_data["category"] # Remove abandoned previews owned by this user before creating a new one.
created_documents = [] stale = ExcelImportPreview.objects.filter(created__lt=timezone.now() - timedelta(hours=24))
for preview in stale:
preview.file.delete(save=False)
preview.delete()
upload.seek(0) upload.seek(0)
original_content = upload.read() preview = ExcelImportPreview.objects.create(
upload.seek(0) user=request.user, category=form.cleaned_data["category"], file=upload,
with transaction.atomic(): original_name=upload.name, content_type=upload.content_type or "",
for sheet in sheets: flatten=form.cleaned_data.get("flatten_excel_tables", False),
base_slug = slugify(sheet.title)[:180] or "arbeitsblatt" 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)
class ExcelImportPreviewView(PermissionRequiredMixin, View):
permission_required = "netbox_documentation.import_document"
template_name = "netbox_documentation/excel_import_preview.html"
def get_preview(self, request, token):
return get_object_or_404(ExcelImportPreview, pk=token, user=request.user)
def read_sheets(self, preview, *, for_preview=False, selected_indexes=None):
preview.file.open("rb")
try:
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:
preview.file.close()
def render_preview(self, request, preview, sheets, formset, overwrite_conflicts=None):
items = []
for form, sheet in zip(formset.forms, sheets):
sample = sheet.markdown
if sheet.images:
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
if truncated:
notice = "… Vorschau gekürzt …"
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({
"form": form, "sheet": sheet,
"preview_html": preview_document.rendered_body(), "truncated": truncated,
})
return render(request, self.template_name, {
"batch": preview, "formset": formset, "items": items,
"overwrite_conflicts": overwrite_conflicts or [],
})
def get(self, request, token):
preview = self.get_preview(request, token)
if preview.created < timezone.now() - timedelta(hours=24):
self.delete_preview(preview)
messages.error(request, "Diese Importvorschau ist abgelaufen. Bitte die Exceldatei erneut hochladen.")
return redirect("plugins:netbox_documentation:document_import")
try:
sheets = self.read_sheets(preview, for_preview=True)
except (ImportFailure, Exception) as exc:
self.delete_preview(preview)
messages.error(request, f"Die Excel-Vorschau konnte nicht erzeugt werden: {exc}")
return redirect("plugins:netbox_documentation:document_import")
initial = [{"include": True, "index": index, "title": sheet.title} for index, sheet in enumerate(sheets)]
return self.render_preview(request, preview, sheets, ExcelSheetSelectionFormSet(initial=initial))
def post(self, request, token):
preview = self.get_preview(request, token)
if request.POST.get("action") == "cancel":
self.delete_preview(preview)
messages.info(request, "Excel-Import wurde abgebrochen.")
return redirect("plugins:netbox_documentation:document_import")
formset = ExcelSheetSelectionFormSet(request.POST)
if not formset.is_valid():
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:
self.delete_preview(preview)
messages.error(request, "Der gewählte Zielordner existiert nicht mehr. Bitte den Import erneut starten.")
return redirect("plugins:netbox_documentation:document_import")
selections = {}
for form in formset.forms:
data = form.cleaned_data
if data.get("include"):
if data["index"] < 0 or data["index"] >= len(preview.sheet_metadata):
raise PermissionDenied
metadata = preview.sheet_metadata[data["index"]]
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)
messages.success(request, (
f"{result['documents']} Arbeitsblätter verarbeitet "
f"({result['created']} neu, {result['updated']} aktualisiert) in „{result['category']}“; "
f"{result['images']} Bilder übernommen."
))
return redirect("plugins:netbox_documentation:document_list")
@staticmethod
@transaction.atomic
def create_documents(preview, sheets, selections):
from django.core.files.base import ContentFile
created_documents, image_count, created_count, updated_count = [], 0, 0, 0
for index, title in selections.items():
sheet = sheets[index]
document = Document.objects.select_for_update().filter(
category=preview.category, title__iexact=title,
).order_by("pk").first()
if document:
document.title = title[:200]
document.body = sheet.markdown
document.body_format = sheet.body_format
document.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 document_slug, counter = base_slug, 2
while Document.objects.filter(slug=document_slug).exists(): while Document.objects.filter(slug=document_slug).exists():
document_slug = f"{base_slug[:190-len(str(counter))]}-{counter}" document_slug = f"{base_slug[:190-len(str(counter))]}-{counter}"
counter += 1 counter += 1
document = Document.objects.create( document = Document.objects.create(
title=sheet.title[:200], slug=document_slug, body=sheet.markdown, title=title[:200], slug=document_slug, body=sheet.markdown,
body_format="markdown", category=category, body_format=sheet.body_format, category=preview.category,
summary=f"Importiert aus {upload.name}", tenant_group=preview.category.tenant_group, tenant=preview.category.tenant,
summary=f"Importiert aus {preview.original_name}",
) )
if sheet.images: created_count += 1
image_lines = [] if sheet.images:
for image in sheet.images: image_lines = []
attachment = DocumentAttachment( for image in sheet.images:
document=document, original_name=image.name,
content_type=image.content_type, size=len(image.content),
)
attachment.file.save(image.name, ContentFile(image.content), save=False)
attachment.save()
location = f" ({image.cell})" if image.cell else ""
image_lines.append(f"![{image.name}{location}]({attachment.file.url})")
document.body += "\n\n## Bilder\n\n" + "\n\n".join(image_lines)
document.save(update_fields=("body", "last_updated"))
created_documents.append(document)
keep = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("keep_imported_file", True)
if keep:
for document in created_documents:
attachment = DocumentAttachment( attachment = DocumentAttachment(
document=document, original_name=upload.name, document=document, original_name=image.name,
content_type=upload.content_type or "", size=len(original_content), content_type=image.content_type, size=len(image.content),
) )
attachment.file.save(upload.name, ContentFile(original_content), save=False) attachment.file.save(image.name, ContentFile(image.content), save=False)
attachment.save() attachment.save()
image_count = sum(len(sheet.images) for sheet in sheets) location = f" ({image.cell})" if image.cell else ""
messages.success(request, ( if document.body_format == "html":
f"{len(created_documents)} Arbeitsblätter als Dokumentationen in „{category}“ importiert; " image_lines.append(
f"{image_count} Bilder übernommen." f'<p><img src="{escape(attachment.file.url)}" alt="{escape(image.name + location)}"></p>'
)) )
return redirect("plugins:netbox_documentation:document_list") 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"))
image_count += len(sheet.images)
created_documents.append(document)
if settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("keep_imported_file", True):
for document in created_documents:
preview.file.open("rb")
attachment = DocumentAttachment(
document=document, original_name=preview.original_name,
content_type=preview.content_type, size=preview.file.size,
)
try:
attachment.file.save(preview.original_name, preview.file, save=False)
attachment.save()
finally:
preview.file.close()
return {
"documents": len(created_documents), "created": created_count,
"updated": updated_count, "images": image_count, "category": preview.category,
}
@staticmethod
def delete_preview(preview):
preview.file.delete(save=False)
preview.delete()
class DocumentMediaUploadView(PermissionRequiredMixin, View): class DocumentMediaUploadView(PermissionRequiredMixin, View):
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "netbox-documentation" name = "netbox-documentation"
version = "0.5.5" 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