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..1f4138b00 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'} @@ -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): 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/views.py b/netbox/dcim/views.py index 4bec35be9..f2e8a13ed 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 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 @@ 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 %} @@ -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 }} - Rack {{ rd.rack }} + {% if rd.rack %} + Rack {{ rd.rack }} + {% else %} + + {% endif %} {{ rd.device_type.full_name }} diff --git a/netbox/templates/dcim/inc/device_header.html b/netbox/templates/dcim/inc/device_header.html index 74b453e1d..4737d59c6 100644 --- a/netbox/templates/dcim/inc/device_header.html +++ b/netbox/templates/dcim/inc/device_header.html @@ -1,17 +1,17 @@
- {% if device.rack %} -