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
- 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
- Unterstützte Standardobjekte: Region, Standort, Location, Rack, Gerät, VM, VM-Cluster und Mandant/Kunde
- 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
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
@@ -119,6 +122,7 @@ Nach Aktivierung stehen die üblichen NetBox-Plugin-Endpunkte bereit:
- `/api/plugins/documentation/documents/`
- `/api/plugins/documentation/assignments/`
- `/api/plugins/documentation/folders/`
## Entwicklung und Tests
+1 -1
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.2.2"
version = "0.3.0"
author = "NetBox Documentation Contributors"
base_url = "documentation"
min_version = "4.0.0"
+10 -2
View File
@@ -1,6 +1,6 @@
from netbox.api.serializers import NetBoxModelSerializer
from rest_framework import serializers
from ..models import Document, DocumentAssignment
from ..models import Document, DocumentAssignment, DocumentCategory
class DocumentSerializer(NetBoxModelSerializer):
@@ -8,7 +8,7 @@ class DocumentSerializer(NetBoxModelSerializer):
class Meta:
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):
@@ -17,3 +17,11 @@ class DocumentAssignmentSerializer(NetBoxModelSerializer):
class Meta:
model = DocumentAssignment
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.register("documents", views.DocumentViewSet)
router.register("assignments", views.DocumentAssignmentViewSet)
router.register("folders", views.DocumentCategoryViewSet)
urlpatterns = router.urls
+8 -3
View File
@@ -1,7 +1,7 @@
from netbox.api.viewsets import NetBoxModelViewSet
from ..filtersets import DocumentFilterSet, AssignmentFilterSet
from ..models import Document, DocumentAssignment
from .serializers import DocumentSerializer, DocumentAssignmentSerializer
from ..filtersets import DocumentFilterSet, AssignmentFilterSet, DocumentCategoryFilterSet
from ..models import Document, DocumentAssignment, DocumentCategory
from .serializers import DocumentSerializer, DocumentAssignmentSerializer, DocumentCategorySerializer
class DocumentViewSet(NetBoxModelViewSet):
@@ -15,3 +15,8 @@ class DocumentAssignmentViewSet(NetBoxModelViewSet):
serializer_class = DocumentAssignmentSerializer
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 .models import Document, DocumentAssignment
from .models import Document, DocumentAssignment, DocumentCategory
class DocumentFilterSet(NetBoxModelFilterSet):
class Meta:
model = Document
fields = ("id", "title", "slug", "is_published")
fields = ("id", "title", "slug", "category_id", "is_published")
def search(self, queryset, name, value):
from django.db.models import Q
@@ -17,3 +17,11 @@ class AssignmentFilterSet(NetBoxModelFilterSet):
model = DocumentAssignment
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.contrib.contenttypes.models import ContentType
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 .models import Document, DocumentAssignment
from dcim.models import Device
from .models import Document, DocumentAssignment, DocumentCategory
def allowed_content_types():
@@ -24,10 +28,16 @@ class DocumentForm(NetBoxModelForm):
"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")
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:
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):
super().__init__(*args, **kwargs)
@@ -40,33 +50,55 @@ class DocumentForm(NetBoxModelForm):
class AssignmentForm(NetBoxModelForm):
assigned_object_type = forms.ModelChoiceField(queryset=ContentType.objects.none(), label="Objekttyp")
assigned_object_id = forms.TypedChoiceField(coerce=int, label="NetBox-Objekt")
assigned_object_type = ContentTypeChoiceField(queryset=ContentType.objects.none(), label="Objekttyp", widget=HTMXSelect())
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:
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):
super().__init__(*args, **kwargs)
self.fields["assigned_object_type"].queryset = allowed_content_types()
content_type = None
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
if self.instance.pk:
self.fields["parent"].queryset = DocumentCategory.objects.exclude(pk=self.instance.pk)
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")
summary = models.CharField(max_length=500, blank=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:
ordering = ("title",)
@@ -56,6 +57,42 @@ class Document(NetBoxModel):
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):
document = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="assignments")
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=(
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",
)
+7 -1
View File
@@ -1,5 +1,5 @@
from netbox.search import SearchIndex, register_search
from .models import Document
from .models import Document, DocumentCategory
@register_search
@@ -8,3 +8,9 @@ class DocumentIndex(SearchIndex):
fields = (("title", 100), ("summary", 80), ("body", 50))
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
from netbox.tables import NetBoxTable, columns
from .models import Document, DocumentAssignment
from .models import Document, DocumentAssignment, DocumentCategory
class DocumentTable(NetBoxTable):
@@ -10,7 +10,17 @@ class DocumentTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
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):
@@ -21,4 +31,3 @@ class AssignmentTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = DocumentAssignment
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>
<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">
{% 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>
@@ -1,9 +1,20 @@
{% extends 'generic/object_edit.html' %}
{% block javascript %}
{{ 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>
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(/_$/, '');
if (typeof tinymce === 'undefined') {
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',
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'
'alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table link image media | removeformat code netboxfocus fullscreen'
],
toolbar_mode: 'sliding',
table_toolbar: 'tableprops tablecellprops | tableinsertrowbefore tableinsertrowafter tabledeleterow | tableinsertcolbefore tableinsertcolafter tabledeletecol | tablemergecells tablesplitcells | tabledelete',
@@ -38,7 +49,43 @@
branding: false,
convert_unsafe_embeds: true,
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>
{% 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>
{% empty %}<div class="list-group-item text-muted">Keine Dokumentation zugeordnet.</div>{% endfor %}
</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>
+6
View File
@@ -9,10 +9,16 @@ urlpatterns = (
path("documents/import/", views.DocumentImportView.as_view(), name="document_import"),
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>/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>/changelog/", ObjectChangeLogView.as_view(), name="document_changelog", kwargs={"model": models.Document}),
path("assignments/", views.AssignmentListView.as_view(), name="documentassignment_list"),
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>/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.auth.mixins import PermissionRequiredMixin
from django.db import transaction
from django.http import FileResponse, Http404
from django.shortcuts import redirect, render
from django.http import FileResponse, Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.text import slugify
from django.views import View
from netbox.views import generic
from .filtersets import DocumentFilterSet, AssignmentFilterSet
from .forms import DocumentForm, AssignmentForm, ImportForm
from .filtersets import DocumentFilterSet, AssignmentFilterSet, DocumentCategoryFilterSet
from .forms import DocumentForm, AssignmentForm, ImportForm, DocumentCategoryForm
from .importers import ImportFailure, import_document
from .models import Document, DocumentAssignment, DocumentAttachment
from .tables import DocumentTable, AssignmentTable
from .models import Document, DocumentAssignment, DocumentAttachment, DocumentCategory
from .tables import DocumentTable, AssignmentTable, DocumentCategoryTable
class EditorAssetView(View):
@@ -52,6 +52,25 @@ class DocumentDeleteView(generic.ObjectDeleteView):
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):
queryset = DocumentAssignment.objects.select_related("document", "assigned_object_type")
table = AssignmentTable
@@ -113,3 +132,31 @@ class DocumentImportView(PermissionRequiredMixin, View):
messages.warning(request, warning)
messages.success(request, f"{upload.name} wurde importiert.")
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]
name = "netbox-documentation"
version = "0.2.2"
version = "0.3.0"
description = "Integrated Markdown wiki and office document importer for NetBox"
readme = "README.md"
requires-python = ">=3.10"
@@ -17,6 +17,7 @@ dependencies = [
"bleach[css]>=6.2,<7",
"markdown>=3.7,<4",
"django-tinymce>=5,<6",
"pillow>=10,<13",
]
[project.optional-dependencies]