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
@@ -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)