Procházet zdrojové kódy

Merge pull request #5985 from netbox-community/5284-vlangroup-scope

Closes #5284: Allow VLANGroup assignment beyond sites
Jeremy Stretch před 4 roky
rodič
revize
ee7f7c877a

+ 9 - 0
docs/release-notes/version-2.11.md

@@ -52,6 +52,12 @@ When exporting a list of objects in NetBox, users now have the option of selecti
 
 
 The legacy static export behavior has been retained to ensure backward compatibility for dependent integrations. However, users are strongly encouraged to adapt custom export templates where needed as this functionality will be removed in v2.12.
 The legacy static export behavior has been retained to ensure backward compatibility for dependent integrations. However, users are strongly encouraged to adapt custom export templates where needed as this functionality will be removed in v2.12.
 
 
+#### Variable Scope Support for VLAN Groups ([#5284](https://github.com/netbox-community/netbox/issues/5284))
+
+In previous releases, VLAN groups could be assigned only to a site. To afford more flexibility in conveying the true scope of an L2 domain, a VLAN group can now be assigned to a region, site group (new in v2.11), site, location, or rack. VLANs assigned to a group will be available only to devices and virtual machines which exist within its scope.
+
+For example, a VLAN within a group assigned to a location will be available only to devices assigned to that location (or one of its child locations), or to a rack within that location.
+
 #### New Site Group Model ([#5892](https://github.com/netbox-community/netbox/issues/5892))
 #### New Site Group Model ([#5892](https://github.com/netbox-community/netbox/issues/5892))
 
 
 This release introduces the new Site Group model, which can be used to organize sites similar to the existing Region model. Whereas regions are intended for geographically arranging sites into countries, states, and so on, the new site group model can be used to organize sites by role or other arbitrary classification. Using regions and site groups in conjunction provides two dimensions along which sites can be organized, offering greater flexibility to the user.
 This release introduces the new Site Group model, which can be used to organize sites similar to the existing Region model. Whereas regions are intended for geographically arranging sites into countries, states, and so on, the new site group model can be used to organize sites by role or other arbitrary classification. Using regions and site groups in conjunction provides two dimensions along which sites can be organized, offering greater flexibility to the user.
@@ -116,3 +122,6 @@ The ObjectChange model (which is used to record the creation, modification, and
   * Renamed `object_data` to `postchange_data`
   * Renamed `object_data` to `postchange_data`
 * extras.Webhook
 * extras.Webhook
   * Added the `/api/extras/webhooks/` endpoint
   * Added the `/api/extras/webhooks/` endpoint
+* ipam.VLANGroup
+  * Added the `scope_type`, `scope_id`, and `scope` fields (`scope` is a generic foreign key)
+  * Dropped the `site` foreign key field

+ 18 - 3
netbox/ipam/api/serializers.py

@@ -113,14 +113,21 @@ class RoleSerializer(OrganizationalModelSerializer):
 
 
 class VLANGroupSerializer(OrganizationalModelSerializer):
 class VLANGroupSerializer(OrganizationalModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
-    site = NestedSiteSerializer(required=False, allow_null=True)
+    scope_type = ContentTypeField(
+        queryset=ContentType.objects.filter(
+            app_label='dcim',
+            model__in=['region', 'sitegroup', 'site', 'location', 'rack']
+        ),
+        required=False
+    )
+    scope = serializers.SerializerMethodField(read_only=True)
     vlan_count = serializers.IntegerField(read_only=True)
     vlan_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = VLANGroup
         model = VLANGroup
         fields = [
         fields = [
-            'id', 'url', 'name', 'slug', 'site', 'description', 'custom_fields', 'created', 'last_updated',
-            'vlan_count',
+            'id', 'url', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'description', 'custom_fields', 'created',
+            'last_updated', 'vlan_count',
         ]
         ]
         validators = []
         validators = []
 
 
@@ -137,6 +144,14 @@ class VLANGroupSerializer(OrganizationalModelSerializer):
 
 
         return data
         return data
 
 
+    def get_scope(self, obj):
+        if obj.scope_id is None:
+            return None
+        serializer = get_serializer_for_model(obj.scope, prefix='Nested')
+        context = {'request': self.context['request']}
+
+        return serializer(obj.scope, context=context).data
+
 
 
 class VLANSerializer(PrimaryModelSerializer):
 class VLANSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')

+ 1 - 1
netbox/ipam/api/views.py

@@ -283,7 +283,7 @@ class IPAddressViewSet(CustomFieldModelViewSet):
 #
 #
 
 
 class VLANGroupViewSet(CustomFieldModelViewSet):
 class VLANGroupViewSet(CustomFieldModelViewSet):
-    queryset = VLANGroup.objects.prefetch_related('site').annotate(
+    queryset = VLANGroup.objects.annotate(
         vlan_count=count_related(VLAN, 'group')
         vlan_count=count_related(VLAN, 'group')
     )
     )
     serializer_class = serializers.VLANGroupSerializer
     serializer_class = serializers.VLANGroupSerializer

+ 26 - 33
netbox/ipam/filters.py

@@ -1,5 +1,6 @@
 import django_filters
 import django_filters
 import netaddr
 import netaddr
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.db.models import Q
 from django.db.models import Q
 from netaddr.core import AddrFormatError
 from netaddr.core import AddrFormatError
@@ -8,8 +9,8 @@ from dcim.models import Device, Interface, Region, Site, SiteGroup
 from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet
 from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet
 from tenancy.filters import TenancyFilterSet
 from tenancy.filters import TenancyFilterSet
 from utilities.filters import (
 from utilities.filters import (
-    BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericArrayFilter, TagFilter,
-    TreeNodeMultipleChoiceFilter,
+    BaseFilterSet, ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet,
+    NumericArrayFilter, TagFilter, TreeNodeMultipleChoiceFilter,
 )
 )
 from virtualization.models import VirtualMachine, VMInterface
 from virtualization.models import VirtualMachine, VMInterface
 from .choices import *
 from .choices import *
@@ -535,46 +536,38 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilter
 
 
 
 
 class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
 class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
-    region_id = TreeNodeMultipleChoiceFilter(
-        queryset=Region.objects.all(),
-        field_name='site__region',
-        lookup_expr='in',
-        label='Region (ID)',
+    scope_type = ContentTypeFilter()
+    region = django_filters.NumberFilter(
+        method='filter_scope'
     )
     )
-    region = TreeNodeMultipleChoiceFilter(
-        queryset=Region.objects.all(),
-        field_name='site__region',
-        lookup_expr='in',
-        to_field_name='slug',
-        label='Region (slug)',
+    sitegroup = django_filters.NumberFilter(
+        method='filter_scope'
     )
     )
-    site_group_id = TreeNodeMultipleChoiceFilter(
-        queryset=SiteGroup.objects.all(),
-        field_name='site__group',
-        lookup_expr='in',
-        label='Site group (ID)',
+    site = django_filters.NumberFilter(
+        method='filter_scope'
     )
     )
-    site_group = TreeNodeMultipleChoiceFilter(
-        queryset=SiteGroup.objects.all(),
-        field_name='site__group',
-        lookup_expr='in',
-        to_field_name='slug',
-        label='Site group (slug)',
+    location = django_filters.NumberFilter(
+        method='filter_scope'
     )
     )
-    site_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=Site.objects.all(),
-        label='Site (ID)',
+    rack = django_filters.NumberFilter(
+        method='filter_scope'
     )
     )
-    site = django_filters.ModelMultipleChoiceFilter(
-        field_name='site__slug',
-        queryset=Site.objects.all(),
-        to_field_name='slug',
-        label='Site (slug)',
+    clustergroup = django_filters.NumberFilter(
+        method='filter_scope'
+    )
+    cluster = django_filters.NumberFilter(
+        method='filter_scope'
     )
     )
 
 
     class Meta:
     class Meta:
         model = VLANGroup
         model = VLANGroup
-        fields = ['id', 'name', 'slug', 'description']
+        fields = ['id', 'name', 'slug', 'description', 'scope_id']
+
+    def filter_scope(self, queryset, name, value):
+        return queryset.filter(
+            scope_type=ContentType.objects.get(model=name),
+            scope_id=value
+        )
 
 
 
 
 class VLANFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
 class VLANFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):

+ 119 - 35
netbox/ipam/forms.py

@@ -1,7 +1,7 @@
 from django import forms
 from django import forms
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from dcim.models import Device, Interface, Rack, Region, Site, SiteGroup
+from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup
 from extras.forms import (
 from extras.forms import (
     AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
     AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
 )
 )
@@ -13,7 +13,7 @@ from utilities.forms import (
     DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, NumericArrayField,
     DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, NumericArrayField,
     ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
     ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
 )
 )
-from virtualization.models import Cluster, VirtualMachine, VMInterface
+from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
 from .choices import *
 from .choices import *
 from .constants import *
 from .constants import *
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
@@ -1161,19 +1161,88 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm):
     site = DynamicModelChoiceField(
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
+        initial_params={
+            'locations': '$location'
+        },
         query_params={
         query_params={
             'region_id': '$region',
             'region_id': '$region',
             'group_id': '$site_group',
             'group_id': '$site_group',
         }
         }
     )
     )
+    location = DynamicModelChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        initial_params={
+            'racks': '$rack'
+        },
+        query_params={
+            'site_id': '$site',
+        }
+    )
+    rack = DynamicModelChoiceField(
+        queryset=Rack.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site',
+            'location_id': '$location',
+        }
+    )
+    cluster_group = DynamicModelChoiceField(
+        queryset=ClusterGroup.objects.all(),
+        required=False,
+        initial_params={
+            'clusters': '$cluster'
+        }
+    )
+    cluster = DynamicModelChoiceField(
+        queryset=Cluster.objects.all(),
+        required=False,
+        query_params={
+            'group_id': '$cluster_group',
+        }
+    )
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
         model = VLANGroup
         model = VLANGroup
         fields = [
         fields = [
-            'region', 'site', 'name', 'slug', 'description',
+            'name', 'slug', 'description', 'region', 'site_group', 'site', 'location', 'rack', 'cluster_group',
+            'cluster',
         ]
         ]
 
 
+    def __init__(self, *args, **kwargs):
+        instance = kwargs.get('instance')
+        initial = kwargs.get('initial', {})
+
+        if instance is not None and instance.scope:
+            if type(instance.scope) is Rack:
+                initial['rack'] = instance.scope
+            elif type(instance.scope) is Location:
+                initial['location'] = instance.scope
+            elif type(instance.scope) is Site:
+                initial['site'] = instance.scope
+            elif type(instance.scope) is SiteGroup:
+                initial['site_group'] = instance.scope
+            elif type(instance.scope) is Region:
+                initial['region'] = instance.scope
+            elif type(instance.scope) is Cluster:
+                initial['cluster'] = instance.scope
+            elif type(instance.scope) is ClusterGroup:
+                initial['cluster_group'] = instance.scope
+
+            kwargs['initial'] = initial
+
+        super().__init__(*args, **kwargs)
+
+    def clean(self):
+        super().clean()
+
+        # Assign scope object
+        self.instance.scope = self.cleaned_data['rack'] or self.cleaned_data['location'] or \
+            self.cleaned_data['site'] or self.cleaned_data['site_group'] or \
+            self.cleaned_data['region'] or self.cleaned_data['cluster'] or \
+            self.cleaned_data['cluster_group'] or None
+
 
 
 class VLANGroupCSVForm(CustomFieldModelCSVForm):
 class VLANGroupCSVForm(CustomFieldModelCSVForm):
     site = CSVModelChoiceField(
     site = CSVModelChoiceField(
@@ -1208,25 +1277,31 @@ class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 
 
 
 
 class VLANGroupFilterForm(BootstrapMixin, forms.Form):
 class VLANGroupFilterForm(BootstrapMixin, forms.Form):
-    region_id = DynamicModelMultipleChoiceField(
+    region = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,
         label=_('Region')
         label=_('Region')
     )
     )
-    site_group_id = DynamicModelMultipleChoiceField(
+    sitegroup = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         queryset=SiteGroup.objects.all(),
         required=False,
         required=False,
         label=_('Site group')
         label=_('Site group')
     )
     )
-    site_id = DynamicModelMultipleChoiceField(
+    site = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
-        null_option='None',
-        query_params={
-            'region_id': '$region_id'
-        },
         label=_('Site')
         label=_('Site')
     )
     )
+    location = DynamicModelMultipleChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        label=_('Location')
+    )
+    rack = DynamicModelMultipleChoiceField(
+        queryset=Rack.objects.all(),
+        required=False,
+        label=_('Rack')
+    )
 
 
 
 
 #
 #
@@ -1234,19 +1309,47 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
 #
 #
 
 
 class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    # VLANGroup assignment fields
+    scope_type = forms.ChoiceField(
+        choices=(
+            ('', ''),
+            ('dcim.region', 'Region'),
+            ('dcim.sitegroup', 'Site group'),
+            ('dcim.site', 'Site'),
+            ('dcim.location', 'Location'),
+            ('dcim.rack', 'Rack'),
+            ('virtualization.clustergroup', 'Cluster group'),
+            ('virtualization.cluster', 'Cluster'),
+        ),
+        required=False,
+        widget=StaticSelect2,
+        label='Group scope'
+    )
+    group = DynamicModelChoiceField(
+        queryset=VLANGroup.objects.all(),
+        required=False,
+        query_params={
+            'scope_type': '$scope_type',
+        },
+        label='VLAN Group'
+    )
+
+    # Site assignment fields
     region = DynamicModelChoiceField(
     region = DynamicModelChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,
         initial_params={
         initial_params={
             'sites': '$site'
             'sites': '$site'
-        }
+        },
+        label='Region'
     )
     )
-    site_group = DynamicModelChoiceField(
+    sitegroup = DynamicModelChoiceField(
         queryset=SiteGroup.objects.all(),
         queryset=SiteGroup.objects.all(),
         required=False,
         required=False,
         initial_params={
         initial_params={
             'sites': '$site'
             'sites': '$site'
-        }
+        },
+        label='Site group'
     )
     )
     site = DynamicModelChoiceField(
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
@@ -1254,16 +1357,11 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         null_option='None',
         null_option='None',
         query_params={
         query_params={
             'region_id': '$region',
             'region_id': '$region',
-            'group_id': '$site_group',
-        }
-    )
-    group = DynamicModelChoiceField(
-        queryset=VLANGroup.objects.all(),
-        required=False,
-        query_params={
-            'site_id': '$site'
+            'group_id': '$sitegroup',
         }
         }
     )
     )
+
+    # Other fields
     role = DynamicModelChoiceField(
     role = DynamicModelChoiceField(
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
         required=False
         required=False
@@ -1278,11 +1376,6 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         fields = [
         fields = [
             'site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags',
             'site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags',
         ]
         ]
-        fieldsets = (
-            ('VLAN', ('vid', 'name', 'status', 'role', 'description', 'tags')),
-            ('Assignment', ('region', 'site_group', 'site', 'group')),
-            ('Tenancy', ('tenant_group', 'tenant')),
-        )
         help_texts = {
         help_texts = {
             'site': "Leave blank if this VLAN spans multiple sites",
             'site': "Leave blank if this VLAN spans multiple sites",
             'group': "VLAN group (optional)",
             'group': "VLAN group (optional)",
@@ -1334,15 +1427,6 @@ class VLANCSVForm(CustomFieldModelCSVForm):
             'name': 'VLAN name',
             'name': 'VLAN name',
         }
         }
 
 
-    def __init__(self, data=None, *args, **kwargs):
-        super().__init__(data, *args, **kwargs)
-
-        if data:
-
-            # Limit vlan queryset by assigned group
-            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
-            self.fields['group'].queryset = self.fields['group'].queryset.filter(**params)
-
 
 
 class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
 class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(

+ 36 - 0
netbox/ipam/migrations/0045_vlangroup_scope.py

@@ -0,0 +1,36 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('ipam', '0044_standardize_models'),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name='vlangroup',
+            old_name='site',
+            new_name='scope_id',
+        ),
+        migrations.AlterField(
+            model_name='vlangroup',
+            name='scope_id',
+            field=models.PositiveBigIntegerField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='vlangroup',
+            name='scope_type',
+            field=models.ForeignKey(blank=True, limit_choices_to=models.Q(model__in=['region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster']), null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype'),
+        ),
+        migrations.AlterModelOptions(
+            name='vlangroup',
+            options={'ordering': ('name', 'pk'), 'verbose_name': 'VLAN group', 'verbose_name_plural': 'VLAN groups'},
+        ),
+        migrations.AlterUniqueTogether(
+            name='vlangroup',
+            unique_together={('scope_type', 'scope_id', 'name'), ('scope_type', 'scope_id', 'slug')},
+        ),
+    ]

+ 35 - 12
netbox/ipam/models/vlans.py

@@ -1,3 +1,5 @@
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
@@ -29,13 +31,23 @@ class VLANGroup(OrganizationalModel):
     slug = models.SlugField(
     slug = models.SlugField(
         max_length=100
         max_length=100
     )
     )
-    site = models.ForeignKey(
-        to='dcim.Site',
-        on_delete=models.PROTECT,
-        related_name='vlan_groups',
+    scope_type = models.ForeignKey(
+        to=ContentType,
+        on_delete=models.CASCADE,
+        limit_choices_to=Q(
+            model__in=['region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster']
+        ),
+        blank=True,
+        null=True
+    )
+    scope_id = models.PositiveBigIntegerField(
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
+    scope = GenericForeignKey(
+        ct_field='scope_type',
+        fk_field='scope_id'
+    )
     description = models.CharField(
     description = models.CharField(
         max_length=200,
         max_length=200,
         blank=True
         blank=True
@@ -43,13 +55,13 @@ class VLANGroup(OrganizationalModel):
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
 
 
-    csv_headers = ['name', 'slug', 'site', 'description']
+    csv_headers = ['name', 'slug', 'scope_type', 'scope_id', 'description']
 
 
     class Meta:
     class Meta:
-        ordering = ('site', 'name', 'pk')  # (site, name) may be non-unique
+        ordering = ('name', 'pk')  # Name may be non-unique
         unique_together = [
         unique_together = [
-            ['site', 'name'],
-            ['site', 'slug'],
+            ['scope_type', 'scope_id', 'name'],
+            ['scope_type', 'scope_id', 'slug'],
         ]
         ]
         verbose_name = 'VLAN group'
         verbose_name = 'VLAN group'
         verbose_name_plural = 'VLAN groups'
         verbose_name_plural = 'VLAN groups'
@@ -60,11 +72,21 @@ class VLANGroup(OrganizationalModel):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('ipam:vlangroup_vlans', args=[self.pk])
         return reverse('ipam:vlangroup_vlans', args=[self.pk])
 
 
+    def clean(self):
+        super().clean()
+
+        # Validate scope assignment
+        if self.scope_type and not self.scope_id:
+            raise ValidationError("Cannot set scope_type without scope_id.")
+        if self.scope_id and not self.scope_type:
+            raise ValidationError("Cannot set scope_id without scope_type.")
+
     def to_csv(self):
     def to_csv(self):
         return (
         return (
             self.name,
             self.name,
             self.slug,
             self.slug,
-            self.site.name if self.site else None,
+            f'{self.scope_type.app_label}.{self.scope_type.model}',
+            self.scope_id,
             self.description,
             self.description,
         )
         )
 
 
@@ -159,10 +181,11 @@ class VLAN(PrimaryModel):
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 
-        # Validate VLAN group
-        if self.group and self.group.site != self.site:
+        # Validate VLAN group (if assigned)
+        if self.group and self.site and self.group.scope != self.site:
             raise ValidationError({
             raise ValidationError({
-                'group': "VLAN group must belong to the assigned site ({}).".format(self.site)
+                'group': f"VLAN is assigned to group {self.group} (scope: {self.group.scope}); cannot also assign to "
+                         f"site {self.site}."
             })
             })
 
 
     def to_csv(self):
     def to_csv(self):

+ 4 - 4
netbox/ipam/tables.py

@@ -90,7 +90,7 @@ VLAN_ROLE_LINK = """
 VLANGROUP_ADD_VLAN = """
 VLANGROUP_ADD_VLAN = """
 {% with next_vid=record.get_next_available_vid %}
 {% with next_vid=record.get_next_available_vid %}
     {% if next_vid and perms.ipam.add_vlan %}
     {% if next_vid and perms.ipam.add_vlan %}
-        <a href="{% url 'ipam:vlan_add' %}?site={{ record.site_id }}&group={{ record.pk }}&vid={{ next_vid }}" title="Add VLAN" class="btn btn-xs btn-success">
+        <a href="{% url 'ipam:vlan_add' %}?group={{ record.pk }}&vid={{ next_vid }}" title="Add VLAN" class="btn btn-xs btn-success">
             <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
             <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
         </a>
         </a>
     {% endif %}
     {% endif %}
@@ -417,7 +417,7 @@ class InterfaceIPAddressTable(BaseTable):
 class VLANGroupTable(BaseTable):
 class VLANGroupTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     name = tables.Column(linkify=True)
     name = tables.Column(linkify=True)
-    site = tables.Column(
+    scope = tables.Column(
         linkify=True
         linkify=True
     )
     )
     vlan_count = LinkedCountColumn(
     vlan_count = LinkedCountColumn(
@@ -432,8 +432,8 @@ class VLANGroupTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = VLANGroup
         model = VLANGroup
-        fields = ('pk', 'name', 'site', 'vlan_count', 'slug', 'description', 'actions')
-        default_columns = ('pk', 'name', 'site', 'vlan_count', 'description', 'actions')
+        fields = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'actions')
+        default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions')
 
 
 
 
 #
 #

+ 57 - 45
netbox/ipam/tests/test_filters.py

@@ -1,10 +1,10 @@
 from django.test import TestCase
 from django.test import TestCase
 
 
-from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Region, Site, SiteGroup
+from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Rack, Region, Site, SiteGroup
 from ipam.choices import *
 from ipam.choices import *
 from ipam.filters import *
 from ipam.filters import *
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
-from virtualization.models import Cluster, ClusterType, VirtualMachine, VMInterface
+from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 
 
 
 
@@ -715,34 +715,39 @@ class VLANGroupTestCase(TestCase):
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
-        regions = (
-            Region(name='Test Region 1', slug='test-region-1'),
-            Region(name='Test Region 2', slug='test-region-2'),
-            Region(name='Test Region 3', slug='test-region-3'),
-        )
-        for r in regions:
-            r.save()
+        region = Region(name='Region 1', slug='region-1')
+        region.save()
 
 
-        site_groups = (
-            SiteGroup(name='Site Group 1', slug='site-group-1'),
-            SiteGroup(name='Site Group 2', slug='site-group-2'),
-            SiteGroup(name='Site Group 3', slug='site-group-3'),
-        )
-        for site_group in site_groups:
-            site_group.save()
+        sitegroup = SiteGroup(name='Site Group 1', slug='site-group-1')
+        sitegroup.save()
 
 
-        sites = (
-            Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]),
-            Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]),
-            Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]),
-        )
-        Site.objects.bulk_create(sites)
+        site = Site(name='Site 1', slug='site-1')
+        site.save()
+
+        location = Location(name='Location 1', slug='location-1', site=site)
+        location.save()
+
+        rack = Rack(name='Rack 1', site=site)
+        rack.save()
+
+        clustertype = ClusterType(name='Cluster Type 1', slug='cluster-type-1')
+        clustertype.save()
+
+        clustergroup = ClusterGroup(name='Cluster Group 1', slug='cluster-group-1')
+        clustergroup.save()
+
+        cluster = Cluster(name='Cluster 1', type=clustertype)
+        cluster.save()
 
 
         vlan_groups = (
         vlan_groups = (
-            VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=sites[0], description='A'),
-            VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=sites[1], description='B'),
-            VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=sites[2], description='C'),
-            VLANGroup(name='VLAN Group 4', slug='vlan-group-4', site=None),
+            VLANGroup(name='VLAN Group 1', slug='vlan-group-1', scope=region, description='A'),
+            VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sitegroup, description='B'),
+            VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=site, description='C'),
+            VLANGroup(name='VLAN Group 4', slug='vlan-group-4', scope=location, description='D'),
+            VLANGroup(name='VLAN Group 5', slug='vlan-group-5', scope=rack, description='E'),
+            VLANGroup(name='VLAN Group 6', slug='vlan-group-6', scope=clustergroup, description='F'),
+            VLANGroup(name='VLAN Group 7', slug='vlan-group-7', scope=cluster, description='G'),
+            VLANGroup(name='VLAN Group 8', slug='vlan-group-8'),
         )
         )
         VLANGroup.objects.bulk_create(vlan_groups)
         VLANGroup.objects.bulk_create(vlan_groups)
 
 
@@ -763,25 +768,32 @@ class VLANGroupTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_region(self):
     def test_region(self):
-        regions = Region.objects.all()[:2]
-        params = {'region_id': [regions[0].pk, regions[1].pk]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        params = {'region': [regions[0].slug, regions[1].slug]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'region': Region.objects.first().pk}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
-    def test_site_group(self):
-        site_groups = SiteGroup.objects.all()[:2]
-        params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+    def test_sitegroup(self):
+        params = {'sitegroup': SiteGroup.objects.first().pk}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
     def test_site(self):
     def test_site(self):
-        sites = Site.objects.all()[:2]
-        params = {'site_id': [sites[0].pk, sites[1].pk]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        params = {'site': [sites[0].slug, sites[1].slug]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site': Site.objects.first().pk}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_location(self):
+        params = {'location': Location.objects.first().pk}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_rack(self):
+        params = {'rack': Rack.objects.first().pk}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_clustergroup(self):
+        params = {'clustergroup': ClusterGroup.objects.first().pk}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_cluster(self):
+        params = {'cluster': Cluster.objects.first().pk}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
 class VLANTestCase(TestCase):
 class VLANTestCase(TestCase):
@@ -822,9 +834,9 @@ class VLANTestCase(TestCase):
         Role.objects.bulk_create(roles)
         Role.objects.bulk_create(roles)
 
 
         groups = (
         groups = (
-            VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=sites[0]),
-            VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=sites[1]),
-            VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=None),
+            VLANGroup(name='VLAN Group 1', slug='vlan-group-1', scope=sites[0]),
+            VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sites[1]),
+            VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=None),
         )
         )
         VLANGroup.objects.bulk_create(groups)
         VLANGroup.objects.bulk_create(groups)
 
 

+ 11 - 7
netbox/ipam/tests/test_views.py

@@ -314,18 +314,22 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
-        site = Site.objects.create(name='Site 1', slug='site-1')
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+        )
+        Site.objects.bulk_create(sites)
 
 
         VLANGroup.objects.bulk_create([
         VLANGroup.objects.bulk_create([
-            VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=site),
-            VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=site),
-            VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=site),
+            VLANGroup(name='VLAN Group 1', slug='vlan-group-1', scope=sites[0]),
+            VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sites[0]),
+            VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=sites[0]),
         ])
         ])
 
 
         cls.form_data = {
         cls.form_data = {
             'name': 'VLAN Group X',
             'name': 'VLAN Group X',
             'slug': 'vlan-group-x',
             'slug': 'vlan-group-x',
-            'site': site.pk,
+            'site': sites[1].pk,
             'description': 'A new VLAN group',
             'description': 'A new VLAN group',
         }
         }
 
 
@@ -354,8 +358,8 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
         vlangroups = (
         vlangroups = (
-            VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=sites[0]),
-            VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=sites[1]),
+            VLANGroup(name='VLAN Group 1', slug='vlan-group-1', scope=sites[0]),
+            VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sites[1]),
         )
         )
         VLANGroup.objects.bulk_create(vlangroups)
         VLANGroup.objects.bulk_create(vlangroups)
 
 

+ 5 - 3
netbox/ipam/views.py

@@ -647,7 +647,7 @@ class IPAddressBulkDeleteView(generic.BulkDeleteView):
 #
 #
 
 
 class VLANGroupListView(generic.ObjectListView):
 class VLANGroupListView(generic.ObjectListView):
-    queryset = VLANGroup.objects.prefetch_related('site').annotate(
+    queryset = VLANGroup.objects.annotate(
         vlan_count=count_related(VLAN, 'group')
         vlan_count=count_related(VLAN, 'group')
     )
     )
     filterset = filters.VLANGroupFilterSet
     filterset = filters.VLANGroupFilterSet
@@ -658,6 +658,7 @@ class VLANGroupListView(generic.ObjectListView):
 class VLANGroupEditView(generic.ObjectEditView):
 class VLANGroupEditView(generic.ObjectEditView):
     queryset = VLANGroup.objects.all()
     queryset = VLANGroup.objects.all()
     model_form = forms.VLANGroupForm
     model_form = forms.VLANGroupForm
+    template_name = 'ipam/vlangroup_edit.html'
 
 
 
 
 class VLANGroupDeleteView(generic.ObjectDeleteView):
 class VLANGroupDeleteView(generic.ObjectDeleteView):
@@ -671,7 +672,7 @@ class VLANGroupBulkImportView(generic.BulkImportView):
 
 
 
 
 class VLANGroupBulkEditView(generic.BulkEditView):
 class VLANGroupBulkEditView(generic.BulkEditView):
-    queryset = VLANGroup.objects.prefetch_related('site').annotate(
+    queryset = VLANGroup.objects.annotate(
         vlan_count=count_related(VLAN, 'group')
         vlan_count=count_related(VLAN, 'group')
     )
     )
     filterset = filters.VLANGroupFilterSet
     filterset = filters.VLANGroupFilterSet
@@ -680,7 +681,7 @@ class VLANGroupBulkEditView(generic.BulkEditView):
 
 
 
 
 class VLANGroupBulkDeleteView(generic.BulkDeleteView):
 class VLANGroupBulkDeleteView(generic.BulkDeleteView):
-    queryset = VLANGroup.objects.prefetch_related('site').annotate(
+    queryset = VLANGroup.objects.annotate(
         vlan_count=count_related(VLAN, 'group')
         vlan_count=count_related(VLAN, 'group')
     )
     )
     filterset = filters.VLANGroupFilterSet
     filterset = filters.VLANGroupFilterSet
@@ -793,6 +794,7 @@ class VLANVMInterfacesView(generic.ObjectView):
 class VLANEditView(generic.ObjectEditView):
 class VLANEditView(generic.ObjectEditView):
     queryset = VLAN.objects.all()
     queryset = VLAN.objects.all()
     model_form = forms.VLANForm
     model_form = forms.VLANForm
+    template_name = 'ipam/vlan_edit.html'
 
 
 
 
 class VLANDeleteView(generic.ObjectDeleteView):
 class VLANDeleteView(generic.ObjectDeleteView):

+ 57 - 0
netbox/templates/ipam/vlan_edit.html

@@ -0,0 +1,57 @@
+{% extends 'generic/object_edit.html' %}
+{% load static %}
+{% load form_helpers %}
+{% load helpers %}
+
+{% block form %}
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>VLAN</strong></div>
+        <div class="panel-body">
+            {% render_field form.vid %}
+            {% render_field form.name %}
+            {% render_field form.status %}
+            {% render_field form.role %}
+            {% render_field form.description %}
+            {% render_field form.tags %}
+        </div>
+    </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Tenancy</strong></div>
+        <div class="panel-body">
+            {% render_field form.tenant_group %}
+            {% render_field form.tenant %}
+        </div>
+    </div>
+    <div class="panel panel-default">
+        <div class="panel-heading">
+            <strong>Assignment</strong>
+        </div>
+        <div class="panel-body">
+            {% with site_tab_active=form.initial.site %}
+                <ul class="nav nav-tabs" role="tablist">
+                    <li role="presentation"{% if not site_tab_active %} class="active"{% endif %}><a href="#group" role="tab" data-toggle="tab">VLAN Group</a></li>
+                    <li role="presentation"{% if site_tab_active %} class="active"{% endif %}><a href="#site" role="tab" data-toggle="tab">Site</a></li>
+                </ul>
+                <div class="tab-content">
+                    <div class="tab-pane{% if not vm_tab_active %} active{% endif %}" id="group">
+                        {% render_field form.scope_type %}
+                        {% render_field form.group %}
+                    </div>
+                    <div class="tab-pane{% if vm_tab_active %} active{% endif %}" id="site">
+                        {% render_field form.region %}
+                        {% render_field form.sitegroup %}
+                        {% render_field form.site %}
+                    </div>
+                </div>
+            {% endwith %}
+        </div>
+    </div>
+    {% if form.custom_fields %}
+        <div class="panel panel-default">
+            <div class="panel-heading"><strong>Custom Fields</strong></div>
+            <div class="panel-body">
+                {% render_custom_fields form %}
+            </div>
+        </div>
+    {% endif %}
+{% endblock %}

+ 49 - 0
netbox/templates/ipam/vlangroup_edit.html

@@ -0,0 +1,49 @@
+{% extends 'generic/object_edit.html' %}
+{% load form_helpers %}
+{% load helpers %}
+
+{% block form %}
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>VLAN Group</strong></div>
+        <div class="panel-body">
+            {% render_field form.name %}
+            {% render_field form.slug %}
+            {% render_field form.description %}
+        </div>
+    </div>
+    <div class="panel panel-default">
+        <div class="panel-heading">
+            <strong>Scope</strong>
+        </div>
+        <div class="panel-body">
+            {% with virtual_tab_active=form.initial.cluster %}
+                <ul class="nav nav-tabs" role="tablist">
+                    <li role="presentation"{% if not virtual_tab_active %} class="active"{% endif %}><a href="#physical" role="tab" data-toggle="tab">Physical</a></li>
+                    <li role="presentation"{% if virtual_tab_active %} class="active"{% endif %}><a href="#virtual" role="tab" data-toggle="tab">Virtual</a></li>
+                </ul>
+                <div class="tab-content">
+                    <div class="tab-pane{% if not virtual_tab_active %} active{% endif %}" id="physical">
+                        {% render_field form.region %}
+                        {% render_field form.site_group %}
+                        {% render_field form.site %}
+                        {% render_field form.location %}
+                        {% render_field form.rack %}
+                    </div>
+                    <div class="tab-pane{% if virtual_tab_active %} active{% endif %}" id="virtual">
+                        {% render_field form.cluster_group %}
+                        {% render_field form.cluster %}
+                    </div>
+                </div>
+                <span class="help-block">The VLAN group will be limited in scope to the most-specific object selected above.</span>
+            {% endwith %}
+        </div>
+    </div>
+    {% if form.custom_fields %}
+        <div class="panel panel-default">
+            <div class="panel-heading"><strong>Custom Fields</strong></div>
+            <div class="panel-body">
+                {% render_custom_fields form %}
+            </div>
+        </div>
+    {% endif %}
+{% endblock %}