diff --git a/docs/data-model/dcim.md b/docs/data-model/dcim.md index aa8673fb1..a345312d5 100644 --- a/docs/data-model/dcim.md +++ b/docs/data-model/dcim.md @@ -24,7 +24,7 @@ Each group is assigned to a parent site for easy navigation. Hierarchical recurs ### Rack Roles -Each rak can optionally be assigned to a functional role. For example, you might designate a rack for compute or storage resources, or to house colocated customer devices. +Each rack can optionally be assigned to a functional role. For example, you might designate a rack for compute or storage resources, or to house colocated customer devices. --- 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/circuits/forms.py b/netbox/circuits/forms.py index 7390c2216..036b74810 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -1,7 +1,7 @@ from django import forms from django.db.models import Count -from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL +from dcim.models import Site, Device, Interface, Rack, VIRTUAL_IFACE_TYPES from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.models import Tenant from utilities.forms import ( @@ -227,14 +227,18 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): # Limit interface choices if self.is_bound and self.data.get('device'): - interfaces = Interface.objects.filter(device=self.data['device'])\ - .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit_termination', 'connected_as_a', - 'connected_as_b') + interfaces = Interface.objects.filter(device=self.data['device']).exclude( + form_factor__in=VIRTUAL_IFACE_TYPES + ).select_related( + 'circuit_termination', 'connected_as_a', 'connected_as_b' + ) self.fields['interface'].widget.attrs['initial'] = self.data.get('interface') elif self.initial.get('device'): - interfaces = Interface.objects.filter(device=self.initial['device'])\ - .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit_termination', 'connected_as_a', - 'connected_as_b') + interfaces = Interface.objects.filter(device=self.initial['device']).exclude( + form_factor__in=VIRTUAL_IFACE_TYPES + ).select_related( + 'circuit_termination', 'connected_as_a', 'connected_as_b' + ) self.fields['interface'].widget.attrs['initial'] = self.initial.get('interface') else: interfaces = [] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index ae1d14211..5f5d009cb 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -503,7 +503,6 @@ class WritablePowerPortSerializer(serializers.ModelSerializer): # Interfaces # - class InterfaceSerializer(serializers.ModelSerializer): device = NestedDeviceSerializer() form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) @@ -513,7 +512,7 @@ class InterfaceSerializer(serializers.ModelSerializer): class Meta: model = Interface fields = [ - 'id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'connection', + 'id', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description', 'connection', 'connected_interface', ] @@ -541,7 +540,7 @@ class WritableInterfaceSerializer(serializers.ModelSerializer): class Meta: model = Interface - fields = ['id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description'] + fields = ['id', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description'] # diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index b376b5eaf..2b8418363 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -8,9 +8,9 @@ from tenancy.models import Tenant from utilities.filters import NullableModelMultipleChoiceFilter from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, - Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, - RackRole, Site, + DeviceBayTemplate, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceConnection, InterfaceTemplate, + Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, + RackReservation, RackRole, Site, VIRTUAL_IFACE_TYPES, ) @@ -391,11 +391,25 @@ class PowerOutletFilter(DeviceComponentFilterSet): class InterfaceFilter(DeviceComponentFilterSet): + type = django_filters.MethodFilter( + action='filter_type', + label='Interface type', + ) class Meta: model = Interface fields = ['name'] + def filter_type(self, queryset, value): + value = value.strip().lower() + if value == 'physical': + return queryset.exclude(form_factor__in=VIRTUAL_IFACE_TYPES) + elif value == 'virtual': + return queryset.filter(form_factor__in=VIRTUAL_IFACE_TYPES) + elif value == 'lag': + return queryset.filter(form_factor=IFACE_FF_LAG) + return queryset + class DeviceBayFilter(DeviceComponentFilterSet): diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index f9e3dcf62..efd1860e3 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -18,9 +18,10 @@ from .formfields import MACAddressFormField from .models import ( DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, - Interface, IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate, + Interface, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, - RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD + RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, + VIRTUAL_IFACE_TYPES ) @@ -53,6 +54,15 @@ def validate_connection_status(value): raise ValidationError('Invalid connection status ({}); must be either "planned" or "connected".'.format(value)) +class DeviceComponentForm(BootstrapMixin, forms.Form): + """ + Allow inclusion of the parent device as context for limiting field choices. + """ + def __init__(self, device, *args, **kwargs): + self.device = device + super(DeviceComponentForm, self).__init__(*args, **kwargs) + + # # Sites # @@ -331,7 +341,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): } -class ConsolePortTemplateCreateForm(BootstrapMixin, forms.Form): +class ConsolePortTemplateCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -345,7 +355,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): } -class ConsoleServerPortTemplateCreateForm(BootstrapMixin, forms.Form): +class ConsoleServerPortTemplateCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -359,7 +369,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): } -class PowerPortTemplateCreateForm(BootstrapMixin, forms.Form): +class PowerPortTemplateCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -373,7 +383,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): } -class PowerOutletTemplateCreateForm(BootstrapMixin, forms.Form): +class PowerOutletTemplateCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -387,7 +397,7 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): } -class InterfaceTemplateCreateForm(BootstrapMixin, forms.Form): +class InterfaceTemplateCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) mgmt_only = forms.BooleanField(required=False, label='OOB Management') @@ -411,7 +421,7 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): } -class DeviceBayTemplateCreateForm(BootstrapMixin, forms.Form): +class DeviceBayTemplateCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -743,7 +753,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm): } -class ConsolePortCreateForm(BootstrapMixin, forms.Form): +class ConsolePortCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -914,7 +924,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): } -class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form): +class ConsoleServerPortCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -1012,7 +1022,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm): } -class PowerPortCreateForm(BootstrapMixin, forms.Form): +class PowerPortCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -1181,7 +1191,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm): } -class PowerOutletCreateForm(BootstrapMixin, forms.Form): +class PowerOutletCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -1273,27 +1283,65 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): class Meta: model = Interface - fields = ['device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description'] + fields = ['device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description'] widgets = { 'device': forms.HiddenInput(), } + def __init__(self, *args, **kwargs): + super(InterfaceForm, self).__init__(*args, **kwargs) -class InterfaceCreateForm(BootstrapMixin, forms.Form): + # Limit LAG choices to interfaces belonging to this device + if self.is_bound: + self.fields['lag'].queryset = Interface.objects.order_naturally().filter( + device_id=self.data['device'], form_factor=IFACE_FF_LAG + ) + else: + self.fields['lag'].queryset = Interface.objects.order_naturally().filter( + device=self.instance.device, form_factor=IFACE_FF_LAG + ) + + +class InterfaceCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) + lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG') mac_address = MACAddressFormField(required=False, label='MAC Address') mgmt_only = forms.BooleanField(required=False, label='OOB Management') description = forms.CharField(max_length=100, required=False) + def __init__(self, *args, **kwargs): + super(InterfaceCreateForm, self).__init__(*args, **kwargs) + + # Limit LAG choices to interfaces belonging to this device + if self.device is not None: + self.fields['lag'].queryset = Interface.objects.order_naturally().filter( + device=self.device, form_factor=IFACE_FF_LAG + ) + else: + self.fields['lag'].queryset = Interface.objects.none() + class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) + device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput) + lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG') form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False) description = forms.CharField(max_length=100, required=False) class Meta: - nullable_fields = ['description'] + nullable_fields = ['lag', 'description'] + + def __init__(self, *args, **kwargs): + super(InterfaceBulkEditForm, self).__init__(*args, **kwargs) + + # Limit LAG choices to interfaces which belong to the parent device. + if self.initial.get('device'): + self.fields['lag'].queryset = Interface.objects.filter( + device=self.initial['device'], form_factor=IFACE_FF_LAG + ) + else: + self.fields['lag'].choices = [] # @@ -1360,8 +1408,11 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm): super(InterfaceConnectionForm, self).__init__(*args, **kwargs) # Initialize interface A choices - device_a_interfaces = Interface.objects.filter(device=device_a).exclude(form_factor=IFACE_FF_VIRTUAL)\ - .select_related('circuit_termination', 'connected_as_a', 'connected_as_b') + device_a_interfaces = Interface.objects.filter(device=device_a).exclude( + form_factor__in=VIRTUAL_IFACE_TYPES + ).select_related( + 'circuit_termination', 'connected_as_a', 'connected_as_b' + ) self.fields['interface_a'].choices = [ (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_a_interfaces ] @@ -1388,13 +1439,17 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm): # Initialize interface_b choices if device_b is set if self.is_bound: - device_b_interfaces = Interface.objects.filter(device=self.data['device_b'])\ - .exclude(form_factor=IFACE_FF_VIRTUAL)\ - .select_related('circuit_termination', 'connected_as_a', 'connected_as_b') + device_b_interfaces = Interface.objects.filter(device=self.data['device_b']).exclude( + form_factor__in=VIRTUAL_IFACE_TYPES + ).select_related( + 'circuit_termination', 'connected_as_a', 'connected_as_b' + ) elif self.initial.get('device_b'): - device_b_interfaces = Interface.objects.filter(device=self.initial['device_b'])\ - .exclude(form_factor=IFACE_FF_VIRTUAL)\ - .select_related('circuit_termination', 'connected_as_a', 'connected_as_b') + device_b_interfaces = Interface.objects.filter(device=self.initial['device_b']).exclude( + form_factor__in=VIRTUAL_IFACE_TYPES + ).select_related( + 'circuit_termination', 'connected_as_a', 'connected_as_b' + ) else: device_b_interfaces = [] self.fields['interface_b'].choices = [ @@ -1512,7 +1567,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm): } -class DeviceBayCreateForm(BootstrapMixin, forms.Form): +class DeviceBayCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') diff --git a/netbox/dcim/migrations/0030_interface_add_lag.py b/netbox/dcim/migrations/0030_interface_add_lag.py new file mode 100644 index 000000000..6f5be67a4 --- /dev/null +++ b/netbox/dcim/migrations/0030_interface_add_lag.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-02-27 19:55 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0029_allow_rackless_devices'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='lag', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='member_interfaces', to='dcim.Interface', verbose_name=b'Parent LAG'), + ), + migrations.AlterField( + model_name='interface', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual'], [200, b'Link Aggregation Group (LAG)']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5100, b'Cisco FlexStack'], [5150, b'Cisco FlexStack Plus']]], [b'Other', [[32767, b'Other']]]], default=1200), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual'], [200, b'Link Aggregation Group (LAG)']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5100, b'Cisco FlexStack'], [5150, b'Cisco FlexStack Plus']]], [b'Other', [[32767, b'Other']]]], default=1200), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 64b0458b4..f589c49a2 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -68,6 +68,7 @@ IFACE_ORDERING_CHOICES = [ # Virtual IFACE_FF_VIRTUAL = 0 +IFACE_FF_LAG = 200 # Ethernet IFACE_FF_100ME_FIXED = 800 IFACE_FF_1GE_FIXED = 1000 @@ -106,6 +107,7 @@ IFACE_FF_CHOICES = [ 'Virtual interfaces', [ [IFACE_FF_VIRTUAL, 'Virtual'], + [IFACE_FF_LAG, 'Link Aggregation Group (LAG)'], ] ], [ @@ -148,6 +150,7 @@ IFACE_FF_CHOICES = [ [IFACE_FF_E1, 'E1 (2.048 Mbps)'], [IFACE_FF_T3, 'T3 (45 Mbps)'], [IFACE_FF_E3, 'E3 (34 Mbps)'], + [IFACE_FF_E3, 'E3 (34 Mbps)'], ] ], [ @@ -167,6 +170,11 @@ IFACE_FF_CHOICES = [ ], ] +VIRTUAL_IFACE_TYPES = [ + IFACE_FF_VIRTUAL, + IFACE_FF_LAG, +] + STATUS_ACTIVE = True STATUS_OFFLINE = False STATUS_CHOICES = [ @@ -1062,6 +1070,10 @@ class Device(CreatedUpdatedModel, CustomFieldModel): return RPC_CLIENTS.get(self.platform.rpc_client) +# +# Console ports +# + @python_2_unicode_compatible class ConsolePort(models.Model): """ @@ -1091,6 +1103,10 @@ class ConsolePort(models.Model): ]) +# +# Console server ports +# + class ConsoleServerPortManager(models.Manager): def get_queryset(self): @@ -1123,6 +1139,10 @@ class ConsoleServerPort(models.Model): return self.name +# +# Power ports +# + @python_2_unicode_compatible class PowerPort(models.Model): """ @@ -1152,6 +1172,10 @@ class PowerPort(models.Model): ]) +# +# Power outlets +# + class PowerOutletManager(models.Manager): def get_queryset(self): @@ -1178,6 +1202,10 @@ class PowerOutlet(models.Model): return self.name +# +# Interfaces +# + @python_2_unicode_compatible class Interface(models.Model): """ @@ -1185,6 +1213,8 @@ class Interface(models.Model): of an InterfaceConnection. """ device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE) + lag = models.ForeignKey('self', related_name='member_interfaces', null=True, blank=True, on_delete=models.SET_NULL, + verbose_name='Parent LAG') name = models.CharField(max_length=30) form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS) mac_address = MACAddressField(null=True, blank=True, verbose_name='MAC Address') @@ -1203,15 +1233,42 @@ class Interface(models.Model): def clean(self): - if self.form_factor == IFACE_FF_VIRTUAL and self.is_connected: + # Virtual interfaces cannot be connected + if self.form_factor in VIRTUAL_IFACE_TYPES and self.is_connected: raise ValidationError({ 'form_factor': "Virtual interfaces cannot be connected to another interface or circuit. Disconnect the " "interface or choose a physical form factor." }) + # An interface's LAG must belong to the same device + if self.lag and self.lag.device != self.device: + raise ValidationError({ + 'lag': u"The selected LAG interface ({}) belongs to a different device ({}).".format( + self.lag.name, self.lag.device.name + ) + }) + + # A LAG interface cannot have a parent LAG + if self.form_factor == IFACE_FF_LAG and self.lag is not None: + raise ValidationError({ + 'lag': u"{} interfaces cannot have a parent LAG interface.".format(self.get_form_factor_display()) + }) + + # Only a LAG can have LAG members + if self.form_factor != IFACE_FF_LAG and self.member_interfaces.exists(): + raise ValidationError({ + 'form_factor': "Cannot change interface form factor; it has LAG members ({}).".format( + u", ".join([iface.name for iface in self.member_interfaces.all()]) + ) + }) + @property - def is_physical(self): - return self.form_factor != IFACE_FF_VIRTUAL + def is_virtual(self): + return self.form_factor in VIRTUAL_IFACE_TYPES + + @property + def is_lag(self): + return self.form_factor == IFACE_FF_LAG @property def is_connected(self): @@ -1275,6 +1332,10 @@ class InterfaceConnection(models.Model): ]) +# +# Device bays +# + @python_2_unicode_compatible class DeviceBay(models.Model): """ @@ -1305,6 +1366,10 @@ class DeviceBay(models.Model): raise ValidationError("Cannot install a device into itself.") +# +# Modules +# + @python_2_unicode_compatible class Module(models.Model): """ diff --git a/netbox/dcim/tests/test_apis.py b/netbox/dcim/tests/test_apis.py index 97bc8a46c..672a4fb6f 100644 --- a/netbox/dcim/tests/test_apis.py +++ b/netbox/dcim/tests/test_apis.py @@ -561,6 +561,7 @@ class InterfaceTest(APITestCase): 'device', 'name', 'form_factor', + 'lag', 'mac_address', 'mgmt_only', 'description', @@ -574,6 +575,7 @@ class InterfaceTest(APITestCase): 'device', 'name', 'form_factor', + 'lag', 'mac_address', 'mgmt_only', 'description', diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index df93e61f3..243d97e92 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -66,11 +66,12 @@ class ComponentCreateView(View): def get(self, request, pk): parent = get_object_or_404(self.parent_model, pk=pk) + form = self.form(parent, initial=request.GET) return render(request, 'dcim/device_component_add.html', { 'parent': parent, 'component_type': self.model._meta.verbose_name, - 'form': self.form(initial=request.GET), + 'form': form, 'return_url': parent.get_absolute_url(), }) @@ -78,7 +79,7 @@ class ComponentCreateView(View): parent = get_object_or_404(self.parent_model, pk=pk) - form = self.form(request.POST) + form = self.form(parent, request.POST) if form.is_valid(): new_components = [] 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/ipam/views.py b/netbox/ipam/views.py index 191d33a90..6eef522ec 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -297,9 +297,17 @@ def aggregate(request, pk): prefix_table.base_columns['pk'].visible = True RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(prefix_table) + # Compile permissions list for rendering the object table + permissions = { + 'add': request.user.has_perm('ipam.add_prefix'), + 'change': request.user.has_perm('ipam.change_prefix'), + 'delete': request.user.has_perm('ipam.delete_prefix'), + } + return render(request, 'ipam/aggregate.html', { 'aggregate': aggregate, 'prefix_table': prefix_table, + 'permissions': permissions, }) @@ -425,6 +433,13 @@ def prefix(request, pk): child_prefix_table.base_columns['pk'].visible = True RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(child_prefix_table) + # Compile permissions list for rendering the object table + permissions = { + 'add': request.user.has_perm('ipam.add_prefix'), + 'change': request.user.has_perm('ipam.change_prefix'), + 'delete': request.user.has_perm('ipam.delete_prefix'), + } + return render(request, 'ipam/prefix.html', { 'prefix': prefix, 'aggregate': aggregate, @@ -432,6 +447,7 @@ def prefix(request, pk): 'parent_prefix_table': parent_prefix_table, 'child_prefix_table': child_prefix_table, 'duplicate_prefix_table': duplicate_prefix_table, + 'permissions': permissions, 'return_url': prefix.get_absolute_url(), }) @@ -490,9 +506,17 @@ def prefix_ipaddresses(request, pk): ip_table.base_columns['pk'].visible = True RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(ip_table) + # Compile permissions list for rendering the object table + permissions = { + 'add': request.user.has_perm('ipam.add_ipaddress'), + 'change': request.user.has_perm('ipam.change_ipaddress'), + 'delete': request.user.has_perm('ipam.delete_ipaddress'), + } + return render(request, 'ipam/prefix_ipaddresses.html', { 'prefix': prefix, 'ip_table': ip_table, + 'permissions': permissions, }) diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 653575f2b..99e06177b 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -396,6 +396,7 @@ {% if perms.dcim.delete_interface %}