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 @@
{{ object.summary }}
{% endif %} -