feat: add atomic multi-device rack reordering

This commit is contained in:
2026-07-31 10:06:14 +02:00
parent 503df48ac7
commit 2cbce03206
5 changed files with 384 additions and 7 deletions
+46 -2
View File
@@ -1,11 +1,12 @@
# NetBox Utilities
Plugin für **NetBox 4.6.5** mit vier Funktionen:
Plugin für **NetBox 4.6.5** mit fünf 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.
- Optional verpflichtende Mandantenzuordnung für alle mandantenfähigen Objekte.
- Mehrere Module desselben Typs in einem Schritt in freie Modulschächte einbauen.
- Optionale Mehrfachspeicherung für verschobene Geräte aus NetBox Reorder Rack.
Die Navigationseinstellungen sind benutzerbezogen. Die aktive Mandanten- oder Gruppenauswahl wird in der jeweiligen Browser-Session gespeichert.
@@ -13,6 +14,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`
Andere NetBox-Versionen werden vom Plugin absichtlich abgelehnt, da die Anpassung der Core-Navigation von deren HTML-Struktur abhängt.
@@ -30,7 +32,7 @@ Release-Tag oder ein bestimmter Commit verwendet werden:
```bash
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
"git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@v0.3.1"
"git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@v0.4.0"
```
Alternativ kann hinter dem `@` die vollständige Commit-ID stehen.
@@ -62,6 +64,7 @@ PLUGINS = [
PLUGINS_CONFIG = {
"netbox_utilities": {
"navigation_customization_enabled": True,
"reorder_rack_bulk_save_enabled": True,
"tenant_filter_enabled": True,
"tenant_required": True,
},
@@ -126,6 +129,47 @@ empfohlen.
## Verwendung
### Mehrere Geräte mit NetBox Reorder Rack verschieben
Wenn `netbox-reorder-rack` in Version `1.1.4` installiert und aktiviert ist,
erweitert NetBox Utilities dessen vorhandenen Speichervorgang automatisch.
In der Drag-and-drop-Ansicht können mehrere Geräte verschoben oder miteinander
getauscht und anschließend gemeinsam über **Save** gespeichert werden.
Die optionale Abhängigkeit wird separat installiert und für spätere NetBox-
Updates in `/opt/netbox/local_requirements.txt` aufgenommen:
```bash
/opt/netbox/venv/bin/pip install "netbox-reorder-rack==1.1.4"
echo "netbox-reorder-rack==1.1.4" | sudo tee -a /opt/netbox/local_requirements.txt
```
Beide Plugins müssen in NetBox aktiviert sein; ihre Reihenfolge ist beliebig:
```python
PLUGINS = [
"netbox_reorder_rack",
"netbox_utilities",
]
```
NetBox Utilities verändert keine Dateien des Reorder-Plugins. Stattdessen wird
dessen API beim Start kompatibel erweitert. Beim Speichern werden alle
betroffenen Geräte und das Rack gesperrt, die alten Positionen gemeinsam
freigegeben und danach sämtliche Zielpositionen mit der normalen
NetBox-Modellvalidierung geprüft. Der Vorgang läuft in einer Transaktion: Bei
einem Platz-, Validierungs- oder Berechtigungsfehler bleibt das gesamte Rack
unverändert. Für jedes tatsächlich verschobene Gerät entsteht weiterhin ein
normaler NetBox-Änderungseintrag.
Die Integration kann mit
`reorder_rack_bulk_save_enabled = False` in `PLUGINS_CONFIG` deaktiviert werden.
Andere Versionen als `1.1.4` werden aus Sicherheitsgründen nicht automatisch
verändert und erzeugen lediglich einen Hinweis im NetBox-Log. Das
Reorder-Projekt selbst weist derzeit offiziell nur Kompatibilität bis NetBox
4.5 aus; die Erweiterung in diesem Plugin ist gezielt für die hier unterstützte
NetBox-Version 4.6.5 umgesetzt.
### Module mehrfach einbauen
Unter **Plugins > NetBox Utilities > Module mehrfach einbauen** kann ein
+8 -3
View File
@@ -1,17 +1,18 @@
from netbox.plugins import PluginConfig
from netbox.plugins import PluginConfig, get_plugin_config
class NetBoxUtilitiesConfig(PluginConfig):
name = "netbox_utilities"
verbose_name = "NetBox Utilities"
description = "Personal navigation, global tenant filtering, and bulk module installation"
version = "0.3.1"
description = "Navigation, tenant filtering, bulk module installation, and atomic rack reordering"
version = "0.4.0"
author = "LKE"
base_url = "utilities"
min_version = "4.6.5"
max_version = "4.6.99"
default_settings = {
"navigation_customization_enabled": True,
"reorder_rack_bulk_save_enabled": True,
"tenant_filter_enabled": True,
"tenant_required": True,
}
@@ -26,6 +27,10 @@ class NetBoxUtilitiesConfig(PluginConfig):
install_search_filter()
install_tenant_validation()
if get_plugin_config("netbox_utilities", "reorder_rack_bulk_save_enabled"):
from .reorder_rack import install_reorder_rack_bulk_save
install_reorder_rack_bulk_save()
config = NetBoxUtilitiesConfig
+194
View File
@@ -0,0 +1,194 @@
import logging
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from importlib import import_module
from dcim.models import Device, Rack
from django.apps import apps
from django.core.exceptions import ValidationError
from django.db import transaction
from django.http import Http404
from django.shortcuts import get_object_or_404
from rest_framework import status
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from utilities.permissions import get_permission_for_model
logger = logging.getLogger(__name__)
SUPPORTED_REORDER_RACK_VERSIONS = {"1.1.4"}
PATCH_MARKER = "_netbox_utilities_bulk_save"
class RackLayoutError(Exception):
pass
@dataclass(frozen=True)
class DevicePlacement:
device_id: int
position: Decimal | None
face: str
def parse_device_placements(layout):
placements = []
seen_device_ids = set()
for section, face in (("front", "front"), ("rear", "rear"), ("other", "")):
for item in layout[section]:
if not isinstance(item, dict):
raise RackLayoutError(f"Ungültiger Eintrag im Bereich {section}.")
try:
device_id = int(item["id"])
except (KeyError, TypeError, ValueError) as error:
raise RackLayoutError(f"Ein Gerät im Bereich {section} besitzt keine gültige ID.") from error
if isinstance(item.get("id"), bool) or device_id < 1:
raise RackLayoutError(f"Ein Gerät im Bereich {section} besitzt keine gültige ID.")
if device_id in seen_device_ids:
raise RackLayoutError(f"Gerät {device_id} kommt im Rack-Layout mehrfach vor.")
seen_device_ids.add(device_id)
if section == "other":
position = None
else:
try:
position = Decimal(str(item["y"]))
except (KeyError, InvalidOperation, TypeError, ValueError) as error:
raise RackLayoutError(f"Gerät {device_id} besitzt keine gültige Rack-Position.") from error
if not position.is_finite():
raise RackLayoutError(f"Gerät {device_id} besitzt keine gültige Rack-Position.")
placements.append(DevicePlacement(device_id=device_id, position=position, face=face))
return placements
def apply_rack_layout(*, rack, placements, user):
placement_by_id = {placement.device_id: placement for placement in placements}
devices = {
device.pk: device for device in Device.objects.select_for_update().filter(rack=rack, pk__in=placement_by_id)
}
missing_ids = set(placement_by_id) - set(devices)
if missing_ids:
missing = ", ".join(str(device_id) for device_id in sorted(missing_ids))
raise RackLayoutError(f"Diese Geräte gehören nicht zum gewählten Rack: {missing}.")
changed = [
(devices[placement.device_id], placement)
for placement in placements
if (
devices[placement.device_id].position != placement.position
or devices[placement.device_id].face != placement.face
)
]
if not changed:
return []
permission = get_permission_for_model(Device, "change")
for device, _placement in changed:
if not user.has_perm(permission, obj=device):
raise PermissionDenied(f"Keine Berechtigung zum Verschieben von {device}.")
for device, _placement in changed:
device.snapshot()
changed_ids = [device.pk for device, _placement in changed]
Device.objects.filter(pk__in=changed_ids).update(position=None, face="")
for device, placement in changed:
device.position = placement.position
device.face = placement.face
device.full_clean()
device.save()
return [device for device, _placement in changed]
def _validation_error_message(error):
if hasattr(error, "message_dict"):
return "; ".join(
f"{field}: {', '.join(str(message) for message in messages)}"
for field, messages in error.message_dict.items()
)
return "; ".join(str(message) for message in error.messages)
def bulk_reorder_update(self, request, pk=None):
serializer = self.serializer_class(data=request.data)
serializer.is_valid(raise_exception=True)
layout = serializer.validated_data
try:
with transaction.atomic():
rack = get_object_or_404(Rack.objects.select_for_update(), pk=pk)
if layout["rack_id"] != rack.pk:
raise RackLayoutError("Die Rack-ID der Anfrage stimmt nicht mit dem Ziel-Rack überein.")
placements = parse_device_placements(layout)
changed_devices = apply_rack_layout(rack=rack, placements=placements, user=request.user)
if not changed_devices:
return Response(
{"message": "No changes detected."},
status=status.HTTP_304_NOT_MODIFIED,
)
return Response(
{
"message": f"{len(changed_devices)} devices reordered successfully",
"data": serializer.data,
},
status=status.HTTP_201_CREATED,
)
except RackLayoutError as error:
return Response(
{"message": "Invalid rack layout", "error": str(error)},
status=status.HTTP_400_BAD_REQUEST,
)
except ValidationError as error:
return Response(
{"message": "Invalid device position", "error": _validation_error_message(error)},
status=status.HTTP_400_BAD_REQUEST,
)
except PermissionDenied as error:
return Response(
{"message": "Permission denied", "error": str(error)},
status=status.HTTP_403_FORBIDDEN,
)
except Http404:
raise
except Exception as error:
logger.exception("Failed to save a reordered rack layout")
return Response(
{"message": "Error saving data", "error": str(error)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
def install_reorder_rack_bulk_save():
if not apps.is_installed("netbox_reorder_rack"):
return False
plugin_config = apps.get_app_config("netbox_reorder_rack")
plugin_version = getattr(plugin_config, "version", None)
if plugin_version not in SUPPORTED_REORDER_RACK_VERSIONS:
logger.warning(
"NetBox Utilities did not patch netbox-reorder-rack version %s; supported versions: %s",
plugin_version,
", ".join(sorted(SUPPORTED_REORDER_RACK_VERSIONS)),
)
return False
views = import_module("netbox_reorder_rack.api.views")
save_viewset = views.SaveViewSet
if getattr(save_viewset, PATCH_MARKER, False):
return True
save_viewset._netbox_utilities_original_update = save_viewset.update
save_viewset.update = bulk_reorder_update
setattr(save_viewset, PATCH_MARKER, True)
logger.info("Enabled atomic multi-device saving for netbox-reorder-rack %s", plugin_version)
return True
+134
View File
@@ -0,0 +1,134 @@
from decimal import Decimal
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from django.test import SimpleTestCase
from rest_framework.exceptions import PermissionDenied
from netbox_utilities.reorder_rack import (
RackLayoutError,
apply_rack_layout,
bulk_reorder_update,
install_reorder_rack_bulk_save,
parse_device_placements,
)
def rack_layout(front=None, rear=None, other=None):
return {
"front": front or [],
"rear": rear or [],
"other": other or [],
}
class RackLayoutParsingTest(SimpleTestCase):
def test_parses_multiple_device_positions(self):
placements = parse_device_placements(
rack_layout(
front=[{"id": 10, "y": 4}, {"id": 11, "y": 8.5}],
other=[{"id": 12, "y": None}],
)
)
self.assertEqual(
[(item.device_id, item.position, item.face) for item in placements],
[
(10, Decimal(4), "front"),
(11, Decimal("8.5"), "front"),
(12, None, ""),
],
)
def test_rejects_duplicate_devices(self):
with self.assertRaisesMessage(RackLayoutError, "mehrfach"):
parse_device_placements(
rack_layout(
front=[{"id": 10, "y": 4}],
rear=[{"id": 10, "y": 6}],
)
)
def test_rejects_invalid_positions(self):
with self.assertRaisesMessage(RackLayoutError, "keine gültige Rack-Position"):
parse_device_placements(rack_layout(front=[{"id": 10, "y": "not-a-position"}]))
class ApplyRackLayoutTest(SimpleTestCase):
@patch("netbox_utilities.reorder_rack.get_permission_for_model", return_value="dcim.change_device")
@patch("netbox_utilities.reorder_rack.Device")
def test_clears_all_old_positions_before_saving_targets(self, device_model, _get_permission):
events = []
first = MagicMock(pk=10, position=Decimal(1), face="front")
second = MagicMock(pk=11, position=Decimal(2), face="front")
first.snapshot.side_effect = lambda: events.append("snapshot-10")
second.snapshot.side_effect = lambda: events.append("snapshot-11")
first.full_clean.side_effect = lambda: events.append("validate-10")
second.full_clean.side_effect = lambda: events.append("validate-11")
first.save.side_effect = lambda: events.append("save-10")
second.save.side_effect = lambda: events.append("save-11")
device_model.objects.select_for_update.return_value.filter.return_value = [first, second]
device_model.objects.filter.return_value.update.side_effect = lambda **_kwargs: events.append("clear")
user = MagicMock()
user.has_perm.return_value = True
placements = parse_device_placements(rack_layout(front=[{"id": 10, "y": 2}, {"id": 11, "y": 1}]))
changed = apply_rack_layout(rack=SimpleNamespace(pk=5), placements=placements, user=user)
self.assertEqual(changed, [first, second])
self.assertLess(events.index("clear"), events.index("validate-10"))
self.assertLess(events.index("clear"), events.index("validate-11"))
device_model.objects.filter.return_value.update.assert_called_once_with(position=None, face="")
self.assertEqual((first.position, first.face), (Decimal(2), "front"))
self.assertEqual((second.position, second.face), (Decimal(1), "front"))
@patch("netbox_utilities.reorder_rack.get_permission_for_model", return_value="dcim.change_device")
@patch("netbox_utilities.reorder_rack.Device")
def test_rejects_changed_device_without_permission(self, device_model, _get_permission):
device = MagicMock(pk=10, position=Decimal(1), face="front")
device_model.objects.select_for_update.return_value.filter.return_value = [device]
user = MagicMock()
user.has_perm.return_value = False
placements = parse_device_placements(rack_layout(front=[{"id": 10, "y": 2}]))
with self.assertRaises(PermissionDenied):
apply_rack_layout(rack=SimpleNamespace(pk=5), placements=placements, user=user)
device_model.objects.filter.assert_not_called()
device.save.assert_not_called()
class ReorderRackPatchTest(SimpleTestCase):
def test_patches_supported_plugin_only_once(self):
original_update = object()
class SaveViewSet:
update = original_update
views = SimpleNamespace(SaveViewSet=SaveViewSet)
plugin_config = SimpleNamespace(version="1.1.4")
with (
patch("netbox_utilities.reorder_rack.apps.is_installed", return_value=True),
patch("netbox_utilities.reorder_rack.apps.get_app_config", return_value=plugin_config),
patch("netbox_utilities.reorder_rack.import_module", return_value=views),
):
self.assertTrue(install_reorder_rack_bulk_save())
self.assertTrue(install_reorder_rack_bulk_save())
self.assertIs(SaveViewSet.update, bulk_reorder_update)
self.assertIs(SaveViewSet._netbox_utilities_original_update, original_update)
def test_skips_unsupported_plugin_version(self):
with (
patch("netbox_utilities.reorder_rack.apps.is_installed", return_value=True),
patch(
"netbox_utilities.reorder_rack.apps.get_app_config",
return_value=SimpleNamespace(version="2.0.0"),
),
patch("netbox_utilities.reorder_rack.import_module") as import_module_mock,
):
self.assertFalse(install_reorder_rack_bulk_save())
import_module_mock.assert_not_called()
+2 -2
View File
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
[project]
name = "netbox-utilities"
version = "0.3.1"
description = "Personal navigation, global tenant filtering, and bulk module installation for NetBox 4.6"
version = "0.4.0"
description = "Navigation, tenant filtering, bulk module installation, and atomic rack reordering for NetBox 4.6"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }