From bc2024ac134c185bf9d572fd152e15fa1d996388 Mon Sep 17 00:00:00 2001 From: Louis Date: Thu, 13 Aug 2026 09:09:55 +0200 Subject: [PATCH] fix: render all devices sharing rack units --- README.md | 8 +- netbox_utilities/__init__.py | 2 +- netbox_utilities/rack_width.py | 74 ++++++++++--------- netbox_utilities/reorder_rack.py | 34 ++++++--- .../netbox_utilities/reorder-rack-width.js | 9 ++- netbox_utilities/tests/test_rack_width.py | 61 ++++++++++----- netbox_utilities/tests/test_reorder_rack.py | 56 +++++++++++++- pyproject.toml | 2 +- 8 files changed, 174 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index b1f1611..b02731f 100644 --- a/README.md +++ b/README.md @@ -105,9 +105,11 @@ sudo systemctl restart netbox netbox-rq Nach Änderungen an JavaScript oder CSS kann ein Hard-Reload des Browsers mit `Strg+F5` erforderlich sein. -Ab Version `0.9.1` werden die Teilbreiten-Adapter auf der Rack- und -Reorder-Seite auch dann eingebunden, wenn keine Teilbreitengeräte gefunden -wurden. Nach diesem Update ist `collectstatic` deshalb zwingend erforderlich; +Ab Version `0.9.2` werden Rack-SVG und Reorder direkt aus allen Geräten des +Racks aufgebaut. Dadurch können mehrere Geräte derselben HE nicht mehr durch +NetBox' native Ein-Gerät-pro-HE-Darstellung verloren gehen. Die Adapter werden +auch dann eingebunden, wenn keine Teilbreitengeräte gefunden wurden. Nach +diesem Update ist `collectstatic` deshalb zwingend erforderlich; die Dateien `topology-rack-width.js` und `reorder-rack-width.js` müssen unter `/opt/netbox/netbox/static/netbox_utilities/` vorhanden sein. diff --git a/netbox_utilities/__init__.py b/netbox_utilities/__init__.py index 5143e7b..2472602 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.9.1" +__version__ = "0.9.2" class NetBoxUtilitiesConfig(PluginConfig): diff --git a/netbox_utilities/rack_width.py b/netbox_utilities/rack_width.py index 4b0b958..1c54e28 100644 --- a/netbox_utilities/rack_width.py +++ b/netbox_utilities/rack_width.py @@ -251,7 +251,20 @@ def _decimal_range(start, stop): def _install_rack_methods(): from dcim.models import Rack - if getattr(Rack, "_netbox_utilities_rack_width_installed", False): + # Version 0.9.0 replaced get_rack_units() globally. A long-running + # process which reloads the plugin must restore NetBox's native method + # before installing the current, non-destructive implementation. + already_installed = getattr(Rack, "_netbox_utilities_rack_width_installed", False) + installed_version = getattr(Rack, "_netbox_utilities_rack_width_version", None) + if already_installed and installed_version is None: + for method_name in ("get_rack_units", "get_available_units"): + method = getattr(Rack, method_name) + if wrapped := getattr(method, "__wrapped__", None): + setattr(Rack, method_name, wrapped) + already_installed = False + + if already_installed: + Rack._netbox_utilities_rack_width_version = 2 return original_available_units = Rack.get_available_units @@ -275,6 +288,7 @@ def _install_rack_methods(): Rack.get_available_units = width_aware_available_units Rack._netbox_utilities_rack_width_installed = True + Rack._netbox_utilities_rack_width_version = 2 def _install_device_validation(): @@ -418,42 +432,32 @@ def _install_device_form(): def _install_rack_svg(): from dcim.svg.racks import RackElevationSVG - if getattr(RackElevationSVG, "_netbox_utilities_rack_width_installed", False): + if getattr(RackElevationSVG, "_netbox_utilities_rack_width_version", None) == 3: return - original_draw_face = RackElevationSVG.draw_face + current_draw_face = RackElevationSVG.draw_face + current_globals = getattr(current_draw_face, "__globals__", {}) + if current_globals.get("__name__") == __name__ and hasattr(current_draw_face, "__wrapped__"): + original_draw_face = current_draw_face.__wrapped__ + else: + original_draw_face = current_draw_face @wraps(original_draw_face) def width_aware_draw_face(elevation, face, opposite=False): - partial_devices = list(_partial_width_elevation_devices(elevation.rack, face)) - partial_device_ids = {device.pk for device in partial_devices} + from svgwrite.shapes import Rect - # Draw regular devices with NetBox's native geometry. Partial devices - # are skipped here and rendered once with their stored horizontal slot - # below. Rack.get_rack_units() deliberately remains untouched so that - # other consumers such as netbox-reorder-rack keep receiving devices. - for unit in elevation.rack.get_rack_units(face=face, expand_devices=False): - device = unit["device"] - if device and device.pk in partial_device_ids: - continue - height = unit.get("height", decimal.Decimal("1.0")) - coords = elevation._get_device_coords(unit["id"], height) - size = (elevation.unit_width, int(elevation.unit_height * height)) - if device and device.pk in elevation.permitted_device_ids: - if device.face == face and not opposite: - elevation.draw_device_front(device, coords, size) - else: - elevation.draw_device_rear(device, coords, size) - elif device: - from svgwrite.shapes import Rect - - elevation.drawing.add(Rect(coords, size, class_="blocked")) - - for device in partial_devices: - placement = device.netbox_utilities_rack_placement + # NetBox's get_rack_units() stores only one device per rack unit and + # therefore cannot represent two devices mounted beside each other. + # Query every mounted device directly and render each exactly once. + for device in _rack_elevation_devices(elevation.rack, face): + try: + placement = device.netbox_utilities_rack_placement + except ObjectDoesNotExist: + placement = None height = decimal.Decimal(str(device.device_type.u_height)) coords = elevation._get_device_coords(device.position, height) - width = elevation.unit_width / placement.width - coords = (coords[0] + width * (placement.horizontal_position - 1), coords[1]) + width = elevation.unit_width if placement is None else elevation.unit_width / placement.width + if placement is not None: + coords = (coords[0] + width * (placement.horizontal_position - 1), coords[1]) size = (width, int(elevation.unit_height * height)) if device.pk in elevation.permitted_device_ids: if device.face == face and not opposite: @@ -461,16 +465,15 @@ def _install_rack_svg(): else: elevation.draw_device_rear(device, coords, size) else: - from svgwrite.shapes import Rect - elevation.drawing.add(Rect(coords, size, class_="blocked")) RackElevationSVG.draw_face = width_aware_draw_face RackElevationSVG._netbox_utilities_rack_width_installed = True + RackElevationSVG._netbox_utilities_rack_width_version = 3 -def _partial_width_elevation_devices(rack, face): - """Return the annotated Device instances expected by NetBox's SVG renderer.""" +def _rack_elevation_devices(rack, face): + """Return every annotated device which must be drawn on a rack face.""" from dcim.models import Device return ( @@ -478,16 +481,17 @@ def _partial_width_elevation_devices(rack, face): rack=rack, position__gt=0, device_type__u_height__gt=0, - netbox_utilities_rack_placement__isnull=False, ) .filter(Q(face=face) | Q(device_type__is_full_depth=True)) .select_related( "device_type", "device_type__manufacturer", "role", + "virtual_chassis", "netbox_utilities_rack_placement", ) .annotate(devicebay_count=Count("devicebays")) + .order_by("position", "pk") ) diff --git a/netbox_utilities/reorder_rack.py b/netbox_utilities/reorder_rack.py index 5e6c897..8e58d22 100644 --- a/netbox_utilities/reorder_rack.py +++ b/netbox_utilities/reorder_rack.py @@ -238,6 +238,16 @@ def _device_label(device): return device.label or str(device.device_type) +def _device_width_position(device): + try: + placement = device.netbox_utilities_rack_placement + except ObjectDoesNotExist: + placement = None + if placement is None: + return FULL_WIDTH, 1 + return normalize_width_position(placement.width, placement.horizontal_position) + + def _empty_reorder_rack_width_data(request, *, status="ready"): selected_view = request.GET.get("view", "images-and-labels") return { @@ -251,7 +261,7 @@ def _empty_reorder_rack_width_data(request, *, status="ready"): def get_reorder_rack_width_data(request): - """Describe partial-width widgets for netbox-reorder-rack's GridStack UI.""" + """Describe every mounted widget for netbox-reorder-rack's GridStack UI.""" if not reorder_rack_width_enabled(request): return None @@ -272,16 +282,22 @@ def get_reorder_rack_width_data(request): Device.objects.restrict(request.user, "view") .filter( rack=rack, - position__isnull=False, - netbox_utilities_rack_placement__isnull=False, + position__gt=0, + device_type__u_height__gt=0, ) - .select_related("device_type", "role", "netbox_utilities_rack_placement") - .order_by("pk") + .select_related( + "device_type", + "device_type__manufacturer", + "role", + "virtual_chassis", + "netbox_utilities_rack_placement", + ) + .order_by("position", "pk") ) permission = get_permission_for_model(Device, "change") for device in devices: - placement = device.netbox_utilities_rack_placement - grid_width, grid_x = grid_dimensions(placement.width, placement.horizontal_position) + width, horizontal_position = _device_width_position(device) + grid_width, grid_x = grid_dimensions(width, horizontal_position) role_color = device.role.color or "1685fc" grid_height = int(Decimal(str(device.device_type.u_height)) * 2) data["devices"].append( @@ -294,8 +310,8 @@ def get_reorder_rack_width_data(request): "grid_y": reorder_grid_y(rack, device.position, device.device_type.u_height), "grid_width": grid_width, "grid_height": grid_height, - "width": placement.width, - "horizontal_position": placement.horizontal_position, + "width": width, + "horizontal_position": horizontal_position, "color": role_color, "text_color": foreground_color(role_color), "front_image": _image_url(device.device_type.front_image), diff --git a/netbox_utilities/static/netbox_utilities/reorder-rack-width.js b/netbox_utilities/static/netbox_utilities/reorder-rack-width.js index 0868240..2b1ea9b 100644 --- a/netbox_utilities/static/netbox_utilities/reorder-rack-width.js +++ b/netbox_utilities/static/netbox_utilities/reorder-rack-width.js @@ -25,8 +25,6 @@ if (!gridElements.front || !gridElements.rear || !gridElements.other) return; const setAttribute = (element, name, value) => element.setAttribute(name, String(value)); - const partialDeviceIds = new Set(data.devices.map(device => String(device.id))); - const originalFetch = window.fetch.bind(window); window.fetch = (resource, options = {}) => { const resourceUrl = typeof resource === 'string' || resource instanceof URL @@ -48,10 +46,13 @@ return originalFetch(resource, {...options, headers}); }; - Object.values(gridElements).forEach(grid => { + Object.entries(gridElements).forEach(([face, grid]) => { setAttribute(grid, 'gs-column', columns); grid.querySelectorAll('.grid-stack-item').forEach(item => { - if (partialDeviceIds.has(item.getAttribute('gs-id'))) { + // The native Reorder view is based on Rack.get_rack_units(), which can + // expose only one device per rack unit. Rebuild the mounted grids from + // the complete server payload; keep native non-racked devices intact. + if (face !== 'other') { item.remove(); return; } diff --git a/netbox_utilities/tests/test_rack_width.py b/netbox_utilities/tests/test_rack_width.py index 51e009c..143f568 100644 --- a/netbox_utilities/tests/test_rack_width.py +++ b/netbox_utilities/tests/test_rack_width.py @@ -1,3 +1,4 @@ +from functools import wraps from importlib import import_module from pathlib import Path from types import SimpleNamespace @@ -13,7 +14,8 @@ from django.urls import resolve, reverse from netbox_utilities.rack_width import ( _cleanup_unracked_placement, - _partial_width_elevation_devices, + _install_rack_methods, + _rack_elevation_devices, available_units_for_device, horizontal_interval, include_rack_width_in_fieldsets, @@ -32,16 +34,17 @@ class RackWidthTest(SimpleTestCase): self.assertIn("positionField.disabled = width === 1", script) self.assertIn("positionField.tomselect.sync()", script) - def test_partial_width_svg_devices_include_core_device_bay_annotation(self): + def test_svg_devices_include_core_device_bay_annotation_and_optional_placement(self): from dcim.choices import DeviceFaceChoices from dcim.models import Rack - devices = _partial_width_elevation_devices(Rack(pk=3), DeviceFaceChoices.FACE_FRONT) + devices = _rack_elevation_devices(Rack(pk=3), DeviceFaceChoices.FACE_FRONT) self.assertIn("devicebay_count", devices.query.annotations) self.assertIn("netbox_utilities_rack_placement", devices.query.select_related) self.assertIn("device_type", devices.query.select_related) self.assertIn("manufacturer", devices.query.select_related["device_type"]) + self.assertNotIn("netbox_utilities_rack_placement__isnull", str(devices.query)) def test_core_rack_units_remain_available_to_other_plugins(self): from dcim.models import Rack @@ -49,10 +52,43 @@ class RackWidthTest(SimpleTestCase): self.assertEqual(Rack.get_rack_units.__module__, "dcim.models.racks") self.assertFalse(hasattr(Rack.get_rack_units, "__wrapped__")) + def test_replaces_legacy_rack_unit_wrapper_during_upgrade(self): + def native_rack_units(rack): + return rack + + def native_available_units(rack, *args, **kwargs): + return rack, args, kwargs + + @wraps(native_rack_units) + def legacy_rack_units(rack): + return native_rack_units(rack) + + @wraps(native_available_units) + def legacy_available_units(rack, *args, **kwargs): + return native_available_units(rack, *args, **kwargs) + + class LegacyRack: + get_rack_units = legacy_rack_units + get_available_units = legacy_available_units + _netbox_utilities_rack_width_installed = True + + with patch.dict("sys.modules", {"dcim.models": SimpleNamespace(Rack=LegacyRack)}): + _install_rack_methods() + + self.assertIs(LegacyRack.get_rack_units, native_rack_units) + self.assertIs(LegacyRack.get_available_units.__wrapped__, native_available_units) + self.assertEqual(LegacyRack._netbox_utilities_rack_width_version, 2) + def test_svg_draws_all_partial_devices_sharing_one_unit_exactly_once(self): from dcim.svg.racks import RackElevationSVG - regular = SimpleNamespace(pk=1, face="front") + regular = SimpleNamespace( + pk=1, + face="front", + position=12, + device_type=SimpleNamespace(u_height=1), + netbox_utilities_rack_placement=None, + ) partial_right = SimpleNamespace( pk=2, face="front", @@ -67,16 +103,7 @@ class RackWidthTest(SimpleTestCase): device_type=SimpleNamespace(u_height=1), netbox_utilities_rack_placement=SimpleNamespace(width=2, horizontal_position=1), ) - rack = SimpleNamespace( - get_rack_units=MagicMock( - return_value=[ - {"id": 12, "device": regular, "height": 1}, - # NetBox's native unit list can expose only one of multiple - # partial devices occupying the same rack unit. - {"id": 11, "device": partial_right, "height": 1}, - ] - ) - ) + rack = SimpleNamespace(get_rack_units=MagicMock()) elevation = SimpleNamespace( rack=rack, unit_width=200, @@ -89,12 +116,12 @@ class RackWidthTest(SimpleTestCase): ) with patch( - "netbox_utilities.rack_width._partial_width_elevation_devices", - return_value=[partial_left, partial_right], + "netbox_utilities.rack_width._rack_elevation_devices", + return_value=[regular, partial_left, partial_right], ): RackElevationSVG.draw_face(elevation, "front") - rack.get_rack_units.assert_called_once_with(face="front", expand_devices=False) + rack.get_rack_units.assert_not_called() self.assertEqual( elevation.draw_device_front.call_args_list, [ diff --git a/netbox_utilities/tests/test_reorder_rack.py b/netbox_utilities/tests/test_reorder_rack.py index f65981a..f8f5670 100644 --- a/netbox_utilities/tests/test_reorder_rack.py +++ b/netbox_utilities/tests/test_reorder_rack.py @@ -212,6 +212,8 @@ class ReorderRackFrontendTest(SimpleTestCase): self.assertIn("grid.on('dropped'", script) self.assertIn("closest('#saveButton')", script) self.assertIn("X-NetBox-Utilities-Rack-Grid-Columns", script) + self.assertIn("if (face !== 'other')", script) + self.assertNotIn("partialDeviceIds", script) self.assertNotIn("JSON.stringify", script) @patch("netbox_utilities.reorder_rack.reorder_rack_width_enabled", return_value=True) @@ -232,7 +234,7 @@ class ReorderRackFrontendTest(SimpleTestCase): @patch("netbox_utilities.reorder_rack.get_permission_for_model", return_value="dcim.change_device") @patch("netbox_utilities.reorder_rack.Device") @patch("netbox_utilities.reorder_rack.Rack") - def test_describes_partial_devices_for_reorder_grid( + def test_describes_every_mounted_device_for_reorder_grid( self, rack_model, device_model, @@ -262,8 +264,44 @@ class ReorderRackFrontendTest(SimpleTestCase): position=Decimal(4), netbox_utilities_rack_placement=placement, ) + adjacent_device = SimpleNamespace( + pk=12, + name="Router", + label="Router", + virtual_chassis=None, + device_type=SimpleNamespace( + u_height=1, + is_full_depth=False, + front_image=None, + rear_image=None, + ), + role=SimpleNamespace(color="fedcba"), + face="front", + position=Decimal(4), + netbox_utilities_rack_placement=SimpleNamespace(width=2, horizontal_position=1), + ) + full_width_device = SimpleNamespace( + pk=11, + name="Switch", + label="Switch", + virtual_chassis=None, + device_type=SimpleNamespace( + u_height=1, + is_full_depth=False, + front_image=None, + rear_image=None, + ), + role=SimpleNamespace(color="123456"), + face="front", + position=Decimal(5), + netbox_utilities_rack_placement=None, + ) device_queryset = MagicMock() - device_queryset.filter.return_value.select_related.return_value.order_by.return_value = [device] + device_queryset.filter.return_value.select_related.return_value.order_by.return_value = [ + device, + adjacent_device, + full_width_device, + ] device_model.objects.restrict.return_value = device_queryset with ( @@ -277,13 +315,27 @@ class ReorderRackFrontendTest(SimpleTestCase): self.assertEqual(result["columns"], 12) self.assertEqual(result["status"], "ready") + self.assertEqual(len(result["devices"]), 3) self.assertEqual(result["devices"][0]["label"], "Fritzbox") self.assertFalse(result["images"]) self.assertTrue(result["labels"]) + device_queryset.filter.assert_called_once_with( + rack=rack, + position__gt=0, + device_type__u_height__gt=0, + ) self.assertEqual( {key: result["devices"][0][key] for key in ("id", "grid_x", "grid_width", "width", "horizontal_position")}, {"id": 10, "grid_x": 6, "grid_width": 6, "width": 2, "horizontal_position": 2}, ) + self.assertEqual( + {key: result["devices"][1][key] for key in ("id", "grid_x", "grid_width", "width")}, + {"id": 12, "grid_x": 0, "grid_width": 6, "width": 2}, + ) + self.assertEqual( + {key: result["devices"][2][key] for key in ("id", "grid_x", "grid_width", "width")}, + {"id": 11, "grid_x": 0, "grid_width": 12, "width": 1}, + ) class ReorderRackPatchTest(SimpleTestCase): diff --git a/pyproject.toml b/pyproject.toml index 90a610d..72d6a21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "netbox-utilities" -version = "0.9.1" +version = "0.9.2" description = "Navigation, tenant utilities, partial-width rack devices, bulk uploads, and rack reordering for NetBox 4.6" readme = "README.md" requires-python = ">=3.12"