From 8a86ca28f785e3325251250b185b0ec9809ffa75 Mon Sep 17 00:00:00 2001 From: Louis Date: Thu, 30 Jul 2026 16:00:31 +0200 Subject: [PATCH] feat: add bulk module installation --- README.md | 26 ++++- netbox_utilities/__init__.py | 4 +- netbox_utilities/forms.py | 76 +++++++++++++ netbox_utilities/module_installation.py | 85 +++++++++++++++ netbox_utilities/navigation.py | 6 + netbox_utilities/template_content.py | 24 +++- .../netbox_utilities/bulk_module_install.html | 36 ++++++ .../device_bulk_module_button.html | 5 + .../tests/test_module_installation.py | 103 ++++++++++++++++++ netbox_utilities/urls.py | 1 + netbox_utilities/views.py | 70 +++++++++++- pyproject.toml | 4 +- 12 files changed, 432 insertions(+), 8 deletions(-) create mode 100644 netbox_utilities/module_installation.py create mode 100644 netbox_utilities/templates/netbox_utilities/bulk_module_install.html create mode 100644 netbox_utilities/templates/netbox_utilities/device_bulk_module_button.html create mode 100644 netbox_utilities/tests/test_module_installation.py diff --git a/README.md b/README.md index e7e9ad5..39725d4 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # NetBox Utilities -Plugin für **NetBox 4.6.5** mit drei Funktionen: +Plugin für **NetBox 4.6.5** mit vier 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. Die Navigationseinstellungen sind benutzerbezogen. Die aktive Mandanten- oder Gruppenauswahl wird in der jeweiligen Browser-Session gespeichert. @@ -29,7 +30,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.2.0" + "git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@v0.3.0" ``` Alternativ kann hinter dem `@` die vollständige Commit-ID stehen. @@ -125,6 +126,27 @@ empfohlen. ## Verwendung +### Module mehrfach einbauen + +Unter **Plugins > NetBox Utilities > Module mehrfach einbauen** kann ein +Benutzer ein Gerät, einen Modultyp und mehrere freie Modulschächte auswählen. +Auf Geräteseiten mit freien Modulschächten steht dieselbe Funktion zusätzlich +über die Schaltfläche **Module mehrfach einbauen** zur Verfügung; das Gerät ist +dort bereits vorausgewählt. Das funktioniert sowohl für modulare Patchpanels +als auch für andere NetBox-Geräte mit Modulschächten. + +Alle ausgewählten Schächte erhalten denselben Modultyp, Status und dieselbe +Beschreibung. Optional werden die Komponenten aus den Vorlagen des Modultyps +für jedes Modul repliziert. NetBox prüft dabei jeden Einbau mit derselben Logik +wie beim einzelnen Modul. Der Vorgang ist atomar: Ist ein Schacht inzwischen +belegt oder entsteht ein Komponenten-/Namenskonflikt, wird keines der Module +gespeichert. Individuelle Seriennummern und Asset-Tags werden anschließend an +den einzelnen Modulen gepflegt. + +Benötigt wird die NetBox-Berechtigung zum Hinzufügen von Modulen. Geräte, +Modultypen und Modulschächte werden zusätzlich durch die bestehenden +NetBox-Objektberechtigungen des Benutzers eingeschränkt. + ### Navigation personalisieren Unter **Plugins > NetBox Utilities > Navigation personalisieren** sieht der Benutzer alle Menüs, für die er aktuell Berechtigungen besitzt. Die Pfeiltasten ändern die Reihenfolge; der Schalter blendet ein Menü aus. **Zurücksetzen** stellt die NetBox-Reihenfolge wieder her. diff --git a/netbox_utilities/__init__.py b/netbox_utilities/__init__.py index 2ed8bca..d36019c 100644 --- a/netbox_utilities/__init__.py +++ b/netbox_utilities/__init__.py @@ -4,8 +4,8 @@ from netbox.plugins import PluginConfig class NetBoxUtilitiesConfig(PluginConfig): name = "netbox_utilities" verbose_name = "NetBox Utilities" - description = "Personal navigation and a global tenant filter" - version = "0.2.0" + description = "Personal navigation, global tenant filtering, and bulk module installation" + version = "0.3.0" author = "LKE" base_url = "utilities" min_version = "4.6.5" diff --git a/netbox_utilities/forms.py b/netbox_utilities/forms.py index dd0dc9a..d3fa9f0 100644 --- a/netbox_utilities/forms.py +++ b/netbox_utilities/forms.py @@ -1,8 +1,84 @@ +from dcim.choices import ModuleStatusChoices +from dcim.models import Device, ModuleBay, ModuleType from django import forms +from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField from .models import UtilitiesSettings +class BulkModuleInstallForm(forms.Form): + device = DynamicModelChoiceField( + label="Gerät", + queryset=Device.objects.all(), + selector=True, + ) + module_type = DynamicModelChoiceField( + label="Modultyp", + queryset=ModuleType.objects.all(), + context={"parent": "manufacturer"}, + selector=True, + ) + module_bays = DynamicModelMultipleChoiceField( + label="Modulschächte", + queryset=ModuleBay.objects.all(), + query_params={"device_id": "$device"}, + context={"disabled": "_occupied"}, + help_text="Alle ausgewählten Modulschächte erhalten ein Modul des gewählten Typs.", + ) + status = forms.ChoiceField( + label="Status", + choices=ModuleStatusChoices, + initial=ModuleStatusChoices.STATUS_ACTIVE, + ) + replicate_components = forms.BooleanField( + label="Komponenten replizieren", + required=False, + initial=True, + help_text="Komponenten aus den Vorlagen des Modultyps automatisch anlegen.", + ) + description = forms.CharField( + label="Beschreibung", + required=False, + widget=forms.Textarea(attrs={"rows": 3}), + ) + + def __init__(self, *args, user, **kwargs): + super().__init__(*args, **kwargs) + self.fields["device"].queryset = Device.objects.restrict(user, "view") + self.fields["module_type"].queryset = ModuleType.objects.restrict(user, "view") + + module_bays = ModuleBay.objects.restrict(user, "view").filter( + enabled=True, + installed_module__isnull=True, + ) + device = self.data.get("device") or self.initial.get("device") + if isinstance(device, Device): + device = device.pk + if device: + module_bays = module_bays.filter(device_id=device) + self.fields["module_bays"].queryset = module_bays + + def clean(self): + cleaned_data = super().clean() + device = cleaned_data.get("device") + module_bays = cleaned_data.get("module_bays") + if device and module_bays: + invalid_bays = [ + module_bay.name + for module_bay in module_bays + if module_bay.device_id != device.pk + or not module_bay.enabled + or hasattr(module_bay, "installed_module") + ] + if invalid_bays: + self.add_error( + "module_bays", + "Diese Modulschächte sind nicht frei oder gehören nicht zum gewählten Gerät: " + + ", ".join(invalid_bays), + ) + return cleaned_data + + class UtilitiesSettingsForm(forms.ModelForm): def __init__(self, *args, tenant_filter_locked=False, tenant_required_locked=False, **kwargs): super().__init__(*args, **kwargs) diff --git a/netbox_utilities/module_installation.py b/netbox_utilities/module_installation.py new file mode 100644 index 0000000..c5983b2 --- /dev/null +++ b/netbox_utilities/module_installation.py @@ -0,0 +1,85 @@ +from dcim.forms import ModuleForm +from dcim.models import ModuleBay +from django.db import IntegrityError, transaction + + +class BulkModuleInstallError(Exception): + pass + + +def build_module_form_data(device, module_bay, module_type, status, replicate_components, description=""): + return { + "device": device.pk, + "module_bay": module_bay.pk, + "module_type": module_type.pk, + "status": status, + "replicate_components": replicate_components, + "adopt_components": False, + "description": description, + } + + +def _format_form_errors(form): + messages = [] + for field_name, errors in form.errors.items(): + field = form.fields.get(field_name) + label = field.label if field else "Allgemeiner Fehler" + messages.append(f"{label}: {', '.join(str(error) for error in errors)}") + return "; ".join(messages) + + +def install_modules( + *, + device, + module_type, + module_bays, + status, + replicate_components=True, + description="", + permitted_modules=None, +): + module_bay_ids = {module_bay.pk for module_bay in module_bays} + if not module_bay_ids: + return [] + + try: + with transaction.atomic(): + locked_bays = list(ModuleBay.objects.select_for_update().filter(pk__in=module_bay_ids)) + if {module_bay.pk for module_bay in locked_bays} != module_bay_ids: + raise BulkModuleInstallError("Mindestens ein ausgewählter Modulschacht existiert nicht mehr.") + + installed_modules = [] + for module_bay in locked_bays: + if ( + module_bay.device_id != device.pk + or not module_bay.enabled + or hasattr(module_bay, "installed_module") + ): + raise BulkModuleInstallError(f'Der Modulschacht "{module_bay.name}" ist nicht mehr verfügbar.') + + module_form = ModuleForm( + data=build_module_form_data( + device, + module_bay, + module_type, + status, + replicate_components, + description, + ) + ) + if not module_form.is_valid(): + raise BulkModuleInstallError( + f'Fehler bei Modulschacht "{module_bay.name}": {_format_form_errors(module_form)}' + ) + module = module_form.save() + if permitted_modules is not None and not permitted_modules.filter(pk=module.pk).exists(): + raise BulkModuleInstallError( + f'Keine Berechtigung zum Einbau eines Moduls in Modulschacht "{module_bay.name}".' + ) + installed_modules.append(module) + + return installed_modules + except IntegrityError as error: + raise BulkModuleInstallError( + "Der Masseneinbau konnte wegen eines zwischenzeitlichen Datenkonflikts nicht gespeichert werden." + ) from error diff --git a/netbox_utilities/navigation.py b/netbox_utilities/navigation.py index 9665579..355a0a0 100644 --- a/netbox_utilities/navigation.py +++ b/netbox_utilities/navigation.py @@ -1,6 +1,12 @@ from netbox.plugins import PluginMenuItem menu_items = ( + PluginMenuItem( + link="plugins:netbox_utilities:bulk_module_install", + link_text="Module mehrfach einbauen", + auth_required=True, + permissions=("dcim.add_module",), + ), PluginMenuItem( link="plugins:netbox_utilities:navigation_preferences", link_text="Navigation personalisieren", diff --git a/netbox_utilities/template_content.py b/netbox_utilities/template_content.py index d764097..2d59a61 100644 --- a/netbox_utilities/template_content.py +++ b/netbox_utilities/template_content.py @@ -1,3 +1,4 @@ +from dcim.models import ModuleBay from netbox.plugins import PluginTemplateExtension from tenancy.models import Tenant, TenantGroup @@ -50,4 +51,25 @@ class UtilitiesGlobalContent(PluginTemplateExtension): ) -template_extensions = [UtilitiesGlobalContent] +class DeviceUtilitiesContent(PluginTemplateExtension): + models = ["dcim.device"] + + def buttons(self): + request = self.context["request"] + device = self.context["object"] + if not request.user.has_perm("dcim.add_module"): + return "" + if ( + not ModuleBay.objects.restrict(request.user, "view") + .filter( + device=device, + enabled=True, + installed_module__isnull=True, + ) + .exists() + ): + return "" + return self.render("netbox_utilities/device_bulk_module_button.html") + + +template_extensions = [UtilitiesGlobalContent, DeviceUtilitiesContent] diff --git a/netbox_utilities/templates/netbox_utilities/bulk_module_install.html b/netbox_utilities/templates/netbox_utilities/bulk_module_install.html new file mode 100644 index 0000000..65d1ba3 --- /dev/null +++ b/netbox_utilities/templates/netbox_utilities/bulk_module_install.html @@ -0,0 +1,36 @@ +{% extends 'base/layout.html' %} +{% load form_helpers %} + +{% block title %}Module mehrfach einbauen{% endblock %} + +{% render_errors form %} + +{% block content %} +
+ {% csrf_token %} +
+
+
+

Module mehrfach einbauen

+
+
+ In alle ausgewählten Modulschächte wird derselbe Modultyp eingebaut. + Seriennummern und Asset-Tags können anschließend je Modul ergänzt werden. + Tritt bei einem Modul ein Fehler auf, wird keines der ausgewählten Module gespeichert. +
+ {% render_form form %} +
+
+
+
+
+
+ Abbrechen + +
+
+
+{% endblock content %} diff --git a/netbox_utilities/templates/netbox_utilities/device_bulk_module_button.html b/netbox_utilities/templates/netbox_utilities/device_bulk_module_button.html new file mode 100644 index 0000000..ff468ab --- /dev/null +++ b/netbox_utilities/templates/netbox_utilities/device_bulk_module_button.html @@ -0,0 +1,5 @@ + + + Module mehrfach einbauen + diff --git a/netbox_utilities/tests/test_module_installation.py b/netbox_utilities/tests/test_module_installation.py new file mode 100644 index 0000000..f53b5bc --- /dev/null +++ b/netbox_utilities/tests/test_module_installation.py @@ -0,0 +1,103 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from django.test import SimpleTestCase + +from netbox_utilities.module_installation import ( + BulkModuleInstallError, + build_module_form_data, + install_modules, +) + + +class BulkModuleInstallationTest(SimpleTestCase): + def setUp(self): + self.device = SimpleNamespace(pk=10) + self.module_type = SimpleNamespace(pk=20) + self.module_bay = SimpleNamespace(pk=30, device_id=10, enabled=True, name="A1") + + def test_builds_core_module_form_data(self): + data = build_module_form_data( + self.device, + self.module_bay, + self.module_type, + "active", + True, + "Patchmodul", + ) + + self.assertEqual( + data, + { + "device": 10, + "module_bay": 30, + "module_type": 20, + "status": "active", + "replicate_components": True, + "adopt_components": False, + "description": "Patchmodul", + }, + ) + + @patch("netbox_utilities.module_installation.transaction.atomic") + @patch("netbox_utilities.module_installation.ModuleForm") + @patch("netbox_utilities.module_installation.ModuleBay") + def test_installs_module_with_core_form(self, module_bay_model, module_form_class, atomic): + module_bay_model.objects.select_for_update.return_value.filter.return_value = [self.module_bay] + form = module_form_class.return_value + form.is_valid.return_value = True + installed_module = SimpleNamespace(pk=40) + form.save.return_value = installed_module + + result = install_modules( + device=self.device, + module_type=self.module_type, + module_bays=[self.module_bay], + status="active", + replicate_components=True, + description="Patchmodul", + ) + + self.assertEqual(result, [installed_module]) + form.save.assert_called_once_with() + atomic.assert_called_once_with() + + @patch("netbox_utilities.module_installation.transaction.atomic") + @patch("netbox_utilities.module_installation.ModuleForm") + @patch("netbox_utilities.module_installation.ModuleBay") + def test_reports_bay_and_rolls_back_on_validation_error(self, module_bay_model, module_form_class, _atomic): + module_bay_model.objects.select_for_update.return_value.filter.return_value = [self.module_bay] + form = module_form_class.return_value + form.is_valid.return_value = False + form.errors = {"__all__": ["Eine Komponente existiert bereits."]} + form.fields = {} + + with self.assertRaisesMessage(BulkModuleInstallError, 'Fehler bei Modulschacht "A1"'): + install_modules( + device=self.device, + module_type=self.module_type, + module_bays=[self.module_bay], + status="active", + ) + + form.save.assert_not_called() + + @patch("netbox_utilities.module_installation.transaction.atomic") + @patch("netbox_utilities.module_installation.ModuleForm") + @patch("netbox_utilities.module_installation.ModuleBay") + def test_rejects_module_outside_add_permission(self, module_bay_model, module_form_class, _atomic): + module_bay_model.objects.select_for_update.return_value.filter.return_value = [self.module_bay] + form = module_form_class.return_value + form.is_valid.return_value = True + form.save.return_value = SimpleNamespace(pk=40) + permitted_modules = MagicMock() + permitted_modules.filter.return_value.exists.return_value = False + + with self.assertRaisesMessage(BulkModuleInstallError, "Keine Berechtigung"): + install_modules( + device=self.device, + module_type=self.module_type, + module_bays=[self.module_bay], + status="active", + permitted_modules=permitted_modules, + ) diff --git a/netbox_utilities/urls.py b/netbox_utilities/urls.py index f10b6c0..4234f53 100644 --- a/netbox_utilities/urls.py +++ b/netbox_utilities/urls.py @@ -5,6 +5,7 @@ from . import views app_name = "netbox_utilities" urlpatterns = [ + path("modules/bulk-install/", views.BulkModuleInstallView.as_view(), name="bulk_module_install"), path("navigation/", views.NavigationPreferencesView.as_view(), name="navigation_preferences"), path("settings/", views.UtilitiesSettingsView.as_view(), name="settings"), path("tenant/select/", views.SelectTenantView.as_view(), name="select_tenant"), diff --git a/netbox_utilities/views.py b/netbox_utilities/views.py index 25179cf..4cadac6 100644 --- a/netbox_utilities/views.py +++ b/netbox_utilities/views.py @@ -1,3 +1,4 @@ +from dcim.models import Device, Module from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.http import HttpResponseBadRequest @@ -7,14 +8,16 @@ from django.utils.http import url_has_allowed_host_and_scheme from django.views import View from netbox.plugins import get_plugin_config from tenancy.models import Tenant, TenantGroup +from utilities.views import ContentTypePermissionRequiredMixin -from .forms import UtilitiesSettingsForm +from .forms import BulkModuleInstallForm, UtilitiesSettingsForm from .middleware import ( SESSION_TENANT_GROUP_KEY, SESSION_TENANT_KEY, GlobalTenantFilterMiddleware, ) from .models import NavigationPreference, UtilitiesSettings +from .module_installation import BulkModuleInstallError, install_modules from .navigation_helpers import get_visible_menus, normalize_preferences from .runtime import ( clear_runtime_settings_cache, @@ -34,6 +37,71 @@ def _safe_return_url(request, default_name="home"): return reverse(default_name) +class BulkModuleInstallView(ContentTypePermissionRequiredMixin, View): + template_name = "netbox_utilities/bulk_module_install.html" + + def get_required_permission(self): + return "dcim.add_module" + + def get(self, request): + initial_device = self._get_device(request.GET.get("device"), request.user) + form = BulkModuleInstallForm( + user=request.user, + initial={"device": initial_device} if initial_device else None, + ) + return self._render(request, form, initial_device) + + def post(self, request): + form = BulkModuleInstallForm(request.POST, user=request.user) + if form.is_valid(): + try: + installed_modules = install_modules( + device=form.cleaned_data["device"], + module_type=form.cleaned_data["module_type"], + module_bays=form.cleaned_data["module_bays"], + status=form.cleaned_data["status"], + replicate_components=form.cleaned_data["replicate_components"], + description=form.cleaned_data["description"], + permitted_modules=Module.objects.restrict(request.user, "add"), + ) + except BulkModuleInstallError as error: + form.add_error(None, str(error)) + else: + device = form.cleaned_data["device"] + count = len(installed_modules) + if count == 1: + success_message = f"Ein Modul des Typs {form.cleaned_data['module_type']} wurde eingebaut." + else: + success_message = f"{count} Module des Typs {form.cleaned_data['module_type']} wurden eingebaut." + messages.success(request, success_message) + return redirect("dcim:device_modulebays", pk=device.pk) + + selected_device = form.cleaned_data.get("device") if hasattr(form, "cleaned_data") else None + if selected_device is None: + selected_device = self._get_device(request.POST.get("device"), request.user) + return self._render(request, form, selected_device) + + @staticmethod + def _get_device(device_id, user): + if not device_id: + return None + return Device.objects.restrict(user, "view").filter(pk=device_id).first() + + def _render(self, request, form, device): + if device: + return_url = reverse("dcim:device_modulebays", kwargs={"pk": device.pk}) + else: + return_url = reverse("dcim:module_list") + return render( + request, + self.template_name, + { + "form": form, + "return_url": return_url, + }, + ) + + class NavigationPreferencesView(LoginRequiredMixin, View): template_name = "netbox_utilities/navigation_preferences.html" diff --git a/pyproject.toml b/pyproject.toml index c3d0584..a90fae6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "hatchling.build" [project] name = "netbox-utilities" -version = "0.2.0" -description = "Personal navigation and a global tenant filter for NetBox 4.6" +version = "0.3.0" +description = "Personal navigation, global tenant filtering, and bulk module installation for NetBox 4.6" readme = "README.md" requires-python = ">=3.12" license = { text = "MIT" }