diff --git a/README.md b/README.md index 6a841fb..c63e97e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/netbox_utilities/__init__.py b/netbox_utilities/__init__.py index eb279d5..7ceeaf9 100644 --- a/netbox_utilities/__init__.py +++ b/netbox_utilities/__init__.py @@ -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 diff --git a/netbox_utilities/reorder_rack.py b/netbox_utilities/reorder_rack.py new file mode 100644 index 0000000..dad3581 --- /dev/null +++ b/netbox_utilities/reorder_rack.py @@ -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 diff --git a/netbox_utilities/tests/test_reorder_rack.py b/netbox_utilities/tests/test_reorder_rack.py new file mode 100644 index 0000000..2396324 --- /dev/null +++ b/netbox_utilities/tests/test_reorder_rack.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index fd2731e..1d578e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" }