feat: add bulk module installation
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
# NetBox Utilities
|
# 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.
|
- 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.
|
- 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.
|
- 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.
|
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
|
```bash
|
||||||
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
|
/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.
|
Alternativ kann hinter dem `@` die vollständige Commit-ID stehen.
|
||||||
@@ -125,6 +126,27 @@ empfohlen.
|
|||||||
|
|
||||||
## Verwendung
|
## 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
|
### 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.
|
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.
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ from netbox.plugins import PluginConfig
|
|||||||
class NetBoxUtilitiesConfig(PluginConfig):
|
class NetBoxUtilitiesConfig(PluginConfig):
|
||||||
name = "netbox_utilities"
|
name = "netbox_utilities"
|
||||||
verbose_name = "NetBox Utilities"
|
verbose_name = "NetBox Utilities"
|
||||||
description = "Personal navigation and a global tenant filter"
|
description = "Personal navigation, global tenant filtering, and bulk module installation"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
author = "LKE"
|
author = "LKE"
|
||||||
base_url = "utilities"
|
base_url = "utilities"
|
||||||
min_version = "4.6.5"
|
min_version = "4.6.5"
|
||||||
|
|||||||
@@ -1,8 +1,84 @@
|
|||||||
|
from dcim.choices import ModuleStatusChoices
|
||||||
|
from dcim.models import Device, ModuleBay, ModuleType
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||||
|
|
||||||
from .models import UtilitiesSettings
|
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):
|
class UtilitiesSettingsForm(forms.ModelForm):
|
||||||
def __init__(self, *args, tenant_filter_locked=False, tenant_required_locked=False, **kwargs):
|
def __init__(self, *args, tenant_filter_locked=False, tenant_required_locked=False, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
from netbox.plugins import PluginMenuItem
|
from netbox.plugins import PluginMenuItem
|
||||||
|
|
||||||
menu_items = (
|
menu_items = (
|
||||||
|
PluginMenuItem(
|
||||||
|
link="plugins:netbox_utilities:bulk_module_install",
|
||||||
|
link_text="Module mehrfach einbauen",
|
||||||
|
auth_required=True,
|
||||||
|
permissions=("dcim.add_module",),
|
||||||
|
),
|
||||||
PluginMenuItem(
|
PluginMenuItem(
|
||||||
link="plugins:netbox_utilities:navigation_preferences",
|
link="plugins:netbox_utilities:navigation_preferences",
|
||||||
link_text="Navigation personalisieren",
|
link_text="Navigation personalisieren",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from dcim.models import ModuleBay
|
||||||
from netbox.plugins import PluginTemplateExtension
|
from netbox.plugins import PluginTemplateExtension
|
||||||
from tenancy.models import Tenant, TenantGroup
|
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,
|
||||||
|
)
|
||||||
@@ -5,6 +5,7 @@ from . import views
|
|||||||
app_name = "netbox_utilities"
|
app_name = "netbox_utilities"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path("modules/bulk-install/", views.BulkModuleInstallView.as_view(), name="bulk_module_install"),
|
||||||
path("navigation/", views.NavigationPreferencesView.as_view(), name="navigation_preferences"),
|
path("navigation/", views.NavigationPreferencesView.as_view(), name="navigation_preferences"),
|
||||||
path("settings/", views.UtilitiesSettingsView.as_view(), name="settings"),
|
path("settings/", views.UtilitiesSettingsView.as_view(), name="settings"),
|
||||||
path("tenant/select/", views.SelectTenantView.as_view(), name="select_tenant"),
|
path("tenant/select/", views.SelectTenantView.as_view(), name="select_tenant"),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from dcim.models import Device, Module
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||||
from django.http import HttpResponseBadRequest
|
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 django.views import View
|
||||||
from netbox.plugins import get_plugin_config
|
from netbox.plugins import get_plugin_config
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
|
from utilities.views import ContentTypePermissionRequiredMixin
|
||||||
|
|
||||||
from .forms import UtilitiesSettingsForm
|
from .forms import BulkModuleInstallForm, UtilitiesSettingsForm
|
||||||
from .middleware import (
|
from .middleware import (
|
||||||
SESSION_TENANT_GROUP_KEY,
|
SESSION_TENANT_GROUP_KEY,
|
||||||
SESSION_TENANT_KEY,
|
SESSION_TENANT_KEY,
|
||||||
GlobalTenantFilterMiddleware,
|
GlobalTenantFilterMiddleware,
|
||||||
)
|
)
|
||||||
from .models import NavigationPreference, UtilitiesSettings
|
from .models import NavigationPreference, UtilitiesSettings
|
||||||
|
from .module_installation import BulkModuleInstallError, install_modules
|
||||||
from .navigation_helpers import get_visible_menus, normalize_preferences
|
from .navigation_helpers import get_visible_menus, normalize_preferences
|
||||||
from .runtime import (
|
from .runtime import (
|
||||||
clear_runtime_settings_cache,
|
clear_runtime_settings_cache,
|
||||||
@@ -34,6 +37,71 @@ def _safe_return_url(request, default_name="home"):
|
|||||||
return reverse(default_name)
|
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):
|
class NavigationPreferencesView(LoginRequiredMixin, View):
|
||||||
template_name = "netbox_utilities/navigation_preferences.html"
|
template_name = "netbox_utilities/navigation_preferences.html"
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "netbox-utilities"
|
name = "netbox-utilities"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
description = "Personal navigation and a global tenant filter for NetBox 4.6"
|
description = "Personal navigation, global tenant filtering, and bulk module installation for NetBox 4.6"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
|
|||||||
Reference in New Issue
Block a user