fix: restore rack and reorder width views

This commit is contained in:
2026-08-13 08:35:21 +02:00
parent 70825c4040
commit a0358136b2
12 changed files with 243 additions and 73 deletions
+24
View File
@@ -105,6 +105,30 @@ sudo systemctl restart netbox netbox-rq
Nach Änderungen an JavaScript oder CSS kann ein Hard-Reload des Browsers mit Nach Änderungen an JavaScript oder CSS kann ein Hard-Reload des Browsers mit
`Strg+F5` erforderlich sein. `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;
die Dateien `topology-rack-width.js` und `reorder-rack-width.js` müssen unter
`/opt/netbox/netbox/static/netbox_utilities/` vorhanden sein.
Die Installation lässt sich anschließend mit diesen Befehlen kontrollieren:
```bash
/opt/netbox/venv/bin/python -c \
"import netbox_utilities; print(netbox_utilities.__version__, netbox_utilities.__file__)"
test -f /opt/netbox/netbox/static/netbox_utilities/reorder-rack-width.js
test -f /opt/netbox/netbox/static/netbox_utilities/topology-rack-width.js
cd /opt/netbox/netbox
/opt/netbox/venv/bin/python manage.py shell -c \
"from django.urls import reverse; print(reverse('dcim:rack_reorder', kwargs={'pk': 1})); print(reverse('plugins:netbox_topology_views:rack_elevation'))"
```
Die letzten beiden Routen müssen auflösbar sein. Die Topology-Rack-Route ist
im getesteten MrBlake-Develop-Stand `86a8daf45ef380d57ac73b461dbc47a16bb831ba`
enthalten, aber nicht im gleich bezeichneten öffentlichen Tag `v4.5.1`.
## Aktualisierung ## Aktualisierung
Bei einer Installation aus dem `main`-Branch wird das Paket erneut aus Gitea Bei einer Installation aus dem `main`-Branch wird das Paket erneut aus Gitea
+1 -1
View File
@@ -1,6 +1,6 @@
from netbox.plugins import PluginConfig, get_plugin_config from netbox.plugins import PluginConfig, get_plugin_config
__version__ = "0.9.0" __version__ = "0.9.1"
class NetBoxUtilitiesConfig(PluginConfig): class NetBoxUtilitiesConfig(PluginConfig):
+25 -41
View File
@@ -248,25 +248,12 @@ def _decimal_range(start, stop):
current += decimal.Decimal("0.5") current += decimal.Decimal("0.5")
def _partial_intervals_for_unit(unit, face, placements):
intervals = []
for placement in placements:
device = placement.device
if not (device.face == face or device.device_type.is_full_depth):
continue
start, end = _vertical_interval(device)
if start <= decimal.Decimal(unit) < end:
intervals.append(horizontal_interval(placement.width, placement.horizontal_position))
return intervals
def _install_rack_methods(): def _install_rack_methods():
from dcim.models import Rack from dcim.models import Rack
if getattr(Rack, "_netbox_utilities_rack_width_installed", False): if getattr(Rack, "_netbox_utilities_rack_width_installed", False):
return return
original_available_units = Rack.get_available_units original_available_units = Rack.get_available_units
original_rack_units = Rack.get_rack_units
@wraps(original_available_units) @wraps(original_available_units)
def width_aware_available_units( def width_aware_available_units(
@@ -286,33 +273,7 @@ def _install_rack_methods():
) )
return original_available_units(rack, u_height, rack_face, exclude, ignore_excluded_devices) return original_available_units(rack, u_height, rack_face, exclude, ignore_excluded_devices)
@wraps(original_rack_units)
def width_aware_rack_units(rack, *args, **kwargs):
units = original_rack_units(rack, *args, **kwargs)
face = kwargs.get("face")
if face is None and len(args) > 1:
face = args[1]
if face is None:
from dcim.choices import DeviceFaceChoices
face = DeviceFaceChoices.FACE_FRONT
placements = list(
DeviceRackPlacement.objects.filter(
device__rack=rack,
device__position__isnull=False,
).select_related("device", "device__device_type")
)
for unit in units:
intervals = _partial_intervals_for_unit(unit["id"], face, placements)
if not intervals:
continue
unit["device"] = None
unit.pop("height", None)
unit["occupied"] = intervals_cover_full_width(intervals)
return units
Rack.get_available_units = width_aware_available_units Rack.get_available_units = width_aware_available_units
Rack.get_rack_units = width_aware_rack_units
Rack._netbox_utilities_rack_width_installed = True Rack._netbox_utilities_rack_width_installed = True
@@ -463,8 +424,31 @@ def _install_rack_svg():
@wraps(original_draw_face) @wraps(original_draw_face)
def width_aware_draw_face(elevation, face, opposite=False): def width_aware_draw_face(elevation, face, opposite=False):
original_draw_face(elevation, face, opposite) partial_devices = list(_partial_width_elevation_devices(elevation.rack, face))
for device in _partial_width_elevation_devices(elevation.rack, face): partial_device_ids = {device.pk for device in partial_devices}
# 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 placement = device.netbox_utilities_rack_placement
height = decimal.Decimal(str(device.device_type.u_height)) height = decimal.Decimal(str(device.device_type.u_height))
coords = elevation._get_device_coords(device.position, height) coords = elevation._get_device_coords(device.position, height)
+30 -18
View File
@@ -234,16 +234,33 @@ def _image_url(image):
return "" return ""
def _device_label(device):
return device.label or str(device.device_type)
def _empty_reorder_rack_width_data(request, *, status="ready"):
selected_view = request.GET.get("view", "images-and-labels")
return {
"columns": GRID_COLUMNS,
"unit_width": 220,
"images": selected_view != "labels-only",
"labels": selected_view != "images-only",
"devices": [],
"status": status,
}
def get_reorder_rack_width_data(request): def get_reorder_rack_width_data(request):
"""Describe partial-width widgets for netbox-reorder-rack's GridStack UI.""" """Describe partial-width widgets for netbox-reorder-rack's GridStack UI."""
if not reorder_rack_width_enabled(request): if not reorder_rack_width_enabled(request):
return None return None
data = _empty_reorder_rack_width_data(request)
rack_id = getattr(request.resolver_match, "kwargs", {}).get("pk") rack_id = getattr(request.resolver_match, "kwargs", {}).get("pk")
if not rack_id: if not rack_id:
return None data["status"] = "missing-rack"
return data
from dcim.svg.racks import get_device_name
from netbox.config import get_config from netbox.config import get_config
from utilities.html import foreground_color from utilities.html import foreground_color
@@ -262,16 +279,15 @@ def get_reorder_rack_width_data(request):
.order_by("pk") .order_by("pk")
) )
permission = get_permission_for_model(Device, "change") permission = get_permission_for_model(Device, "change")
descriptors = []
for device in devices: for device in devices:
placement = device.netbox_utilities_rack_placement placement = device.netbox_utilities_rack_placement
grid_width, grid_x = grid_dimensions(placement.width, placement.horizontal_position) grid_width, grid_x = grid_dimensions(placement.width, placement.horizontal_position)
role_color = device.role.color or "1685fc" role_color = device.role.color or "1685fc"
grid_height = int(Decimal(str(device.device_type.u_height)) * 2) grid_height = int(Decimal(str(device.device_type.u_height)) * 2)
descriptors.append( data["devices"].append(
{ {
"id": device.pk, "id": device.pk,
"label": get_device_name(device), "label": _device_label(device),
"face": device.face, "face": device.face,
"full_depth": device.device_type.is_full_depth, "full_depth": device.device_type.is_full_depth,
"grid_x": grid_x, "grid_x": grid_x,
@@ -287,17 +303,8 @@ def get_reorder_rack_width_data(request):
"locked": not request.user.has_perm(permission, obj=device), "locked": not request.user.has_perm(permission, obj=device),
} }
) )
if not descriptors: data["unit_width"] = get_config().RACK_ELEVATION_DEFAULT_UNIT_WIDTH
return None return data
selected_view = request.GET.get("view", "images-and-labels")
return {
"columns": GRID_COLUMNS,
"unit_width": get_config().RACK_ELEVATION_DEFAULT_UNIT_WIDTH,
"images": selected_view != "labels-only",
"labels": selected_view != "images-only",
"devices": descriptors,
}
except ( except (
AttributeError, AttributeError,
ObjectDoesNotExist, ObjectDoesNotExist,
@@ -307,8 +314,13 @@ def get_reorder_rack_width_data(request):
TypeError, TypeError,
ValueError, ValueError,
): ):
logger.warning("Could not load partial rack widths for netbox-reorder-rack", exc_info=True) logger.warning(
return None "Could not load partial rack widths for netbox-reorder-rack rack %s; using the native rack layout",
rack_id,
exc_info=True,
)
data["status"] = "native-fallback"
return data
def bulk_reorder_update(self, request, pk=None): def bulk_reorder_update(self, request, pk=None):
@@ -11,6 +11,10 @@
console.error('NetBox Utilities could not read the Reorder Rack width data.', error); console.error('NetBox Utilities could not read the Reorder Rack width data.', error);
return; return;
} }
if (data.status && data.status !== 'ready') {
console.warn('NetBox Utilities is leaving Reorder Rack in its native layout.', data.status);
return;
}
const columns = Number.parseInt(data.columns, 10) || 12; const columns = Number.parseInt(data.columns, 10) || 12;
const gridElements = { const gridElements = {
@@ -4,13 +4,22 @@
const placementSource = document.getElementById('netbox-utilities-topology-rack-width-data'); const placementSource = document.getElementById('netbox-utilities-topology-rack-width-data');
if (!placementSource) return; if (!placementSource) return;
let placementData; let payload;
try { try {
placementData = JSON.parse(placementSource.textContent); payload = JSON.parse(placementSource.textContent);
} catch (error) { } catch (error) {
console.error('NetBox Utilities could not read the Topology Views rack widths.', error); console.error('NetBox Utilities could not read the Topology Views rack widths.', error);
return; return;
} }
if (!Array.isArray(payload) && payload?.status && payload.status !== 'ready') {
console.warn('NetBox Utilities is leaving Topology Views in its native layout.', payload.status);
return;
}
const placementData = Array.isArray(payload) ? payload : payload?.devices;
if (!Array.isArray(placementData)) {
console.error('NetBox Utilities received invalid Topology Views rack width data.');
return;
}
const normalizePath = value => { const normalizePath = value => {
try { try {
@@ -59,6 +68,7 @@
&& typeof device.dataset.deviceColor === 'string' && typeof device.dataset.deviceColor === 'string'
)); ));
}); });
if (!rackCards.length && !placementData.length) return;
if (!liveViewCompatible) { if (!liveViewCompatible) {
console.warn('NetBox Utilities left Topology Views unchanged because its rack DOM is not compatible.'); console.warn('NetBox Utilities left Topology Views unchanged because its rack DOM is not compatible.');
return; return;
@@ -0,0 +1,54 @@
from unittest.mock import patch
from django.conf import settings
from django.contrib.auth.context_processors import PermWrapper
from django.contrib.auth.models import AnonymousUser
from django.test import RequestFactory, SimpleTestCase
from netbox_utilities.template_content import UtilitiesGlobalContent
class OptionalRackIntegrationHeadTest(SimpleTestCase):
@staticmethod
def _context(path):
request = RequestFactory().get(path)
request.user = AnonymousUser()
return {
"request": request,
"settings": settings,
"csrf_token": "",
"perms": PermWrapper(request.user),
}
def test_reorder_adapter_is_emitted_for_an_empty_enabled_payload(self):
data = {
"columns": 12,
"unit_width": 220,
"images": True,
"labels": True,
"devices": [],
"status": "ready",
}
with (
patch("netbox_utilities.template_content.get_reorder_rack_width_data", return_value=data),
patch("netbox_utilities.template_content.get_topology_rack_width_data", return_value=None),
):
html = UtilitiesGlobalContent(self._context("/dcim/racks/3/reorder/")).head()
self.assertIn('id="netbox-utilities-reorder-rack-width-data"', html)
self.assertIn("netbox_utilities/reorder-rack-width.js", html)
self.assertIn('"status": "ready"', html)
def test_topology_adapter_is_emitted_for_an_empty_enabled_payload(self):
data = {"devices": [], "status": "ready"}
with (
patch("netbox_utilities.template_content.get_reorder_rack_width_data", return_value=None),
patch("netbox_utilities.template_content.get_topology_rack_width_data", return_value=data),
):
html = UtilitiesGlobalContent(
self._context("/plugins/netbox_topology_views/rack-elevation/?rack_id=3")
).head()
self.assertIn('id="netbox-utilities-topology-rack-width-data"', html)
self.assertIn("netbox_utilities/topology-rack-width.js", html)
self.assertIn('"status": "ready"', html)
+62 -1
View File
@@ -1,7 +1,7 @@
from importlib import import_module from importlib import import_module
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, call, patch
from django.conf import settings from django.conf import settings
from django.contrib.auth.context_processors import PermWrapper from django.contrib.auth.context_processors import PermWrapper
@@ -43,6 +43,67 @@ class RackWidthTest(SimpleTestCase):
self.assertIn("device_type", devices.query.select_related) self.assertIn("device_type", devices.query.select_related)
self.assertIn("manufacturer", devices.query.select_related["device_type"]) self.assertIn("manufacturer", devices.query.select_related["device_type"])
def test_core_rack_units_remain_available_to_other_plugins(self):
from dcim.models import Rack
self.assertEqual(Rack.get_rack_units.__module__, "dcim.models.racks")
self.assertFalse(hasattr(Rack.get_rack_units, "__wrapped__"))
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")
partial_right = SimpleNamespace(
pk=2,
face="front",
position=11,
device_type=SimpleNamespace(u_height=1),
netbox_utilities_rack_placement=SimpleNamespace(width=2, horizontal_position=2),
)
partial_left = SimpleNamespace(
pk=3,
face="front",
position=11,
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},
]
)
)
elevation = SimpleNamespace(
rack=rack,
unit_width=200,
unit_height=20,
permitted_device_ids={1, 2, 3},
drawing=SimpleNamespace(add=MagicMock()),
_get_device_coords=MagicMock(return_value=(10, 20)),
draw_device_front=MagicMock(),
draw_device_rear=MagicMock(),
)
with patch(
"netbox_utilities.rack_width._partial_width_elevation_devices",
return_value=[partial_left, partial_right],
):
RackElevationSVG.draw_face(elevation, "front")
rack.get_rack_units.assert_called_once_with(face="front", expand_devices=False)
self.assertEqual(
elevation.draw_device_front.call_args_list,
[
call(regular, (10, 20), (200, 20)),
call(partial_left, (10.0, 20), (100.0, 20)),
call(partial_right, (110.0, 20), (100.0, 20)),
],
)
def test_adds_rack_width_fields_to_existing_location_fieldset(self): def test_adds_rack_width_fields_to_existing_location_fieldset(self):
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
+7 -2
View File
@@ -223,7 +223,10 @@ class ReorderRackFrontendTest(SimpleTestCase):
rack_model.objects.restrict.return_value.filter.return_value.first.return_value = SimpleNamespace(pk=5) rack_model.objects.restrict.return_value.filter.return_value.first.return_value = SimpleNamespace(pk=5)
with patch("netbox_utilities.reorder_rack.Device.objects.restrict", side_effect=AttributeError("stale")): with patch("netbox_utilities.reorder_rack.Device.objects.restrict", side_effect=AttributeError("stale")):
self.assertIsNone(get_reorder_rack_width_data(request)) result = get_reorder_rack_width_data(request)
self.assertEqual(result["status"], "native-fallback")
self.assertEqual(result["devices"], [])
@patch("netbox_utilities.reorder_rack.reorder_rack_width_enabled", return_value=True) @patch("netbox_utilities.reorder_rack.reorder_rack_width_enabled", return_value=True)
@patch("netbox_utilities.reorder_rack.get_permission_for_model", return_value="dcim.change_device") @patch("netbox_utilities.reorder_rack.get_permission_for_model", return_value="dcim.change_device")
@@ -246,6 +249,7 @@ class ReorderRackFrontendTest(SimpleTestCase):
device = SimpleNamespace( device = SimpleNamespace(
pk=10, pk=10,
name="Fritzbox", name="Fritzbox",
label="Fritzbox",
virtual_chassis=None, virtual_chassis=None,
device_type=SimpleNamespace( device_type=SimpleNamespace(
u_height=1, u_height=1,
@@ -263,7 +267,6 @@ class ReorderRackFrontendTest(SimpleTestCase):
device_model.objects.restrict.return_value = device_queryset device_model.objects.restrict.return_value = device_queryset
with ( with (
patch("dcim.svg.racks.get_device_name", return_value="Fritzbox"),
patch( patch(
"netbox.config.get_config", "netbox.config.get_config",
return_value=SimpleNamespace(RACK_ELEVATION_DEFAULT_UNIT_WIDTH=220), return_value=SimpleNamespace(RACK_ELEVATION_DEFAULT_UNIT_WIDTH=220),
@@ -273,6 +276,8 @@ class ReorderRackFrontendTest(SimpleTestCase):
result = get_reorder_rack_width_data(request) result = get_reorder_rack_width_data(request)
self.assertEqual(result["columns"], 12) self.assertEqual(result["columns"], 12)
self.assertEqual(result["status"], "ready")
self.assertEqual(result["devices"][0]["label"], "Fritzbox")
self.assertFalse(result["images"]) self.assertFalse(result["images"])
self.assertTrue(result["labels"]) self.assertTrue(result["labels"])
self.assertEqual( self.assertEqual(
@@ -94,8 +94,9 @@ class TopologyViewsRackWidthTest(SimpleTestCase):
position__isnull=False, position__isnull=False,
) )
placement_objects.filter.assert_called_once_with(device__in="permitted-device-query") placement_objects.filter.assert_called_once_with(device__in="permitted-device-query")
self.assertEqual(result[0]["width"], 2) self.assertEqual(result["status"], "ready")
self.assertEqual(result[0]["horizontal_position"], 2) self.assertEqual(result["devices"][0]["width"], 2)
self.assertEqual(result["devices"][0]["horizontal_position"], 2)
def test_frontend_supports_live_view_and_all_rack_export_formats(self): def test_frontend_supports_live_view_and_all_rack_export_formats(self):
script = (Path(__file__).parents[1] / "static" / "netbox_utilities" / "topology-rack-width.js").read_text( script = (Path(__file__).parents[1] / "static" / "netbox_utilities" / "topology-rack-width.js").read_text(
@@ -112,6 +113,8 @@ class TopologyViewsRackWidthTest(SimpleTestCase):
self.assertIn("event.stopImmediatePropagation()", script) self.assertIn("event.stopImmediatePropagation()", script)
self.assertIn("rackExportCompatible", script) self.assertIn("rackExportCompatible", script)
self.assertIn("liveViewCompatible", script) self.assertIn("liveViewCompatible", script)
self.assertIn("payload?.devices", script)
self.assertIn("payload.status !== 'ready'", script)
self.assertLess(script.index("liveViewCompatible"), script.index("netbox-utilities-partial-width")) self.assertLess(script.index("liveViewCompatible"), script.index("netbox-utilities-partial-width"))
self.assertLess(script.index("exportGraphic(graphic"), script.index("event.preventDefault()")) self.assertLess(script.index("exportGraphic(graphic"), script.index("event.preventDefault()"))
@@ -119,7 +122,10 @@ class TopologyViewsRackWidthTest(SimpleTestCase):
def test_malformed_partial_placement_fails_open(self, _enabled): def test_malformed_partial_placement_fails_open(self, _enabled):
self.request.resolver_match = SimpleNamespace(view_name="plugins:netbox_topology_views:rack_elevation") self.request.resolver_match = SimpleNamespace(view_name="plugins:netbox_topology_views:rack_elevation")
with patch("dcim.models.Rack.objects.restrict", side_effect=AttributeError("stale")): with patch("dcim.models.Rack.objects.restrict", side_effect=AttributeError("stale")):
self.assertIsNone(get_topology_rack_width_data(self.request)) result = get_topology_rack_width_data(self.request)
self.assertEqual(result["status"], "native-fallback")
self.assertEqual(result["devices"], [])
def test_partial_width_css_uses_topology_device_slot_geometry(self): def test_partial_width_css_uses_topology_device_slot_geometry(self):
stylesheet = (Path(__file__).parents[1] / "static" / "netbox_utilities" / "netbox_utilities.css").read_text( stylesheet = (Path(__file__).parents[1] / "static" / "netbox_utilities" / "netbox_utilities.css").read_text(
+14 -4
View File
@@ -41,9 +41,13 @@ def serialize_topology_placement(placement):
def get_topology_rack_width_data(request): def get_topology_rack_width_data(request):
"""Return permitted partial-width devices shown by Topology Views.""" """Return permitted partial-width devices shown by Topology Views."""
if not topology_rack_width_enabled(request) or not request.GET: if not topology_rack_width_enabled(request):
return None return None
data = {"devices": [], "status": "ready"}
if not request.GET:
return data
from dcim.models import Device, Rack from dcim.models import Device, Rack
try: try:
@@ -66,7 +70,8 @@ def get_topology_rack_width_data(request):
placements = ( placements = (
DeviceRackPlacement.objects.filter(device__in=devices).select_related("device").order_by("device_id") DeviceRackPlacement.objects.filter(device__in=devices).select_related("device").order_by("device_id")
) )
return [serialize_topology_placement(placement) for placement in placements] data["devices"] = [serialize_topology_placement(placement) for placement in placements]
return data
except ( except (
AttributeError, AttributeError,
ObjectDoesNotExist, ObjectDoesNotExist,
@@ -76,5 +81,10 @@ def get_topology_rack_width_data(request):
ValueError, ValueError,
): ):
# Keep NetBox usable while plugin migrations are being installed. # Keep NetBox usable while plugin migrations are being installed.
logger.warning("Could not load partial rack widths for NetBox Topology Views", exc_info=True) logger.warning(
return None "Could not load partial rack widths for NetBox Topology Views path %s; using its native rack layout",
request.get_full_path(),
exc_info=True,
)
data["status"] = "native-fallback"
return data
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "netbox-utilities" name = "netbox-utilities"
version = "0.9.0" version = "0.9.1"
description = "Navigation, tenant utilities, partial-width rack devices, bulk uploads, and rack reordering for NetBox 4.6" description = "Navigation, tenant utilities, partial-width rack devices, bulk uploads, and rack reordering for NetBox 4.6"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"