diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 356f70373..97abd274a 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -1069,6 +1069,10 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet): queryset=Site.objects.all(), method='get_for_site' ) + available_at_site_group = django_filters.ModelChoiceFilter( + queryset=SiteGroup.objects.all(), + method='get_for_site_group' + ) available_on_device = django_filters.ModelChoiceFilter( queryset=Device.objects.select_related('cluster'), method='get_for_device' @@ -1130,6 +1134,10 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet): def get_for_site(self, queryset, name, value): return queryset.get_for_site(value) + @extend_schema_field(OpenApiTypes.STR) + def get_for_site_group(self, queryset, name, value): + return queryset.get_for_site_group(value) + @extend_schema_field(OpenApiTypes.STR) def get_for_device(self, queryset, name, value): return queryset.get_for_device(value) diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index d2f2a2ad6..f6f4a17b9 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -5,7 +5,7 @@ from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from dcim.forms.mixins import ScopedForm -from dcim.models import Device, Interface, Site +from dcim.models import Device, Interface, Site, SiteGroup from ipam.choices import * from ipam.constants import * from ipam.formfields import IPNetworkFormField @@ -245,9 +245,17 @@ class PrefixForm(TenancyForm, ScopedForm, PrimaryModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # #18605: only filter VLAN select list if scope field is a Site + # #18605: only filter VLAN select list if scope field is a Site or Site Group if scope_field := self.fields.get('scope', None): - if scope_field.queryset.model is not Site: + if scope_field.queryset.model is Site: + pass # already filtered by available_at_site + elif scope_field.queryset.model is SiteGroup: + self.fields['vlan'].widget.dynamic_params.clear() + self.fields['vlan'].widget.attrs.pop('data-dynamic-params', None) + self.fields['vlan'].widget.add_query_params({ + 'available_at_site_group': '$scope', + }) + else: self.fields['vlan'].widget.attrs.pop('data-dynamic-params', None) diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py index d58632672..ef546b697 100644 --- a/netbox/ipam/querysets.py +++ b/netbox/ipam/querysets.py @@ -290,6 +290,23 @@ class VLANQuerySet(RestrictedQuerySet): Q(group__isnull=True, site__isnull=True) # Global VLANs ) + def get_for_site_group(self, site_group): + """ + Return all VLANs available to the specified site group. + """ + if site_group is None: + return self.none() + from .models import VLANGroup + q = Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'), + scope_id__in=site_group.get_ancestors(include_self=True) + ) + return self.filter( + Q(group__in=VLANGroup.objects.filter(q)) | + Q(group__scope_id__isnull=True, site__isnull=True) | # Global group VLANs + Q(group__isnull=True, site__isnull=True) # Global VLANs + ) + def get_for_device(self, device): """ Return all VLANs available to the specified Device. diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 3b9288453..7b1fa8339 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -2221,6 +2221,11 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'available_at_site': site_id} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) # 4 scoped + 1 global group + 1 global + def test_available_at_site_group(self): + site_group = SiteGroup.objects.get(name='Site Group 1') + params = {'available_at_site_group': site_group.pk} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) # 1 scoped + 1 global group + 1 global + def test_interface(self): interface_id = Interface.objects.first().pk params = {'interface_id': interface_id} diff --git a/netbox/ipam/tests/test_forms.py b/netbox/ipam/tests/test_forms.py index f6ea0375a..6352effb9 100644 --- a/netbox/ipam/tests/test_forms.py +++ b/netbox/ipam/tests/test_forms.py @@ -29,19 +29,27 @@ class PrefixFormTestCase(TestCase): assert form.fields['vlan'].widget.attrs['data-dynamic-params'] == self.default_dynamic_params + def test_vlan_field_sets_dynamic_params_for_scope_site_group(self): + """data-dynamic-params present with available_at_site_group when scope type is Site Group""" + site_group = SiteGroup.objects.create(name='Site Group 1', slug='site-group-1') + form = PrefixForm(data={ + 'scope_type': ContentType.objects.get_for_model(SiteGroup).id, + 'scope': site_group, + }) + expected = '[{"fieldName":"scope","queryParam":"available_at_site_group"}]' + assert form.fields['vlan'].widget.attrs['data-dynamic-params'] == expected + def test_vlan_field_does_not_set_dynamic_params_for_other_scopes(self): - """data-dynamic-params not present when scope type is populated by is not Site""" + """data-dynamic-params not present when scope type is not Site or Site Group""" cases = [ Region(name='Region 1', slug='region-1'), Location(site=self.site, name='Location 1', slug='location-1'), - SiteGroup(name='Site Group 1', slug='site-group-1'), ] for case in cases: form = PrefixForm(data={ 'scope_type': ContentType.objects.get_for_model(case._meta.model).id, 'scope': case, }) - assert 'data-dynamic-params' not in form.fields['vlan'].widget.attrs