From a5f8cb62c70698e4442479c4b6077335e190d4ec Mon Sep 17 00:00:00 2001 From: Louis Date: Thu, 13 Aug 2026 09:37:59 +0200 Subject: [PATCH] fix: restore populated rack and reorder views --- README.md | 43 +++++++- netbox_utilities/__init__.py | 2 +- netbox_utilities/rack_width.py | 78 +++++++++++++-- netbox_utilities/reorder_rack.py | 29 +++--- .../netbox_utilities/reorder-rack-width.js | 25 +++++ .../netbox_utilities/topology-rack-width.js | 6 ++ .../tests/test_optional_integration_pages.py | 4 +- netbox_utilities/tests/test_rack_width.py | 60 ++++++++++++ netbox_utilities/tests/test_reorder_rack.py | 98 ++++++++++++++++++- netbox_utilities/tests/test_topology_views.py | 53 +++++++--- netbox_utilities/topology_views.py | 46 ++++++--- pyproject.toml | 2 +- 12 files changed, 379 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index b02731f..a0be430 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ 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.2` werden Rack-SVG und Reorder direkt aus allen Geräten des +Ab Version `0.9.3` 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 @@ -113,6 +113,16 @@ 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. +Reorder ersetzt seine nativen Gerätekacheln erst, nachdem ein vollständiger +und zur JavaScript-Version passender Datensatz geprüft wurde. Bei einer +gemischten Installation aus altem Python-Code und neuen statischen Dateien +bleibt deshalb die native Ansicht erhalten, statt ein leeres Rack zu zeigen. +Bereits vorhandene Geräte, die dieselbe HE und Rackseite belegen, aber noch +keine Plugin-Platzierungszeile besitzen, werden in Rack-SVG, Reorder und der +Topology-Rack-Ansicht ohne Datenbankänderung gleichmäßig nebeneinander +dargestellt. Explizit gespeicherte Rackbreiten und Breitenpositionen haben +stets Vorrang vor dieser Anzeige-Ableitung. + Die Installation lässt sich anschließend mit diesen Befehlen kontrollieren: ```bash @@ -121,6 +131,8 @@ Die Installation lässt sich anschließend mit diesen Befehlen kontrollieren: test -f /opt/netbox/netbox/static/netbox_utilities/reorder-rack-width.js test -f /opt/netbox/netbox/static/netbox_utilities/topology-rack-width.js +grep -q "schema_version !== 3" \ + /opt/netbox/netbox/static/netbox_utilities/reorder-rack-width.js cd /opt/netbox/netbox /opt/netbox/venv/bin/python manage.py shell -c \ @@ -131,6 +143,23 @@ 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`. +Für Rack `2` kann zusätzlich geprüft werden, welche gespeicherten Breiten das +Plugin findet. Zwei Geräte derselben HE dürfen dabei unterschiedliche oder +noch leere Plugin-Werte besitzen; leere Werte werden ab `0.9.3` für die +Darstellung abgeleitet: + +```bash +cd /opt/netbox/netbox +/opt/netbox/venv/bin/python manage.py shell -c " +from dcim.models import Device +print(list(Device.objects.filter(rack_id=2).order_by('position', 'id').values( + 'id', 'name', 'position', 'face', + 'netbox_utilities_rack_placement__width', + 'netbox_utilities_rack_placement__horizontal_position', +))) +" +``` + ## Aktualisierung Bei einer Installation aus dem `main`-Branch wird das Paket erneut aus Gitea @@ -183,10 +212,14 @@ unterstützt. Nach einer Änderung der Rackbreite wird die **Breitenposition** unmittelbar aktualisiert; ein Zwischenspeichern oder Neuladen ist nicht erforderlich. -Ohne Breitenangabe belegt ein Gerät wie bisher die volle Rackbreite. Das gilt -automatisch für sämtliche vorhandenen Geräte; es findet keine Änderung oder -Migration bestehender Platzierungen statt. Teilbreite und Position werden auf -der Geräteseite angezeigt und in der Rackgrafik nebeneinander dargestellt. +Ohne Breitenangabe belegt ein Gerät wie bisher die volle Rackbreite. Ausnahme +ist eine bereits vorhandene gemeinsame Belegung derselben HE und Rackseite: +Fehlen dort Plugin-Platzierungen, teilt die Darstellung den verfügbaren Platz +gleichmäßig auf die zwei bis vier vorhandenen Geräte auf. Diese Ableitung +ändert keine Datenbankwerte und dient nur dazu, bereits gemeinsam eingepflegte +Geräte wieder sichtbar zu machen. Es findet keine Migration bestehender +Platzierungen statt. Explizit gewählte Teilbreite und Position werden auf der +Geräteseite angezeigt und in der Rackgrafik nebeneinander dargestellt. NetBox besitzt standardmäßig eine Datenbank-Eindeutigkeit für Rack, HE und Rackseite. Die Plugin-Migration `0007` entfernt ausschließlich diese diff --git a/netbox_utilities/__init__.py b/netbox_utilities/__init__.py index 2472602..7dbe5de 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.2" +__version__ = "0.9.3" class NetBoxUtilitiesConfig(PluginConfig): diff --git a/netbox_utilities/rack_width.py b/netbox_utilities/rack_width.py index 1c54e28..f156b03 100644 --- a/netbox_utilities/rack_width.py +++ b/netbox_utilities/rack_width.py @@ -1,5 +1,6 @@ import decimal import logging +from collections import defaultdict from contextvars import ContextVar from copy import deepcopy from fractions import Fraction @@ -114,6 +115,65 @@ def get_width_position(device, placements=None): return _stored_width_position(device) +def effective_width_positions(devices, placements=None): + """Return stored rack widths and infer missing rows for devices sharing one slot.""" + devices = list(devices) + groups = defaultdict(list) + result = {} + + for device in devices: + key = ( + device.rack_id, + device.face, + decimal.Decimal(str(device.position)), + decimal.Decimal(str(device.device_type.u_height)), + ) + groups[key].append(device) + + for group in groups.values(): + stored = {} + missing = [] + for device in group: + if placements is not None: + placement = placements.get(device.pk) + else: + try: + placement = device.netbox_utilities_rack_placement + except ObjectDoesNotExist: + placement = None + if placement is None: + missing.append(device) + else: + stored[device.pk] = normalize_width_position(placement.width, placement.horizontal_position) + + candidate_width = None + stored_widths = {width for width, _position in stored.values()} + if len(stored_widths) == 1: + stored_width = next(iter(stored_widths)) + if len(group) <= stored_width: + candidate_width = stored_width + elif not stored and 2 <= len(group) <= 4: + candidate_width = len(group) + + if candidate_width is not None: + used_positions = {position for width, position in stored.values() if width == candidate_width} + free_positions = iter( + position for position in range(1, candidate_width + 1) if position not in used_positions + ) + for device in sorted(missing, key=lambda item: item.pk): + position = next(free_positions, None) + if position is None: + break + result[device.pk] = (candidate_width, position, "inferred") + + for device_id, (width, position) in stored.items(): + result[device_id] = (width, position, "stored") + for device in missing: + result.setdefault(device.pk, (FULL_WIDTH, 1, "default")) + + return result + + def stage_width_position(device, width, horizontal_position): width, horizontal_position = normalize_width_position(width, horizontal_position) device._netbox_utilities_rack_width = width @@ -432,7 +492,7 @@ def _install_device_form(): def _install_rack_svg(): from dcim.svg.racks import RackElevationSVG - if getattr(RackElevationSVG, "_netbox_utilities_rack_width_version", None) == 3: + if getattr(RackElevationSVG, "_netbox_utilities_rack_width_version", None) == 4: return current_draw_face = RackElevationSVG.draw_face current_globals = getattr(current_draw_face, "__globals__", {}) @@ -448,16 +508,14 @@ def _install_rack_svg(): # 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 + devices = list(_rack_elevation_devices(elevation.rack, face)) + widths = effective_width_positions(devices) + for device in devices: + width_divisor, horizontal_position, _source = widths[device.pk] height = decimal.Decimal(str(device.device_type.u_height)) coords = elevation._get_device_coords(device.position, height) - 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]) + width = elevation.unit_width / width_divisor + coords = (coords[0] + width * (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: @@ -469,7 +527,7 @@ def _install_rack_svg(): RackElevationSVG.draw_face = width_aware_draw_face RackElevationSVG._netbox_utilities_rack_width_installed = True - RackElevationSVG._netbox_utilities_rack_width_version = 3 + RackElevationSVG._netbox_utilities_rack_width_version = 4 def _rack_elevation_devices(rack, face): diff --git a/netbox_utilities/reorder_rack.py b/netbox_utilities/reorder_rack.py index 8e58d22..a12fc32 100644 --- a/netbox_utilities/reorder_rack.py +++ b/netbox_utilities/reorder_rack.py @@ -16,7 +16,7 @@ from rest_framework.response import Response from utilities.permissions import get_permission_for_model from .models import DeviceRackPlacement -from .rack_width import FULL_WIDTH, normalize_width_position, stage_width_position +from .rack_width import FULL_WIDTH, effective_width_positions, normalize_width_position, stage_width_position logger = logging.getLogger(__name__) @@ -116,11 +116,14 @@ def apply_rack_layout(*, rack, placements, user): placement.device_id: placement for placement in DeviceRackPlacement.objects.select_for_update().filter(device_id__in=placement_by_id) } + mounted_devices = [device for device in devices.values() if device.position is not None and device.position > 0] + effective_placements = effective_width_positions(mounted_devices, stored_placements) target_placements = [] for placement in placements: - stored = stored_placements.get(placement.device_id) - current_width = stored.width if stored else FULL_WIDTH - current_horizontal_position = stored.horizontal_position if stored else 1 + current_width, current_horizontal_position, _source = effective_placements.get( + placement.device_id, + (FULL_WIDTH, 1, "default"), + ) if placement.position is None: target_width, target_horizontal_position = FULL_WIDTH, 1 elif placement.grid_x is None: @@ -238,16 +241,6 @@ 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 { @@ -257,6 +250,8 @@ def _empty_reorder_rack_width_data(request, *, status="ready"): "labels": selected_view != "images-only", "devices": [], "status": status, + "schema_version": 3, + "complete": False, } @@ -294,9 +289,11 @@ def get_reorder_rack_width_data(request): ) .order_by("position", "pk") ) + devices = list(devices) + widths = effective_width_positions(devices) permission = get_permission_for_model(Device, "change") for device in devices: - width, horizontal_position = _device_width_position(device) + width, horizontal_position, width_source = widths[device.pk] 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) @@ -312,6 +309,7 @@ def get_reorder_rack_width_data(request): "grid_height": grid_height, "width": width, "horizontal_position": horizontal_position, + "width_source": width_source, "color": role_color, "text_color": foreground_color(role_color), "front_image": _image_url(device.device_type.front_image), @@ -320,6 +318,7 @@ def get_reorder_rack_width_data(request): } ) data["unit_width"] = get_config().RACK_ELEVATION_DEFAULT_UNIT_WIDTH + data["complete"] = True return data except ( AttributeError, diff --git a/netbox_utilities/static/netbox_utilities/reorder-rack-width.js b/netbox_utilities/static/netbox_utilities/reorder-rack-width.js index 2b1ea9b..1f5f33b 100644 --- a/netbox_utilities/static/netbox_utilities/reorder-rack-width.js +++ b/netbox_utilities/static/netbox_utilities/reorder-rack-width.js @@ -15,6 +15,22 @@ console.warn('NetBox Utilities is leaving Reorder Rack in its native layout.', data.status); return; } + if (data.schema_version !== 3 || data.complete !== true || !Array.isArray(data.devices)) { + console.warn('NetBox Utilities received an incomplete Reorder Rack payload and kept the native layout.'); + return; + } + const validDeviceData = data.devices.every(device => ( + Number.isInteger(Number(device.id)) + && ['front', 'rear'].includes(device.face) + && Number.isInteger(Number(device.grid_x)) + && Number.isInteger(Number(device.grid_y)) + && Number.isInteger(Number(device.grid_width)) + && Number.isInteger(Number(device.grid_height)) + )); + if (!validDeviceData) { + console.warn('NetBox Utilities received invalid Reorder Rack device data and kept the native layout.'); + return; + } const columns = Number.parseInt(data.columns, 10) || 12; const gridElements = { @@ -24,6 +40,15 @@ }; if (!gridElements.front || !gridElements.rear || !gridElements.other) return; + const nativeMountedItems = [ + ...gridElements.front.querySelectorAll('.grid-stack-item'), + ...gridElements.rear.querySelectorAll('.grid-stack-item'), + ]; + if (!data.devices.length && nativeMountedItems.length) { + console.warn('NetBox Utilities received no replacement devices and kept the populated native layout.'); + return; + } + const setAttribute = (element, name, value) => element.setAttribute(name, String(value)); const originalFetch = window.fetch.bind(window); window.fetch = (resource, options = {}) => { diff --git a/netbox_utilities/static/netbox_utilities/topology-rack-width.js b/netbox_utilities/static/netbox_utilities/topology-rack-width.js index 6ab3819..feabea4 100644 --- a/netbox_utilities/static/netbox_utilities/topology-rack-width.js +++ b/netbox_utilities/static/netbox_utilities/topology-rack-width.js @@ -11,6 +11,10 @@ console.error('NetBox Utilities could not read the Topology Views rack widths.', error); return; } + if (!Array.isArray(payload) && (payload?.schema_version !== 3 || payload?.complete !== true)) { + console.warn('NetBox Utilities received an incomplete Topology Views payload and kept the native layout.'); + 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; @@ -36,6 +40,7 @@ return { width, horizontalPosition, + source: String(value.width_source || 'stored'), leftPercent: Number.isFinite(Number(value.left_percent)) ? Number(value.left_percent) : (horizontalPosition - 1) / width * 100, @@ -87,6 +92,7 @@ device.classList.add('netbox-utilities-partial-width'); device.dataset.rackWidth = String(placement.width); device.dataset.horizontalPosition = String(placement.horizontalPosition); + device.dataset.rackWidthSource = placement.source; device.style.setProperty('--netbox-utilities-rack-device-left', `${placement.leftPercent}%`); device.style.setProperty('--netbox-utilities-rack-device-width', `${placement.widthPercent}%`); }); diff --git a/netbox_utilities/tests/test_optional_integration_pages.py b/netbox_utilities/tests/test_optional_integration_pages.py index 9b9d63a..49bb54b 100644 --- a/netbox_utilities/tests/test_optional_integration_pages.py +++ b/netbox_utilities/tests/test_optional_integration_pages.py @@ -28,6 +28,8 @@ class OptionalRackIntegrationHeadTest(SimpleTestCase): "labels": True, "devices": [], "status": "ready", + "schema_version": 3, + "complete": True, } with ( patch("netbox_utilities.template_content.get_reorder_rack_width_data", return_value=data), @@ -40,7 +42,7 @@ class OptionalRackIntegrationHeadTest(SimpleTestCase): self.assertIn('"status": "ready"', html) def test_topology_adapter_is_emitted_for_an_empty_enabled_payload(self): - data = {"devices": [], "status": "ready"} + data = {"devices": [], "status": "ready", "schema_version": 3, "complete": True} 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), diff --git a/netbox_utilities/tests/test_rack_width.py b/netbox_utilities/tests/test_rack_width.py index 143f568..0dcfd6d 100644 --- a/netbox_utilities/tests/test_rack_width.py +++ b/netbox_utilities/tests/test_rack_width.py @@ -17,6 +17,7 @@ from netbox_utilities.rack_width import ( _install_rack_methods, _rack_elevation_devices, available_units_for_device, + effective_width_positions, horizontal_interval, include_rack_width_in_fieldsets, intervals_cover_full_width, @@ -84,6 +85,7 @@ class RackWidthTest(SimpleTestCase): regular = SimpleNamespace( pk=1, + rack_id=3, face="front", position=12, device_type=SimpleNamespace(u_height=1), @@ -91,6 +93,7 @@ class RackWidthTest(SimpleTestCase): ) partial_right = SimpleNamespace( pk=2, + rack_id=3, face="front", position=11, device_type=SimpleNamespace(u_height=1), @@ -98,6 +101,7 @@ class RackWidthTest(SimpleTestCase): ) partial_left = SimpleNamespace( pk=3, + rack_id=3, face="front", position=11, device_type=SimpleNamespace(u_height=1), @@ -131,6 +135,62 @@ class RackWidthTest(SimpleTestCase): ], ) + def test_infers_two_half_widths_when_legacy_devices_share_one_unit(self): + devices = [ + SimpleNamespace( + pk=21, + rack_id=3, + face="front", + position=11, + device_type=SimpleNamespace(u_height=1), + netbox_utilities_rack_placement=None, + ), + SimpleNamespace( + pk=22, + rack_id=3, + face="front", + position=11, + device_type=SimpleNamespace(u_height=1), + netbox_utilities_rack_placement=None, + ), + ] + + self.assertEqual( + effective_width_positions(devices), + { + 21: (2, 1, "inferred"), + 22: (2, 2, "inferred"), + }, + ) + + def test_infers_missing_position_next_to_stored_half_width(self): + devices = [ + SimpleNamespace( + pk=31, + rack_id=3, + face="front", + position=11, + device_type=SimpleNamespace(u_height=1), + netbox_utilities_rack_placement=SimpleNamespace(width=2, horizontal_position=2), + ), + SimpleNamespace( + pk=32, + rack_id=3, + face="front", + position=11, + device_type=SimpleNamespace(u_height=1), + netbox_utilities_rack_placement=None, + ), + ] + + self.assertEqual( + effective_width_positions(devices), + { + 31: (2, 2, "stored"), + 32: (2, 1, "inferred"), + }, + ) + 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 f8f5670..26f1810 100644 --- a/netbox_utilities/tests/test_reorder_rack.py +++ b/netbox_utilities/tests/test_reorder_rack.py @@ -97,8 +97,20 @@ class ApplyRackLayoutTest(SimpleTestCase): placement_model, ): events = [] - first = MagicMock(pk=10, position=Decimal(1), face="front") - second = MagicMock(pk=11, position=Decimal(2), face="front") + first = MagicMock( + pk=10, + rack_id=5, + position=Decimal(1), + face="front", + device_type=SimpleNamespace(u_height=1), + ) + second = MagicMock( + pk=11, + rack_id=5, + position=Decimal(2), + face="front", + device_type=SimpleNamespace(u_height=1), + ) first.snapshot.side_effect = lambda: events.append("snapshot-10") second.snapshot.side_effect = lambda: events.append("snapshot-11") first.full_clean.side_effect = lambda: events.append("validate-10") @@ -126,7 +138,13 @@ class ApplyRackLayoutTest(SimpleTestCase): @patch("netbox_utilities.reorder_rack.get_permission_for_model", return_value="dcim.change_device") @patch("netbox_utilities.reorder_rack.Device") def test_rejects_changed_device_without_permission(self, device_model, _get_permission, placement_model): - device = MagicMock(pk=10, position=Decimal(1), face="front") + device = MagicMock( + pk=10, + rack_id=5, + position=Decimal(1), + face="front", + device_type=SimpleNamespace(u_height=1), + ) device_model.objects.select_for_update.return_value.filter.return_value = [device] placement_model.objects.select_for_update.return_value.filter.return_value = [] user = MagicMock() @@ -148,7 +166,13 @@ class ApplyRackLayoutTest(SimpleTestCase): _get_permission, placement_model, ): - device = MagicMock(pk=10, position=Decimal(4), face="front") + device = MagicMock( + pk=10, + rack_id=5, + position=Decimal(4), + face="front", + device_type=SimpleNamespace(u_height=1), + ) stored = SimpleNamespace(device_id=10, width=2, horizontal_position=1) device_model.objects.select_for_update.return_value.filter.return_value = [device] placement_model.objects.select_for_update.return_value.filter.return_value = [stored] @@ -178,7 +202,13 @@ class ApplyRackLayoutTest(SimpleTestCase): _get_permission, placement_model, ): - device = MagicMock(pk=10, position=Decimal(4), face="front") + device = MagicMock( + pk=10, + rack_id=5, + position=Decimal(4), + face="front", + device_type=SimpleNamespace(u_height=1), + ) stored = SimpleNamespace(device_id=10, width=2, horizontal_position=2) device_model.objects.select_for_update.return_value.filter.return_value = [device] placement_model.objects.select_for_update.return_value.filter.return_value = [stored] @@ -194,6 +224,51 @@ class ApplyRackLayoutTest(SimpleTestCase): defaults={"width": 2, "horizontal_position": 2}, ) + @patch("netbox_utilities.reorder_rack.DeviceRackPlacement") + @patch("netbox_utilities.reorder_rack.get_permission_for_model", return_value="dcim.change_device") + @patch("netbox_utilities.reorder_rack.Device") + def test_persists_inferred_half_width_when_shared_device_is_moved( + self, + device_model, + _get_permission, + placement_model, + ): + left = MagicMock( + pk=10, + rack_id=5, + position=Decimal(4), + face="front", + device_type=SimpleNamespace(u_height=1), + ) + right = MagicMock( + pk=11, + rack_id=5, + position=Decimal(4), + face="front", + device_type=SimpleNamespace(u_height=1), + ) + device_model.objects.select_for_update.return_value.filter.return_value = [left, right] + placement_model.objects.select_for_update.return_value.filter.return_value = [] + user = MagicMock() + user.has_perm.return_value = True + placements = parse_device_placements( + rack_layout( + front=[ + {"id": 10, "x": 0, "y": 4}, + {"id": 11, "x": 6, "y": 5}, + ] + ), + grid_columns=12, + ) + + changed = apply_rack_layout(rack=SimpleNamespace(pk=5), placements=placements, user=user) + + self.assertEqual(changed, [right]) + placement_model.objects.update_or_create.assert_called_once_with( + device=right, + defaults={"width": 2, "horizontal_position": 2}, + ) + class ReorderRackFrontendTest(SimpleTestCase): def test_grid_y_matches_reorder_plugin_for_both_numbering_directions(self): @@ -213,6 +288,9 @@ class ReorderRackFrontendTest(SimpleTestCase): self.assertIn("closest('#saveButton')", script) self.assertIn("X-NetBox-Utilities-Rack-Grid-Columns", script) self.assertIn("if (face !== 'other')", script) + self.assertIn("data.schema_version !== 3", script) + self.assertIn("data.complete !== true", script) + self.assertIn("nativeMountedItems.length", script) self.assertNotIn("partialDeviceIds", script) self.assertNotIn("JSON.stringify", script) @@ -229,6 +307,7 @@ class ReorderRackFrontendTest(SimpleTestCase): self.assertEqual(result["status"], "native-fallback") self.assertEqual(result["devices"], []) + self.assertFalse(result["complete"]) @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") @@ -250,6 +329,7 @@ class ReorderRackFrontendTest(SimpleTestCase): placement = SimpleNamespace(width=2, horizontal_position=2) device = SimpleNamespace( pk=10, + rack_id=5, name="Fritzbox", label="Fritzbox", virtual_chassis=None, @@ -266,6 +346,7 @@ class ReorderRackFrontendTest(SimpleTestCase): ) adjacent_device = SimpleNamespace( pk=12, + rack_id=5, name="Router", label="Router", virtual_chassis=None, @@ -282,6 +363,7 @@ class ReorderRackFrontendTest(SimpleTestCase): ) full_width_device = SimpleNamespace( pk=11, + rack_id=5, name="Switch", label="Switch", virtual_chassis=None, @@ -315,6 +397,8 @@ class ReorderRackFrontendTest(SimpleTestCase): self.assertEqual(result["columns"], 12) self.assertEqual(result["status"], "ready") + self.assertEqual(result["schema_version"], 3) + self.assertTrue(result["complete"]) self.assertEqual(len(result["devices"]), 3) self.assertEqual(result["devices"][0]["label"], "Fritzbox") self.assertFalse(result["images"]) @@ -336,6 +420,10 @@ class ReorderRackFrontendTest(SimpleTestCase): {key: result["devices"][2][key] for key in ("id", "grid_x", "grid_width", "width")}, {"id": 11, "grid_x": 0, "grid_width": 12, "width": 1}, ) + self.assertEqual( + [device["width_source"] for device in result["devices"]], + ["stored", "stored", "default"], + ) class ReorderRackPatchTest(SimpleTestCase): diff --git a/netbox_utilities/tests/test_topology_views.py b/netbox_utilities/tests/test_topology_views.py index e6b9032..b858340 100644 --- a/netbox_utilities/tests/test_topology_views.py +++ b/netbox_utilities/tests/test_topology_views.py @@ -43,7 +43,7 @@ class TopologyViewsRackWidthTest(SimpleTestCase): def test_serializes_fractional_width_geometry_and_device_url(self): placement = SimpleNamespace( device_id=17, - device=SimpleNamespace(get_absolute_url=lambda: "/dcim/devices/17/"), + device=SimpleNamespace(pk=17, get_absolute_url=lambda: "/dcim/devices/17/"), width=3, horizontal_position=2, ) @@ -57,12 +57,12 @@ class TopologyViewsRackWidthTest(SimpleTestCase): "horizontal_position": 2, "left_percent": 33.33333333, "width_percent": 33.33333333, + "width_source": "stored", }, ) - @patch("netbox_utilities.topology_views.DeviceRackPlacement.objects") @patch("netbox_utilities.topology_views.topology_rack_width_enabled", return_value=True) - def test_queries_only_permitted_devices_from_selected_racks(self, _enabled, placement_objects): + def test_queries_all_permitted_devices_and_infers_shared_unit_widths(self, _enabled): self.request.resolver_match = SimpleNamespace(view_name="plugins:netbox_topology_views:rack_elevation") rack_queryset = MagicMock() rack_queryset.filter.return_value = rack_queryset @@ -71,19 +71,32 @@ class TopologyViewsRackWidthTest(SimpleTestCase): rack_model.objects.restrict.return_value = rack_queryset device_queryset = MagicMock() - device_queryset.filter.return_value = "permitted-device-query" + selected_devices = MagicMock() + device_queryset.filter.return_value = selected_devices device_model = MagicMock() device_model.objects.restrict.return_value = device_queryset - placement = SimpleNamespace( - device_id=17, - device=SimpleNamespace(get_absolute_url=lambda: "/dcim/devices/17/"), - width=2, - horizontal_position=2, - ) - placement_queryset = MagicMock() - placement_queryset.select_related.return_value.order_by.return_value = [placement] - placement_objects.filter.return_value = placement_queryset + devices = [ + SimpleNamespace( + pk=17, + rack_id=3, + face="front", + position=11, + device_type=SimpleNamespace(u_height=1), + netbox_utilities_rack_placement=None, + get_absolute_url=lambda: "/dcim/devices/17/", + ), + SimpleNamespace( + pk=18, + rack_id=3, + face="front", + position=11, + device_type=SimpleNamespace(u_height=1), + netbox_utilities_rack_placement=None, + get_absolute_url=lambda: "/dcim/devices/18/", + ), + ] + selected_devices.select_related.return_value.order_by.return_value = devices with patch.dict("sys.modules", {"dcim.models": SimpleNamespace(Device=device_model, Rack=rack_model)}): result = get_topology_rack_width_data(self.request) @@ -91,12 +104,17 @@ class TopologyViewsRackWidthTest(SimpleTestCase): rack_queryset.filter.assert_called_once_with(pk__in=["3"]) device_queryset.filter.assert_called_once_with( rack_id__in="selected-rack-query", - position__isnull=False, + position__gt=0, + device_type__u_height__gt=0, ) - placement_objects.filter.assert_called_once_with(device__in="permitted-device-query") self.assertEqual(result["status"], "ready") + self.assertEqual(result["schema_version"], 3) + self.assertTrue(result["complete"]) + self.assertEqual(len(result["devices"]), 2) self.assertEqual(result["devices"][0]["width"], 2) - self.assertEqual(result["devices"][0]["horizontal_position"], 2) + self.assertEqual(result["devices"][0]["horizontal_position"], 1) + self.assertEqual(result["devices"][1]["horizontal_position"], 2) + self.assertEqual(result["devices"][0]["width_source"], "inferred") 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( @@ -115,6 +133,8 @@ class TopologyViewsRackWidthTest(SimpleTestCase): self.assertIn("liveViewCompatible", script) self.assertIn("payload?.devices", script) self.assertIn("payload.status !== 'ready'", script) + self.assertIn("payload?.schema_version !== 3", script) + self.assertIn("placement.source", script) self.assertLess(script.index("liveViewCompatible"), script.index("netbox-utilities-partial-width")) self.assertLess(script.index("exportGraphic(graphic"), script.index("event.preventDefault()")) @@ -126,6 +146,7 @@ class TopologyViewsRackWidthTest(SimpleTestCase): self.assertEqual(result["status"], "native-fallback") self.assertEqual(result["devices"], []) + self.assertFalse(result["complete"]) 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 70554bd..6eb0cfc 100644 --- a/netbox_utilities/topology_views.py +++ b/netbox_utilities/topology_views.py @@ -5,7 +5,7 @@ from django.core.exceptions import ObjectDoesNotExist from django.db import OperationalError, ProgrammingError from netbox.plugins import get_plugin_config -from .models import DeviceRackPlacement +from .rack_width import effective_width_positions logger = logging.getLogger(__name__) @@ -27,15 +27,25 @@ def topology_rack_width_enabled(request): def serialize_topology_placement(placement): - width = int(placement.width) - horizontal_position = int(placement.horizontal_position) + return serialize_topology_width( + placement.device, + placement.width, + placement.horizontal_position, + source="stored", + ) + + +def serialize_topology_width(device, width, horizontal_position, *, source): + width = int(width) + horizontal_position = int(horizontal_position) return { - "device_id": placement.device_id, - "url": placement.device.get_absolute_url(), + "device_id": device.pk, + "url": device.get_absolute_url(), "width": width, "horizontal_position": horizontal_position, "left_percent": round((horizontal_position - 1) / width * 100, 8), "width_percent": round(100 / width, 8), + "width_source": source, } @@ -44,7 +54,7 @@ def get_topology_rack_width_data(request): if not topology_rack_width_enabled(request): return None - data = {"devices": [], "status": "ready"} + data = {"devices": [], "status": "ready", "schema_version": 3, "complete": False} if not request.GET: return data @@ -63,14 +73,24 @@ def get_topology_rack_width_data(request): if selected_locations: racks = racks.filter(location_id__in=selected_locations) - devices = Device.objects.restrict(request.user, "view").filter( - rack_id__in=racks.values("pk"), - position__isnull=False, + devices = list( + Device.objects.restrict(request.user, "view") + .filter( + rack_id__in=racks.values("pk"), + position__gt=0, + device_type__u_height__gt=0, + ) + .select_related("device_type", "netbox_utilities_rack_placement") + .order_by("rack_id", "face", "position", "pk") ) - placements = ( - DeviceRackPlacement.objects.filter(device__in=devices).select_related("device").order_by("device_id") - ) - data["devices"] = [serialize_topology_placement(placement) for placement in placements] + widths = effective_width_positions(devices) + data["devices"] = [ + serialize_topology_width(device, width, position, source=source) + for device in devices + for width, position, source in (widths[device.pk],) + if width > 1 + ] + data["complete"] = True return data except ( AttributeError, diff --git a/pyproject.toml b/pyproject.toml index 72d6a21..bfd8ea4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "netbox-utilities" -version = "0.9.2" +version = "0.9.3" description = "Navigation, tenant utilities, partial-width rack devices, bulk uploads, and rack reordering for NetBox 4.6" readme = "README.md" requires-python = ">=3.12"