diff --git a/README.md b/README.md index 5561b10..fa9b06e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # NetBox Utilities -Plugin für **NetBox 4.6.5 bis 4.6.8** mit zwölf Funktionen: +Plugin für **NetBox 4.6.5 bis 4.6.8** mit dreizehn Funktionen: - Jeder Benutzer kann die Menüs der linken Navigation verschieben oder ausblenden. - Ein Dropdown in der Kopfleiste setzt einen sitzungsweiten Filter für einen Mandanten oder eine Mandantengruppe. @@ -14,6 +14,7 @@ Plugin für **NetBox 4.6.5 bis 4.6.8** mit zwölf Funktionen: - Optionale Mehrfachspeicherung für verschobene Geräte aus NetBox Reorder Rack. - Rackbreiten bleiben beim optionalen NetBox-Export und -Import erhalten. - Kabel- und Funkverbindungen können direkt einem oder mehreren VLANs zugeordnet werden. +- Die Geräteauswahl der B-Seite einer Verkabelung lässt sich nach Mandantengruppe, Mandant und Standort vorfiltern. Die Navigationseinstellungen sind benutzerbezogen. Die aktive Mandanten- oder Gruppenauswahl wird in der jeweiligen Browser-Session gespeichert. @@ -35,6 +36,11 @@ Portkennung unabhängig von zusätzlichen Bezeichnungen wie `LC`, `Front` oder `Rear`. Damit werden auch kleinere LC/LC-Patchpanels, etwa mit sechs Ports, fortlaufend eins-zu-eins zugeordnet. +Ab Version `0.13.0` besitzt die **B-Seite** einer Verkabelung drei optionale +Filterfelder für Mandantengruppe, Mandant und Standort. Sie schränken die +Auswahlliste des B-seitigen Geräts ein und sind mit dem Mandanten der A-Seite +beziehungsweise dem globalen Mandantenfilter vorbelegt. + ## Kompatibilität - NetBox `>=4.6.5,<4.7` @@ -93,6 +99,7 @@ PLUGINS_CONFIG = { "netbox_utilities": { "navigation_customization_enabled": True, "connection_vlans_enabled": True, + "connection_tenant_filter_enabled": True, "reorder_rack_bulk_save_enabled": True, "topology_views_rack_width_enabled": True, "tenant_filter_enabled": True, @@ -241,6 +248,34 @@ empfohlen. ## Verwendung +### Vorfilter für die B-Seite einer Verkabelung + +Beim Anlegen und Bearbeiten eines Kabels erscheint im Abschnitt **B-Seite** +oberhalb der Geräteauswahl je ein optionales Feld für **Mandantengruppe**, +**Mandant** und **Standort**. Die Felder filtern die Auswahllisten der +B-Seite live mit; das gilt für Geräte, Stromverteiler (nur Standort) und +Circuits. + +Die Felder sind vorbelegt: + +- mit Mandant und Standort der bereits gewählten A-Seite, sofern diese + eindeutig sind; +- sonst mit dem Mandanten oder der Mandantengruppe aus dem globalen + Mandantenfilter der Kopfleiste. + +Es handelt sich bewusst um einen reinen **Vorfilter**: Die Werte werden nicht +am Kabel gespeichert, und wer über Mandanten- oder Standortgrenzen hinweg +verkabeln muss, leert das jeweilige Feld einfach wieder. Eine Verkabelung wird +dadurch nie blockiert. + +Zu beachten: Der Mandantenfilter wertet das Feld `tenant` des Geräts aus. +Geräte, deren Mandant faktisch nur über den Standort abgeleitet ist, erscheinen +bei gesetztem Mandantenfilter nicht in der Liste — in diesem Fall ist der +Standortfilter der passende Einstieg. + +Das Feature kann installationsweit mit `connection_tenant_filter_enabled = False` +in `PLUGINS_CONFIG` deaktiviert werden. + ### VLANs direkt an Verbindungen dokumentieren Ab Version `0.10.0` erscheint beim normalen **Anlegen und Bearbeiten** einer diff --git a/netbox_utilities/__init__.py b/netbox_utilities/__init__.py index d89b186..0cbcd87 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.12.2" +__version__ = "0.13.0" class NetBoxUtilitiesConfig(PluginConfig): @@ -15,6 +15,7 @@ class NetBoxUtilitiesConfig(PluginConfig): default_settings = { "navigation_customization_enabled": True, "connection_vlans_enabled": True, + "connection_tenant_filter_enabled": True, "reorder_rack_bulk_save_enabled": True, "topology_views_rack_width_enabled": True, "tenant_filter_enabled": True, @@ -40,6 +41,10 @@ class NetBoxUtilitiesConfig(PluginConfig): from .connection_vlans import install_connection_vlan_support install_connection_vlan_support() + if get_plugin_config("netbox_utilities", "connection_tenant_filter_enabled"): + from .connection_scope import install_connection_scope_filter + + install_connection_scope_filter() if self.apps.is_installed("netbox_export"): from .netbox_export import install_netbox_export_rack_width_support diff --git a/netbox_utilities/connection_scope.py b/netbox_utilities/connection_scope.py new file mode 100644 index 0000000..490bcca --- /dev/null +++ b/netbox_utilities/connection_scope.py @@ -0,0 +1,203 @@ +"""Vorfilter für die B-Seite einer Verkabelung. + +Die Felder sind reine Filterfelder: Sie werden nicht gespeichert und können +vom Benutzer geleert werden, um bewusst über Mandanten- oder Standortgrenzen +hinweg zu verkabeln. +""" + +import json +from functools import wraps + +from dcim.models import Site +from tenancy.models import Tenant, TenantGroup +from utilities.forms.fields import DynamicModelChoiceField + +from .tenant_autofill import _related_objects, tenant_id_from_object +from .tenant_scope import active_tenant_scope + +TENANT_GROUP_FIELD = "utilities_b_tenant_group" +TENANT_FIELD = "utilities_b_tenant" +SITE_FIELD = "utilities_b_site" +FILTER_FIELDS = (TENANT_GROUP_FIELD, TENANT_FIELD, SITE_FIELD) +PATCH_MARKER = "_netbox_utilities_connection_scope_installed" + +A_SIDE_FIELDS = ( + "a_terminations", + "termination_a_device", + "termination_a_powerpanel", + "termination_a_circuit", +) + +# Welche Filter der jeweilige FilterSet des B-seitigen Auswahlfelds kennt. +SCOPED_TERMINATION_FIELDS = { + "termination_b_device": { + "tenant_group_id": TENANT_GROUP_FIELD, + "tenant_id": TENANT_FIELD, + "site_id": SITE_FIELD, + }, + "termination_b_powerpanel": { + "site_id": SITE_FIELD, + }, + "termination_b_circuit": { + "tenant_group_id": TENANT_GROUP_FIELD, + "tenant_id": TENANT_FIELD, + "site_id": SITE_FIELD, + }, +} + +HELP_TEXT = ( + "Nur ein Vorfilter für die Auswahl der B-Seite. Wird nicht gespeichert und " + "kann geleert werden, um jedes Gerät auswählen zu können." +) + + +def build_filter_fields(): + """Return fresh filter field instances for the B side of a connection.""" + return { + TENANT_GROUP_FIELD: DynamicModelChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + selector=True, + label="Mandantengruppe (Filter B-Seite)", + help_text=HELP_TEXT, + ), + TENANT_FIELD: DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + selector=True, + query_params={"group_id": f"${TENANT_GROUP_FIELD}"}, + label="Mandant (Filter B-Seite)", + help_text=HELP_TEXT, + ), + SITE_FIELD: DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + selector=True, + label="Standort (Filter B-Seite)", + help_text=HELP_TEXT, + ), + } + + +def add_query_param(field, name, value): + """Attach a live query parameter to an already instantiated selection field.""" + query_params = dict(getattr(field, "query_params", None) or {}) + query_params[name] = value + field.query_params = query_params + + widget = field.widget + adder = getattr(widget, "add_query_param", None) + if callable(adder): + adder(name, value) + return + widget.attrs[f"data-query-param-{name}"] = json.dumps([value]) + + +def apply_termination_filters(form): + """Wire the filter fields into every B-side selection field of the form.""" + for termination_field, filters in SCOPED_TERMINATION_FIELDS.items(): + field = form.fields.get(termination_field) + if field is None: + continue + for query_param, filter_field in filters.items(): + if filter_field in form.fields: + add_query_param(field, query_param, f"${filter_field}") + + +def _a_side_objects(form): + objects = [] + for field_name in A_SIDE_FIELDS: + objects.extend(_related_objects(form, field_name)) + return objects + + +def infer_filter_defaults(form): + """Derive the tenant/site defaults from the A side or the global tenant filter.""" + defaults = {} + objects = _a_side_objects(form) + + tenant_ids = {tenant_id for obj in objects if (tenant_id := tenant_id_from_object(obj))} + if len(tenant_ids) == 1: + defaults[TENANT_FIELD] = tenant_ids.pop() + + site_ids = {site_id for obj in objects if (site_id := _site_id_from_object(obj))} + if len(site_ids) == 1: + defaults[SITE_FIELD] = site_ids.pop() + + scope = active_tenant_scope.get() + if scope is not None: + if scope.kind == "tenant": + defaults.setdefault(TENANT_FIELD, scope.object_id) + elif scope.kind == "group": + defaults.setdefault(TENANT_GROUP_FIELD, scope.object_id) + + if TENANT_GROUP_FIELD not in defaults and (tenant_id := defaults.get(TENANT_FIELD)): + group_id = Tenant.objects.filter(pk=tenant_id).values_list("group_id", flat=True).first() + if group_id: + defaults[TENANT_GROUP_FIELD] = group_id + return defaults + + +def _site_id_from_object(obj, depth=0): + if obj is None or depth > 3: + return None + if isinstance(obj, Site): + return obj.pk + if getattr(obj, "site_id", None): + return obj.site_id + for relation in ("device", "module", "rack", "location", "power_panel", "circuit"): + parent = getattr(obj, relation, None) + if parent is not None and (site_id := _site_id_from_object(parent, depth + 1)): + return site_id + return None + + +def apply_connection_scope(form): + """Add the B-side filter fields to a connection form and prefill them.""" + for name, field in build_filter_fields().items(): + form.fields.setdefault(name, field) + apply_termination_filters(form) + + if form.is_bound: + return + for name, value in infer_filter_defaults(form).items(): + form.initial.setdefault(name, value) + + +def _build_scoped_form(base_form): + if getattr(base_form, PATCH_MARKER, False): + return base_form + + class ConnectionScopeForm(base_form): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + apply_connection_scope(self) + + ConnectionScopeForm.__name__ = f"ConnectionScope{base_form.__name__}" + ConnectionScopeForm.__qualname__ = ConnectionScopeForm.__name__ + ConnectionScopeForm.__module__ = __name__ + setattr(ConnectionScopeForm, PATCH_MARKER, True) + return ConnectionScopeForm + + +def install_connection_scope_filter(): + """Install the B-side tenant/site prefilter on the cable connect form.""" + import dcim.forms + from dcim.forms import connections + from dcim.views import CableEditView + + if getattr(CableEditView, PATCH_MARKER, False): + return + + native_get_cable_form = dcim.forms.get_cable_form + + @wraps(native_get_cable_form) + def get_scoped_cable_form(*args, **kwargs): + return _build_scoped_form(native_get_cable_form(*args, **kwargs)) + + setattr(get_scoped_cable_form, PATCH_MARKER, True) + dcim.forms.get_cable_form = get_scoped_cable_form + connections.get_cable_form = get_scoped_cable_form + CableEditView.template_name = "netbox_utilities/cable_edit.html" + CableEditView.htmx_template_name = "netbox_utilities/cable_edit_form.html" + setattr(CableEditView, PATCH_MARKER, True) diff --git a/netbox_utilities/templates/netbox_utilities/cable_edit_form.html b/netbox_utilities/templates/netbox_utilities/cable_edit_form.html index 62ba9e3..7fd58e5 100644 --- a/netbox_utilities/templates/netbox_utilities/cable_edit_form.html +++ b/netbox_utilities/templates/netbox_utilities/cable_edit_form.html @@ -33,6 +33,15 @@

{% trans "B Side" %}

{% render_field form.b_terminations_type %} + {% if form.utilities_b_tenant_group %} + {% render_field form.utilities_b_tenant_group %} + {% endif %} + {% if form.utilities_b_tenant %} + {% render_field form.utilities_b_tenant %} + {% endif %} + {% if form.utilities_b_site %} + {% render_field form.utilities_b_site %} + {% endif %} {% if 'termination_b_device' in form.fields %} {% render_field form.termination_b_device %} {% endif %} diff --git a/netbox_utilities/tests/test_connection_scope.py b/netbox_utilities/tests/test_connection_scope.py new file mode 100644 index 0000000..a245753 --- /dev/null +++ b/netbox_utilities/tests/test_connection_scope.py @@ -0,0 +1,131 @@ +from pathlib import Path + +from django import forms +from django.test import SimpleTestCase + +from netbox_utilities.connection_scope import ( + SITE_FIELD, + TENANT_FIELD, + TENANT_GROUP_FIELD, + add_query_param, + apply_connection_scope, + build_filter_fields, +) +from netbox_utilities.tenant_scope import ActiveTenantScope, active_tenant_scope + + +class _Widget(forms.Select): + def __init__(self): + super().__init__() + self.added = {} + + def add_query_param(self, name, value): + self.added[name] = value + + +class _TerminationField(forms.Field): + def __init__(self): + super().__init__(required=False, widget=_Widget()) + self.query_params = {} + + +class _Form(forms.Form): + def __init__(self, termination_fields=(), **kwargs): + super().__init__(**kwargs) + for name in termination_fields: + self.fields[name] = _TerminationField() + + +class ConnectionScopeFieldTest(SimpleTestCase): + def test_filter_fields_are_optional_and_chained(self): + fields = build_filter_fields() + + self.assertEqual(set(fields), {TENANT_GROUP_FIELD, TENANT_FIELD, SITE_FIELD}) + for field in fields.values(): + self.assertFalse(field.required) + self.assertEqual(fields[TENANT_FIELD].query_params, {"group_id": f"${TENANT_GROUP_FIELD}"}) + + def test_add_query_param_updates_field_and_widget(self): + field = _TerminationField() + + add_query_param(field, "tenant_id", f"${TENANT_FIELD}") + + self.assertEqual(field.query_params, {"tenant_id": f"${TENANT_FIELD}"}) + self.assertEqual(field.widget.added, {"tenant_id": f"${TENANT_FIELD}"}) + + def test_device_selection_is_filtered_by_all_three_fields(self): + form = _Form(termination_fields=("termination_b_device",)) + + apply_connection_scope(form) + + self.assertEqual( + form.fields["termination_b_device"].query_params, + { + "tenant_group_id": f"${TENANT_GROUP_FIELD}", + "tenant_id": f"${TENANT_FIELD}", + "site_id": f"${SITE_FIELD}", + }, + ) + + def test_power_panel_selection_is_filtered_by_site_only(self): + form = _Form(termination_fields=("termination_b_powerpanel",)) + + apply_connection_scope(form) + + self.assertEqual(form.fields["termination_b_powerpanel"].query_params, {"site_id": f"${SITE_FIELD}"}) + + def test_a_side_fields_are_never_filtered(self): + form = _Form(termination_fields=("termination_a_device", "termination_b_device")) + + apply_connection_scope(form) + + self.assertEqual(form.fields["termination_a_device"].query_params, {}) + + def test_global_tenant_filter_prefills_the_tenant_field(self): + form = _Form() + token = active_tenant_scope.set(ActiveTenantScope(kind="tenant", object_id=7, tenant_ids=frozenset({7}))) + try: + apply_connection_scope(form) + finally: + active_tenant_scope.reset(token) + + self.assertEqual(form.initial[TENANT_FIELD], 7) + + def test_global_group_filter_prefills_the_group_field(self): + form = _Form() + token = active_tenant_scope.set(ActiveTenantScope(kind="group", object_id=3, tenant_ids=frozenset({7, 8}))) + try: + apply_connection_scope(form) + finally: + active_tenant_scope.reset(token) + + self.assertEqual(form.initial[TENANT_GROUP_FIELD], 3) + self.assertNotIn(TENANT_FIELD, form.initial) + + def test_bound_forms_keep_the_submitted_filter_values(self): + form = _Form(data={TENANT_FIELD: ""}) + token = active_tenant_scope.set(ActiveTenantScope(kind="tenant", object_id=7, tenant_ids=frozenset({7}))) + try: + apply_connection_scope(form) + finally: + active_tenant_scope.reset(token) + + self.assertEqual(form.initial, {}) + + +class ConnectionScopeTemplateTest(SimpleTestCase): + def test_filter_fields_are_rendered_above_the_b_side_device(self): + template_path = Path(__file__).parents[1] / "templates" / "netbox_utilities" / "cable_edit_form.html" + template = template_path.read_text(encoding="utf-8") + + b_side = template.index('{% trans "B Side" %}') + group_filter = template.index(f"{{% render_field form.{TENANT_GROUP_FIELD} %}}") + tenant_filter = template.index(f"{{% render_field form.{TENANT_FIELD} %}}") + site_filter = template.index(f"{{% render_field form.{SITE_FIELD} %}}") + device_field = template.index("{% render_field form.termination_b_device %}") + + self.assertLess(b_side, group_filter) + self.assertLess(group_filter, tenant_filter) + self.assertLess(tenant_filter, site_filter) + self.assertLess(site_filter, device_field) + self.assertNotIn(f"form.{TENANT_FIELD} %}}\n {{% render_field form.termination_a_device", template) diff --git a/pyproject.toml b/pyproject.toml index 89da0be..2ca466c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "netbox-utilities" -version = "0.12.2" +version = "0.13.0" description = "Navigation, tenant utilities, connection VLANs, partial-width racks, and bulk operations for NetBox 4.6/4.7" readme = "README.md" requires-python = ">=3.12"