Browse Source

Merge branch 'develop' into api2

Conflicts:
	netbox/dcim/api/serializers.py
	netbox/dcim/api/views.py
	netbox/dcim/filters.py
Jeremy Stretch 8 years ago
parent
commit
8f42f59a80

+ 1 - 1
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.
 
 ---
 

+ 4 - 2
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.
 
 ---
 

+ 11 - 7
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 = []

+ 2 - 3
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']
 
 
 #

+ 17 - 3
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):
 

+ 79 - 24
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)
+
+        # 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(BootstrapMixin, forms.Form):
+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')
 
 

File diff suppressed because it is too large
+ 31 - 0
netbox/dcim/migrations/0030_interface_add_lag.py


+ 68 - 3
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):
     """

+ 2 - 0
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',

+ 3 - 2
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 = []

+ 6 - 6
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)',

+ 13 - 8
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',

+ 26 - 0
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'),
+        ),
+    ]

+ 5 - 3
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,

+ 24 - 0
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,
     })
 
 

+ 1 - 0
netbox/templates/dcim/device.html

@@ -396,6 +396,7 @@
             {% if perms.dcim.delete_interface %}
                 <form method="post">
                 {% csrf_token %}
+                <input type="hidden" name="device" value="{{ device.pk }}" />
             {% endif %}
             <div class="panel panel-default">
                 <div class="panel-heading">

+ 1 - 1
netbox/templates/dcim/device_component_add.html

@@ -3,7 +3,7 @@
 
 {% block title %}Create {{ component_type }} ({{ parent }}){% endblock %}
 
-{% block content %}{{ form.errors }}
+{% block content %}
 <form action="." method="post" class="form form-horizontal">
     {% csrf_token %}
     <div class="row">

+ 8 - 2
netbox/templates/dcim/inc/interface.html

@@ -6,14 +6,20 @@
     {% endif %}
     <td>
         <i class="fa fa-fw fa-{{ icon|default:"exchange" }}"></i> <span title="{{ iface.get_form_factor_display }}">{{ iface.name }}</span>
+        {% if iface.lag %}
+            <span class="label label-primary">{{ iface.lag.name }}</span>
+        {% endif %}
         {% if iface.description %}
             <i class="fa fa-fw fa-comment-o" title="{{ iface.description }}"></i>
         {% endif %}
+        {% if iface.is_lag %}
+            <br /><small class="text-muted">{{ iface.member_interfaces.all|join:", "|default:"No members" }}</small>
+        {% endif %}
     </td>
     <td>
         <small>{{ iface.mac_address|default:'' }}</small>
     </td>
-    {% if not iface.is_physical %}
+    {% if iface.is_virtual %}
         <td colspan="2" class="text-muted">Virtual interface</td>
     {% elif iface.connection %}
         {% with iface.connected_interface as connected_iface %}
@@ -48,7 +54,7 @@
             {% endif %}
         {% endif %}
         {% if perms.dcim.change_interface %}
-            {% if iface.is_physical %}
+            {% if not iface.is_virtual %}
                 {% if iface.connection %}
                     {% if iface.connection.connection_status %}
                         <a href="#" class="btn btn-warning btn-xs interface-toggle connected" data="{{ iface.connection.pk }}" title="Mark planned">

+ 11 - 3
netbox/templates/ipam/vlan.html

@@ -8,9 +8,11 @@
     <div class="col-sm-8 col-md-9">
         <ol class="breadcrumb">
             <li><a href="{% url 'ipam:vlan_list' %}">VLANs</a></li>
-            <li><a href="{% url 'ipam:vlan_list' %}?site={{ vlan.site.slug }}">{{ vlan.site }}</a></li>
+            {% if vlan.site %}
+                <li><a href="{% url 'ipam:vlan_list' %}?site={{ vlan.site.slug }}">{{ vlan.site }}</a></li>
+            {% endif %}
             {% if vlan.group %}
-                <li><a href="{% url 'ipam:vlan_list' %}?site={{ vlan.site.slug }}&group={{ vlan.group.slug }}">{{ vlan.group.name }}</a></li>
+                <li><a href="{% url 'ipam:vlan_list' %}?group={{ vlan.group.slug }}">{{ vlan.group.name }}</a></li>
             {% endif %}
             <li>{{ vlan.name }} ({{ vlan.vid }})</li>
         </ol>
@@ -53,7 +55,13 @@
             <table class="table table-hover panel-body attr-table">
                 <tr>
                     <td>Site</td>
-                    <td><a href="{% url 'dcim:site' slug=vlan.site.slug %}">{{ vlan.site }}</a></td>
+                    <td>
+                        {% if vlan.site %}
+                            <a href="{% url 'dcim:site' slug=vlan.site.slug %}">{{ vlan.site }}</a>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
                 </tr>
                 <tr>
                     <td>Group</td>

+ 6 - 3
netbox/utilities/views.py

@@ -307,11 +307,12 @@ class BulkAddView(View):
         if form.is_valid():
 
             # The first field will be used as the pattern
-            pattern_field = form.fields.keys()[0]
+            field_names = list(form.fields.keys())
+            pattern_field = field_names[0]
             pattern = form.cleaned_data[pattern_field]
 
             # All other fields will be copied as object attributes
-            kwargs = {k: form.cleaned_data[k] for k in form.fields.keys()[1:]}
+            kwargs = {k: form.cleaned_data[k] for k in field_names[1:]}
 
             new_objs = []
             try:
@@ -470,7 +471,9 @@ class BulkEditView(View):
                 return redirect(return_url)
 
         else:
-            form = self.form(self.cls, initial={'pk': pk_list})
+            initial_data = request.POST.copy()
+            initial_data['pk'] = pk_list
+            form = self.form(self.cls, initial=initial_data)
 
         selected_objects = self.cls.objects.filter(pk__in=pk_list)
         if not selected_objects: