From e8896fe238b098d330916610bd0af789c922dfce Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 16 Feb 2017 15:13:35 -0500 Subject: [PATCH 1/5] Closes #898: Expand circuits list in provider view --- netbox/circuits/views.py | 3 +- netbox/templates/circuits/provider.html | 46 +++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index de5ef1a22..466104883 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -31,7 +31,8 @@ class ProviderListView(ObjectListView): def provider(request, slug): provider = get_object_or_404(Provider, slug=slug) - circuits = Circuit.objects.filter(provider=provider) + circuits = Circuit.objects.filter(provider=provider).select_related('type', 'tenant')\ + .prefetch_related('terminations__site') show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists() return render(request, 'circuits/provider.html', { diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index c9c8b9742..5465b8599 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -48,7 +48,7 @@

{{ provider }}

{% include 'inc/created_updated.html' with obj=provider %}
-
+
Provider @@ -104,6 +104,12 @@ {% endif %} + + Circuits + + {{ provider.circuits.count }} + +
{% with provider.get_custom_fields as custom_fields %} @@ -122,12 +128,20 @@
-
+
Circuits
+ + + + + + + + {% for c in circuits %} + + + + {% empty %} From 9d44d5d4e78d247d0d7e5d37e94208d8da3d6489 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 16 Feb 2017 15:17:13 -0500 Subject: [PATCH 2/5] Fixes #897: Fixed power connections CSV export --- netbox/dcim/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 1225988ca..5ec28231f 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1110,8 +1110,8 @@ class PowerPort(models.Model): return self.name # Used for connections export - def csv_format(self): - return ','.join([ + def to_csv(self): + return csv_format([ self.power_outlet.device.identifier if self.power_outlet else None, self.power_outlet.name if self.power_outlet else None, self.device.identifier, From 198ed859ff3d4264e029a2420bdf1363510c4085 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 17 Feb 2017 14:48:00 -0500 Subject: [PATCH 3/5] 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 --- netbox/circuits/forms.py | 54 ++- netbox/dcim/api/serializers.py | 9 +- netbox/dcim/filters.py | 6 +- netbox/dcim/fixtures/dcim.json | 11 + netbox/dcim/forms.py | 368 +++++++++++++----- .../dcim/migrations/0027_device_add_site.py | 21 + .../0028_device_copy_rack_to_site.py | 23 ++ .../migrations/0029_allow_rackless_devices.py | 26 ++ netbox/dcim/models.py | 88 +++-- netbox/dcim/tables.py | 6 +- netbox/dcim/tests/test_apis.py | 4 + netbox/dcim/tests/test_models.py | 15 +- netbox/dcim/views.py | 4 +- netbox/ipam/forms.py | 64 ++- netbox/ipam/views.py | 2 + netbox/project-static/js/forms.js | 28 +- .../templates/dcim/consoleport_connect.html | 9 + .../dcim/consoleserverport_connect.html | 11 +- netbox/templates/dcim/device.html | 18 +- netbox/templates/dcim/inc/device_header.html | 22 +- .../dcim/interfaceconnection_edit.html | 140 +++---- .../templates/dcim/poweroutlet_connect.html | 11 +- netbox/templates/dcim/powerport_connect.html | 11 +- 23 files changed, 684 insertions(+), 267 deletions(-) create mode 100644 netbox/dcim/migrations/0027_device_add_site.py create mode 100644 netbox/dcim/migrations/0028_device_copy_rack_to_site.py create mode 100644 netbox/dcim/migrations/0029_allow_rackless_devices.py diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 4b9e949f8..7390c2216 100644 --- a/netbox/circuits/forms.py +++ b/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 diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 34e0b1a1e..358fcd1f2 100644 --- a/netbox/dcim/api/serializers.py +++ b/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: diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 256dd0084..58e339278 100644 --- a/netbox/dcim/filters.py +++ b/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)', diff --git a/netbox/dcim/fixtures/dcim.json b/netbox/dcim/fixtures/dcim.json index 7c011eb89..4a9eb15e4 100644 --- a/netbox/dcim/fixtures/dcim.json +++ b/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, diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 64e8b57fa..a4fc39626 100644 --- a/netbox/dcim/forms.py +++ b/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): diff --git a/netbox/dcim/migrations/0027_device_add_site.py b/netbox/dcim/migrations/0027_device_add_site.py new file mode 100644 index 000000000..12d85f53e --- /dev/null +++ b/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'), + ), + ] diff --git a/netbox/dcim/migrations/0028_device_copy_rack_to_site.py b/netbox/dcim/migrations/0028_device_copy_rack_to_site.py new file mode 100644 index 000000000..6e7c52114 --- /dev/null +++ b/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), + ] diff --git a/netbox/dcim/migrations/0029_allow_rackless_devices.py b/netbox/dcim/migrations/0029_allow_rackless_devices.py new file mode 100644 index 000000000..83906fc76 --- /dev/null +++ b/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'), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 5ec28231f..64b0458b4 100644 --- a/netbox/dcim/models.py +++ b/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 site/rack combination + if self.rack and self.site != self.rack.site: + raise ValidationError({ + 'rack': "Rack {} does not belong to site {}.".format(self.rack, self.site), + }) + + if self.rack is None: + if self.face is not None: + raise ValidationError({ + 'face': "Cannot select a rack face without assigning a rack.", + }) + if self.position: + raise ValidationError({ + 'face': "Cannot select a rack position without assigning a rack.", + }) + # Validate position/face combination if self.position and self.face is None: raise ValidationError({ - 'face': "Must specify rack face when defining rack position." + 'face': "Must specify rack face when defining rack position.", }) - try: - # 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({ - '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." - }) + if self.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 [] 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(), ]) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 442e2f8fb..0a891efea 100644 --- a/netbox/dcim/tables.py +++ b/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') diff --git a/netbox/dcim/tests/test_apis.py b/netbox/dcim/tests/test_apis.py index cec552984..604a952b7 100644 --- a/netbox/dcim/tests/test_apis.py +++ b/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', diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 2f3d8def6..d1b721cb0 100644 --- a/netbox/dcim/tests/test_models.py +++ b/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, diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 4bec35be9..df93e61f3 100644 --- a/netbox/dcim/views.py +++ b/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), diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 4b9d8ddf5..83be76169 100644 --- a/netbox/ipam/forms.py +++ b/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): diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index b53bb82ab..191d33a90 100644 --- a/netbox/ipam/views.py +++ b/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() diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 5a736627e..e421f6283 100644 --- a/netbox/project-static/js/forms.js +++ b/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($("").attr("value", "").text("")); - - if ($(this).val()) { + child_field.append($("").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', diff --git a/netbox/templates/dcim/consoleport_connect.html b/netbox/templates/dcim/consoleport_connect.html index c237bc2c9..f896ebbd5 100644 --- a/netbox/templates/dcim/consoleport_connect.html +++ b/netbox/templates/dcim/consoleport_connect.html @@ -7,6 +7,9 @@ {% block content %}
{% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %}
{% if form.non_field_errors %} @@ -29,6 +32,12 @@ {% render_field form.livesearch %}
+
+ +
+

{{ consoleport.device.site }}

+
+
{% render_field form.rack %} {% render_field form.console_server %}
diff --git a/netbox/templates/dcim/consoleserverport_connect.html b/netbox/templates/dcim/consoleserverport_connect.html index e747a9d57..08850d1ed 100644 --- a/netbox/templates/dcim/consoleserverport_connect.html +++ b/netbox/templates/dcim/consoleserverport_connect.html @@ -6,7 +6,10 @@ {% block content %} -{% csrf_token %} + {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %}
{% if form.non_field_errors %} @@ -29,6 +32,12 @@ {% render_field form.livesearch %}
+
+ +
+

{{ consoleserverport.device.site }}

+
+
{% render_field form.rack %} {% render_field form.device %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 4507e2141..cde8ce439 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -27,13 +27,17 @@
@@ -44,9 +48,9 @@ U{{ parent.position }} / {{ parent.get_face_display }} ({{ parent }} - {{ device.parent_bay.name }}) {% endwith %} - {% elif device.position %} + {% elif device.rack and device.position %} U{{ device.position }} / {{ device.get_face_display }} - {% elif device.device_type.u_height %} + {% elif device.rack and device.device_type.u_height %} Not racked {% else %} N/A @@ -314,7 +318,11 @@ {{ rd }} diff --git a/netbox/templates/dcim/inc/device_header.html b/netbox/templates/dcim/inc/device_header.html index 74b453e1d..9f31c73fc 100644 --- a/netbox/templates/dcim/inc/device_header.html +++ b/netbox/templates/dcim/inc/device_header.html @@ -1,17 +1,17 @@
- {% if device.rack %} -
diff --git a/netbox/templates/dcim/interfaceconnection_edit.html b/netbox/templates/dcim/interfaceconnection_edit.html index ea30ad006..ad0335398 100644 --- a/netbox/templates/dcim/interfaceconnection_edit.html +++ b/netbox/templates/dcim/interfaceconnection_edit.html @@ -7,88 +7,88 @@ {% block content %}

Connect Interfaces

-{% csrf_token %} -
-
- {% if form.non_field_errors %} -
-
Errors
+ {% csrf_token %} +
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
+
+
+
+
+ A Side +
- {{ form.non_field_errors }} -
-
- {% endif %} -
-
-
-
-
-
- A Side -
-
-
- -
-

{{ device.rack.site }}

+
+ +
+

{{ device.site }}

+
-
-
- -
-

{{ device.rack }}

+
+ +
+

{{ device.rack|default:"None" }}

+
-
-
- -
-

{{ device }}

+
+ +
+

{{ device }}

+
+ {% render_field form.interface_a %}
- {% render_field form.interface_a %}
-
-
- -
-
-
-
- B Side -
-
- -
- -
- {% render_field form.site_b %} - {% render_field form.rack_b %} - {% render_field form.device_b %} -
+
+ +
+
+
+
+ B Side +
+
+ +
+ +
+ {% render_field form.site_b %} + {% render_field form.rack_b %} + {% render_field form.device_b %} +
+
+ {% render_field form.interface_b %}
- {% render_field form.interface_b %}
-
-
-
-
- {% render_field form.connection_status %}
-
-
-
- - - Cancel +
+
+ {% render_field form.connection_status %} +
+
+
+
+ + + Cancel +
-
{% endblock %} diff --git a/netbox/templates/dcim/poweroutlet_connect.html b/netbox/templates/dcim/poweroutlet_connect.html index a302722df..927ed7b71 100644 --- a/netbox/templates/dcim/poweroutlet_connect.html +++ b/netbox/templates/dcim/poweroutlet_connect.html @@ -6,7 +6,10 @@ {% block content %}
-{% csrf_token %} + {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %}
{% if form.non_field_errors %} @@ -29,6 +32,12 @@ {% render_field form.livesearch %}
+
+ +
+

{{ poweroutlet.device.site }}

+
+
{% render_field form.rack %} {% render_field form.device %}
diff --git a/netbox/templates/dcim/powerport_connect.html b/netbox/templates/dcim/powerport_connect.html index 94e567e68..9e7f1fae9 100644 --- a/netbox/templates/dcim/powerport_connect.html +++ b/netbox/templates/dcim/powerport_connect.html @@ -6,7 +6,10 @@ {% block content %} -{% csrf_token %} + {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %}
{% if form.non_field_errors %} @@ -29,6 +32,12 @@ {% render_field form.livesearch %}
+
+ +
+

{{ powerport.device.site }}

+
+
{% render_field form.rack %} {% render_field form.pdu %}
From 102cf52a16abe405b8a84e20e8011afe4c58745e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 17 Feb 2017 14:52:58 -0500 Subject: [PATCH 4/5] Cleanup from work on #198 --- netbox/dcim/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index a4fc39626..f9e3dcf62 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -482,7 +482,7 @@ class DeviceForm(BootstrapMixin, CustomFieldForm): if self.instance.pk: # Initialize helper selections - self.initial['site'] = self.instance.rack.site + self.initial['site'] = self.instance.site self.initial['manufacturer'] = self.instance.device_type.manufacturer # Compile list of choices for primary IPv4 and IPv6 addresses From 4d26fc7e7ca5763f35760f8c1aea57076e932298 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 17 Feb 2017 15:10:08 -0500 Subject: [PATCH 5/5] Fixes #903: Only alert on missing criticial connections if present in the parent device type --- netbox/templates/dcim/device.html | 54 +++++++++++++++++-------------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index cde8ce439..653575f2b 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -240,38 +240,44 @@ {% for iface in mgmt_interfaces %} {% include 'dcim/inc/interface.html' with icon='wrench' %} {% empty %} -
- - + {% if device.device_type.interface_templates.exists %} + + + + {% endif %} {% endfor %} {% for cp in console_ports %} {% include 'dcim/inc/consoleport.html' %} {% empty %} - - - + {% if device.device_type.console_port_templates.exists %} + + + + {% endif %} {% endfor %} {% for pp in power_ports %} {% include 'dcim/inc/powerport.html' %} {% empty %} - - - + {% if device.device_type.power_port_templates.exists %} + + + + {% endif %} {% endfor %}
Circuit IDTypeTenantA SideZ SideDescription
@@ -136,6 +150,34 @@ {{ c.type }} + {% if c.tenant %} + {{ c.tenant }} + {% else %} + + {% endif %} + + {% if c.termination_a %} + {{ c.termination_a.site }} + {% else %} + + {% endif %} + + {% if c.termination_z %} + {{ c.termination_z.site }} + {% else %} + + {% endif %} + + {% if c.description %} + {{ c.description }} + {% else %} + + {% endif %} +
Site - {{ device.rack.site }} + {{ device.site }}
Rack - {{ device.rack.name }}{% if device.rack.facility_id %} ({{ device.rack.facility_id }}){% endif %} + {% if device.rack %} + {{ device.rack.name }}{% if device.rack.facility_id %} ({{ device.rack.facility_id }}){% endif %} + {% else %} + None + {% endif %}
- Rack {{ rd.rack }} + {% if rd.rack %} + Rack {{ rd.rack }} + {% else %} + + {% endif %} {{ rd.device_type.full_name }}
- No management interfaces defined - {% if perms.dcim.add_interface %} - - {% endif %} -
+ No management interfaces defined + {% if perms.dcim.add_interface %} + + {% endif %} +
- No console ports defined - {% if perms.dcim.add_consoleport %} - - {% endif %} -
+ No console ports defined + {% if perms.dcim.add_consoleport %} + + {% endif %} +
- No power ports defined - {% if perms.dcim.add_powerport %} - - {% endif %} -
+ No power ports defined + {% if perms.dcim.add_powerport %} + + {% endif %} +
{% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %}