diff --git a/README.md b/README.md index 4206411..0c8692d 100644 --- a/README.md +++ b/README.md @@ -348,6 +348,12 @@ none are saved. An optional shared description can be applied; the original file name is kept as the display name. Requires permission to add image attachments and to view the target object. +NetBox caps Pillow at 25 megapixels, which breaks current 50 MP smartphone +photos (e.g. Google Pixel, 8160×6144) and their thumbnails. The bulk upload +therefore scales larger images down to NetBox' limit (EXIF orientation is +applied) before saving. If an image is rejected, the form also shows Pillow's +exact error message. + ### Installing multiple modules Under **Plugins > NetBox Utilities > Install multiple modules** (or via the @@ -359,17 +365,7 @@ The operation is atomic: if a bay is taken meanwhile or a naming conflict occurs, nothing is saved. Requires the permission to add modules; object permissions still apply. -<<<<<<< HEAD -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 -======= ### Personalised navigation ->>>>>>> a85e4d6375a673a333d601fa8867e00a96423fec Under **Plugins > NetBox Utilities > Customize navigation** users see every menu they have access to. Arrow buttons change the order; a toggle hides a menu. diff --git a/netbox_utilities/__init__.py b/netbox_utilities/__init__.py index d36b83a..2bc9573 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.1" +__version__ = "0.14.2" class NetBoxUtilitiesConfig(PluginConfig): diff --git a/netbox_utilities/views.py b/netbox_utilities/views.py index cdff733..734bd6e 100644 --- a/netbox_utilities/views.py +++ b/netbox_utilities/views.py @@ -6,6 +6,7 @@ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.contenttypes.models import ContentType from django.core.exceptions import SuspiciousFileOperation, ValidationError +from django.core.files.uploadedfile import SimpleUploadedFile from django.db import DatabaseError, transaction from django.http import Http404, HttpResponse, HttpResponseBadRequest, JsonResponse from django.shortcuts import get_object_or_404, redirect, render @@ -66,11 +67,45 @@ def raised_image_pixel_limit(): if original_limit is not None and original_limit < BULK_IMAGE_MAX_PIXELS: Image.MAX_IMAGE_PIXELS = BULK_IMAGE_MAX_PIXELS try: - yield + yield original_limit finally: Image.MAX_IMAGE_PIXELS = original_limit +def downscale_image(upload, max_pixels): + """Shrink an upload to NetBox' pixel limit so NetBox can render thumbnails later. + + Returns the original upload when it is small enough or cannot be processed; + NetBox' own validation reports broken files afterwards. + """ + from PIL import Image, ImageOps + + if not max_pixels: + return upload + try: + upload.seek(0) + with Image.open(upload) as image: + width, height = image.size + if width * height <= max_pixels: + return upload + image_format = image.format + image = ImageOps.exif_transpose(image) + scale = (max_pixels / (width * height)) ** 0.5 + size = (max(1, int(image.width * scale)), max(1, int(image.height * scale))) + image = image.resize(size, Image.Resampling.LANCZOS) + if image_format == "JPEG" and image.mode not in ("RGB", "L"): + image = image.convert("RGB") + content = BytesIO() + save_options = {"quality": 90} if image_format in ("JPEG", "WEBP") else {} + image.save(content, format=image_format, **save_options) + except Exception: # noqa: BLE001 - leave validation to NetBox + return upload + finally: + if hasattr(upload, "seek"): + upload.seek(0) + return SimpleUploadedFile(upload.name, content.getvalue(), content_type=getattr(upload, "content_type", None)) + + class BulkImageUploadView(ContentTypePermissionRequiredMixin, View): template_name = "netbox_utilities/bulk_image_upload.html" @@ -86,12 +121,12 @@ class BulkImageUploadView(ContentTypePermissionRequiredMixin, View): parent = self._get_parent(request, request.POST) form = BulkImageUploadForm(request.POST, request.FILES) if form.is_valid(): - with raised_image_pixel_limit(): - return self._process_upload(request, form, parent) + with raised_image_pixel_limit() as netbox_pixel_limit: + return self._process_upload(request, form, parent, netbox_pixel_limit) return self._render(request, form, parent) - def _process_upload(self, request, form, parent): - image_forms = self._build_image_forms(parent, form.cleaned_data) + def _process_upload(self, request, form, parent, max_pixels): + image_forms = self._build_image_forms(parent, form.cleaned_data, max_pixels) invalid = [image_form for image_form in image_forms if not image_form.is_valid()] if invalid: for image_form in invalid: @@ -161,9 +196,10 @@ class BulkImageUploadView(ContentTypePermissionRequiredMixin, View): return None @staticmethod - def _build_image_forms(parent, cleaned_data): + def _build_image_forms(parent, cleaned_data, max_pixels=None): image_forms = [] for image in cleaned_data["images"]: + image = downscale_image(image, max_pixels) if hasattr(image, "seek"): image.seek(0) image_forms.append( diff --git a/pyproject.toml b/pyproject.toml index bba0f81..054c35c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "netbox-utilities" -version = "0.14.1" +version = "0.14.2" 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"