fix: render all devices sharing rack units

This commit is contained in:
2026-08-13 09:09:55 +02:00
parent a0358136b2
commit bc2024ac13
8 changed files with 174 additions and 72 deletions
+5 -3
View File
@@ -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.
+1 -1
View File
@@ -1,6 +1,6 @@
from netbox.plugins import PluginConfig, get_plugin_config
__version__ = "0.9.1"
__version__ = "0.9.2"
class NetBoxUtilitiesConfig(PluginConfig):
+39 -35
View File
@@ -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")
)
+25 -9
View File
@@ -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),
@@ -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;
}
+44 -17
View File
@@ -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,
[
+54 -2
View File
@@ -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):
+1 -1
View File
@@ -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"