feat: add atomic multi-device rack reordering
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user