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
This commit is contained in:
Arthur Hanson 2025-03-28 12:32:02 -07:00 committed by GitHub
parent 7a71c7b8f8
commit 1508e3a770
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 327 additions and 55 deletions

View File

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

View File

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

View File

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

View File

@ -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( role_id = TreeNodeMultipleChoiceFilter(
field_name='role_id', field_name='role',
queryset=DeviceRole.objects.all(), queryset=DeviceRole.objects.all(),
lookup_expr='in',
label=_('Role (ID)'), label=_('Role (ID)'),
) )
role = django_filters.ModelMultipleChoiceFilter( role = TreeNodeMultipleChoiceFilter(
field_name='role__slug',
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)'),
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000')
DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000'), DeviceRole.objects.create(name='Device Role 2', slug='device-role-2', color='00ff00')
DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00'), DeviceRole.objects.create(name='Device Role 3', slug='device-role-3', color='0000ff')
DeviceRole(name='Device Role 3', slug='device-role-3', color='0000ff'),
)
DeviceRole.objects.bulk_create(roles)
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')

View File

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

View File

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

View File

@ -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(name='Device Role 4', slug='device-role-4'),
DeviceRole.objects.bulk_create(roles) ]
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'),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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( role = TreeNodeMultipleChoiceFilter(
field_name='role__slug', 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)'),
) )

View File

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

View File

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