diff --git a/README.md b/README.md index fa68de0..242ef15 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/netbox_utilities/__init__.py b/netbox_utilities/__init__.py index 305cd9e..fe72679 100644 --- a/netbox_utilities/__init__.py +++ b/netbox_utilities/__init__.py @@ -1,6 +1,6 @@ from netbox.plugins import PluginConfig, get_plugin_config -__version__ = "0.10.2" +__version__ = "0.10.3" class NetBoxUtilitiesConfig(PluginConfig): diff --git a/netbox_utilities/connection_vlans.py b/netbox_utilities/connection_vlans.py index a2e8ac1..ab41269 100644 --- a/netbox_utilities/connection_vlans.py +++ b/netbox_utilities/connection_vlans.py @@ -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) diff --git a/netbox_utilities/templates/netbox_utilities/cable_edit_form.html b/netbox_utilities/templates/netbox_utilities/cable_edit_form.html index e28b983..62ba9e3 100644 --- a/netbox_utilities/templates/netbox_utilities/cable_edit_form.html +++ b/netbox_utilities/templates/netbox_utilities/cable_edit_form.html @@ -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 %} diff --git a/netbox_utilities/tests/test_connection_vlans.py b/netbox_utilities/tests/test_connection_vlans.py index f3cc37c..f0ac1f4 100644 --- a/netbox_utilities/tests/test_connection_vlans.py +++ b/netbox_utilities/tests/test_connection_vlans.py @@ -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]} diff --git a/netbox_utilities/tests/test_reorder_rack.py b/netbox_utilities/tests/test_reorder_rack.py index cf0d12e..7a317e7 100644 --- a/netbox_utilities/tests/test_reorder_rack.py +++ b/netbox_utilities/tests/test_reorder_rack.py @@ -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) diff --git a/netbox_utilities/tests/test_topology_views.py b/netbox_utilities/tests/test_topology_views.py index 9614535..b7e86a0 100644 --- a/netbox_utilities/tests/test_topology_views.py +++ b/netbox_utilities/tests/test_topology_views.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 813b957..26852df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"