diff --git a/README.md b/README.md index f87b650..c38f159 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ 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 +- Breite Editoransicht und TinyMCE-Vollbildmodus +- Dokumentationen und Ordner optional Mandanten und Mandantengruppen zuweisen - Bilder direkt vom eigenen Gerät in die Dokumentation hochladen - Formatierte Word-Inhalte inklusive Tabellen und unterstützten Zwischenablage-Bildern einfügen - Mehrere Dokumentationen mit Ordnern, Zuordnungen und Anhängen als ZIP exportieren und wieder importieren @@ -28,7 +29,7 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit ## Kompatibilität -Die Version `0.7.0` zielt auf NetBox 4.x (mindestens 4.0). Vor einem produktiven Rollout sollte das Plugin gegen die konkret eingesetzte NetBox-Minor-Version in einer Testinstanz geprüft werden. +Die Version `0.9.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 diff --git a/netbox_documentation/__init__.py b/netbox_documentation/__init__.py index 9dcbaae..abfa8df 100644 --- a/netbox_documentation/__init__.py +++ b/netbox_documentation/__init__.py @@ -5,7 +5,7 @@ class DocumentationConfig(PluginConfig): name = "netbox_documentation" verbose_name = "NetBox Dokumentation" description = "Wiki und Office-Dokumentation direkt in NetBox" - version = "0.7.14" + version = "0.9.0" author = "LKE" base_url = "documentation" min_version = "4.0.0" diff --git a/netbox_documentation/api/serializers.py b/netbox_documentation/api/serializers.py index e849bfb..6035557 100644 --- a/netbox_documentation/api/serializers.py +++ b/netbox_documentation/api/serializers.py @@ -8,7 +8,7 @@ class DocumentSerializer(NetBoxModelSerializer): class Meta: model = Document - fields = ("id", "url", "display", "title", "slug", "category", "summary", "body", "body_format", "is_published", "tags", "created", "last_updated") + fields = ("id", "url", "display", "title", "slug", "category", "tenant_group", "tenant", "summary", "body", "body_format", "is_published", "tags", "created", "last_updated") class DocumentAssignmentSerializer(NetBoxModelSerializer): @@ -24,7 +24,7 @@ class DocumentCategorySerializer(NetBoxModelSerializer): class Meta: model = DocumentCategory - fields = ("id", "url", "display", "name", "slug", "parent", "description", "tags", "created", "last_updated") + fields = ("id", "url", "display", "name", "slug", "parent", "tenant_group", "tenant", "description", "tags", "created", "last_updated") class DocumentAttachmentSerializer(NetBoxModelSerializer): diff --git a/netbox_documentation/archive.py b/netbox_documentation/archive.py index abd102c..485026d 100644 --- a/netbox_documentation/archive.py +++ b/netbox_documentation/archive.py @@ -7,6 +7,7 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.db import transaction from django.utils.text import slugify +from tenancy.models import Tenant, TenantGroup from .models import Document, DocumentAssignment, DocumentAttachment, DocumentCategory @@ -43,11 +44,25 @@ def _category_path(category): return list(reversed(path)) +def _category_tenancy_path(category): + path, seen = [], set() + while category and category.pk not in seen: + path.append({ + "tenant": category.tenant.slug if category.tenant else None, + "tenant_group": category.tenant_group.slug if category.tenant_group else None, + }) + seen.add(category.pk) + category = category.parent + return list(reversed(path)) + + def export_documents(documents): stream = SpooledTemporaryFile(max_size=10 * 1024 * 1024, mode="w+b") manifest = {"format": ARCHIVE_FORMAT, "version": ARCHIVE_VERSION, "documents": []} with ZipFile(stream, "w", compression=ZIP_DEFLATED, compresslevel=6) as archive: - for document in documents.prefetch_related("assignments__assigned_object_type", "attachments").select_related("category"): + for document in documents.prefetch_related("assignments__assigned_object_type", "attachments").select_related( + "category", "tenant", "tenant_group", "category__tenant", "category__tenant_group", + ): root = f"documents/{document.pk}" extension = "html" if document.body_format == "html" else "md" body_path = f"{root}/content.{extension}" @@ -76,6 +91,9 @@ def export_documents(documents): "title": document.title, "slug": document.slug, "summary": document.summary, "body_format": document.body_format, "is_published": document.is_published, "category_path": _category_path(document.category), "body_path": body_path, + "category_tenancy": _category_tenancy_path(document.category), + "tenant": document.tenant.slug if document.tenant else None, + "tenant_group": document.tenant_group.slug if document.tenant_group else None, "assignments": assignments, "attachments": attachments, }) archive.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8")) @@ -105,9 +123,10 @@ def _read_json(archive, name): raise ArchiveFailure("Das Archiv enthält kein gültiges manifest.json.") from exc -def _category_from_path(names): +def _category_from_path(names, tenancy_path=None): parent = None - for name in names: + tenancy_path = tenancy_path or [] + for index, name in enumerate(names): name = str(name).strip()[:100] if not name: continue @@ -115,6 +134,11 @@ def _category_from_path(names): category = DocumentCategory.objects.filter(parent=parent, slug=slug).first() if not category: category = DocumentCategory.objects.create(name=name, slug=slug, parent=parent) + tenancy = tenancy_path[index] if index < len(tenancy_path) and isinstance(tenancy_path[index], dict) else {} + if tenancy: + category.tenant_group = TenantGroup.objects.filter(slug=tenancy.get("tenant_group")).first() + category.tenant = Tenant.objects.filter(slug=tenancy.get("tenant")).first() + category.save(update_fields=("tenant_group", "tenant", "last_updated")) parent = category return parent @@ -153,7 +177,9 @@ def import_archive(upload, update_existing=False): raise ArchiveFailure("Ein Dokumentinhalt fehlt oder ist nicht UTF-8-kodiert.") from exc source_slug = str(record.get("slug") or record["title"]) document = Document.objects.filter(slug=source_slug).first() if update_existing else None - category = _category_from_path(record.get("category_path") or []) + category = _category_from_path( + record.get("category_path") or [], record.get("category_tenancy") or [], + ) if document: updated += 1 else: @@ -165,6 +191,8 @@ def import_archive(upload, update_existing=False): document.body_format = record.get("body_format") if record.get("body_format") in {"html", "markdown"} else "html" document.is_published = bool(record.get("is_published", True)) document.category = category + document.tenant_group = TenantGroup.objects.filter(slug=record.get("tenant_group")).first() + document.tenant = Tenant.objects.filter(slug=record.get("tenant")).first() document.save() for assignment in record.get("assignments") or []: label = f"{assignment.get('app_label')}.{assignment.get('model')}" diff --git a/netbox_documentation/filtersets.py b/netbox_documentation/filtersets.py index 0db8b12..f1c6b6a 100644 --- a/netbox_documentation/filtersets.py +++ b/netbox_documentation/filtersets.py @@ -5,7 +5,7 @@ from .models import Document, DocumentAssignment, DocumentCategory class DocumentFilterSet(NetBoxModelFilterSet): class Meta: model = Document - fields = ("id", "title", "slug", "category_id", "is_published") + fields = ("id", "title", "slug", "category_id", "tenant_group_id", "tenant_id", "is_published") def search(self, queryset, name, value): from django.db.models import Q @@ -21,7 +21,7 @@ class AssignmentFilterSet(NetBoxModelFilterSet): class DocumentCategoryFilterSet(NetBoxModelFilterSet): class Meta: model = DocumentCategory - fields = ("id", "name", "slug", "parent_id") + fields = ("id", "name", "slug", "parent_id", "tenant_group_id", "tenant_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 2842e64..e68f7c2 100644 --- a/netbox_documentation/forms.py +++ b/netbox_documentation/forms.py @@ -12,6 +12,7 @@ from utilities.forms.rendering import FieldSet from utilities.forms.widgets import HTMXSelect from netbox.forms import NetBoxModelForm from dcim.models import Device +from tenancy.models import Tenant, TenantGroup from .embedded_images import EmbeddedImageError, store_embedded_images, validate_embedded_images from .models import Document, DocumentAssignment, DocumentAttachment, DocumentCategory @@ -42,15 +43,21 @@ class DocumentForm(NetBoxModelForm): }), 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") + tenant_group = DynamicModelChoiceField( + queryset=TenantGroup.objects.all(), required=False, selector=True, label="Mandantengruppe", + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), required=False, selector=True, label="Mandant", + ) fieldsets = ( - FieldSet("title", "slug", "category", "summary", "is_published", "tags", name="Dokumentation"), + FieldSet("title", "slug", "category", "tenant_group", "tenant", "summary", "is_published", "tags", name="Dokumentation"), FieldSet("body", "body_format", name="Inhalt"), ) class Meta: model = Document - fields = ("title", "slug", "category", "summary", "body", "body_format", "is_published", "tags") + fields = ("title", "slug", "category", "tenant_group", "tenant", "summary", "body", "body_format", "is_published", "tags") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -70,6 +77,13 @@ class DocumentForm(NetBoxModelForm): raise forms.ValidationError(str(exc)) from exc return body + def clean(self): + cleaned = super().clean() + tenant, group = cleaned.get("tenant"), cleaned.get("tenant_group") + if tenant and group and tenant.group_id != group.pk: + self.add_error("tenant", "Der Mandant gehört nicht zur ausgewählten Mandantengruppe.") + return cleaned + def save(self, commit=True): document = super().save(commit=commit) if not commit or not document.pk or "data:image/" not in (document.body or "").lower(): @@ -134,18 +148,31 @@ class AssignmentForm(NetBoxModelForm): class DocumentCategoryForm(NetBoxModelForm): slug = SlugField(slug_source="name") parent = DynamicModelChoiceField(queryset=DocumentCategory.objects.all(), required=False, label="Übergeordneter Ordner") + tenant_group = DynamicModelChoiceField( + queryset=TenantGroup.objects.all(), required=False, selector=True, label="Mandantengruppe", + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), required=False, selector=True, label="Mandant", + ) - fieldsets = (FieldSet("name", "slug", "parent", "description", "tags", name="Ordner"),) + fieldsets = (FieldSet("name", "slug", "parent", "tenant_group", "tenant", "description", "tags", name="Ordner"),) class Meta: model = DocumentCategory - fields = ("name", "slug", "parent", "description", "tags") + fields = ("name", "slug", "parent", "tenant_group", "tenant", "description", "tags") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if self.instance.pk: self.fields["parent"].queryset = DocumentCategory.objects.exclude(pk=self.instance.pk) + def clean(self): + cleaned = super().clean() + tenant, group = cleaned.get("tenant"), cleaned.get("tenant_group") + if tenant and group and tenant.group_id != group.pk: + self.add_error("tenant", "Der Mandant gehört nicht zur ausgewählten Mandantengruppe.") + return cleaned + class ImportForm(forms.Form): file = forms.FileField(label="Word-, Excel- oder PDF-Datei") diff --git a/netbox_documentation/migrations/0005_document_tenancy.py b/netbox_documentation/migrations/0005_document_tenancy.py new file mode 100644 index 0000000..a8343b1 --- /dev/null +++ b/netbox_documentation/migrations/0005_document_tenancy.py @@ -0,0 +1,44 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("netbox_documentation", "0004_excelimportpreview"), + ("tenancy", "0001_squashed_0012"), + ] + + operations = [ + migrations.AddField( + model_name="document", + name="tenant_group", + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name="documentation_documents", to="tenancy.tenantgroup", + ), + ), + migrations.AddField( + model_name="document", + name="tenant", + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name="documentation_documents", to="tenancy.tenant", + ), + ), + migrations.AddField( + model_name="documentcategory", + name="tenant_group", + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name="documentation_categories", to="tenancy.tenantgroup", + ), + ), + migrations.AddField( + model_name="documentcategory", + name="tenant", + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name="documentation_categories", to="tenancy.tenant", + ), + ), + ] diff --git a/netbox_documentation/models.py b/netbox_documentation/models.py index 8a42cf9..4d8da1a 100644 --- a/netbox_documentation/models.py +++ b/netbox_documentation/models.py @@ -19,6 +19,14 @@ class Document(NetBoxModel): 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") + tenant_group = models.ForeignKey( + "tenancy.TenantGroup", on_delete=models.SET_NULL, null=True, blank=True, + related_name="documentation_documents", + ) + tenant = models.ForeignKey( + "tenancy.Tenant", on_delete=models.SET_NULL, null=True, blank=True, + related_name="documentation_documents", + ) class Meta: ordering = ("title",) @@ -68,6 +76,14 @@ class DocumentCategory(NetBoxModel): 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) + tenant_group = models.ForeignKey( + "tenancy.TenantGroup", on_delete=models.SET_NULL, null=True, blank=True, + related_name="documentation_categories", + ) + tenant = models.ForeignKey( + "tenancy.Tenant", on_delete=models.SET_NULL, null=True, blank=True, + related_name="documentation_categories", + ) class Meta: ordering = ("name",) diff --git a/netbox_documentation/tables.py b/netbox_documentation/tables.py index 26b1dbc..e80545d 100644 --- a/netbox_documentation/tables.py +++ b/netbox_documentation/tables.py @@ -10,7 +10,7 @@ class DocumentTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Document - fields = ("pk", "title", "category", "summary", "is_published", "assignments", "last_updated", "actions") + fields = ("pk", "title", "category", "tenant_group", "tenant", "summary", "is_published", "assignments", "last_updated", "actions") class DocumentCategoryTable(NetBoxTable): @@ -20,7 +20,7 @@ class DocumentCategoryTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = DocumentCategory - fields = ("pk", "name", "parent", "description", "document_count", "actions") + fields = ("pk", "name", "parent", "tenant_group", "tenant", "description", "document_count", "actions") class AssignmentTable(NetBoxTable): diff --git a/netbox_documentation/templates/netbox_documentation/document.html b/netbox_documentation/templates/netbox_documentation/document.html index 46c0c4d..7dfa96e 100644 --- a/netbox_documentation/templates/netbox_documentation/document.html +++ b/netbox_documentation/templates/netbox_documentation/document.html @@ -78,6 +78,10 @@