diff --git a/docs/models/dcim/platform.md b/docs/models/dcim/platform.md index 35b0b68ebd..3400294e60 100644 --- a/docs/models/dcim/platform.md +++ b/docs/models/dcim/platform.md @@ -2,12 +2,20 @@ A platform defines the type of software running on a [device](./device.md) or [virtual machine](../virtualization/virtualmachine.md). This can be helpful to model when it is necessary to distinguish between different versions or feature sets. Note that two devices of the same type may be assigned different platforms: For example, one Juniper MX240 might run Junos 14 while another runs Junos 15. +Platforms may be nested under parents to form a hierarchy. For example, platforms named "Debian" and "RHEL" might both be created under a generic "Linux" parent. + Platforms may optionally be limited by [manufacturer](./manufacturer.md): If a platform is assigned to a particular manufacturer, it can only be assigned to devices with a type belonging to that manufacturer. -The assignment of platforms to devices is an optional feature, and may be disregarded if not desired. +The assignment of platforms to devices and virtual machines is optional. ## Fields +## Parent + +!!! "This field was introduced in NetBox v4.4." + +The parent platform class to which this platform belongs (optional). + ### Name A human-friendly name for the platform. Must be unique per manufacturer. diff --git a/netbox/dcim/api/serializers_/nested.py b/netbox/dcim/api/serializers_/nested.py index 0e9eaa52fe..5b1be4d984 100644 --- a/netbox/dcim/api/serializers_/nested.py +++ b/netbox/dcim/api/serializers_/nested.py @@ -6,11 +6,13 @@ from dcim import models __all__ = ( 'NestedDeviceBaySerializer', + 'NestedDeviceRoleSerializer', 'NestedDeviceSerializer', 'NestedInterfaceSerializer', 'NestedInterfaceTemplateSerializer', 'NestedLocationSerializer', 'NestedModuleBaySerializer', + 'NestedPlatformSerializer', 'NestedRegionSerializer', 'NestedSiteGroupSerializer', ) @@ -102,3 +104,10 @@ class NestedModuleBaySerializer(WritableNestedSerializer): class Meta: model = models.ModuleBay fields = ['id', 'url', 'display_url', 'display', 'name'] + + +class NestedPlatformSerializer(WritableNestedSerializer): + + class Meta: + model = models.Platform + fields = ['id', 'url', 'display_url', 'display', 'name'] diff --git a/netbox/dcim/api/serializers_/platforms.py b/netbox/dcim/api/serializers_/platforms.py index 2f47457019..c357b0bbe8 100644 --- a/netbox/dcim/api/serializers_/platforms.py +++ b/netbox/dcim/api/serializers_/platforms.py @@ -1,15 +1,17 @@ from dcim.models import Platform from extras.api.serializers_.configtemplates import ConfigTemplateSerializer from netbox.api.fields import RelatedObjectCountField -from netbox.api.serializers import NetBoxModelSerializer +from netbox.api.serializers import NestedGroupModelSerializer from .manufacturers import ManufacturerSerializer +from .nested import NestedPlatformSerializer __all__ = ( 'PlatformSerializer', ) -class PlatformSerializer(NetBoxModelSerializer): +class PlatformSerializer(NestedGroupModelSerializer): + parent = NestedPlatformSerializer(required=False, allow_null=True, default=None) manufacturer = ManufacturerSerializer(nested=True, required=False, allow_null=True) config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None) @@ -20,7 +22,10 @@ class PlatformSerializer(NetBoxModelSerializer): class Meta: model = Platform fields = [ - 'id', 'url', 'display_url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description', - 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', + 'id', 'url', 'display_url', 'display', 'parent', 'name', 'slug', 'manufacturer', 'config_template', + 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', + 'virtualmachine_count', '_depth', ] - brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count') + brief_fields = ( + 'id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count', '_depth', + ) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 7f14935573..b75febd728 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -547,14 +547,17 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): to_field_name='slug', label=_('Manufacturer (slug)'), ) - default_platform_id = django_filters.ModelMultipleChoiceFilter( + default_platform_id = TreeNodeMultipleChoiceFilter( queryset=Platform.objects.all(), + field_name='default_platform', + lookup_expr='in', label=_('Default platform (ID)'), ) - default_platform = django_filters.ModelMultipleChoiceFilter( - field_name='default_platform__slug', + default_platform = TreeNodeMultipleChoiceFilter( queryset=Platform.objects.all(), + field_name='default_platform', to_field_name='slug', + lookup_expr='in', label=_('Default platform (slug)'), ) has_front_image = django_filters.BooleanFilter( @@ -979,6 +982,29 @@ class DeviceRoleFilterSet(OrganizationalModelFilterSet): class PlatformFilterSet(OrganizationalModelFilterSet): + parent_id = django_filters.ModelMultipleChoiceFilter( + queryset=Platform.objects.all(), + label=_('Immediate parent platform (ID)'), + ) + parent = django_filters.ModelMultipleChoiceFilter( + field_name='parent__slug', + queryset=Platform.objects.all(), + to_field_name='slug', + label=_('Immediate parent platform (slug)'), + ) + ancestor_id = TreeNodeMultipleChoiceFilter( + queryset=Platform.objects.all(), + field_name='parent', + lookup_expr='in', + label=_('Parent platform (ID)'), + ) + ancestor = TreeNodeMultipleChoiceFilter( + queryset=Platform.objects.all(), + field_name='parent', + lookup_expr='in', + to_field_name='slug', + label=_('Parent platform (slug)'), + ) manufacturer_id = django_filters.ModelMultipleChoiceFilter( field_name='manufacturer', queryset=Manufacturer.objects.all(), @@ -1058,14 +1084,17 @@ class DeviceFilterSet( queryset=Device.objects.all(), label=_('Parent Device (ID)'), ) - platform_id = django_filters.ModelMultipleChoiceFilter( + platform_id = TreeNodeMultipleChoiceFilter( queryset=Platform.objects.all(), + field_name='platform', + lookup_expr='in', label=_('Platform (ID)'), ) - platform = django_filters.ModelMultipleChoiceFilter( - field_name='platform__slug', + platform = TreeNodeMultipleChoiceFilter( + field_name='platform', queryset=Platform.objects.all(), to_field_name='slug', + lookup_expr='in', label=_('Platform (slug)'), ) region_id = TreeNodeMultipleChoiceFilter( diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 5f70683aed..587b7dbde5 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -682,6 +682,11 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): class PlatformBulkEditForm(NetBoxModelBulkEditForm): + parent = DynamicModelChoiceField( + label=_('Parent'), + queryset=Platform.objects.all(), + required=False, + ) manufacturer = DynamicModelChoiceField( label=_('Manufacturer'), queryset=Manufacturer.objects.all(), @@ -697,12 +702,13 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField() model = Platform fieldsets = ( - FieldSet('manufacturer', 'config_template', 'description'), + FieldSet('parent', 'manufacturer', 'config_template', 'description'), ) - nullable_fields = ('manufacturer', 'config_template', 'description') + nullable_fields = ('parent', 'manufacturer', 'config_template', 'description', 'comments') class DeviceBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index fc33c2162e..be47f1fc05 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -504,6 +504,16 @@ class DeviceRoleImportForm(NetBoxModelImportForm): class PlatformImportForm(NetBoxModelImportForm): slug = SlugField() + parent = CSVModelChoiceField( + label=_('Parent'), + queryset=Platform.objects.all(), + required=False, + to_field_name='name', + help_text=_('Parent platform'), + error_messages={ + 'invalid_choice': _('Platform not found.'), + } + ) manufacturer = CSVModelChoiceField( label=_('Manufacturer'), queryset=Manufacturer.objects.all(), @@ -522,7 +532,7 @@ class PlatformImportForm(NetBoxModelImportForm): class Meta: model = Platform fields = ( - 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags', + 'name', 'slug', 'parent', 'manufacturer', 'config_template', 'description', 'tags', ) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 8e75695099..3c7a575463 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -714,6 +714,11 @@ class DeviceRoleFilterForm(NetBoxModelFilterSetForm): class PlatformFilterForm(NetBoxModelFilterSetForm): model = Platform selector_fields = ('filter_id', 'q', 'manufacturer_id') + parent_id = DynamicModelMultipleChoiceField( + queryset=Platform.objects.all(), + required=False, + label=_('Parent') + ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), required=False, diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 6454e1d141..bdaa1f0e3a 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -536,6 +536,11 @@ class DeviceRoleForm(NetBoxModelForm): class PlatformForm(NetBoxModelForm): + parent = DynamicModelChoiceField( + label=_('Parent'), + queryset=Platform.objects.all(), + required=False, + ) manufacturer = DynamicModelChoiceField( label=_('Manufacturer'), queryset=Manufacturer.objects.all(), @@ -551,15 +556,18 @@ class PlatformForm(NetBoxModelForm): label=_('Slug'), max_length=64 ) + comments = CommentField() fieldsets = ( - FieldSet('name', 'slug', 'manufacturer', 'config_template', 'description', 'tags', name=_('Platform')), + FieldSet( + 'name', 'slug', 'parent', 'manufacturer', 'config_template', 'description', 'tags', name=_('Platform'), + ), ) class Meta: model = Platform fields = [ - 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags', + 'name', 'slug', 'parent', 'manufacturer', 'config_template', 'description', 'comments', 'tags', ] diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 8b1755e358..0cd5e8fd11 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -633,6 +633,8 @@ class ModuleTypeType(NetBoxObjectType): pagination=True ) class PlatformType(OrganizationalObjectType): + parent: Annotated['PlatformType', strawberry.lazy('dcim.graphql.types')] | None + children: List[Annotated['PlatformType', strawberry.lazy('dcim.graphql.types')]] manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] | None config_template: Annotated["ConfigTemplateType", strawberry.lazy('extras.graphql.types')] | None diff --git a/netbox/dcim/migrations/0211_platform_parent.py b/netbox/dcim/migrations/0211_platform_parent.py new file mode 100644 index 0000000000..e5b5c6bc3b --- /dev/null +++ b/netbox/dcim/migrations/0211_platform_parent.py @@ -0,0 +1,55 @@ +import django.db.models.deletion +import mptt.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0210_interface_tx_power_negative'), + ] + + operations = [ + # Add parent & MPTT fields + migrations.AddField( + model_name='platform', + name='parent', + field=mptt.fields.TreeForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='children', + to='dcim.platform' + ), + ), + migrations.AddField( + model_name='platform', + name='level', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='platform', + name='lft', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='platform', + name='rght', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='platform', + name='tree_id', + field=models.PositiveIntegerField(db_index=True, default=0, editable=False), + preserve_default=False, + ), + # Add comments field + migrations.AddField( + model_name='platform', + name='comments', + field=models.TextField(blank=True), + ), + ] diff --git a/netbox/dcim/migrations/0212_platform_rebuild.py b/netbox/dcim/migrations/0212_platform_rebuild.py new file mode 100644 index 0000000000..b15ffd281d --- /dev/null +++ b/netbox/dcim/migrations/0212_platform_rebuild.py @@ -0,0 +1,29 @@ +from django.db import migrations +import mptt +import mptt.managers + + +def rebuild_mptt(apps, schema_editor): + """ + Construct the MPTT hierarchy. + """ + Platform = apps.get_model('dcim', 'Platform') + manager = mptt.managers.TreeManager() + manager.model = Platform + mptt.register(Platform) + manager.contribute_to_class(Platform, 'objects') + manager.rebuild() + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0211_platform_parent'), + ] + + operations = [ + migrations.RunPython( + code=rebuild_mptt, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 78fd881a70..ab4aeb1281 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -424,7 +424,7 @@ class DeviceRole(NestedGroupModel): verbose_name_plural = _('device roles') -class Platform(OrganizationalModel): +class Platform(NestedGroupModel): """ Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". A Platform may optionally be associated with a particular Manufacturer. @@ -454,6 +454,8 @@ class Platform(OrganizationalModel): null=True ) + clone_fields = ('parent', 'description') + class Meta: ordering = ('name',) verbose_name = _('platform') diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index f0465a1b53..d63a098042 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -103,7 +103,7 @@ class DeviceRoleTable(NetBoxTable): # class PlatformTable(NetBoxTable): - name = tables.Column( + name = columns.MPTTColumn( verbose_name=_('Name'), linkify=True ) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 8af539b047..cefbc7b521 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1247,7 +1247,9 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase): class PlatformTest(APIViewTestCases.APIViewTestCase): model = Platform - brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] + brief_fields = [ + '_depth', 'description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count', + ] create_data = [ { 'name': 'Platform 4', @@ -1274,7 +1276,8 @@ class PlatformTest(APIViewTestCases.APIViewTestCase): Platform(name='Platform 2', slug='platform-2'), Platform(name='Platform 3', slug='platform-3'), ) - Platform.objects.bulk_create(platforms) + for platform in platforms: + platform.save() class DeviceTest(APIViewTestCases.APIViewTestCase): diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 2ae178653a..f0701ee4b4 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1256,7 +1256,8 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): 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) + for platform in platforms: + platform.save() device_types = ( DeviceType( @@ -2435,7 +2436,37 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests): Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], description='foobar3'), Platform(name='Platform 4', slug='platform-4'), ) - Platform.objects.bulk_create(platforms) + for platform in platforms: + platform.save() + child_platforms = ( + Platform(parent=platforms[0], name='Platform 1A', slug='platform-1a', manufacturer=manufacturers[0]), + Platform(parent=platforms[1], name='Platform 2A', slug='platform-2a', manufacturer=manufacturers[1]), + Platform(parent=platforms[2], name='Platform 3A', slug='platform-3a', manufacturer=manufacturers[2]), + ) + for platform in child_platforms: + platform.save() + grandchild_platforms = ( + Platform( + parent=child_platforms[0], + name='Platform 1A1', + slug='platform-1a1', + manufacturer=manufacturers[0], + ), + Platform( + parent=child_platforms[1], + name='Platform 2A1', + slug='platform-2a1', + manufacturer=manufacturers[1], + ), + Platform( + parent=child_platforms[2], + name='Platform 3A1', + slug='platform-3a1', + manufacturer=manufacturers[2], + ), + ) + for platform in grandchild_platforms: + platform.save() def test_q(self): params = {'q': 'foobar1'} @@ -2453,12 +2484,26 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_parent(self): + platforms = Platform.objects.filter(parent__isnull=True)[:2] + params = {'parent_id': [platforms[0].pk, platforms[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'parent': [platforms[0].slug, platforms[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_ancestor(self): + platforms = Platform.objects.filter(parent__isnull=True)[:2] + params = {'ancestor_id': [platforms[0].pk, platforms[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'ancestor': [platforms[0].slug, platforms[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_manufacturer(self): manufacturers = Manufacturer.objects.all()[:2] params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) def test_available_for_device_type(self): manufacturers = Manufacturer.objects.all()[:2] @@ -2469,7 +2514,7 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests): u_height=1 ) params = {'available_for_device_type': device_type.pk} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): @@ -2507,7 +2552,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): Platform(name='Platform 2', slug='platform-2'), Platform(name='Platform 3', slug='platform-3'), ) - Platform.objects.bulk_create(platforms) + for platform in platforms: + platform.save() regions = ( Region(name='Region 1', slug='region-1'), @@ -2763,7 +2809,7 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'device_type': [device_types[0].slug, device_types[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_devicerole(self): + def test_role(self): roles = DeviceRole.objects.all()[:2] params = {'role_id': [roles[0].pk, roles[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 5e41b37f75..42a30e4f9d 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -619,7 +619,8 @@ class DeviceTypeTestCase( 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) + for platform in platforms: + platform.save() DeviceType.objects.bulk_create([ DeviceType(model='Device Type 1', slug='device-type-1', manufacturer=manufacturers[0]), @@ -1891,7 +1892,8 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase): Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturer), Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturer), ) - Platform.objects.bulk_create(platforms) + for platform in platforms: + platform.save() tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -1912,9 +1914,9 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase): cls.csv_update_data = ( "id,name,description", - f"{platforms[0].pk},Platform 7,Fourth platform7", - f"{platforms[1].pk},Platform 8,Fifth platform8", - f"{platforms[2].pk},Platform 9,Sixth platform9", + f"{platforms[0].pk},Foo,New description", + f"{platforms[1].pk},Bar,New description", + f"{platforms[2].pk},Baz,New description", ) cls.bulk_edit_data = { @@ -1962,7 +1964,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): Platform(name='Platform 1', slug='platform-1'), Platform(name='Platform 2', slug='platform-2'), ) - Platform.objects.bulk_create(platforms) + for platform in platforms: + platform.save() devices = ( Device( diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index f9147a30c9..5c943b74cd 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -931,7 +931,8 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): Platform(name='Platform 2', slug='platform-2'), Platform(name='Platform 3', slug='platform-3'), ) - Platform.objects.bulk_create(platforms) + for platform in platforms: + platform.save() cluster_types = ( ClusterType(name='Cluster Type 1', slug='cluster-type-1'), diff --git a/netbox/templates/dcim/platform.html b/netbox/templates/dcim/platform.html index bed496625b..4becc042b2 100644 --- a/netbox/templates/dcim/platform.html +++ b/netbox/templates/dcim/platform.html @@ -33,6 +33,10 @@