diff --git a/docs/models/dcim/platform.md b/docs/models/dcim/platform.md index 35b0b68eb..3400294e6 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 0e9eaa52f..5b1be4d98 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 2f4745701..c357b0bbe 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 7f1493557..b75febd72 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 5f70683ae..587b7dbde 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 fc33c2162..be47f1fc0 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 8e7569509..3c7a57546 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 6454e1d14..bdaa1f0e3 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 8b1755e35..0cd5e8fd1 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 000000000..e5b5c6bc3 --- /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 000000000..b15ffd281 --- /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 78fd881a7..ab4aeb128 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 f0465a1b5..d63a09804 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 8af539b04..cefbc7b52 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 2ae178653..f0701ee4b 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 5e41b37f7..42a30e4f9 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 f9147a30c..5c943b74c 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 bed496625..4becc042b 100644 --- a/netbox/templates/dcim/platform.html +++ b/netbox/templates/dcim/platform.html @@ -33,6 +33,10 @@ {% trans "Description" %} {{ object.description|placeholder }} + + {% trans "Parent" %} + {{ object.parent|linkify|placeholder }} + {% trans "Manufacturer" %} {{ object.manufacturer|linkify|placeholder }} @@ -49,11 +53,25 @@
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
+
+

+ {% trans "Child Platforms" %} + {% if perms.dcim.add_platform %} + + {% endif %} +

+ {% htmx_table 'dcim:platform_list' parent_id=object.pk %} +
{% plugin_full_width_page object %}
diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index 97fde5116..3c24fe10c 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/netbox/utilities/tests/test_filters.py @@ -415,7 +415,8 @@ class DynamicFilterLookupExpressionTest(TestCase): 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'), diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 06a38da36..802e34e00 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -183,13 +183,16 @@ class VirtualMachineFilterSet( to_field_name='slug', label=_('Role (slug)'), ) - 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( queryset=Platform.objects.all(), + field_name='platform', + lookup_expr='in', to_field_name='slug', label=_('Platform (slug)'), ) diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index 228cf66ed..0179069af 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -287,7 +287,8 @@ class VirtualMachineTestCase(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() roles = ( DeviceRole(name='Device Role 1', slug='device-role-1'), diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 27b1a08a5..35226c16d 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -210,7 +210,8 @@ class VirtualMachineTestCase(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() sites = ( Site(name='Site 1', slug='site-1'),