Merge pull request #20047 from netbox-community/19740-platform-nesting
Some checks are pending
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run

Closes #19740: Enable recursive nesting for platforms
This commit is contained in:
bctiemann 2025-08-12 10:40:27 -04:00 committed by GitHub
commit 032bd52dc7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 286 additions and 41 deletions

View File

@ -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.

View File

@ -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']

View File

@ -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',
)

View File

@ -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(

View File

@ -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):

View File

@ -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',
)

View File

@ -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,

View File

@ -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',
]

View File

@ -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

View File

@ -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),
),
]

View File

@ -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
),
]

View File

@ -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')

View File

@ -103,7 +103,7 @@ class DeviceRoleTable(NetBoxTable):
#
class PlatformTable(NetBoxTable):
name = tables.Column(
name = columns.MPTTColumn(
verbose_name=_('Name'),
linkify=True
)

View File

@ -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):

View File

@ -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)

View File

@ -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(

View File

@ -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'),

View File

@ -33,6 +33,10 @@
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Parent" %}</th>
<td>{{ object.parent|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Manufacturer" %}</th>
<td>{{ object.manufacturer|linkify|placeholder }}</td>
@ -49,11 +53,25 @@
<div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">
{% trans "Child Platforms" %}
{% if perms.dcim.add_platform %}
<div class="card-actions">
<a href="{% url 'dcim:platform_add' %}?parent={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Platform" %}
</a>
</div>
{% endif %}
</h2>
{% htmx_table 'dcim:platform_list' parent_id=object.pk %}
</div>
{% plugin_full_width_page object %}
</div>
</div>

View File

@ -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'),

View File

@ -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)'),
)

View File

@ -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'),

View File

@ -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'),