fix: preserve shared rack units during import

This commit is contained in:
2026-08-19 10:53:44 +02:00
parent 7216dad2b4
commit 1c6708216c
7 changed files with 110 additions and 18 deletions
+7
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
from netbox.plugins import PluginConfig, get_plugin_config
__version__ = "0.9.10"
__version__ = "0.9.11"
class NetBoxUtilitiesConfig(PluginConfig):
+14 -1
View File
@@ -141,12 +141,26 @@ 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:
_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={
@@ -154,7 +168,6 @@ def _apply_rack_widths(original, placements, resolved, resolver, compatibility,
"horizontal_position": placement.horizontal_position,
},
)
return result
def _rack_placement_is_exportable(original, model):
+78 -6
View File
@@ -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},
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],
)
placement_objects.filter.assert_called_once_with(device=full)
placement_objects.filter.return_value.delete.assert_called_once_with()
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")
+1 -1
View File
@@ -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)
@@ -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)
+1 -1
View File
@@ -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"