500 lines
23 KiB
Python
500 lines
23 KiB
Python
from pathlib import Path
|
|
from datetime import timedelta
|
|
from html import escape
|
|
import mimetypes
|
|
import tinymce
|
|
from django.conf import settings
|
|
from django.contrib import messages
|
|
from django.contrib.auth.mixins import PermissionRequiredMixin
|
|
from django.core.exceptions import PermissionDenied
|
|
from django.db import transaction
|
|
from django.db.models import Q
|
|
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.utils import timezone
|
|
from django.views import View
|
|
from netbox.views import generic
|
|
from netbox.object_actions import AddObject, BulkDelete, BulkImport
|
|
from .filtersets import DocumentFilterSet, AssignmentFilterSet, DocumentCategoryFilterSet
|
|
from .forms import (
|
|
ArchiveExportForm, ArchiveImportForm, AssignmentForm, DocumentCategoryForm,
|
|
DocumentForm, ExcelSheetSelectionFormSet, ImportForm,
|
|
)
|
|
from .archive import ArchiveFailure, documents_for_categories, export_documents, import_archive
|
|
from .importers import ImportFailure, import_document, import_excel_sheets
|
|
from .models import (
|
|
Document, DocumentAssignment, DocumentAttachment, DocumentCategory,
|
|
ExcelImportPreview,
|
|
)
|
|
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
|
|
actions = (AddObject, BulkImport, BulkDelete)
|
|
|
|
|
|
class DocumentView(generic.ObjectView):
|
|
queryset = Document.objects.prefetch_related("assignments", "attachments")
|
|
|
|
|
|
class DocumentPrintView(PermissionRequiredMixin, View):
|
|
permission_required = "netbox_documentation.view_document"
|
|
template_name = "netbox_documentation/document_print.html"
|
|
|
|
def get(self, request, pk):
|
|
document = get_object_or_404(
|
|
Document.objects.restrict(request.user, "view").select_related("category").prefetch_related("assignments"),
|
|
pk=pk,
|
|
)
|
|
return render(request, self.template_name, {"object": document})
|
|
|
|
|
|
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 DocumentBulkDeleteView(generic.BulkDeleteView):
|
|
queryset = Document.objects.all()
|
|
table = DocumentTable
|
|
filterset = DocumentFilterSet
|
|
|
|
|
|
class DocumentCategoryListView(generic.ObjectListView):
|
|
queryset = DocumentCategory.objects.select_related("parent").prefetch_related("documents")
|
|
table = DocumentCategoryTable
|
|
filterset = DocumentCategoryFilterSet
|
|
actions = (AddObject, BulkImport, BulkDelete)
|
|
|
|
|
|
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 DocumentCategoryBulkDeleteView(generic.BulkDeleteView):
|
|
queryset = DocumentCategory.objects.all()
|
|
table = DocumentCategoryTable
|
|
filterset = DocumentCategoryFilterSet
|
|
|
|
|
|
class AssignmentListView(generic.ObjectListView):
|
|
queryset = DocumentAssignment.objects.select_related("document", "assigned_object_type")
|
|
table = AssignmentTable
|
|
filterset = AssignmentFilterSet
|
|
actions = (AddObject, BulkImport, BulkDelete)
|
|
|
|
|
|
class AssignmentEditView(generic.ObjectEditView):
|
|
queryset = DocumentAssignment.objects.all()
|
|
form = AssignmentForm
|
|
|
|
|
|
class AssignmentDeleteView(generic.ObjectDeleteView):
|
|
queryset = DocumentAssignment.objects.all()
|
|
|
|
|
|
class AssignmentBulkDeleteView(generic.BulkDeleteView):
|
|
queryset = DocumentAssignment.objects.all()
|
|
table = AssignmentTable
|
|
filterset = AssignmentFilterSet
|
|
|
|
|
|
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(user=request.user)})
|
|
|
|
def post(self, request):
|
|
form = ImportForm(request.POST, request.FILES, user=request.user)
|
|
if not form.is_valid():
|
|
return render(request, self.template_name, {"form": form})
|
|
upload = form.cleaned_data["file"]
|
|
if form.cleaned_data.get("excel_sheets_as_documents"):
|
|
return self._import_excel_sheets(request, form, upload)
|
|
try:
|
|
result = import_document(upload, flatten_excel=form.cleaned_data.get("flatten_excel_tables", False))
|
|
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=result.body_format,
|
|
)
|
|
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)
|
|
|
|
def _import_excel_sheets(self, request, form, upload):
|
|
try:
|
|
sheets = import_excel_sheets(
|
|
upload, flatten=form.cleaned_data.get("flatten_excel_tables", False),
|
|
metadata_only=True, include_image_data=False,
|
|
)
|
|
except (ImportFailure, Exception) as exc:
|
|
form.add_error("file", f"Excel-Mehrblattimport fehlgeschlagen: {exc}")
|
|
return render(request, self.template_name, {"form": form})
|
|
# Remove abandoned previews owned by this user before creating a new one.
|
|
stale = ExcelImportPreview.objects.filter(created__lt=timezone.now() - timedelta(hours=24))
|
|
for preview in stale:
|
|
preview.file.delete(save=False)
|
|
preview.delete()
|
|
upload.seek(0)
|
|
preview = ExcelImportPreview.objects.create(
|
|
user=request.user, category=form.cleaned_data["category"], file=upload,
|
|
original_name=upload.name, content_type=upload.content_type or "",
|
|
flatten=form.cleaned_data.get("flatten_excel_tables", False),
|
|
sheet_metadata=[{
|
|
"title": sheet.title, "image_count": len(sheet.images),
|
|
"source_index": sheet.source_index,
|
|
} for sheet in sheets],
|
|
)
|
|
return redirect("plugins:netbox_documentation:excel_import_preview", token=preview.pk)
|
|
|
|
|
|
class ExcelImportPreviewView(PermissionRequiredMixin, View):
|
|
permission_required = "netbox_documentation.import_document"
|
|
template_name = "netbox_documentation/excel_import_preview.html"
|
|
|
|
def get_preview(self, request, token):
|
|
return get_object_or_404(ExcelImportPreview, pk=token, user=request.user)
|
|
|
|
def read_sheets(self, preview, *, for_preview=False, selected_indexes=None):
|
|
preview.file.open("rb")
|
|
try:
|
|
return import_excel_sheets(
|
|
preview.file, flatten=preview.flatten,
|
|
preview_max_rows=100 if for_preview else None,
|
|
include_image_data=not for_preview,
|
|
selected_indexes=selected_indexes,
|
|
)
|
|
finally:
|
|
preview.file.close()
|
|
|
|
def render_preview(self, request, preview, sheets, formset, overwrite_conflicts=None):
|
|
items = []
|
|
for form, sheet in zip(formset.forms, sheets):
|
|
sample = sheet.markdown
|
|
if sheet.images:
|
|
image_notice = f"{len(sheet.images)} eingebettete Bilder werden beim Import angefügt."
|
|
if sheet.body_format == "html":
|
|
sample += f"\n<h2>Bilder</h2><p><em>{image_notice}</em></p>"
|
|
else:
|
|
sample += f"\n\n## Bilder\n\n_{image_notice}_"
|
|
truncated = len(sample) > 100000
|
|
if truncated:
|
|
notice = "… Vorschau gekürzt …"
|
|
sample = sample[:100000] + (
|
|
f"<p><em>{notice}</em></p>" if sheet.body_format == "html" else f"\n\n_{notice}_"
|
|
)
|
|
preview_document = Document(body=sample, body_format=sheet.body_format)
|
|
items.append({
|
|
"form": form, "sheet": sheet,
|
|
"preview_html": preview_document.rendered_body(), "truncated": truncated,
|
|
})
|
|
return render(request, self.template_name, {
|
|
"batch": preview, "formset": formset, "items": items,
|
|
"overwrite_conflicts": overwrite_conflicts or [],
|
|
})
|
|
|
|
def get(self, request, token):
|
|
preview = self.get_preview(request, token)
|
|
if preview.created < timezone.now() - timedelta(hours=24):
|
|
self.delete_preview(preview)
|
|
messages.error(request, "Diese Importvorschau ist abgelaufen. Bitte die Exceldatei erneut hochladen.")
|
|
return redirect("plugins:netbox_documentation:document_import")
|
|
try:
|
|
sheets = self.read_sheets(preview, for_preview=True)
|
|
except (ImportFailure, Exception) as exc:
|
|
self.delete_preview(preview)
|
|
messages.error(request, f"Die Excel-Vorschau konnte nicht erzeugt werden: {exc}")
|
|
return redirect("plugins:netbox_documentation:document_import")
|
|
initial = [{"include": True, "index": index, "title": sheet.title} for index, sheet in enumerate(sheets)]
|
|
return self.render_preview(request, preview, sheets, ExcelSheetSelectionFormSet(initial=initial))
|
|
|
|
def post(self, request, token):
|
|
preview = self.get_preview(request, token)
|
|
if request.POST.get("action") == "cancel":
|
|
self.delete_preview(preview)
|
|
messages.info(request, "Excel-Import wurde abgebrochen.")
|
|
return redirect("plugins:netbox_documentation:document_import")
|
|
formset = ExcelSheetSelectionFormSet(request.POST)
|
|
if not formset.is_valid():
|
|
try:
|
|
preview_sheets = self.read_sheets(preview, for_preview=True)
|
|
except (ImportFailure, Exception) as exc:
|
|
self.delete_preview(preview)
|
|
messages.error(request, f"Die Exceldatei konnte nicht erneut gelesen werden: {exc}")
|
|
return redirect("plugins:netbox_documentation:document_import")
|
|
return self.render_preview(request, preview, preview_sheets, formset)
|
|
if not preview.category:
|
|
self.delete_preview(preview)
|
|
messages.error(request, "Der gewählte Zielordner existiert nicht mehr. Bitte den Import erneut starten.")
|
|
return redirect("plugins:netbox_documentation:document_import")
|
|
selections = {}
|
|
for form in formset.forms:
|
|
data = form.cleaned_data
|
|
if data.get("include"):
|
|
if data["index"] < 0 or data["index"] >= len(preview.sheet_metadata):
|
|
raise PermissionDenied
|
|
metadata = preview.sheet_metadata[data["index"]]
|
|
source_index = metadata.get("source_index", data["index"])
|
|
selections[source_index] = data["title"].strip()
|
|
title_query = Q()
|
|
for title in selections.values():
|
|
title_query |= Q(title__iexact=title)
|
|
conflicts = list(Document.objects.filter(
|
|
title_query, category=preview.category,
|
|
).order_by("title"))
|
|
if conflicts:
|
|
allowed_ids = set(Document.objects.restrict(request.user, "change").filter(
|
|
pk__in=[document.pk for document in conflicts]
|
|
).values_list("pk", flat=True))
|
|
if any(document.pk not in allowed_ids for document in conflicts):
|
|
messages.error(request, "Mindestens eine gleichnamige Dokumentation darf nicht aktualisiert werden.")
|
|
return redirect("plugins:netbox_documentation:excel_import_preview", token=preview.pk)
|
|
if request.POST.get("confirm_overwrite") != "1":
|
|
try:
|
|
preview_sheets = self.read_sheets(preview, for_preview=True)
|
|
except (ImportFailure, Exception) as exc:
|
|
messages.error(request, f"Die Bestätigung konnte nicht angezeigt werden: {exc}")
|
|
return redirect("plugins:netbox_documentation:excel_import_preview", token=preview.pk)
|
|
return self.render_preview(
|
|
request, preview, preview_sheets, formset,
|
|
overwrite_conflicts=conflicts,
|
|
)
|
|
try:
|
|
selected_sheets = self.read_sheets(preview, selected_indexes=selections)
|
|
except (ImportFailure, Exception) as exc:
|
|
messages.error(request, f"Die ausgewählten Excel-Blätter konnten nicht importiert werden: {exc}")
|
|
return redirect("plugins:netbox_documentation:excel_import_preview", token=preview.pk)
|
|
sheets_by_index = {sheet.source_index: sheet for sheet in selected_sheets}
|
|
result = self.create_documents(preview, sheets_by_index, selections)
|
|
self.delete_preview(preview)
|
|
messages.success(request, (
|
|
f"{result['documents']} Arbeitsblätter verarbeitet "
|
|
f"({result['created']} neu, {result['updated']} aktualisiert) in „{result['category']}“; "
|
|
f"{result['images']} Bilder übernommen."
|
|
))
|
|
return redirect("plugins:netbox_documentation:document_list")
|
|
|
|
@staticmethod
|
|
@transaction.atomic
|
|
def create_documents(preview, sheets, selections):
|
|
from django.core.files.base import ContentFile
|
|
created_documents, image_count, created_count, updated_count = [], 0, 0, 0
|
|
for index, title in selections.items():
|
|
sheet = sheets[index]
|
|
document = Document.objects.select_for_update().filter(
|
|
category=preview.category, title__iexact=title,
|
|
).order_by("pk").first()
|
|
if document:
|
|
document.title = title[:200]
|
|
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"))
|
|
updated_count += 1
|
|
else:
|
|
base_slug = slugify(title)[:180] or "arbeitsblatt"
|
|
document_slug, counter = base_slug, 2
|
|
while Document.objects.filter(slug=document_slug).exists():
|
|
document_slug = f"{base_slug[:190-len(str(counter))]}-{counter}"
|
|
counter += 1
|
|
document = Document.objects.create(
|
|
title=title[:200], slug=document_slug, body=sheet.markdown,
|
|
body_format=sheet.body_format, category=preview.category,
|
|
summary=f"Importiert aus {preview.original_name}",
|
|
)
|
|
created_count += 1
|
|
if sheet.images:
|
|
image_lines = []
|
|
for image in sheet.images:
|
|
attachment = DocumentAttachment(
|
|
document=document, original_name=image.name,
|
|
content_type=image.content_type, size=len(image.content),
|
|
)
|
|
attachment.file.save(image.name, ContentFile(image.content), save=False)
|
|
attachment.save()
|
|
location = f" ({image.cell})" if image.cell else ""
|
|
if document.body_format == "html":
|
|
image_lines.append(
|
|
f'<p><img src="{escape(attachment.file.url)}" alt="{escape(image.name + location)}"></p>'
|
|
)
|
|
else:
|
|
image_lines.append(f"")
|
|
if document.body_format == "html":
|
|
document.body += "\n<h2>Bilder</h2>\n" + "\n".join(image_lines)
|
|
else:
|
|
document.body += "\n\n## Bilder\n\n" + "\n\n".join(image_lines)
|
|
document.save(update_fields=("body", "last_updated"))
|
|
image_count += len(sheet.images)
|
|
created_documents.append(document)
|
|
if settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("keep_imported_file", True):
|
|
for document in created_documents:
|
|
preview.file.open("rb")
|
|
attachment = DocumentAttachment(
|
|
document=document, original_name=preview.original_name,
|
|
content_type=preview.content_type, size=preview.file.size,
|
|
)
|
|
try:
|
|
attachment.file.save(preview.original_name, preview.file, save=False)
|
|
attachment.save()
|
|
finally:
|
|
preview.file.close()
|
|
return {
|
|
"documents": len(created_documents), "created": created_count,
|
|
"updated": updated_count, "images": image_count, "category": preview.category,
|
|
}
|
|
|
|
@staticmethod
|
|
def delete_preview(preview):
|
|
preview.file.delete(save=False)
|
|
preview.delete()
|
|
|
|
|
|
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})
|
|
|
|
|
|
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)
|
|
documents = documents_for_categories(
|
|
form.cleaned_data["categories"], form.cleaned_data["include_subfolders"], request.user
|
|
)
|
|
if not documents.exists():
|
|
form.add_error("categories", "Die ausgewählten Ordner enthalten keine exportierbaren Dokumentationen.")
|
|
return self._render(request, export_form=form)
|
|
stream = export_documents(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)
|