Compare commits
23
Commits
5b00c9b96f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
117ffac210 | ||
|
|
2af5ea997d | ||
|
|
791b40b3c4 | ||
|
|
d20baa3ea3 | ||
|
|
b632a5bb04 | ||
|
|
8d6b6a46db | ||
|
|
83cac4447a | ||
|
|
dbf3e7a62a | ||
|
|
4690e67b32 | ||
|
|
214502277f | ||
|
|
5e17e9ecdb | ||
|
|
3f2e7ca667 | ||
|
|
e4f4cd7c14 | ||
|
|
8a2ccfd516 | ||
|
|
6ef3a593b9 | ||
|
|
d9ded6287f | ||
|
|
bf68c9c9ed | ||
|
|
58637e1200 | ||
|
|
601c7308ce | ||
|
|
df11d0eb05 | ||
|
|
ae9c39865b | ||
|
|
631ce4d13a | ||
|
|
523e03d26b |
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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')}"
|
||||||
|
|||||||
@@ -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 "")
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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
@@ -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"")
|
|
||||||
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"")
|
||||||
|
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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user