From 1508e3a770380bf2ebf0c9e6aee284057962ca96 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 28 Mar 2025 12:32:02 -0700 Subject: [PATCH] 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 --- docs/models/dcim/devicerole.md | 4 + netbox/dcim/api/serializers_/nested.py | 7 ++ netbox/dcim/api/serializers_/roles.py | 13 ++- netbox/dcim/filtersets.py | 33 +++++- netbox/dcim/forms/bulk_edit.py | 10 +- netbox/dcim/forms/bulk_import.py | 14 ++- netbox/dcim/forms/filtersets.py | 5 + netbox/dcim/forms/model_forms.py | 11 +- netbox/dcim/graphql/types.py | 2 + .../migrations/0203_device_role_nested.py | 65 +++++++++++ .../migrations/0204_device_role_rebuild.py | 22 ++++ netbox/dcim/models/devices.py | 6 +- netbox/dcim/tables/devices.py | 2 +- netbox/dcim/tests/test_api.py | 16 +-- netbox/dcim/tests/test_filtersets.py | 110 +++++++++++++++--- netbox/dcim/tests/test_models.py | 3 +- netbox/dcim/tests/test_views.py | 13 ++- netbox/extras/filtersets.py | 4 +- netbox/extras/forms/filtersets.py | 4 +- netbox/extras/tests/test_filtersets.py | 3 +- netbox/templates/dcim/devicerole.html | 18 +++ netbox/utilities/tests/test_filters.py | 3 +- netbox/virtualization/filtersets.py | 8 +- .../virtualization/tests/test_filtersets.py | 3 +- netbox/virtualization/tests/test_views.py | 3 +- 25 files changed, 327 insertions(+), 55 deletions(-) create mode 100644 netbox/dcim/migrations/0203_device_role_nested.py create mode 100644 netbox/dcim/migrations/0204_device_role_rebuild.py diff --git a/docs/models/dcim/devicerole.md b/docs/models/dcim/devicerole.md index 786170f2b..e58373565 100644 --- a/docs/models/dcim/devicerole.md +++ b/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. diff --git a/netbox/dcim/api/serializers_/nested.py b/netbox/dcim/api/serializers_/nested.py index ea346cc63..0e9eaa52f 100644 --- a/netbox/dcim/api/serializers_/nested.py +++ b/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: diff --git a/netbox/dcim/api/serializers_/roles.py b/netbox/dcim/api/serializers_/roles.py index 8f922da10..17eeaa949 100644 --- a/netbox/dcim/api/serializers_/roles.py +++ b/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): diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 81c92d759..2c8b77f57 100644 --- a/netbox/dcim/filtersets.py +++ b/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)'), ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 31f6fd9df..a77c7fa9c 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/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): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 708bc7618..081e9d41d 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/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): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index d794c6893..b70661348 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/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) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 8fa7e153f..2829ac754 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/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', ] diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 9554a9f60..24fa16263 100644 --- a/netbox/dcim/graphql/types.py +++ b/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 diff --git a/netbox/dcim/migrations/0203_device_role_nested.py b/netbox/dcim/migrations/0203_device_role_nested.py new file mode 100644 index 000000000..c9dd791b3 --- /dev/null +++ b/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), + ), + ] diff --git a/netbox/dcim/migrations/0204_device_role_rebuild.py b/netbox/dcim/migrations/0204_device_role_rebuild.py new file mode 100644 index 000000000..69837c522 --- /dev/null +++ b/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), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 2acd98801..76269f5c9 100644 --- a/netbox/dcim/models/devices.py +++ b/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') diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 06f6469d3..5cfb31750 100644 --- a/netbox/dcim/tables/devices.py +++ b/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 ) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 807ac77d4..c2a7660c6 100644 --- a/netbox/dcim/tests/test_api.py +++ b/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') diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index f46391310..b2353b4ba 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/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) ) - DeviceRole.objects.bulk_create(roles) + 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] + ) + ) + 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'), diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index bdb07d6d1..66f52b1bf 100644 --- a/netbox/dcim/tests/test_models.py +++ b/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') diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 83effa188..0bf8fefb3 100644 --- a/netbox/dcim/tests/test_views.py +++ b/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'), diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index e63b6d673..8381316cc 100644 --- a/netbox/extras/filtersets.py +++ b/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 diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 1691559f9..0a50047fe 100644 --- a/netbox/extras/forms/filtersets.py +++ b/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') diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index ff4543bd2..84d7aad5a 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/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'), diff --git a/netbox/templates/dcim/devicerole.html b/netbox/templates/dcim/devicerole.html index d9e170af3..3644337e4 100644 --- a/netbox/templates/dcim/devicerole.html +++ b/netbox/templates/dcim/devicerole.html @@ -30,6 +30,10 @@ {% trans "Description" %} {{ object.description|placeholder }} + + {% trans "Parent" %} + {{ object.parent|linkify|placeholder }} + {% trans "Color" %} @@ -52,11 +56,25 @@
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
+
+

+ {% trans "Child Device Roles" %} + {% if perms.dcim.add_devicerole %} + + {% endif %} +

+ {% htmx_table 'dcim:devicerole_list' parent_id=object.pk %} +
{% plugin_full_width_page object %}
diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index 6956396d2..1598d3d52 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/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'), diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index b031d2bf3..06a38da36 100644 --- a/netbox/virtualization/filtersets.py +++ b/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)'), ) diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index eef5d6b52..7fbf0045d 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/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]), diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 3c8d7eadc..27b1a08a5 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/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'),