feat: add bulk module installation

This commit is contained in:
2026-07-30 16:00:31 +02:00
parent 35e06ee004
commit 8a86ca28f7
12 changed files with 432 additions and 8 deletions
+24 -2
View File
@@ -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.
+2 -2
View File
@@ -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"
+76
View File
@@ -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)
+85
View File
@@ -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
+6
View File
@@ -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",
+23 -1
View File
@@ -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]
@@ -0,0 +1,36 @@
{% extends 'base/layout.html' %}
{% load form_helpers %}
{% block title %}Module mehrfach einbauen{% endblock %}
{% render_errors form %}
{% block content %}
<form method="post">
{% csrf_token %}
<div class="row mb-3">
<div class="col col-md-8 col-xl-6 offset-md-2 offset-xl-3">
<div class="card">
<h2 class="card-header">Module mehrfach einbauen</h2>
<div class="card-body">
<div class="alert alert-info">
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.
</div>
{% render_form form %}
</div>
</div>
</div>
</div>
<div class="row">
<div class="col col-md-8 col-xl-6 offset-md-2 offset-xl-3 text-end">
<a href="{{ return_url }}" class="btn btn-outline-secondary">Abbrechen</a>
<button type="submit" class="btn btn-primary">
<i class="mdi mdi-package-variant-plus" aria-hidden="true"></i>
Module einbauen
</button>
</div>
</div>
</form>
{% endblock content %}
@@ -0,0 +1,5 @@
<a href="{% url 'plugins:netbox_utilities:bulk_module_install' %}?device={{ object.pk }}"
class="btn btn-primary">
<i class="mdi mdi-package-variant-plus" aria-hidden="true"></i>
Module mehrfach einbauen
</a>
@@ -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,
)
+1
View File
@@ -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"),
+69 -1
View File
@@ -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"
+2 -2
View File
@@ -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" }