feat: prefilter the B side of a cable by tenant group, tenant and site

Add three optional filter fields above the B-side termination selection.
They are never saved on the cable and can be cleared, so cabling across
tenant or site boundaries stays possible. Defaults come from the A side
or the global tenant filter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 11:27:03 +02:00
co-authored by Claude Opus 5
parent ca1e38a1da
commit ad4085a2db
6 changed files with 386 additions and 3 deletions
+6 -1
View File
@@ -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
+203
View File
@@ -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)
@@ -33,6 +33,15 @@
<h2 class="col-9 offset-3">{% trans "B Side" %}</h2>
</div>
{% 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 %}
@@ -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)