feat: gemeinsamen ZIP-Export und -Import hinzufügen

This commit is contained in:
2026-07-22 12:35:59 +02:00
parent e0c2c0bbc9
commit c586e9fdaa
9 changed files with 347 additions and 4 deletions
+16 -1
View File
@@ -10,6 +10,7 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit
- Breite Editoransicht und Fokusmodus innerhalb des NetBox-Layouts - Breite Editoransicht und Fokusmodus innerhalb des NetBox-Layouts
- Bilder direkt vom eigenen Gerät in die Dokumentation hochladen - Bilder direkt vom eigenen Gerät in die Dokumentation hochladen
- Formatierte Word-Inhalte inklusive Tabellen und unterstützten Zwischenablage-Bildern einfügen - 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
- Eine Dokumentation mehreren Objekten zuordnen und umgekehrt - Eine Dokumentation mehreren Objekten zuordnen und umgekehrt
- Unbegrenzt viele Objektzuordnungen pro Dokumentation; nur identische Doppelzuordnungen werden verhindert - Unbegrenzt viele Objektzuordnungen pro Dokumentation; nur identische Doppelzuordnungen werden verhindert
- Unterstützte Standardobjekte: Region, Standort, Location, Rack, Gerät, VM, VM-Cluster und Mandant/Kunde - Unterstützte Standardobjekte: Region, Standort, Location, Rack, Gerät, VM, VM-Cluster und Mandant/Kunde
@@ -20,7 +21,7 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit
## Kompatibilität ## Kompatibilität
Die Version `0.3.4` 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.4.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 ## Installation
@@ -68,6 +69,7 @@ PLUGINS = [
PLUGINS_CONFIG = { PLUGINS_CONFIG = {
"netbox_documentation": { "netbox_documentation": {
"max_import_size_mb": 25, "max_import_size_mb": 25,
"max_archive_size_mb": 250,
"keep_imported_file": True, "keep_imported_file": True,
"allowed_object_types": [ "allowed_object_types": [
"dcim.region", "dcim.region",
@@ -127,6 +129,19 @@ Nach Aktivierung stehen die üblichen NetBox-Plugin-Endpunkte bereit:
- `/api/plugins/documentation/folders/` - `/api/plugins/documentation/folders/`
- `/api/plugins/documentation/attachments/` - `/api/plugins/documentation/attachments/`
## ZIP-Archiv
Unter **Dokumentation → ZIP Export & Import** können mehrere Dokumentationen ausgewählt und gemeinsam heruntergeladen werden. Das Archiv enthält:
- Dokumentinhalt und Metadaten
- vollständige Ordnerpfade
- Zuordnungen zu NetBox-Objekten
- importierte Dateien und Editor-Bilder
Beim Import kann gewählt werden, ob Dokumentationen mit derselben Kennung aktualisiert oder als neue Kopie angelegt werden. Zuordnungen zu nicht vorhandenen beziehungsweise nicht erlaubten NetBox-Objekten werden übersprungen. Eingebettete Bild-URLs werden auf die neu gespeicherten Anhänge umgeschrieben.
Archive werden vor dem Import auf sichere Pfade, Dateianzahl, Dateitypen, Kompressionsverhältnis und entpackte Gesamtgröße geprüft. Das Größenlimit wird mit `max_archive_size_mb` in `PLUGINS_CONFIG` festgelegt.
## Entwicklung und Tests ## Entwicklung und Tests
```bash ```bash
+2 -1
View File
@@ -5,7 +5,7 @@ class DocumentationConfig(PluginConfig):
name = "netbox_documentation" name = "netbox_documentation"
verbose_name = "NetBox Dokumentation" verbose_name = "NetBox Dokumentation"
description = "Wiki und Office-Dokumentation direkt in NetBox" description = "Wiki und Office-Dokumentation direkt in NetBox"
version = "0.3.4" version = "0.4.0"
author = "LKE" author = "LKE"
base_url = "documentation" base_url = "documentation"
min_version = "4.0.0" min_version = "4.0.0"
@@ -17,6 +17,7 @@ class DocumentationConfig(PluginConfig):
"virtualization.cluster", "tenancy.tenant", "virtualization.cluster", "tenancy.tenant",
], ],
"max_import_size_mb": 25, "max_import_size_mb": 25,
"max_archive_size_mb": 250,
"keep_imported_file": True, "keep_imported_file": True,
} }
+197
View File
@@ -0,0 +1,197 @@
import json
from pathlib import Path, PurePosixPath
from tempfile import SpooledTemporaryFile
from zipfile import ZIP_DEFLATED, BadZipFile, ZipFile
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.utils.text import slugify
from .models import Document, DocumentAssignment, DocumentAttachment, DocumentCategory
ARCHIVE_FORMAT = "netbox-documentation"
ARCHIVE_VERSION = 1
MAX_ENTRIES = 5000
class ArchiveFailure(ValueError):
pass
def _category_path(category):
path, seen = [], set()
while category and category.pk not in seen:
path.append(category.name)
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"):
root = f"documents/{document.pk}"
extension = "html" if document.body_format == "html" else "md"
body_path = f"{root}/content.{extension}"
archive.writestr(body_path, document.body.encode("utf-8"))
attachments = []
for attachment in document.attachments.all():
safe_name = Path(attachment.original_name).name or f"attachment-{attachment.pk}"
member = f"{root}/attachments/{attachment.pk}-{safe_name}"
try:
with attachment.file.open("rb") as source:
archive.writestr(member, source.read())
except (FileNotFoundError, OSError):
continue
attachments.append({
"path": member, "original_name": safe_name,
"content_type": attachment.content_type, "size": attachment.size,
"source_url": attachment.file.url,
})
assignments = [{
"app_label": item.assigned_object_type.app_label,
"model": item.assigned_object_type.model,
"object_id": item.assigned_object_id,
"note": item.note,
} for item in document.assignments.all()]
manifest["documents"].append({
"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,
"assignments": assignments, "attachments": attachments,
})
archive.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8"))
stream.seek(0)
return stream
def _validate_archive(archive):
infos = archive.infolist()
if len(infos) > MAX_ENTRIES:
raise ArchiveFailure(f"Das Archiv enthält mehr als {MAX_ENTRIES} Dateien.")
limit = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("max_archive_size_mb", 250) * 1024 * 1024
if sum(info.file_size for info in infos) > limit:
raise ArchiveFailure("Die entpackte Gesamtgröße überschreitet das konfigurierte Limit.")
for info in infos:
path = PurePosixPath(info.filename)
if path.is_absolute() or ".." in path.parts:
raise ArchiveFailure("Das Archiv enthält einen unsicheren Dateipfad.")
if info.compress_size and info.file_size / info.compress_size > 200:
raise ArchiveFailure("Das Archiv enthält eine verdächtig stark komprimierte Datei.")
def _read_json(archive, name):
try:
return json.loads(archive.read(name).decode("utf-8"))
except (KeyError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ArchiveFailure("Das Archiv enthält kein gültiges manifest.json.") from exc
def _category_from_path(names):
parent = None
for name in names:
name = str(name).strip()[:100]
if not name:
continue
slug = slugify(name)[:100] or "ordner"
category = DocumentCategory.objects.filter(parent=parent, slug=slug).first()
if not category:
category = DocumentCategory.objects.create(name=name, slug=slug, parent=parent)
parent = category
return parent
def _unique_slug(value):
base = (slugify(value) or "dokumentation")[:180]
candidate, number = base, 2
while Document.objects.filter(slug=candidate).exists():
candidate = f"{base[:190-len(str(number))]}-{number}"
number += 1
return candidate
@transaction.atomic
def import_archive(upload, update_existing=False):
try:
archive = ZipFile(upload)
except BadZipFile as exc:
raise ArchiveFailure("Die hochgeladene Datei ist kein gültiges ZIP-Archiv.") from exc
with archive:
_validate_archive(archive)
manifest = _read_json(archive, "manifest.json")
if manifest.get("format") != ARCHIVE_FORMAT or manifest.get("version") != ARCHIVE_VERSION:
raise ArchiveFailure("Archivformat oder Version wird nicht unterstützt.")
records = manifest.get("documents")
if not isinstance(records, list):
raise ArchiveFailure("Die Dokumentliste im Archiv ist ungültig.")
allowed = set(settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("allowed_object_types", []))
created, updated, skipped_assignments = 0, 0, 0
for record in records:
if not isinstance(record, dict) or not record.get("title") or not record.get("body_path"):
raise ArchiveFailure("Das Archiv enthält einen unvollständigen Dokumenteintrag.")
try:
body = archive.read(record["body_path"]).decode("utf-8")
except (KeyError, UnicodeDecodeError) as exc:
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 [])
if document:
updated += 1
else:
document = Document(slug=_unique_slug(source_slug))
created += 1
document.title = str(record["title"])[:200]
document.summary = str(record.get("summary") or "")[:500]
document.body = body
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.save()
for assignment in record.get("assignments") or []:
label = f"{assignment.get('app_label')}.{assignment.get('model')}"
if label not in allowed:
skipped_assignments += 1
continue
content_type = ContentType.objects.filter(app_label=assignment.get("app_label"), model=assignment.get("model")).first()
model = content_type.model_class() if content_type else None
if not model or not model.objects.filter(pk=assignment.get("object_id")).exists():
skipped_assignments += 1
continue
DocumentAssignment.objects.get_or_create(
document=document, assigned_object_type=content_type,
assigned_object_id=assignment["object_id"],
defaults={"note": str(assignment.get("note") or "")[:200]},
)
for item in record.get("attachments") or []:
member = item.get("path")
if not member:
continue
try:
content = archive.read(member)
except KeyError as exc:
raise ArchiveFailure(f"Anhang {member} fehlt im Archiv.") from exc
from django.core.files.base import ContentFile
original_name = Path(str(item.get("original_name") or "attachment")).name[:255]
allowed_extensions = {".docx", ".xlsx", ".xlsm", ".pdf", ".md", ".txt", ".jpg", ".jpeg", ".png", ".gif", ".webp"}
if Path(original_name).suffix.lower() not in allowed_extensions:
raise ArchiveFailure(f"Der Anhang {original_name} verwendet einen nicht erlaubten Dateityp.")
existing = document.attachments.filter(original_name=original_name, size=len(content)).first()
if existing:
if item.get("source_url"):
document.body = document.body.replace(str(item["source_url"]), existing.file.url)
continue
attachment = DocumentAttachment(
document=document, original_name=original_name,
content_type=str(item.get("content_type") or "")[:100], size=len(content),
)
attachment.file.save(original_name, ContentFile(content), save=False)
attachment.save()
if item.get("source_url"):
document.body = document.body.replace(str(item["source_url"]), attachment.file.url)
document.save(update_fields=("body", "last_updated"))
return {"created": created, "updated": updated, "skipped_assignments": skipped_assignments}
+32
View File
@@ -116,3 +116,35 @@ class ImportForm(forms.Form):
if upload and upload.size > limit * 1024 * 1024: if upload and upload.size > limit * 1024 * 1024:
self.add_error("file", f"Die Datei ist größer als {limit} MB.") self.add_error("file", f"Die Datei ist größer als {limit} MB.")
return data return data
class ArchiveExportForm(forms.Form):
documents = forms.ModelMultipleChoiceField(
queryset=Document.objects.none(), widget=forms.CheckboxSelectMultiple,
label="Dokumentationen",
)
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
queryset = Document.objects.all().order_by("title")
if user is not None:
queryset = queryset.restrict(user, "view")
self.fields["documents"].queryset = queryset
class ArchiveImportForm(forms.Form):
archive = forms.FileField(label="ZIP-Archiv")
update_existing = forms.BooleanField(
required=False, initial=False,
label="Bestehende Dokumentationen mit gleicher Kennung aktualisieren",
help_text="Ohne diese Option werden bei Namenskonflikten neue Dokumentationen mit einer fortlaufenden Kennung angelegt.",
)
def clean_archive(self):
upload = self.cleaned_data["archive"]
if not upload.name.lower().endswith(".zip"):
raise forms.ValidationError("Bitte ein vom Plugin erzeugtes ZIP-Archiv auswählen.")
limit = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("max_archive_size_mb", 250)
if upload.size > limit * 1024 * 1024:
raise forms.ValidationError(f"Das Archiv ist größer als {limit} MB.")
return upload
+1
View File
@@ -14,6 +14,7 @@ menu = PluginMenu(
PluginMenuItem(link="plugins:netbox_documentation:documentcategory_list", link_text="Ordner & Kategorien", buttons=( 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), PluginMenuButton(link="plugins:netbox_documentation:documentcategory_add", title="Ordner erstellen", icon_class="mdi mdi-folder-plus", color=ButtonColorChoices.GREEN),
)), )),
PluginMenuItem(link="plugins:netbox_documentation:document_archive", link_text="ZIP Export & Import"),
)),), )),),
icon_class="mdi mdi-book-open-page-variant", icon_class="mdi mdi-book-open-page-variant",
) )
@@ -0,0 +1,37 @@
{% extends 'base/layout.html' %}
{% load form_helpers %}
{% block title %}Dokumentationsarchiv{% endblock %}
{% block content %}
<div class="row g-4">
<div class="col-12 col-xl-6">
<div class="card h-100">
<h5 class="card-header"><i class="mdi mdi-folder-zip"></i> Dokumentationen exportieren</h5>
<div class="card-body">
<p>Ausgewählte Dokumentationen gemeinsam mit Ordnern, Objektzuordnungen und Anhängen herunterladen.</p>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="export">
{% render_form export_form %}
<button type="submit" class="btn btn-primary"><i class="mdi mdi-download"></i> ZIP herunterladen</button>
</form>
</div>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="card h-100">
<h5 class="card-header"><i class="mdi mdi-folder-upload"></i> Dokumentationen importieren</h5>
<div class="card-body">
<p>Ein zuvor mit diesem Plugin erzeugtes ZIP-Archiv hochladen. Nicht vorhandene NetBox-Zielobjekte werden sicher übersprungen.</p>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="action" value="import">
{% render_form import_form %}
<button type="submit" class="btn btn-primary"><i class="mdi mdi-upload"></i> ZIP importieren</button>
</form>
</div>
</div>
</div>
</div>
{% endblock content %}
+1
View File
@@ -7,6 +7,7 @@ urlpatterns = (
path("", views.DocumentListView.as_view(), name="document_list"), path("", views.DocumentListView.as_view(), name="document_list"),
path("documents/add/", views.DocumentEditView.as_view(), name="document_add"), path("documents/add/", views.DocumentEditView.as_view(), name="document_add"),
path("documents/import/", views.DocumentImportView.as_view(), name="document_import"), path("documents/import/", views.DocumentImportView.as_view(), name="document_import"),
path("archive/", views.DocumentArchiveView.as_view(), name="document_archive"),
path("documents/<int:pk>/", views.DocumentView.as_view(), name="document"), 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>/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>/media-upload/", views.DocumentMediaUploadView.as_view(), name="document_media_upload"),
+60 -1
View File
@@ -4,6 +4,7 @@ import tinymce
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import PermissionDenied
from django.db import transaction from django.db import transaction
from django.http import FileResponse, Http404, JsonResponse from django.http import FileResponse, Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
@@ -11,7 +12,11 @@ from django.utils.text import slugify
from django.views import View from django.views import View
from netbox.views import generic from netbox.views import generic
from .filtersets import DocumentFilterSet, AssignmentFilterSet, DocumentCategoryFilterSet from .filtersets import DocumentFilterSet, AssignmentFilterSet, DocumentCategoryFilterSet
from .forms import DocumentForm, AssignmentForm, ImportForm, DocumentCategoryForm from .forms import (
ArchiveExportForm, ArchiveImportForm, AssignmentForm, DocumentCategoryForm,
DocumentForm, ImportForm,
)
from .archive import ArchiveFailure, export_documents, import_archive
from .importers import ImportFailure, import_document from .importers import ImportFailure, import_document
from .models import Document, DocumentAssignment, DocumentAttachment, DocumentCategory from .models import Document, DocumentAssignment, DocumentAttachment, DocumentCategory
from .tables import DocumentTable, AssignmentTable, DocumentCategoryTable from .tables import DocumentTable, AssignmentTable, DocumentCategoryTable
@@ -160,3 +165,57 @@ class DocumentMediaUploadView(PermissionRequiredMixin, View):
content_type=upload.content_type or "", size=upload.size, content_type=upload.content_type or "", size=upload.size,
) )
return JsonResponse({"location": attachment.file.url}) return JsonResponse({"location": attachment.file.url})
class DocumentArchiveView(View):
template_name = "netbox_documentation/document_archive.html"
def _render(self, request, export_form=None, import_form=None):
return render(request, self.template_name, {
"export_form": export_form or ArchiveExportForm(user=request.user),
"import_form": import_form or ArchiveImportForm(),
})
def get(self, request):
if not request.user.has_perm("netbox_documentation.view_document"):
raise PermissionDenied
return self._render(request)
def post(self, request):
action = request.POST.get("action")
if action == "export":
if not request.user.has_perm("netbox_documentation.view_document"):
raise PermissionDenied
form = ArchiveExportForm(request.POST, user=request.user)
if not form.is_valid():
return self._render(request, export_form=form)
stream = export_documents(form.cleaned_data["documents"])
response = FileResponse(stream, content_type="application/zip")
response["Content-Disposition"] = 'attachment; filename="netbox-documentation-export.zip"'
return response
if action == "import":
required = (
"netbox_documentation.import_document",
"netbox_documentation.add_document",
"netbox_documentation.change_document",
"netbox_documentation.add_documentassignment",
"netbox_documentation.add_documentattachment",
"netbox_documentation.add_documentcategory",
)
if not all(request.user.has_perm(permission) for permission in required):
raise PermissionDenied
form = ArchiveImportForm(request.POST, request.FILES)
if not form.is_valid():
return self._render(request, import_form=form)
try:
result = import_archive(form.cleaned_data["archive"], form.cleaned_data["update_existing"])
except ArchiveFailure as exc:
form.add_error("archive", str(exc))
return self._render(request, import_form=form)
messages.success(request, (
f"Archiv importiert: {result['created']} erstellt, {result['updated']} aktualisiert."
))
if result["skipped_assignments"]:
messages.warning(request, f"{result['skipped_assignments']} nicht verfügbare Objektzuordnungen wurden übersprungen.")
return redirect("plugins:netbox_documentation:document_list")
return self._render(request)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "netbox-documentation" name = "netbox-documentation"
version = "0.3.4" version = "0.4.0"
description = "Integrated Markdown wiki and office document importer for NetBox" description = "Integrated Markdown wiki and office document importer for NetBox"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"