From 7bc6e5ec5407e599ffd8347311c1260359d95b04 Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 22 Jul 2026 10:35:20 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20WYSIWYG-Editor=20f=C3=BCr=20Dokumentati?= =?UTF-8?q?onen=20hinzuf=C3=BCgen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +++- netbox_documentation/__init__.py | 5 ++- netbox_documentation/api/serializers.py | 3 +- netbox_documentation/forms.py | 18 ++++++-- .../migrations/0001_initial.py | 2 +- netbox_documentation/models.py | 34 ++++++++++++++- .../netbox_documentation/document.html | 3 +- .../netbox_documentation/document_edit.html | 42 +++++++++++++++++++ netbox_documentation/views.py | 10 +++-- pyproject.toml | 4 +- 10 files changed, 111 insertions(+), 18 deletions(-) create mode 100644 netbox_documentation/templates/netbox_documentation/document_edit.html diff --git a/README.md b/README.md index 12ce3b7..faead6d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit ## Funktionen -- Dokumentationen direkt in NetBox als Markdown schreiben und sicher gerendert 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 - Eine Dokumentation mehreren Objekten zuordnen und umgekehrt - Unterstützte Standardobjekte: Region, Standort, Location, Rack, Gerät, VM, VM-Cluster und Mandant/Kunde - DOCX, XLSX/XLSM, textbasierte PDF-, Markdown- und Textdateien importieren @@ -14,7 +15,7 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit ## Kompatibilität -Die Version `0.1.0` zielt auf NetBox 4.x (mindestens 4.0). Vor einem produktiven Rollout sollte das Plugin gegen die konkret eingesetzte NetBox-Minor-Version in einer Testinstanz geprüft werden. +Die Version `0.2.0` zielt auf NetBox 4.x (mindestens 4.0). Vor einem produktiven Rollout sollte das Plugin gegen die konkret eingesetzte NetBox-Minor-Version in einer Testinstanz geprüft werden. ## Installation @@ -63,6 +64,7 @@ PLUGINS_CONFIG = { "netbox_documentation": { "max_import_size_mb": 25, "keep_imported_file": True, + "editor_script_url": "https://cdn.jsdelivr.net/npm/tinymce@7/tinymce.min.js", "allowed_object_types": [ "dcim.region", "dcim.site", @@ -77,6 +79,8 @@ PLUGINS_CONFIG = { } ``` +Der Rich-Text-Editor wird standardmäßig über jsDelivr geladen. In abgeschotteten Netzen kann TinyMCE 7 lokal unter NetBox `STATIC_ROOT` bereitgestellt und `editor_script_url` auf die interne URL gesetzt werden. Das verwendete TinyMCE wird im GPL-Modus betrieben. Kann das Script nicht geladen werden, bleibt als Rückfall ein normales HTML-Textfeld verfügbar. + Danach die Migrationen und statischen Dateien aktualisieren und NetBox neu starten: ```bash diff --git a/netbox_documentation/__init__.py b/netbox_documentation/__init__.py index 71d3b52..b7b73f5 100644 --- a/netbox_documentation/__init__.py +++ b/netbox_documentation/__init__.py @@ -5,7 +5,7 @@ class DocumentationConfig(PluginConfig): name = "netbox_documentation" verbose_name = "Dokumentation" description = "Wiki und Office-Dokumentation direkt in NetBox" - version = "0.1.0" + version = "0.2.0" author = "NetBox Documentation Contributors" base_url = "documentation" min_version = "4.0.0" @@ -17,8 +17,9 @@ class DocumentationConfig(PluginConfig): ], "max_import_size_mb": 25, "keep_imported_file": True, + # Can be replaced with a locally hosted TinyMCE bundle for offline use. + "editor_script_url": "https://cdn.jsdelivr.net/npm/tinymce@7/tinymce.min.js", } config = DocumentationConfig - diff --git a/netbox_documentation/api/serializers.py b/netbox_documentation/api/serializers.py index a55035a..3ff406a 100644 --- a/netbox_documentation/api/serializers.py +++ b/netbox_documentation/api/serializers.py @@ -8,7 +8,7 @@ class DocumentSerializer(NetBoxModelSerializer): class Meta: model = Document - fields = ("id", "url", "display", "title", "slug", "summary", "body", "is_published", "tags", "created", "last_updated") + fields = ("id", "url", "display", "title", "slug", "summary", "body", "body_format", "is_published", "tags", "created", "last_updated") class DocumentAssignmentSerializer(NetBoxModelSerializer): @@ -17,4 +17,3 @@ class DocumentAssignmentSerializer(NetBoxModelSerializer): class Meta: model = DocumentAssignment fields = ("id", "url", "display", "document", "assigned_object_type", "assigned_object_id", "note", "tags", "created", "last_updated") - diff --git a/netbox_documentation/forms.py b/netbox_documentation/forms.py index a795f3a..3f9a130 100644 --- a/netbox_documentation/forms.py +++ b/netbox_documentation/forms.py @@ -20,13 +20,23 @@ def allowed_content_types(): class DocumentForm(NetBoxModelForm): slug = SlugField(slug_source="title") - body = forms.CharField(required=False, widget=forms.Textarea(attrs={ - "rows": 28, "class": "font-monospace", "data-markdown-editor": "true" - }), help_text="Markdown wird unterstützt. HTML wird bei der Ausgabe sicher gefiltert.") + body = forms.CharField(required=False, label="Inhalt", widget=forms.Textarea(attrs={ + "rows": 32, "class": "rich-text-editor", "data-rich-text-editor": "true" + }), help_text="Formatierter Text mit Tabellen, Farben, Schriftarten und Größen.") + body_format = forms.CharField(widget=forms.HiddenInput(), initial="html") class Meta: model = Document - fields = ("title", "slug", "summary", "body", "is_published", "tags") + fields = ("title", "slug", "summary", "body", "body_format", "is_published", "tags") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Legacy Markdown remains readable. Once edited in WYSIWYG mode it is + # converted to HTML so the editor never exposes Markdown as plain text. + if self.instance.pk and self.instance.body_format == "markdown" and not self.is_bound: + from markdown import markdown + self.initial["body"] = markdown(self.instance.body, extensions=("extra", "sane_lists")) + self.initial["body_format"] = "html" class AssignmentForm(NetBoxModelForm): diff --git a/netbox_documentation/migrations/0001_initial.py b/netbox_documentation/migrations/0001_initial.py index 38687bf..769f619 100644 --- a/netbox_documentation/migrations/0001_initial.py +++ b/netbox_documentation/migrations/0001_initial.py @@ -15,6 +15,7 @@ class Migration(migrations.Migration): ("custom_field_data", models.JSONField(blank=True, default=dict, encoder=netbox.models.features.CustomFieldJSONEncoder)), ("title", models.CharField(max_length=200)), ("slug", models.SlugField(max_length=200, unique=True)), ("body", models.TextField(blank=True, help_text="Markdown")), ("summary", models.CharField(blank=True, max_length=500)), + ("body_format", models.CharField(choices=[("html", "Rich Text"), ("markdown", "Markdown")], default="html", max_length=10)), ("is_published", models.BooleanField(default=True)), ("tags", models.ManyToManyField(blank=True, related_name="netbox_documentation_document_items", to="extras.tag")), ], options={"ordering": ("title",), "permissions": (("import_document", "Can import office documents"),)}), @@ -39,4 +40,3 @@ class Migration(migrations.Migration): ], options={"ordering": ("original_name",)}), migrations.AddConstraint(model_name="documentassignment", constraint=models.UniqueConstraint(fields=("document", "assigned_object_type", "assigned_object_id"), name="netbox_documentation_documentassignment_unique_assignment")), ] - diff --git a/netbox_documentation/models.py b/netbox_documentation/models.py index a033f96..453a739 100644 --- a/netbox_documentation/models.py +++ b/netbox_documentation/models.py @@ -5,10 +5,14 @@ from django.urls import reverse from netbox.models import NetBoxModel +BODY_FORMAT_CHOICES = (("html", "Rich Text"), ("markdown", "Markdown")) + + class Document(NetBoxModel): title = models.CharField(max_length=200) slug = models.SlugField(max_length=200, unique=True) body = models.TextField(blank=True, help_text="Markdown") + body_format = models.CharField(max_length=10, choices=BODY_FORMAT_CHOICES, default="html") summary = models.CharField(max_length=500, blank=True) is_published = models.BooleanField(default=True) @@ -22,6 +26,35 @@ class Document(NetBoxModel): def get_absolute_url(self): return reverse("plugins:netbox_documentation:document", args=[self.pk]) + def rendered_body(self): + """Render Markdown/HTML and remove executable or unsafe markup.""" + import bleach + from bleach.css_sanitizer import CSSSanitizer + from markdown import markdown + + value = markdown(self.body, extensions=("extra", "sane_lists")) if self.body_format == "markdown" else self.body + tags = { + "a", "abbr", "blockquote", "br", "caption", "code", "col", "colgroup", "div", + "em", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "img", "li", "ol", "p", + "pre", "span", "strong", "sub", "sup", "table", "tbody", "td", "tfoot", "th", + "thead", "tr", "u", "ul", + } + attributes = { + "a": ("href", "title", "target", "rel"), + "img": ("src", "alt", "title", "width", "height"), + "table": ("class", "style"), + "th": ("colspan", "rowspan", "scope", "style"), + "td": ("colspan", "rowspan", "style"), + "col": ("span", "style"), + "*": ("class", "style"), + } + css = CSSSanitizer(allowed_css_properties={ + "background-color", "border", "border-color", "border-style", "border-width", + "color", "font-family", "font-size", "font-style", "font-weight", "height", + "margin-left", "text-align", "text-decoration", "vertical-align", "width", + }) + return bleach.clean(value, tags=tags, attributes=attributes, protocols=("http", "https", "mailto"), css_sanitizer=css) + class DocumentAssignment(NetBoxModel): document = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="assignments") @@ -57,4 +90,3 @@ class DocumentAttachment(NetBoxModel): def __str__(self): return self.original_name - diff --git a/netbox_documentation/templates/netbox_documentation/document.html b/netbox_documentation/templates/netbox_documentation/document.html index 2231c41..4e9b73a 100644 --- a/netbox_documentation/templates/netbox_documentation/document.html +++ b/netbox_documentation/templates/netbox_documentation/document.html @@ -4,7 +4,7 @@
{% if object.summary %}

{{ object.summary }}

{% endif %} -
{{ object.body|render_markdown }}
+
{{ object.rendered_body|safe }}
Zuordnungen
@@ -16,4 +16,3 @@
{% endblock content %} - diff --git a/netbox_documentation/templates/netbox_documentation/document_edit.html b/netbox_documentation/templates/netbox_documentation/document_edit.html new file mode 100644 index 0000000..270a22e --- /dev/null +++ b/netbox_documentation/templates/netbox_documentation/document_edit.html @@ -0,0 +1,42 @@ +{% extends 'generic/object_edit.html' %} + +{% block javascript %} + {{ block.super }} + + +{% endblock javascript %} diff --git a/netbox_documentation/views.py b/netbox_documentation/views.py index e762975..b614779 100644 --- a/netbox_documentation/views.py +++ b/netbox_documentation/views.py @@ -27,6 +27,7 @@ class DocumentView(generic.ObjectView): class DocumentEditView(generic.ObjectEditView): queryset = Document.objects.all() form = DocumentForm + template_name = "netbox_documentation/document_edit.html" class DocumentDeleteView(generic.ObjectDeleteView): @@ -69,8 +70,12 @@ class DocumentImportView(PermissionRequiredMixin, View): with transaction.atomic(): document = form.cleaned_data.get("document") if document: + imported_body = result.markdown + if document.body_format == "html": + from markdown import markdown + imported_body = markdown(imported_body, extensions=("extra", "sane_lists")) separator = "\n\n---\n\n" if document.body else "" - document.body += separator + result.markdown + document.body += separator + imported_body document.save() else: title = form.cleaned_data.get("title") or Path(upload.name).stem @@ -80,7 +85,7 @@ class DocumentImportView(PermissionRequiredMixin, View): while Document.objects.filter(slug=slug).exists(): slug = f"{base_slug}-{counter}" counter += 1 - document = Document.objects.create(title=title, slug=slug, body=result.markdown) + document = Document.objects.create(title=title, slug=slug, body=result.markdown, body_format="markdown") keep = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("keep_imported_file", True) if keep: upload.seek(0) @@ -90,4 +95,3 @@ class DocumentImportView(PermissionRequiredMixin, View): messages.warning(request, warning) messages.success(request, f"{upload.name} wurde importiert.") return redirect(document) - diff --git a/pyproject.toml b/pyproject.toml index 2babedd..745b1eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netbox-documentation" -version = "0.1.0" +version = "0.2.0" description = "Integrated Markdown wiki and office document importer for NetBox" readme = "README.md" requires-python = ">=3.10" @@ -14,6 +14,8 @@ dependencies = [ "openpyxl>=3.1,<4", "pypdf>=5,<7", "tabulate>=0.9,<1", + "bleach[css]>=6.2,<7", + "markdown>=3.7,<4", ] [project.optional-dependencies]