diff --git a/netbox/dcim/api/serializers_/nested.py b/netbox/dcim/api/serializers_/nested.py index ea346cc63..cdd964934 100644 --- a/netbox/dcim/api/serializers_/nested.py +++ b/netbox/dcim/api/serializers_/nested.py @@ -7,6 +7,7 @@ from dcim import models __all__ = ( 'NestedDeviceBaySerializer', 'NestedDeviceSerializer', + 'NestedDeviceRoleGroupSerializer', 'NestedInterfaceSerializer', 'NestedInterfaceTemplateSerializer', 'NestedLocationSerializer', @@ -52,6 +53,18 @@ class NestedLocationSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'rack_count', '_depth'] +@extend_schema_serializer( + exclude_fields=('tenant_count',), +) +class NestedDeviceRoleGroupSerializer(WritableNestedSerializer): + role_count = serializers.IntegerField(read_only=True) + _depth = serializers.IntegerField(source='level', read_only=True) + + class Meta: + model = models.DeviceRoleGroup + fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'role_count', '_depth'] + + class NestedDeviceSerializer(WritableNestedSerializer): class Meta: diff --git a/netbox/dcim/api/serializers_/roles.py b/netbox/dcim/api/serializers_/roles.py index 8f922da10..38084e03b 100644 --- a/netbox/dcim/api/serializers_/roles.py +++ b/netbox/dcim/api/serializers_/roles.py @@ -1,14 +1,31 @@ -from dcim.models import DeviceRole, InventoryItemRole +from rest_framework import serializers + +from dcim.models import DeviceRole, DeviceRoleGroup, 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 NestedDeviceRoleGroupSerializer __all__ = ( 'DeviceRoleSerializer', + 'DeviceRoleGroupSerializer', 'InventoryItemRoleSerializer', ) +class DeviceRoleGroupSerializer(NestedGroupModelSerializer): + parent = NestedDeviceRoleGroupSerializer(required=False, allow_null=True) + tenant_count = serializers.IntegerField(read_only=True, default=0) + + class Meta: + model = DeviceRoleGroup + fields = [ + 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', + 'created', 'last_updated', 'role_count', 'comments', '_depth', + ] + brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'role_count', '_depth') + + class DeviceRoleSerializer(NetBoxModelSerializer): config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None) diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index fc3740374..512bdd0df 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -36,6 +36,7 @@ router.register('inventory-item-templates', views.InventoryItemTemplateViewSet) # Device/modules router.register('device-roles', views.DeviceRoleViewSet) +router.register('device-role-groups', views.DeviceRoleGroupViewSet) router.register('platforms', views.PlatformViewSet) router.register('devices', views.DeviceViewSet) router.register('virtual-device-contexts', views.VirtualDeviceContextViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index d7dbbef91..483b8ca87 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -343,6 +343,18 @@ class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet): # Device roles # +class DeviceRoleGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet): + queryset = DeviceRoleGroup.objects.add_related_count( + DeviceRoleGroup.objects.all(), + DeviceRole, + 'group', + 'role_count', + cumulative=True + ) + serializer_class = serializers.DeviceRoleGroupSerializer + filterset_class = filtersets.DeviceRoleGroupFilterSet + + class DeviceRoleViewSet(NetBoxModelViewSet): queryset = DeviceRole.objects.all() serializer_class = serializers.DeviceRoleSerializer diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 6f9f481c3..0092d3655 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -43,6 +43,7 @@ __all__ = ( 'DeviceBayTemplateFilterSet', 'DeviceFilterSet', 'DeviceRoleFilterSet', + 'DeviceRoleGroupFilterSet', 'DeviceTypeFilterSet', 'FrontPortFilterSet', 'FrontPortTemplateFilterSet', @@ -917,6 +918,36 @@ class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeCompo return queryset.filter(qs_filter) +class DeviceRoleGroupFilterSet(NestedGroupModelFilterSet): + parent_id = django_filters.ModelMultipleChoiceFilter( + queryset=DeviceRoleGroup.objects.all(), + label=_('Parent device role group (ID)'), + ) + parent = django_filters.ModelMultipleChoiceFilter( + field_name='parent__slug', + queryset=DeviceRoleGroup.objects.all(), + to_field_name='slug', + label=_('Parent device role group (slug)'), + ) + ancestor_id = TreeNodeMultipleChoiceFilter( + queryset=DeviceRoleGroup.objects.all(), + field_name='parent', + lookup_expr='in', + label=_('Device role group (ID)'), + ) + ancestor = TreeNodeMultipleChoiceFilter( + queryset=DeviceRoleGroup.objects.all(), + field_name='parent', + lookup_expr='in', + to_field_name='slug', + label=_('Device role group (slug)'), + ) + + class Meta: + model = DeviceRoleGroup + fields = ('id', 'name', 'slug', 'description') + + class DeviceRoleFilterSet(OrganizationalModelFilterSet): config_template_id = django_filters.ModelMultipleChoiceFilter( queryset=ConfigTemplate.objects.all(), diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index c1da9c8d1..a14074512 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -31,6 +31,7 @@ __all__ = ( 'DeviceBayTemplateBulkEditForm', 'DeviceBulkEditForm', 'DeviceRoleBulkEditForm', + 'DeviceRoleGroupBulkEditForm', 'DeviceTypeBulkEditForm', 'FrontPortBulkEditForm', 'FrontPortTemplateBulkEditForm', @@ -611,6 +612,23 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): nullable_fields = ('part_number', 'weight', 'weight_unit', 'description', 'comments') +class DeviceRoleGroupBulkEditForm(NetBoxModelBulkEditForm): + parent = DynamicModelChoiceField( + label=_('Parent'), + queryset=DeviceRoleGroup.objects.all(), + required=False + ) + description = forms.CharField( + label=_('Description'), + max_length=200, + required=False + ) + comments = CommentField() + + model = DeviceRoleGroup + nullable_fields = ('parent', 'description', 'comments') + + class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): color = ColorField( label=_('Color'), diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 469e40217..9f84ea22e 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -28,6 +28,7 @@ __all__ = ( 'DeviceBayImportForm', 'DeviceImportForm', 'DeviceRoleImportForm', + 'DeviceRoleGroupImportForm', 'DeviceTypeImportForm', 'FrontPortImportForm', 'InterfaceImportForm', @@ -459,6 +460,21 @@ class ModuleTypeImportForm(NetBoxModelImportForm): ] +class DeviceRoleGroupImportForm(NetBoxModelImportForm): + parent = CSVModelChoiceField( + label=_('Parent'), + queryset=DeviceRoleGroup.objects.all(), + required=False, + to_field_name='name', + help_text=_('Parent group') + ) + slug = SlugField() + + class Meta: + model = DeviceRoleGroup + fields = ('name', 'slug', 'parent', 'description', 'tags', 'comments') + + class DeviceRoleImportForm(NetBoxModelImportForm): config_template = CSVModelChoiceField( label=_('Config template'), diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index d794c6893..07764f023 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -27,6 +27,7 @@ __all__ = ( 'DeviceBayFilterForm', 'DeviceFilterForm', 'DeviceRoleFilterForm', + 'DeviceRoleGroupFilterForm', 'DeviceTypeFilterForm', 'FrontPortFilterForm', 'InterfaceConnectionFilterForm', @@ -682,6 +683,16 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm): ) +class DeviceRoleGroupFilterForm(NetBoxModelFilterSetForm): + model = DeviceRoleGroup + parent_id = DynamicModelMultipleChoiceField( + queryset=DeviceRoleGroup.objects.all(), + required=False, + label=_('Parent group') + ) + tag = TagFilterField(model) + + class DeviceRoleFilterForm(NetBoxModelFilterSetForm): model = DeviceRole config_template_id = DynamicModelMultipleChoiceField( diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index dea031b64..ae184389c 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -32,6 +32,7 @@ __all__ = ( 'DeviceBayTemplateForm', 'DeviceForm', 'DeviceRoleForm', + 'DeviceRoleGroupForm', 'DeviceTypeForm', 'DeviceVCMembershipForm', 'FrontPortForm', @@ -423,6 +424,26 @@ class ModuleTypeForm(NetBoxModelForm): ] +class DeviceRoleGroupForm(NetBoxModelForm): + parent = DynamicModelChoiceField( + label=_('Parent'), + queryset=DeviceRoleGroup.objects.all(), + required=False + ) + slug = SlugField() + comments = CommentField() + + fieldsets = ( + FieldSet('parent', 'name', 'slug', 'description', 'tags', name=_('Device Role Group')), + ) + + class Meta: + model = DeviceRoleGroup + fields = [ + 'parent', 'name', 'slug', 'description', 'tags', 'comments' + ] + + class DeviceRoleForm(NetBoxModelForm): config_template = DynamicModelChoiceField( label=_('Config template'), diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 547de4927..4c37d2124 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -36,6 +36,7 @@ from .mixins import RenderConfigMixin __all__ = ( 'Device', 'DeviceRole', + 'DeviceRoleGroup', 'DeviceType', 'MACAddress', 'Manufacturer', @@ -471,7 +472,7 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): class DeviceRoleGroup(NestedGroupModel): """ - An arbitrary collection of Tenants. + An arbitrary collection of DeviceRoles. """ name = models.CharField( verbose_name=_('name'), diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index a85005679..47c1bf546 100644 --- a/netbox/dcim/search.py +++ b/netbox/dcim/search.py @@ -75,6 +75,18 @@ class DeviceRoleIndex(SearchIndex): display_attrs = ('description',) +@register_search +class DeviceRoleGroupIndex(SearchIndex): + model = models.DeviceRoleGroup + fields = ( + ('name', 100), + ('slug', 110), + ('description', 500), + ('comments', 5000), + ) + display_attrs = ('description',) + + @register_search class DeviceTypeIndex(SearchIndex): model = models.DeviceType diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 06f6469d3..cc99188ca 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -24,6 +24,7 @@ __all__ = ( 'DevicePowerOutletTable', 'DeviceRearPortTable', 'DeviceRoleTable', + 'DeviceRoleGroupTable', 'DeviceTable', 'FrontPortTable', 'InterfaceTable', @@ -58,6 +59,32 @@ MACADDRESS_COPY_BUTTON = """ # Device roles # +class DeviceRoleGroupTable(NetBoxTable): + name = columns.MPTTColumn( + verbose_name=_('Name'), + linkify=True + ) + role_count = columns.LinkedCountColumn( + viewname='dcim:devicerole_list', + url_params={'group_id': 'pk'}, + verbose_name=_('Device Roles') + ) + tags = columns.TagColumn( + url_name='dcim:devicerolegroup_list' + ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) + + class Meta(NetBoxTable.Meta): + model = models.DeviceRoleGroup + fields = ( + 'pk', 'id', 'name', 'role_count', 'description', 'comments', 'slug', 'tags', 'created', + 'last_updated', 'actions', + ) + default_columns = ('pk', 'name', 'role_count', 'description') + + class DeviceRoleTable(NetBoxTable): name = tables.Column( verbose_name=_('Name'), diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index bcfd32707..09f3d8f63 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -73,6 +73,9 @@ urlpatterns = [ path('device-roles/', include(get_model_urls('dcim', 'devicerole', detail=False))), path('device-roles//', include(get_model_urls('dcim', 'devicerole'))), + path('device-role-groups/', include(get_model_urls('dcim', 'devicerolegroup', detail=False))), + path('device-role-groups//', include(get_model_urls('dcim', 'devicerolegroup'))), + path('platforms/', include(get_model_urls('dcim', 'platform', detail=False))), path('platforms//', include(get_model_urls('dcim', 'platform'))), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 0978747d1..38e340c4b 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1897,6 +1897,61 @@ class InventoryItemTemplateBulkDeleteView(generic.BulkDeleteView): table = tables.InventoryItemTemplateTable +# +# Device role groups +# + +@register_model_view(DeviceRoleGroup, 'list', path='', detail=False) +class DeviceRoleGroupListView(generic.ObjectListView): + queryset = DeviceRoleGroup.objects.all() + filterset = filtersets.DeviceRoleGroupFilterSet + filterset_form = forms.DeviceRoleGroupFilterForm + table = tables.DeviceRoleGroupTable + + +@register_model_view(DeviceRoleGroup) +class DeviceRoleGroupView(GetRelatedModelsMixin, generic.ObjectView): + queryset = DeviceRoleGroup.objects.all() + + def get_extra_context(self, request, instance): + return { + 'related_models': self.get_related_models(request, instance), + } + + +@register_model_view(DeviceRoleGroup, 'add', detail=False) +@register_model_view(DeviceRoleGroup, 'edit') +class DeviceRoleGroupEditView(generic.ObjectEditView): + queryset = DeviceRoleGroup.objects.all() + form = forms.DeviceRoleGroupForm + + +@register_model_view(DeviceRoleGroup, 'delete') +class DeviceRoleGroupDeleteView(generic.ObjectDeleteView): + queryset = DeviceRoleGroup.objects.all() + + +@register_model_view(DeviceRoleGroup, 'bulk_import', detail=False) +class DeviceRoleGroupBulkImportView(generic.BulkImportView): + queryset = DeviceRoleGroup.objects.all() + model_form = forms.DeviceRoleGroupImportForm + + +@register_model_view(DeviceRoleGroup, 'bulk_edit', path='edit', detail=False) +class DeviceRoleGroupBulkEditView(generic.BulkEditView): + queryset = DeviceRoleGroup.objects.all() + filterset = filtersets.DeviceRoleGroupFilterSet + table = tables.DeviceRoleGroupTable + form = forms.DeviceRoleGroupBulkEditForm + + +@register_model_view(DeviceRoleGroup, 'bulk_delete', path='delete', detail=False) +class DeviceRoleGroupBulkDeleteView(generic.BulkDeleteView): + queryset = DeviceRoleGroup.objects.all() + filterset = filtersets.DeviceRoleGroupFilterSet + table = tables.DeviceRoleGroupTable + + # # Device roles # diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index 8d20fed45..ac6b91b15 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -23,6 +23,7 @@ ADVISORY_LOCK_KEYS = { 'wirelesslangroup': 105600, 'inventoryitem': 105700, 'inventoryitemtemplate': 105800, + 'devicerolegroup': 105900, # Jobs 'job-schedules': 110100, diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 9148caa8e..d3a07fd37 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -75,6 +75,7 @@ DEVICES_MENU = Menu( get_model_item('dcim', 'device', _('Devices')), get_model_item('dcim', 'module', _('Modules')), get_model_item('dcim', 'devicerole', _('Device Roles')), + get_model_item('dcim', 'devicerolegroup', _('Device Role Groups')), get_model_item('dcim', 'platform', _('Platforms')), get_model_item('dcim', 'virtualchassis', _('Virtual Chassis')), get_model_item('dcim', 'virtualdevicecontext', _('Virtual Device Contexts')),