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
|
## 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
|
- Eine Dokumentation mehreren Objekten zuordnen und umgekehrt
|
||||||
- Unterstützte Standardobjekte: Region, Standort, Location, Rack, Gerät, VM, VM-Cluster und Mandant/Kunde
|
- Unterstützte Standardobjekte: Region, Standort, Location, Rack, Gerät, VM, VM-Cluster und Mandant/Kunde
|
||||||
- DOCX, XLSX/XLSM, textbasierte PDF-, Markdown- und Textdateien importieren
|
- 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
|
## 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
|
## Installation
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ PLUGINS_CONFIG = {
|
|||||||
"netbox_documentation": {
|
"netbox_documentation": {
|
||||||
"max_import_size_mb": 25,
|
"max_import_size_mb": 25,
|
||||||
"keep_imported_file": True,
|
"keep_imported_file": True,
|
||||||
|
"editor_script_url": "https://cdn.jsdelivr.net/npm/tinymce@7/tinymce.min.js",
|
||||||
"allowed_object_types": [
|
"allowed_object_types": [
|
||||||
"dcim.region",
|
"dcim.region",
|
||||||
"dcim.site",
|
"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:
|
Danach die Migrationen und statischen Dateien aktualisieren und NetBox neu starten:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ class DocumentationConfig(PluginConfig):
|
|||||||
name = "netbox_documentation"
|
name = "netbox_documentation"
|
||||||
verbose_name = "Dokumentation"
|
verbose_name = "Dokumentation"
|
||||||
description = "Wiki und Office-Dokumentation direkt in NetBox"
|
description = "Wiki und Office-Dokumentation direkt in NetBox"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
author = "NetBox Documentation Contributors"
|
author = "NetBox Documentation Contributors"
|
||||||
base_url = "documentation"
|
base_url = "documentation"
|
||||||
min_version = "4.0.0"
|
min_version = "4.0.0"
|
||||||
@@ -17,8 +17,9 @@ class DocumentationConfig(PluginConfig):
|
|||||||
],
|
],
|
||||||
"max_import_size_mb": 25,
|
"max_import_size_mb": 25,
|
||||||
"keep_imported_file": True,
|
"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
|
config = DocumentationConfig
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class DocumentSerializer(NetBoxModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Document
|
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):
|
class DocumentAssignmentSerializer(NetBoxModelSerializer):
|
||||||
@@ -17,4 +17,3 @@ class DocumentAssignmentSerializer(NetBoxModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = DocumentAssignment
|
model = DocumentAssignment
|
||||||
fields = ("id", "url", "display", "document", "assigned_object_type", "assigned_object_id", "note", "tags", "created", "last_updated")
|
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):
|
class DocumentForm(NetBoxModelForm):
|
||||||
slug = SlugField(slug_source="title")
|
slug = SlugField(slug_source="title")
|
||||||
body = forms.CharField(required=False, widget=forms.Textarea(attrs={
|
body = forms.CharField(required=False, label="Inhalt", widget=forms.Textarea(attrs={
|
||||||
"rows": 28, "class": "font-monospace", "data-markdown-editor": "true"
|
"rows": 32, "class": "rich-text-editor", "data-rich-text-editor": "true"
|
||||||
}), help_text="Markdown wird unterstützt. HTML wird bei der Ausgabe sicher gefiltert.")
|
}), help_text="Formatierter Text mit Tabellen, Farben, Schriftarten und Größen.")
|
||||||
|
body_format = forms.CharField(widget=forms.HiddenInput(), initial="html")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Document
|
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):
|
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)),
|
("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)),
|
("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", 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)),
|
("is_published", models.BooleanField(default=True)),
|
||||||
("tags", models.ManyToManyField(blank=True, related_name="netbox_documentation_document_items", to="extras.tag")),
|
("tags", models.ManyToManyField(blank=True, related_name="netbox_documentation_document_items", to="extras.tag")),
|
||||||
], options={"ordering": ("title",), "permissions": (("import_document", "Can import office documents"),)}),
|
], options={"ordering": ("title",), "permissions": (("import_document", "Can import office documents"),)}),
|
||||||
@@ -39,4 +40,3 @@ class Migration(migrations.Migration):
|
|||||||
], options={"ordering": ("original_name",)}),
|
], 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")),
|
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
|
from netbox.models import NetBoxModel
|
||||||
|
|
||||||
|
|
||||||
|
BODY_FORMAT_CHOICES = (("html", "Rich Text"), ("markdown", "Markdown"))
|
||||||
|
|
||||||
|
|
||||||
class Document(NetBoxModel):
|
class Document(NetBoxModel):
|
||||||
title = models.CharField(max_length=200)
|
title = models.CharField(max_length=200)
|
||||||
slug = models.SlugField(max_length=200, unique=True)
|
slug = models.SlugField(max_length=200, unique=True)
|
||||||
body = models.TextField(blank=True, help_text="Markdown")
|
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)
|
summary = models.CharField(max_length=500, blank=True)
|
||||||
is_published = models.BooleanField(default=True)
|
is_published = models.BooleanField(default=True)
|
||||||
|
|
||||||
@@ -22,6 +26,35 @@ class Document(NetBoxModel):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("plugins:netbox_documentation:document", args=[self.pk])
|
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):
|
class DocumentAssignment(NetBoxModel):
|
||||||
document = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="assignments")
|
document = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="assignments")
|
||||||
@@ -57,4 +90,3 @@ class DocumentAttachment(NetBoxModel):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.original_name
|
return self.original_name
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-md-9">
|
<div class="col col-md-9">
|
||||||
{% 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">{{ object.body|render_markdown }}</div></div>
|
<div class="card"><div class="card-body rendered-markdown">{{ object.rendered_body|safe }}</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col col-md-3">
|
<div class="col col-md-3">
|
||||||
<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">
|
||||||
@@ -16,4 +16,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% 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):
|
class DocumentEditView(generic.ObjectEditView):
|
||||||
queryset = Document.objects.all()
|
queryset = Document.objects.all()
|
||||||
form = DocumentForm
|
form = DocumentForm
|
||||||
|
template_name = "netbox_documentation/document_edit.html"
|
||||||
|
|
||||||
|
|
||||||
class DocumentDeleteView(generic.ObjectDeleteView):
|
class DocumentDeleteView(generic.ObjectDeleteView):
|
||||||
@@ -69,8 +70,12 @@ class DocumentImportView(PermissionRequiredMixin, View):
|
|||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
document = form.cleaned_data.get("document")
|
document = form.cleaned_data.get("document")
|
||||||
if 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 ""
|
separator = "\n\n---\n\n" if document.body else ""
|
||||||
document.body += separator + result.markdown
|
document.body += separator + imported_body
|
||||||
document.save()
|
document.save()
|
||||||
else:
|
else:
|
||||||
title = form.cleaned_data.get("title") or Path(upload.name).stem
|
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():
|
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)
|
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)
|
keep = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("keep_imported_file", True)
|
||||||
if keep:
|
if keep:
|
||||||
upload.seek(0)
|
upload.seek(0)
|
||||||
@@ -90,4 +95,3 @@ class DocumentImportView(PermissionRequiredMixin, View):
|
|||||||
messages.warning(request, warning)
|
messages.warning(request, warning)
|
||||||
messages.success(request, f"{upload.name} wurde importiert.")
|
messages.success(request, f"{upload.name} wurde importiert.")
|
||||||
return redirect(document)
|
return redirect(document)
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "netbox-documentation"
|
name = "netbox-documentation"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
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"
|
||||||
@@ -14,6 +14,8 @@ dependencies = [
|
|||||||
"openpyxl>=3.1,<4",
|
"openpyxl>=3.1,<4",
|
||||||
"pypdf>=5,<7",
|
"pypdf>=5,<7",
|
||||||
"tabulate>=0.9,<1",
|
"tabulate>=0.9,<1",
|
||||||
|
"bleach[css]>=6.2,<7",
|
||||||
|
"markdown>=3.7,<4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
Reference in New Issue
Block a user