Просмотр исходного кода

Fixes #18245: Make DeviceRole Hierarchical (#19008)

Made DeviceRoles hierarchical, had to also change the filtersets for Device, ConfigContext and VirtualMachine to use the TreeNodeMultipleChoiceFilter.

Note: The model was changed to use NestedGroupModel, a side-effect of this is it also adds comments field, but I thought that was better then doing a one-off just for DeviceRole and having to define the fields, validators, etc.. - keeps everything DRY / consistent.

* 18981 Make Device Roles Hierarchical

* 18981 forms, serializer

* 18981 fix tests

* 18981 fix tests

* 18981 fix tests

* 18981 fix tests

* 18981 fix tests

* 18981 fix migration merge

* 18981 fix tests

* 18981 fix filtersets

* 18981 fix tests

* 18981 comments

* 18981 review changes
Arthur Hanson 10 месяцев назад
Родитель
Сommit
1508e3a770

+ 4 - 0
docs/models/dcim/devicerole.md

@@ -4,6 +4,10 @@ Devices can be organized by functional roles, which are fully customizable by th
 
 
 ## Fields
 ## Fields
 
 
+### Parent
+
+The parent role of which this role is a child (optional).
+
 ### Name
 ### Name
 
 
 A unique human-friendly name.
 A unique human-friendly name.

+ 7 - 0
netbox/dcim/api/serializers_/nested.py

@@ -52,6 +52,13 @@ class NestedLocationSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'rack_count', '_depth']
         fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'rack_count', '_depth']
 
 
 
 
+class NestedDeviceRoleSerializer(WritableNestedSerializer):
+
+    class Meta:
+        model = models.DeviceRole
+        fields = ['id', 'url', 'display_url', 'display', 'name']
+
+
 class NestedDeviceSerializer(WritableNestedSerializer):
 class NestedDeviceSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:

+ 9 - 4
netbox/dcim/api/serializers_/roles.py

@@ -1,7 +1,8 @@
 from dcim.models import DeviceRole, InventoryItemRole
 from dcim.models import DeviceRole, InventoryItemRole
 from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
 from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
 from netbox.api.fields import RelatedObjectCountField
 from netbox.api.fields import RelatedObjectCountField
-from netbox.api.serializers import NetBoxModelSerializer
+from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
+from .nested import NestedDeviceRoleSerializer
 
 
 __all__ = (
 __all__ = (
     'DeviceRoleSerializer',
     'DeviceRoleSerializer',
@@ -9,7 +10,8 @@ __all__ = (
 )
 )
 
 
 
 
-class DeviceRoleSerializer(NetBoxModelSerializer):
+class DeviceRoleSerializer(NestedGroupModelSerializer):
+    parent = NestedDeviceRoleSerializer(required=False, allow_null=True, default=None)
     config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None)
     config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None)
 
 
     # Related object counts
     # Related object counts
@@ -19,10 +21,13 @@ class DeviceRoleSerializer(NetBoxModelSerializer):
     class Meta:
     class Meta:
         model = DeviceRole
         model = DeviceRole
         fields = [
         fields = [
-            'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template',
+            'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', 'parent',
             'description', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
             'description', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
+            'comments', '_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'
+        )
 
 
 
 
 class InventoryItemRoleSerializer(NetBoxModelSerializer):
 class InventoryItemRoleSerializer(NetBoxModelSerializer):

+ 29 - 4
netbox/dcim/filtersets.py

@@ -922,6 +922,29 @@ class DeviceRoleFilterSet(OrganizationalModelFilterSet):
         queryset=ConfigTemplate.objects.all(),
         queryset=ConfigTemplate.objects.all(),
         label=_('Config template (ID)'),
         label=_('Config template (ID)'),
     )
     )
+    parent_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=DeviceRole.objects.all(),
+        label=_('Parent device role (ID)'),
+    )
+    parent = django_filters.ModelMultipleChoiceFilter(
+        field_name='parent__slug',
+        queryset=DeviceRole.objects.all(),
+        to_field_name='slug',
+        label=_('Parent device role (slug)'),
+    )
+    ancestor_id = TreeNodeMultipleChoiceFilter(
+        queryset=DeviceRole.objects.all(),
+        field_name='parent',
+        lookup_expr='in',
+        label=_('Parent device role (ID)'),
+    )
+    ancestor = TreeNodeMultipleChoiceFilter(
+        queryset=DeviceRole.objects.all(),
+        field_name='parent',
+        lookup_expr='in',
+        to_field_name='slug',
+        label=_('Parent device role (slug)'),
+    )
 
 
     class Meta:
     class Meta:
         model = DeviceRole
         model = DeviceRole
@@ -990,14 +1013,16 @@ class DeviceFilterSet(
         queryset=DeviceType.objects.all(),
         queryset=DeviceType.objects.all(),
         label=_('Device type (ID)'),
         label=_('Device type (ID)'),
     )
     )
-    role_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='role_id',
+    role_id = TreeNodeMultipleChoiceFilter(
+        field_name='role',
         queryset=DeviceRole.objects.all(),
         queryset=DeviceRole.objects.all(),
+        lookup_expr='in',
         label=_('Role (ID)'),
         label=_('Role (ID)'),
     )
     )
-    role = django_filters.ModelMultipleChoiceFilter(
-        field_name='role__slug',
+    role = TreeNodeMultipleChoiceFilter(
         queryset=DeviceRole.objects.all(),
         queryset=DeviceRole.objects.all(),
+        field_name='role',
+        lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label=_('Role (slug)'),
         label=_('Role (slug)'),
     )
     )

+ 8 - 2
netbox/dcim/forms/bulk_edit.py

@@ -620,6 +620,11 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
 
 
 
 
 class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
 class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
+    parent = DynamicModelChoiceField(
+        label=_('Parent'),
+        queryset=DeviceRole.objects.all(),
+        required=False,
+    )
     color = ColorField(
     color = ColorField(
         label=_('Color'),
         label=_('Color'),
         required=False
         required=False
@@ -639,12 +644,13 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
         max_length=200,
         max_length=200,
         required=False
         required=False
     )
     )
+    comments = CommentField()
 
 
     model = DeviceRole
     model = DeviceRole
     fieldsets = (
     fieldsets = (
-        FieldSet('color', 'vm_role', 'config_template', 'description'),
+        FieldSet('parent', 'color', 'vm_role', 'config_template', 'description'),
     )
     )
-    nullable_fields = ('color', 'config_template', 'description')
+    nullable_fields = ('parent', 'color', 'config_template', 'description', 'comments')
 
 
 
 
 class PlatformBulkEditForm(NetBoxModelBulkEditForm):
 class PlatformBulkEditForm(NetBoxModelBulkEditForm):

+ 13 - 1
netbox/dcim/forms/bulk_import.py

@@ -460,6 +460,16 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
 
 
 
 
 class DeviceRoleImportForm(NetBoxModelImportForm):
 class DeviceRoleImportForm(NetBoxModelImportForm):
+    parent = CSVModelChoiceField(
+        label=_('Parent'),
+        queryset=DeviceRole.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Parent Device Role'),
+        error_messages={
+            'invalid_choice': _('Device role not found.'),
+        }
+    )
     config_template = CSVModelChoiceField(
     config_template = CSVModelChoiceField(
         label=_('Config template'),
         label=_('Config template'),
         queryset=ConfigTemplate.objects.all(),
         queryset=ConfigTemplate.objects.all(),
@@ -471,7 +481,9 @@ class DeviceRoleImportForm(NetBoxModelImportForm):
 
 
     class Meta:
     class Meta:
         model = DeviceRole
         model = DeviceRole
-        fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags')
+        fields = (
+            'name', 'slug', 'parent', 'color', 'vm_role', 'config_template', 'description', 'comments', 'tags'
+        )
 
 
 
 
 class PlatformImportForm(NetBoxModelImportForm):
 class PlatformImportForm(NetBoxModelImportForm):

+ 5 - 0
netbox/dcim/forms/filtersets.py

@@ -689,6 +689,11 @@ class DeviceRoleFilterForm(NetBoxModelFilterSetForm):
         required=False,
         required=False,
         label=_('Config template')
         label=_('Config template')
     )
     )
+    parent_id = DynamicModelMultipleChoiceField(
+        queryset=DeviceRole.objects.all(),
+        required=False,
+        label=_('Parent')
+    )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 

+ 9 - 2
netbox/dcim/forms/model_forms.py

@@ -431,17 +431,24 @@ class DeviceRoleForm(NetBoxModelForm):
         required=False
         required=False
     )
     )
     slug = SlugField()
     slug = SlugField()
+    parent = DynamicModelChoiceField(
+        label=_('Parent'),
+        queryset=DeviceRole.objects.all(),
+        required=False,
+    )
+    comments = CommentField()
 
 
     fieldsets = (
     fieldsets = (
         FieldSet(
         FieldSet(
-            'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags', name=_('Device Role')
+            'name', 'slug', 'parent', 'color', 'vm_role', 'config_template', 'description',
+            'tags', name=_('Device Role')
         ),
         ),
     )
     )
 
 
     class Meta:
     class Meta:
         model = DeviceRole
         model = DeviceRole
         fields = [
         fields = [
-            'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags',
+            'name', 'slug', 'parent', 'color', 'vm_role', 'config_template', 'description', 'comments', 'tags',
         ]
         ]
 
 
 
 

+ 2 - 0
netbox/dcim/graphql/types.py

@@ -338,6 +338,8 @@ class InventoryItemTemplateType(ComponentTemplateType):
     pagination=True
     pagination=True
 )
 )
 class DeviceRoleType(OrganizationalObjectType):
 class DeviceRoleType(OrganizationalObjectType):
+    parent: Annotated['DeviceRoleType', strawberry.lazy('dcim.graphql.types')] | None
+    children: List[Annotated['DeviceRoleType', strawberry.lazy('dcim.graphql.types')]]
     color: str
     color: str
     config_template: Annotated["ConfigTemplateType", strawberry.lazy('extras.graphql.types')] | None
     config_template: Annotated["ConfigTemplateType", strawberry.lazy('extras.graphql.types')] | None
 
 

+ 65 - 0
netbox/dcim/migrations/0203_device_role_nested.py

@@ -0,0 +1,65 @@
+# Generated by Django 5.1.7 on 2025-03-25 18:06
+
+import django.db.models.manager
+import mptt.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0203_add_rack_outer_height'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='devicerole',
+            name='level',
+            field=models.PositiveIntegerField(default=0, editable=False),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='devicerole',
+            name='lft',
+            field=models.PositiveIntegerField(default=0, editable=False),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='devicerole',
+            name='rght',
+            field=models.PositiveIntegerField(default=0, editable=False),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='devicerole',
+            name='tree_id',
+            field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='devicerole',
+            name='parent',
+            field=mptt.fields.TreeForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name='children',
+                to='dcim.devicerole',
+            ),
+        ),
+        migrations.AddField(
+            model_name='devicerole',
+            name='comments',
+            field=models.TextField(blank=True),
+        ),
+        migrations.AlterField(
+            model_name='devicerole',
+            name='name',
+            field=models.CharField(max_length=100),
+        ),
+        migrations.AlterField(
+            model_name='devicerole',
+            name='slug',
+            field=models.SlugField(max_length=100),
+        ),
+    ]

+ 22 - 0
netbox/dcim/migrations/0204_device_role_rebuild.py

@@ -0,0 +1,22 @@
+from django.db import migrations
+import mptt
+import mptt.managers
+
+
+def rebuild_mptt(apps, schema_editor):
+    manager = mptt.managers.TreeManager()
+    DeviceRole = apps.get_model('dcim', 'DeviceRole')
+    manager.model = DeviceRole
+    mptt.register(DeviceRole)
+    manager.contribute_to_class(DeviceRole, 'objects')
+    manager.rebuild()
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('dcim', '0203_device_role_nested'),
+    ]
+
+    operations = [
+        migrations.RunPython(code=rebuild_mptt, reverse_code=migrations.RunPython.noop),
+    ]

+ 4 - 2
netbox/dcim/models/devices.py

@@ -23,7 +23,7 @@ from extras.models import ConfigContextModel, CustomField
 from extras.querysets import ConfigContextModelQuerySet
 from extras.querysets import ConfigContextModelQuerySet
 from netbox.choices import ColorChoices
 from netbox.choices import ColorChoices
 from netbox.config import ConfigItem
 from netbox.config import ConfigItem
-from netbox.models import OrganizationalModel, PrimaryModel
+from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel
 from netbox.models.mixins import WeightMixin
 from netbox.models.mixins import WeightMixin
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
 from utilities.fields import ColorField, CounterCacheField
 from utilities.fields import ColorField, CounterCacheField
@@ -468,7 +468,7 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
 # Devices
 # Devices
 #
 #
 
 
-class DeviceRole(OrganizationalModel):
+class DeviceRole(NestedGroupModel):
     """
     """
     Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
     Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
     color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to
     color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to
@@ -491,6 +491,8 @@ class DeviceRole(OrganizationalModel):
         null=True
         null=True
     )
     )
 
 
+    clone_fields = ('parent', 'description')
+
     class Meta:
     class Meta:
         ordering = ('name',)
         ordering = ('name',)
         verbose_name = _('device role')
         verbose_name = _('device role')

+ 1 - 1
netbox/dcim/tables/devices.py

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

+ 8 - 8
netbox/dcim/tests/test_api.py

@@ -1149,7 +1149,9 @@ class InventoryItemTemplateTest(APIViewTestCases.APIViewTestCase):
 
 
 class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
 class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
     model = DeviceRole
     model = DeviceRole
-    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 = [
     create_data = [
         {
         {
             'name': 'Device Role 4',
             'name': 'Device Role 4',
@@ -1174,12 +1176,9 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
-        roles = (
-            DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000'),
-            DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00'),
-            DeviceRole(name='Device Role 3', slug='device-role-3', color='0000ff'),
-        )
-        DeviceRole.objects.bulk_create(roles)
+        DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000')
+        DeviceRole.objects.create(name='Device Role 2', slug='device-role-2', color='00ff00')
+        DeviceRole.objects.create(name='Device Role 3', slug='device-role-3', color='0000ff')
 
 
 
 
 class PlatformTest(APIViewTestCases.APIViewTestCase):
 class PlatformTest(APIViewTestCases.APIViewTestCase):
@@ -1252,7 +1251,8 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
             DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000'),
             DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000'),
             DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00'),
             DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00'),
         )
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
 
         cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
         cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
 
 

+ 94 - 16
netbox/dcim/tests/test_filtersets.py

@@ -2191,12 +2191,65 @@ class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
-        roles = (
+        parent_roles = (
             DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000', vm_role=True, description='foobar1'),
             DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000', vm_role=True, description='foobar1'),
             DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00', vm_role=True, description='foobar2'),
             DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00', vm_role=True, description='foobar2'),
-            DeviceRole(name='Device Role 3', slug='device-role-3', color='0000ff', vm_role=False),
+            DeviceRole(name='Device Role 3', slug='device-role-3', color='0000ff', vm_role=False)
+        )
+        for role in parent_roles:
+            role.save()
+
+        roles = (
+            DeviceRole(
+                name='Device Role 1A',
+                slug='device-role-1a',
+                color='aa0000',
+                vm_role=True,
+                parent=parent_roles[0]
+            ),
+            DeviceRole(
+                name='Device Role 2A',
+                slug='device-role-2a',
+                color='00aa00',
+                vm_role=True,
+                parent=parent_roles[1]
+            ),
+            DeviceRole(
+                name='Device Role 3A',
+                slug='device-role-3a',
+                color='0000aa',
+                vm_role=False,
+                parent=parent_roles[2]
+            )
         )
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
+
+        child_roles = (
+            DeviceRole(
+                name='Device Role 1A1',
+                slug='device-role-1a1',
+                color='bb0000',
+                vm_role=True,
+                parent=roles[0]
+            ),
+            DeviceRole(
+                name='Device Role 2A1',
+                slug='device-role-2a1',
+                color='00bb00',
+                vm_role=True,
+                parent=roles[1]
+            ),
+            DeviceRole(
+                name='Device Role 3A1',
+                slug='device-role-3a1',
+                color='0000bb',
+                vm_role=False,
+                parent=roles[2]
+            )
+        )
+        for role in child_roles:
+            role.save()
 
 
     def test_q(self):
     def test_q(self):
         params = {'q': 'foobar1'}
         params = {'q': 'foobar1'}
@@ -2216,14 +2269,28 @@ class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
 
 
     def test_vm_role(self):
     def test_vm_role(self):
         params = {'vm_role': 'true'}
         params = {'vm_role': 'true'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
         params = {'vm_role': 'false'}
         params = {'vm_role': 'false'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
 
     def test_description(self):
     def test_description(self):
         params = {'description': ['foobar1', 'foobar2']}
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_parent(self):
+        roles = DeviceRole.objects.filter(parent__isnull=True)[:2]
+        params = {'parent_id': [roles[0].pk, roles[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'parent': [roles[0].slug, roles[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_ancestor(self):
+        roles = DeviceRole.objects.filter(parent__isnull=True)[:2]
+        params = {'ancestor_id': [roles[0].pk, roles[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'ancestor': [roles[0].slug, roles[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
 
 
 class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
 class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Platform.objects.all()
     queryset = Platform.objects.all()
@@ -2309,7 +2376,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
         )
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
 
         platforms = (
         platforms = (
             Platform(name='Platform 1', slug='platform-1'),
             Platform(name='Platform 1', slug='platform-1'),
@@ -2974,7 +3042,8 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
         )
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
 
         locations = (
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -3186,7 +3255,8 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
         )
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
 
         locations = (
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -3404,7 +3474,8 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
         )
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
 
         locations = (
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -3648,7 +3719,8 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
         )
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
 
         locations = (
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -3913,7 +3985,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
         )
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
 
         locations = (
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -4492,7 +4565,8 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
         )
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
 
         locations = (
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -4764,7 +4838,8 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
         )
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
 
         locations = (
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -5004,7 +5079,8 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
         )
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
 
         locations = (
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -5176,7 +5252,8 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
         )
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
 
         locations = (
         locations = (
             Location(name='Location 1', slug='location-1', site=sites[0]),
             Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -5311,7 +5388,8 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
         )
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
 
         regions = (
         regions = (
             Region(name='Region 1', slug='region-1'),
             Region(name='Region 1', slug='region-1'),

+ 2 - 1
netbox/dcim/tests/test_models.py

@@ -346,7 +346,8 @@ class DeviceTestCase(TestCase):
             DeviceRole(name='Test Role 1', slug='test-role-1'),
             DeviceRole(name='Test Role 1', slug='test-role-1'),
             DeviceRole(name='Test Role 2', slug='test-role-2'),
             DeviceRole(name='Test Role 2', slug='test-role-2'),
         )
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
 
         # Create a CustomField with a default value & assign it to all component models
         # Create a CustomField with a default value & assign it to all component models
         cf1 = CustomField.objects.create(name='cf1', default='foo')
         cf1 = CustomField.objects.create(name='cf1', default='foo')

+ 9 - 4
netbox/dcim/tests/test_views.py

@@ -1694,13 +1694,16 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
-        roles = (
+        roles = [
             DeviceRole(name='Device Role 1', slug='device-role-1'),
             DeviceRole(name='Device Role 1', slug='device-role-1'),
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
-        )
-        DeviceRole.objects.bulk_create(roles)
+            DeviceRole(name='Device Role 4', slug='device-role-4'),
+        ]
+        for role in roles:
+            role.save()
 
 
+        roles.append(DeviceRole.objects.create(name='Device Role 5', slug='device-role-5', parent=roles[3]))
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
@@ -1724,6 +1727,7 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             f"{roles[0].pk},Device Role 7,New description7",
             f"{roles[0].pk},Device Role 7,New description7",
             f"{roles[1].pk},Device Role 8,New description8",
             f"{roles[1].pk},Device Role 8,New description8",
             f"{roles[2].pk},Device Role 9,New description9",
             f"{roles[2].pk},Device Role 9,New description9",
+            f"{roles[4].pk},Device Role 10,New description10",
         )
         )
 
 
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
@@ -1809,7 +1813,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             DeviceRole(name='Device Role 1', slug='device-role-1'),
             DeviceRole(name='Device Role 1', slug='device-role-1'),
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 2', slug='device-role-2'),
         )
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
 
         platforms = (
         platforms = (
             Platform(name='Platform 1', slug='platform-1'),
             Platform(name='Platform 1', slug='platform-1'),

+ 3 - 1
netbox/extras/filtersets.py

@@ -8,7 +8,9 @@ from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site
 from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
 from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from users.models import Group, User
 from users.models import Group, User
-from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
+from utilities.filters import (
+    ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
+)
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 from .choices import *
 from .choices import *
 from .filters import TagFilter
 from .filters import TagFilter

+ 2 - 2
netbox/extras/forms/filtersets.py

@@ -322,7 +322,7 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
         FieldSet('q', 'filter_id', 'tag_id'),
         FieldSet('q', 'filter_id', 'tag_id'),
         FieldSet('data_source_id', 'data_file_id', name=_('Data')),
         FieldSet('data_source_id', 'data_file_id', name=_('Data')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
-        FieldSet('device_type_id', 'platform_id', 'role_id', name=_('Device')),
+        FieldSet('device_type_id', 'platform_id', 'device_role_id', name=_('Device')),
         FieldSet('cluster_type_id', 'cluster_group_id', 'cluster_id', name=_('Cluster')),
         FieldSet('cluster_type_id', 'cluster_group_id', 'cluster_id', name=_('Cluster')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant'))
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant'))
     )
     )
@@ -364,7 +364,7 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
         required=False,
         required=False,
         label=_('Device types')
         label=_('Device types')
     )
     )
-    role_id = DynamicModelMultipleChoiceField(
+    device_role_id = DynamicModelMultipleChoiceField(
         queryset=DeviceRole.objects.all(),
         queryset=DeviceRole.objects.all(),
         required=False,
         required=False,
         label=_('Roles')
         label=_('Roles')

+ 2 - 1
netbox/extras/tests/test_filtersets.py

@@ -904,7 +904,8 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
         )
         )
-        DeviceRole.objects.bulk_create(device_roles)
+        for device_role in device_roles:
+            device_role.save()
 
 
         platforms = (
         platforms = (
             Platform(name='Platform 1', slug='platform-1'),
             Platform(name='Platform 1', slug='platform-1'),

+ 18 - 0
netbox/templates/dcim/devicerole.html

@@ -30,6 +30,10 @@
           <th scope="row">{% trans "Description" %}</th>
           <th scope="row">{% trans "Description" %}</th>
           <td>{{ object.description|placeholder }}</td>
           <td>{{ object.description|placeholder }}</td>
         </tr>
         </tr>
+        <tr>
+          <th scope="row">{% trans "Parent" %}</th>
+          <td>{{ object.parent|linkify|placeholder }}</td>
+        </tr>
         <tr>
         <tr>
           <th scope="row">{% trans "Color" %}</th>
           <th scope="row">{% trans "Color" %}</th>
           <td>
           <td>
@@ -52,11 +56,25 @@
 	<div class="col col-md-6">
 	<div class="col col-md-6">
     {% include 'inc/panels/related_objects.html' %}
     {% include 'inc/panels/related_objects.html' %}
     {% include 'inc/panels/custom_fields.html' %}
     {% include 'inc/panels/custom_fields.html' %}
+    {% include 'inc/panels/comments.html' %}
     {% plugin_right_page object %}
     {% plugin_right_page object %}
   </div>
   </div>
 </div>
 </div>
 <div class="row mb-3">
 <div class="row mb-3">
 	<div class="col col-md-12">
 	<div class="col col-md-12">
+    <div class="card">
+      <h2 class="card-header">
+        {% trans "Child Device Roles" %}
+        {% if perms.dcim.add_devicerole %}
+          <div class="card-actions">
+            <a href="{% url 'dcim:devicerole_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 Device Role" %}
+            </a>
+          </div>
+        {% endif %}
+      </h2>
+      {% htmx_table 'dcim:devicerole_list' parent_id=object.pk %}
+    </div>
     {% plugin_full_width_page object %}
     {% plugin_full_width_page object %}
   </div>
   </div>
 </div>
 </div>

+ 2 - 1
netbox/utilities/tests/test_filters.py

@@ -391,7 +391,8 @@ class DynamicFilterLookupExpressionTest(TestCase):
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
         )
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
 
         platforms = (
         platforms = (
             Platform(name='Platform 1', slug='platform-1'),
             Platform(name='Platform 1', slug='platform-1'),

+ 5 - 3
netbox/virtualization/filtersets.py

@@ -171,13 +171,15 @@ class VirtualMachineFilterSet(
     name = MultiValueCharFilter(
     name = MultiValueCharFilter(
         lookup_expr='iexact'
         lookup_expr='iexact'
     )
     )
-    role_id = django_filters.ModelMultipleChoiceFilter(
+    role_id = TreeNodeMultipleChoiceFilter(
         queryset=DeviceRole.objects.all(),
         queryset=DeviceRole.objects.all(),
+        lookup_expr='in',
         label=_('Role (ID)'),
         label=_('Role (ID)'),
     )
     )
-    role = django_filters.ModelMultipleChoiceFilter(
-        field_name='role__slug',
+    role = TreeNodeMultipleChoiceFilter(
+        field_name='role',
         queryset=DeviceRole.objects.all(),
         queryset=DeviceRole.objects.all(),
+        lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label=_('Role (slug)'),
         label=_('Role (slug)'),
     )
     )

+ 2 - 1
netbox/virtualization/tests/test_filtersets.py

@@ -294,7 +294,8 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
         )
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
 
         devices = (
         devices = (
             create_test_device('device1', cluster=clusters[0]),
             create_test_device('device1', cluster=clusters[0]),

+ 2 - 1
netbox/virtualization/tests/test_views.py

@@ -203,7 +203,8 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             DeviceRole(name='Device Role 1', slug='device-role-1'),
             DeviceRole(name='Device Role 1', slug='device-role-1'),
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             DeviceRole(name='Device Role 2', slug='device-role-2'),
         )
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
 
         platforms = (
         platforms = (
             Platform(name='Platform 1', slug='platform-1'),
             Platform(name='Platform 1', slug='platform-1'),