diff --git a/netbox_documentation/__init__.py b/netbox_documentation/__init__.py index 599fbb5..2a9fb73 100644 --- a/netbox_documentation/__init__.py +++ b/netbox_documentation/__init__.py @@ -5,7 +5,7 @@ class DocumentationConfig(PluginConfig): name = "netbox_documentation" verbose_name = "NetBox Dokumentation" description = "Wiki und Office-Dokumentation direkt in NetBox" - version = "0.7.11" + version = "0.7.12" author = "LKE" base_url = "documentation" min_version = "4.0.0" diff --git a/netbox_documentation/forms.py b/netbox_documentation/forms.py index 8d8ea7e..9a90583 100644 --- a/netbox_documentation/forms.py +++ b/netbox_documentation/forms.py @@ -232,6 +232,9 @@ class BaseExcelSheetSelectionFormSet(forms.BaseFormSet): indices = [item["index"] for item in selected] if len(indices) != len(set(indices)): raise forms.ValidationError("Die Arbeitsblattauswahl ist ungültig.") + titles = [item["title"].strip().casefold() for item in selected] + if len(titles) != len(set(titles)): + raise forms.ValidationError("Ausgewählte Arbeitsblätter dürfen nicht denselben Dokumenttitel haben.") ExcelSheetSelectionFormSet = forms.formset_factory( diff --git a/netbox_documentation/templates/netbox_documentation/excel_import_preview.html b/netbox_documentation/templates/netbox_documentation/excel_import_preview.html index dd26dc6..6aafe45 100644 --- a/netbox_documentation/templates/netbox_documentation/excel_import_preview.html +++ b/netbox_documentation/templates/netbox_documentation/excel_import_preview.html @@ -26,6 +26,16 @@
{% csrf_token %} + {% if overwrite_conflicts %} + + + {% endif %} {{ formset.management_form }} {% if formset.non_form_errors %}
{{ formset.non_form_errors }}
{% endif %} @@ -62,7 +72,10 @@
diff --git a/netbox_documentation/views.py b/netbox_documentation/views.py index 121d5d2..fe91549 100644 --- a/netbox_documentation/views.py +++ b/netbox_documentation/views.py @@ -8,6 +8,7 @@ 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 @@ -228,7 +229,7 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View): finally: preview.file.close() - def render_preview(self, request, preview, sheets, formset): + def render_preview(self, request, preview, sheets, formset, overwrite_conflicts=None): items = [] for form, sheet in zip(formset.forms, sheets): sample = sheet.markdown @@ -251,6 +252,7 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View): }) return render(request, self.template_name, { "batch": preview, "formset": formset, "items": items, + "overwrite_conflicts": overwrite_conflicts or [], }) def get(self, request, token): @@ -296,6 +298,29 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View): 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: @@ -305,7 +330,8 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View): result = self.create_documents(preview, sheets_by_index, selections) self.delete_preview(preview) messages.success(request, ( - f"{result['documents']} Arbeitsblätter als Dokumentationen in „{result['category']}“ importiert; " + 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") @@ -314,19 +340,31 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View): @transaction.atomic def create_documents(preview, sheets, selections): from django.core.files.base import ContentFile - created_documents, image_count = [], 0 + created_documents, image_count, created_count, updated_count = [], 0, 0, 0 for index, title in selections.items(): sheet = sheets[index] - 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}", - ) + 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: @@ -362,7 +400,10 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View): attachment.save() finally: preview.file.close() - return {"documents": len(created_documents), "images": image_count, "category": preview.category} + return { + "documents": len(created_documents), "created": created_count, + "updated": updated_count, "images": image_count, "category": preview.category, + } @staticmethod def delete_preview(preview): diff --git a/pyproject.toml b/pyproject.toml index 486c7bf..de56ec9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netbox-documentation" -version = "0.7.11" +version = "0.7.12" description = "Integrated Markdown wiki and office document importer for NetBox" readme = "README.md" requires-python = ">=3.10"