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>
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
occurs, nothing is saved. Requires the permission to add modules; object
|
||||||
permissions still apply.
|
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
|
### Personalised navigation
|
||||||
>>>>>>> a85e4d6375a673a333d601fa8867e00a96423fec
|
|
||||||
|
|
||||||
Under **Plugins > NetBox Utilities > Customize navigation** users see every menu
|
Under **Plugins > NetBox Utilities > Customize navigation** users see every menu
|
||||||
they have access to. Arrow buttons change the order; a toggle hides a menu.
|
they have access to. Arrow buttons change the order; a toggle hides a menu.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from netbox.plugins import PluginConfig, get_plugin_config
|
from netbox.plugins import PluginConfig, get_plugin_config
|
||||||
|
|
||||||
__version__ = "0.14.1"
|
__version__ = "0.14.2"
|
||||||
|
|
||||||
|
|
||||||
class NetBoxUtilitiesConfig(PluginConfig):
|
class NetBoxUtilitiesConfig(PluginConfig):
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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
|
||||||
@@ -66,11 +67,45 @@ def raised_image_pixel_limit():
|
|||||||
if original_limit is not None and original_limit < BULK_IMAGE_MAX_PIXELS:
|
if original_limit is not None and original_limit < BULK_IMAGE_MAX_PIXELS:
|
||||||
Image.MAX_IMAGE_PIXELS = BULK_IMAGE_MAX_PIXELS
|
Image.MAX_IMAGE_PIXELS = BULK_IMAGE_MAX_PIXELS
|
||||||
try:
|
try:
|
||||||
yield
|
yield original_limit
|
||||||
finally:
|
finally:
|
||||||
Image.MAX_IMAGE_PIXELS = original_limit
|
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"
|
||||||
|
|
||||||
@@ -86,12 +121,12 @@ 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():
|
||||||
with raised_image_pixel_limit():
|
with raised_image_pixel_limit() as netbox_pixel_limit:
|
||||||
return self._process_upload(request, form, parent)
|
return self._process_upload(request, form, parent, netbox_pixel_limit)
|
||||||
return self._render(request, form, parent)
|
return self._render(request, form, parent)
|
||||||
|
|
||||||
def _process_upload(self, request, form, parent):
|
def _process_upload(self, request, form, parent, max_pixels):
|
||||||
image_forms = self._build_image_forms(parent, form.cleaned_data)
|
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()]
|
invalid = [image_form for image_form in image_forms if not image_form.is_valid()]
|
||||||
if invalid:
|
if invalid:
|
||||||
for image_form in invalid:
|
for image_form in invalid:
|
||||||
@@ -161,9 +196,10 @@ class BulkImageUploadView(ContentTypePermissionRequiredMixin, View):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_image_forms(parent, cleaned_data):
|
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "netbox-utilities"
|
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"
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user