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
`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
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
__version__ = "0.9.0"
__version__ = "0.9.1"
class NetBoxUtilitiesConfig(PluginConfig):
+25 -41
View File
@@ -248,25 +248,12 @@ def _decimal_range(start, stop):
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():
from dcim.models import Rack
if getattr(Rack, "_netbox_utilities_rack_width_installed", False):
return
original_available_units = Rack.get_available_units
original_rack_units = Rack.get_rack_units
@wraps(original_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)
@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_rack_units = width_aware_rack_units
Rack._netbox_utilities_rack_width_installed = True
@@ -463,8 +424,31 @@ def _install_rack_svg():
@wraps(original_draw_face)
def width_aware_draw_face(elevation, face, opposite=False):
original_draw_face(elevation, face, opposite)
for device in _partial_width_elevation_devices(elevation.rack, face):
partial_devices = list(_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
height = decimal.Decimal(str(device.device_type.u_height))
coords = elevation._get_device_coords(device.position, height)
+30 -18
View File
@@ -234,16 +234,33 @@ def _image_url(image):
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):
"""Describe partial-width widgets for netbox-reorder-rack's GridStack UI."""
if not reorder_rack_width_enabled(request):
return None
data = _empty_reorder_rack_width_data(request)
rack_id = getattr(request.resolver_match, "kwargs", {}).get("pk")
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 utilities.html import foreground_color
@@ -262,16 +279,15 @@ def get_reorder_rack_width_data(request):
.order_by("pk")
)
permission = get_permission_for_model(Device, "change")
descriptors = []
for device in devices:
placement = device.netbox_utilities_rack_placement
grid_width, grid_x = grid_dimensions(placement.width, placement.horizontal_position)
role_color = device.role.color or "1685fc"
grid_height = int(Decimal(str(device.device_type.u_height)) * 2)
descriptors.append(
data["devices"].append(
{
"id": device.pk,
"label": get_device_name(device),
"label": _device_label(device),
"face": device.face,
"full_depth": device.device_type.is_full_depth,
"grid_x": grid_x,
@@ -287,17 +303,8 @@ def get_reorder_rack_width_data(request):
"locked": not request.user.has_perm(permission, obj=device),
}
)
if not descriptors:
return None
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,
}
data["unit_width"] = get_config().RACK_ELEVATION_DEFAULT_UNIT_WIDTH
return data
except (
AttributeError,
ObjectDoesNotExist,
@@ -307,8 +314,13 @@ def get_reorder_rack_width_data(request):
TypeError,
ValueError,
):
logger.warning("Could not load partial rack widths for netbox-reorder-rack", exc_info=True)
return None
logger.warning(
"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):
@@ -11,6 +11,10 @@
console.error('NetBox Utilities could not read the Reorder Rack width data.', error);
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 gridElements = {
@@ -4,13 +4,22 @@
const placementSource = document.getElementById('netbox-utilities-topology-rack-width-data');
if (!placementSource) return;
let placementData;
let payload;
try {
placementData = JSON.parse(placementSource.textContent);
payload = JSON.parse(placementSource.textContent);
} catch (error) {
console.error('NetBox Utilities could not read the Topology Views rack widths.', error);
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 => {
try {
@@ -59,6 +68,7 @@
&& typeof device.dataset.deviceColor === 'string'
));
});
if (!rackCards.length && !placementData.length) return;
if (!liveViewCompatible) {
console.warn('NetBox Utilities left Topology Views unchanged because its rack DOM is not compatible.');
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 pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, call, patch
from django.conf import settings
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("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):
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)
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.get_permission_for_model", return_value="dcim.change_device")
@@ -246,6 +249,7 @@ class ReorderRackFrontendTest(SimpleTestCase):
device = SimpleNamespace(
pk=10,
name="Fritzbox",
label="Fritzbox",
virtual_chassis=None,
device_type=SimpleNamespace(
u_height=1,
@@ -263,7 +267,6 @@ class ReorderRackFrontendTest(SimpleTestCase):
device_model.objects.restrict.return_value = device_queryset
with (
patch("dcim.svg.racks.get_device_name", return_value="Fritzbox"),
patch(
"netbox.config.get_config",
return_value=SimpleNamespace(RACK_ELEVATION_DEFAULT_UNIT_WIDTH=220),
@@ -273,6 +276,8 @@ class ReorderRackFrontendTest(SimpleTestCase):
result = get_reorder_rack_width_data(request)
self.assertEqual(result["columns"], 12)
self.assertEqual(result["status"], "ready")
self.assertEqual(result["devices"][0]["label"], "Fritzbox")
self.assertFalse(result["images"])
self.assertTrue(result["labels"])
self.assertEqual(
@@ -94,8 +94,9 @@ class TopologyViewsRackWidthTest(SimpleTestCase):
position__isnull=False,
)
placement_objects.filter.assert_called_once_with(device__in="permitted-device-query")
self.assertEqual(result[0]["width"], 2)
self.assertEqual(result[0]["horizontal_position"], 2)
self.assertEqual(result["status"], "ready")
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):
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("rackExportCompatible", 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("exportGraphic(graphic"), script.index("event.preventDefault()"))
@@ -119,7 +122,10 @@ class TopologyViewsRackWidthTest(SimpleTestCase):
def test_malformed_partial_placement_fails_open(self, _enabled):
self.request.resolver_match = SimpleNamespace(view_name="plugins:netbox_topology_views:rack_elevation")
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):
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):
"""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
data = {"devices": [], "status": "ready"}
if not request.GET:
return data
from dcim.models import Device, Rack
try:
@@ -66,7 +70,8 @@ def get_topology_rack_width_data(request):
placements = (
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 (
AttributeError,
ObjectDoesNotExist,
@@ -76,5 +81,10 @@ def get_topology_rack_width_data(request):
ValueError,
):
# Keep NetBox usable while plugin migrations are being installed.
logger.warning("Could not load partial rack widths for NetBox Topology Views", exc_info=True)
return None
logger.warning(
"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]
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"
readme = "README.md"
requires-python = ">=3.12"