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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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