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
|
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,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):
|
||||||
|
|||||||
@@ -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]}
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user