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 @@
{{ object.rendered_body|safe }}
+ {% if object.tenant_group or object.tenant %}
Mandantenzuordnung
+ {% if object.tenant_group %} {{ object.tenant_group }}{% endif %} + {% if object.tenant %} {{ object.tenant }}{% endif %} +
{% endif %} {% if object.category %}
Ordner
{{ 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/documentcategory.html b/netbox_documentation/templates/netbox_documentation/documentcategory.html index aba012c..3c10a8f 100644 --- a/netbox_documentation/templates/netbox_documentation/documentcategory.html +++ b/netbox_documentation/templates/netbox_documentation/documentcategory.html @@ -3,6 +3,15 @@ {% block content %}
+ {% if object.tenant_group or object.tenant %} +
+
Mandantenzuordnung
+
+ {% if object.tenant_group %} {{ object.tenant_group }}{% endif %} + {% if object.tenant %} {{ object.tenant }}{% endif %} +
+
+ {% endif %}
Unterordner
diff --git a/netbox_documentation/views.py b/netbox_documentation/views.py index fe91549..fa6b5cf 100644 --- a/netbox_documentation/views.py +++ b/netbox_documentation/views.py @@ -46,14 +46,14 @@ class EditorAssetView(View): class DocumentListView(generic.ObjectListView): - queryset = Document.objects.prefetch_related("assignments") + queryset = Document.objects.select_related("category", "tenant_group", "tenant").prefetch_related("assignments") table = DocumentTable filterset = DocumentFilterSet actions = (AddObject, BulkImport, BulkDelete) class DocumentView(generic.ObjectView): - queryset = Document.objects.prefetch_related("assignments", "attachments") + queryset = Document.objects.select_related("category", "tenant_group", "tenant").prefetch_related("assignments", "attachments") class DocumentPrintView(PermissionRequiredMixin, View): @@ -85,14 +85,14 @@ class DocumentBulkDeleteView(generic.BulkDeleteView): class DocumentCategoryListView(generic.ObjectListView): - queryset = DocumentCategory.objects.select_related("parent").prefetch_related("documents") + queryset = DocumentCategory.objects.select_related("parent", "tenant_group", "tenant").prefetch_related("documents") table = DocumentCategoryTable filterset = DocumentCategoryFilterSet actions = (AddObject, BulkImport, BulkDelete) class DocumentCategoryView(generic.ObjectView): - queryset = DocumentCategory.objects.prefetch_related("children", "documents") + queryset = DocumentCategory.objects.select_related("tenant_group", "tenant").prefetch_related("children", "documents") class DocumentCategoryEditView(generic.ObjectEditView): @@ -170,8 +170,12 @@ class DocumentImportView(PermissionRequiredMixin, View): while Document.objects.filter(slug=slug).exists(): slug = f"{base_slug}-{counter}" counter += 1 + category = form.cleaned_data.get("category") document = Document.objects.create( title=title, slug=slug, body=result.markdown, body_format=result.body_format, + category=category, + tenant_group=category.tenant_group if category else None, + tenant=category.tenant if category else None, ) keep = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("keep_imported_file", True) if keep: @@ -351,7 +355,11 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View): document.body = sheet.markdown document.body_format = sheet.body_format document.summary = f"Importiert aus {preview.original_name}" - document.save(update_fields=("title", "body", "body_format", "summary", "last_updated")) + document.tenant_group = preview.category.tenant_group + document.tenant = preview.category.tenant + document.save(update_fields=( + "title", "body", "body_format", "summary", "tenant_group", "tenant", "last_updated", + )) updated_count += 1 else: base_slug = slugify(title)[:180] or "arbeitsblatt" @@ -362,6 +370,7 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View): document = Document.objects.create( title=title[:200], slug=document_slug, body=sheet.markdown, body_format=sheet.body_format, category=preview.category, + tenant_group=preview.category.tenant_group, tenant=preview.category.tenant, summary=f"Importiert aus {preview.original_name}", ) created_count += 1 diff --git a/pyproject.toml b/pyproject.toml index f79ed64..6ae3e2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netbox-documentation" -version = "0.7.14" +version = "0.9.0" description = "Integrated Markdown wiki and office document importer for NetBox" readme = "README.md" requires-python = ">=3.10"