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.select_related("category", "tenant_group", "tenant").prefetch_related("assignments") table = DocumentTable filterset = DocumentFilterSet actions = (AddObject, BulkImport, BulkDelete) class DocumentView(generic.ObjectView): queryset = Document.objects.select_related("category", "tenant_group", "tenant").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", "tenant_group", "tenant").prefetch_related("documents") table = DocumentCategoryTable filterset = DocumentCategoryFilterSet actions = (AddObject, BulkImport, BulkDelete) class DocumentCategoryView(generic.ObjectView): queryset = DocumentCategory.objects.select_related("tenant_group", "tenant").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 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: 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
{image_notice}
" else: sample += f"\n\n## Bilder\n\n_{image_notice}_" truncated = len(sample) > 100000 if truncated: notice = "… Vorschau gekürzt …" sample = sample[:100000] + ( f"{notice}
" 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.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" 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, tenant_group=preview.category.tenant_group, tenant=preview.category.tenant, 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'