From 1c6708216c0aee6da2748896a02dfa0b3ba21563 Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 19 Aug 2026 10:53:44 +0200 Subject: [PATCH] fix: preserve shared rack units during import --- README.md | 7 ++ netbox_utilities/__init__.py | 2 +- netbox_utilities/netbox_export.py | 27 ++++-- netbox_utilities/tests/test_netbox_export.py | 86 +++++++++++++++++-- netbox_utilities/tests/test_reorder_rack.py | 2 +- netbox_utilities/tests/test_topology_views.py | 2 +- pyproject.toml | 2 +- 7 files changed, 110 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 4576125..5b3010a 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,13 @@ Test- und Echtimport auch unter PostgreSQL, ohne einen unzulässigen `FOR UPDATE`-Outer-Join zu erzeugen. Bereits mit Version `0.9.9` erstellte Archive müssen dafür nicht neu exportiert werden. +Ab Version `0.9.11` werden alle im Archiv enthaltenen Rackbreiten vor der +eigentlichen Geräteplatzierung innerhalb derselben Transaktion vorgemerkt. +Dadurch erkennt die Konfliktprüfung mehrere teilbreite Geräte derselben HE +bereits während des Imports korrekt und löst kein zuvor platziertes Gerät aus +dem Rack. Nach der Platzierung werden die Breiten mit dem endgültigen +Importergebnis abgeglichen. + ### Teilbreiten in MrBlake NetBox Topology Views Ist diff --git a/netbox_utilities/__init__.py b/netbox_utilities/__init__.py index 8bca608..aefeee3 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.10" +__version__ = "0.9.11" class NetBoxUtilitiesConfig(PluginConfig): diff --git a/netbox_utilities/netbox_export.py b/netbox_utilities/netbox_export.py index cd2d43b..9fc12e7 100644 --- a/netbox_utilities/netbox_export.py +++ b/netbox_utilities/netbox_export.py @@ -141,22 +141,35 @@ def _apply_rack_widths(original, placements, resolved, resolver, compatibility, stage_width_position(device, placement.width, placement.horizontal_position) width_aware.append((device, placement)) + # The native placement pass reloads rack occupants from the database for + # every device. Persist all imported widths first so a device placed early + # in the same pass is not mistaken for a full-width conflict later on. + for device, placement in width_aware: + _save_rack_width(device, placement) + result = original(placements, resolved, resolver, compatibility, **kwargs) for device, placement in width_aware: if placement.width == FULL_WIDTH or not device.rack_id or not device.position: DeviceRackPlacement.objects.filter(device=device).delete() else: - DeviceRackPlacement.objects.update_or_create( - device=device, - defaults={ - "width": placement.width, - "horizontal_position": placement.horizontal_position, - }, - ) + _save_rack_width(device, placement) return result +def _save_rack_width(device, placement): + if placement.width == FULL_WIDTH: + DeviceRackPlacement.objects.filter(device=device).delete() + return + DeviceRackPlacement.objects.update_or_create( + device=device, + defaults={ + "width": placement.width, + "horizontal_position": placement.horizontal_position, + }, + ) + + def _rack_placement_is_exportable(original, model): if model is DeviceRackPlacement: return True diff --git a/netbox_utilities/tests/test_netbox_export.py b/netbox_utilities/tests/test_netbox_export.py index 0b2295d..52030bd 100644 --- a/netbox_utilities/tests/test_netbox_export.py +++ b/netbox_utilities/tests/test_netbox_export.py @@ -30,7 +30,12 @@ class FakeQuerySet(list): return self def filter(self, **kwargs): - return self + values = self + if kwargs.get("position__isnull") is False: + values = [item for item in values if item.position is not None] + result = FakeQuerySet(values) + result.selected_related = self.selected_related + return result def exclude(self, **kwargs): pk = kwargs.get("pk") @@ -155,6 +160,12 @@ class NetBoxExportRackWidthTest(SimpleTestCase): self.assertEqual(received, placements) self.assertEqual(partial._netbox_utilities_rack_width, 2) self.assertEqual(full._netbox_utilities_rack_width, 1) + placement_objects.update_or_create.assert_called_once_with( + device=partial, + defaults={"width": 2, "horizontal_position": 1}, + ) + placement_objects.filter.assert_called_once_with(device=full) + placement_objects.filter.return_value.delete.assert_called_once_with() partial.rack_id = full.rack_id = 1 partial.position = 11 full.position = 12 @@ -163,12 +174,73 @@ class NetBoxExportRackWidthTest(SimpleTestCase): result = _apply_rack_widths(native_apply, placements, resolved, object(), object()) self.assertEqual(result, "saved") - placement_objects.update_or_create.assert_called_once_with( - device=partial, - defaults={"width": 2, "horizontal_position": 1}, - ) - placement_objects.filter.assert_called_once_with(device=full) - placement_objects.filter.return_value.delete.assert_called_once_with() + self.assertEqual(placement_objects.update_or_create.call_count, 2) + self.assertEqual(placement_objects.filter.call_count, 2) + self.assertEqual(placement_objects.filter.return_value.delete.call_count, 2) + + @patch("netbox_utilities.netbox_export.DeviceRackPlacement.objects") + def test_import_preloads_all_partial_widths_before_native_placement(self, placement_objects): + left = FakeDevice(1) + right = FakeDevice(2) + placements = [ + ("left", WidthAwareDeferredDevicePlacement(None, 11, "front", 2, 1)), + ("right", WidthAwareDeferredDevicePlacement(None, 11, "front", 2, 2)), + ] + resolved = {"left": left, "right": right} + + def native_apply(*_args, **_kwargs): + self.assertEqual(placement_objects.update_or_create.call_count, 2) + self.assertEqual( + [call.kwargs["device"] for call in placement_objects.update_or_create.call_args_list], + [left, right], + ) + left.rack_id = right.rack_id = 1 + left.position = right.position = 11 + + _apply_rack_widths(native_apply, placements, resolved, object(), object()) + + self.assertEqual(placement_objects.update_or_create.call_count, 4) + + @patch("netbox_utilities.netbox_export.DeviceRackPlacement.objects") + def test_two_partial_devices_remain_in_the_same_unit_during_import(self, placement_objects): + left = FakeDevice(1) + right = FakeDevice(2) + FakeDevice._default_manager = FakeQuerySet([left, right]) + placements = [ + ("left", WidthAwareDeferredDevicePlacement(None, 11, "front", 2, 1)), + ("right", WidthAwareDeferredDevicePlacement(None, 11, "front", 2, 2)), + ] + resolved = {"left": left, "right": right} + stored = {} + + def store_width(*, device, defaults): + stored[device.pk] = SimpleNamespace(device_id=device.pk, **defaults) + + def locked_widths(**kwargs): + return [stored[pk] for pk in kwargs["device_id__in"] if pk in stored] + + placement_objects.update_or_create.side_effect = store_width + placement_objects.select_for_update.return_value.filter.side_effect = locked_widths + + def native_apply(received, *_args, **_kwargs): + for record_id, placement in received: + device = resolved[record_id] + conflicts = _width_aware_device_placement_conflicts( + MagicMock(), + device, + object(), + placement.position, + placement.face, + ) + self.assertEqual(conflicts, []) + device.rack_id = 1 + device.position = placement.position + device.face = placement.face + + _apply_rack_widths(native_apply, placements, resolved, object(), object()) + + self.assertEqual(left.position, 11) + self.assertEqual(right.position, 11) @patch("netbox_utilities.netbox_export.import_module") @patch("netbox_utilities.netbox_export.apps.get_app_config") diff --git a/netbox_utilities/tests/test_reorder_rack.py b/netbox_utilities/tests/test_reorder_rack.py index 0b76639..7711e55 100644 --- a/netbox_utilities/tests/test_reorder_rack.py +++ b/netbox_utilities/tests/test_reorder_rack.py @@ -449,7 +449,7 @@ class ReorderRackFrontendTest(SimpleTestCase): self.assertEqual(template_name, "netbox_utilities/reorder_rack.html") self.assertEqual(context["reorder_devices"][0]["label"], "LEO-Fritzbox") self.assertEqual(context["reorder_devices"][0]["grid_width"], 6) - self.assertEqual(context["asset_version"], "0.9.10") + self.assertEqual(context["asset_version"], "0.9.11") self.assertIs(context["reorder_rack_width_data"], get_width_data.return_value) get_width_data.assert_called_once() self.assertIs(get_width_data.call_args.kwargs["rack"], rack) diff --git a/netbox_utilities/tests/test_topology_views.py b/netbox_utilities/tests/test_topology_views.py index a0d6aed..5ee5074 100644 --- a/netbox_utilities/tests/test_topology_views.py +++ b/netbox_utilities/tests/test_topology_views.py @@ -246,7 +246,7 @@ class TopologyViewsRackWidthTest(SimpleTestCase): self.assertIn('id="netbox-utilities-topology-rack-width-styles"', html) self.assertIn('.rack-device[href="/dcim/devices/334/"]', html) self.assertIn("left: calc(50% + 3px) !important", html) - self.assertIn("netbox_utilities/topology-rack-width.js?v=0.9.10", html) + self.assertIn("netbox_utilities/topology-rack-width.js?v=0.9.11", html) self.assertIn("left:calc(0% + 3px)!important", html) self.assertIn("left:calc(50% + 3px)!important", html) self.assertIn("width:calc(50% - 6px)!important", html) diff --git a/pyproject.toml b/pyproject.toml index 46c8a19..0d3c7f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "netbox-utilities" -version = "0.9.10" +version = "0.9.11" description = "Navigation, tenant utilities, partial-width rack devices, bulk uploads, and rack reordering for NetBox 4.6" readme = "README.md" requires-python = ">=3.12"