Compare commits

..
3 Commits
Author SHA1 Message Date
MrBlakeandClaude Opus 5.5 cbb8128385 fix: downscale oversized bulk uploads so NetBox can render thumbnails
NetBox renders image attachment thumbnails under its global 25 MP Pillow
limit, so 50 MP photos uploaded via the bulk form showed no preview.
Scale images above that limit down (applying EXIF orientation) before
saving. Also resolve leftover merge conflict markers in the README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:19:19 +02:00
MrBlake f7c9ce995f Merge branch 'main' of https://git.mrblake.cc/MrBlake/Netbox-Utilities 2026-10-02 10:14:54 +02:00
MrBlakeandClaude Opus 5.5 a9fd36f31c fix: allow 50 MP smartphone photos in bulk image upload
NetBox sets Pillow's MAX_IMAGE_PIXELS to 25 MP, so Pillow rejects images
above 50 MP as decompression bombs. Raise the limit to 100 MP while the
bulk upload is processed and surface the underlying Pillow error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:14:13 +02:00
5 changed files with 136 additions and 27 deletions
+6
View File
@@ -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 file name is kept as the display name. Requires permission to add image
attachments and to view the target object. 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 ### Installing multiple modules
Under **Plugins > NetBox Utilities > Install multiple modules** (or via the Under **Plugins > NetBox Utilities > Install multiple modules** (or via the
+1 -1
View File
@@ -1,6 +1,6 @@
from netbox.plugins import PluginConfig, get_plugin_config from netbox.plugins import PluginConfig, get_plugin_config
__version__ = "0.14.0" __version__ = "0.14.2"
class NetBoxUtilitiesConfig(PluginConfig): class NetBoxUtilitiesConfig(PluginConfig):
+16
View File
@@ -35,6 +35,14 @@ class PatchpanelPairingTest(SimpleTestCase):
synchronize.assert_called_once_with(42) synchronize.assert_called_once_with(42)
@patch("netbox_utilities.patchpanel.synchronize_patchpanel")
def test_raw_component_imports_do_not_trigger_automation(self, synchronize):
component = SimpleNamespace(device_id=42)
_component_saved(None, component, created=True, raw=True)
synchronize.assert_not_called()
@patch("netbox_utilities.patchpanel.synchronize_patchpanel") @patch("netbox_utilities.patchpanel.synchronize_patchpanel")
def test_existing_device_save_does_not_trigger_automation(self, synchronize): def test_existing_device_save_does_not_trigger_automation(self, synchronize):
class Container: class Container:
@@ -89,6 +97,14 @@ class PatchpanelPairingTest(SimpleTestCase):
self.assertEqual(port_identifier(port(2, "Rear Port 1")), (1,)) self.assertEqual(port_identifier(port(2, "Rear Port 1")), (1,))
self.assertEqual(port_identifier(port(3, "FrontPort01")), (1,)) self.assertEqual(port_identifier(port(3, "FrontPort01")), (1,))
def test_pairs_plain_numeric_port_names_one_to_one(self):
front_ports = [port(24, "24"), port(1, "1"), port(2, "2")]
rear_ports = [port(102, "2"), port(124, "24"), port(101, "1")]
pairs = pair_ports(front_ports, rear_ports)
self.assertEqual([(front.pk, rear.pk) for front, rear in pairs], [(1, 101), (2, 102), (24, 124)])
def test_does_not_pair_different_identifiers(self): def test_does_not_pair_different_identifiers(self):
pairs = pair_ports([port(1, "Front 1")], [port(102, "Rear 2")]) pairs = pair_ports([port(1, "Front 1")], [port(102, "Rear 2")])
+112 -25
View File
@@ -1,8 +1,12 @@
from contextlib import contextmanager
from io import BytesIO
from dcim.models import Device, Module, Rack from dcim.models import Device, Module, Rack
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import SuspiciousFileOperation, ValidationError from django.core.exceptions import SuspiciousFileOperation, ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.db import DatabaseError, transaction from django.db import DatabaseError, transaction
from django.http import Http404, HttpResponse, HttpResponseBadRequest, JsonResponse from django.http import Http404, HttpResponse, HttpResponseBadRequest, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
@@ -50,6 +54,58 @@ def _safe_return_url(request, default_name="home"):
return reverse(default_name) 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 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): class BulkImageUploadView(ContentTypePermissionRequiredMixin, View):
template_name = "netbox_utilities/bulk_image_upload.html" template_name = "netbox_utilities/bulk_image_upload.html"
@@ -65,31 +121,41 @@ class BulkImageUploadView(ContentTypePermissionRequiredMixin, View):
parent = self._get_parent(request, request.POST) parent = self._get_parent(request, request.POST)
form = BulkImageUploadForm(request.POST, request.FILES) form = BulkImageUploadForm(request.POST, request.FILES)
if form.is_valid(): if form.is_valid():
image_forms = self._build_image_forms(parent, form.cleaned_data) with raised_image_pixel_limit() as netbox_pixel_limit:
invalid = [image_form for image_form in image_forms if not image_form.is_valid()] return self._process_upload(request, form, parent, netbox_pixel_limit)
if invalid: return self._render(request, form, parent)
for image_form in invalid:
filename = getattr(image_form.files.get("image"), "name", "Bild") def _process_upload(self, request, form, parent, max_pixels):
for errors in image_form.errors.values(): image_forms = self._build_image_forms(parent, form.cleaned_data, max_pixels)
for error in errors: invalid = [image_form for image_form in image_forms if not image_form.is_valid()]
form.add_error("images", f"{filename}: {error}") if invalid:
else: for image_form in invalid:
created = [] image = image_form.files.get("image")
try: reason = self._image_error_reason(image)
with transaction.atomic(): if reason:
for image_form in image_forms: form.add_error("images", f"{getattr(image, 'name', 'Bild')}: Pillow meldet: {reason}")
created.append(image_form.save()) for image_form in invalid:
except (DatabaseError, OSError, SuspiciousFileOperation, ValidationError, ValueError) as error: 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: for image_form in image_forms:
image_file = image_form.instance.image created.append(image_form.save())
if image_file.name and image_file._committed: except (DatabaseError, OSError, SuspiciousFileOperation, ValidationError, ValueError) as error:
image_file.delete(save=False) for image_form in image_forms:
form.add_error("images", f"Die Bilder konnten nicht gespeichert werden: {error}") image_file = image_form.instance.image
else: if image_file.name and image_file._committed:
count = len(created) image_file.delete(save=False)
noun = "Bild wurde" if count == 1 else "Bilder wurden" form.add_error("images", f"Die Bilder konnten nicht gespeichert werden: {error}")
messages.success(request, f"{count} {noun} gleichzeitig hochgeladen.") else:
return redirect(self._return_url(request, parent)) 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) return self._render(request, form, parent)
@staticmethod @staticmethod
@@ -110,9 +176,30 @@ class BulkImageUploadView(ContentTypePermissionRequiredMixin, View):
return get_object_or_404(queryset, pk=object_id) return get_object_or_404(queryset, pk=object_id)
@staticmethod @staticmethod
def _build_image_forms(parent, cleaned_data): 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, max_pixels=None):
image_forms = [] image_forms = []
for image in cleaned_data["images"]: for image in cleaned_data["images"]:
image = downscale_image(image, max_pixels)
if hasattr(image, "seek"): if hasattr(image, "seek"):
image.seek(0) image.seek(0)
image_forms.append( image_forms.append(
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "netbox-utilities" name = "netbox-utilities"
version = "0.14.0" version = "0.14.2"
description = "Navigation, tenant utilities, connection VLANs, partial-width racks, and bulk operations for NetBox 4.6/4.7" description = "Navigation, tenant utilities, connection VLANs, partial-width racks, and bulk operations for NetBox 4.6/4.7"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"