diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 1f4f3ff5f..379d71b0d 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -309,6 +309,7 @@ class ManufacturerSerializer(NetBoxModelSerializer): class DeviceTypeSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') manufacturer = NestedManufacturerSerializer() + default_platform = NestedPlatformSerializer(required=False, allow_null=True) u_height = serializers.DecimalField( max_digits=4, decimal_places=1, @@ -324,7 +325,7 @@ class DeviceTypeSerializer(NetBoxModelSerializer): class Meta: model = DeviceType fields = [ - 'id', 'url', 'display', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', + 'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', ] diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 83ae8bcc9..774f8a41f 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -436,6 +436,16 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): to_field_name='slug', label=_('Manufacturer (slug)'), ) + default_platform_id = django_filters.ModelMultipleChoiceFilter( + queryset=Platform.objects.all(), + label=_('Default platform (ID)'), + ) + default_platform = django_filters.ModelMultipleChoiceFilter( + field_name='default_platform__slug', + queryset=Platform.objects.all(), + to_field_name='slug', + label=_('Default platform (slug)'), + ) has_front_image = django_filters.BooleanFilter( label=_('Has a front image'), method='_has_front_image' diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index d9770db40..e5b896f9f 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -374,6 +374,10 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): queryset=Manufacturer.objects.all(), required=False ) + default_platform = DynamicModelChoiceField( + queryset=Platform.objects.all(), + required=False + ) part_number = forms.CharField( required=False ) @@ -412,7 +416,7 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): model = DeviceType fieldsets = ( - ('Device Type', ('manufacturer', 'part_number', 'u_height', 'is_full_depth', 'airflow', 'description')), + ('Device Type', ('manufacturer', 'default_platform', 'part_number', 'u_height', 'is_full_depth', 'airflow', 'description')), ('Weight', ('weight', 'weight_unit')), ) nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments') diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 3f016899e..1e8abcac6 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -281,12 +281,17 @@ class DeviceTypeImportForm(NetBoxModelImportForm): queryset=Manufacturer.objects.all(), to_field_name='name' ) + default_platform = forms.ModelChoiceField( + queryset=Platform.objects.all(), + to_field_name='name', + required=False, + ) class Meta: model = DeviceType fields = [ - 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', - 'description', 'comments', + 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', + 'subdevice_role', 'airflow', 'description', 'comments', ] diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 4dd2f73eb..7e2b4d2d8 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -378,7 +378,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): model = DeviceType fieldsets = ( (None, ('q', 'filter_id', 'tag')), - ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')), + ('Hardware', ('manufacturer_id', 'default_platform_id', 'part_number', 'subdevice_role', 'airflow')), ('Images', ('has_front_image', 'has_rear_image')), ('Components', ( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', @@ -391,6 +391,11 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): required=False, label=_('Manufacturer') ) + default_platform_id = DynamicModelMultipleChoiceField( + queryset=Platform.objects.all(), + required=False, + label=_('Default platform') + ) part_number = forms.CharField( required=False ) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 44e2e3526..14217d2d6 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -378,13 +378,17 @@ class DeviceTypeForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all() ) + default_platform = DynamicModelChoiceField( + queryset=Platform.objects.all(), + required=False + ) slug = SlugField( slug_source='model' ) comments = CommentField() fieldsets = ( - ('Device Type', ('manufacturer', 'model', 'slug', 'description', 'tags')), + ('Device Type', ('manufacturer', 'model', 'slug', 'description', 'tags', 'default_platform')), ('Chassis', ( 'u_height', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow', 'weight', 'weight_unit', )), @@ -395,7 +399,7 @@ class DeviceTypeForm(NetBoxModelForm): model = DeviceType fields = [ 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', - 'weight', 'weight_unit', 'front_image', 'rear_image', 'description', 'comments', 'tags', + 'weight', 'weight_unit', 'front_image', 'rear_image', 'description', 'comments', 'tags', 'default_platform' ] widgets = { 'airflow': StaticSelect(), diff --git a/netbox/dcim/migrations/0169_devicetype_default_platform.py b/netbox/dcim/migrations/0169_devicetype_default_platform.py new file mode 100644 index 000000000..a143f2c62 --- /dev/null +++ b/netbox/dcim/migrations/0169_devicetype_default_platform.py @@ -0,0 +1,19 @@ +# Generated by Django 4.1.6 on 2023-02-10 18:06 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0168_interface_template_enabled'), + ] + + operations = [ + migrations.AddField( + model_name='devicetype', + name='default_platform', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.platform'), + ), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 603129228..94f61aba7 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -82,6 +82,14 @@ class DeviceType(PrimaryModel, WeightMixin): slug = models.SlugField( max_length=100 ) + default_platform = models.ForeignKey( + to='dcim.Platform', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True, + verbose_name='Default platform' + ) part_number = models.CharField( max_length=50, blank=True, @@ -121,7 +129,7 @@ class DeviceType(PrimaryModel, WeightMixin): ) clone_fields = ( - 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit' + 'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit' ) prerequisite_models = ( 'dcim.Manufacturer', @@ -165,6 +173,7 @@ class DeviceType(PrimaryModel, WeightMixin): 'manufacturer': self.manufacturer.name, 'model': self.model, 'slug': self.slug, + 'default_platform': self.default_platform.name if self.default_platform else None, 'part_number': self.part_number, 'u_height': float(self.u_height), 'is_full_depth': self.is_full_depth, @@ -801,6 +810,10 @@ class Device(PrimaryModel, ConfigContextModel): if is_new and not self.airflow: self.airflow = self.device_type.airflow + # Inherit default_platform from DeviceType if not set + if is_new and not self.platform: + self.platform = self.device_type.default_platform + super().save(*args, **kwargs) # If this is a new Device, instantiate all the related components per the DeviceType definition diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index dff697588..91a37fab3 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -77,6 +77,9 @@ class DeviceTypeTable(NetBoxTable): manufacturer = tables.Column( linkify=True ) + default_platform = tables.Column( + linkify=True + ) is_full_depth = columns.BooleanColumn( verbose_name='Full Depth' ) @@ -100,7 +103,7 @@ class DeviceTypeTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = models.DeviceType fields = ( - 'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', + 'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'description', 'comments', 'instance_count', 'tags', 'created', 'last_updated', ) default_columns = ( diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 45d5797bd..c78b592d3 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -699,9 +699,16 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): ) Manufacturer.objects.bulk_create(manufacturers) + platforms = ( + Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0]), + Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1]), + Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2]), + ) + Platform.objects.bulk_create(platforms) + device_types = ( - DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True, front_image='front.png', rear_image='rear.png', weight=10, weight_unit=WeightUnitChoices.UNIT_POUND), - DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, weight=20, weight_unit=WeightUnitChoices.UNIT_POUND), + DeviceType(manufacturer=manufacturers[0], default_platform=platforms[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True, front_image='front.png', rear_image='rear.png', weight=10, weight_unit=WeightUnitChoices.UNIT_POUND), + DeviceType(manufacturer=manufacturers[1], default_platform=platforms[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, weight=20, weight_unit=WeightUnitChoices.UNIT_POUND), DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM), ) DeviceType.objects.bulk_create(device_types) @@ -785,6 +792,13 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_default_platform(self): + platforms = Platform.objects.all()[:2] + params = {'default_platform_id': [platforms[0].pk, platforms[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'default_platform': [platforms[0].slug, platforms[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_has_front_image(self): params = {'has_front_image': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 6ea935bc8..bba91412d 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -503,6 +503,12 @@ class DeviceTypeTestCase( ) Manufacturer.objects.bulk_create(manufacturers) + platforms = ( + Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0]), + Platform(name='Platform 2', slug='platform-3', manufacturer=manufacturers[1]), + ) + Platform.objects.bulk_create(platforms) + DeviceType.objects.bulk_create([ DeviceType(model='Device Type 1', slug='device-type-1', manufacturer=manufacturers[0]), DeviceType(model='Device Type 2', slug='device-type-2', manufacturer=manufacturers[0]), @@ -513,6 +519,7 @@ class DeviceTypeTestCase( cls.form_data = { 'manufacturer': manufacturers[1].pk, + 'default_platform': platforms[0].pk, 'model': 'Device Type X', 'slug': 'device-type-x', 'part_number': '123ABC', @@ -525,6 +532,7 @@ class DeviceTypeTestCase( cls.bulk_edit_data = { 'manufacturer': manufacturers[1].pk, + 'default_platform': platforms[1].pk, 'u_height': 3, 'is_full_depth': False, } @@ -673,6 +681,7 @@ class DeviceTypeTestCase( """ IMPORT_DATA = """ manufacturer: Generic +default_platform: Platform model: TEST-1000 slug: test-1000 u_height: 2 @@ -755,8 +764,11 @@ inventory-items: manufacturer: Generic """ - # Create the manufacturer - Manufacturer(name='Generic', slug='generic').save() + # Create the manufacturer and platform + manufacturer = Manufacturer(name='Generic', slug='generic') + manufacturer.save() + platform = Platform(name='Platform', slug='test-platform', manufacturer=manufacturer) + platform.save() # Add all required permissions to the test user self.add_permissions( @@ -783,6 +795,7 @@ inventory-items: device_type = DeviceType.objects.get(model='TEST-1000') self.assertEqual(device_type.comments, 'Test comment') + self.assertEqual(device_type.default_platform.pk, platform.pk) # Verify all of the components were created self.assertEqual(device_type.consoleporttemplates.count(), 3) diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 73c82ddae..984898caa 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -27,6 +27,10 @@