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