feat: gemeinsamen ZIP-Export und -Import hinzufügen
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user