feat: assign VLANs to network connections

This commit is contained in:
2026-08-19 12:24:40 +02:00
parent 1c6708216c
commit bd748ef985
15 changed files with 488 additions and 10 deletions
+7 -2
View File
@@ -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
+137
View File
@@ -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()
@@ -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",
},
),
]
+42
View File
@@ -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}"
+4
View File
@@ -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
+38 -3
View File
@@ -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]
@@ -0,0 +1,5 @@
{% extends 'dcim/cable_edit.html' %}
{% block form %}
{% include 'netbox_utilities/cable_edit_form.html' %}
{% endblock %}
@@ -0,0 +1,12 @@
{% load form_helpers %}
{% include 'dcim/htmx/cable_edit.html' %}
{% if form.utilities_vlans %}
<div class="field-group mb-5">
<div class="row">
<h2 class="col-9 offset-3">VLANs der Verbindung</h2>
</div>
{% render_field form.utilities_vlans %}
</div>
{% endif %}
@@ -0,0 +1,12 @@
<div class="card">
<h2 class="card-header">VLANs der Verbindung</h2>
<div class="card-body">
{% if connection_vlans %}
{% for vlan in connection_vlans %}
<a href="{{ vlan.get_absolute_url }}" class="badge text-bg-primary me-1 mb-1">{{ vlan }}</a>
{% endfor %}
{% else %}
<span class="text-muted">Keine VLANs zugeordnet</span>
{% endif %}
</div>
</div>
@@ -0,0 +1,115 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from django import forms
from django.forms.utils import ErrorDict
from django.test import SimpleTestCase
from netbox_utilities.connection_vlans import (
FORM_FIELD,
_build_connection_vlan_form,
include_connection_vlans_in_fieldsets,
)
from netbox_utilities.models import CableVLANAssignment, WirelessLinkVLANAssignment
class ConnectionVLANTest(SimpleTestCase):
def test_assignment_models_are_tied_to_their_connection_and_vlans(self):
self.assertEqual(
CableVLANAssignment._meta.get_field("cable").remote_field.model._meta.label_lower, "dcim.cable"
)
self.assertEqual(
WirelessLinkVLANAssignment._meta.get_field("wireless_link").remote_field.model._meta.label_lower,
"wireless.wirelesslink",
)
self.assertEqual(CableVLANAssignment._meta.get_field("vlans").remote_field.model._meta.label_lower, "ipam.vlan")
self.assertEqual(
WirelessLinkVLANAssignment._meta.get_field("vlans").remote_field.model._meta.label_lower,
"ipam.vlan",
)
def test_connection_vlan_field_is_inserted_into_link_fieldset(self):
from utilities.forms.rendering import FieldSet
original = (FieldSet("status", "description", "tags", name="Link"),)
extended = include_connection_vlans_in_fieldsets(original)
self.assertEqual(extended[0].items, ("status", "description", FORM_FIELD, "tags"))
self.assertEqual(original[0].items, ("status", "description", "tags"))
def test_resolved_edit_views_expose_connection_vlan_field(self):
import dcim.forms
from dcim.models import Interface
from dcim.views import CableEditView
from wireless.views import WirelessLinkEditView
cable_form = dcim.forms.get_cable_form(Interface, Interface)
self.assertIn(FORM_FIELD, cable_form.base_fields)
self.assertEqual(CableEditView.template_name, "netbox_utilities/cable_edit.html")
self.assertEqual(CableEditView.htmx_template_name, "netbox_utilities/cable_edit_form.html")
self.assertIn(FORM_FIELD, WirelessLinkEditView.form.base_fields)
@patch("netbox_utilities.connection_vlans.CableVLANAssignment.objects")
def test_form_loads_and_saves_multiple_vlans(self, assignment_objects):
from dcim.models import Cable
from utilities.forms.rendering import FieldSet
class BareCableForm(forms.ModelForm):
fieldsets = (FieldSet("description", name="Cable"),)
class Meta:
model = Cable
fields = ()
vlan_a = SimpleNamespace(pk=10)
vlan_b = SimpleNamespace(pk=20)
assignment = MagicMock()
assignment.vlans.all.return_value = [vlan_a, vlan_b]
assignment_objects.filter.return_value.prefetch_related.return_value.first.return_value = assignment
assignment_objects.get_or_create.return_value = (assignment, False)
cable = Cable(pk=7)
form_class = _build_connection_vlan_form(BareCableForm, CableVLANAssignment, "cable")
with patch(
"netbox_utilities.runtime._get_database_settings",
return_value={"tenant_required": True},
):
form = form_class(instance=cable)
self.assertEqual(form.initial[FORM_FIELD], [vlan_a, vlan_b])
form._errors = ErrorDict()
form.cleaned_data = {FORM_FIELD: [vlan_a, vlan_b]}
with patch.object(Cable, "save"):
self.assertIs(form.save(), cable)
assignment_objects.get_or_create.assert_called_once_with(cable=cable)
assignment.vlans.set.assert_called_once_with([vlan_a, vlan_b])
@patch("netbox_utilities.connection_vlans.CableVLANAssignment.objects")
def test_empty_form_selection_removes_assignment(self, assignment_objects):
from dcim.models import Cable
class BareCableForm(forms.ModelForm):
fieldsets = ()
class Meta:
model = Cable
fields = ()
cable = Cable(pk=8)
form_class = _build_connection_vlan_form(BareCableForm, CableVLANAssignment, "cable")
with patch(
"netbox_utilities.runtime._get_database_settings",
return_value={"tenant_required": True},
):
form = form_class(instance=cable)
form._errors = ErrorDict()
form.cleaned_data = {FORM_FIELD: []}
with patch.object(Cable, "save"):
form.save()
assignment_objects.filter.assert_called_with(cable=cable)
assignment_objects.filter.return_value.delete.assert_called_once_with()
assignment_objects.get_or_create.assert_not_called()
@@ -62,6 +62,14 @@ class FakeDevice:
class NetBoxExportRackWidthTest(SimpleTestCase):
def test_connection_vlan_assignments_remain_exportable(self):
from netbox_export.services.graph import is_exportable_model
from netbox_utilities.models import CableVLANAssignment, WirelessLinkVLANAssignment
self.assertTrue(is_exportable_model(CableVLANAssignment))
self.assertTrue(is_exportable_model(WirelessLinkVLANAssignment))
def test_export_embeds_partial_and_full_rack_widths_on_devices(self):
partial = FakeDevice(1, width=2, horizontal_position=2)
full = FakeDevice(2)
+1 -1
View File
@@ -449,7 +449,7 @@ class ReorderRackFrontendTest(SimpleTestCase):
self.assertEqual(template_name, "netbox_utilities/reorder_rack.html")
self.assertEqual(context["reorder_devices"][0]["label"], "LEO-Fritzbox")
self.assertEqual(context["reorder_devices"][0]["grid_width"], 6)
self.assertEqual(context["asset_version"], "0.9.11")
self.assertEqual(context["asset_version"], "0.10.0")
self.assertIs(context["reorder_rack_width_data"], get_width_data.return_value)
get_width_data.assert_called_once()
self.assertIs(get_width_data.call_args.kwargs["rack"], rack)
@@ -246,7 +246,7 @@ class TopologyViewsRackWidthTest(SimpleTestCase):
self.assertIn('id="netbox-utilities-topology-rack-width-styles"', html)
self.assertIn('.rack-device[href="/dcim/devices/334/"]', html)
self.assertIn("left: calc(50% + 3px) !important", html)
self.assertIn("netbox_utilities/topology-rack-width.js?v=0.9.11", html)
self.assertIn("netbox_utilities/topology-rack-width.js?v=0.10.0", html)
self.assertIn("left:calc(0% + 3px)!important", html)
self.assertIn("left:calc(50% + 3px)!important", html)
self.assertIn("width:calc(50% - 6px)!important", html)