diff --git a/README.md b/README.md index 916acc3..b1f1611 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/netbox_utilities/__init__.py b/netbox_utilities/__init__.py index 2a6e716..5143e7b 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.0" +__version__ = "0.9.1" class NetBoxUtilitiesConfig(PluginConfig): diff --git a/netbox_utilities/rack_width.py b/netbox_utilities/rack_width.py index e0c2556..4b0b958 100644 --- a/netbox_utilities/rack_width.py +++ b/netbox_utilities/rack_width.py @@ -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) diff --git a/netbox_utilities/reorder_rack.py b/netbox_utilities/reorder_rack.py index 037f2a2..5e6c897 100644 --- a/netbox_utilities/reorder_rack.py +++ b/netbox_utilities/reorder_rack.py @@ -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): diff --git a/netbox_utilities/static/netbox_utilities/reorder-rack-width.js b/netbox_utilities/static/netbox_utilities/reorder-rack-width.js index 3d98b0c..0868240 100644 --- a/netbox_utilities/static/netbox_utilities/reorder-rack-width.js +++ b/netbox_utilities/static/netbox_utilities/reorder-rack-width.js @@ -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 = { diff --git a/netbox_utilities/static/netbox_utilities/topology-rack-width.js b/netbox_utilities/static/netbox_utilities/topology-rack-width.js index 52f0171..6ab3819 100644 --- a/netbox_utilities/static/netbox_utilities/topology-rack-width.js +++ b/netbox_utilities/static/netbox_utilities/topology-rack-width.js @@ -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; diff --git a/netbox_utilities/tests/test_optional_integration_pages.py b/netbox_utilities/tests/test_optional_integration_pages.py new file mode 100644 index 0000000..9b9d63a --- /dev/null +++ b/netbox_utilities/tests/test_optional_integration_pages.py @@ -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) diff --git a/netbox_utilities/tests/test_rack_width.py b/netbox_utilities/tests/test_rack_width.py index f1c1458..51e009c 100644 --- a/netbox_utilities/tests/test_rack_width.py +++ b/netbox_utilities/tests/test_rack_width.py @@ -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 diff --git a/netbox_utilities/tests/test_reorder_rack.py b/netbox_utilities/tests/test_reorder_rack.py index e30371b..f65981a 100644 --- a/netbox_utilities/tests/test_reorder_rack.py +++ b/netbox_utilities/tests/test_reorder_rack.py @@ -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( diff --git a/netbox_utilities/tests/test_topology_views.py b/netbox_utilities/tests/test_topology_views.py index 90c2442..e6b9032 100644 --- a/netbox_utilities/tests/test_topology_views.py +++ b/netbox_utilities/tests/test_topology_views.py @@ -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( diff --git a/netbox_utilities/topology_views.py b/netbox_utilities/topology_views.py index 85f7980..70554bd 100644 --- a/netbox_utilities/topology_views.py +++ b/netbox_utilities/topology_views.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 26b0338..90a610d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"