2
0
Эх сурвалжийг харах

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 сар өмнө
parent
commit
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
 
+### Parent
+
+The parent role of which this role is a child (optional).
+
 ### 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']
 
 
+class NestedDeviceRoleSerializer(WritableNestedSerializer):
+
+    class Meta:
+        model = models.DeviceRole
+        fields = ['id', 'url', 'display_url', 'display', 'name']
+
+
 class NestedDeviceSerializer(WritableNestedSerializer):
 
     class Meta:

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

@@ -1,7 +1,8 @@
 from dcim.models import DeviceRole, InventoryItemRole
 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, NetBoxModelSerializer
+from .nested import NestedDeviceRoleSerializer
 
 __all__ = (
     '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)
 
     # Related object counts
@@ -19,10 +21,13 @@ class DeviceRoleSerializer(NetBoxModelSerializer):
     class Meta:
         model = DeviceRole
         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',
+            '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):

+ 29 - 4
netbox/dcim/filtersets.py

@@ -922,6 +922,29 @@ class DeviceRoleFilterSet(OrganizationalModelFilterSet):
         queryset=ConfigTemplate.objects.all(),
         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:
         model = DeviceRole
@@ -990,14 +1013,16 @@ class DeviceFilterSet(
         queryset=DeviceType.objects.all(),
         label=_('Device type (ID)'),
     )
-    role_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='role_id',
+    role_id = TreeNodeMultipleChoiceFilter(
+        field_name='role',
         queryset=DeviceRole.objects.all(),
+        lookup_expr='in',
         label=_('Role (ID)'),
     )
-    role = django_filters.ModelMultipleChoiceFilter(
-        field_name='role__slug',
+    role = TreeNodeMultipleChoiceFilter(
         queryset=DeviceRole.objects.all(),
+        field_name='role',
+        lookup_expr='in',
         to_field_name='slug',
         label=_('Role (slug)'),
     )

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

@@ -620,6 +620,11 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
 
 
 class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
+    parent = DynamicModelChoiceField(
+        label=_('Parent'),
+        queryset=DeviceRole.objects.all(),
+        required=False,
+    )
     color = ColorField(
         label=_('Color'),
         required=False
@@ -639,12 +644,13 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
         max_length=200,
         required=False
     )
+    comments = CommentField()
 
     model = DeviceRole
     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):

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

@@ -460,6 +460,16 @@ class ModuleTypeImportForm(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(
         label=_('Config template'),
         queryset=ConfigTemplate.objects.all(),
@@ -471,7 +481,9 @@ class DeviceRoleImportForm(NetBoxModelImportForm):
 
     class Meta:
         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):

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

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

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

@@ -431,17 +431,24 @@ class DeviceRoleForm(NetBoxModelForm):
         required=False
     )
     slug = SlugField()
+    parent = DynamicModelChoiceField(
+        label=_('Parent'),
+        queryset=DeviceRole.objects.all(),
+        required=False,
+    )
+    comments = CommentField()
 
     fieldsets = (
         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:
         model = DeviceRole
         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
 )
 class DeviceRoleType(OrganizationalObjectType):
+    parent: Annotated['DeviceRoleType', strawberry.lazy('dcim.graphql.types')] | None
+    children: List[Annotated['DeviceRoleType', strawberry.lazy('dcim.graphql.types')]]
     color: str
     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 netbox.choices import ColorChoices
 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.features import ContactsMixin, ImageAttachmentsMixin
 from utilities.fields import ColorField, CounterCacheField
@@ -468,7 +468,7 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
 # 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
     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
     )
 
+    clone_fields = ('parent', 'description')
+
     class Meta:
         ordering = ('name',)
         verbose_name = _('device role')

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

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

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

@@ -1149,7 +1149,9 @@ class InventoryItemTemplateTest(APIViewTestCases.APIViewTestCase):
 
 class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
     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 = [
         {
             'name': 'Device Role 4',
@@ -1174,12 +1176,9 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
     @classmethod
     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):
@@ -1252,7 +1251,8 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
             DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000'),
             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')
 

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

@@ -2191,12 +2191,65 @@ class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
     @classmethod
     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 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):
         params = {'q': 'foobar1'}
@@ -2216,14 +2269,28 @@ class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
 
     def test_vm_role(self):
         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'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
     def test_description(self):
         params = {'description': ['foobar1', 'foobar2']}
         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):
     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 3', slug='device-role-3'),
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
         platforms = (
             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 3', slug='device-role-3'),
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
         locations = (
             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 3', slug='device-role-3'),
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
         locations = (
             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 3', slug='device-role-3'),
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
         locations = (
             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 3', slug='device-role-3'),
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
         locations = (
             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 3', slug='device-role-3'),
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
         locations = (
             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 3', slug='device-role-3'),
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
         locations = (
             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 3', slug='device-role-3'),
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
         locations = (
             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 3', slug='device-role-3'),
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
         locations = (
             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 3', slug='device-role-3'),
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
         locations = (
             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 3', slug='device-role-3'),
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
         regions = (
             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 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
         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
     def setUpTestData(cls):
 
-        roles = (
+        roles = [
             DeviceRole(name='Device Role 1', slug='device-role-1'),
             DeviceRole(name='Device Role 2', slug='device-role-2'),
             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')
 
         cls.form_data = {
@@ -1724,6 +1727,7 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             f"{roles[0].pk},Device Role 7,New description7",
             f"{roles[1].pk},Device Role 8,New description8",
             f"{roles[2].pk},Device Role 9,New description9",
+            f"{roles[4].pk},Device Role 10,New description10",
         )
 
         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 2', slug='device-role-2'),
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
         platforms = (
             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 tenancy.models import Tenant, TenantGroup
 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 .choices import *
 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('data_source_id', 'data_file_id', name=_('Data')),
         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('tenant_group_id', 'tenant_id', name=_('Tenant'))
     )
@@ -364,7 +364,7 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
         required=False,
         label=_('Device types')
     )
-    role_id = DynamicModelMultipleChoiceField(
+    device_role_id = DynamicModelMultipleChoiceField(
         queryset=DeviceRole.objects.all(),
         required=False,
         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 3', slug='device-role-3'),
         )
-        DeviceRole.objects.bulk_create(device_roles)
+        for device_role in device_roles:
+            device_role.save()
 
         platforms = (
             Platform(name='Platform 1', slug='platform-1'),

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

@@ -30,6 +30,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 "Color" %}</th>
           <td>
@@ -52,11 +56,25 @@
 	<div class="col 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 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 %}
   </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 3', slug='device-role-3'),
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
         platforms = (
             Platform(name='Platform 1', slug='platform-1'),

+ 5 - 3
netbox/virtualization/filtersets.py

@@ -171,13 +171,15 @@ class VirtualMachineFilterSet(
     name = MultiValueCharFilter(
         lookup_expr='iexact'
     )
-    role_id = django_filters.ModelMultipleChoiceFilter(
+    role_id = TreeNodeMultipleChoiceFilter(
         queryset=DeviceRole.objects.all(),
+        lookup_expr='in',
         label=_('Role (ID)'),
     )
-    role = django_filters.ModelMultipleChoiceFilter(
-        field_name='role__slug',
+    role = TreeNodeMultipleChoiceFilter(
+        field_name='role',
         queryset=DeviceRole.objects.all(),
+        lookup_expr='in',
         to_field_name='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 3', slug='device-role-3'),
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
         devices = (
             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 2', slug='device-role-2'),
         )
-        DeviceRole.objects.bulk_create(roles)
+        for role in roles:
+            role.save()
 
         platforms = (
             Platform(name='Platform 1', slug='platform-1'),