diff --git a/docs/data-model/ipam.md b/docs/data-model/ipam.md index ee54e74d2..6e72d5b87 100644 --- a/docs/data-model/ipam.md +++ b/docs/data-model/ipam.md @@ -83,9 +83,11 @@ One IP address can be designated as the network address translation (NAT) IP add # VLANs -A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094). Note that while it is good practice, neither VLAN names nor IDs must be unique within a site. This is to accommodate the fact that many real-world network use less-than-optimal VLAN allocations and may have overlapping VLAN ID assignments in practice. +A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094) as defined in [IEEE 802.1Q](https://en.wikipedia.org/wiki/IEEE_802.1Q). VLANs may be assigned to a site and/or VLAN group. Like prefixes, each VLAN is assigned an operational status and (optionally) a functional role. -Like prefixes, each VLAN is assigned an operational status and (optionally) a functional role. +### VLAN Groups + +VLAN groups can be employed for administrative organization within NetBox. Each VLAN within a group must have a unique ID and name. VLANs which are not assigned to a group may have overlapping names and IDs, including within a site. --- diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index a0dc9f633..b83228f9c 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -262,13 +262,13 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): class VLANGroupFilter(django_filters.FilterSet): - site_id = django_filters.ModelMultipleChoiceFilter( + site_id = NullableModelMultipleChoiceFilter( name='site', queryset=Site.objects.all(), label='Site (ID)', ) - site = django_filters.ModelMultipleChoiceFilter( - name='site__slug', + site = NullableModelMultipleChoiceFilter( + name='site', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -283,13 +283,13 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): action='search', label='Search', ) - site_id = django_filters.ModelMultipleChoiceFilter( + site_id = NullableModelMultipleChoiceFilter( name='site', queryset=Site.objects.all(), label='Site (ID)', ) - site = django_filters.ModelMultipleChoiceFilter( - name='site__slug', + site = NullableModelMultipleChoiceFilter( + name='site', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 83be76169..123ca0131 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -153,7 +153,7 @@ class RoleForm(BootstrapMixin, forms.ModelForm): class PrefixForm(BootstrapMixin, CustomFieldForm): site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site', - widget=forms.Select(attrs={'filter-for': 'vlan'})) + widget=forms.Select(attrs={'filter-for': 'vlan', 'nullable': 'true'})) vlan = forms.ModelChoiceField(queryset=VLAN.objects.all(), required=False, label='VLAN', widget=APISelect(api_url='/api/ipam/vlans/?site_id={{site}}', display_field='display_name')) @@ -173,7 +173,7 @@ class PrefixForm(BootstrapMixin, CustomFieldForm): elif self.initial.get('site'): self.fields['vlan'].queryset = VLAN.objects.filter(site=self.initial['site']) else: - self.fields['vlan'].choices = [] + self.fields['vlan'].queryset = VLAN.objects.filter(site=None) class PrefixFromCSVForm(forms.ModelForm): @@ -508,7 +508,11 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm): class VLANGroupFilterForm(BootstrapMixin, forms.Form): - site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlan_groups')), to_field_name='slug') + site = FilterChoiceField( + queryset=Site.objects.annotate(filter_count=Count('vlan_groups')), + to_field_name='slug', + null_option=(0, 'Global') + ) # @@ -524,7 +528,7 @@ class VLANForm(BootstrapMixin, CustomFieldForm): model = VLAN fields = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description'] help_texts = { - 'site': "The site at which this VLAN exists", + 'site': "Leave blank if this VLAN spans multiple sites", 'group': "VLAN group (optional)", 'vid': "Configured VLAN ID", 'name': "Configured VLAN name", @@ -532,7 +536,7 @@ class VLANForm(BootstrapMixin, CustomFieldForm): 'role': "The primary function of this VLAN", } widgets = { - 'site': forms.Select(attrs={'filter-for': 'group'}), + 'site': forms.Select(attrs={'filter-for': 'group', 'nullable': 'true'}), } def __init__(self, *args, **kwargs): @@ -545,11 +549,11 @@ class VLANForm(BootstrapMixin, CustomFieldForm): elif self.initial.get('site'): self.fields['group'].queryset = VLANGroup.objects.filter(site=self.initial['site']) else: - self.fields['group'].choices = [] + self.fields['group'].queryset = VLANGroup.objects.filter(site=None) class VLANFromCSVForm(forms.ModelForm): - site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', + site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, to_field_name='name', error_messages={'invalid_choice': 'Site not found.'}) group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, to_field_name='name', error_messages={'invalid_choice': 'VLAN group not found.'}) @@ -599,7 +603,8 @@ def vlan_status_choices(): class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VLAN q = forms.CharField(required=False, label='Search') - site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlans')), to_field_name='slug') + site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlans')), to_field_name='slug', + null_option=(0, 'Global')) group_id = FilterChoiceField(queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')), label='VLAN group', null_option=(0, 'None')) tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vlans')), to_field_name='slug', diff --git a/netbox/ipam/migrations/0015_global_vlans.py b/netbox/ipam/migrations/0015_global_vlans.py new file mode 100644 index 000000000..18d82cbaf --- /dev/null +++ b/netbox/ipam/migrations/0015_global_vlans.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-02-21 18:45 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0014_ipaddress_status_add_deprecated'), + ] + + operations = [ + migrations.AlterField( + model_name='vlan', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='dcim.Site'), + ), + migrations.AlterField( + model_name='vlangroup', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlan_groups', to='dcim.Site'), + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index d37fdec25..a04e4b9ce 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -485,7 +485,7 @@ class VLANGroup(models.Model): """ name = models.CharField(max_length=50) slug = models.SlugField() - site = models.ForeignKey('dcim.Site', related_name='vlan_groups') + site = models.ForeignKey('dcim.Site', related_name='vlan_groups', on_delete=models.PROTECT, blank=True, null=True) class Meta: ordering = ['site', 'name'] @@ -497,6 +497,8 @@ class VLANGroup(models.Model): verbose_name_plural = 'VLAN groups' def __str__(self): + if self.site is None: + return self.name return u'{} - {}'.format(self.site.name, self.name) def get_absolute_url(self): @@ -513,7 +515,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): Like Prefixes, each VLAN is assigned an operational status and optionally a user-defined Role. A VLAN can have zero or more Prefixes assigned to it. """ - site = models.ForeignKey('dcim.Site', related_name='vlans', on_delete=models.PROTECT) + site = models.ForeignKey('dcim.Site', related_name='vlans', on_delete=models.PROTECT, blank=True, null=True) group = models.ForeignKey('VLANGroup', related_name='vlans', blank=True, null=True, on_delete=models.PROTECT) vid = models.PositiveSmallIntegerField(verbose_name='ID', validators=[ MinValueValidator(1), @@ -551,7 +553,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): def to_csv(self): return csv_format([ - self.site.name, + self.site.name if self.site else None, self.group.name if self.group else None, self.vid, self.name, diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 3d81392e0..1b6ddb5dd 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -8,9 +8,11 @@
Site | -{{ vlan.site }} | ++ {% if vlan.site %} + {{ vlan.site }} + {% else %} + None + {% endif %} + |
Group |