diff --git a/README.md b/README.md index 5e640f8..3db806d 100644 --- a/README.md +++ b/README.md @@ -541,6 +541,12 @@ Anzeigename bleibt der jeweilige ursprüngliche Dateiname erhalten. Benötigt werden weiterhin die NetBox-Berechtigung zum Hinzufügen von Bildanhängen und eine Leseberechtigung für das Zielobjekt. +NetBox begrenzt Pillow global auf 25 Megapixel und lehnt Bilder ab 50 +Megapixeln ab. Damit aktuelle 50-MP-Smartphone-Fotos (z. B. Google Pixel, +8160×6144) hochgeladen werden können, hebt der Mehrfach-Upload dieses Limit +während der Verarbeitung auf 100 Megapixel an. Wird ein Bild abgelehnt, zeigt +das Formular zusätzlich die genaue Fehlermeldung von Pillow an. + ### Module mehrfach einbauen Unter **Plugins > NetBox Utilities > Module mehrfach einbauen** kann ein diff --git a/netbox_utilities/__init__.py b/netbox_utilities/__init__.py index 36c3314..d36b83a 100644 --- a/netbox_utilities/__init__.py +++ b/netbox_utilities/__init__.py @@ -1,6 +1,6 @@ from netbox.plugins import PluginConfig, get_plugin_config -__version__ = "0.14.0" +__version__ = "0.14.1" class NetBoxUtilitiesConfig(PluginConfig): diff --git a/netbox_utilities/views.py b/netbox_utilities/views.py index 84eb59e..cdff733 100644 --- a/netbox_utilities/views.py +++ b/netbox_utilities/views.py @@ -1,3 +1,6 @@ +from contextlib import contextmanager +from io import BytesIO + from dcim.models import Device, Module, Rack from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin @@ -50,6 +53,24 @@ def _safe_return_url(request, default_name="home"): return reverse(default_name) +# NetBox limits Pillow to 25 MP (bomb error above 50 MP), which rejects +# current 50 MP smartphone photos such as Google Pixel images (8160x6144). +BULK_IMAGE_MAX_PIXELS = 100_000_000 + + +@contextmanager +def raised_image_pixel_limit(): + from PIL import Image + + original_limit = Image.MAX_IMAGE_PIXELS + if original_limit is not None and original_limit < BULK_IMAGE_MAX_PIXELS: + Image.MAX_IMAGE_PIXELS = BULK_IMAGE_MAX_PIXELS + try: + yield + finally: + Image.MAX_IMAGE_PIXELS = original_limit + + class BulkImageUploadView(ContentTypePermissionRequiredMixin, View): template_name = "netbox_utilities/bulk_image_upload.html" @@ -65,31 +86,41 @@ class BulkImageUploadView(ContentTypePermissionRequiredMixin, View): parent = self._get_parent(request, request.POST) form = BulkImageUploadForm(request.POST, request.FILES) if form.is_valid(): - image_forms = self._build_image_forms(parent, form.cleaned_data) - invalid = [image_form for image_form in image_forms if not image_form.is_valid()] - if invalid: - for image_form in invalid: - filename = getattr(image_form.files.get("image"), "name", "Bild") - for errors in image_form.errors.values(): - for error in errors: - form.add_error("images", f"{filename}: {error}") - else: - created = [] - try: - with transaction.atomic(): - for image_form in image_forms: - created.append(image_form.save()) - except (DatabaseError, OSError, SuspiciousFileOperation, ValidationError, ValueError) as error: + with raised_image_pixel_limit(): + return self._process_upload(request, form, parent) + return self._render(request, form, parent) + + def _process_upload(self, request, form, parent): + image_forms = self._build_image_forms(parent, form.cleaned_data) + invalid = [image_form for image_form in image_forms if not image_form.is_valid()] + if invalid: + for image_form in invalid: + image = image_form.files.get("image") + reason = self._image_error_reason(image) + if reason: + form.add_error("images", f"{getattr(image, 'name', 'Bild')}: Pillow meldet: {reason}") + for image_form in invalid: + filename = getattr(image_form.files.get("image"), "name", "Bild") + for errors in image_form.errors.values(): + for error in errors: + form.add_error("images", f"{filename}: {error}") + else: + created = [] + try: + with transaction.atomic(): for image_form in image_forms: - image_file = image_form.instance.image - if image_file.name and image_file._committed: - image_file.delete(save=False) - form.add_error("images", f"Die Bilder konnten nicht gespeichert werden: {error}") - else: - count = len(created) - noun = "Bild wurde" if count == 1 else "Bilder wurden" - messages.success(request, f"{count} {noun} gleichzeitig hochgeladen.") - return redirect(self._return_url(request, parent)) + created.append(image_form.save()) + except (DatabaseError, OSError, SuspiciousFileOperation, ValidationError, ValueError) as error: + for image_form in image_forms: + image_file = image_form.instance.image + if image_file.name and image_file._committed: + image_file.delete(save=False) + form.add_error("images", f"Die Bilder konnten nicht gespeichert werden: {error}") + else: + count = len(created) + noun = "Bild wurde" if count == 1 else "Bilder wurden" + messages.success(request, f"{count} {noun} gleichzeitig hochgeladen.") + return redirect(self._return_url(request, parent)) return self._render(request, form, parent) @staticmethod @@ -109,6 +140,26 @@ class BulkImageUploadView(ContentTypePermissionRequiredMixin, View): queryset = queryset.restrict(request.user, "view") return get_object_or_404(queryset, pk=object_id) + @staticmethod + def _image_error_reason(image): + """Return the underlying Pillow error, which Django hides behind a generic message.""" + from PIL import Image + + try: + if hasattr(image, "temporary_file_path"): + source = image.temporary_file_path() + else: + image.seek(0) + source = BytesIO(image.read()) + with Image.open(source) as opened: + opened.verify() + except Exception as error: # noqa: BLE001 - surface any Pillow failure + return f"{type(error).__name__}: {error}" + finally: + if hasattr(image, "seek"): + image.seek(0) + return None + @staticmethod def _build_image_forms(parent, cleaned_data): image_forms = [] diff --git a/pyproject.toml b/pyproject.toml index 4100cf2..bba0f81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "netbox-utilities" -version = "0.14.0" +version = "0.14.1" description = "Navigation, tenant utilities, connection VLANs, partial-width racks, and bulk operations for NetBox 4.6/4.7" readme = "README.md" requires-python = ">=3.12"