feat: Ordnerstruktur, Medienupload und erweiterten Editor ergänzen

- NetBox-4.6-Verknüpfungsformular korrigieren
- hierarchische Dokumentationsordner hinzufügen
- breite Editoransicht und Fokusmodus implementieren
- sicheren direkten Bild-Upload integrieren
- Ordner über Suche und REST-API bereitstellen
This commit is contained in:
2026-07-22 11:04:10 +02:00
parent da0774b3aa
commit 59598fbd74
19 changed files with 324 additions and 47 deletions
+5 -1
View File
@@ -6,6 +6,9 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit
- Dokumentationen direkt in NetBox schreiben und als sicher bereinigten Rich Text 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 - Word-ähnlicher WYSIWYG-Editor mit Schriftarten, Schriftgrößen, Farben, Ausrichtung und Tabellen
- Hierarchische Ordner und Unterordner für eine BookStack-ähnliche Struktur
- Breite Editoransicht und Fokusmodus innerhalb des NetBox-Layouts
- Bilder direkt vom eigenen Gerät in die Dokumentation hochladen
- 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
@@ -15,7 +18,7 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit
## Kompatibilität ## Kompatibilität
Die Version `0.2.2` 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.3.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
@@ -119,6 +122,7 @@ Nach Aktivierung stehen die üblichen NetBox-Plugin-Endpunkte bereit:
- `/api/plugins/documentation/documents/` - `/api/plugins/documentation/documents/`
- `/api/plugins/documentation/assignments/` - `/api/plugins/documentation/assignments/`
- `/api/plugins/documentation/folders/`
## Entwicklung und Tests ## Entwicklung und Tests
+1 -1
View File
@@ -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.2.2" version = "0.3.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"
+10 -2
View File
@@ -1,6 +1,6 @@
from netbox.api.serializers import NetBoxModelSerializer from netbox.api.serializers import NetBoxModelSerializer
from rest_framework import serializers from rest_framework import serializers
from ..models import Document, DocumentAssignment from ..models import Document, DocumentAssignment, DocumentCategory
class DocumentSerializer(NetBoxModelSerializer): class DocumentSerializer(NetBoxModelSerializer):
@@ -8,7 +8,7 @@ class DocumentSerializer(NetBoxModelSerializer):
class Meta: class Meta:
model = Document model = Document
fields = ("id", "url", "display", "title", "slug", "summary", "body", "body_format", "is_published", "tags", "created", "last_updated") fields = ("id", "url", "display", "title", "slug", "category", "summary", "body", "body_format", "is_published", "tags", "created", "last_updated")
class DocumentAssignmentSerializer(NetBoxModelSerializer): class DocumentAssignmentSerializer(NetBoxModelSerializer):
@@ -17,3 +17,11 @@ 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")
class DocumentCategorySerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_documentation-api:documentcategory-detail")
class Meta:
model = DocumentCategory
fields = ("id", "url", "display", "name", "slug", "parent", "description", "tags", "created", "last_updated")
+1 -1
View File
@@ -4,5 +4,5 @@ from . import views
router = NetBoxRouter() router = NetBoxRouter()
router.register("documents", views.DocumentViewSet) router.register("documents", views.DocumentViewSet)
router.register("assignments", views.DocumentAssignmentViewSet) router.register("assignments", views.DocumentAssignmentViewSet)
router.register("folders", views.DocumentCategoryViewSet)
urlpatterns = router.urls urlpatterns = router.urls
+8 -3
View File
@@ -1,7 +1,7 @@
from netbox.api.viewsets import NetBoxModelViewSet from netbox.api.viewsets import NetBoxModelViewSet
from ..filtersets import DocumentFilterSet, AssignmentFilterSet from ..filtersets import DocumentFilterSet, AssignmentFilterSet, DocumentCategoryFilterSet
from ..models import Document, DocumentAssignment from ..models import Document, DocumentAssignment, DocumentCategory
from .serializers import DocumentSerializer, DocumentAssignmentSerializer from .serializers import DocumentSerializer, DocumentAssignmentSerializer, DocumentCategorySerializer
class DocumentViewSet(NetBoxModelViewSet): class DocumentViewSet(NetBoxModelViewSet):
@@ -15,3 +15,8 @@ class DocumentAssignmentViewSet(NetBoxModelViewSet):
serializer_class = DocumentAssignmentSerializer serializer_class = DocumentAssignmentSerializer
filterset_class = AssignmentFilterSet filterset_class = AssignmentFilterSet
class DocumentCategoryViewSet(NetBoxModelViewSet):
queryset = DocumentCategory.objects.all()
serializer_class = DocumentCategorySerializer
filterset_class = DocumentCategoryFilterSet
+10 -2
View File
@@ -1,11 +1,11 @@
from netbox.filtersets import NetBoxModelFilterSet from netbox.filtersets import NetBoxModelFilterSet
from .models import Document, DocumentAssignment from .models import Document, DocumentAssignment, DocumentCategory
class DocumentFilterSet(NetBoxModelFilterSet): class DocumentFilterSet(NetBoxModelFilterSet):
class Meta: class Meta:
model = Document model = Document
fields = ("id", "title", "slug", "is_published") fields = ("id", "title", "slug", "category_id", "is_published")
def search(self, queryset, name, value): def search(self, queryset, name, value):
from django.db.models import Q from django.db.models import Q
@@ -17,3 +17,11 @@ class AssignmentFilterSet(NetBoxModelFilterSet):
model = DocumentAssignment model = DocumentAssignment
fields = ("id", "document_id", "assigned_object_type", "assigned_object_id") fields = ("id", "document_id", "assigned_object_type", "assigned_object_id")
class DocumentCategoryFilterSet(NetBoxModelFilterSet):
class Meta:
model = DocumentCategory
fields = ("id", "name", "slug", "parent_id")
def search(self, queryset, name, value):
return queryset.filter(name__icontains=value)
+56 -24
View File
@@ -2,9 +2,13 @@ from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q from django.db.models import Q
from utilities.forms.fields import SlugField from utilities.forms import get_field_value
from utilities.forms.fields import ContentTypeChoiceField, DynamicModelChoiceField, SlugField
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import HTMXSelect
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from .models import Document, DocumentAssignment from dcim.models import Device
from .models import Document, DocumentAssignment, DocumentCategory
def allowed_content_types(): def allowed_content_types():
@@ -24,10 +28,16 @@ class DocumentForm(NetBoxModelForm):
"rows": 32, "class": "rich-text-editor", "data-rich-text-editor": "true" "rows": 32, "class": "rich-text-editor", "data-rich-text-editor": "true"
}), help_text="Formatierter Text mit Tabellen, Farben, Schriftarten und Größen.") }), help_text="Formatierter Text mit Tabellen, Farben, Schriftarten und Größen.")
body_format = forms.CharField(widget=forms.HiddenInput(), initial="html") body_format = forms.CharField(widget=forms.HiddenInput(), initial="html")
category = DynamicModelChoiceField(queryset=DocumentCategory.objects.all(), required=False, label="Ordner")
fieldsets = (
FieldSet("title", "slug", "category", "summary", "is_published", "tags", name="Dokumentation"),
FieldSet("body", "body_format", name="Inhalt"),
)
class Meta: class Meta:
model = Document model = Document
fields = ("title", "slug", "summary", "body", "body_format", "is_published", "tags") fields = ("title", "slug", "category", "summary", "body", "body_format", "is_published", "tags")
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -40,33 +50,55 @@ class DocumentForm(NetBoxModelForm):
class AssignmentForm(NetBoxModelForm): class AssignmentForm(NetBoxModelForm):
assigned_object_type = forms.ModelChoiceField(queryset=ContentType.objects.none(), label="Objekttyp") assigned_object_type = ContentTypeChoiceField(queryset=ContentType.objects.none(), label="Objekttyp", widget=HTMXSelect())
assigned_object_id = forms.TypedChoiceField(coerce=int, label="NetBox-Objekt") assigned_object = DynamicModelChoiceField(
queryset=Device.objects.none(), label="NetBox-Objekt", required=True, disabled=True, selector=True
)
fieldsets = (FieldSet("document", "assigned_object_type", "assigned_object", "note", "tags", name="Dokumentation zuordnen"),)
class Meta: class Meta:
model = DocumentAssignment model = DocumentAssignment
fields = ("document", "assigned_object_type", "assigned_object_id", "note", "tags") fields = ("document", "assigned_object_type", "note", "tags")
def __init__(self, *args, **kwargs):
instance = kwargs.get("instance")
initial = kwargs.setdefault("initial", {})
if instance and instance.pk and instance.assigned_object:
initial["assigned_object"] = instance.assigned_object
super().__init__(*args, **kwargs)
self.fields["assigned_object_type"].queryset = allowed_content_types()
ct_id = get_field_value(self, "assigned_object_type")
if ct_id:
content_type = self.fields["assigned_object_type"].queryset.filter(pk=ct_id).first()
else:
content_type = None
if content_type and content_type.model_class():
model = content_type.model_class()
self.fields["assigned_object"].queryset = model.objects.all()
self.fields["assigned_object"].widget.attrs["selector"] = model._meta.label_lower
self.fields["assigned_object"].disabled = False
self.fields["assigned_object"].label = model._meta.verbose_name.title()
def clean(self):
super().clean()
self.instance.assigned_object = self.cleaned_data.get("assigned_object")
class DocumentCategoryForm(NetBoxModelForm):
slug = SlugField(slug_source="name")
parent = DynamicModelChoiceField(queryset=DocumentCategory.objects.all(), required=False, label="Übergeordneter Ordner")
fieldsets = (FieldSet("name", "slug", "parent", "description", "tags", name="Ordner"),)
class Meta:
model = DocumentCategory
fields = ("name", "slug", "parent", "description", "tags")
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["assigned_object_type"].queryset = allowed_content_types() if self.instance.pk:
content_type = None self.fields["parent"].queryset = DocumentCategory.objects.exclude(pk=self.instance.pk)
ct_id = (self.data.get("assigned_object_type") or self.initial.get("assigned_object_type")
or getattr(self.instance, "assigned_object_type_id", None))
if ct_id:
content_type = ContentType.objects.filter(pk=ct_id).first()
if content_type:
model = content_type.model_class()
objects = model.objects.all().order_by("pk")
self.fields["assigned_object_id"].choices = [(obj.pk, str(obj)) for obj in objects]
def clean(self):
cleaned = super().clean()
object_id = cleaned.get("assigned_object_id")
ct = cleaned.get("assigned_object_type")
if object_id and ct and not ct.model_class().objects.filter(pk=object_id).exists():
self.add_error("assigned_object_id", "Das Objekt existiert für diesen Objekttyp nicht.")
return cleaned
class ImportForm(forms.Form): class ImportForm(forms.Form):
@@ -0,0 +1,37 @@
from django.db import migrations, models
import django.db.models.deletion
import netbox.models.features
class Migration(migrations.Migration):
dependencies = [
("extras", "0001_squashed"),
("netbox_documentation", "0002_document_body_format"),
]
operations = [
migrations.CreateModel(
name="DocumentCategory",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
("created", models.DateTimeField(auto_now_add=True, null=True)),
("last_updated", models.DateTimeField(auto_now=True, null=True)),
("custom_field_data", models.JSONField(blank=True, default=dict, encoder=netbox.models.features.CustomFieldJSONEncoder)),
("name", models.CharField(max_length=100)),
("slug", models.SlugField(max_length=100)),
("description", models.CharField(blank=True, max_length=500)),
("parent", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="children", to="netbox_documentation.documentcategory")),
("tags", models.ManyToManyField(blank=True, related_name="netbox_documentation_documentcategory_items", to="extras.tag")),
],
options={"verbose_name_plural": "document categories", "ordering": ("name",)},
),
migrations.AddConstraint(
model_name="documentcategory",
constraint=models.UniqueConstraint(fields=("parent", "slug"), name="netbox_documentation_documentcategory_unique_parent_slug"),
),
migrations.AddField(
model_name="document",
name="category",
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="documents", to="netbox_documentation.documentcategory"),
),
]
+37
View File
@@ -15,6 +15,7 @@ class Document(NetBoxModel):
body_format = models.CharField(max_length=10, choices=BODY_FORMAT_CHOICES, default="html") 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)
category = models.ForeignKey("DocumentCategory", on_delete=models.SET_NULL, null=True, blank=True, related_name="documents")
class Meta: class Meta:
ordering = ("title",) ordering = ("title",)
@@ -56,6 +57,42 @@ class Document(NetBoxModel):
return bleach.clean(value, tags=tags, attributes=attributes, protocols=("http", "https", "mailto"), css_sanitizer=css) return bleach.clean(value, tags=tags, attributes=attributes, protocols=("http", "https", "mailto"), css_sanitizer=css)
class DocumentCategory(NetBoxModel):
name = models.CharField(max_length=100)
slug = models.SlugField(max_length=100)
parent = models.ForeignKey("self", on_delete=models.CASCADE, null=True, blank=True, related_name="children")
description = models.CharField(max_length=500, blank=True)
class Meta:
ordering = ("name",)
constraints = [models.UniqueConstraint(fields=("parent", "slug"), name="%(app_label)s_%(class)s_unique_parent_slug")]
verbose_name_plural = "document categories"
def __str__(self):
names = [self.name]
parent = self.parent
seen = {self.pk}
while parent and parent.pk not in seen:
names.append(parent.name)
seen.add(parent.pk)
parent = parent.parent
return " / ".join(reversed(names))
def get_absolute_url(self):
return reverse("plugins:netbox_documentation:documentcategory", args=[self.pk])
def clean(self):
super().clean()
from django.core.exceptions import ValidationError
parent = self.parent
seen = {self.pk} if self.pk else set()
while parent:
if parent.pk in seen:
raise ValidationError({"parent": "Ein Ordner kann nicht sich selbst oder einem Unterordner untergeordnet werden."})
seen.add(parent.pk)
parent = parent.parent
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")
assigned_object_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) assigned_object_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
+3
View File
@@ -11,6 +11,9 @@ menu = PluginMenu(
PluginMenuItem(link="plugins:netbox_documentation:documentassignment_list", link_text="Zuordnungen", buttons=( PluginMenuItem(link="plugins:netbox_documentation:documentassignment_list", link_text="Zuordnungen", buttons=(
PluginMenuButton(link="plugins:netbox_documentation:documentassignment_add", title="Zuordnen", icon_class="mdi mdi-link-plus", color=ButtonColorChoices.GREEN), PluginMenuButton(link="plugins:netbox_documentation:documentassignment_add", title="Zuordnen", icon_class="mdi mdi-link-plus", color=ButtonColorChoices.GREEN),
)), )),
PluginMenuItem(link="plugins:netbox_documentation:documentcategory_list", link_text="Ordner & Kategorien", buttons=(
PluginMenuButton(link="plugins:netbox_documentation:documentcategory_add", title="Ordner erstellen", icon_class="mdi mdi-folder-plus", color=ButtonColorChoices.GREEN),
)),
)),), )),),
icon_class="mdi mdi-book-open-page-variant", icon_class="mdi mdi-book-open-page-variant",
) )
+7 -1
View File
@@ -1,5 +1,5 @@
from netbox.search import SearchIndex, register_search from netbox.search import SearchIndex, register_search
from .models import Document from .models import Document, DocumentCategory
@register_search @register_search
@@ -8,3 +8,9 @@ class DocumentIndex(SearchIndex):
fields = (("title", 100), ("summary", 80), ("body", 50)) fields = (("title", 100), ("summary", 80), ("body", 50))
display_attrs = ("summary",) display_attrs = ("summary",)
@register_search
class DocumentCategoryIndex(SearchIndex):
model = DocumentCategory
fields = (("name", 100), ("description", 60))
display_attrs = ("description",)
+12 -3
View File
@@ -1,6 +1,6 @@
import django_tables2 as tables import django_tables2 as tables
from netbox.tables import NetBoxTable, columns from netbox.tables import NetBoxTable, columns
from .models import Document, DocumentAssignment from .models import Document, DocumentAssignment, DocumentCategory
class DocumentTable(NetBoxTable): class DocumentTable(NetBoxTable):
@@ -10,7 +10,17 @@ class DocumentTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = Document model = Document
fields = ("pk", "title", "summary", "is_published", "assignments", "last_updated", "actions") fields = ("pk", "title", "category", "summary", "is_published", "assignments", "last_updated", "actions")
class DocumentCategoryTable(NetBoxTable):
name = tables.Column(linkify=True, verbose_name="Ordner")
document_count = tables.Column(accessor="documents.count", verbose_name="Dokumente", orderable=False)
actions = columns.ActionsColumn(actions=("edit", "delete"))
class Meta(NetBoxTable.Meta):
model = DocumentCategory
fields = ("pk", "name", "parent", "description", "document_count", "actions")
class AssignmentTable(NetBoxTable): class AssignmentTable(NetBoxTable):
@@ -21,4 +31,3 @@ class AssignmentTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = DocumentAssignment model = DocumentAssignment
fields = ("pk", "document", "assigned_object_type", "assigned_object", "note", "actions") fields = ("pk", "document", "assigned_object_type", "assigned_object", "note", "actions")
@@ -7,6 +7,7 @@
<div class="card"><div class="card-body rendered-markdown">{{ object.rendered_body|safe }}</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">
{% if object.category %}<div class="card mb-3"><h5 class="card-header">Ordner</h5><a class="list-group-item list-group-item-action" href="{{ object.category.get_absolute_url }}"><i class="mdi mdi-folder"></i> {{ object.category }}</a></div>{% endif %}
<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">
{% for assignment in object.assignments.all %}<a class="list-group-item" href="{{ assignment.assigned_object.get_absolute_url }}">{{ assignment.assigned_object_type }}: {{ assignment.assigned_object }}</a>{% empty %}<div class="list-group-item text-muted">Noch nicht zugeordnet</div>{% endfor %} {% for assignment in object.assignments.all %}<a class="list-group-item" href="{{ assignment.assigned_object.get_absolute_url }}">{{ assignment.assigned_object_type }}: {{ assignment.assigned_object }}</a>{% empty %}<div class="list-group-item text-muted">Noch nicht zugeordnet</div>{% endfor %}
</div></div> </div></div>
@@ -1,9 +1,20 @@
{% extends 'generic/object_edit.html' %} {% extends 'generic/object_edit.html' %}
{% block javascript %} {% block javascript %}
{{ block.super }} {{ block.super }}
<style>
body.documentation-editor-page .container-xl { max-width: none !important; }
body.documentation-editor-focus #form_fields .field-group:not(.documentation-content-group) { display: none; }
body.documentation-editor-focus #form_fields .documentation-content-group { margin-bottom: 0 !important; }
</style>
<script src="{% url 'plugins:netbox_documentation:editor_asset' asset_path='tinymce.min.js' %}"></script> <script src="{% url 'plugins:netbox_documentation:editor_asset' asset_path='tinymce.min.js' %}"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
document.body.classList.add('documentation-editor-page');
const bodyField = document.querySelector('#id_body');
if (bodyField) {
const contentGroup = bodyField.closest('.field-group');
if (contentGroup) contentGroup.classList.add('documentation-content-group');
}
const editorAssetBase = "{% url 'plugins:netbox_documentation:editor_asset' asset_path='_' %}".replace(/_$/, ''); const editorAssetBase = "{% url 'plugins:netbox_documentation:editor_asset' asset_path='_' %}".replace(/_$/, '');
if (typeof tinymce === 'undefined') { if (typeof tinymce === 'undefined') {
const editor = document.querySelector('[data-rich-text-editor]'); const editor = document.querySelector('[data-rich-text-editor]');
@@ -25,7 +36,7 @@
plugins: 'advlist anchor autolink charmap code fullscreen image link lists media preview searchreplace table visualblocks wordcount', plugins: 'advlist anchor autolink charmap code fullscreen image link lists media preview searchreplace table visualblocks wordcount',
toolbar: [ toolbar: [
'undo redo | blocks fontfamily fontsize | bold italic underline strikethrough | forecolor backcolor', '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' 'alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table link image media | removeformat code netboxfocus fullscreen'
], ],
toolbar_mode: 'sliding', toolbar_mode: 'sliding',
table_toolbar: 'tableprops tablecellprops | tableinsertrowbefore tableinsertrowafter tabledeleterow | tableinsertcolbefore tableinsertcolafter tabledeletecol | tablemergecells tablesplitcells | tabledelete', table_toolbar: 'tableprops tablecellprops | tableinsertrowbefore tableinsertrowafter tabledeleterow | tableinsertcolbefore tableinsertcolafter tabledeletecol | tablemergecells tablesplitcells | tabledelete',
@@ -38,7 +49,43 @@
branding: false, branding: false,
convert_unsafe_embeds: true, convert_unsafe_embeds: true,
relative_urls: false relative_urls: false
{% if object.pk %},
images_file_types: 'jpg,jpeg,png,gif,webp',
automatic_uploads: true,
images_upload_handler: (blobInfo, progress) => new Promise((resolve, reject) => {
const data = new FormData();
data.append('file', blobInfo.blob(), blobInfo.filename());
fetch("{% url 'plugins:netbox_documentation:document_media_upload' pk=object.pk %}", {
method: 'POST',
headers: {'X-CSRFToken': document.querySelector('input[name=csrfmiddlewaretoken]').value},
body: data,
credentials: 'same-origin'
}).then(async response => {
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || 'Upload fehlgeschlagen');
resolve(payload.location);
}).catch(error => reject(error.message));
})
{% endif %},
setup: (editor) => {
editor.ui.registry.addToggleButton('netboxfocus', {
icon: 'expand',
tooltip: 'Fokusmodus im NetBox-Fenster',
onAction: api => {
const active = document.body.classList.toggle('documentation-editor-focus');
const container = editor.getContainer();
container.style.height = active ? 'calc(100vh - 190px)' : '650px';
api.setActive(active);
}
});
}
}); });
}); });
</script> </script>
{% endblock javascript %} {% endblock javascript %}
{% block pre_form_fields %}
{% if not object.pk %}
<div class="alert alert-info">Speichere die neue Dokumentation einmal. Danach können Bilder direkt über die Editor-Werkzeugleiste hochgeladen werden.</div>
{% endif %}
{% endblock pre_form_fields %}
@@ -0,0 +1,26 @@
{% extends 'generic/object.html' %}
{% block content %}
<div class="row">
<div class="col col-md-4">
<div class="card">
<h5 class="card-header">Unterordner</h5>
<div class="list-group list-group-flush">
{% for child in object.children.all %}
<a class="list-group-item list-group-item-action" href="{{ child.get_absolute_url }}"><i class="mdi mdi-folder"></i> {{ child.name }}</a>
{% empty %}<div class="list-group-item text-muted">Keine Unterordner</div>{% endfor %}
</div>
</div>
</div>
<div class="col col-md-8">
<div class="card">
<h5 class="card-header">Dokumentationen</h5>
<div class="list-group list-group-flush">
{% for document in object.documents.all %}
<a class="list-group-item list-group-item-action" href="{{ document.get_absolute_url }}"><strong>{{ document.title }}</strong>{% if document.summary %}<br><small>{{ document.summary }}</small>{% endif %}</a>
{% empty %}<div class="list-group-item text-muted">Keine Dokumentationen in diesem Ordner</div>{% endfor %}
</div>
</div>
</div>
</div>
{% endblock content %}
@@ -7,5 +7,5 @@
</a> </a>
{% empty %}<div class="list-group-item text-muted">Keine Dokumentation zugeordnet.</div>{% endfor %} {% empty %}<div class="list-group-item text-muted">Keine Dokumentation zugeordnet.</div>{% endfor %}
</div> </div>
{% if perms.netbox_documentation.add_documentassignment %}<div class="card-footer"><a href="{% url 'plugins:netbox_documentation:documentassignment_add' %}?assigned_object_type={{ content_type.pk }}&assigned_object_id={{ object.pk }}" class="btn btn-sm btn-primary"><i class="mdi mdi-link-plus"></i> Zuordnen</a></div>{% endif %} {% if perms.netbox_documentation.add_documentassignment %}<div class="card-footer"><a href="{% url 'plugins:netbox_documentation:documentassignment_add' %}?assigned_object_type={{ content_type.pk }}&assigned_object={{ object.pk }}" class="btn btn-sm btn-primary"><i class="mdi mdi-link-plus"></i> Zuordnen</a></div>{% endif %}
</div> </div>
+6
View File
@@ -9,10 +9,16 @@ urlpatterns = (
path("documents/import/", views.DocumentImportView.as_view(), name="document_import"), path("documents/import/", views.DocumentImportView.as_view(), name="document_import"),
path("documents/<int:pk>/", views.DocumentView.as_view(), name="document"), path("documents/<int:pk>/", views.DocumentView.as_view(), name="document"),
path("documents/<int:pk>/edit/", views.DocumentEditView.as_view(), name="document_edit"), path("documents/<int:pk>/edit/", views.DocumentEditView.as_view(), name="document_edit"),
path("documents/<int:pk>/media-upload/", views.DocumentMediaUploadView.as_view(), name="document_media_upload"),
path("documents/<int:pk>/delete/", views.DocumentDeleteView.as_view(), name="document_delete"), path("documents/<int:pk>/delete/", views.DocumentDeleteView.as_view(), name="document_delete"),
path("documents/<int:pk>/changelog/", ObjectChangeLogView.as_view(), name="document_changelog", kwargs={"model": models.Document}), path("documents/<int:pk>/changelog/", ObjectChangeLogView.as_view(), name="document_changelog", kwargs={"model": models.Document}),
path("assignments/", views.AssignmentListView.as_view(), name="documentassignment_list"), path("assignments/", views.AssignmentListView.as_view(), name="documentassignment_list"),
path("assignments/add/", views.AssignmentEditView.as_view(), name="documentassignment_add"), path("assignments/add/", views.AssignmentEditView.as_view(), name="documentassignment_add"),
path("assignments/<int:pk>/edit/", views.AssignmentEditView.as_view(), name="documentassignment_edit"), path("assignments/<int:pk>/edit/", views.AssignmentEditView.as_view(), name="documentassignment_edit"),
path("assignments/<int:pk>/delete/", views.AssignmentDeleteView.as_view(), name="documentassignment_delete"), path("assignments/<int:pk>/delete/", views.AssignmentDeleteView.as_view(), name="documentassignment_delete"),
path("folders/", views.DocumentCategoryListView.as_view(), name="documentcategory_list"),
path("folders/add/", views.DocumentCategoryEditView.as_view(), name="documentcategory_add"),
path("folders/<int:pk>/", views.DocumentCategoryView.as_view(), name="documentcategory"),
path("folders/<int:pk>/edit/", views.DocumentCategoryEditView.as_view(), name="documentcategory_edit"),
path("folders/<int:pk>/delete/", views.DocumentCategoryDeleteView.as_view(), name="documentcategory_delete"),
) )
+53 -6
View File
@@ -5,16 +5,16 @@ from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db import transaction from django.db import transaction
from django.http import FileResponse, Http404 from django.http import FileResponse, Http404, JsonResponse
from django.shortcuts import redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.utils.text import slugify from django.utils.text import slugify
from django.views import View from django.views import View
from netbox.views import generic from netbox.views import generic
from .filtersets import DocumentFilterSet, AssignmentFilterSet from .filtersets import DocumentFilterSet, AssignmentFilterSet, DocumentCategoryFilterSet
from .forms import DocumentForm, AssignmentForm, ImportForm from .forms import DocumentForm, AssignmentForm, ImportForm, DocumentCategoryForm
from .importers import ImportFailure, import_document from .importers import ImportFailure, import_document
from .models import Document, DocumentAssignment, DocumentAttachment from .models import Document, DocumentAssignment, DocumentAttachment, DocumentCategory
from .tables import DocumentTable, AssignmentTable from .tables import DocumentTable, AssignmentTable, DocumentCategoryTable
class EditorAssetView(View): class EditorAssetView(View):
@@ -52,6 +52,25 @@ class DocumentDeleteView(generic.ObjectDeleteView):
queryset = Document.objects.all() queryset = Document.objects.all()
class DocumentCategoryListView(generic.ObjectListView):
queryset = DocumentCategory.objects.select_related("parent").prefetch_related("documents")
table = DocumentCategoryTable
filterset = DocumentCategoryFilterSet
class DocumentCategoryView(generic.ObjectView):
queryset = DocumentCategory.objects.prefetch_related("children", "documents")
class DocumentCategoryEditView(generic.ObjectEditView):
queryset = DocumentCategory.objects.all()
form = DocumentCategoryForm
class DocumentCategoryDeleteView(generic.ObjectDeleteView):
queryset = DocumentCategory.objects.all()
class AssignmentListView(generic.ObjectListView): class AssignmentListView(generic.ObjectListView):
queryset = DocumentAssignment.objects.select_related("document", "assigned_object_type") queryset = DocumentAssignment.objects.select_related("document", "assigned_object_type")
table = AssignmentTable table = AssignmentTable
@@ -113,3 +132,31 @@ 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)
class DocumentMediaUploadView(PermissionRequiredMixin, View):
permission_required = "netbox_documentation.change_document"
def post(self, request, pk):
document = get_object_or_404(Document.objects.restrict(request.user, "change"), pk=pk)
upload = request.FILES.get("file")
if not upload:
return JsonResponse({"error": "Keine Datei empfangen."}, status=400)
limit = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("max_import_size_mb", 25)
if upload.size > limit * 1024 * 1024:
return JsonResponse({"error": f"Die Datei ist größer als {limit} MB."}, status=400)
allowed = {"image/jpeg", "image/png", "image/gif", "image/webp"}
if upload.content_type not in allowed:
return JsonResponse({"error": "Im Editor sind JPEG, PNG, GIF und WebP erlaubt."}, status=400)
try:
from PIL import Image
image = Image.open(upload)
image.verify()
upload.seek(0)
except Exception:
return JsonResponse({"error": "Die hochgeladene Datei ist kein gültiges Bild."}, status=400)
attachment = DocumentAttachment.objects.create(
document=document, file=upload, original_name=upload.name,
content_type=upload.content_type or "", size=upload.size,
)
return JsonResponse({"location": attachment.file.url})
+2 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "netbox-documentation" name = "netbox-documentation"
version = "0.2.2" version = "0.3.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"
@@ -17,6 +17,7 @@ dependencies = [
"bleach[css]>=6.2,<7", "bleach[css]>=6.2,<7",
"markdown>=3.7,<4", "markdown>=3.7,<4",
"django-tinymce>=5,<6", "django-tinymce>=5,<6",
"pillow>=10,<13",
] ]
[project.optional-dependencies] [project.optional-dependencies]