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

Closes #5284: Allow VLANGroup assignment beyond sites
This commit is contained in:
Jeremy Stretch 2021-03-15 21:11:49 -04:00 committed by GitHub
commit ee7f7c877a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 427 additions and 143 deletions

View File

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

View File

@ -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', 'id', 'url', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'description', 'custom_fields', 'created',
'vlan_count', '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')

View File

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

View File

@ -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, BaseFilterSet, ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet,
TreeNodeMultipleChoiceFilter, 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( scope_type = ContentTypeFilter()
queryset=Region.objects.all(), region = django_filters.NumberFilter(
field_name='site__region', method='filter_scope'
lookup_expr='in',
label='Region (ID)',
) )
region = TreeNodeMultipleChoiceFilter( sitegroup = django_filters.NumberFilter(
queryset=Region.objects.all(), method='filter_scope'
field_name='site__region',
lookup_expr='in',
to_field_name='slug',
label='Region (slug)',
) )
site_group_id = TreeNodeMultipleChoiceFilter( site = django_filters.NumberFilter(
queryset=SiteGroup.objects.all(), method='filter_scope'
field_name='site__group',
lookup_expr='in',
label='Site group (ID)',
) )
site_group = TreeNodeMultipleChoiceFilter( location = django_filters.NumberFilter(
queryset=SiteGroup.objects.all(), method='filter_scope'
field_name='site__group',
lookup_expr='in',
to_field_name='slug',
label='Site group (slug)',
) )
site_id = django_filters.ModelMultipleChoiceFilter( rack = django_filters.NumberFilter(
queryset=Site.objects.all(), method='filter_scope'
label='Site (ID)',
) )
site = django_filters.ModelMultipleChoiceFilter( clustergroup = django_filters.NumberFilter(
field_name='site__slug', method='filter_scope'
queryset=Site.objects.all(), )
to_field_name='slug', cluster = django_filters.NumberFilter(
label='Site (slug)', 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):

View File

@ -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_id': '$sitegroup',
}
)
group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),
required=False,
query_params={
'site_id': '$site'
} }
) )
# 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(

View File

@ -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')},
),
]

View File

@ -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( scope_type = models.ForeignKey(
to='dcim.Site', to=ContentType,
on_delete=models.PROTECT, on_delete=models.CASCADE,
related_name='vlan_groups', limit_choices_to=Q(
model__in=['region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster']
),
blank=True, blank=True,
null=True null=True
) )
scope_id = models.PositiveBigIntegerField(
blank=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'], ['scope_type', 'scope_id', 'name'],
['site', 'slug'], ['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 # Validate VLAN group (if assigned)
if self.group and self.group.site != self.site: 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):

View File

@ -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') fields = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'actions')
default_columns = ('pk', 'name', 'site', 'vlan_count', 'description', 'actions') default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions')
# #

View File

@ -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 = Region(name='Region 1', slug='region-1')
Region(name='Test Region 1', slug='test-region-1'), region.save()
Region(name='Test Region 2', slug='test-region-2'),
Region(name='Test Region 3', slug='test-region-3'),
)
for r in regions:
r.save()
site_groups = ( sitegroup = SiteGroup(name='Site Group 1', slug='site-group-1')
SiteGroup(name='Site Group 1', slug='site-group-1'), sitegroup.save()
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()
sites = ( site = Site(name='Site 1', slug='site-1')
Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]), site.save()
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]), location = Location(name='Location 1', slug='location-1', site=site)
) location.save()
Site.objects.bulk_create(sites)
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 1', slug='vlan-group-1', scope=region, description='A'),
VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=sites[1], description='B'), VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sitegroup, description='B'),
VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=sites[2], description='C'), VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=site, description='C'),
VLANGroup(name='VLAN Group 4', slug='vlan-group-4', site=None), 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': Region.objects.first().pk}
params = {'region_id': [regions[0].pk, regions[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
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)
def test_site_group(self): def test_sitegroup(self):
site_groups = SiteGroup.objects.all()[:2] params = {'sitegroup': SiteGroup.objects.first().pk}
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
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_site(self): def test_site(self):
sites = Site.objects.all()[:2] params = {'site': Site.objects.first().pk}
params = {'site_id': [sites[0].pk, sites[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site': [sites[0].slug, sites[1].slug]} def test_location(self):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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 1', slug='vlan-group-1', scope=sites[0]),
VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=sites[1]), VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sites[1]),
VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=None), VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=None),
) )
VLANGroup.objects.bulk_create(groups) VLANGroup.objects.bulk_create(groups)

View File

@ -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 1', slug='vlan-group-1', scope=sites[0]),
VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=site), VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sites[0]),
VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=site), 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 1', slug='vlan-group-1', scope=sites[0]),
VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=sites[1]), VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sites[1]),
) )
VLANGroup.objects.bulk_create(vlangroups) VLANGroup.objects.bulk_create(vlangroups)

View File

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

View File

@ -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 %}

View File

@ -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 %}