feat: preserve rack widths in netbox export
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user