feat: filter connection VLANs by group
This commit is contained in:
@@ -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,6 +1,6 @@
|
||||
from netbox.plugins import PluginConfig, get_plugin_config
|
||||
|
||||
__version__ = "0.10.2"
|
||||
__version__ = "0.10.3"
|
||||
|
||||
|
||||
class NetBoxUtilitiesConfig(PluginConfig):
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user