feat: preserve rack widths in netbox export

This commit is contained in:
2026-08-19 10:33:17 +02:00
parent 839b076d94
commit 639dc958a5
7 changed files with 426 additions and 7 deletions
+5 -1
View File
@@ -1,6 +1,6 @@
from netbox.plugins import PluginConfig, get_plugin_config
__version__ = "0.9.8"
__version__ = "0.9.9"
class NetBoxUtilitiesConfig(PluginConfig):
@@ -34,6 +34,10 @@ class NetBoxUtilitiesConfig(PluginConfig):
install_tenant_validation()
install_patchpanel_automation()
install_rack_width_support()
if self.apps.is_installed("netbox_export"):
from .netbox_export import install_netbox_export_rack_width_support
install_netbox_export_rack_width_support()
if get_plugin_config("netbox_utilities", "reorder_rack_bulk_save_enabled"):
from .reorder_rack import install_reorder_rack_bulk_save
+200
View File
@@ -0,0 +1,200 @@
import logging
from dataclasses import dataclass
from decimal import Decimal
from functools import wraps
from importlib import import_module
from django.apps import apps
from django.core.exceptions import ValidationError
from .models import DeviceRackPlacement
from .rack_width import (
FULL_WIDTH,
get_width_position,
normalize_width_position,
placement_rectangles_overlap,
stage_width_position,
)
logger = logging.getLogger(__name__)
SUPPORTED_NETBOX_EXPORT_VERSIONS = {"0.3.11"}
RACK_WIDTH_RECORD_KEY = "netbox_utilities_rack_placement"
PATCH_MARKER = "_netbox_utilities_rack_width_version"
PATCH_VERSION = 1
@dataclass(frozen=True)
class WidthAwareDeferredDevicePlacement:
rack_spec: dict | None
position: object
face: object
width: int
horizontal_position: int
def _is_device(value):
return getattr(getattr(value, "_meta", None), "label_lower", None) == "dcim.device"
def _serialize_rack_width(original, obj, *args, **kwargs):
record = original(obj, *args, **kwargs)
if _is_device(obj):
width, horizontal_position = get_width_position(obj)
record[RACK_WIDTH_RECORD_KEY] = {
"schema_version": 1,
"width": width,
"horizontal_position": horizontal_position,
}
return record
def _defer_rack_width(original, model, record):
prepared, placement = original(model, record)
if placement is None:
return prepared, placement
metadata = record.get(RACK_WIDTH_RECORD_KEY)
if not isinstance(metadata, dict) or metadata.get("schema_version") != 1:
return prepared, placement
try:
width, horizontal_position = normalize_width_position(
metadata.get("width"),
metadata.get("horizontal_position"),
)
except ValidationError:
logger.warning("NetBox Export archive contains invalid rack width metadata for %s", record.get("id"))
return prepared, placement
return prepared, WidthAwareDeferredDevicePlacement(
rack_spec=placement.rack_spec,
position=placement.position,
face=placement.face,
width=width,
horizontal_position=horizontal_position,
)
def _device_footprint(device):
device_type = getattr(device, "device_type", None)
height = Decimal(str(getattr(device_type, "u_height", 1) or 0))
if height <= 0:
height = Decimal("0.5")
return height, bool(getattr(device_type, "is_full_depth", False))
def _width_aware_device_placement_conflicts(original, device, rack, position, face):
if not _is_device(device):
return original(device, rack, position, face)
if rack is None or position is None:
return []
width, horizontal_position = get_width_position(device)
height, full_depth = _device_footprint(device)
candidates = (
type(device)
._default_manager.select_for_update()
.select_related("device_type", "netbox_utilities_rack_placement")
.filter(rack=rack, position__isnull=False)
.exclude(pk=device.pk)
)
conflicts = []
for candidate in candidates:
candidate_height, candidate_full_depth = _device_footprint(candidate)
if not (full_depth or candidate_full_depth or candidate.face == face):
continue
candidate_width, candidate_horizontal_position = get_width_position(candidate)
if placement_rectangles_overlap(
position,
height,
width,
horizontal_position,
candidate.position,
candidate_height,
candidate_width,
candidate_horizontal_position,
):
conflicts.append(candidate)
return conflicts
def _apply_rack_widths(original, placements, resolved, resolver, compatibility, **kwargs):
placements = list(placements)
width_aware = []
for record_id, placement in placements:
if not isinstance(placement, WidthAwareDeferredDevicePlacement):
continue
device = resolved[record_id]
stage_width_position(device, placement.width, placement.horizontal_position)
width_aware.append((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,
},
)
return result
def _rack_placement_is_exportable(original, model):
if model is DeviceRackPlacement:
return True
return original(model)
def _patch_function(module, name, wrapper_factory):
current = getattr(module, name)
if getattr(current, PATCH_MARKER, None) == PATCH_VERSION:
return
original = current
@wraps(original)
def patched(*args, **kwargs):
return wrapper_factory(original, *args, **kwargs)
setattr(patched, PATCH_MARKER, PATCH_VERSION)
setattr(module, name, patched)
def install_netbox_export_rack_width_support():
if not apps.is_installed("netbox_export"):
return False
plugin_config = apps.get_app_config("netbox_export")
plugin_version = getattr(plugin_config, "version", None)
if plugin_version not in SUPPORTED_NETBOX_EXPORT_VERSIONS:
logger.warning(
"NetBox Utilities did not patch netbox-export version %s; supported versions: %s",
plugin_version,
", ".join(sorted(SUPPORTED_NETBOX_EXPORT_VERSIONS)),
)
return False
exporter = import_module("netbox_export.services.exporter")
graph = import_module("netbox_export.services.graph")
importer = import_module("netbox_export.services.importer")
required = (
(exporter, "serialize_object"),
(graph, "is_exportable_model"),
(importer, "_defer_device_placement"),
(importer, "_device_placement_conflicts"),
(importer, "_apply_device_placements"),
)
if not all(hasattr(module, name) for module, name in required):
logger.warning("NetBox Utilities could not find the expected netbox-export 0.3.11 integration hooks")
return False
_patch_function(exporter, "serialize_object", _serialize_rack_width)
_patch_function(graph, "is_exportable_model", _rack_placement_is_exportable)
_patch_function(importer, "_defer_device_placement", _defer_rack_width)
_patch_function(importer, "_device_placement_conflicts", _width_aware_device_placement_conflicts)
_patch_function(importer, "_apply_device_placements", _apply_rack_widths)
return True
@@ -0,0 +1,193 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from django.core.exceptions import ValidationError
from django.test import SimpleTestCase
from netbox_utilities.netbox_export import (
PATCH_MARKER,
PATCH_VERSION,
RACK_WIDTH_RECORD_KEY,
WidthAwareDeferredDevicePlacement,
_apply_rack_widths,
_defer_rack_width,
_serialize_rack_width,
_width_aware_device_placement_conflicts,
install_netbox_export_rack_width_support,
)
class FakeQuerySet(list):
def select_for_update(self):
return self
def select_related(self, *args):
return self
def filter(self, **kwargs):
return self
def exclude(self, **kwargs):
pk = kwargs.get("pk")
return FakeQuerySet(item for item in self if item.pk != pk)
class FakeDevice:
_meta = SimpleNamespace(label_lower="dcim.device")
_default_manager = FakeQuerySet()
def __init__(self, pk, *, width=1, horizontal_position=1, position=None, face="front", full_depth=False):
self.pk = pk
self.rack_id = 1 if position is not None else None
self.position = position
self.face = face
self.device_type = SimpleNamespace(u_height=1, is_full_depth=full_depth)
if width != 1:
self.netbox_utilities_rack_placement = SimpleNamespace(
width=width,
horizontal_position=horizontal_position,
)
class NetBoxExportRackWidthTest(SimpleTestCase):
def test_export_embeds_partial_and_full_rack_widths_on_devices(self):
partial = FakeDevice(1, width=2, horizontal_position=2)
full = FakeDevice(2)
original = lambda obj, *args, **kwargs: {"id": f"dcim.device:{obj.pk}"}
partial_record = _serialize_rack_width(original, partial)
full_record = _serialize_rack_width(original, full)
self.assertEqual(
partial_record[RACK_WIDTH_RECORD_KEY],
{"schema_version": 1, "width": 2, "horizontal_position": 2},
)
self.assertEqual(
full_record[RACK_WIDTH_RECORD_KEY],
{"schema_version": 1, "width": 1, "horizontal_position": 1},
)
def test_import_reads_width_metadata_without_breaking_legacy_archives(self):
native = SimpleNamespace(rack_spec={"ref": "dcim.rack:2"}, position=11, face="front")
original = MagicMock(return_value=({"fields": {}}, native))
record = {
"id": "dcim.device:334",
RACK_WIDTH_RECORD_KEY: {
"schema_version": 1,
"width": 2,
"horizontal_position": 2,
},
}
prepared, placement = _defer_rack_width(original, FakeDevice, record)
self.assertEqual(prepared, {"fields": {}})
self.assertEqual(
placement,
WidthAwareDeferredDevicePlacement(
rack_spec={"ref": "dcim.rack:2"},
position=11,
face="front",
width=2,
horizontal_position=2,
),
)
legacy_record = {"id": "dcim.device:334"}
self.assertIs(_defer_rack_width(original, FakeDevice, legacy_record)[1], native)
@patch("netbox_utilities.netbox_export.normalize_width_position", side_effect=ValidationError("invalid"))
def test_invalid_width_metadata_falls_back_to_native_import(self, _normalize):
native = SimpleNamespace(rack_spec=None, position=11, face="front")
original = MagicMock(return_value=({}, native))
record = {
"id": "dcim.device:334",
RACK_WIDTH_RECORD_KEY: {
"schema_version": 1,
"width": 9,
"horizontal_position": 9,
},
}
self.assertIs(_defer_rack_width(original, FakeDevice, record)[1], native)
def test_import_conflicts_respect_horizontal_rack_positions(self):
left = FakeDevice(1, width=2, horizontal_position=1, position=11)
right = FakeDevice(2, width=2, horizontal_position=2)
right.rack_id = 1
FakeDevice._default_manager = FakeQuerySet([left])
original = MagicMock()
conflicts = _width_aware_device_placement_conflicts(original, right, object(), 11, "front")
self.assertEqual(conflicts, [])
original.assert_not_called()
overlapping = FakeDevice(3, width=2, horizontal_position=1)
overlapping.rack_id = 1
self.assertEqual(
_width_aware_device_placement_conflicts(original, overlapping, object(), 11, "front"),
[left],
)
@patch("netbox_utilities.netbox_export.DeviceRackPlacement.objects")
def test_import_persists_partial_width_and_removes_stale_full_width(self, placement_objects):
partial = FakeDevice(1)
full = FakeDevice(2, width=2, horizontal_position=2)
partial_placement = WidthAwareDeferredDevicePlacement(None, 11, "front", 2, 1)
full_placement = WidthAwareDeferredDevicePlacement(None, 12, "front", 1, 1)
placements = [("partial", partial_placement), ("full", full_placement)]
resolved = {"partial": partial, "full": full}
def native_apply(received, *_args, **_kwargs):
self.assertEqual(received, placements)
self.assertEqual(partial._netbox_utilities_rack_width, 2)
self.assertEqual(full._netbox_utilities_rack_width, 1)
partial.rack_id = full.rack_id = 1
partial.position = 11
full.position = 12
return "saved"
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()
@patch("netbox_utilities.netbox_export.import_module")
@patch("netbox_utilities.netbox_export.apps.get_app_config")
@patch("netbox_utilities.netbox_export.apps.is_installed", return_value=True)
def test_installer_patches_supported_netbox_export(self, _installed, get_config, import_module):
get_config.return_value = SimpleNamespace(version="0.3.11")
exporter = SimpleNamespace(serialize_object=lambda *args, **kwargs: {})
graph = SimpleNamespace(is_exportable_model=lambda model: False)
importer = SimpleNamespace(
_defer_device_placement=lambda *args, **kwargs: ({}, None),
_device_placement_conflicts=lambda *args, **kwargs: [],
_apply_device_placements=lambda *args, **kwargs: None,
)
import_module.side_effect = [exporter, graph, importer]
self.assertTrue(install_netbox_export_rack_width_support())
for module, name in (
(exporter, "serialize_object"),
(graph, "is_exportable_model"),
(importer, "_defer_device_placement"),
(importer, "_device_placement_conflicts"),
(importer, "_apply_device_placements"),
):
self.assertEqual(getattr(getattr(module, name), PATCH_MARKER), PATCH_VERSION)
@patch("netbox_utilities.netbox_export.import_module")
@patch("netbox_utilities.netbox_export.apps.get_app_config")
@patch("netbox_utilities.netbox_export.apps.is_installed", return_value=True)
def test_installer_ignores_unsupported_netbox_export(self, _installed, get_config, import_module):
get_config.return_value = SimpleNamespace(version="0.4.0")
self.assertFalse(install_netbox_export_rack_width_support())
import_module.assert_not_called()
+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.8")
self.assertEqual(context["asset_version"], "0.9.9")
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.8", html)
self.assertIn("netbox_utilities/topology-rack-width.js?v=0.9.9", 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)