- NetBox-4.6-Verknüpfungsformular korrigieren - hierarchische Dokumentationsordner hinzufügen - breite Editoransicht und Fokusmodus implementieren - sicheren direkten Bild-Upload integrieren - Ordner über Suche und REST-API bereitstellen
163 lines
6.8 KiB
Python
163 lines
6.8 KiB
Python
from pathlib import Path
|
|
import mimetypes
|
|
import tinymce
|
|
from django.conf import settings
|
|
from django.contrib import messages
|
|
from django.contrib.auth.mixins import PermissionRequiredMixin
|
|
from django.db import transaction
|
|
from django.http import FileResponse, Http404, JsonResponse
|
|
from django.shortcuts import get_object_or_404, redirect, render
|
|
from django.utils.text import slugify
|
|
from django.views import View
|
|
from netbox.views import generic
|
|
from .filtersets import DocumentFilterSet, AssignmentFilterSet, DocumentCategoryFilterSet
|
|
from .forms import DocumentForm, AssignmentForm, ImportForm, DocumentCategoryForm
|
|
from .importers import ImportFailure, import_document
|
|
from .models import Document, DocumentAssignment, DocumentAttachment, DocumentCategory
|
|
from .tables import DocumentTable, AssignmentTable, DocumentCategoryTable
|
|
|
|
|
|
class EditorAssetView(View):
|
|
"""Serve the bundled editor assets when NetBox's static proxy is unavailable."""
|
|
|
|
def get(self, request, asset_path):
|
|
root = (Path(tinymce.__file__).resolve().parent / "static" / "tinymce").resolve()
|
|
candidate = (root / asset_path).resolve()
|
|
if root not in candidate.parents or not candidate.is_file():
|
|
raise Http404
|
|
content_type = mimetypes.guess_type(candidate.name)[0] or "application/octet-stream"
|
|
response = FileResponse(candidate.open("rb"), content_type=content_type)
|
|
response["Cache-Control"] = "public, max-age=31536000, immutable"
|
|
response["X-Content-Type-Options"] = "nosniff"
|
|
return response
|
|
|
|
|
|
class DocumentListView(generic.ObjectListView):
|
|
queryset = Document.objects.prefetch_related("assignments")
|
|
table = DocumentTable
|
|
filterset = DocumentFilterSet
|
|
|
|
|
|
class DocumentView(generic.ObjectView):
|
|
queryset = Document.objects.prefetch_related("assignments", "attachments")
|
|
|
|
|
|
class DocumentEditView(generic.ObjectEditView):
|
|
queryset = Document.objects.all()
|
|
form = DocumentForm
|
|
template_name = "netbox_documentation/document_edit.html"
|
|
|
|
|
|
class DocumentDeleteView(generic.ObjectDeleteView):
|
|
queryset = Document.objects.all()
|
|
|
|
|
|
class DocumentCategoryListView(generic.ObjectListView):
|
|
queryset = DocumentCategory.objects.select_related("parent").prefetch_related("documents")
|
|
table = DocumentCategoryTable
|
|
filterset = DocumentCategoryFilterSet
|
|
|
|
|
|
class DocumentCategoryView(generic.ObjectView):
|
|
queryset = DocumentCategory.objects.prefetch_related("children", "documents")
|
|
|
|
|
|
class DocumentCategoryEditView(generic.ObjectEditView):
|
|
queryset = DocumentCategory.objects.all()
|
|
form = DocumentCategoryForm
|
|
|
|
|
|
class DocumentCategoryDeleteView(generic.ObjectDeleteView):
|
|
queryset = DocumentCategory.objects.all()
|
|
|
|
|
|
class AssignmentListView(generic.ObjectListView):
|
|
queryset = DocumentAssignment.objects.select_related("document", "assigned_object_type")
|
|
table = AssignmentTable
|
|
filterset = AssignmentFilterSet
|
|
|
|
|
|
class AssignmentEditView(generic.ObjectEditView):
|
|
queryset = DocumentAssignment.objects.all()
|
|
form = AssignmentForm
|
|
|
|
|
|
class AssignmentDeleteView(generic.ObjectDeleteView):
|
|
queryset = DocumentAssignment.objects.all()
|
|
|
|
|
|
class DocumentImportView(PermissionRequiredMixin, View):
|
|
permission_required = "netbox_documentation.import_document"
|
|
template_name = "netbox_documentation/document_import.html"
|
|
|
|
def get(self, request):
|
|
return render(request, self.template_name, {"form": ImportForm()})
|
|
|
|
def post(self, request):
|
|
form = ImportForm(request.POST, request.FILES)
|
|
if not form.is_valid():
|
|
return render(request, self.template_name, {"form": form})
|
|
upload = form.cleaned_data["file"]
|
|
try:
|
|
result = import_document(upload)
|
|
except (ImportFailure, Exception) as exc:
|
|
# Known conversion/library errors are presented without exposing a traceback.
|
|
form.add_error("file", f"Import fehlgeschlagen: {exc}")
|
|
return render(request, self.template_name, {"form": form})
|
|
with transaction.atomic():
|
|
document = form.cleaned_data.get("document")
|
|
if document:
|
|
imported_body = result.markdown
|
|
if document.body_format == "html":
|
|
from markdown import markdown
|
|
imported_body = markdown(imported_body, extensions=("extra", "sane_lists"))
|
|
separator = "\n\n---\n\n" if document.body else ""
|
|
document.body += separator + imported_body
|
|
document.save()
|
|
else:
|
|
title = form.cleaned_data.get("title") or Path(upload.name).stem
|
|
base_slug = slugify(title)[:180] or "dokumentation"
|
|
slug = base_slug
|
|
counter = 2
|
|
while Document.objects.filter(slug=slug).exists():
|
|
slug = f"{base_slug}-{counter}"
|
|
counter += 1
|
|
document = Document.objects.create(title=title, slug=slug, body=result.markdown, body_format="markdown")
|
|
keep = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("keep_imported_file", True)
|
|
if keep:
|
|
upload.seek(0)
|
|
DocumentAttachment.objects.create(document=document, file=upload,
|
|
original_name=upload.name, content_type=upload.content_type or "", size=upload.size)
|
|
for warning in result.warnings:
|
|
messages.warning(request, warning)
|
|
messages.success(request, f"{upload.name} wurde importiert.")
|
|
return redirect(document)
|
|
|
|
|
|
class DocumentMediaUploadView(PermissionRequiredMixin, View):
|
|
permission_required = "netbox_documentation.change_document"
|
|
|
|
def post(self, request, pk):
|
|
document = get_object_or_404(Document.objects.restrict(request.user, "change"), pk=pk)
|
|
upload = request.FILES.get("file")
|
|
if not upload:
|
|
return JsonResponse({"error": "Keine Datei empfangen."}, status=400)
|
|
limit = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("max_import_size_mb", 25)
|
|
if upload.size > limit * 1024 * 1024:
|
|
return JsonResponse({"error": f"Die Datei ist größer als {limit} MB."}, status=400)
|
|
allowed = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
|
if upload.content_type not in allowed:
|
|
return JsonResponse({"error": "Im Editor sind JPEG, PNG, GIF und WebP erlaubt."}, status=400)
|
|
try:
|
|
from PIL import Image
|
|
image = Image.open(upload)
|
|
image.verify()
|
|
upload.seek(0)
|
|
except Exception:
|
|
return JsonResponse({"error": "Die hochgeladene Datei ist kein gültiges Bild."}, status=400)
|
|
attachment = DocumentAttachment.objects.create(
|
|
document=document, file=upload, original_name=upload.name,
|
|
content_type=upload.content_type or "", size=upload.size,
|
|
)
|
|
return JsonResponse({"location": attachment.file.url})
|