Compare commits
3
Commits
a85e4d6375
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbb8128385 | ||
|
|
f7c9ce995f | ||
|
|
a9fd36f31c |
@@ -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,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):
|
||||||
|
|||||||
@@ -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")])
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +121,19 @@ 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:
|
||||||
|
return self._process_upload(request, form, parent, netbox_pixel_limit)
|
||||||
|
return self._render(request, form, parent)
|
||||||
|
|
||||||
|
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()]
|
invalid = [image_form for image_form in image_forms if not image_form.is_valid()]
|
||||||
if invalid:
|
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:
|
for image_form in invalid:
|
||||||
filename = getattr(image_form.files.get("image"), "name", "Bild")
|
filename = getattr(image_form.files.get("image"), "name", "Bild")
|
||||||
for errors in image_form.errors.values():
|
for errors in image_form.errors.values():
|
||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user