From 59598fbd74c55382d16a93368518e34b9fa9afa2 Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 22 Jul 2026 11:04:10 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Ordnerstruktur,=20Medienupload=20und=20?= =?UTF-8?q?erweiterten=20Editor=20erg=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 6 +- netbox_documentation/__init__.py | 2 +- netbox_documentation/api/serializers.py | 12 ++- netbox_documentation/api/urls.py | 2 +- netbox_documentation/api/views.py | 11 ++- netbox_documentation/filtersets.py | 12 ++- netbox_documentation/forms.py | 80 +++++++++++++------ ...0003_documentcategory_document_category.py | 37 +++++++++ netbox_documentation/models.py | 37 +++++++++ netbox_documentation/navigation.py | 3 + netbox_documentation/search.py | 8 +- netbox_documentation/tables.py | 15 +++- .../netbox_documentation/document.html | 1 + .../netbox_documentation/document_edit.html | 49 +++++++++++- .../documentcategory.html | 26 ++++++ .../inc/object_documents.html | 2 +- netbox_documentation/urls.py | 6 ++ netbox_documentation/views.py | 59 ++++++++++++-- pyproject.toml | 3 +- 19 files changed, 324 insertions(+), 47 deletions(-) create mode 100644 netbox_documentation/migrations/0003_documentcategory_document_category.py create mode 100644 netbox_documentation/templates/netbox_documentation/documentcategory.html diff --git a/README.md b/README.md index b196979..876b1a4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/netbox_documentation/__init__.py b/netbox_documentation/__init__.py index 2f0823b..eb3ff1f 100644 --- a/netbox_documentation/__init__.py +++ b/netbox_documentation/__init__.py @@ -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" diff --git a/netbox_documentation/api/serializers.py b/netbox_documentation/api/serializers.py index 3ff406a..c95aeac 100644 --- a/netbox_documentation/api/serializers.py +++ b/netbox_documentation/api/serializers.py @@ -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") diff --git a/netbox_documentation/api/urls.py b/netbox_documentation/api/urls.py index 655a1b0..d17579b 100644 --- a/netbox_documentation/api/urls.py +++ b/netbox_documentation/api/urls.py @@ -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 - diff --git a/netbox_documentation/api/views.py b/netbox_documentation/api/views.py index 81af47b..12a74b3 100644 --- a/netbox_documentation/api/views.py +++ b/netbox_documentation/api/views.py @@ -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 diff --git a/netbox_documentation/filtersets.py b/netbox_documentation/filtersets.py index e85d0ab..0db8b12 100644 --- a/netbox_documentation/filtersets.py +++ b/netbox_documentation/filtersets.py @@ -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) diff --git a/netbox_documentation/forms.py b/netbox_documentation/forms.py index 3f9a130..7107aa7 100644 --- a/netbox_documentation/forms.py +++ b/netbox_documentation/forms.py @@ -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): diff --git a/netbox_documentation/migrations/0003_documentcategory_document_category.py b/netbox_documentation/migrations/0003_documentcategory_document_category.py new file mode 100644 index 0000000..77d5ab4 --- /dev/null +++ b/netbox_documentation/migrations/0003_documentcategory_document_category.py @@ -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"), + ), + ] diff --git a/netbox_documentation/models.py b/netbox_documentation/models.py index 453a739..ed96418 100644 --- a/netbox_documentation/models.py +++ b/netbox_documentation/models.py @@ -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) diff --git a/netbox_documentation/navigation.py b/netbox_documentation/navigation.py index dff232a..0a1beda 100644 --- a/netbox_documentation/navigation.py +++ b/netbox_documentation/navigation.py @@ -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", ) diff --git a/netbox_documentation/search.py b/netbox_documentation/search.py index 8e296f6..4338973 100644 --- a/netbox_documentation/search.py +++ b/netbox_documentation/search.py @@ -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",) diff --git a/netbox_documentation/tables.py b/netbox_documentation/tables.py index 6982afd..26b1dbc 100644 --- a/netbox_documentation/tables.py +++ b/netbox_documentation/tables.py @@ -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") - diff --git a/netbox_documentation/templates/netbox_documentation/document.html b/netbox_documentation/templates/netbox_documentation/document.html index 4e9b73a..a673ae2 100644 --- a/netbox_documentation/templates/netbox_documentation/document.html +++ b/netbox_documentation/templates/netbox_documentation/document.html @@ -7,6 +7,7 @@
{{ object.rendered_body|safe }}
+ {% if object.category %}{% endif %}
Zuordnungen
{% for assignment in object.assignments.all %}{{ assignment.assigned_object_type }}: {{ assignment.assigned_object }}{% empty %}
Noch nicht zugeordnet
{% endfor %}
diff --git a/netbox_documentation/templates/netbox_documentation/document_edit.html b/netbox_documentation/templates/netbox_documentation/document_edit.html index aef43ad..cfeb2db 100644 --- a/netbox_documentation/templates/netbox_documentation/document_edit.html +++ b/netbox_documentation/templates/netbox_documentation/document_edit.html @@ -1,9 +1,20 @@ {% extends 'generic/object_edit.html' %} {% block javascript %} {{ block.super }} + {% endblock javascript %} + +{% block pre_form_fields %} + {% if not object.pk %} +
Speichere die neue Dokumentation einmal. Danach können Bilder direkt über die Editor-Werkzeugleiste hochgeladen werden.
+ {% endif %} +{% endblock pre_form_fields %} diff --git a/netbox_documentation/templates/netbox_documentation/documentcategory.html b/netbox_documentation/templates/netbox_documentation/documentcategory.html new file mode 100644 index 0000000..aba012c --- /dev/null +++ b/netbox_documentation/templates/netbox_documentation/documentcategory.html @@ -0,0 +1,26 @@ +{% extends 'generic/object.html' %} + +{% block content %} +
+
+
+
Unterordner
+
+ {% for child in object.children.all %} + {{ child.name }} + {% empty %}
Keine Unterordner
{% endfor %} +
+
+
+
+
+
Dokumentationen
+
+ {% for document in object.documents.all %} + {{ document.title }}{% if document.summary %}
{{ document.summary }}{% endif %}
+ {% empty %}
Keine Dokumentationen in diesem Ordner
{% endfor %} +
+
+
+
+{% endblock content %} diff --git a/netbox_documentation/templates/netbox_documentation/inc/object_documents.html b/netbox_documentation/templates/netbox_documentation/inc/object_documents.html index d52ec70..e4d2aff 100644 --- a/netbox_documentation/templates/netbox_documentation/inc/object_documents.html +++ b/netbox_documentation/templates/netbox_documentation/inc/object_documents.html @@ -7,5 +7,5 @@ {% empty %}
Keine Dokumentation zugeordnet.
{% endfor %}
- {% if perms.netbox_documentation.add_documentassignment %}{% endif %} + {% if perms.netbox_documentation.add_documentassignment %}{% endif %} diff --git a/netbox_documentation/urls.py b/netbox_documentation/urls.py index 59b1cb8..791419e 100644 --- a/netbox_documentation/urls.py +++ b/netbox_documentation/urls.py @@ -9,10 +9,16 @@ urlpatterns = ( path("documents/import/", views.DocumentImportView.as_view(), name="document_import"), path("documents//", views.DocumentView.as_view(), name="document"), path("documents//edit/", views.DocumentEditView.as_view(), name="document_edit"), + path("documents//media-upload/", views.DocumentMediaUploadView.as_view(), name="document_media_upload"), path("documents//delete/", views.DocumentDeleteView.as_view(), name="document_delete"), path("documents//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//edit/", views.AssignmentEditView.as_view(), name="documentassignment_edit"), path("assignments//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//", views.DocumentCategoryView.as_view(), name="documentcategory"), + path("folders//edit/", views.DocumentCategoryEditView.as_view(), name="documentcategory_edit"), + path("folders//delete/", views.DocumentCategoryDeleteView.as_view(), name="documentcategory_delete"), ) diff --git a/netbox_documentation/views.py b/netbox_documentation/views.py index d24a3be..e358102 100644 --- a/netbox_documentation/views.py +++ b/netbox_documentation/views.py @@ -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}) diff --git a/pyproject.toml b/pyproject.toml index aee3846..cc75b06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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]