feat: WYSIWYG-Editor für Dokumentationen hinzufügen
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")),
|
||||
]
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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)
|
||||
|
||||
|
||||
+3
-1
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user