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
+25 -3
View File
@@ -1,6 +1,6 @@
# NetBox Utilities # 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. - 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. - 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 Bilder in einem Schritt im Bilder-Tab eines Objekts hochladen.
- Mehrere Module desselben Typs in einem Schritt in freie Modulschächte einbauen. - Mehrere Module desselben Typs in einem Schritt in freie Modulschächte einbauen.
- Optionale Mehrfachspeicherung für verschobene Geräte aus NetBox Reorder Rack. - 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. 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` - NetBox `>=4.6.5,<4.7`
- Python `>=3.12` - Python `>=3.12`
- optional: NetBox Reorder Rack `1.1.4` - 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 - 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. 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 werden; die Core-Eindeutigkeit wird bei einer Deinstallation nicht automatisch
wiederhergestellt. wiederhergestellt.
Die Zusatzfelder werden derzeit im NetBox-Webformular gepflegt. REST- oder Die Zusatzfelder werden im NetBox-Webformular gepflegt. REST- oder normale
CSV-Vorgänge ohne diese Felder behandeln neue Geräte als volle Rackbreite. 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 ### Teilbreiten in MrBlake NetBox Topology Views
+5 -1
View File
@@ -1,6 +1,6 @@
from netbox.plugins import PluginConfig, get_plugin_config from netbox.plugins import PluginConfig, get_plugin_config
__version__ = "0.9.8" __version__ = "0.9.9"
class NetBoxUtilitiesConfig(PluginConfig): class NetBoxUtilitiesConfig(PluginConfig):
@@ -34,6 +34,10 @@ class NetBoxUtilitiesConfig(PluginConfig):
install_tenant_validation() install_tenant_validation()
install_patchpanel_automation() install_patchpanel_automation()
install_rack_width_support() 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"): if get_plugin_config("netbox_utilities", "reorder_rack_bulk_save_enabled"):
from .reorder_rack import install_reorder_rack_bulk_save 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(template_name, "netbox_utilities/reorder_rack.html")
self.assertEqual(context["reorder_devices"][0]["label"], "LEO-Fritzbox") self.assertEqual(context["reorder_devices"][0]["label"], "LEO-Fritzbox")
self.assertEqual(context["reorder_devices"][0]["grid_width"], 6) 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) self.assertIs(context["reorder_rack_width_data"], get_width_data.return_value)
get_width_data.assert_called_once() get_width_data.assert_called_once()
self.assertIs(get_width_data.call_args.kwargs["rack"], rack) 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('id="netbox-utilities-topology-rack-width-styles"', html)
self.assertIn('.rack-device[href="/dcim/devices/334/"]', html) self.assertIn('.rack-device[href="/dcim/devices/334/"]', html)
self.assertIn("left: calc(50% + 3px) !important", 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(0% + 3px)!important", html)
self.assertIn("left:calc(50% + 3px)!important", html) self.assertIn("left:calc(50% + 3px)!important", html)
self.assertIn("width:calc(50% - 6px)!important", html) self.assertIn("width:calc(50% - 6px)!important", html)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "netbox-utilities" 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" description = "Navigation, tenant utilities, partial-width rack devices, bulk uploads, and rack reordering for NetBox 4.6"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"