From 639dc958a5290f204dfa1c71cb7030098d7a325e Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 19 Aug 2026 10:33:17 +0200 Subject: [PATCH] feat: preserve rack widths in netbox export --- README.md | 28 ++- netbox_utilities/__init__.py | 6 +- netbox_utilities/netbox_export.py | 200 ++++++++++++++++++ netbox_utilities/tests/test_netbox_export.py | 193 +++++++++++++++++ netbox_utilities/tests/test_reorder_rack.py | 2 +- netbox_utilities/tests/test_topology_views.py | 2 +- pyproject.toml | 2 +- 7 files changed, 426 insertions(+), 7 deletions(-) create mode 100644 netbox_utilities/netbox_export.py create mode 100644 netbox_utilities/tests/test_netbox_export.py diff --git a/README.md b/README.md index 607f2b9..63d9d5c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # NetBox Utilities -Plugin für **NetBox 4.6.5 bis 4.6.7** mit zehn Funktionen: +Plugin für **NetBox 4.6.5 bis 4.6.7** mit elf Funktionen: - Jeder Benutzer kann die Menüs der linken Navigation verschieben oder ausblenden. - Ein Dropdown in der Kopfleiste setzt einen sitzungsweiten Filter für einen Mandanten oder eine Mandantengruppe. @@ -12,6 +12,7 @@ Plugin für **NetBox 4.6.5 bis 4.6.7** mit zehn Funktionen: - Mehrere Bilder in einem Schritt im Bilder-Tab eines Objekts hochladen. - Mehrere Module desselben Typs in einem Schritt in freie Modulschächte einbauen. - Optionale Mehrfachspeicherung für verschobene Geräte aus NetBox Reorder Rack. +- Rackbreiten bleiben beim optionalen NetBox-Export und -Import erhalten. Die Navigationseinstellungen sind benutzerbezogen. Die aktive Mandanten- oder Gruppenauswahl wird in der jeweiligen Browser-Session gespeichert. @@ -20,6 +21,7 @@ Die Navigationseinstellungen sind benutzerbezogen. Die aktive Mandanten- oder Gr - NetBox `>=4.6.5,<4.7` - Python `>=3.12` - optional: NetBox Reorder Rack `1.1.4` +- optional: NetBox-Export `0.3.11` - optional: [MrBlake NetBox Topology Views](https://git.mrblake.cc/MrBlake/mrb-netbox-topology-views) mit Rack-Ansicht Andere NetBox-Versionen werden vom Plugin absichtlich abgelehnt, da die Anpassung der Core-Navigation von deren HTML-Struktur abhängt. @@ -256,8 +258,28 @@ späteren Entfernen des Plugins müssen geteilte Höheneinheiten wieder aufgelö werden; die Core-Eindeutigkeit wird bei einer Deinstallation nicht automatisch wiederhergestellt. -Die Zusatzfelder werden derzeit im NetBox-Webformular gepflegt. REST- oder -CSV-Vorgänge ohne diese Felder behandeln neue Geräte als volle Rackbreite. +Die Zusatzfelder werden im NetBox-Webformular gepflegt. REST- oder normale +NetBox-CSV-Vorgänge ohne diese Felder behandeln neue Geräte als volle +Rackbreite. Der nachfolgend beschriebene portable ZIP-Export überträgt sie +dagegen ausdrücklich. + +### Rackbreiten in NetBox-Export + +Ist das optionale Plugin +[NetBox-Export](https://git.mrblake.cc/MrBlake/netbox-export) in Version +`0.3.11` installiert, erweitert NetBox Utilities dessen ZIP-Export und Import +automatisch. Für jedes Gerät werden auch die Rackbreite und die von links +gezählte Breitenposition im Archiv gespeichert. Beim Import berücksichtigt die +Konfliktprüfung sowohl die Gerätehöhe als auch die horizontale Rackfläche. +Dadurch können beispielsweise zwei Geräte mit halber Breite wieder in dieselbe +HE importiert werden, ohne dass das zuerst importierte Gerät aus dem Rack +entfernt wird. + +Auch volle Rackbreite wird ausdrücklich gespeichert. Wird ein früher +teilbreites Zielgerät durch einen neueren Export auf volle Breite zurückgesetzt, +entfernt der Import deshalb seine veraltete Teilbreitenzuordnung. Ältere +Archive ohne diese Zusatzmetadaten bleiben importierbar; vorhandene +`DeviceRackPlacement`-Datensätze darin werden weiterhin berücksichtigt. ### Teilbreiten in MrBlake NetBox Topology Views diff --git a/netbox_utilities/__init__.py b/netbox_utilities/__init__.py index efe5b76..5210f77 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.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 diff --git a/netbox_utilities/netbox_export.py b/netbox_utilities/netbox_export.py new file mode 100644 index 0000000..679680a --- /dev/null +++ b/netbox_utilities/netbox_export.py @@ -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 diff --git a/netbox_utilities/tests/test_netbox_export.py b/netbox_utilities/tests/test_netbox_export.py new file mode 100644 index 0000000..c634dd0 --- /dev/null +++ b/netbox_utilities/tests/test_netbox_export.py @@ -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() diff --git a/netbox_utilities/tests/test_reorder_rack.py b/netbox_utilities/tests/test_reorder_rack.py index 0884110..2ae57d6 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.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) diff --git a/netbox_utilities/tests/test_topology_views.py b/netbox_utilities/tests/test_topology_views.py index 458a1de..0acc5b2 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.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) diff --git a/pyproject.toml b/pyproject.toml index 2f9892d..3f9a111 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "netbox-utilities" -version = "0.9.8" +version = "0.9.9" description = "Navigation, tenant utilities, partial-width rack devices, bulk uploads, and rack reordering for NetBox 4.6" readme = "README.md" requires-python = ">=3.12"