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
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
- NetBox `>=4.6.5,<4.7`
+1 -1
View File
@@ -1,6 +1,6 @@
from netbox.plugins import PluginConfig, get_plugin_config
__version__ = "0.10.2"
__version__ = "0.10.3"
class NetBoxUtilitiesConfig(PluginConfig):
+20 -7
View File
@@ -1,29 +1,30 @@
from copy import deepcopy
from functools import wraps
from ipam.models import VLAN
from utilities.forms.fields import DynamicModelMultipleChoiceField
from ipam.models import VLAN, VLANGroup
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.rendering import FieldSet
from .models import CableVLANAssignment, WirelessLinkVLANAssignment
FORM_FIELD = "utilities_vlans"
GROUP_FILTER_FIELD = "utilities_vlan_group"
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"),)
return (FieldSet(GROUP_FILTER_FIELD, 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:
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)
if FORM_FIELD not in items:
items.insert(index, FORM_FIELD)
items[index:index] = (GROUP_FILTER_FIELD, FORM_FIELD)
group.items = tuple(items)
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):
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):
@@ -42,10 +43,18 @@ def _build_connection_vlan_form(base_form, assignment_model, target_field):
return 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(
queryset=VLAN.objects.all(),
required=False,
selector=True,
query_params={"group_id": f"${GROUP_FILTER_FIELD}"},
label="VLANs der Verbindung",
help_text=(
"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()
)
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):
connection = super().save(commit=commit)
@@ -45,6 +45,9 @@
{% if 'b_terminations' in form.fields %}
{% render_field form.b_terminations %}
{% endif %}
{% if form.utilities_vlan_group %}
{% render_field form.utilities_vlan_group %}
{% endif %}
{% if form.utilities_vlans %}
{% render_field form.utilities_vlans %}
{% endif %}
@@ -9,6 +9,7 @@ from django.test import SimpleTestCase
from netbox_utilities.connection_vlans import (
FORM_FIELD,
GROUP_FILTER_FIELD,
_build_connection_vlan_form,
include_connection_vlans_in_fieldsets,
)
@@ -22,11 +23,14 @@ class ConnectionVLANTest(SimpleTestCase):
template = template_path.read_text(encoding="utf-8")
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 %}")
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.assertEqual(template.count("{% render_field form.utilities_vlan_group %}"), 1)
self.assertEqual(template.count("{% render_field form.utilities_vlans %}"), 1)
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)
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"))
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)
self.assertIn(GROUP_FILTER_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.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.assertEqual(
WirelessLinkEditView.form.base_fields[FORM_FIELD].query_params,
{"group_id": f"${GROUP_FILTER_FIELD}"},
)
@patch("netbox_utilities.connection_vlans.CableVLANAssignment.objects")
def test_form_loads_and_saves_multiple_vlans(self, assignment_objects):
@@ -78,8 +92,8 @@ class ConnectionVLANTest(SimpleTestCase):
model = Cable
fields = ()
vlan_a = SimpleNamespace(pk=10)
vlan_b = SimpleNamespace(pk=20)
vlan_a = SimpleNamespace(pk=10, group_id=5)
vlan_b = SimpleNamespace(pk=20, group_id=5)
assignment = MagicMock()
assignment.vlans.all.return_value = [vlan_a, vlan_b]
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)
self.assertEqual(form.initial[FORM_FIELD], [vlan_a, vlan_b])
self.assertEqual(form.initial[GROUP_FILTER_FIELD], 5)
form._errors = ErrorDict()
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(context["reorder_devices"][0]["label"], "LEO-Fritzbox")
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)
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.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(50% + 3px)!important", html)
self.assertIn("width:calc(50% - 6px)!important", html)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
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"
readme = "README.md"
requires-python = ">=3.12"