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