feat: filter connection VLANs by group

This commit is contained in:
2026-08-25 12:11:18 +02:00
parent d245ae0f79
commit f3d658b52b
8 changed files with 50 additions and 15 deletions
+4
View File
@@ -26,6 +26,10 @@ Ab Version `0.10.2` befindet sich die VLAN-Auswahl beim Anlegen und Bearbeiten
eines Kabels direkt im Abschnitt **B-Seite**, unterhalb des B-seitigen eines Kabels direkt im Abschnitt **B-Seite**, unterhalb des B-seitigen
Verbindungsendes. Verbindungsendes.
Ab Version `0.10.3` kann dort optional eine VLAN-Gruppe ausgewählt werden. Sie
dient als dynamischer Filter für die VLAN-Mehrfachauswahl und wird nicht als
zusätzliche Eigenschaft der Verbindung gespeichert.
## Kompatibilität ## Kompatibilität
- NetBox `>=4.6.5,<4.7` - NetBox `>=4.6.5,<4.7`
+1 -1
View File
@@ -1,6 +1,6 @@
from netbox.plugins import PluginConfig, get_plugin_config from netbox.plugins import PluginConfig, get_plugin_config
__version__ = "0.10.2" __version__ = "0.10.3"
class NetBoxUtilitiesConfig(PluginConfig): class NetBoxUtilitiesConfig(PluginConfig):
+20 -7
View File
@@ -1,29 +1,30 @@
from copy import deepcopy from copy import deepcopy
from functools import wraps from functools import wraps
from ipam.models import VLAN from ipam.models import VLAN, VLANGroup
from utilities.forms.fields import DynamicModelMultipleChoiceField from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
from .models import CableVLANAssignment, WirelessLinkVLANAssignment from .models import CableVLANAssignment, WirelessLinkVLANAssignment
FORM_FIELD = "utilities_vlans" FORM_FIELD = "utilities_vlans"
GROUP_FILTER_FIELD = "utilities_vlan_group"
PATCH_MARKER = "_netbox_utilities_connection_vlans_installed" PATCH_MARKER = "_netbox_utilities_connection_vlans_installed"
def include_connection_vlans_in_fieldsets(fieldsets): def include_connection_vlans_in_fieldsets(fieldsets):
"""Add the connection VLAN field to an isolated copy of a form layout.""" """Add the connection VLAN field to an isolated copy of a form layout."""
if not fieldsets: if not fieldsets:
return (FieldSet(FORM_FIELD, name="VLANs der Verbindung"),) return (FieldSet(GROUP_FILTER_FIELD, FORM_FIELD, name="VLANs der Verbindung"),)
copied_fieldsets = deepcopy(fieldsets) copied_fieldsets = deepcopy(fieldsets)
def insert_into_link_group(group): def insert_into_link_group(group):
items = list(getattr(group, "items", ())) items = list(getattr(group, "items", ()))
if "description" in items or "tags" in items: if "description" in items or "tags" in items:
items = [item for item in items if item not in (GROUP_FILTER_FIELD, FORM_FIELD)]
index = items.index("tags") if "tags" in items else len(items) index = items.index("tags") if "tags" in items else len(items)
if FORM_FIELD not in items: items[index:index] = (GROUP_FILTER_FIELD, FORM_FIELD)
items.insert(index, FORM_FIELD)
group.items = tuple(items) group.items = tuple(items)
return True return True
@@ -34,7 +35,7 @@ def include_connection_vlans_in_fieldsets(fieldsets):
if any(insert_into_link_group(fieldset) for fieldset in copied_fieldsets): if any(insert_into_link_group(fieldset) for fieldset in copied_fieldsets):
return tuple(copied_fieldsets) return tuple(copied_fieldsets)
return (*copied_fieldsets, FieldSet(FORM_FIELD, name="VLANs der Verbindung")) return (*copied_fieldsets, FieldSet(GROUP_FILTER_FIELD, FORM_FIELD, name="VLANs der Verbindung"))
def _build_connection_vlan_form(base_form, assignment_model, target_field): def _build_connection_vlan_form(base_form, assignment_model, target_field):
@@ -42,10 +43,18 @@ def _build_connection_vlan_form(base_form, assignment_model, target_field):
return base_form return base_form
class ConnectionVLANForm(base_form): class ConnectionVLANForm(base_form):
utilities_vlan_group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),
required=False,
selector=True,
label="VLAN-Gruppe (Filter)",
help_text="Optional: Schränkt die nachfolgende VLAN-Auswahl auf diese Gruppe ein.",
)
utilities_vlans = DynamicModelMultipleChoiceField( utilities_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
required=False, required=False,
selector=True, selector=True,
query_params={"group_id": f"${GROUP_FILTER_FIELD}"},
label="VLANs der Verbindung", label="VLANs der Verbindung",
help_text=( help_text=(
"Dokumentiert ein oder mehrere VLANs auf dieser Verbindung. " "Dokumentiert ein oder mehrere VLANs auf dieser Verbindung. "
@@ -64,7 +73,11 @@ def _build_connection_vlan_form(base_form, assignment_model, target_field):
assignment_model.objects.filter(**{target_field: self.instance}).prefetch_related("vlans").first() assignment_model.objects.filter(**{target_field: self.instance}).prefetch_related("vlans").first()
) )
if assignment is not None: if assignment is not None:
self.initial[FORM_FIELD] = assignment.vlans.all() selected_vlans = list(assignment.vlans.all())
self.initial[FORM_FIELD] = selected_vlans
group_ids = {vlan.group_id for vlan in selected_vlans if getattr(vlan, "group_id", None)}
if len(group_ids) == 1:
self.initial[GROUP_FILTER_FIELD] = group_ids.pop()
def save(self, commit=True): def save(self, commit=True):
connection = super().save(commit=commit) connection = super().save(commit=commit)
@@ -45,6 +45,9 @@
{% if 'b_terminations' in form.fields %} {% if 'b_terminations' in form.fields %}
{% render_field form.b_terminations %} {% render_field form.b_terminations %}
{% endif %} {% endif %}
{% if form.utilities_vlan_group %}
{% render_field form.utilities_vlan_group %}
{% endif %}
{% if form.utilities_vlans %} {% if form.utilities_vlans %}
{% render_field form.utilities_vlans %} {% render_field form.utilities_vlans %}
{% endif %} {% endif %}
@@ -9,6 +9,7 @@ from django.test import SimpleTestCase
from netbox_utilities.connection_vlans import ( from netbox_utilities.connection_vlans import (
FORM_FIELD, FORM_FIELD,
GROUP_FILTER_FIELD,
_build_connection_vlan_form, _build_connection_vlan_form,
include_connection_vlans_in_fieldsets, include_connection_vlans_in_fieldsets,
) )
@@ -22,11 +23,14 @@ class ConnectionVLANTest(SimpleTestCase):
template = template_path.read_text(encoding="utf-8") template = template_path.read_text(encoding="utf-8")
b_side = template.index('{% trans "B Side" %}') b_side = template.index('{% trans "B Side" %}')
group_filter = template.index("{% render_field form.utilities_vlan_group %}")
vlan_field = template.index("{% render_field form.utilities_vlans %}") vlan_field = template.index("{% render_field form.utilities_vlans %}")
cable_section = template.index('{% trans "Cable" %}') cable_section = template.index('{% trans "Cable" %}')
self.assertLess(b_side, vlan_field) self.assertLess(b_side, group_filter)
self.assertLess(group_filter, vlan_field)
self.assertLess(vlan_field, cable_section) self.assertLess(vlan_field, cable_section)
self.assertEqual(template.count("{% render_field form.utilities_vlan_group %}"), 1)
self.assertEqual(template.count("{% render_field form.utilities_vlans %}"), 1) self.assertEqual(template.count("{% render_field form.utilities_vlans %}"), 1)
def test_assignment_models_are_tied_to_their_connection_and_vlans(self): def test_assignment_models_are_tied_to_their_connection_and_vlans(self):
@@ -50,7 +54,10 @@ class ConnectionVLANTest(SimpleTestCase):
extended = include_connection_vlans_in_fieldsets(original) extended = include_connection_vlans_in_fieldsets(original)
self.assertEqual(extended[0].items, ("status", "description", FORM_FIELD, "tags")) self.assertEqual(
extended[0].items,
("status", "description", GROUP_FILTER_FIELD, FORM_FIELD, "tags"),
)
self.assertEqual(original[0].items, ("status", "description", "tags")) self.assertEqual(original[0].items, ("status", "description", "tags"))
def test_resolved_edit_views_expose_connection_vlan_field(self): def test_resolved_edit_views_expose_connection_vlan_field(self):
@@ -61,10 +68,17 @@ class ConnectionVLANTest(SimpleTestCase):
cable_form = dcim.forms.get_cable_form(Interface, Interface) cable_form = dcim.forms.get_cable_form(Interface, Interface)
self.assertIn(GROUP_FILTER_FIELD, cable_form.base_fields)
self.assertIn(FORM_FIELD, cable_form.base_fields) self.assertIn(FORM_FIELD, cable_form.base_fields)
self.assertEqual(cable_form.base_fields[FORM_FIELD].query_params, {"group_id": f"${GROUP_FILTER_FIELD}"})
self.assertEqual(CableEditView.template_name, "netbox_utilities/cable_edit.html") self.assertEqual(CableEditView.template_name, "netbox_utilities/cable_edit.html")
self.assertEqual(CableEditView.htmx_template_name, "netbox_utilities/cable_edit_form.html") self.assertEqual(CableEditView.htmx_template_name, "netbox_utilities/cable_edit_form.html")
self.assertIn(GROUP_FILTER_FIELD, WirelessLinkEditView.form.base_fields)
self.assertIn(FORM_FIELD, WirelessLinkEditView.form.base_fields) self.assertIn(FORM_FIELD, WirelessLinkEditView.form.base_fields)
self.assertEqual(
WirelessLinkEditView.form.base_fields[FORM_FIELD].query_params,
{"group_id": f"${GROUP_FILTER_FIELD}"},
)
@patch("netbox_utilities.connection_vlans.CableVLANAssignment.objects") @patch("netbox_utilities.connection_vlans.CableVLANAssignment.objects")
def test_form_loads_and_saves_multiple_vlans(self, assignment_objects): def test_form_loads_and_saves_multiple_vlans(self, assignment_objects):
@@ -78,8 +92,8 @@ class ConnectionVLANTest(SimpleTestCase):
model = Cable model = Cable
fields = () fields = ()
vlan_a = SimpleNamespace(pk=10) vlan_a = SimpleNamespace(pk=10, group_id=5)
vlan_b = SimpleNamespace(pk=20) vlan_b = SimpleNamespace(pk=20, group_id=5)
assignment = MagicMock() assignment = MagicMock()
assignment.vlans.all.return_value = [vlan_a, vlan_b] assignment.vlans.all.return_value = [vlan_a, vlan_b]
assignment_objects.filter.return_value.prefetch_related.return_value.first.return_value = assignment assignment_objects.filter.return_value.prefetch_related.return_value.first.return_value = assignment
@@ -93,6 +107,7 @@ class ConnectionVLANTest(SimpleTestCase):
form = form_class(instance=cable) form = form_class(instance=cable)
self.assertEqual(form.initial[FORM_FIELD], [vlan_a, vlan_b]) self.assertEqual(form.initial[FORM_FIELD], [vlan_a, vlan_b])
self.assertEqual(form.initial[GROUP_FILTER_FIELD], 5)
form._errors = ErrorDict() form._errors = ErrorDict()
form.cleaned_data = {FORM_FIELD: [vlan_a, vlan_b]} form.cleaned_data = {FORM_FIELD: [vlan_a, vlan_b]}
+1 -1
View File
@@ -449,7 +449,7 @@ class ReorderRackFrontendTest(SimpleTestCase):
self.assertEqual(template_name, "netbox_utilities/reorder_rack.html") self.assertEqual(template_name, "netbox_utilities/reorder_rack.html")
self.assertEqual(context["reorder_devices"][0]["label"], "LEO-Fritzbox") self.assertEqual(context["reorder_devices"][0]["label"], "LEO-Fritzbox")
self.assertEqual(context["reorder_devices"][0]["grid_width"], 6) self.assertEqual(context["reorder_devices"][0]["grid_width"], 6)
self.assertEqual(context["asset_version"], "0.10.2") self.assertEqual(context["asset_version"], "0.10.3")
self.assertIs(context["reorder_rack_width_data"], get_width_data.return_value) self.assertIs(context["reorder_rack_width_data"], get_width_data.return_value)
get_width_data.assert_called_once() get_width_data.assert_called_once()
self.assertIs(get_width_data.call_args.kwargs["rack"], rack) 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('id="netbox-utilities-topology-rack-width-styles"', html)
self.assertIn('.rack-device[href="/dcim/devices/334/"]', html) self.assertIn('.rack-device[href="/dcim/devices/334/"]', html)
self.assertIn("left: calc(50% + 3px) !important", html) self.assertIn("left: calc(50% + 3px) !important", html)
self.assertIn("netbox_utilities/topology-rack-width.js?v=0.10.2", html) self.assertIn("netbox_utilities/topology-rack-width.js?v=0.10.3", html)
self.assertIn("left:calc(0% + 3px)!important", html) self.assertIn("left:calc(0% + 3px)!important", html)
self.assertIn("left:calc(50% + 3px)!important", html) self.assertIn("left:calc(50% + 3px)!important", html)
self.assertIn("width:calc(50% - 6px)!important", html) self.assertIn("width:calc(50% - 6px)!important", html)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "netbox-utilities" name = "netbox-utilities"
version = "0.10.2" version = "0.10.3"
description = "Navigation, tenant utilities, connection VLANs, partial-width racks, and bulk operations for NetBox 4.6" description = "Navigation, tenant utilities, connection VLANs, partial-width racks, and bulk operations for NetBox 4.6"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"