Browse Source

Closes #198: Support for rackless devices (#902)

* Initial work to support rackless devices

* Updated device component connection forms

* Updated IP address assignment form

* Updated circuit termination form

* Formatting cleanup

* Fixed tests
Jeremy Stretch 8 years ago
parent
commit
198ed859ff

+ 42 - 12
netbox/circuits/forms.py

@@ -143,19 +143,49 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
 #
 
 class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
-    site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
-    rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, label='Rack',
-                                  widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}',
-                                                   attrs={'filter-for': 'device'}))
-    device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device',
-                                    widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
-                                                     display_field='display_name', attrs={'filter-for': 'interface'}))
-    livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
-        query_key='q', query_url='dcim-api:device_list', field_to_update='device')
+    site = forms.ModelChoiceField(
+        queryset=Site.objects.all(),
+        widget=forms.Select(
+            attrs={'filter-for': 'rack'}
+        )
+    )
+    rack = forms.ModelChoiceField(
+        queryset=Rack.objects.all(),
+        required=False,
+        label='Rack',
+        widget=APISelect(
+            api_url='/api/dcim/racks/?site_id={{site}}',
+            attrs={'filter-for': 'device', 'nullable': 'true'}
+        )
+    )
+    device = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        label='Device',
+        widget=APISelect(
+            api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
+            display_field='display_name',
+            attrs={'filter-for': 'interface'}
+        )
+    )
+    livesearch = forms.CharField(
+        required=False,
+        label='Device',
+        widget=Livesearch(
+            query_key='q',
+            query_url='dcim-api:device_list',
+            field_to_update='device'
+        )
+    )
+    interface = forms.ModelChoiceField(
+        queryset=Interface.objects.all(),
+        required=False,
+        label='Interface',
+        widget=APISelect(
+            api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical',
+            disabled_indicator='is_connected'
+        )
     )
-    interface = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Interface',
-                                       widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical',
-                                                        disabled_indicator='is_connected'))
 
     class Meta:
         model = CircuitTermination

+ 6 - 3
netbox/dcim/api/serializers.py

@@ -275,6 +275,7 @@ class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer):
     device_role = DeviceRoleNestedSerializer()
     tenant = TenantNestedSerializer()
     platform = PlatformNestedSerializer()
+    site = SiteNestedSerializer()
     rack = RackNestedSerializer()
     primary_ip = DeviceIPAddressNestedSerializer()
     primary_ip4 = DeviceIPAddressNestedSerializer()
@@ -283,9 +284,11 @@ class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer):
 
     class Meta:
         model = Device
-        fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial',
-                  'asset_tag', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4',
-                  'primary_ip6', 'comments', 'custom_fields']
+        fields = [
+            'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
+            'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
+            'comments', 'custom_fields',
+        ]
 
     def get_parent_device(self, obj):
         try:

+ 3 - 3
netbox/dcim/filters.py

@@ -175,12 +175,12 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='MAC address',
     )
     site_id = django_filters.ModelMultipleChoiceFilter(
-        name='rack__site',
+        name='site',
         queryset=Site.objects.all(),
         label='Site (ID)',
     )
     site = django_filters.ModelMultipleChoiceFilter(
-        name='rack__site__slug',
+        name='site__slug',
         queryset=Site.objects.all(),
         to_field_name='slug',
         label='Site name (slug)',
@@ -190,7 +190,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
         queryset=RackGroup.objects.all(),
         label='Rack group (ID)',
     )
-    rack_id = django_filters.ModelMultipleChoiceFilter(
+    rack_id = NullableModelMultipleChoiceFilter(
         name='rack',
         queryset=Rack.objects.all(),
         label='Rack (ID)',

+ 11 - 0
netbox/dcim/fixtures/dcim.json

@@ -1915,6 +1915,7 @@
         "platform": 1,
         "name": "test1-edge1",
         "serial": "5555555555",
+        "site": 1,
         "rack": 1,
         "position": 1,
         "face": 0,
@@ -1935,6 +1936,7 @@
         "platform": 1,
         "name": "test1-core1",
         "serial": "",
+        "site": 1,
         "rack": 1,
         "position": 17,
         "face": 0,
@@ -1955,6 +1957,7 @@
         "platform": 1,
         "name": "test1-spine1",
         "serial": "",
+        "site": 1,
         "rack": 1,
         "position": 33,
         "face": 0,
@@ -1975,6 +1978,7 @@
         "platform": 1,
         "name": "test1-leaf1",
         "serial": "",
+        "site": 1,
         "rack": 1,
         "position": 34,
         "face": 0,
@@ -1995,6 +1999,7 @@
         "platform": 1,
         "name": "test1-leaf2",
         "serial": "9823478293748",
+        "site": 1,
         "rack": 2,
         "position": 34,
         "face": 0,
@@ -2015,6 +2020,7 @@
         "platform": 1,
         "name": "test1-spine2",
         "serial": "45649818158",
+        "site": 1,
         "rack": 2,
         "position": 33,
         "face": 0,
@@ -2035,6 +2041,7 @@
         "platform": 1,
         "name": "test1-edge2",
         "serial": "7567356345",
+        "site": 1,
         "rack": 2,
         "position": 1,
         "face": 0,
@@ -2055,6 +2062,7 @@
         "platform": 1,
         "name": "test1-core2",
         "serial": "67856734534",
+        "site": 1,
         "rack": 2,
         "position": 17,
         "face": 0,
@@ -2075,6 +2083,7 @@
         "platform": 2,
         "name": "test1-oob1",
         "serial": "98273942938",
+        "site": 1,
         "rack": 1,
         "position": 42,
         "face": 0,
@@ -2095,6 +2104,7 @@
         "platform": null,
         "name": "test1-pdu1",
         "serial": "",
+        "site": 1,
         "rack": 1,
         "position": null,
         "face": null,
@@ -2115,6 +2125,7 @@
         "platform": null,
         "name": "test1-pdu2",
         "serial": "",
+        "site": 1,
         "rack": 2,
         "position": null,
         "face": null,

+ 278 - 90
netbox/dcim/forms.py

@@ -445,7 +445,7 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
 
 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(), widget=APISelect(
+    rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, widget=APISelect(
         api_url='/api/dcim/racks/?site_id={{site}}',
         display_field='display_name',
         attrs={'filter-for': 'position'}
@@ -549,7 +549,7 @@ class DeviceForm(BootstrapMixin, CustomFieldForm):
         if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
             self.fields['site'].disabled = True
             self.fields['rack'].disabled = True
-            self.initial['site'] = self.instance.parent_bay.device.rack.site_id
+            self.initial['site'] = self.instance.parent_bay.device.site_id
             self.initial['rack'] = self.instance.parent_bay.device.rack_id
 
 
@@ -585,7 +585,7 @@ class DeviceFromCSVForm(BaseDeviceFromCSVForm):
     site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', error_messages={
         'invalid_choice': 'Invalid site name.',
     })
-    rack_name = forms.CharField()
+    rack_name = forms.CharField(required=False)
     face = forms.CharField(required=False)
 
     class Meta(BaseDeviceFromCSVForm.Meta):
@@ -748,9 +748,13 @@ class ConsolePortCreateForm(BootstrapMixin, forms.Form):
 
 
 class ConsoleConnectionCSVForm(forms.Form):
-    console_server = FlexibleModelChoiceField(queryset=Device.objects.filter(device_type__is_console_server=True),
-                                              to_field_name='name',
-                                              error_messages={'invalid_choice': 'Console server not found'})
+    console_server = FlexibleModelChoiceField(
+        queryset=Device.objects.filter(device_type__is_console_server=True),
+        to_field_name='name',
+        error_messages={
+            'invalid_choice': 'Console server not found',
+        }
+    )
     cs_port = forms.CharField()
     device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
                                       error_messages={'invalid_choice': 'Device not found'})
@@ -815,22 +819,49 @@ class ConsoleConnectionImportForm(BootstrapMixin, BulkImportForm):
 
 
 class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm):
-    rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
-                                  widget=forms.Select(attrs={'filter-for': 'console_server'}))
-    console_server = forms.ModelChoiceField(queryset=Device.objects.all(), label='Console Server', required=False,
-                                            widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}&is_console_server=True',
-                                                             display_field='display_name',
-                                                             attrs={'filter-for': 'cs_port'}))
-    livesearch = forms.CharField(required=False, label='Console Server', widget=Livesearch(
-        query_key='q', query_url='dcim-api:device_list', field_to_update='console_server')
+    site = forms.ModelChoiceField(
+        queryset=Site.objects.all(),
+        widget=forms.HiddenInput(),
+    )
+    rack = forms.ModelChoiceField(
+        queryset=Rack.objects.all(),
+        label='Rack',
+        required=False,
+        widget=forms.Select(
+            attrs={'filter-for': 'console_server', 'nullable': 'true'}
+        )
+    )
+    console_server = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        label='Console Server',
+        required=False,
+        widget=APISelect(
+            api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}&is_console_server=True',
+            display_field='display_name',
+            attrs={'filter-for': 'cs_port'}
+        )
+    )
+    livesearch = forms.CharField(
+        required=False,
+        label='Console Server',
+        widget=Livesearch(
+            query_key='q',
+            query_url='dcim-api:device_list',
+            field_to_update='console_server',
+        )
+    )
+    cs_port = forms.ModelChoiceField(
+        queryset=ConsoleServerPort.objects.all(),
+        label='Port',
+        widget=APISelect(
+            api_url='/api/dcim/devices/{{console_server}}/console-server-ports/',
+            disabled_indicator='connected_console',
+        )
     )
-    cs_port = forms.ModelChoiceField(queryset=ConsoleServerPort.objects.all(), label='Port',
-                                     widget=APISelect(api_url='/api/dcim/devices/{{console_server}}/console-server-ports/',
-                                                      disabled_indicator='connected_console'))
 
     class Meta:
         model = ConsolePort
-        fields = ['rack', 'console_server', 'livesearch', 'cs_port', 'connection_status']
+        fields = ['site', 'rack', 'console_server', 'livesearch', 'cs_port', 'connection_status']
         labels = {
             'cs_port': 'Port',
             'connection_status': 'Status',
@@ -843,17 +874,22 @@ class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm):
         if not self.instance.pk:
             raise RuntimeError("ConsolePortConnectionForm must be initialized with an existing ConsolePort instance.")
 
-        self.fields['rack'].queryset = Rack.objects.filter(site=self.instance.device.rack.site)
+        self.initial['site'] = self.instance.device.site
+        self.fields['rack'].queryset = Rack.objects.filter(site=self.instance.device.site)
         self.fields['cs_port'].required = True
         self.fields['connection_status'].choices = CONNECTION_STATUS_CHOICES
 
         # Initialize console server choices
         if self.is_bound and self.data.get('rack'):
-            self.fields['console_server'].queryset = Device.objects.filter(rack=self.data['rack'], device_type__is_console_server=True)
+            self.fields['console_server'].queryset = Device.objects.filter(rack=self.data['rack'],
+                                                                           device_type__is_console_server=True)
         elif self.initial.get('rack'):
-            self.fields['console_server'].queryset = Device.objects.filter(rack=self.initial['rack'], device_type__is_console_server=True)
+            self.fields['console_server'].queryset = Device.objects.filter(rack=self.initial['rack'],
+                                                                           device_type__is_console_server=True)
         else:
-            self.fields['console_server'].choices = []
+            self.fields['console_server'].queryset = Device.objects.filter(site=self.instance.device.site,
+                                                                           rack__isnull=True,
+                                                                           device_type__is_console_server=True)
 
         # Initialize CS port choices
         if self.is_bound:
@@ -883,22 +919,56 @@ class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form):
 
 
 class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form):
-    rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
-                                  widget=forms.Select(attrs={'filter-for': 'device'}))
-    device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
-                                    widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
-                                                     display_field='display_name', attrs={'filter-for': 'port'}))
-    livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
-        query_key='q', query_url='dcim-api:device_list', field_to_update='device')
+    site = forms.ModelChoiceField(
+        queryset=Site.objects.all(),
+        widget=forms.HiddenInput(),
+    )
+    rack = forms.ModelChoiceField(
+        queryset=Rack.objects.all(),
+        label='Rack',
+        required=False,
+        widget=forms.Select(
+            attrs={'filter-for': 'device', 'nullable': 'true'}
+        )
+    )
+    device = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        label='Device',
+        required=False,
+        widget=APISelect(
+            api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
+            display_field='display_name',
+            attrs={'filter-for': 'port'}
+        )
+    )
+    livesearch = forms.CharField(
+        required=False,
+        label='Device',
+        widget=Livesearch(
+            query_key='q',
+            query_url='dcim-api:device_list',
+            field_to_update='device'
+        )
+    )
+    port = forms.ModelChoiceField(
+        queryset=ConsolePort.objects.all(),
+        label='Port',
+        widget=APISelect(
+            api_url='/api/dcim/devices/{{device}}/console-ports/',
+            disabled_indicator='cs_port'
+        )
+    )
+    connection_status = forms.BooleanField(
+        required=False,
+        initial=CONNECTION_STATUS_CONNECTED,
+        label='Status',
+        widget=forms.Select(
+            choices=CONNECTION_STATUS_CHOICES
+        )
     )
-    port = forms.ModelChoiceField(queryset=ConsolePort.objects.all(), label='Port',
-                                  widget=APISelect(api_url='/api/dcim/devices/{{device}}/console-ports/',
-                                                   disabled_indicator='cs_port'))
-    connection_status = forms.BooleanField(required=False, initial=CONNECTION_STATUS_CONNECTED, label='Status',
-                                           widget=forms.Select(choices=CONNECTION_STATUS_CHOICES))
 
     class Meta:
-        fields = ['rack', 'device', 'livesearch', 'port', 'connection_status']
+        fields = ['site', 'rack', 'device', 'livesearch', 'port', 'connection_status']
         labels = {
             'connection_status': 'Status',
         }
@@ -907,7 +977,8 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form):
 
         super(ConsoleServerPortConnectionForm, self).__init__(*args, **kwargs)
 
-        self.fields['rack'].queryset = Rack.objects.filter(site=consoleserverport.device.rack.site)
+        self.initial['site'] = consoleserverport.device.site
+        self.fields['rack'].queryset = Rack.objects.filter(site=consoleserverport.device.site)
 
         # Initialize device choices
         if self.is_bound and self.data.get('rack'):
@@ -915,7 +986,8 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form):
         elif self.initial.get('rack', None):
             self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack'])
         else:
-            self.fields['device'].choices = []
+            self.fields['device'].queryset = Device.objects.filter(site=consoleserverport.device.site,
+                                                                   rack__isnull=True)
 
         # Initialize port choices
         if self.is_bound:
@@ -945,8 +1017,13 @@ class PowerPortCreateForm(BootstrapMixin, forms.Form):
 
 
 class PowerConnectionCSVForm(forms.Form):
-    pdu = FlexibleModelChoiceField(queryset=Device.objects.filter(device_type__is_pdu=True), to_field_name='name',
-                                   error_messages={'invalid_choice': 'PDU not found.'})
+    pdu = FlexibleModelChoiceField(
+        queryset=Device.objects.filter(device_type__is_pdu=True),
+        to_field_name='name',
+        error_messages={
+            'invalid_choice': 'PDU not found.',
+        }
+    )
     power_outlet = forms.CharField()
     device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
                                       error_messages={'invalid_choice': 'Device not found'})
@@ -1012,21 +1089,46 @@ class PowerConnectionImportForm(BootstrapMixin, BulkImportForm):
 
 
 class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm):
-    rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
-                                  widget=forms.Select(attrs={'filter-for': 'pdu'}))
-    pdu = forms.ModelChoiceField(queryset=Device.objects.all(), label='PDU', required=False,
-                                 widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}&is_pdu=True',
-                                                  display_field='display_name', attrs={'filter-for': 'power_outlet'}))
-    livesearch = forms.CharField(required=False, label='PDU', widget=Livesearch(
-        query_key='q', query_url='dcim-api:device_list', field_to_update='pdu')
+    site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.HiddenInput())
+    rack = forms.ModelChoiceField(
+        queryset=Rack.objects.all(),
+        label='Rack',
+        required=False,
+        widget=forms.Select(
+            attrs={'filter-for': 'pdu', 'nullable': 'true'}
+        )
+    )
+    pdu = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        label='PDU',
+        required=False,
+        widget=APISelect(
+            api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}&is_pdu=True',
+            display_field='display_name',
+            attrs={'filter-for': 'power_outlet'}
+        )
+    )
+    livesearch = forms.CharField(
+        required=False,
+        label='PDU',
+        widget=Livesearch(
+            query_key='q',
+            query_url='dcim-api:device_list',
+            field_to_update='pdu'
+        )
+    )
+    power_outlet = forms.ModelChoiceField(
+        queryset=PowerOutlet.objects.all(),
+        label='Outlet',
+        widget=APISelect(
+            api_url='/api/dcim/devices/{{pdu}}/power-outlets/',
+            disabled_indicator='connected_port'
+        )
     )
-    power_outlet = forms.ModelChoiceField(queryset=PowerOutlet.objects.all(), label='Outlet',
-                                          widget=APISelect(api_url='/api/dcim/devices/{{pdu}}/power-outlets/',
-                                                           disabled_indicator='connected_port'))
 
     class Meta:
         model = PowerPort
-        fields = ['rack', 'pdu', 'livesearch', 'power_outlet', 'connection_status']
+        fields = ['site', 'rack', 'pdu', 'livesearch', 'power_outlet', 'connection_status']
         labels = {
             'power_outlet': 'Outlet',
             'connection_status': 'Status',
@@ -1039,17 +1141,22 @@ class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm):
         if not self.instance.pk:
             raise RuntimeError("PowerPortConnectionForm must be initialized with an existing PowerPort instance.")
 
-        self.fields['rack'].queryset = Rack.objects.filter(site=self.instance.device.rack.site)
+        self.initial['site'] = self.instance.device.site
+        self.fields['rack'].queryset = Rack.objects.filter(site=self.instance.device.site)
         self.fields['power_outlet'].required = True
         self.fields['connection_status'].choices = CONNECTION_STATUS_CHOICES
 
         # Initialize PDU choices
         if self.is_bound and self.data.get('rack'):
-            self.fields['pdu'].queryset = Device.objects.filter(rack=self.data['rack'], device_type__is_pdu=True)
+            self.fields['pdu'].queryset = Device.objects.filter(rack=self.data['rack'],
+                                                                device_type__is_pdu=True)
         elif self.initial.get('rack', None):
-            self.fields['pdu'].queryset = Device.objects.filter(rack=self.initial['rack'], device_type__is_pdu=True)
+            self.fields['pdu'].queryset = Device.objects.filter(rack=self.initial['rack'],
+                                                                device_type__is_pdu=True)
         else:
-            self.fields['pdu'].choices = []
+            self.fields['pdu'].queryset = Device.objects.filter(site=self.instance.device.site,
+                                                                rack__isnull=True,
+                                                                device_type__is_pdu=True)
 
         # Initialize power outlet choices
         if self.is_bound:
@@ -1079,22 +1186,56 @@ class PowerOutletCreateForm(BootstrapMixin, forms.Form):
 
 
 class PowerOutletConnectionForm(BootstrapMixin, forms.Form):
-    rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
-                                  widget=forms.Select(attrs={'filter-for': 'device'}))
-    device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
-                                    widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
-                                                     display_field='display_name', attrs={'filter-for': 'port'}))
-    livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
-        query_key='q', query_url='dcim-api:device_list', field_to_update='device')
+    site = forms.ModelChoiceField(
+        queryset=Site.objects.all(),
+        widget=forms.HiddenInput()
+    )
+    rack = forms.ModelChoiceField(
+        queryset=Rack.objects.all(),
+        label='Rack',
+        required=False,
+        widget=forms.Select(
+            attrs={'filter-for': 'device', 'nullable': 'true'}
+        )
+    )
+    device = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        label='Device',
+        required=False,
+        widget=APISelect(
+            api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
+            display_field='display_name',
+            attrs={'filter-for': 'port'}
+        )
+    )
+    livesearch = forms.CharField(
+        required=False,
+        label='Device',
+        widget=Livesearch(
+            query_key='q',
+            query_url='dcim-api:device_list',
+            field_to_update='device'
+        )
+    )
+    port = forms.ModelChoiceField(
+        queryset=PowerPort.objects.all(),
+        label='Port',
+        widget=APISelect(
+            api_url='/api/dcim/devices/{{device}}/power-ports/',
+            disabled_indicator='power_outlet'
+        )
+    )
+    connection_status = forms.BooleanField(
+        required=False,
+        initial=CONNECTION_STATUS_CONNECTED,
+        label='Status',
+        widget=forms.Select(
+            choices=CONNECTION_STATUS_CHOICES
+        )
     )
-    port = forms.ModelChoiceField(queryset=PowerPort.objects.all(), label='Port',
-                                  widget=APISelect(api_url='/api/dcim/devices/{{device}}/power-ports/',
-                                                   disabled_indicator='power_outlet'))
-    connection_status = forms.BooleanField(required=False, initial=CONNECTION_STATUS_CONNECTED, label='Status',
-                                           widget=forms.Select(choices=CONNECTION_STATUS_CHOICES))
 
     class Meta:
-        fields = ['rack', 'device', 'livesearch', 'port', 'connection_status']
+        fields = ['site', 'rack', 'device', 'livesearch', 'port', 'connection_status']
         labels = {
             'connection_status': 'Status',
         }
@@ -1103,7 +1244,8 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form):
 
         super(PowerOutletConnectionForm, self).__init__(*args, **kwargs)
 
-        self.fields['rack'].queryset = Rack.objects.filter(site=poweroutlet.device.rack.site)
+        self.initial['site'] = poweroutlet.device.site
+        self.fields['rack'].queryset = Rack.objects.filter(site=poweroutlet.device.site)
 
         # Initialize device choices
         if self.is_bound and self.data.get('rack'):
@@ -1111,7 +1253,8 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form):
         elif self.initial.get('rack', None):
             self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack'])
         else:
-            self.fields['device'].choices = []
+            self.fields['device'].queryset = Device.objects.filter(site=poweroutlet.device.site,
+                                                                   rack__isnull=True)
 
         # Initialize port choices
         if self.is_bound:
@@ -1158,22 +1301,55 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
 #
 
 class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
-    interface_a = forms.ChoiceField(choices=[], widget=SelectWithDisabled, label='Interface')
-    site_b = forms.ModelChoiceField(queryset=Site.objects.all(), label='Site', required=False,
-                                    widget=forms.Select(attrs={'filter-for': 'rack_b'}))
-    rack_b = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
-                                    widget=APISelect(api_url='/api/dcim/racks/?site_id={{site_b}}',
-                                                     attrs={'filter-for': 'device_b'}))
-    device_b = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
-                                      widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack_b}}',
-                                                       display_field='display_name',
-                                                       attrs={'filter-for': 'interface_b'}))
-    livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
-        query_key='q', query_url='dcim-api:device_list', field_to_update='device_b')
+    interface_a = forms.ChoiceField(
+        choices=[],
+        widget=SelectWithDisabled,
+        label='Interface'
+    )
+    site_b = forms.ModelChoiceField(
+        queryset=Site.objects.all(),
+        label='Site',
+        required=False,
+        widget=forms.Select(
+            attrs={'filter-for': 'rack_b'}
+        )
+    )
+    rack_b = forms.ModelChoiceField(
+        queryset=Rack.objects.all(),
+        label='Rack',
+        required=False,
+        widget=APISelect(
+            api_url='/api/dcim/racks/?site_id={{site_b}}',
+            attrs={'filter-for': 'device_b', 'nullable': 'true'}
+        )
+    )
+    device_b = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        label='Device',
+        required=False,
+        widget=APISelect(
+            api_url='/api/dcim/devices/?site_id={{site_b}}&rack_id={{rack_b}}',
+            display_field='display_name',
+            attrs={'filter-for': 'interface_b'}
+        )
+    )
+    livesearch = forms.CharField(
+        required=False,
+        label='Device',
+        widget=Livesearch(
+            query_key='q',
+            query_url='dcim-api:device_list',
+            field_to_update='device_b'
+        )
+    )
+    interface_b = forms.ModelChoiceField(
+        queryset=Interface.objects.all(),
+        label='Interface',
+        widget=APISelect(
+            api_url='/api/dcim/devices/{{device_b}}/interfaces/?type=physical',
+            disabled_indicator='is_connected'
+        )
     )
-    interface_b = forms.ModelChoiceField(queryset=Interface.objects.all(), label='Interface',
-                                         widget=APISelect(api_url='/api/dcim/devices/{{device_b}}/interfaces/?type=physical',
-                                                          disabled_indicator='is_connected'))
 
     class Meta:
         model = InterfaceConnection
@@ -1198,11 +1374,15 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
         else:
             self.fields['rack_b'].choices = []
 
-        # Initialize device_b choices if rack_b is set
+        # Initialize device_b choices if rack_b or site_b is set
         if self.is_bound and self.data.get('rack_b'):
             self.fields['device_b'].queryset = Device.objects.filter(rack__pk=self.data['rack_b'])
+        elif self.is_bound and self.data.get('site_b'):
+            self.fields['device_b'].queryset = Device.objects.filter(site__pk=self.data['site_b'], rack__isnull=True)
         elif 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 = []
 
@@ -1223,13 +1403,21 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
 
 
 class InterfaceConnectionCSVForm(forms.Form):
-    device_a = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
-                                        error_messages={'invalid_choice': 'Device A not found.'})
+    device_a = FlexibleModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        error_messages={'invalid_choice': 'Device A not found.'}
+    )
     interface_a = forms.CharField()
-    device_b = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
-                                        error_messages={'invalid_choice': 'Device B not found.'})
+    device_b = FlexibleModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        error_messages={'invalid_choice': 'Device B not found.'}
+    )
     interface_b = forms.CharField()
-    status = forms.CharField(validators=[validate_connection_status])
+    status = forms.CharField(
+        validators=[validate_connection_status]
+    )
 
     def clean(self):
 

+ 21 - 0
netbox/dcim/migrations/0027_device_add_site.py

@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10.4 on 2017-02-16 21:21
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0026_add_rack_reservations'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='device',
+            name='site',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Site'),
+        ),
+    ]

+ 23 - 0
netbox/dcim/migrations/0028_device_copy_rack_to_site.py

@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10.4 on 2017-02-16 21:23
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+def copy_site_from_rack(apps, schema_editor):
+    Device = apps.get_model('dcim', 'Device')
+    for device in Device.objects.all():
+        device.site = device.rack.site
+        device.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0027_device_add_site'),
+    ]
+
+    operations = [
+        migrations.RunPython(copy_site_from_rack),
+    ]

+ 26 - 0
netbox/dcim/migrations/0029_allow_rackless_devices.py

@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10.4 on 2017-02-16 21:25
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0028_device_copy_rack_to_site'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='device',
+            name='rack',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Rack'),
+        ),
+        migrations.AlterField(
+            model_name='device',
+            name='site',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Site'),
+        ),
+    ]

+ 58 - 26
netbox/dcim/models.py

@@ -370,6 +370,19 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
                         )
                     })
 
+    def save(self, *args, **kwargs):
+
+        # Record the original site assignment for this rack.
+        _site_id = None
+        if self.pk:
+            _site_id = Rack.objects.get(pk=self.pk).site_id
+
+        super(Rack, self).save(*args, **kwargs)
+
+        # Update racked devices if the assigned Site has been changed.
+        if _site_id is not None and self.site_id != _site_id:
+            Device.objects.filter(rack=self).update(site_id=self.site.pk)
+
     def to_csv(self):
         return csv_format([
             self.site.name,
@@ -871,7 +884,8 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
     serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number')
     asset_tag = NullableCharField(max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag',
                                   help_text='A unique tag used to identify this device')
-    rack = models.ForeignKey('Rack', related_name='devices', on_delete=models.PROTECT)
+    site = models.ForeignKey('Site', related_name='devices', on_delete=models.PROTECT)
+    rack = models.ForeignKey('Rack', related_name='devices', blank=True, null=True, on_delete=models.PROTECT)
     position = models.PositiveSmallIntegerField(blank=True, null=True, validators=[MinValueValidator(1)],
                                                 verbose_name='Position (U)',
                                                 help_text='The lowest-numbered unit occupied by the device')
@@ -898,41 +912,59 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
 
     def clean(self):
 
-        # Validate position/face combination
-        if self.position and self.face is None:
+        # Validate site/rack combination
+        if self.rack and self.site != self.rack.site:
             raise ValidationError({
-                'face': "Must specify rack face when defining rack position."
+                'rack': "Rack {} does not belong to site {}.".format(self.rack, self.site),
             })
 
-        try:
-            # Child devices cannot be assigned to a rack face/unit
-            if self.device_type.is_child_device and self.face is not None:
+        if self.rack is None:
+            if self.face is not None:
                 raise ValidationError({
-                    'face': "Child device types cannot be assigned to a rack face. This is an attribute of the parent "
-                            "device."
+                    'face': "Cannot select a rack face without assigning a rack.",
                 })
-            if self.device_type.is_child_device and self.position:
+            if self.position:
                 raise ValidationError({
-                    'position': "Child device types cannot be assigned to a rack position. This is an attribute of the "
-                                "parent device."
+                    'face': "Cannot select a rack position without assigning a rack.",
                 })
 
-            # Validate rack space
-            rack_face = self.face if not self.device_type.is_full_depth else None
-            exclude_list = [self.pk] if self.pk else []
+        # Validate position/face combination
+        if self.position and self.face is None:
+            raise ValidationError({
+                'face': "Must specify rack face when defining rack position.",
+            })
+
+        if self.rack:
+
             try:
-                available_units = self.rack.get_available_units(u_height=self.device_type.u_height, rack_face=rack_face,
-                                                                exclude=exclude_list)
-                if self.position and self.position not in available_units:
+                # Child devices cannot be assigned to a rack face/unit
+                if self.device_type.is_child_device and self.face is not None:
                     raise ValidationError({
-                        'position': "U{} is already occupied or does not have sufficient space to accommodate a(n) {} "
-                                    "({}U).".format(self.position, self.device_type, self.device_type.u_height)
+                        'face': "Child device types cannot be assigned to a rack face. This is an attribute of the parent "
+                                "device."
+                    })
+                if self.device_type.is_child_device and self.position:
+                    raise ValidationError({
+                        'position': "Child device types cannot be assigned to a rack position. This is an attribute of the "
+                                    "parent device."
                     })
-            except Rack.DoesNotExist:
-                pass
 
-        except DeviceType.DoesNotExist:
-            pass
+                # Validate rack space
+                rack_face = self.face if not self.device_type.is_full_depth else None
+                exclude_list = [self.pk] if self.pk else []
+                try:
+                    available_units = self.rack.get_available_units(u_height=self.device_type.u_height, rack_face=rack_face,
+                                                                    exclude=exclude_list)
+                    if self.position and self.position not in available_units:
+                        raise ValidationError({
+                            'position': "U{} is already occupied or does not have sufficient space to accommodate a(n) {} "
+                                        "({}U).".format(self.position, self.device_type, self.device_type.u_height)
+                        })
+                except Rack.DoesNotExist:
+                    pass
+
+            except DeviceType.DoesNotExist:
+                pass
 
     def save(self, *args, **kwargs):
 
@@ -980,8 +1012,8 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
             self.platform.name if self.platform else None,
             self.serial,
             self.asset_tag,
-            self.rack.site.name,
-            self.rack.name,
+            self.site.name,
+            self.rack.name if self.rack else None,
             self.position,
             self.get_face_display(),
         ])

+ 2 - 4
netbox/dcim/tables.py

@@ -311,8 +311,7 @@ class DeviceTable(BaseTable):
     status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='')
     name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
     tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
-    site = tables.LinkColumn('dcim:site', accessor=Accessor('rack.site'), args=[Accessor('rack.site.slug')],
-                             verbose_name='Site')
+    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
     device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role')
     device_type = tables.LinkColumn('dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type',
@@ -328,8 +327,7 @@ class DeviceTable(BaseTable):
 class DeviceImportTable(BaseTable):
     name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
     tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
-    site = tables.LinkColumn('dcim:site', accessor=Accessor('rack.site'), args=[Accessor('rack.site.slug')],
-                             verbose_name='Site')
+    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
     position = tables.Column(verbose_name='Position')
     device_role = tables.Column(verbose_name='Role')

+ 4 - 0
netbox/dcim/tests/test_apis.py

@@ -346,6 +346,7 @@ class DeviceTest(APITestCase):
         'platform',
         'serial',
         'asset_tag',
+        'site',
         'rack',
         'position',
         'face',
@@ -417,6 +418,9 @@ class DeviceTest(APITestCase):
             'primary_ip4_family',
             'primary_ip4_id',
             'primary_ip6',
+            'site_id',
+            'site_name',
+            'site_slug',
             'rack_display_name',
             'rack_facility_id',
             'rack_id',

+ 8 - 7
netbox/dcim/tests/test_models.py

@@ -6,14 +6,14 @@ class RackTestCase(TestCase):
 
     def setUp(self):
 
-        site = Site.objects.create(
+        self.site = Site.objects.create(
             name='TestSite1',
             slug='my-test-site'
         )
         self.rack = Rack.objects.create(
             name='TestRack1',
             facility_id='A101',
-            site=site,
+            site=self.site,
             u_height=42
         )
         self.manufacturer = Manufacturer.objects.create(
@@ -56,29 +56,29 @@ class RackTestCase(TestCase):
 
     def test_mount_single_device(self):
 
-        rack1 = Rack.objects.get(name='TestRack1')
         device1 = Device(
             name='TestSwitch1',
             device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'),
             device_role=DeviceRole.objects.get(slug='switch'),
-            rack=rack1,
+            site=self.site,
+            rack=self.rack,
             position=10,
             face=RACK_FACE_REAR,
         )
         device1.save()
 
         # Validate rack height
-        self.assertEqual(list(rack1.units), list(reversed(range(1, 43))))
+        self.assertEqual(list(self.rack.units), list(reversed(range(1, 43))))
 
         # Validate inventory (front face)
-        rack1_inventory_front = rack1.get_front_elevation()
+        rack1_inventory_front = self.rack.get_front_elevation()
         self.assertEqual(rack1_inventory_front[-10]['device'], device1)
         del(rack1_inventory_front[-10])
         for u in rack1_inventory_front:
             self.assertIsNone(u['device'])
 
         # Validate inventory (rear face)
-        rack1_inventory_rear = rack1.get_rear_elevation()
+        rack1_inventory_rear = self.rack.get_rear_elevation()
         self.assertEqual(rack1_inventory_rear[-10]['device'], device1)
         del(rack1_inventory_rear[-10])
         for u in rack1_inventory_rear:
@@ -89,6 +89,7 @@ class RackTestCase(TestCase):
             name='TestPDU',
             device_role=self.role.get('PDU'),
             device_type=self.device_type.get('cc5000'),
+            site=self.site,
             rack=self.rack,
             position=None,
             face=None,

+ 2 - 2
netbox/dcim/views.py

@@ -627,7 +627,7 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 
 class DeviceListView(ObjectListView):
-    queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'rack__site',
+    queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack',
                                              'primary_ip4', 'primary_ip6')
     filter = filters.DeviceFilter
     filter_form = forms.DeviceFilterForm
@@ -1411,7 +1411,7 @@ def interfaceconnection_add(request, pk):
     else:
         form = forms.InterfaceConnectionForm(device, initial={
             'interface_a': request.GET.get('interface_a', None),
-            'site_b': request.GET.get('site_b', device.rack.site),
+            'site_b': request.GET.get('site_b', device.site),
             'rack_b': request.GET.get('rack_b', None),
             'device_b': request.GET.get('device_b', None),
             'interface_b': request.GET.get('interface_b', None),

+ 49 - 15
netbox/ipam/forms.py

@@ -307,10 +307,10 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
             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.rack.site.pk
+                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(
-                    rack__site=nat_inside.interface.device.rack.site)
+                    rack__site=nat_inside.interface.device.site)
                 self.fields['nat_inside'].queryset = IPAddress.objects.filter(
                     interface__device=nat_inside.interface.device)
             else:
@@ -346,20 +346,54 @@ class IPAddressBulkAddForm(BootstrapMixin, forms.Form):
 
 
 class IPAddressAssignForm(BootstrapMixin, forms.Form):
-    site = forms.ModelChoiceField(queryset=Site.objects.all(), label='Site', required=False,
-                                  widget=forms.Select(attrs={'filter-for': 'rack'}))
-    rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
-                                  widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}',
-                                                   display_field='display_name', attrs={'filter-for': 'device'}))
-    device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
-                                    widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
-                                                     display_field='display_name', attrs={'filter-for': 'interface'}))
-    livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
-        query_key='q', query_url='dcim-api:device_list', field_to_update='device')
+    site = forms.ModelChoiceField(
+        queryset=Site.objects.all(),
+        label='Site',
+        required=False,
+        widget=forms.Select(
+            attrs={'filter-for': 'rack'}
+        )
+    )
+    rack = forms.ModelChoiceField(
+        queryset=Rack.objects.all(),
+        label='Rack',
+        required=False,
+        widget=APISelect(
+            api_url='/api/dcim/racks/?site_id={{site}}',
+            display_field='display_name',
+            attrs={'filter-for': 'device', 'nullable': 'true'}
+        )
+    )
+    device = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        label='Device',
+        required=False,
+        widget=APISelect(
+            api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
+            display_field='display_name',
+            attrs={'filter-for': 'interface'}
+        )
+    )
+    livesearch = forms.CharField(
+        required=False,
+        label='Device',
+        widget=Livesearch(
+            query_key='q',
+            query_url='dcim-api:device_list',
+            field_to_update='device'
+        )
+    )
+    interface = forms.ModelChoiceField(
+        queryset=Interface.objects.all(),
+        label='Interface',
+        widget=APISelect(
+            api_url='/api/dcim/devices/{{device}}/interfaces/'
+        )
+    )
+    set_as_primary = forms.BooleanField(
+        label='Set as primary IP for device',
+        required=False
     )
-    interface = forms.ModelChoiceField(queryset=Interface.objects.all(), label='Interface',
-                                       widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/'))
-    set_as_primary = forms.BooleanField(label='Set as primary IP for device', required=False)
 
     def __init__(self, *args, **kwargs):
 

+ 2 - 0
netbox/ipam/views.py

@@ -559,6 +559,8 @@ def ipaddress_assign(request, pk):
                 device.save()
 
             return redirect('ipam:ipaddress', pk=ipaddress.pk)
+        else:
+            assert False, form.errors
 
     else:
         form = forms.IPAddressAssignForm()

+ 14 - 14
netbox/project-static/js/forms.js

@@ -68,38 +68,38 @@ $(document).ready(function() {
     });
 
     // API select widget
-    $('select[filter-for]').change(function () {
+    $('select[filter-for]').change(function() {
 
         // Resolve child field by ID specified in parent
         var child_name = $(this).attr('filter-for');
         var child_field = $('#id_' + child_name);
         var child_selected = child_field.val();
 
-        // Wipe out any existing options within the child field
+        // Wipe out any existing options within the child field and create a default option
         child_field.empty();
-        child_field.append($("<option></option>").attr("value", "").text(""));
-
-        if ($(this).val()) {
+        child_field.append($("<option></option>").attr("value", "").text("---------"));
 
+        if ($(this).val() || $(this).attr('nullable') == 'true') {
             var api_url = child_field.attr('api-url');
             var disabled_indicator = child_field.attr('disabled-indicator');
             var initial_value = child_field.attr('initial');
             var display_field = child_field.attr('display-field') || 'name';
 
-            // Gather the values of all other filter fields for this child
-            $("select[filter-for='" + child_name + "']").each(function() {
-                var filter_field = $(this);
+            // Determine the filter fields needed to make an API call
+            var filter_regex = /\{\{([a-z_]+)\}\}/g;
+            var match;
+            while (match = filter_regex.exec(api_url)) {
+                var filter_field = $('#id_' + match[1]);
                 if (filter_field.val()) {
-                    api_url = api_url.replace('{{' + filter_field.attr('name') + '}}', filter_field.val());
-                } else {
-                    // Not all filters have been selected yet
-                    return false;
+                    api_url = api_url.replace(match[0], filter_field.val());
+                } else if ($(this).attr('nullable') == 'true') {
+                    api_url = api_url.replace(match[0], '0');
                 }
-
-            });
+            }
 
             // If all URL variables have been replaced, make the API call
             if (api_url.search('{{') < 0) {
+                console.log(child_name + ": Fetching " + api_url);
                 $.ajax({
                     url: api_url,
                     dataType: 'json',

+ 9 - 0
netbox/templates/dcim/consoleport_connect.html

@@ -7,6 +7,9 @@
 {% block content %}
 <form action="." method="post" class="form form-horizontal">
     {% csrf_token %}
+    {% for field in form.hidden_fields %}
+        {{ field }}
+    {% endfor %}
     <div class="row">
         <div class="col-md-6 col-md-offset-3">
             {% if form.non_field_errors %}
@@ -29,6 +32,12 @@
                             {% render_field form.livesearch %}
                         </div>
                         <div class="tab-pane" id="select">
+                            <div class="form-group">
+                                <label class="col-md-3 control-label">Site</label>
+                                <div class="col-md-9">
+                                    <p class="form-control-static">{{ consoleport.device.site }}</p>
+                                </div>
+                            </div>
                             {% render_field form.rack %}
                             {% render_field form.console_server %}
                         </div>

+ 10 - 1
netbox/templates/dcim/consoleserverport_connect.html

@@ -6,7 +6,10 @@
 
 {% block content %}
 <form action="." method="post" class="form form-horizontal">
-{% csrf_token %}
+    {% csrf_token %}
+    {% for field in form.hidden_fields %}
+        {{ field }}
+    {% endfor %}
     <div class="row">
         <div class="col-md-6 col-md-offset-3">
             {% if form.non_field_errors %}
@@ -29,6 +32,12 @@
                             {% render_field form.livesearch %}
                         </div>
                         <div class="tab-pane" id="select">
+                            <div class="form-group">
+                                <label class="col-md-3 control-label">Site</label>
+                                <div class="col-md-9">
+                                    <p class="form-control-static">{{ consoleserverport.device.site }}</p>
+                                </div>
+                            </div>
                             {% render_field form.rack %}
                             {% render_field form.device %}
                         </div>

+ 13 - 5
netbox/templates/dcim/device.html

@@ -27,13 +27,17 @@
                 <tr>
                     <td>Site</td>
                     <td>
-                        <a href="{% url 'dcim:site' slug=device.rack.site.slug %}">{{ device.rack.site }}</a>
+                        <a href="{% url 'dcim:site' slug=device.site.slug %}">{{ device.site }}</a>
                     </td>
                 </tr>
                 <tr>
                     <td>Rack</td>
                     <td>
-                        <span><a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack.name }}</a>{% if device.rack.facility_id %} ({{ device.rack.facility_id }}){% endif %}</span>
+                        {% if device.rack %}
+                            <span><a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack.name }}</a>{% if device.rack.facility_id %} ({{ device.rack.facility_id }}){% endif %}</span>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
                     </td>
                 </tr>
                 <tr>
@@ -44,9 +48,9 @@
                                 <span>U{{ parent.position }} / {{ parent.get_face_display }}
                                 (<a href="{{ parent.get_absolute_url }}">{{ parent }}</a> - {{ device.parent_bay.name }})</span>
                             {% endwith %}
-                        {% elif device.position %}
+                        {% elif device.rack and device.position %}
                             <span>U{{ device.position }} / {{ device.get_face_display }}</span>
-                        {% elif device.device_type.u_height %}
+                        {% elif device.rack and device.device_type.u_height %}
                             <span class="label label-warning">Not racked</span>
                         {% else %}
                             <span class="text-muted">N/A</span>
@@ -314,7 +318,11 @@
                                 <a href="{% url 'dcim:device' pk=rd.pk %}">{{ rd }}</a>
                             </td>
                             <td>
-                                <a href="{% url 'dcim:rack' pk=rd.rack.pk %}">Rack {{ rd.rack }}</a>
+                                {% if rd.rack %}
+                                    <a href="{% url 'dcim:rack' pk=rd.rack.pk %}">Rack {{ rd.rack }}</a>
+                                {% else %}
+                                    <span class="text-muted">&mdash;</span>
+                                {% endif %}
                             </td>
                             <td>{{ rd.device_type.full_name }}</td>
                         </tr>

+ 11 - 11
netbox/templates/dcim/inc/device_header.html

@@ -1,17 +1,17 @@
 <div class="row">
     <div class="col-sm-8 col-md-9">
-    {% if device.rack %}
-        <ol class="breadcrumb">
-            <li><a href="{% url 'dcim:site' slug=device.rack.site.slug %}">{{ device.rack.site }}</a></li>
-            <li><a href="{% url 'dcim:rack_list' %}?site={{ device.rack.site.slug }}">Racks</a></li>
+    <ol class="breadcrumb">
+        <li><a href="{% url 'dcim:site' slug=device.site.slug %}">{{ device.site }}</a></li>
+        {% if device.rack %}
+            <li><a href="{% url 'dcim:rack_list' %}?site={{ device.site.slug }}">Racks</a></li>
             <li><a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack }}</a></li>
-            {% if device.parent_bay %}
-                <li><a href="{% url 'dcim:device' pk=device.parent_bay.device.pk %}">{{ device.parent_bay.device }}</a></li>
-                <li>{{ device.parent_bay.name }}</li>
-            {% endif %}
-            <li>{{ device }}</li>
-        </ol>
-    {% endif %}
+        {% endif %}
+        {% if device.parent_bay %}
+            <li><a href="{% url 'dcim:device' pk=device.parent_bay.device.pk %}">{{ device.parent_bay.device }}</a></li>
+            <li>{{ device.parent_bay.name }}</li>
+        {% endif %}
+        <li>{{ device }}</li>
+    </ol>
     </div>
     <div class="col-sm-4 col-md-3">
         <form action="{% url 'dcim:device_list' %}" method="get">

+ 68 - 68
netbox/templates/dcim/interfaceconnection_edit.html

@@ -7,88 +7,88 @@
 {% block content %}
 <h1>Connect Interfaces</h1>
 <form action="." method="post" class="form form-horizontal">
-{% csrf_token %}
-<div class="row">
-    <div class="col-md-6 col-md-offset-3">
-        {% if form.non_field_errors %}
-            <div class="panel panel-danger">
-                <div class="panel-heading"><strong>Errors</strong></div>
-                <div class="panel-body">
-                    {{ form.non_field_errors }}
+    {% csrf_token %}
+    <div class="row">
+        <div class="col-md-6 col-md-offset-3">
+            {% if form.non_field_errors %}
+                <div class="panel panel-danger">
+                    <div class="panel-heading"><strong>Errors</strong></div>
+                    <div class="panel-body">
+                        {{ form.non_field_errors }}
+                    </div>
                 </div>
-            </div>
-        {% endif %}
+            {% endif %}
+        </div>
     </div>
-</div>
-<div class="row">
-	<div class="col-md-5">
-        <div class="panel panel-default">
-            <div class="panel-heading text-center">
-                <strong>A Side</strong>
-            </div>
-            <div class="panel-body">
-                <div class="form-group">
-                    <label class="col-md-3 control-label required">Site</label>
-                    <div class="col-md-9">
-                        <p class="form-control-static">{{ device.rack.site }}</p>
-                    </div>
+    <div class="row">
+        <div class="col-md-5">
+            <div class="panel panel-default">
+                <div class="panel-heading text-center">
+                    <strong>A Side</strong>
                 </div>
-                <div class="form-group">
-                    <label class="col-md-3 control-label required">Rack</label>
-                    <div class="col-md-9">
-                        <p class="form-control-static">{{ device.rack }}</p>
+                <div class="panel-body">
+                    <div class="form-group">
+                        <label class="col-md-3 control-label required">Site</label>
+                        <div class="col-md-9">
+                            <p class="form-control-static">{{ device.site }}</p>
+                        </div>
                     </div>
-                </div>
-                <div class="form-group">
-                    <label class="col-md-3 control-label required">Device</label>
-                    <div class="col-md-9">
-                        <p class="form-control-static">{{ device }}</p>
+                    <div class="form-group">
+                        <label class="col-md-3 control-label required">Rack</label>
+                        <div class="col-md-9">
+                            <p class="form-control-static">{{ device.rack|default:"None" }}</p>
+                        </div>
+                    </div>
+                    <div class="form-group">
+                        <label class="col-md-3 control-label required">Device</label>
+                        <div class="col-md-9">
+                            <p class="form-control-static">{{ device }}</p>
+                        </div>
                     </div>
+                    {% render_field form.interface_a %}
                 </div>
-                {% render_field form.interface_a %}
             </div>
         </div>
-	</div>
-	<div class="col-md-2 text-center" style="padding-top: 90px;">
-        <i class="fa fa-exchange fa-4x"></i>
-    </div>
-	<div class="col-md-5">
-        <div class="panel panel-default">
-            <div class="panel-heading text-center">
-                <strong>B Side</strong>
-            </div>
-            <div class="panel-body">
-                <ul class="nav nav-tabs" role="tablist">
-                    <li role="presentation" class="active"><a href="#search" aria-controls="search" role="tab" data-toggle="tab">Search</a></li>
-                    <li role="presentation"><a href="#select" aria-controls="home" role="tab" data-toggle="tab">Select</a></li>
-                </ul>
-                <div class="tab-content">
-                    <div class="tab-pane active" id="search">
-                        {% render_field form.livesearch %}
-                    </div>
-                    <div class="tab-pane" id="select">
-                        {% render_field form.site_b %}
-                        {% render_field form.rack_b %}
-                        {% render_field form.device_b %}
+        <div class="col-md-2 text-center" style="padding-top: 90px;">
+            <i class="fa fa-exchange fa-4x"></i>
+        </div>
+        <div class="col-md-5">
+            <div class="panel panel-default">
+                <div class="panel-heading text-center">
+                    <strong>B Side</strong>
+                </div>
+                <div class="panel-body">
+                    <ul class="nav nav-tabs" role="tablist">
+                        <li role="presentation" class="active"><a href="#search" aria-controls="search" role="tab" data-toggle="tab">Search</a></li>
+                        <li role="presentation"><a href="#select" aria-controls="home" role="tab" data-toggle="tab">Select</a></li>
+                    </ul>
+                    <div class="tab-content">
+                        <div class="tab-pane active" id="search">
+                            {% render_field form.livesearch %}
+                        </div>
+                        <div class="tab-pane" id="select">
+                            {% render_field form.site_b %}
+                            {% render_field form.rack_b %}
+                            {% render_field form.device_b %}
+                        </div>
                     </div>
+                    {% render_field form.interface_b %}
                 </div>
-                {% render_field form.interface_b %}
             </div>
         </div>
-	</div>
-</div>
-<div class="row">
-    <div class="col-md-4 col-md-offset-4">
-        {% render_field form.connection_status %}
     </div>
-</div>
-<div class="text-center">
-    <div class="form-group">
-        <button type="submit" name="_create" class="btn btn-primary">Connect</button>
-        <button type="submit" name="_addanother" class="btn btn-primary">Connect and Add Another</button>
-        <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
+    <div class="row">
+        <div class="col-md-4 col-md-offset-4">
+            {% render_field form.connection_status %}
+        </div>
+    </div>
+    <div class="text-center">
+        <div class="form-group">
+            <button type="submit" name="_create" class="btn btn-primary">Connect</button>
+            <button type="submit" name="_addanother" class="btn btn-primary">Connect and Add Another</button>
+            <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
+        </div>
     </div>
-</div>
 </form>
 {% endblock %}
 

+ 10 - 1
netbox/templates/dcim/poweroutlet_connect.html

@@ -6,7 +6,10 @@
 
 {% block content %}
 <form action="." method="post" class="form form-horizontal">
-{% csrf_token %}
+    {% csrf_token %}
+    {% for field in form.hidden_fields %}
+        {{ field }}
+    {% endfor %}
     <div class="row">
         <div class="col-md-6 col-md-offset-3">
             {% if form.non_field_errors %}
@@ -29,6 +32,12 @@
                             {% render_field form.livesearch %}
                         </div>
                         <div class="tab-pane" id="select">
+                            <div class="form-group">
+                                <label class="col-md-3 control-label">Site</label>
+                                <div class="col-md-9">
+                                    <p class="form-control-static">{{ poweroutlet.device.site }}</p>
+                                </div>
+                            </div>
                             {% render_field form.rack %}
                             {% render_field form.device %}
                         </div>

+ 10 - 1
netbox/templates/dcim/powerport_connect.html

@@ -6,7 +6,10 @@
 
 {% block content %}
 <form action="." method="post" class="form form-horizontal">
-{% csrf_token %}
+    {% csrf_token %}
+    {% for field in form.hidden_fields %}
+        {{ field }}
+    {% endfor %}
     <div class="row">
         <div class="col-md-6 col-md-offset-3">
             {% if form.non_field_errors %}
@@ -29,6 +32,12 @@
                             {% render_field form.livesearch %}
                         </div>
                         <div class="tab-pane" id="select">
+                            <div class="form-group">
+                                <label class="col-md-3 control-label">Site</label>
+                                <div class="col-md-9">
+                                    <p class="form-control-static">{{ powerport.device.site }}</p>
+                                </div>
+                            </div>
                             {% render_field form.rack %}
                             {% render_field form.pdu %}
                         </div>