diff --git a/README.md b/README.md index 5b3010a..6772d15 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # NetBox Utilities -Plugin für **NetBox 4.6.5 bis 4.6.7** mit elf Funktionen: +Plugin für **NetBox 4.6.5 bis 4.6.7** mit zwölf 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. @@ -13,6 +13,7 @@ Plugin für **NetBox 4.6.5 bis 4.6.7** mit elf Funktionen: - Mehrere Module desselben Typs in einem Schritt in freie Modulschächte einbauen. - Optionale Mehrfachspeicherung für verschobene Geräte aus NetBox Reorder Rack. - Rackbreiten bleiben beim optionalen NetBox-Export und -Import erhalten. +- Kabel- und Funkverbindungen können direkt einem oder mehreren VLANs zugeordnet werden. Die Navigationseinstellungen sind benutzerbezogen. Die aktive Mandanten- oder Gruppenauswahl wird in der jeweiligen Browser-Session gespeichert. @@ -73,6 +74,7 @@ PLUGINS = [ PLUGINS_CONFIG = { "netbox_utilities": { "navigation_customization_enabled": True, + "connection_vlans_enabled": True, "reorder_rack_bulk_save_enabled": True, "topology_views_rack_width_enabled": True, "tenant_filter_enabled": True, @@ -221,6 +223,34 @@ empfohlen. ## Verwendung +### VLANs direkt an Verbindungen dokumentieren + +Ab Version `0.10.0` erscheint beim normalen **Anlegen und Bearbeiten** einer +Kabelverbindung oder Funkverbindung das optionale Feld **VLANs der +Verbindung**. Darin können ein oder mehrere bestehende NetBox-VLANs ausgewählt +werden. Die Auswahl wird außerdem auf der Detailseite der Verbindung +angezeigt. + +Die Zuordnung dokumentiert, welche VLANs über die jeweilige Verbindung +transportiert werden. Sie verändert bewusst nicht automatisch die nativen +Felder **Untagged VLAN** und **Tagged VLANs** der beteiligten Interfaces, da +beide Enden unterschiedliche Interface-Modi besitzen können. Diese bleiben +weiterhin die technische Konfiguration der einzelnen Interfaces. + +Die Funktion gilt für: + +- physische NetBox-Kabel, einschließlich Verbindungen über Patchfelder; +- NetBox-Funkverbindungen (`WirelessLink`). + +Leere Auswahlen erzeugen keinen Zuordnungsdatensatz. Beim Löschen einer +Verbindung wird ihre VLAN-Zuordnung automatisch mit entfernt. Das Feature kann +installationsweit mit `connection_vlans_enabled = False` in `PLUGINS_CONFIG` +deaktiviert werden. + +Ist das optionale NetBox-Export-Plugin installiert, werden diese +VLAN-Zuordnungen als normale Plugin-Datensätze zusammen mit den Verbindungen +und VLANs exportiert und wieder importiert. + ### Mehrere Geräte nebeneinander in derselben HE Auf der normalen Seite zum **Anlegen oder Bearbeiten eines Geräts** stehen diff --git a/netbox_utilities/__init__.py b/netbox_utilities/__init__.py index aefeee3..3f4443e 100644 --- a/netbox_utilities/__init__.py +++ b/netbox_utilities/__init__.py @@ -1,12 +1,12 @@ from netbox.plugins import PluginConfig, get_plugin_config -__version__ = "0.9.11" +__version__ = "0.10.0" class NetBoxUtilitiesConfig(PluginConfig): name = "netbox_utilities" verbose_name = "NetBox Utilities" - description = "Navigation, tenant utilities, partial-width rack devices, bulk uploads, and rack reordering" + description = "Navigation, tenant utilities, connection VLANs, partial-width racks, and bulk operations" version = __version__ author = "LKE" base_url = "utilities" @@ -14,6 +14,7 @@ class NetBoxUtilitiesConfig(PluginConfig): max_version = "4.6.99" default_settings = { "navigation_customization_enabled": True, + "connection_vlans_enabled": True, "reorder_rack_bulk_save_enabled": True, "topology_views_rack_width_enabled": True, "tenant_filter_enabled": True, @@ -34,6 +35,10 @@ class NetBoxUtilitiesConfig(PluginConfig): install_tenant_validation() install_patchpanel_automation() install_rack_width_support() + if get_plugin_config("netbox_utilities", "connection_vlans_enabled"): + from .connection_vlans import install_connection_vlan_support + + install_connection_vlan_support() if self.apps.is_installed("netbox_export"): from .netbox_export import install_netbox_export_rack_width_support diff --git a/netbox_utilities/connection_vlans.py b/netbox_utilities/connection_vlans.py new file mode 100644 index 0000000..a2e8ac1 --- /dev/null +++ b/netbox_utilities/connection_vlans.py @@ -0,0 +1,137 @@ +from copy import deepcopy +from functools import wraps + +from ipam.models import VLAN +from utilities.forms.fields import DynamicModelMultipleChoiceField +from utilities.forms.rendering import FieldSet + +from .models import CableVLANAssignment, WirelessLinkVLANAssignment + +FORM_FIELD = "utilities_vlans" +PATCH_MARKER = "_netbox_utilities_connection_vlans_installed" + + +def include_connection_vlans_in_fieldsets(fieldsets): + """Add the connection VLAN field to an isolated copy of a form layout.""" + if not fieldsets: + return (FieldSet(FORM_FIELD, name="VLANs der Verbindung"),) + + copied_fieldsets = deepcopy(fieldsets) + + def insert_into_link_group(group): + items = list(getattr(group, "items", ())) + if "description" in items or "tags" in items: + index = items.index("tags") if "tags" in items else len(items) + if FORM_FIELD not in items: + items.insert(index, FORM_FIELD) + group.items = tuple(items) + return True + + for item in items: + if any(insert_into_link_group(nested) for nested in getattr(item, "groups", ())): + return True + return False + + if any(insert_into_link_group(fieldset) for fieldset in copied_fieldsets): + return tuple(copied_fieldsets) + return (*copied_fieldsets, FieldSet(FORM_FIELD, name="VLANs der Verbindung")) + + +def _build_connection_vlan_form(base_form, assignment_model, target_field): + if getattr(base_form, PATCH_MARKER, False): + return base_form + + class ConnectionVLANForm(base_form): + utilities_vlans = DynamicModelMultipleChoiceField( + queryset=VLAN.objects.all(), + required=False, + selector=True, + label="VLANs der Verbindung", + help_text=( + "Dokumentiert ein oder mehrere VLANs auf dieser Verbindung. " + "Die Tagged-/Untagged-VLAN-Einstellungen der Interfaces bleiben unverändert." + ), + ) + + @property + def fieldsets(self): + return include_connection_vlans_in_fieldsets(super().fieldsets) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.is_bound and getattr(self.instance, "pk", None): + assignment = ( + assignment_model.objects.filter(**{target_field: self.instance}).prefetch_related("vlans").first() + ) + if assignment is not None: + self.initial[FORM_FIELD] = assignment.vlans.all() + + def save(self, commit=True): + connection = super().save(commit=commit) + if commit: + self._save_connection_vlans(connection) + else: + native_save_m2m = self.save_m2m + + def save_m2m(): + native_save_m2m() + self._save_connection_vlans(connection) + + self.save_m2m = save_m2m + return connection + + def _save_connection_vlans(self, connection): + selected_vlans = list(self.cleaned_data.get(FORM_FIELD) or ()) + lookup = {target_field: connection} + if not selected_vlans: + assignment_model.objects.filter(**lookup).delete() + return + assignment, _created = assignment_model.objects.get_or_create(**lookup) + assignment.vlans.set(selected_vlans) + + ConnectionVLANForm.__name__ = f"ConnectionVLAN{base_form.__name__}" + ConnectionVLANForm.__qualname__ = ConnectionVLANForm.__name__ + ConnectionVLANForm.__module__ = __name__ + setattr(ConnectionVLANForm, PATCH_MARKER, True) + return ConnectionVLANForm + + +def _install_cable_vlan_form(): + import dcim.forms + from dcim.forms import connections + from dcim.views import CableEditView + + if getattr(CableEditView, PATCH_MARKER, False): + return + + native_get_cable_form = dcim.forms.get_cable_form + + @wraps(native_get_cable_form) + def get_connection_vlan_cable_form(*args, **kwargs): + base_form = native_get_cable_form(*args, **kwargs) + return _build_connection_vlan_form(base_form, CableVLANAssignment, "cable") + + setattr(get_connection_vlan_cable_form, PATCH_MARKER, True) + dcim.forms.get_cable_form = get_connection_vlan_cable_form + connections.get_cable_form = get_connection_vlan_cable_form + CableEditView.template_name = "netbox_utilities/cable_edit.html" + CableEditView.htmx_template_name = "netbox_utilities/cable_edit_form.html" + setattr(CableEditView, PATCH_MARKER, True) + + +def _install_wireless_link_vlan_form(): + from wireless.views import WirelessLinkEditView + + if getattr(WirelessLinkEditView, PATCH_MARKER, False): + return + WirelessLinkEditView.form = _build_connection_vlan_form( + WirelessLinkEditView.form, + WirelessLinkVLANAssignment, + "wireless_link", + ) + setattr(WirelessLinkEditView, PATCH_MARKER, True) + + +def install_connection_vlan_support(): + _install_cable_vlan_form() + _install_wireless_link_vlan_form() diff --git a/netbox_utilities/migrations/0008_connection_vlan_assignments.py b/netbox_utilities/migrations/0008_connection_vlan_assignments.py new file mode 100644 index 0000000..8fe8c22 --- /dev/null +++ b/netbox_utilities/migrations/0008_connection_vlan_assignments.py @@ -0,0 +1,73 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("ipam", "0001_squashed"), + ("netbox_utilities", "0007_devicerackplacement"), + ("wireless", "0001_squashed_0008"), + ] + + operations = [ + migrations.CreateModel( + name="CableVLANAssignment", + fields=[ + ( + "id", + models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID"), + ), + ("updated", models.DateTimeField(auto_now=True)), + ( + "cable", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="netbox_utilities_vlan_assignment", + to="dcim.cable", + ), + ), + ( + "vlans", + models.ManyToManyField( + blank=True, + related_name="netbox_utilities_cable_assignments", + to="ipam.vlan", + ), + ), + ], + options={ + "verbose_name": "VLAN-Zuordnung einer Kabelverbindung", + "verbose_name_plural": "VLAN-Zuordnungen von Kabelverbindungen", + }, + ), + migrations.CreateModel( + name="WirelessLinkVLANAssignment", + fields=[ + ( + "id", + models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID"), + ), + ("updated", models.DateTimeField(auto_now=True)), + ( + "vlans", + models.ManyToManyField( + blank=True, + related_name="netbox_utilities_wireless_link_assignments", + to="ipam.vlan", + ), + ), + ( + "wireless_link", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="netbox_utilities_vlan_assignment", + to="wireless.wirelesslink", + ), + ), + ], + options={ + "verbose_name": "VLAN-Zuordnung einer Funkverbindung", + "verbose_name_plural": "VLAN-Zuordnungen von Funkverbindungen", + }, + ), + ] diff --git a/netbox_utilities/models.py b/netbox_utilities/models.py index 5d656c6..c68512b 100644 --- a/netbox_utilities/models.py +++ b/netbox_utilities/models.py @@ -79,3 +79,45 @@ class DeviceRackPlacement(models.Model): def __str__(self): return f"{self.device}: 1/{self.width}, Position {self.horizontal_position}" + + +class CableVLANAssignment(models.Model): + cable = models.OneToOneField( + "dcim.Cable", + on_delete=models.CASCADE, + related_name="netbox_utilities_vlan_assignment", + ) + vlans = models.ManyToManyField( + "ipam.VLAN", + related_name="netbox_utilities_cable_assignments", + blank=True, + ) + updated = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "VLAN-Zuordnung einer Kabelverbindung" + verbose_name_plural = "VLAN-Zuordnungen von Kabelverbindungen" + + def __str__(self): + return f"VLANs für Kabel {self.cable}" + + +class WirelessLinkVLANAssignment(models.Model): + wireless_link = models.OneToOneField( + "wireless.WirelessLink", + on_delete=models.CASCADE, + related_name="netbox_utilities_vlan_assignment", + ) + vlans = models.ManyToManyField( + "ipam.VLAN", + related_name="netbox_utilities_wireless_link_assignments", + blank=True, + ) + updated = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "VLAN-Zuordnung einer Funkverbindung" + verbose_name_plural = "VLAN-Zuordnungen von Funkverbindungen" + + def __str__(self): + return f"VLANs für Funkverbindung {self.wireless_link}" diff --git a/netbox_utilities/runtime.py b/netbox_utilities/runtime.py index 0631b79..c2489f5 100644 --- a/netbox_utilities/runtime.py +++ b/netbox_utilities/runtime.py @@ -11,6 +11,10 @@ def navigation_customization_enabled(): return bool(get_plugin_config("netbox_utilities", "navigation_customization_enabled")) +def connection_vlans_enabled(): + return bool(get_plugin_config("netbox_utilities", "connection_vlans_enabled")) + + def tenant_filter_enabled(): if not get_plugin_config("netbox_utilities", "tenant_filter_enabled"): return False diff --git a/netbox_utilities/template_content.py b/netbox_utilities/template_content.py index d4ef5b1..9cbfad1 100644 --- a/netbox_utilities/template_content.py +++ b/netbox_utilities/template_content.py @@ -5,7 +5,12 @@ from netbox.plugins import PluginTemplateExtension from tenancy.models import Tenant, TenantGroup from . import __version__ -from .models import DeviceRackPlacement, NavigationPreference +from .models import ( + CableVLANAssignment, + DeviceRackPlacement, + NavigationPreference, + WirelessLinkVLANAssignment, +) from .navigation_helpers import ( SIDEBAR_WIDTH_DEFAULT, SIDEBAR_WIDTH_MAX, @@ -14,7 +19,7 @@ from .navigation_helpers import ( normalize_preferences, normalize_sidebar_width, ) -from .runtime import navigation_customization_enabled, tenant_filter_enabled +from .runtime import connection_vlans_enabled, navigation_customization_enabled, tenant_filter_enabled class UtilitiesGlobalContent(PluginTemplateExtension): @@ -112,4 +117,34 @@ class DeviceUtilitiesContent(PluginTemplateExtension): ) -template_extensions = [UtilitiesGlobalContent, DeviceUtilitiesContent] +class ConnectionVLANContent(PluginTemplateExtension): + models = ["dcim.cable", "wireless.wirelesslink"] + + def right_page(self): + if not connection_vlans_enabled(): + return "" + + connection = self.context["object"] + if connection._meta.label_lower == "dcim.cable": + assignment_model = CableVLANAssignment + lookup = {"cable": connection} + else: + assignment_model = WirelessLinkVLANAssignment + lookup = {"wireless_link": connection} + + assignment = assignment_model.objects.filter(**lookup).first() + if assignment is None: + return "" + + from ipam.models import VLAN + + visible_vlans = VLAN.objects.restrict(self.context["request"].user, "view").filter( + pk__in=assignment.vlans.values("pk") + ) + return self.render( + "netbox_utilities/connection_vlans_panel.html", + {"connection_vlans": visible_vlans}, + ) + + +template_extensions = [UtilitiesGlobalContent, DeviceUtilitiesContent, ConnectionVLANContent] diff --git a/netbox_utilities/templates/netbox_utilities/cable_edit.html b/netbox_utilities/templates/netbox_utilities/cable_edit.html new file mode 100644 index 0000000..a839768 --- /dev/null +++ b/netbox_utilities/templates/netbox_utilities/cable_edit.html @@ -0,0 +1,5 @@ +{% extends 'dcim/cable_edit.html' %} + +{% block form %} + {% include 'netbox_utilities/cable_edit_form.html' %} +{% endblock %} diff --git a/netbox_utilities/templates/netbox_utilities/cable_edit_form.html b/netbox_utilities/templates/netbox_utilities/cable_edit_form.html new file mode 100644 index 0000000..ce1af16 --- /dev/null +++ b/netbox_utilities/templates/netbox_utilities/cable_edit_form.html @@ -0,0 +1,12 @@ +{% load form_helpers %} + +{% include 'dcim/htmx/cable_edit.html' %} + +{% if form.utilities_vlans %} +