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 @@