Просмотр исходного кода

Merge pull request #1181 from digitalocean/develop

Release v2.0.2
Jeremy Stretch 8 лет назад
Родитель
Сommit
43e1e0dbc8

+ 1 - 1
docs/installation/web-server.md

@@ -25,7 +25,7 @@ server {
 
     server_name netbox.example.com;
 
-    access_log off;
+    client_max_body_size 25m;
 
     location /static/ {
         alias /opt/netbox/netbox/static/;

+ 28 - 51
netbox/circuits/forms.py

@@ -3,10 +3,11 @@ from django.db.models import Count
 
 from dcim.models import Site, Device, Interface, Rack, VIRTUAL_IFACE_TYPES
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
+from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
 from utilities.forms import (
-    APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, Livesearch, SmallTextarea,
-    SlugField,
+    APISelect, BootstrapMixin, BulkImportForm, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVDataField,
+    FilterChoiceField, Livesearch, SmallTextarea, SlugField,
 )
 
 from .models import Circuit, CircuitTermination, CircuitType, Provider
@@ -83,12 +84,15 @@ class CircuitTypeForm(BootstrapMixin, forms.ModelForm):
 # Circuits
 #
 
-class CircuitForm(BootstrapMixin, CustomFieldForm):
+class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
     comments = CommentField()
 
     class Meta:
         model = Circuit
-        fields = ['cid', 'type', 'provider', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
+        fields = [
+            'cid', 'type', 'provider', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
+            'comments',
+        ]
         help_texts = {
             'cid': "Unique circuit ID",
             'install_date': "Format: YYYY-MM-DD",
@@ -152,15 +156,16 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
 # Circuit terminations
 #
 
-class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
+class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
     site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
         widget=forms.Select(
             attrs={'filter-for': 'rack'}
         )
     )
-    rack = forms.ModelChoiceField(
+    rack = ChainedModelChoiceField(
         queryset=Rack.objects.all(),
+        chains={'site': 'site'},
         required=False,
         label='Rack',
         widget=APISelect(
@@ -168,8 +173,9 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
             attrs={'filter-for': 'device', 'nullable': 'true'}
         )
     )
-    device = forms.ModelChoiceField(
+    device = ChainedModelChoiceField(
         queryset=Device.objects.all(),
+        chains={'site': 'site', 'rack': 'rack'},
         required=False,
         label='Device',
         widget=APISelect(
@@ -187,8 +193,11 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
             field_to_update='device'
         )
     )
-    interface = forms.ModelChoiceField(
-        queryset=Interface.objects.all(),
+    interface = ChainedModelChoiceField(
+        queryset=Interface.objects.exclude(form_factor__in=VIRTUAL_IFACE_TYPES).select_related(
+            'circuit_termination', 'connected_as_a', 'connected_as_b'
+        ),
+        chains={'device': 'device'},
         required=False,
         label='Interface',
         widget=APISelect(
@@ -212,49 +221,17 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
 
     def __init__(self, *args, **kwargs):
 
+        # Initialize helper selectors
+        instance = kwargs.get('instance')
+        if instance and instance.interface is not None:
+            initial = kwargs.get('initial', {})
+            initial['rack'] = instance.interface.device.rack
+            initial['device'] = instance.interface.device
+            kwargs['initial'] = initial
+
         super(CircuitTerminationForm, self).__init__(*args, **kwargs)
 
-        # If an interface has been assigned, initialize rack and device
-        if self.instance.interface:
-            self.initial['rack'] = self.instance.interface.device.rack
-            self.initial['device'] = self.instance.interface.device
-
-        # Limit rack choices
-        if self.is_bound:
-            self.fields['rack'].queryset = Rack.objects.filter(site__pk=self.data['site'])
-        elif self.initial.get('site'):
-            self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
-        else:
-            self.fields['rack'].choices = []
-
-        # Limit device choices
-        if self.is_bound and self.data.get('rack'):
-            self.fields['device'].queryset = Device.objects.filter(rack=self.data['rack'])
-        elif self.initial.get('rack'):
-            self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack'])
-        else:
-            self.fields['device'].choices = []
-
-        # Limit interface choices
-        if self.is_bound and self.data.get('device'):
-            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__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 = []
+        # Mark connected interfaces as disabled
         self.fields['interface'].choices = [
-            (iface.id, {
-                'label': iface.name,
-                'disabled': iface.is_connected and iface.id != self.fields['interface'].widget.attrs.get('initial'),
-            }) for iface in interfaces
+            (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in self.fields['interface'].queryset
         ]

+ 7 - 1
netbox/circuits/tables.py

@@ -79,7 +79,13 @@ class CircuitSearchTable(SearchTable):
     cid = tables.LinkColumn(verbose_name='ID')
     provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')])
     tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+    a_side = tables.LinkColumn(
+        'dcim:site', accessor=Accessor('termination_a.site'), args=[Accessor('termination_a.site.slug')]
+    )
+    z_side = tables.LinkColumn(
+        'dcim:site', accessor=Accessor('termination_z.site'), args=[Accessor('termination_z.site.slug')]
+    )
 
     class Meta(SearchTable.Meta):
         model = Circuit
-        fields = ('cid', 'type', 'provider', 'tenant', 'description')
+        fields = ('cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'description')

+ 1 - 0
netbox/dcim/api/serializers.py

@@ -608,6 +608,7 @@ class InterfaceSerializer(serializers.ModelSerializer):
 class PeerInterfaceSerializer(serializers.ModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
     device = NestedDeviceSerializer()
+    form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
 
     class Meta:
         model = Interface

+ 106 - 202
netbox/dcim/forms.py

@@ -8,11 +8,13 @@ from django.db.models import Count, Q
 
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from ipam.models import IPAddress
+from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
 from utilities.forms import (
     APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
-    BulkImportForm, CommentField, CSVDataField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField,
-    Livesearch, SelectWithDisabled, SmallTextarea, SlugField, FilterTreeNodeMultipleChoiceField,
+    BulkImportForm, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVDataField, ExpandableNameField,
+    FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
+    FilterTreeNodeMultipleChoiceField,
 )
 
 from .formfields import MACAddressFormField
@@ -80,7 +82,7 @@ class RegionForm(BootstrapMixin, forms.ModelForm):
 # Sites
 #
 
-class SiteForm(BootstrapMixin, CustomFieldForm):
+class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
     region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False)
     slug = SlugField()
     comments = CommentField()
@@ -88,8 +90,8 @@ class SiteForm(BootstrapMixin, CustomFieldForm):
     class Meta:
         model = Site
         fields = [
-            'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
-            'contact_name', 'contact_phone', 'contact_email', 'comments',
+            'name', 'slug', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'physical_address',
+            'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
         ]
         widgets = {
             'physical_address': SmallTextarea(attrs={'rows': 3}),
@@ -184,16 +186,23 @@ class RackRoleForm(BootstrapMixin, forms.ModelForm):
 # Racks
 #
 
-class RackForm(BootstrapMixin, CustomFieldForm):
-    group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group', widget=APISelect(
-        api_url='/api/dcim/rack-groups/?site_id={{site}}',
-    ))
+class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+    group = ChainedModelChoiceField(
+        queryset=RackGroup.objects.all(),
+        chains={'site': 'site'},
+        required=False,
+        widget=APISelect(
+            api_url='/api/dcim/rack-groups/?site_id={{site}}',
+        )
+    )
     comments = CommentField()
 
     class Meta:
         model = Rack
-        fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units',
-                  'comments']
+        fields = [
+            'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'role', 'type', 'width', 'u_height',
+            'desc_units', 'comments',
+        ]
         help_texts = {
             'site': "The site at which the rack exists",
             'name': "Organizational rack name",
@@ -204,18 +213,6 @@ class RackForm(BootstrapMixin, CustomFieldForm):
             'site': forms.Select(attrs={'filter-for': 'group'}),
         }
 
-    def __init__(self, *args, **kwargs):
-
-        super(RackForm, self).__init__(*args, **kwargs)
-
-        # Limit rack group choices
-        if self.is_bound and self.data.get('site'):
-            self.fields['group'].queryset = RackGroup.objects.filter(site__pk=self.data['site'])
-        elif self.initial.get('site'):
-            self.fields['group'].queryset = RackGroup.objects.filter(site=self.initial['site'])
-        else:
-            self.fields['group'].choices = []
-
 
 class RackFromCSVForm(forms.ModelForm):
     site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name',
@@ -538,33 +535,54 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
 # Devices
 #
 
-class DeviceForm(BootstrapMixin, CustomFieldForm):
-    site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
-    rack = forms.ModelChoiceField(
-        queryset=Rack.objects.all(), required=False, widget=APISelect(
+class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+    site = forms.ModelChoiceField(
+        queryset=Site.objects.all(),
+        widget=forms.Select(
+            attrs={'filter-for': 'rack'}
+        )
+    )
+    rack = ChainedModelChoiceField(
+        queryset=Rack.objects.all(),
+        chains={'site': 'site'},
+        required=False,
+        widget=APISelect(
             api_url='/api/dcim/racks/?site_id={{site}}',
             display_field='display_name',
             attrs={'filter-for': 'position'}
         )
     )
     position = forms.TypedChoiceField(
-        required=False, empty_value=None, help_text="The lowest-numbered unit occupied by the device",
-        widget=APISelect(api_url='/api/dcim/racks/{{rack}}/units/?face={{face}}', disabled_indicator='device')
+        required=False,
+        empty_value=None,
+        help_text="The lowest-numbered unit occupied by the device",
+        widget=APISelect(
+            api_url='/api/dcim/racks/{{rack}}/units/?face={{face}}',
+            disabled_indicator='device'
+        )
     )
     manufacturer = forms.ModelChoiceField(
-        queryset=Manufacturer.objects.all(), widget=forms.Select(attrs={'filter-for': 'device_type'})
+        queryset=Manufacturer.objects.all(),
+        widget=forms.Select(
+            attrs={'filter-for': 'device_type'}
+        )
     )
-    device_type = forms.ModelChoiceField(
-        queryset=DeviceType.objects.all(), label='Device type',
-        widget=APISelect(api_url='/api/dcim/device-types/?manufacturer_id={{manufacturer}}', display_field='model')
+    device_type = ChainedModelChoiceField(
+        queryset=DeviceType.objects.all(),
+        chains={'manufacturer': 'manufacturer'},
+        label='Device type',
+        widget=APISelect(
+            api_url='/api/dcim/device-types/?manufacturer_id={{manufacturer}}',
+            display_field='model'
+        )
     )
     comments = CommentField()
 
     class Meta:
         model = Device
         fields = [
-            'name', 'device_role', 'tenant', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face',
-            'status', 'platform', 'primary_ip4', 'primary_ip6', 'comments',
+            'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', 'status',
+            'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments',
         ]
         help_texts = {
             'device_role': "The function this device serves",
@@ -572,19 +590,22 @@ class DeviceForm(BootstrapMixin, CustomFieldForm):
         }
         widgets = {
             'face': forms.Select(attrs={'filter-for': 'position'}),
-            'manufacturer': forms.Select(attrs={'filter-for': 'device_type'}),
         }
 
     def __init__(self, *args, **kwargs):
 
+        # Initialize helper selectors
+        instance = kwargs.get('instance')
+        # Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field
+        if instance and hasattr(instance, 'device_type'):
+            initial = kwargs.get('initial', {})
+            initial['manufacturer'] = instance.device_type.manufacturer
+            kwargs['initial'] = initial
+
         super(DeviceForm, self).__init__(*args, **kwargs)
 
         if self.instance.pk:
 
-            # Initialize helper selections
-            self.initial['site'] = self.instance.site
-            self.initial['manufacturer'] = self.instance.device_type.manufacturer
-
             # Compile list of choices for primary IPv4 and IPv6 addresses
             for family in [4, 6]:
                 ip_choices = []
@@ -607,14 +628,6 @@ class DeviceForm(BootstrapMixin, CustomFieldForm):
             self.fields['primary_ip6'].choices = []
             self.fields['primary_ip6'].widget.attrs['readonly'] = True
 
-        # Limit rack choices
-        if self.is_bound and self.data.get('site'):
-            self.fields['rack'].queryset = Rack.objects.filter(site__pk=self.data['site'])
-        elif self.initial.get('site'):
-            self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
-        else:
-            self.fields['rack'].choices = []
-
         # Rack position
         pk = self.instance.pk if self.instance.pk else None
         try:
@@ -635,16 +648,6 @@ class DeviceForm(BootstrapMixin, CustomFieldForm):
             }) for p in position_choices
         ]
 
-        # Limit device_type choices
-        if self.is_bound:
-            self.fields['device_type'].queryset = DeviceType.objects.filter(manufacturer__pk=self.data['manufacturer'])\
-                .select_related('manufacturer')
-        elif self.initial.get('manufacturer'):
-            self.fields['device_type'].queryset = DeviceType.objects.filter(manufacturer=self.initial['manufacturer'])\
-                .select_related('manufacturer')
-        else:
-            self.fields['device_type'].choices = []
-
         # Disable rack assignment if this is a child device installed in a parent device
         if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
             self.fields['site'].disabled = True
@@ -811,6 +814,10 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
         queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks__devices')),
         label='Rack group',
     )
+    rack_id = FilterChoiceField(
+        queryset=Rack.objects.annotate(filter_count=Count('devices')),
+        label='Rack',
+    )
     role = FilterChoiceField(
         queryset=DeviceRole.objects.annotate(filter_count=Count('devices')),
         to_field_name='slug',
@@ -940,21 +947,23 @@ class ConsoleConnectionImportForm(BootstrapMixin, BulkImportForm):
         self.cleaned_data['csv'] = connection_list
 
 
-class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm):
+class ConsolePortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
     site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
         widget=forms.HiddenInput(),
     )
-    rack = forms.ModelChoiceField(
+    rack = ChainedModelChoiceField(
         queryset=Rack.objects.all(),
+        chains={'site': 'site'},
         label='Rack',
         required=False,
         widget=forms.Select(
             attrs={'filter-for': 'console_server', 'nullable': 'true'}
         )
     )
-    console_server = forms.ModelChoiceField(
-        queryset=Device.objects.all(),
+    console_server = ChainedModelChoiceField(
+        queryset=Device.objects.filter(device_type__is_console_server=True),
+        chains={'site': 'site', 'rack': 'rack'},
         label='Console Server',
         required=False,
         widget=APISelect(
@@ -972,8 +981,9 @@ class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm):
             field_to_update='console_server',
         )
     )
-    cs_port = forms.ModelChoiceField(
+    cs_port = ChainedModelChoiceField(
         queryset=ConsoleServerPort.objects.all(),
+        chains={'device': 'console_server'},
         label='Port',
         widget=APISelect(
             api_url='/api/dcim/console-server-ports/?device_id={{console_server}}',
@@ -996,32 +1006,6 @@ class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm):
         if not self.instance.pk:
             raise RuntimeError("ConsolePortConnectionForm must be initialized with an existing ConsolePort instance.")
 
-        # Initialize rack choices if site is set
-        if self.initial.get('site'):
-            self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
-        else:
-            self.fields['rack'].choices = []
-
-        # Initialize console_server choices if rack or site is set
-        if self.initial.get('rack'):
-            self.fields['console_server'].queryset = Device.objects.filter(
-                rack=self.initial['rack'], device_type__is_console_server=True
-            )
-        elif self.initial.get('site'):
-            self.fields['console_server'].queryset = Device.objects.filter(
-                site=self.initial['site'], rack__isnull=True, device_type__is_console_server=True
-            )
-        else:
-            self.fields['console_server'].choices = []
-
-        # Initialize CS port choices if console_server is set
-        if self.initial.get('console_server'):
-            self.fields['cs_port'].queryset = ConsoleServerPort.objects.filter(
-                device=self.initial['console_server']
-            )
-        else:
-            self.fields['cs_port'].choices = []
-
 
 #
 # Console server ports
@@ -1041,21 +1025,23 @@ class ConsoleServerPortCreateForm(DeviceComponentForm):
     name_pattern = ExpandableNameField(label='Name')
 
 
-class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form):
+class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
     site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
         widget=forms.HiddenInput(),
     )
-    rack = forms.ModelChoiceField(
+    rack = ChainedModelChoiceField(
         queryset=Rack.objects.all(),
+        chains={'site': 'site'},
         label='Rack',
         required=False,
         widget=forms.Select(
             attrs={'filter-for': 'device', 'nullable': 'true'}
         )
     )
-    device = forms.ModelChoiceField(
+    device = ChainedModelChoiceField(
         queryset=Device.objects.all(),
+        chains={'site': 'site', 'rack': 'rack'},
         label='Device',
         required=False,
         widget=APISelect(
@@ -1073,8 +1059,9 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form):
             field_to_update='device'
         )
     )
-    port = forms.ModelChoiceField(
+    port = ChainedModelChoiceField(
         queryset=ConsolePort.objects.all(),
+        chains={'device': 'device'},
         label='Port',
         widget=APISelect(
             api_url='/api/dcim/console-ports/?device_id={{device}}',
@@ -1096,30 +1083,6 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form):
             'connection_status': 'Status',
         }
 
-    def __init__(self, *args, **kwargs):
-
-        super(ConsoleServerPortConnectionForm, self).__init__(*args, **kwargs)
-
-        # Initialize rack choices if site is set
-        if self.initial.get('site'):
-            self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
-        else:
-            self.fields['rack'].choices = []
-
-        # Initialize device choices if rack or site is set
-        if self.initial.get('rack'):
-            self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack'])
-        elif self.initial.get('site'):
-            self.fields['device'].queryset = Device.objects.filter(site=self.initial['site'], rack__isnull=True)
-        else:
-            self.fields['device'].choices = []
-
-        # Initialize port choices if device is set
-        if self.initial.get('device'):
-            self.fields['port'].queryset = ConsolePort.objects.filter(device=self.initial['device'])
-        else:
-            self.fields['port'].choices = []
-
 
 #
 # Power ports
@@ -1211,18 +1174,20 @@ class PowerConnectionImportForm(BootstrapMixin, BulkImportForm):
         self.cleaned_data['csv'] = connection_list
 
 
-class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm):
+class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
     site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.HiddenInput())
-    rack = forms.ModelChoiceField(
+    rack = ChainedModelChoiceField(
         queryset=Rack.objects.all(),
+        chains={'site': 'site'},
         label='Rack',
         required=False,
         widget=forms.Select(
             attrs={'filter-for': 'pdu', 'nullable': 'true'}
         )
     )
-    pdu = forms.ModelChoiceField(
+    pdu = ChainedModelChoiceField(
         queryset=Device.objects.all(),
+        chains={'site': 'site', 'rack': 'rack'},
         label='PDU',
         required=False,
         widget=APISelect(
@@ -1240,8 +1205,9 @@ class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm):
             field_to_update='pdu'
         )
     )
-    power_outlet = forms.ModelChoiceField(
+    power_outlet = ChainedModelChoiceField(
         queryset=PowerOutlet.objects.all(),
+        chains={'device': 'device'},
         label='Outlet',
         widget=APISelect(
             api_url='/api/dcim/power-outlets/?device_id={{pdu}}',
@@ -1264,30 +1230,6 @@ class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm):
         if not self.instance.pk:
             raise RuntimeError("PowerPortConnectionForm must be initialized with an existing PowerPort instance.")
 
-        # Initialize rack choices if site is set
-        if self.initial.get('site'):
-            self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
-        else:
-            self.fields['rack'].choices = []
-
-        # Initialize pdu choices if rack or site is set
-        if self.initial.get('rack'):
-            self.fields['pdu'].queryset = Device.objects.filter(
-                rack=self.initial['rack'], device_type__is_pdu=True
-            )
-        elif self.initial.get('site'):
-            self.fields['pdu'].queryset = Device.objects.filter(
-                site=self.initial['site'], rack__isnull=True, device_type__is_pdu=True
-            )
-        else:
-            self.fields['pdu'].choices = []
-
-        # Initialize power outlet choices if pdu is set
-        if self.initial.get('pdu'):
-            self.fields['power_outlet'].queryset = PowerOutlet.objects.filter(device=self.initial['pdu'])
-        else:
-            self.fields['power_outlet'].choices = []
-
 
 #
 # Power outlets
@@ -1307,21 +1249,23 @@ class PowerOutletCreateForm(DeviceComponentForm):
     name_pattern = ExpandableNameField(label='Name')
 
 
-class PowerOutletConnectionForm(BootstrapMixin, forms.Form):
+class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
     site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
         widget=forms.HiddenInput()
     )
-    rack = forms.ModelChoiceField(
+    rack = ChainedModelChoiceField(
         queryset=Rack.objects.all(),
+        chains={'site': 'site'},
         label='Rack',
         required=False,
         widget=forms.Select(
             attrs={'filter-for': 'device', 'nullable': 'true'}
         )
     )
-    device = forms.ModelChoiceField(
+    device = ChainedModelChoiceField(
         queryset=Device.objects.all(),
+        chains={'site': 'site', 'rack': 'rack'},
         label='Device',
         required=False,
         widget=APISelect(
@@ -1339,8 +1283,9 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form):
             field_to_update='device'
         )
     )
-    port = forms.ModelChoiceField(
+    port = ChainedModelChoiceField(
         queryset=PowerPort.objects.all(),
+        chains={'device': 'device'},
         label='Port',
         widget=APISelect(
             api_url='/api/dcim/power-ports/?device_id={{device}}',
@@ -1362,30 +1307,6 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form):
             'connection_status': 'Status',
         }
 
-    def __init__(self, *args, **kwargs):
-
-        super(PowerOutletConnectionForm, self).__init__(*args, **kwargs)
-
-        # Initialize rack choices if site is set
-        if self.initial.get('site'):
-            self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
-        else:
-            self.fields['rack'].choices = []
-
-        # Initialize device choices if rack or site is set
-        if self.initial.get('rack'):
-            self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack'])
-        elif self.initial.get('site'):
-            self.fields['device'].queryset = Device.objects.filter(site=self.initial['site'], rack__isnull=True)
-        else:
-            self.fields['device'].choices = []
-
-        # Initialize port choices if device is set
-        if self.initial.get('device'):
-            self.fields['port'].queryset = PowerPort.objects.filter(device=self.initial['device'])
-        else:
-            self.fields['port'].choices = []
-
 
 #
 # Interfaces
@@ -1468,7 +1389,7 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
 # Interface connections
 #
 
-class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
+class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
     interface_a = forms.ChoiceField(
         choices=[],
         widget=SelectWithDisabled,
@@ -1482,8 +1403,9 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
             attrs={'filter-for': 'rack_b'}
         )
     )
-    rack_b = forms.ModelChoiceField(
+    rack_b = ChainedModelChoiceField(
         queryset=Rack.objects.all(),
+        chains={'site': 'site_b'},
         label='Rack',
         required=False,
         widget=APISelect(
@@ -1491,8 +1413,9 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
             attrs={'filter-for': 'device_b', 'nullable': 'true'}
         )
     )
-    device_b = forms.ModelChoiceField(
+    device_b = ChainedModelChoiceField(
         queryset=Device.objects.all(),
+        chains={'site': 'site_b', 'rack': 'rack_b'},
         label='Device',
         required=False,
         widget=APISelect(
@@ -1510,8 +1433,11 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
             field_to_update='device_b'
         )
     )
-    interface_b = forms.ModelChoiceField(
-        queryset=Interface.objects.all(),
+    interface_b = ChainedModelChoiceField(
+        queryset=Interface.objects.exclude(form_factor__in=VIRTUAL_IFACE_TYPES).select_related(
+            'circuit_termination', 'connected_as_a', 'connected_as_b'
+        ),
+        chains={'device': 'device_b'},
         label='Interface',
         widget=APISelect(
             api_url='/api/dcim/interfaces/?device_id={{device_b}}&type=physical',
@@ -1537,31 +1463,9 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
             (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_a_interfaces
         ]
 
-        # Initialize rack_b choices if site_b is set
-        if self.initial.get('site_b'):
-            self.fields['rack_b'].queryset = Rack.objects.filter(site=self.initial['site_b'])
-        else:
-            self.fields['rack_b'].choices = []
-
-        # Initialize device_b choices if rack_b or site_b is set
-        if self.initial.get('rack_b'):
-            self.fields['device_b'].queryset = Device.objects.filter(rack=self.initial['rack_b'])
-        elif self.initial.get('site_b'):
-            self.fields['device_b'].queryset = Device.objects.filter(site=self.initial['site_b'], rack__isnull=True)
-        else:
-            self.fields['device_b'].choices = []
-
-        # Initialize interface_b choices if device_b is set
-        if self.initial.get('device_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 = []
+        # Mark connected interfaces as disabled
         self.fields['interface_b'].choices = [
-            (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_b_interfaces
+            (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in self.fields['interface_b'].queryset
         ]
 
 

+ 16 - 16
netbox/dcim/models.py

@@ -410,7 +410,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
         ]
 
     def __str__(self):
-        return self.display_name
+        return self.display_name or super(Rack, self).__str__()
 
     def get_absolute_url(self):
         return reverse('dcim:rack', args=[self.pk])
@@ -467,7 +467,9 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
     def display_name(self):
         if self.facility_id:
             return u"{} ({})".format(self.name, self.facility_id)
-        return self.name
+        elif self.name:
+            return self.name
+        return u""
 
     def get_rack_units(self, face=RACK_FACE_FRONT, exclude=None, remove_redundant=False):
         """
@@ -810,13 +812,13 @@ class InterfaceManager(models.Manager):
 
     def order_naturally(self, method=IFACE_ORDERING_POSITION):
         """
-        Naturally order interfaces by their name and numeric position. The sort method must be one of the defined
+        Naturally order interfaces by their type and numeric position. The sort method must be one of the defined
         IFACE_ORDERING_CHOICES (typically indicated by a parent Device's DeviceType).
 
-        To order interfaces naturally, the `name` field is split into five distinct components: leading text (name),
+        To order interfaces naturally, the `name` field is split into six distinct components: leading text (type),
         slot, subslot, position, channel, and virtual circuit:
 
-            {name}{slot}/{subslot}/{position}:{channel}.{vc}
+            {type}{slot}/{subslot}/{position}:{channel}.{vc}
 
         Components absent from the interface name are ignored. For example, an interface named GigabitEthernet0/1 would
         be parsed as follows:
@@ -828,16 +830,17 @@ class InterfaceManager(models.Manager):
             channel = None
             vc = 0
 
-        The chosen sorting method will determine which fields are ordered first in the query.
+        The original `name` field is taken as a whole to serve as a fallback in the event interfaces do not match any of
+        the prescribed fields.
         """
         queryset = self.get_queryset()
         sql_col = '{}.name'.format(queryset.model._meta.db_table)
         ordering = {
-            IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_vc', '_name'),
-            IFACE_ORDERING_NAME: ('_name', '_slot', '_subslot', '_position', '_channel', '_vc'),
+            IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_vc', '_type', 'name'),
+            IFACE_ORDERING_NAME: ('_type', '_slot', '_subslot', '_position', '_channel', '_vc', 'name'),
         }[method]
         return queryset.extra(select={
-            '_name': "SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col),
+            '_type': "SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col),
             '_slot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
             '_subslot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
             '_position': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
@@ -983,7 +986,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
         unique_together = ['rack', 'position', 'face']
 
     def __str__(self):
-        return self.display_name
+        return self.display_name or super(Device, self).__str__()
 
     def get_absolute_url(self):
         return reverse('dcim:device', args=[self.pk])
@@ -1102,12 +1105,9 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
     def display_name(self):
         if self.name:
             return self.name
-        elif self.position:
-            return u"{} ({} U{})".format(self.device_type, self.rack.name, self.position)
-        elif self.rack:
-            return u"{} ({})".format(self.device_type, self.rack.name)
-        else:
-            return u"{} ({})".format(self.device_type, self.site.name)
+        elif hasattr(self, 'device_type'):
+            return u"{}".format(self.device_type)
+        return u""
 
     @property
     def identifier(self):

+ 3 - 0
netbox/dcim/views.py

@@ -105,6 +105,9 @@ class ComponentCreateView(View):
                     new_components.append(component_form.save(commit=False))
                 else:
                     for field, errors in component_form.errors.as_data().items():
+                        # Assign errors on the child form's name field to name_pattern on the parent form
+                        if field == 'name':
+                            field = 'name_pattern'
                         for e in errors:
                             form.add_error(field, u'{}: {}'.format(name, ', '.join(e)))
 

+ 8 - 4
netbox/extras/models.py

@@ -13,6 +13,8 @@ from django.template import Template, Context
 from django.utils.encoding import python_2_unicode_compatible
 from django.utils.safestring import mark_safe
 
+from utilities.utils import foreground_color
+
 
 CUSTOMFIELD_MODELS = (
     'site', 'rack', 'devicetype', 'device',                 # DCIM
@@ -316,7 +318,7 @@ class TopologyMap(models.Model):
     def render(self, img_format='png'):
 
         from circuits.models import CircuitTermination
-        from dcim.models import Device, InterfaceConnection
+        from dcim.models import CONNECTION_STATUS_CONNECTED, Device, InterfaceConnection
 
         # Construct the graph
         graph = graphviz.Graph()
@@ -336,8 +338,9 @@ class TopologyMap(models.Model):
             for query in device_set.split(';'):  # Split regexes on semicolons
                 devices += Device.objects.filter(name__regex=query).select_related('device_role')
             for d in devices:
-                fillcolor = '#{}'.format(d.device_role.color)
-                subgraph.node(d.name, style='filled', fillcolor=fillcolor)
+                bg_color = '#{}'.format(d.device_role.color)
+                fg_color = '#{}'.format(foreground_color(d.device_role.color))
+                subgraph.node(d.name, style='filled', fillcolor=bg_color, fontcolor=fg_color, fontname='sans')
 
             # Add an invisible connection to each successive device in a set to enforce horizontal order
             for j in range(0, len(devices) - 1):
@@ -357,7 +360,8 @@ class TopologyMap(models.Model):
             interface_a__device__in=devices, interface_b__device__in=devices
         )
         for c in connections:
-            graph.edge(c.interface_a.device.name, c.interface_b.device.name)
+            style = 'solid' if c.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
+            graph.edge(c.interface_a.device.name, c.interface_b.device.name, style=style)
 
         # Add all circuits to the graph
         for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices):

+ 132 - 128
netbox/ipam/forms.py

@@ -3,10 +3,11 @@ from django.db.models import Count
 
 from dcim.models import Site, Rack, Device, Interface
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
+from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
 from utilities.forms import (
-    APISelect, BootstrapMixin, BulkEditNullBooleanSelect, BulkImportForm, CSVDataField, ExpandableIPAddressField,
-    FilterChoiceField, Livesearch, ReturnURLForm, SlugField, add_blank_choice,
+    APISelect, BootstrapMixin, BulkEditNullBooleanSelect, BulkImportForm, ChainedModelChoiceField, CSVDataField,
+    ExpandableIPAddressField, FilterChoiceField, Livesearch, ReturnURLForm, SlugField, add_blank_choice,
 )
 
 from .models import (
@@ -32,11 +33,11 @@ IPADDRESS_MASK_LENGTH_CHOICES = PREFIX_MASK_LENGTH_CHOICES + [(128, 128)]
 # VRFs
 #
 
-class VRFForm(BootstrapMixin, CustomFieldForm):
+class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm):
 
     class Meta:
         model = VRF
-        fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
+        fields = ['name', 'rd', 'enforce_unique', 'description', 'tenant_group', 'tenant']
         labels = {
             'rd': "RD",
         }
@@ -163,30 +164,27 @@ class RoleForm(BootstrapMixin, forms.ModelForm):
 # Prefixes
 #
 
-class PrefixForm(BootstrapMixin, CustomFieldForm):
-    site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site',
-                                  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'))
+class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+    site = forms.ModelChoiceField(
+        queryset=Site.objects.all(), required=False, label='Site', widget=forms.Select(
+            attrs={'filter-for': 'vlan', 'nullable': 'true'}
+        )
+    )
+    vlan = ChainedModelChoiceField(
+        queryset=VLAN.objects.all(), chains={'site': 'site'}, required=False, label='VLAN', widget=APISelect(
+            api_url='/api/ipam/vlans/?site_id={{site}}', display_field='display_name'
+        )
+    )
 
     class Meta:
         model = Prefix
-        fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan', 'status', 'role', 'is_pool', 'description']
+        fields = ['prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'description', 'tenant_group', 'tenant']
 
     def __init__(self, *args, **kwargs):
         super(PrefixForm, self).__init__(*args, **kwargs)
 
         self.fields['vrf'].empty_label = 'Global'
 
-        # Initialize field without choices to avoid pulling all VLANs from the database
-        if self.is_bound and self.data.get('site'):
-            self.fields['vlan'].queryset = VLAN.objects.filter(site__pk=self.data['site'])
-        elif self.initial.get('site'):
-            self.fields['vlan'].queryset = VLAN.objects.filter(site=self.initial['site'])
-        else:
-            self.fields['vlan'].queryset = VLAN.objects.filter(site=None)
-
 
 class PrefixFromCSVForm(forms.ModelForm):
     vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
@@ -214,7 +212,6 @@ class PrefixFromCSVForm(forms.ModelForm):
         vlan_group_name = self.cleaned_data.get('vlan_group_name')
         vlan_vid = self.cleaned_data.get('vlan_vid')
         vlan_group = None
-        vlan = None
 
         # Validate VLAN group
         if vlan_group_name:
@@ -310,84 +307,122 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
 # IP addresses
 #
 
-class IPAddressForm(BootstrapMixin, ReturnURLForm, CustomFieldForm):
+class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm):
     interface_site = forms.ModelChoiceField(
-        queryset=Site.objects.all(), required=False, label='Site', widget=forms.Select(
+        queryset=Site.objects.all(),
+        required=False,
+        label='Site',
+        widget=forms.Select(
             attrs={'filter-for': 'interface_rack'}
         )
     )
-    interface_rack = forms.ModelChoiceField(
-        queryset=Rack.objects.all(), required=False, label='Rack', widget=APISelect(
-            api_url='/api/dcim/racks/?site_id={{interface_site}}', display_field='display_name',
+    interface_rack = ChainedModelChoiceField(
+        queryset=Rack.objects.all(),
+        chains={'site': 'interface_site'},
+        required=False,
+        label='Rack',
+        widget=APISelect(
+            api_url='/api/dcim/racks/?site_id={{interface_site}}',
+            display_field='display_name',
             attrs={'filter-for': 'interface_device', 'nullable': 'true'}
         )
     )
-    interface_device = forms.ModelChoiceField(
-        queryset=Device.objects.all(), required=False, label='Device', widget=APISelect(
+    interface_device = ChainedModelChoiceField(
+        queryset=Device.objects.all(),
+        chains={'site': 'interface_site', 'rack': 'interface_rack'},
+        required=False,
+        label='Device',
+        widget=APISelect(
             api_url='/api/dcim/devices/?site_id={{interface_site}}&rack_id={{interface_rack}}',
-            display_field='display_name', attrs={'filter-for': 'interface'}
+            display_field='display_name',
+            attrs={'filter-for': 'interface'}
+        )
+    )
+    interface = ChainedModelChoiceField(
+        queryset=Interface.objects.all(),
+        chains={'device': 'interface_device'},
+        required=False,
+        widget=APISelect(
+            api_url='/api/dcim/interfaces/?device_id={{interface_device}}'
         )
     )
     nat_site = forms.ModelChoiceField(
-        queryset=Site.objects.all(), required=False, label='Site', widget=forms.Select(
+        queryset=Site.objects.all(),
+        required=False,
+        label='Site',
+        widget=forms.Select(
             attrs={'filter-for': 'nat_device'}
         )
     )
-    nat_device = forms.ModelChoiceField(
-        queryset=Device.objects.all(), required=False, label='Device', widget=APISelect(
-            api_url='/api/dcim/devices/?site_id={{nat_site}}', display_field='display_name',
+    nat_rack = ChainedModelChoiceField(
+        queryset=Rack.objects.all(),
+        chains={'site': 'nat_site'},
+        required=False,
+        label='Rack',
+        widget=APISelect(
+            api_url='/api/dcim/racks/?site_id={{interface_site}}',
+            display_field='display_name',
+            attrs={'filter-for': 'nat_device', 'nullable': 'true'}
+        )
+    )
+    nat_device = ChainedModelChoiceField(
+        queryset=Device.objects.all(),
+        chains={'site': 'nat_site'},
+        required=False,
+        label='Device',
+        widget=APISelect(
+            api_url='/api/dcim/devices/?site_id={{nat_site}}',
+            display_field='display_name',
             attrs={'filter-for': 'nat_inside'}
         )
     )
+    nat_inside = ChainedModelChoiceField(
+        queryset=IPAddress.objects.all(),
+        chains={'interface__device': 'nat_device'},
+        required=False,
+        label='IP Address',
+        widget=APISelect(
+            api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}',
+            display_field='address'
+        )
+    )
     livesearch = forms.CharField(
-        required=False, label='IP Address', widget=Livesearch(
-            query_key='q', query_url='ipam-api:ipaddress-list', field_to_update='nat_inside', obj_label='address'
+        required=False,
+        label='IP Address',
+        widget=Livesearch(
+            query_key='q',
+            query_url='ipam-api:ipaddress-list',
+            field_to_update='nat_inside',
+            obj_label='address'
         )
     )
     primary_for_device = forms.BooleanField(required=False, label='Make this the primary IP for the device')
 
     class Meta:
         model = IPAddress
-        fields = ['address', 'vrf', 'tenant', 'status', 'description', 'interface', 'primary_for_device', 'nat_inside']
-        widgets = {
-            'interface': APISelect(api_url='/api/dcim/interfaces/?device_id={{interface_device}}'),
-            'nat_inside': APISelect(api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}', display_field='address')
-        }
+        fields = [
+            'address', 'vrf', 'status', 'description', 'interface', 'primary_for_device', 'nat_inside', 'tenant_group',
+            'tenant',
+        ]
 
     def __init__(self, *args, **kwargs):
-        super(IPAddressForm, self).__init__(*args, **kwargs)
 
-        self.fields['vrf'].empty_label = 'Global'
+        # Initialize helper selectors
+        instance = kwargs.get('instance')
+        initial = kwargs.get('initial', {})
+        if instance and instance.interface is not None:
+            initial['interface_site'] = instance.interface.device.site
+            initial['interface_rack'] = instance.interface.device.rack
+            initial['interface_device'] = instance.interface.device
+        if instance and instance.nat_inside is not None:
+            initial['nat_site'] = instance.nat_inside.device.site
+            initial['nat_rack'] = instance.nat_inside.device.rack
+            initial['nat_device'] = instance.nat_inside.device
+        kwargs['initial'] = initial
 
-        # If an interface has been assigned, initialize site, rack, and device
-        if self.instance.interface:
-            self.initial['interface_site'] = self.instance.interface.device.site
-            self.initial['interface_rack'] = self.instance.interface.device.rack
-            self.initial['interface_device'] = self.instance.interface.device
-
-        # Limit rack choices
-        if self.is_bound and self.data.get('interface_site'):
-            self.fields['interface_rack'].queryset = Rack.objects.filter(site__pk=self.data['interface_site'])
-        elif self.initial.get('interface_site'):
-            self.fields['interface_rack'].queryset = Rack.objects.filter(site=self.initial['interface_site'])
-        else:
-            self.fields['interface_rack'].choices = []
-
-        # Limit device choices
-        if self.is_bound and self.data.get('interface_rack'):
-            self.fields['interface_device'].queryset = Device.objects.filter(rack=self.data['interface_rack'])
-        elif self.initial.get('interface_rack'):
-            self.fields['interface_device'].queryset = Device.objects.filter(rack=self.initial['interface_rack'])
-        else:
-            self.fields['interface_device'].choices = []
+        super(IPAddressForm, self).__init__(*args, **kwargs)
 
-        # Limit interface choices
-        if self.is_bound and self.data.get('interface_device'):
-            self.fields['interface'].queryset = Interface.objects.filter(device=self.data['interface_device'])
-        elif self.initial.get('interface_device'):
-            self.fields['interface'].queryset = Interface.objects.filter(device=self.initial['interface_device'])
-        else:
-            self.fields['interface'].choices = []
+        self.fields['vrf'].empty_label = 'Global'
 
         # Initialize primary_for_device if IP address is already assigned
         if self.instance.interface is not None:
@@ -398,38 +433,6 @@ class IPAddressForm(BootstrapMixin, ReturnURLForm, CustomFieldForm):
             ):
                 self.initial['primary_for_device'] = True
 
-        if self.instance.nat_inside:
-            nat_inside = self.instance.nat_inside
-            # If the IP is assigned to an interface, populate site/device fields accordingly
-            if self.instance.nat_inside.interface:
-                self.initial['nat_site'] = self.instance.nat_inside.interface.device.site.pk
-                self.initial['nat_device'] = self.instance.nat_inside.interface.device.pk
-                self.fields['nat_device'].queryset = Device.objects.filter(
-                    site=nat_inside.interface.device.site
-                )
-                self.fields['nat_inside'].queryset = IPAddress.objects.filter(
-                    interface__device=nat_inside.interface.device
-                )
-            else:
-                self.fields['nat_inside'].queryset = IPAddress.objects.filter(pk=nat_inside.pk)
-        else:
-            # Initialize nat_device choices if nat_site is set
-            if self.is_bound and self.data.get('nat_site'):
-                self.fields['nat_device'].queryset = Device.objects.filter(site__pk=self.data['nat_site'])
-            elif self.initial.get('nat_site'):
-                self.fields['nat_device'].queryset = Device.objects.filter(site=self.initial['nat_site'])
-            else:
-                self.fields['nat_device'].choices = []
-            # Initialize nat_inside choices if nat_device is set
-            if self.is_bound and self.data.get('nat_device'):
-                self.fields['nat_inside'].queryset = IPAddress.objects.filter(
-                    interface__device__pk=self.data['nat_device'])
-            elif self.initial.get('nat_device'):
-                self.fields['nat_inside'].queryset = IPAddress.objects.filter(
-                    interface__device__pk=self.initial['nat_device'])
-            else:
-                self.fields['nat_inside'].choices = []
-
     def clean(self):
         super(IPAddressForm, self).clean()
 
@@ -468,15 +471,19 @@ class IPAddressForm(BootstrapMixin, ReturnURLForm, CustomFieldForm):
         return ipaddress
 
 
-class IPAddressBulkAddForm(BootstrapMixin, CustomFieldForm):
-    address_pattern = ExpandableIPAddressField(label='Address Pattern')
-    vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF', empty_label='Global')
+class IPAddressPatternForm(BootstrapMixin, forms.Form):
+    pattern = ExpandableIPAddressField(label='Address pattern')
+
 
-    pattern_map = ('address_pattern', 'address')
+class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm):
 
     class Meta:
         model = IPAddress
-        fields = ['address_pattern', 'vrf', 'tenant', 'status', 'description']
+        fields = ['address', 'status', 'vrf', 'description', 'tenant_group', 'tenant']
+
+    def __init__(self, *args, **kwargs):
+        super(IPAddressBulkAddForm, self).__init__(*args, **kwargs)
+        self.fields['vrf'].empty_label = 'Global'
 
 
 class IPAddressFromCSVForm(forms.ModelForm):
@@ -602,14 +609,26 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
 # VLANs
 #
 
-class VLANForm(BootstrapMixin, CustomFieldForm):
-    group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, label='Group', widget=APISelect(
-        api_url='/api/ipam/vlan-groups/?site_id={{site}}',
-    ))
+class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+    site = forms.ModelChoiceField(
+        queryset=Site.objects.all(),
+        widget=forms.Select(
+            attrs={'filter-for': 'group', 'nullable': 'true'}
+        )
+    )
+    group = ChainedModelChoiceField(
+        queryset=VLANGroup.objects.all(),
+        chains={'site': 'site'},
+        required=False,
+        label='Group',
+        widget=APISelect(
+            api_url='/api/ipam/vlan-groups/?site_id={{site}}',
+        )
+    )
 
     class Meta:
         model = VLAN
-        fields = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description']
+        fields = ['site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant']
         help_texts = {
             'site': "Leave blank if this VLAN spans multiple sites",
             'group': "VLAN group (optional)",
@@ -618,21 +637,6 @@ class VLANForm(BootstrapMixin, CustomFieldForm):
             'status': "Operational status of this VLAN",
             'role': "The primary function of this VLAN",
         }
-        widgets = {
-            'site': forms.Select(attrs={'filter-for': 'group', 'nullable': 'true'}),
-        }
-
-    def __init__(self, *args, **kwargs):
-
-        super(VLANForm, self).__init__(*args, **kwargs)
-
-        # Limit VLAN group choices
-        if self.is_bound and self.data.get('site'):
-            self.fields['group'].queryset = VLANGroup.objects.filter(site__pk=self.data['site'])
-        elif self.initial.get('site'):
-            self.fields['group'].queryset = VLANGroup.objects.filter(site=self.initial['site'])
-        else:
-            self.fields['group'].queryset = VLANGroup.objects.filter(site=None)
 
 
 class VLANFromCSVForm(forms.ModelForm):
@@ -663,7 +667,7 @@ class VLANFromCSVForm(forms.ModelForm):
         group_name = self.cleaned_data.get('group_name')
         if group_name:
             try:
-                vlan_group = VLANGroup.objects.get(site=self.cleaned_data.get('site'), name=group_name)
+                VLANGroup.objects.get(site=self.cleaned_data.get('site'), name=group_name)
             except VLANGroup.DoesNotExist:
                 self.add_error('group_name', "Invalid VLAN group {}.".format(group_name))
 
@@ -697,7 +701,7 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     description = forms.CharField(max_length=100, required=False)
 
     class Meta:
-        nullable_fields = ['group', 'tenant', 'role', 'description']
+        nullable_fields = ['site', 'group', 'tenant', 'role', 'description']
 
 
 def vlan_status_choices():

+ 4 - 2
netbox/ipam/models.py

@@ -538,7 +538,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
         verbose_name_plural = 'VLANs'
 
     def __str__(self):
-        return self.display_name
+        return self.display_name or super(VLAN, self).__str__()
 
     def get_absolute_url(self):
         return reverse('ipam:vlan', args=[self.pk])
@@ -565,7 +565,9 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
 
     @property
     def display_name(self):
-        return u'{} ({})'.format(self.vid, self.name)
+        if self.vid and self.name:
+            return u"{} ({})".format(self.vid, self.name)
+        return None
 
     def get_status_class(self):
         return STATUS_CHOICE_CLASSES[self.status]

+ 14 - 3
netbox/ipam/tables.py

@@ -76,6 +76,15 @@ IPADDRESS_LINK = """
 {% endif %}
 """
 
+IPADDRESS_DEVICE = """
+{% if record.interface %}
+    <a href="{{ record.interface.device.get_absolute_url }}">{{ record.interface.device }}</a>
+    ({{ record.interface.name }})
+{% else %}
+    &mdash;
+{% endif %}
+"""
+
 VRF_LINK = """
 {% if record.vrf %}
     <a href="{{ record.vrf.get_absolute_url }}">{{ record.vrf }}</a>
@@ -281,12 +290,14 @@ class IPAddressTable(BaseTable):
     status = tables.TemplateColumn(STATUS_LABEL)
     vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
     tenant = tables.TemplateColumn(TENANT_LINK)
-    device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False)
-    interface = tables.Column(orderable=False)
+    nat_inside = tables.LinkColumn(
+        'ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False, verbose_name='NAT (Inside)'
+    )
+    device = tables.TemplateColumn(IPADDRESS_DEVICE, orderable=False)
 
     class Meta(BaseTable.Meta):
         model = IPAddress
-        fields = ('pk', 'address', 'status', 'vrf', 'tenant', 'device', 'interface', 'description')
+        fields = ('pk', 'address', 'status', 'vrf', 'tenant', 'nat_inside', 'device', 'description')
         row_attrs = {
             'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
         }

+ 5 - 7
netbox/ipam/views.py

@@ -2,15 +2,12 @@ from django_tables2 import RequestConfig
 import netaddr
 
 from django.conf import settings
-from django.contrib.auth.decorators import permission_required
 from django.contrib.auth.mixins import PermissionRequiredMixin
-from django.contrib import messages
 from django.db.models import Count, Q
-from django.shortcuts import get_object_or_404, redirect, render
+from django.shortcuts import get_object_or_404, render
 from django.urls import reverse
 
 from dcim.models import Device
-from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator
 from utilities.views import (
     BulkAddView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
@@ -536,7 +533,7 @@ def prefix_ipaddresses(request, pk):
 #
 
 class IPAddressListView(ObjectListView):
-    queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device')
+    queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')
     filter = filters.IPAddressFilter
     filter_form = forms.IPAddressFilterForm
     table = tables.IPAddressTable
@@ -587,8 +584,9 @@ class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 
 class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView):
     permission_required = 'ipam.add_ipaddress'
-    form = forms.IPAddressBulkAddForm
-    model_form = forms.IPAddressForm
+    pattern_form = forms.IPAddressPatternForm
+    model_form = forms.IPAddressBulkAddForm
+    pattern_target = 'address'
     template_name = 'ipam/ipaddress_bulk_add.html'
     default_return_url = 'ipam:ipaddress_list'
 

+ 1 - 1
netbox/netbox/settings.py

@@ -13,7 +13,7 @@ except ImportError:
     )
 
 
-VERSION = '2.0.1'
+VERSION = '2.0.2'
 
 # Import local configuration
 ALLOWED_HOSTS = DATABASE = SECRET_KEY = None

+ 1 - 1
netbox/netbox/views.py

@@ -36,7 +36,7 @@ SEARCH_TYPES = {
         'url': 'circuits:provider_list',
     },
     'circuit': {
-        'queryset': Circuit.objects.select_related('type', 'provider', 'tenant'),
+        'queryset': Circuit.objects.select_related('type', 'provider', 'tenant').prefetch_related('terminations__site'),
         'filter': CircuitFilter,
         'table': CircuitSearchTable,
         'url': 'circuits:circuit_list',

+ 1 - 0
netbox/secrets/tables.py

@@ -44,6 +44,7 @@ class SecretTable(BaseTable):
 
 
 class SecretSearchTable(SearchTable):
+    device = tables.LinkColumn()
 
     class Meta(SearchTable.Meta):
         model = Secret

+ 7 - 1
netbox/templates/circuits/circuit_edit.html

@@ -8,12 +8,18 @@
             {% render_field form.provider %}
             {% render_field form.cid %}
             {% render_field form.type %}
-            {% render_field form.tenant %}
             {% render_field form.install_date %}
             {% render_field form.commit_rate %}
             {% render_field form.description %}
         </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>
     {% if form.custom_fields %}
         <div class="panel panel-default">
             <div class="panel-heading"><strong>Custom Fields</strong></div>

+ 7 - 1
netbox/templates/dcim/device_edit.html

@@ -7,7 +7,6 @@
         <div class="panel-body">
             {% render_field form.name %}
             {% render_field form.device_role %}
-            {% render_field form.tenant %}
         </div>
     </div>
     <div class="panel panel-default">
@@ -63,6 +62,13 @@
             {% endif %}
         </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>
     {% if form.custom_fields %}
         <div class="panel panel-default">
             <div class="panel-heading"><strong>Custom Fields</strong></div>

+ 65 - 6
netbox/templates/dcim/device_list.html

@@ -31,24 +31,83 @@
 {% block javascript %}
 <script type="text/javascript">
 $(document).ready(function() {
+
+    var site_list = $('#id_site');
+    var rack_group_list = $('#id_rack_group_id');
+    var rack_list = $('#id_rack_id');
+    var manufacturer_list = $('#id_manufacturer_id');
     var model_list = $('#id_device_type_id');
-    $('#id_manufacturer_id').change(function() {
-        model_list.empty();
+
+    // Update device type options based on selected manufacturer
+    manufacturer_list.change(function() {
         var selected_manufacturers = $(this).val();
         if (selected_manufacturers) {
-            var api_url = netbox_api_path + 'dcim/device-types/?manufacturer_id=' + selected_manufacturers.join('&manufacturer_id=');
+            model_list.empty();
             $.ajax({
-                url: api_url,
+                url: netbox_api_path + 'dcim/device-types/?limit=500&manufacturer_id=' + selected_manufacturers.join('&manufacturer_id='),
                 dataType: 'json',
                 success: function (response, status) {
-                    $.each(response, function (index, device_type) {
-                        var option = $("<option></option>").attr("value", device_type.id).text(device_type["model"] + " (" + device_type["instance_count"] + ")");
+                    $.each(response["results"], function (index, device_type) {
+                        var option = $("<option></option>").attr("value", device_type.id).text(device_type.model + " (" + device_type.instance_count + ")");
                         model_list.append(option);
                     });
                 }
             });
         }
     });
+
+    // Update rack group and rack options based on selected site
+    site_list.change(function() {
+        var selected_sites = $(this).val();
+        if (selected_sites) {
+
+            // Update rack group options
+            rack_group_list.empty();
+            $.ajax({
+                url: netbox_api_path + 'dcim/rack-groups/?limit=500&site=' + selected_sites.join('&site='),
+                dataType: 'json',
+                success: function (response, status) {
+                    $.each(response["results"], function (index, group) {
+                        var option = $("<option></option>").attr("value", group.id).text(group.name);
+                        rack_group_list.append(option);
+                    });
+                }
+            });
+
+            // Update rack options
+            rack_list.empty();
+            $.ajax({
+                url: netbox_api_path + 'dcim/racks/?limit=500&site=' + selected_sites.join('&site='),
+                dataType: 'json',
+                success: function (response, status) {
+                    $.each(response["results"], function (index, rack) {
+                        var option = $("<option></option>").attr("value", rack.id).text(rack.display_name);
+                        rack_list.append(option);
+                    });
+                }
+            });
+
+        }
+    });
+
+    // Update rack options based on selected rack group
+    rack_group_list.change(function() {
+        var selected_rack_groups = $(this).val();
+        if (selected_rack_groups) {
+            rack_list.empty();
+            $.ajax({
+                url: netbox_api_path + 'dcim/racks/?limit=500&group_id=' + selected_rack_groups.join('&group_id='),
+                dataType: 'json',
+                success: function (response, status) {
+                    $.each(response["results"], function (index, rack) {
+                        var option = $("<option></option>").attr("value", rack.id).text(rack.display_name);
+                        rack_list.append(option);
+                    });
+                }
+            });
+        }
+    });
+
 });
 </script>
 {% endblock %}

+ 1 - 1
netbox/templates/dcim/inc/interface.html

@@ -134,7 +134,7 @@
             <span class="label label-{{ ip.get_status_class }}">{{ ip.get_status_display }}</span>
         </td>
         <td class="text-right">
-            {% if perms.ipam.edit_ipaddress %}
+            {% if perms.ipam.change_ipaddress %}
                 <a href="{% url 'ipam:ipaddress_edit' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs">
                     <i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit IP address"></i>
                 </a>

+ 13 - 2
netbox/templates/dcim/rack_edit.html

@@ -6,11 +6,22 @@
         <div class="panel-heading"><strong>Rack</strong></div>
         <div class="panel-body">
             {% render_field form.site %}
-            {% render_field form.group %}
             {% render_field form.name %}
             {% render_field form.facility_id %}
-            {% render_field form.tenant %}
+            {% render_field form.group %}
             {% render_field form.role %}
+        </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>Dimensions</strong></div>
+        <div class="panel-body">
             {% render_field form.type %}
             {% render_field form.width %}
             {% render_field form.u_height %}

+ 2 - 2
netbox/templates/dcim/rack_elevation_list.html

@@ -14,7 +14,7 @@
             {% for rack in page %}
                 <div style="display: inline-block; width: 266px">
                     <div class="rack_header">
-                        <h4>{{ rack.name }}</h4>
+                        <h4><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></h4>
                     </div>
                     {% if face_id %}
                         {% include 'dcim/inc/rack_elevation.html' with primary_face=rack.get_rear_elevation secondary_face=rack.get_front_elevation face_id=1 %}
@@ -23,7 +23,7 @@
                     {% endif %}
                     <div class="clearfix"></div>
                     <div class="rack_header">
-                        <h4>{{ rack.name }}</h4>
+                        <h4><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></h4>
                     </div>
                 </div>
             {% endfor %}

+ 7 - 1
netbox/templates/dcim/site_edit.html

@@ -8,11 +8,17 @@
             {% render_field form.name %}
             {% render_field form.slug %}
             {% render_field form.region %}
-            {% render_field form.tenant %}
             {% render_field form.facility %}
             {% render_field form.asn %}
         </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>Contact Info</strong></div>
         <div class="panel-body">

+ 13 - 7
netbox/templates/ipam/ipaddress_bulk_add.html

@@ -12,18 +12,24 @@
     <div class="panel panel-default">
         <div class="panel-heading"><strong>IP Addresses</strong></div>
         <div class="panel-body">
-            {% render_field form.address_pattern %}
-            {% render_field form.vrf %}
-            {% render_field form.tenant %}
-            {% render_field form.status %}
-            {% render_field form.description %}
+            {% render_field pattern_form.pattern %}
+            {% render_field model_form.status %}
+            {% render_field model_form.vrf %}
+            {% render_field model_form.description %}
         </div>
     </div>
-    {% if form.custom_fields %}
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Tenancy</strong></div>
+        <div class="panel-body">
+            {% render_field model_form.tenant_group %}
+            {% render_field model_form.tenant %}
+        </div>
+    </div>
+    {% if model_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 %}
+                {% render_custom_fields model_form %}
             </div>
         </div>
     {% endif %}

+ 8 - 2
netbox/templates/ipam/ipaddress_edit.html

@@ -13,12 +13,18 @@
         <div class="panel-heading"><strong>IP Address</strong></div>
         <div class="panel-body">
             {% render_field form.address %}
-            {% render_field form.vrf %}
-            {% render_field form.tenant %}
             {% render_field form.status %}
+            {% render_field form.vrf %}
             {% render_field form.description %}
         </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>Interface Assignment</strong>

+ 9 - 3
netbox/templates/ipam/prefix_edit.html

@@ -6,14 +6,20 @@
         <div class="panel-heading"><strong>Prefix</strong></div>
         <div class="panel-body">
             {% render_field form.prefix %}
+            {% render_field form.status %}
             {% render_field form.vrf %}
-            {% render_field form.tenant %}
             {% render_field form.site %}
             {% render_field form.vlan %}
-            {% render_field form.status %}
             {% render_field form.role %}
-            {% render_field form.is_pool %}
             {% render_field form.description %}
+            {% render_field form.is_pool %}
+        </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>
     {% if form.custom_fields %}

+ 7 - 1
netbox/templates/ipam/vlan_edit.html

@@ -9,12 +9,18 @@
             {% render_field form.group %}
             {% render_field form.vid %}
             {% render_field form.name %}
-            {% render_field form.tenant %}
             {% render_field form.status %}
             {% render_field form.role %}
             {% render_field form.description %}
         </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>
     {% if form.custom_fields %}
         <div class="panel panel-default">
             <div class="panel-heading"><strong>Custom Fields</strong></div>

+ 7 - 1
netbox/templates/ipam/vrf_edit.html

@@ -7,11 +7,17 @@
         <div class="panel-body">
             {% render_field form.name %}
             {% render_field form.rd %}
-            {% render_field form.tenant %}
             {% render_field form.enforce_unique %}
             {% render_field form.description %}
         </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>
     {% if form.custom_fields %}
         <div class="panel panel-default">
             <div class="panel-heading"><strong>Custom Fields</strong></div>

+ 10 - 8
netbox/templates/table_paginator.html

@@ -7,7 +7,7 @@
         <nav>
             <ul class="pagination pull-right">
                 {% if table.page.has_previous %}
-                    <li><a href="{% querystring table.prefixed_page_field=table.page.previous_page_number %}">&laquo;</a></li>
+                    <li><a href="{% querystring table.prefixed_page_field=table.page.previous_page_number %}"><i class="fa fa-angle-double-left"></i></a></li>
                 {% endif %}
                 {% for p in table.page.smart_pages %}
                     {% if p %}
@@ -17,18 +17,20 @@
                     {% endif %}
                 {% endfor %}
                 {% if table.page.has_next %}
-                    <li><a href="{% querystring table.prefixed_page_field=table.page.next_page_number %}">&raquo;</a></li>
+                    <li><a href="{% querystring table.prefixed_page_field=table.page.next_page_number %}"><i class="fa fa-angle-double-right"></i></a></li>
                 {% endif %}
             </ul>
         </nav>
     {% endif %}
     <div class="clearfix"></div>
     <div class="text-right text-muted">
-        Showing {{ table.page.start_index }}-{{ table.page.end_index }} of {{ total }}
-        {% if total == 1 %}
-            {{ table.data.verbose_name }}
-        {% else %}
-            {{ table.data.verbose_name_plural }}
-        {% endif %}
+        {% with table.page.paginator.count as total %}
+            Showing {{ table.page.start_index }}-{{ table.page.end_index }} of {{ total }}
+            {% if total == 1 %}
+                {{ table.data.verbose_name }}
+            {% else %}
+                {{ table.data.verbose_name_plural }}
+            {% endif %}
+        {% endwith %}
     </div>
 </div>

+ 37 - 2
netbox/tenancy/forms.py

@@ -2,8 +2,10 @@ from django import forms
 from django.db.models import Count
 
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
-from utilities.forms import BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, SlugField
-
+from utilities.forms import (
+    APISelect, BootstrapMixin, BulkImportForm, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVDataField,
+    FilterChoiceField, SlugField,
+)
 from .models import Tenant, TenantGroup
 
 
@@ -61,3 +63,36 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm):
         to_field_name='slug',
         null_option=(0, 'None')
     )
+
+
+#
+# Tenancy form extension
+#
+
+class TenancyForm(ChainedFieldsMixin, forms.Form):
+    tenant_group = forms.ModelChoiceField(
+        queryset=TenantGroup.objects.all(),
+        required=False,
+        widget=forms.Select(
+            attrs={'filter-for': 'tenant', 'nullable': 'true'}
+        )
+    )
+    tenant = ChainedModelChoiceField(
+        queryset=Tenant.objects.all(),
+        chains={'group': 'tenant_group'},
+        required=False,
+        widget=APISelect(
+            api_url='/api/tenancy/tenants/?group_id={{tenant_group}}'
+        )
+    )
+
+    def __init__(self, *args, **kwargs):
+
+        # Initialize helper selector
+        instance = kwargs.get('instance')
+        if instance and instance.tenant is not None:
+            initial = kwargs.get('initial', {})
+            initial['tenant_group'] = instance.tenant.group
+            kwargs['initial'] = initial
+
+        super(TenancyForm, self).__init__(*args, **kwargs)

+ 1 - 0
netbox/tenancy/tables.py

@@ -44,6 +44,7 @@ class TenantTable(BaseTable):
 
 
 class TenantSearchTable(SearchTable):
+    name = tables.LinkColumn()
 
     class Meta(SearchTable.Meta):
         model = Tenant

+ 5 - 2
netbox/users/views.py

@@ -216,10 +216,13 @@ class TokenEditView(LoginRequiredMixin, View):
             token.user = request.user
             token.save()
 
-            msg = "Token updated" if pk else "New token created"
+            msg = "Modified token {}".format(token) if pk else "Created token {}".format(token)
             messages.success(request, msg)
 
-            return redirect('user:token_list')
+            if '_addanother' in request.POST:
+                return redirect(request.path)
+            else:
+                return redirect('user:token_list')
 
 
 class TokenDeleteView(LoginRequiredMixin, View):

+ 45 - 0
netbox/utilities/forms.py

@@ -331,6 +331,25 @@ class FlexibleModelChoiceField(forms.ModelChoiceField):
         return value
 
 
+class ChainedModelChoiceField(forms.ModelChoiceField):
+    """
+    A ModelChoiceField which is initialized based on the values of other fields within a form. `chains` is a dictionary
+    mapping of model fields to peer fields within the form. For example:
+
+        country1 = forms.ModelChoiceField(queryset=Country.objects.all())
+        city1 = ChainedModelChoiceField(queryset=City.objects.all(), chains={'country': 'country1'}
+
+    The queryset of the `city1` field will be modified as
+
+        .filter(country=<value>)
+
+    where <value> is the value of the `country1` field. (Note: The form must inherit from ChainedFieldsMixin.)
+    """
+    def __init__(self, chains=None, *args, **kwargs):
+        self.chains = chains
+        super(ChainedModelChoiceField, self).__init__(*args, **kwargs)
+
+
 class SlugField(forms.SlugField):
 
     def __init__(self, slug_source='name', *args, **kwargs):
@@ -411,6 +430,32 @@ class BootstrapMixin(forms.BaseForm):
                 field.widget.attrs['placeholder'] = field.label
 
 
+class ChainedFieldsMixin(forms.BaseForm):
+    """
+    Iterate through all ChainedModelChoiceFields in the form and modify their querysets based on chained fields.
+    """
+    def __init__(self, *args, **kwargs):
+        super(ChainedFieldsMixin, self).__init__(*args, **kwargs)
+
+        for field_name, field in self.fields.items():
+
+            if isinstance(field, ChainedModelChoiceField):
+
+                filters_dict = {}
+                for db_field, parent_field in field.chains.items():
+                    if self.is_bound:
+                        filters_dict[db_field] = self.data.get(parent_field) or None
+                    elif self.initial.get(parent_field):
+                        filters_dict[db_field] = self.initial[parent_field]
+                    else:
+                        filters_dict[db_field] = None
+
+                if filters_dict:
+                    field.queryset = field.queryset.filter(**filters_dict)
+                else:
+                    field.queryset = field.queryset.none()
+
+
 class ReturnURLForm(forms.Form):
     """
     Provides a hidden return URL field to control where the user is directed after the form is submitted.

+ 12 - 0
netbox/utilities/utils.py

@@ -24,3 +24,15 @@ def csv_format(data):
             csv.append(u'{}'.format(value))
 
     return u','.join(csv)
+
+
+def foreground_color(bg_color):
+    """
+    Return the ideal foreground color (black or white) for a given background color in hexadecimal RGB format.
+    """
+    bg_color = bg_color.strip('#')
+    r, g, b = [int(bg_color[c:c + 2], 16) for c in (0, 2, 4)]
+    if r * 0.299 + g * 0.587 + b * 0.114 > 186:
+        return '000000'
+    else:
+        return 'ffffff'

+ 40 - 28
netbox/utilities/views.py

@@ -290,66 +290,78 @@ class BulkAddView(View):
     """
     Create new objects in bulk.
 
-    form: Form class
+    pattern_form: Form class which provides the `pattern` field
     model_form: The ModelForm used to create individual objects
     template_name: The name of the template
     default_return_url: Name of the URL to which the user is redirected after creating the objects
     """
-    form = None
+    pattern_form = None
     model_form = None
+    pattern_target = ''
     template_name = None
     default_return_url = 'home'
 
     def get(self, request):
 
-        form = self.form()
+        pattern_form = self.pattern_form()
+        model_form = self.model_form()
 
         return render(request, self.template_name, {
             'obj_type': self.model_form._meta.model._meta.verbose_name,
-            'form': form,
+            'pattern_form': pattern_form,
+            'model_form': model_form,
             'return_url': reverse(self.default_return_url),
         })
 
     def post(self, request):
 
         model = self.model_form._meta.model
-        form = self.form(request.POST)
-        if form.is_valid():
+        pattern_form = self.pattern_form(request.POST)
+        model_form = self.model_form(request.POST)
 
-            # Read the pattern field and target from the form's pattern_map
-            pattern_field, pattern_target = form.pattern_map
-            pattern = form.cleaned_data[pattern_field]
-            model_form_data = form.cleaned_data
+        if pattern_form.is_valid():
 
+            pattern = pattern_form.cleaned_data['pattern']
             new_objs = []
+
             try:
                 with transaction.atomic():
-                    # Validate and save each object individually
+
+                    # Create objects from the expanded. Abort the transaction on the first validation error.
                     for value in pattern:
-                        model_form_data[pattern_target] = value
-                        model_form = self.model_form(model_form_data)
+
+                        # Reinstantiate the model form each time to avoid overwriting the same instance. Use a mutable
+                        # copy of the POST QueryDict so that we can update the target field value.
+                        model_form = self.model_form(request.POST.copy())
+                        model_form.data[self.pattern_target] = value
+
+                        # Validate each new object independently.
                         if model_form.is_valid():
                             obj = model_form.save()
                             new_objs.append(obj)
                         else:
-                            for error in model_form.errors.as_data().values():
-                                form.add_error(None, error)
-                    # Abort the creation of all objects if errors exist
-                    if form.errors:
-                        raise ValidationError("Validation of one or more model forms failed.")
-            except ValidationError:
-                pass
+                            # Copy any errors on the pattern target field to the pattern form.
+                            errors = model_form.errors.as_data()
+                            if errors.get(self.pattern_target):
+                                pattern_form.add_error('pattern', errors[self.pattern_target])
+                            # Raise an IntegrityError to break the for loop and abort the transaction.
+                            raise IntegrityError()
+
+                    # If we make it to this point, validation has succeeded on all new objects.
+                    msg = u"Added {} {}".format(len(new_objs), model._meta.verbose_name_plural)
+                    messages.success(request, msg)
+                    UserAction.objects.log_bulk_create(request.user, ContentType.objects.get_for_model(model), msg)
 
-            if not form.errors:
-                msg = u"Added {} {}".format(len(new_objs), model._meta.verbose_name_plural)
-                messages.success(request, msg)
-                UserAction.objects.log_bulk_create(request.user, ContentType.objects.get_for_model(model), msg)
-                if '_addanother' in request.POST:
-                    return redirect(request.path)
-                return redirect(self.default_return_url)
+                    if '_addanother' in request.POST:
+                        return redirect(request.path)
+                    return redirect(self.default_return_url)
+
+            except IntegrityError:
+                pass
 
         return render(request, self.template_name, {
-            'form': form,
+            'pattern_form': pattern_form,
+            'model_form': model_form,
             'obj_type': model._meta.verbose_name,
             'return_url': reverse(self.default_return_url),
         })