mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-22 12:06:53 -06:00
Merge pull request #5985 from netbox-community/5284-vlangroup-scope
Closes #5284: Allow VLANGroup assignment beyond sites
This commit is contained in:
commit
ee7f7c877a
@ -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.
|
||||
|
||||
#### 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))
|
||||
|
||||
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`
|
||||
* extras.Webhook
|
||||
* 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
|
||||
|
@ -113,14 +113,21 @@ class RoleSerializer(OrganizationalModelSerializer):
|
||||
|
||||
class VLANGroupSerializer(OrganizationalModelSerializer):
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
model = VLANGroup
|
||||
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 = []
|
||||
|
||||
@ -137,6 +144,14 @@ class VLANGroupSerializer(OrganizationalModelSerializer):
|
||||
|
||||
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):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
|
||||
|
@ -283,7 +283,7 @@ class IPAddressViewSet(CustomFieldModelViewSet):
|
||||
#
|
||||
|
||||
class VLANGroupViewSet(CustomFieldModelViewSet):
|
||||
queryset = VLANGroup.objects.prefetch_related('site').annotate(
|
||||
queryset = VLANGroup.objects.annotate(
|
||||
vlan_count=count_related(VLAN, 'group')
|
||||
)
|
||||
serializer_class = serializers.VLANGroupSerializer
|
||||
|
@ -1,5 +1,6 @@
|
||||
import django_filters
|
||||
import netaddr
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
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 tenancy.filters import TenancyFilterSet
|
||||
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 .choices import *
|
||||
@ -535,46 +536,38 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilter
|
||||
|
||||
|
||||
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:
|
||||
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):
|
||||
|
@ -1,7 +1,7 @@
|
||||
from django import forms
|
||||
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 (
|
||||
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
|
||||
)
|
||||
@ -13,7 +13,7 @@ from utilities.forms import (
|
||||
DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, NumericArrayField,
|
||||
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 .constants import *
|
||||
from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
|
||||
@ -1161,19 +1161,88 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm):
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'locations': '$location'
|
||||
},
|
||||
query_params={
|
||||
'region_id': '$region',
|
||||
'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()
|
||||
|
||||
class Meta:
|
||||
model = VLANGroup
|
||||
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):
|
||||
site = CSVModelChoiceField(
|
||||
@ -1208,25 +1277,31 @@ class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
|
||||
|
||||
class VLANGroupFilterForm(BootstrapMixin, forms.Form):
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
region = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region')
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
sitegroup = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group')
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
site = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
query_params={
|
||||
'region_id': '$region_id'
|
||||
},
|
||||
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):
|
||||
# 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(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
},
|
||||
label='Region'
|
||||
)
|
||||
site_group = DynamicModelChoiceField(
|
||||
sitegroup = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
},
|
||||
label='Site group'
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
@ -1254,16 +1357,11 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
null_option='None',
|
||||
query_params={
|
||||
'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(
|
||||
queryset=Role.objects.all(),
|
||||
required=False
|
||||
@ -1278,11 +1376,6 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
fields = [
|
||||
'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 = {
|
||||
'site': "Leave blank if this VLAN spans multiple sites",
|
||||
'group': "VLAN group (optional)",
|
||||
@ -1334,15 +1427,6 @@ class VLANCSVForm(CustomFieldModelCSVForm):
|
||||
'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):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
|
36
netbox/ipam/migrations/0045_vlangroup_scope.py
Normal file
36
netbox/ipam/migrations/0045_vlangroup_scope.py
Normal 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')},
|
||||
),
|
||||
]
|
@ -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.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
@ -29,13 +31,23 @@ class VLANGroup(OrganizationalModel):
|
||||
slug = models.SlugField(
|
||||
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,
|
||||
null=True
|
||||
)
|
||||
scope = GenericForeignKey(
|
||||
ct_field='scope_type',
|
||||
fk_field='scope_id'
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=200,
|
||||
blank=True
|
||||
@ -43,13 +55,13 @@ class VLANGroup(OrganizationalModel):
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
csv_headers = ['name', 'slug', 'site', 'description']
|
||||
csv_headers = ['name', 'slug', 'scope_type', 'scope_id', 'description']
|
||||
|
||||
class Meta:
|
||||
ordering = ('site', 'name', 'pk') # (site, name) may be non-unique
|
||||
ordering = ('name', 'pk') # Name may be non-unique
|
||||
unique_together = [
|
||||
['site', 'name'],
|
||||
['site', 'slug'],
|
||||
['scope_type', 'scope_id', 'name'],
|
||||
['scope_type', 'scope_id', 'slug'],
|
||||
]
|
||||
verbose_name = 'VLAN group'
|
||||
verbose_name_plural = 'VLAN groups'
|
||||
@ -60,11 +72,21 @@ class VLANGroup(OrganizationalModel):
|
||||
def get_absolute_url(self):
|
||||
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):
|
||||
return (
|
||||
self.name,
|
||||
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,
|
||||
)
|
||||
|
||||
@ -159,10 +181,11 @@ class VLAN(PrimaryModel):
|
||||
def clean(self):
|
||||
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({
|
||||
'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):
|
||||
|
@ -90,7 +90,7 @@ VLAN_ROLE_LINK = """
|
||||
VLANGROUP_ADD_VLAN = """
|
||||
{% with next_vid=record.get_next_available_vid %}
|
||||
{% 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>
|
||||
</a>
|
||||
{% endif %}
|
||||
@ -417,7 +417,7 @@ class InterfaceIPAddressTable(BaseTable):
|
||||
class VLANGroupTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.Column(linkify=True)
|
||||
site = tables.Column(
|
||||
scope = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
vlan_count = LinkedCountColumn(
|
||||
@ -432,8 +432,8 @@ class VLANGroupTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
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')
|
||||
|
||||
|
||||
#
|
||||
|
@ -1,10 +1,10 @@
|
||||
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.filters import *
|
||||
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
|
||||
|
||||
|
||||
@ -715,34 +715,39 @@ class VLANGroupTestCase(TestCase):
|
||||
@classmethod
|
||||
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 = (
|
||||
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)
|
||||
|
||||
@ -763,25 +768,32 @@ class VLANGroupTestCase(TestCase):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
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):
|
||||
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):
|
||||
@ -822,9 +834,9 @@ class VLANTestCase(TestCase):
|
||||
Role.objects.bulk_create(roles)
|
||||
|
||||
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)
|
||||
|
||||
|
@ -314,18 +314,22 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
@classmethod
|
||||
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(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 = {
|
||||
'name': 'VLAN Group X',
|
||||
'slug': 'vlan-group-x',
|
||||
'site': site.pk,
|
||||
'site': sites[1].pk,
|
||||
'description': 'A new VLAN group',
|
||||
}
|
||||
|
||||
@ -354,8 +358,8 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
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)
|
||||
|
||||
|
@ -647,7 +647,7 @@ class IPAddressBulkDeleteView(generic.BulkDeleteView):
|
||||
#
|
||||
|
||||
class VLANGroupListView(generic.ObjectListView):
|
||||
queryset = VLANGroup.objects.prefetch_related('site').annotate(
|
||||
queryset = VLANGroup.objects.annotate(
|
||||
vlan_count=count_related(VLAN, 'group')
|
||||
)
|
||||
filterset = filters.VLANGroupFilterSet
|
||||
@ -658,6 +658,7 @@ class VLANGroupListView(generic.ObjectListView):
|
||||
class VLANGroupEditView(generic.ObjectEditView):
|
||||
queryset = VLANGroup.objects.all()
|
||||
model_form = forms.VLANGroupForm
|
||||
template_name = 'ipam/vlangroup_edit.html'
|
||||
|
||||
|
||||
class VLANGroupDeleteView(generic.ObjectDeleteView):
|
||||
@ -671,7 +672,7 @@ class VLANGroupBulkImportView(generic.BulkImportView):
|
||||
|
||||
|
||||
class VLANGroupBulkEditView(generic.BulkEditView):
|
||||
queryset = VLANGroup.objects.prefetch_related('site').annotate(
|
||||
queryset = VLANGroup.objects.annotate(
|
||||
vlan_count=count_related(VLAN, 'group')
|
||||
)
|
||||
filterset = filters.VLANGroupFilterSet
|
||||
@ -680,7 +681,7 @@ class VLANGroupBulkEditView(generic.BulkEditView):
|
||||
|
||||
|
||||
class VLANGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = VLANGroup.objects.prefetch_related('site').annotate(
|
||||
queryset = VLANGroup.objects.annotate(
|
||||
vlan_count=count_related(VLAN, 'group')
|
||||
)
|
||||
filterset = filters.VLANGroupFilterSet
|
||||
@ -793,6 +794,7 @@ class VLANVMInterfacesView(generic.ObjectView):
|
||||
class VLANEditView(generic.ObjectEditView):
|
||||
queryset = VLAN.objects.all()
|
||||
model_form = forms.VLANForm
|
||||
template_name = 'ipam/vlan_edit.html'
|
||||
|
||||
|
||||
class VLANDeleteView(generic.ObjectDeleteView):
|
||||
|
57
netbox/templates/ipam/vlan_edit.html
Normal file
57
netbox/templates/ipam/vlan_edit.html
Normal 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 %}
|
49
netbox/templates/ipam/vlangroup_edit.html
Normal file
49
netbox/templates/ipam/vlangroup_edit.html
Normal 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 %}
|
Loading…
Reference in New Issue
Block a user