fix: render all devices sharing rack units
This commit is contained in:
@@ -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,6 +1,6 @@
|
||||
from netbox.plugins import PluginConfig, get_plugin_config
|
||||
|
||||
__version__ = "0.9.1"
|
||||
__version__ = "0.9.2"
|
||||
|
||||
|
||||
class NetBoxUtilitiesConfig(PluginConfig):
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
[
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user