feat: WYSIWYG-Editor für Dokumentationen hinzufügen

This commit is contained in:
2026-07-22 10:35:20 +02:00
parent 5d23c63ac5
commit 7bc6e5ec54
10 changed files with 111 additions and 18 deletions
+3 -2
View File
@@ -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
+1 -2
View File
@@ -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")
+14 -4
View File
@@ -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):
@@ -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")),
]
+33 -1
View File
@@ -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
@@ -4,7 +4,7 @@
<div class="row">
<div class="col col-md-9">
{% if object.summary %}<p class="lead">{{ object.summary }}</p>{% endif %}
<div class="card"><div class="card-body rendered-markdown">{{ object.body|render_markdown }}</div></div>
<div class="card"><div class="card-body rendered-markdown">{{ object.rendered_body|safe }}</div></div>
</div>
<div class="col col-md-3">
<div class="card"><h5 class="card-header">Zuordnungen</h5><div class="list-group list-group-flush">
@@ -16,4 +16,3 @@
</div>
</div>
{% endblock content %}
@@ -0,0 +1,42 @@
{% extends 'generic/object_edit.html' %}
{% block javascript %}
{{ block.super }}
<script src="{{ config.editor_script_url }}" referrerpolicy="origin"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
if (typeof tinymce === 'undefined') {
const editor = document.querySelector('[data-rich-text-editor]');
if (editor) {
const warning = document.createElement('div');
warning.className = 'alert alert-warning';
warning.textContent = 'Der Rich-Text-Editor konnte nicht geladen werden. Der Inhalt kann als HTML bearbeitet werden.';
editor.parentNode.insertBefore(warning, editor);
}
return;
}
tinymce.init({
selector: 'textarea[data-rich-text-editor]',
license_key: 'gpl',
height: 650,
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',
toolbar: [
'undo redo | blocks fontfamily fontsize | bold italic underline strikethrough | forecolor backcolor',
'alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table link image media | removeformat code fullscreen'
],
toolbar_mode: 'sliding',
table_toolbar: 'tableprops tablecellprops | tableinsertrowbefore tableinsertrowafter tabledeleterow | tableinsertcolbefore tableinsertcolafter tabledeletecol | tablemergecells tablesplitcells | tabledelete',
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',
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; }',
browser_spellcheck: true,
contextmenu: 'link image table',
promotion: false,
branding: false,
convert_unsafe_embeds: true,
relative_urls: false
});
});
</script>
{% endblock javascript %}
+7 -3
View File
@@ -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)