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

Closes #20961: Introduce RackGroup for physical rack placement (#21624)

Fixes #20961
Martin Hauser 9 цаг өмнө
parent
commit
e2665ef211

+ 5 - 1
docs/models/dcim/rack.md

@@ -1,6 +1,6 @@
 # Racks
 # Racks
 
 
-The rack model represents a physical two- or four-post equipment rack in which [devices](./device.md) can be installed. Each rack must be assigned to a [site](./site.md), and may optionally be assigned to a [location](./location.md) within that site. Racks can also be organized by user-defined functional roles. The name and facility ID of each rack within a location must be unique.
+The rack model represents a physical two- or four-post equipment rack in which [devices](./device.md) can be installed. Each rack must be assigned to a [site](./site.md), and may optionally be assigned to a [location](./location.md) within that site. Racks can also be organized by user-defined functional roles or by [rack groups](./rackgroup.md). The name and facility ID of each rack within a location must be unique.
 
 
 Rack height is measured in *rack units* (U); racks are commonly between 42U and 48U tall, but NetBox allows you to define racks of arbitrary height. A toggle is provided to indicate whether rack units are in ascending (from the ground up) or descending order.
 Rack height is measured in *rack units* (U); racks are commonly between 42U and 48U tall, but NetBox allows you to define racks of arbitrary height. A toggle is provided to indicate whether rack units are in ascending (from the ground up) or descending order.
 
 
@@ -16,6 +16,10 @@ The [site](./site.md) to which the rack is assigned.
 
 
 The [location](./location.md) within a site where the rack has been installed (optional).
 The [location](./location.md) within a site where the rack has been installed (optional).
 
 
+### Rack Group
+
+The [group](./rackgroup.md) used to organize racks by physical placement (optional).
+
 ### Name
 ### Name
 
 
 The rack's name or identifier. Must be unique to the rack's location, if assigned.
 The rack's name or identifier. Must be unique to the rack's location, if assigned.

+ 15 - 0
docs/models/dcim/rackgroup.md

@@ -0,0 +1,15 @@
+# Rack Groups
+
+Racks can optionally be assigned to rack groups to reflect their physical placement. Rack groups provide a secondary means of categorization alongside [locations](./location.md), which is particularly useful for datacenter operators who need to group racks by row, aisle, or similar physical arrangement while keeping them assigned to the same location, such as a cage or room. Rack groups are flat and do not form a hierarchy.
+
+Rack groups can also be used to scope [VLAN groups](../ipam/vlangroup.md), which can help model L2 domains spanning rows or pairs of racks.
+
+## Fields
+
+### Name
+
+A unique human-friendly name.
+
+### Slug
+
+A unique URL-friendly identifier. (This value can be used for filtering.)

+ 1 - 0
mkdocs.yml

@@ -221,6 +221,7 @@ nav:
             - PowerPort: 'models/dcim/powerport.md'
             - PowerPort: 'models/dcim/powerport.md'
             - PowerPortTemplate: 'models/dcim/powerporttemplate.md'
             - PowerPortTemplate: 'models/dcim/powerporttemplate.md'
             - Rack: 'models/dcim/rack.md'
             - Rack: 'models/dcim/rack.md'
+            - RackGroup: 'models/dcim/rackgroup.md'
             - RackReservation: 'models/dcim/rackreservation.md'
             - RackReservation: 'models/dcim/rackreservation.md'
             - RackRole: 'models/dcim/rackrole.md'
             - RackRole: 'models/dcim/rackrole.md'
             - RackType: 'models/dcim/racktype.md'
             - RackType: 'models/dcim/racktype.md'

+ 26 - 6
netbox/dcim/api/serializers_/racks.py

@@ -3,7 +3,7 @@ from rest_framework import serializers
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
-from dcim.models import Rack, RackReservation, RackRole, RackType
+from dcim.models import Rack, RackGroup, RackReservation, RackRole, RackType
 from netbox.api.fields import ChoiceField, RelatedObjectCountField
 from netbox.api.fields import ChoiceField, RelatedObjectCountField
 from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer
 from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer
 from netbox.choices import *
 from netbox.choices import *
@@ -16,6 +16,7 @@ from .sites import LocationSerializer, SiteSerializer
 
 
 __all__ = (
 __all__ = (
     'RackElevationDetailFilterSerializer',
     'RackElevationDetailFilterSerializer',
+    'RackGroupSerializer',
     'RackReservationSerializer',
     'RackReservationSerializer',
     'RackRoleSerializer',
     'RackRoleSerializer',
     'RackSerializer',
     'RackSerializer',
@@ -23,6 +24,20 @@ __all__ = (
 )
 )
 
 
 
 
+class RackGroupSerializer(OrganizationalModelSerializer):
+
+    # Related object counts
+    rack_count = RelatedObjectCountField('racks')
+
+    class Meta:
+        model = RackGroup
+        fields = [
+            'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'owner', 'comments', 'tags',
+            'custom_fields', 'created', 'last_updated', 'rack_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count')
+
+
 class RackRoleSerializer(OrganizationalModelSerializer):
 class RackRoleSerializer(OrganizationalModelSerializer):
 
 
     # Related object counts
     # Related object counts
@@ -87,6 +102,11 @@ class RackSerializer(RackBaseSerializer):
         allow_null=True,
         allow_null=True,
         default=None
         default=None
     )
     )
+    group = RackGroupSerializer(
+        nested=True,
+        required=False,
+        allow_null=True
+    )
     tenant = TenantSerializer(
     tenant = TenantSerializer(
         nested=True,
         nested=True,
         required=False,
         required=False,
@@ -127,11 +147,11 @@ class RackSerializer(RackBaseSerializer):
     class Meta:
     class Meta:
         model = Rack
         model = Rack
         fields = [
         fields = [
-            'id', 'url', 'display_url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status',
-            'role', 'serial', 'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'weight',
-            'max_weight', 'weight_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit',
-            'mounting_depth', 'airflow', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created',
-            'last_updated', 'device_count', 'powerfeed_count',
+            'id', 'url', 'display_url', 'display', 'name', 'facility_id', 'site', 'location', 'group', 'tenant',
+            'status', 'role', 'serial', 'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit',
+            'weight', 'max_weight', 'weight_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
+            'outer_unit', 'mounting_depth', 'airflow', 'description', 'owner', 'comments', 'tags', 'custom_fields',
+            'created', 'last_updated', 'device_count', 'powerfeed_count',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count')
         brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count')
 
 

+ 1 - 0
netbox/dcim/api/urls.py

@@ -12,6 +12,7 @@ router.register('sites', views.SiteViewSet)
 
 
 # Racks
 # Racks
 router.register('locations', views.LocationViewSet)
 router.register('locations', views.LocationViewSet)
+router.register('rack-groups', views.RackGroupViewSet)
 router.register('rack-types', views.RackTypeViewSet)
 router.register('rack-types', views.RackTypeViewSet)
 router.register('rack-roles', views.RackRoleViewSet)
 router.register('rack-roles', views.RackRoleViewSet)
 router.register('racks', views.RackViewSet)
 router.register('racks', views.RackViewSet)

+ 11 - 0
netbox/dcim/api/views.py

@@ -154,6 +154,17 @@ class LocationViewSet(MPTTLockedMixin, NetBoxModelViewSet):
     filterset_class = filtersets.LocationFilterSet
     filterset_class = filtersets.LocationFilterSet
 
 
 
 
+#
+# Rack groups
+#
+
+
+class RackGroupViewSet(NetBoxModelViewSet):
+    queryset = RackGroup.objects.all()
+    serializer_class = serializers.RackGroupSerializer
+    filterset_class = filtersets.RackGroupFilterSet
+
+
 #
 #
 # Rack roles
 # Rack roles
 #
 #

+ 34 - 0
netbox/dcim/filtersets.py

@@ -85,6 +85,7 @@ __all__ = (
     'PowerPortFilterSet',
     'PowerPortFilterSet',
     'PowerPortTemplateFilterSet',
     'PowerPortTemplateFilterSet',
     'RackFilterSet',
     'RackFilterSet',
+    'RackGroupFilterSet',
     'RackReservationFilterSet',
     'RackReservationFilterSet',
     'RackRoleFilterSet',
     'RackRoleFilterSet',
     'RackTypeFilterSet',
     'RackTypeFilterSet',
@@ -315,6 +316,14 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupMode
         return queryset
         return queryset
 
 
 
 
+@register_filterset
+class RackGroupFilterSet(OrganizationalModelFilterSet):
+
+    class Meta:
+        model = RackGroup
+        fields = ('id', 'name', 'slug', 'description')
+
+
 @register_filterset
 @register_filterset
 class RackRoleFilterSet(OrganizationalModelFilterSet):
 class RackRoleFilterSet(OrganizationalModelFilterSet):
 
 
@@ -419,6 +428,18 @@ class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterS
         to_field_name='slug',
         to_field_name='slug',
         label=_('Location (slug)'),
         label=_('Location (slug)'),
     )
     )
+    group_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=RackGroup.objects.all(),
+        distinct=False,
+        label=_('Group (ID)'),
+    )
+    group = django_filters.ModelMultipleChoiceFilter(
+        field_name='group__slug',
+        queryset=RackGroup.objects.all(),
+        distinct=False,
+        to_field_name='slug',
+        label=_('Group (slug)'),
+    )
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
         field_name='rack_type__manufacturer',
         field_name='rack_type__manufacturer',
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
@@ -553,6 +574,19 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label=_('Location (slug)'),
         label=_('Location (slug)'),
     )
     )
+    group_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=RackGroup.objects.all(),
+        field_name='rack__group',
+        distinct=False,
+        label=_('Group (ID)'),
+    )
+    group = django_filters.ModelMultipleChoiceFilter(
+        field_name='rack__group__slug',
+        queryset=RackGroup.objects.all(),
+        distinct=False,
+        to_field_name='slug',
+        label=_('Group (slug)'),
+    )
     status = django_filters.MultipleChoiceFilter(
     status = django_filters.MultipleChoiceFilter(
         choices=RackReservationStatusChoices,
         choices=RackReservationStatusChoices,
         distinct=False,
         distinct=False,

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

@@ -61,6 +61,7 @@ __all__ = (
     'PowerPortBulkEditForm',
     'PowerPortBulkEditForm',
     'PowerPortTemplateBulkEditForm',
     'PowerPortTemplateBulkEditForm',
     'RackBulkEditForm',
     'RackBulkEditForm',
+    'RackGroupBulkEditForm',
     'RackReservationBulkEditForm',
     'RackReservationBulkEditForm',
     'RackRoleBulkEditForm',
     'RackRoleBulkEditForm',
     'RackTypeBulkEditForm',
     'RackTypeBulkEditForm',
@@ -201,6 +202,14 @@ class LocationBulkEditForm(NestedGroupModelBulkEditForm):
     nullable_fields = ('parent', 'tenant', 'facility', 'description', 'comments')
     nullable_fields = ('parent', 'tenant', 'facility', 'description', 'comments')
 
 
 
 
+class RackGroupBulkEditForm(OrganizationalModelBulkEditForm):
+    model = RackGroup
+    fieldsets = (
+        FieldSet('description'),
+    )
+    nullable_fields = ('description', 'comments')
+
+
 class RackRoleBulkEditForm(OrganizationalModelBulkEditForm):
 class RackRoleBulkEditForm(OrganizationalModelBulkEditForm):
     color = ColorField(
     color = ColorField(
         label=_('Color'),
         label=_('Color'),
@@ -336,6 +345,11 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
             'site_id': '$site'
             'site_id': '$site'
         }
         }
     )
     )
+    group = DynamicModelChoiceField(
+        label=_('Group'),
+        queryset=RackGroup.objects.all(),
+        required=False
+    )
     tenant = DynamicModelChoiceField(
     tenant = DynamicModelChoiceField(
         label=_('Tenant'),
         label=_('Tenant'),
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
@@ -435,14 +449,16 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
 
 
     model = Rack
     model = Rack
     fieldsets = (
     fieldsets = (
-        FieldSet('status', 'role', 'tenant', 'serial', 'asset_tag', 'rack_type', 'description', name=_('Rack')),
+        FieldSet(
+            'status', 'group', 'role', 'tenant', 'serial', 'asset_tag', 'rack_type', 'description', name=_('Rack')
+        ),
         FieldSet('region', 'site_group', 'site', 'location', name=_('Location')),
         FieldSet('region', 'site_group', 'site', 'location', name=_('Location')),
         FieldSet('outer_width', 'outer_height', 'outer_depth', 'outer_unit', name=_('Outer Dimensions')),
         FieldSet('outer_width', 'outer_height', 'outer_depth', 'outer_unit', name=_('Outer Dimensions')),
         FieldSet('form_factor', 'width', 'u_height', 'desc_units', 'airflow', 'mounting_depth', name=_('Hardware')),
         FieldSet('form_factor', 'width', 'u_height', 'desc_units', 'airflow', 'mounting_depth', name=_('Hardware')),
         FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
         FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
     )
     )
     nullable_fields = (
     nullable_fields = (
-        'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_height', 'outer_depth',
+        'location', 'group', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_height', 'outer_depth',
         'outer_unit', 'weight', 'max_weight', 'weight_unit', 'description', 'comments',
         'outer_unit', 'weight', 'max_weight', 'weight_unit', 'description', 'comments',
     )
     )
 
 

+ 19 - 4
netbox/dcim/forms/bulk_import.py

@@ -57,6 +57,7 @@ __all__ = (
     'PowerOutletImportForm',
     'PowerOutletImportForm',
     'PowerPanelImportForm',
     'PowerPanelImportForm',
     'PowerPortImportForm',
     'PowerPortImportForm',
+    'RackGroupImportForm',
     'RackImportForm',
     'RackImportForm',
     'RackReservationImportForm',
     'RackReservationImportForm',
     'RackRoleImportForm',
     'RackRoleImportForm',
@@ -187,6 +188,13 @@ class LocationImportForm(NestedGroupModelImportForm):
             self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
             self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
 
 
 
 
+class RackGroupImportForm(OrganizationalModelImportForm):
+
+    class Meta:
+        model = RackGroup
+        fields = ('name', 'slug', 'description', 'owner', 'comments', 'tags')
+
+
 class RackRoleImportForm(OrganizationalModelImportForm):
 class RackRoleImportForm(OrganizationalModelImportForm):
 
 
     class Meta:
     class Meta:
@@ -261,6 +269,13 @@ class RackImportForm(PrimaryModelImportForm):
         to_field_name='name',
         to_field_name='name',
         help_text=_('Name of assigned tenant')
         help_text=_('Name of assigned tenant')
     )
     )
+    group = CSVModelChoiceField(
+        label=_('Rack group'),
+        queryset=RackGroup.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Name of assigned group')
+    )
     status = CSVChoiceField(
     status = CSVChoiceField(
         label=_('Status'),
         label=_('Status'),
         choices=RackStatusChoices,
         choices=RackStatusChoices,
@@ -318,10 +333,10 @@ class RackImportForm(PrimaryModelImportForm):
     class Meta:
     class Meta:
         model = Rack
         model = Rack
         fields = (
         fields = (
-            'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'rack_type', 'form_factor', 'serial',
-            'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit',
-            'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', 'description', 'owner', 'comments',
-            'tags',
+            'site', 'location', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'rack_type', 'form_factor',
+            'serial', 'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
+            'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', 'description', 'owner',
+            'comments', 'tags',
         )
         )
 
 
     def __init__(self, data=None, *args, **kwargs):
     def __init__(self, data=None, *args, **kwargs):

+ 26 - 3
netbox/dcim/forms/filtersets.py

@@ -64,6 +64,7 @@ __all__ = (
     'PowerPortTemplateFilterForm',
     'PowerPortTemplateFilterForm',
     'RackElevationFilterForm',
     'RackElevationFilterForm',
     'RackFilterForm',
     'RackFilterForm',
+    'RackGroupFilterForm',
     'RackReservationFilterForm',
     'RackReservationFilterForm',
     'RackRoleFilterForm',
     'RackRoleFilterForm',
     'RackTypeFilterForm',
     'RackTypeFilterForm',
@@ -276,6 +277,15 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NestedGroupM
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
+class RackGroupFilterForm(OrganizationalModelFilterSetForm):
+    model = RackGroup
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
+    )
+    tag = TagFilterField(model)
+
+
 class RackRoleFilterForm(OrganizationalModelFilterSetForm):
 class RackRoleFilterForm(OrganizationalModelFilterSetForm):
     model = RackRole
     model = RackRole
     fieldsets = (
     fieldsets = (
@@ -355,7 +365,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterFo
     model = Rack
     model = Rack
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('q', 'filter_id', 'tag'),
-        FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
+        FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'group_id', name=_('Location')),
         FieldSet('status', 'role_id', 'manufacturer_id', 'rack_type_id', 'serial', 'asset_tag', name=_('Rack')),
         FieldSet('status', 'role_id', 'manufacturer_id', 'rack_type_id', 'serial', 'asset_tag', name=_('Rack')),
         FieldSet('form_factor', 'width', 'u_height', 'airflow', name=_('Hardware')),
         FieldSet('form_factor', 'width', 'u_height', 'airflow', name=_('Hardware')),
         FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
         FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
@@ -392,6 +402,12 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterFo
         },
         },
         label=_('Location')
         label=_('Location')
     )
     )
+    group_id = DynamicModelMultipleChoiceField(
+        queryset=RackGroup.objects.all(),
+        required=False,
+        null_option='None',
+        label=_('Rack group')
+    )
     status = forms.MultipleChoiceField(
     status = forms.MultipleChoiceField(
         label=_('Status'),
         label=_('Status'),
         choices=RackStatusChoices,
         choices=RackStatusChoices,
@@ -435,7 +451,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterFo
 class RackElevationFilterForm(RackFilterForm):
 class RackElevationFilterForm(RackFilterForm):
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('q', 'filter_id', 'tag'),
-        FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'id', name=_('Location')),
+        FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'group_id', 'id', name=_('Location')),
         FieldSet('status', 'role_id', name=_('Function')),
         FieldSet('status', 'role_id', name=_('Function')),
         FieldSet('type', 'width', 'serial', 'asset_tag', name=_('Hardware')),
         FieldSet('type', 'width', 'serial', 'asset_tag', name=_('Hardware')),
         FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
         FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
@@ -459,7 +475,7 @@ class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('status', 'user_id', name=_('Reservation')),
         FieldSet('status', 'user_id', name=_('Reservation')),
-        FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Rack')),
+        FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'group_id', 'rack_id', name=_('Rack')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
@@ -491,10 +507,17 @@ class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
         label=_('Location'),
         label=_('Location'),
         null_option='None'
         null_option='None'
     )
     )
+    group_id = DynamicModelMultipleChoiceField(
+        queryset=RackGroup.objects.all(),
+        required=False,
+        null_option='None',
+        label=_('Rack group')
+    )
     rack_id = DynamicModelMultipleChoiceField(
     rack_id = DynamicModelMultipleChoiceField(
         queryset=Rack.objects.all(),
         queryset=Rack.objects.all(),
         required=False,
         required=False,
         query_params={
         query_params={
+            'group_id': '$group_id',
             'site_id': '$site_id',
             'site_id': '$site_id',
             'location_id': '$location_id',
             'location_id': '$location_id',
         },
         },

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

@@ -74,6 +74,7 @@ __all__ = (
     'PowerPortForm',
     'PowerPortForm',
     'PowerPortTemplateForm',
     'PowerPortTemplateForm',
     'RackForm',
     'RackForm',
+    'RackGroupForm',
     'RackReservationForm',
     'RackReservationForm',
     'RackRoleForm',
     'RackRoleForm',
     'RackTypeForm',
     'RackTypeForm',
@@ -206,6 +207,18 @@ class LocationForm(TenancyForm, NestedGroupModelForm):
         )
         )
 
 
 
 
+class RackGroupForm(OrganizationalModelForm):
+    fieldsets = (
+        FieldSet('name', 'slug', 'description', 'tags', name=_('Rack Group')),
+    )
+
+    class Meta:
+        model = RackGroup
+        fields = [
+            'name', 'slug', 'description', 'owner', 'comments', 'tags',
+        ]
+
+
 class RackRoleForm(OrganizationalModelForm):
 class RackRoleForm(OrganizationalModelForm):
     fieldsets = (
     fieldsets = (
         FieldSet('name', 'slug', 'color', 'description', 'tags', name=_('Rack Role')),
         FieldSet('name', 'slug', 'color', 'description', 'tags', name=_('Rack Role')),
@@ -263,6 +276,11 @@ class RackForm(TenancyForm, PrimaryModelForm):
             'site_id': '$site'
             'site_id': '$site'
         }
         }
     )
     )
+    group = DynamicModelChoiceField(
+        label=_('Rack Group'),
+        queryset=RackGroup.objects.all(),
+        required=False
+    )
     role = DynamicModelChoiceField(
     role = DynamicModelChoiceField(
         label=_('Role'),
         label=_('Role'),
         queryset=RackRole.objects.all(),
         queryset=RackRole.objects.all(),
@@ -278,7 +296,7 @@ class RackForm(TenancyForm, PrimaryModelForm):
 
 
     fieldsets = (
     fieldsets = (
         FieldSet(
         FieldSet(
-            'site', 'location', 'name', 'status', 'role', 'rack_type', 'description', 'airflow', 'tags',
+            'site', 'location', 'group', 'name', 'status', 'role', 'rack_type', 'description', 'airflow', 'tags',
             name=_('Rack')
             name=_('Rack')
         ),
         ),
         FieldSet('facility_id', 'serial', 'asset_tag', name=_('Inventory Control')),
         FieldSet('facility_id', 'serial', 'asset_tag', name=_('Inventory Control')),
@@ -288,7 +306,7 @@ class RackForm(TenancyForm, PrimaryModelForm):
     class Meta:
     class Meta:
         model = Rack
         model = Rack
         fields = [
         fields = [
-            'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial',
+            'site', 'location', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial',
             'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width',
             'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width',
             'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight',
             'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight',
             'weight_unit', 'description', 'owner', 'comments', 'tags',
             'weight_unit', 'description', 'owner', 'comments', 'tags',

+ 10 - 0
netbox/dcim/graphql/filters.py

@@ -93,6 +93,7 @@ __all__ = (
     'PowerPortFilter',
     'PowerPortFilter',
     'PowerPortTemplateFilter',
     'PowerPortTemplateFilter',
     'RackFilter',
     'RackFilter',
+    'RackGroupFilter',
     'RackReservationFilter',
     'RackReservationFilter',
     'RackRoleFilter',
     'RackRoleFilter',
     'RackTypeFilter',
     'RackTypeFilter',
@@ -959,6 +960,10 @@ class RackFilter(
     location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
+    group: Annotated['RackGroupFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field()
+    )
+    group_id: ID | None = strawberry_django.filter_field()
     status: BaseFilterLookup[Annotated['RackStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     status: BaseFilterLookup[Annotated['RackStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -974,6 +979,11 @@ class RackFilter(
     )
     )
 
 
 
 
+@strawberry_django.filter_type(models.RackGroup, lookups=True)
+class RackGroupFilter(OrganizationalModelFilter):
+    pass
+
+
 @strawberry_django.filter_type(models.RackReservation, lookups=True)
 @strawberry_django.filter_type(models.RackReservation, lookups=True)
 class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilter):
 class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilter):
     rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()

+ 3 - 0
netbox/dcim/graphql/schema.py

@@ -102,6 +102,9 @@ class DCIMQuery:
     power_port_template: PowerPortTemplateType = strawberry_django.field()
     power_port_template: PowerPortTemplateType = strawberry_django.field()
     power_port_template_list: list[PowerPortTemplateType] = strawberry_django.field()
     power_port_template_list: list[PowerPortTemplateType] = strawberry_django.field()
 
 
+    rack_group: RackGroupType = strawberry_django.field()
+    rack_group_list: list[RackGroupType] = strawberry_django.field()
+
     rack_type: RackTypeType = strawberry_django.field()
     rack_type: RackTypeType = strawberry_django.field()
     rack_type_list: list[RackTypeType] = strawberry_django.field()
     rack_type_list: list[RackTypeType] = strawberry_django.field()
 
 

+ 13 - 0
netbox/dcim/graphql/types.py

@@ -73,6 +73,7 @@ __all__ = (
     'PowerPanelType',
     'PowerPanelType',
     'PowerPortTemplateType',
     'PowerPortTemplateType',
     'PowerPortType',
     'PowerPortType',
+    'RackGroupType',
     'RackReservationType',
     'RackReservationType',
     'RackRoleType',
     'RackRoleType',
     'RackType',
     'RackType',
@@ -736,6 +737,17 @@ class PowerPortTemplateType(ModularComponentTemplateType):
     poweroutlet_templates: list[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]]
     poweroutlet_templates: list[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]]
 
 
 
 
+@strawberry_django.type(
+    models.RackGroup,
+    fields='__all__',
+    filters=RackGroupFilter,
+    pagination=True
+)
+class RackGroupType(OrganizationalObjectType):
+
+    racks: list[Annotated["RackType", strawberry.lazy('dcim.graphql.types')]]
+
+
 @strawberry_django.type(
 @strawberry_django.type(
     models.RackType,
     models.RackType,
     fields='__all__',
     fields='__all__',
@@ -756,6 +768,7 @@ class RackTypeType(ImageAttachmentsMixin, PrimaryObjectType):
 class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, PrimaryObjectType):
 class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, PrimaryObjectType):
     site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]
     site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]
     location: Annotated["LocationType", strawberry.lazy('dcim.graphql.types')] | None
     location: Annotated["LocationType", strawberry.lazy('dcim.graphql.types')] | None
+    group: Annotated["RackGroupType", strawberry.lazy('dcim.graphql.types')] | None
     tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
     tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
     role: Annotated["RackRoleType", strawberry.lazy('dcim.graphql.types')] | None
     role: Annotated["RackRoleType", strawberry.lazy('dcim.graphql.types')] | None
 
 

+ 57 - 0
netbox/dcim/migrations/0227_rack_group.py

@@ -0,0 +1,57 @@
+import django.db.models.deletion
+import taggit.managers
+from django.db import migrations, models
+
+import netbox.models.deletion
+import utilities.json
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('dcim', '0226_modulebay_rebuild_tree'),
+        ('extras', '0134_owner'),
+        ('users', '0015_owner'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='RackGroup',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                (
+                    'custom_field_data',
+                    models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+                ),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('slug', models.SlugField(max_length=100, unique=True)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('comments', models.TextField(blank=True)),
+                (
+                    'owner',
+                    models.ForeignKey(
+                        blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                    ),
+                ),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'verbose_name': 'rack group',
+                'verbose_name_plural': 'rack groups',
+                'ordering': ('name',),
+            },
+            bases=(netbox.models.deletion.DeleteMixin, models.Model),
+        ),
+        migrations.AddField(
+            model_name='rack',
+            name='group',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.PROTECT,
+                related_name='racks',
+                to='dcim.rackgroup',
+            ),
+        ),
+    ]

+ 29 - 1
netbox/dcim/models/racks.py

@@ -29,14 +29,30 @@ from .power import PowerFeed
 
 
 __all__ = (
 __all__ = (
     'Rack',
     'Rack',
+    'RackGroup',
     'RackReservation',
     'RackReservation',
     'RackRole',
     'RackRole',
     'RackType',
     'RackType',
 )
 )
 
 
+#
+# Rack Organization
+#
+
+
+class RackGroup(OrganizationalModel):
+    """
+    Racks can be grouped by physical placement within a Location.
+    """
+
+    class Meta:
+        ordering = ('name',)
+        verbose_name = _('rack group')
+        verbose_name_plural = _('rack groups')
+
 
 
 #
 #
-# Rack Types
+# Rack Base
 #
 #
 
 
 class RackBase(WeightMixin, PrimaryModel):
 class RackBase(WeightMixin, PrimaryModel):
@@ -123,6 +139,10 @@ class RackBase(WeightMixin, PrimaryModel):
         abstract = True
         abstract = True
 
 
 
 
+#
+# Rack Types
+#
+
 class RackType(ImageAttachmentsMixin, RackBase):
 class RackType(ImageAttachmentsMixin, RackBase):
     """
     """
     Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
     Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
@@ -290,6 +310,14 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase):
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
+    group = models.ForeignKey(
+        to='dcim.RackGroup',
+        on_delete=models.PROTECT,
+        related_name='racks',
+        blank=True,
+        null=True,
+        help_text=_('physical grouping')
+    )
     tenant = models.ForeignKey(
     tenant = models.ForeignKey(
         to='tenancy.Tenant',
         to='tenancy.Tenant',
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,

+ 12 - 0
netbox/dcim/search.py

@@ -315,6 +315,18 @@ class RackReservationIndex(SearchIndex):
     display_attrs = ('rack', 'tenant', 'user', 'description')
     display_attrs = ('rack', 'tenant', 'user', 'description')
 
 
 
 
+@register_search
+class RackGroupIndex(SearchIndex):
+    model = models.RackGroup
+    fields = (
+        ('name', 100),
+        ('slug', 110),
+        ('description', 500),
+        ('comments', 5000),
+    )
+    display_attrs = ('description',)
+
+
 @register_search
 @register_search
 class RackRoleIndex(SearchIndex):
 class RackRoleIndex(SearchIndex):
     model = models.RackRole
     model = models.RackRole

+ 39 - 6
netbox/dcim/tables/racks.py

@@ -2,13 +2,14 @@ import django_tables2 as tables
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from django_tables2.utils import Accessor
 from django_tables2.utils import Accessor
 
 
-from dcim.models import Rack, RackReservation, RackRole, RackType
+from dcim.models import Rack, RackGroup, RackReservation, RackRole, RackType
 from netbox.tables import OrganizationalModelTable, PrimaryModelTable, columns
 from netbox.tables import OrganizationalModelTable, PrimaryModelTable, columns
 from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
 from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
 
 
 from .template_code import OUTER_UNIT, WEIGHT
 from .template_code import OUTER_UNIT, WEIGHT
 
 
 __all__ = (
 __all__ = (
+    'RackGroupTable',
     'RackReservationTable',
     'RackReservationTable',
     'RackRoleTable',
     'RackRoleTable',
     'RackTable',
     'RackTable',
@@ -16,6 +17,29 @@ __all__ = (
 )
 )
 
 
 
 
+class RackGroupTable(OrganizationalModelTable):
+    name = tables.Column(
+        verbose_name=_('Name'),
+        linkify=True,
+    )
+    rack_count = columns.LinkedCountColumn(
+        viewname='dcim:rack_list',
+        url_params={'group_id': 'pk'},
+        verbose_name=_('Racks'),
+    )
+    tags = columns.TagColumn(
+        url_name='dcim:rackgroup_list',
+    )
+
+    class Meta(OrganizationalModelTable.Meta):
+        model = RackGroup
+        fields = (
+            'pk', 'id', 'name', 'rack_count', 'description', 'slug', 'comments', 'tags', 'actions', 'created',
+            'last_updated',
+        )
+        default_columns = ('pk', 'name', 'rack_count', 'description')
+
+
 class RackRoleTable(OrganizationalModelTable):
 class RackRoleTable(OrganizationalModelTable):
     name = tables.Column(
     name = tables.Column(
         verbose_name=_('Name'),
         verbose_name=_('Name'),
@@ -111,6 +135,10 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
         verbose_name=_('Site'),
         verbose_name=_('Site'),
         linkify=True
         linkify=True
     )
     )
+    group = tables.Column(
+        verbose_name=_('Group'),
+        linkify=True,
+    )
     status = columns.ChoiceFieldColumn(
     status = columns.ChoiceFieldColumn(
         verbose_name=_('Status'),
         verbose_name=_('Status'),
     )
     )
@@ -172,15 +200,15 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
     class Meta(PrimaryModelTable.Meta):
     class Meta(PrimaryModelTable.Meta):
         model = Rack
         model = Rack
         fields = (
         fields = (
-            'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role',
+            'pk', 'id', 'name', 'site', 'location', 'group', 'status', 'facility_id', 'tenant', 'tenant_group', 'role',
             'rack_type', 'serial', 'asset_tag', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width',
             'rack_type', 'serial', 'asset_tag', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width',
             'outer_height', 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'comments',
             'outer_height', 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'comments',
             'device_count', 'get_utilization', 'get_power_utilization', 'description', 'contacts',
             'device_count', 'get_utilization', 'get_power_utilization', 'description', 'contacts',
             'tags', 'created', 'last_updated',
             'tags', 'created', 'last_updated',
         )
         )
         default_columns = (
         default_columns = (
-            'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'rack_type', 'u_height',
-            'device_count', 'get_utilization',
+            'pk', 'name', 'site', 'location', 'group', 'status', 'facility_id', 'tenant', 'role', 'rack_type',
+            'u_height', 'device_count', 'get_utilization',
         )
         )
 
 
 
 
@@ -200,6 +228,11 @@ class RackReservationTable(TenancyColumnsMixin, PrimaryModelTable):
         accessor=Accessor('rack__location'),
         accessor=Accessor('rack__location'),
         linkify=True
         linkify=True
     )
     )
+    group = tables.Column(
+        verbose_name=_('Group'),
+        accessor=Accessor('rack__group'),
+        linkify=True
+    )
     rack = tables.Column(
     rack = tables.Column(
         verbose_name=_('Rack'),
         verbose_name=_('Rack'),
         linkify=True
         linkify=True
@@ -218,7 +251,7 @@ class RackReservationTable(TenancyColumnsMixin, PrimaryModelTable):
     class Meta(PrimaryModelTable.Meta):
     class Meta(PrimaryModelTable.Meta):
         model = RackReservation
         model = RackReservation
         fields = (
         fields = (
-            'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'status', 'user', 'created', 'tenant',
-            'tenant_group', 'description', 'comments', 'tags', 'actions', 'created', 'last_updated',
+            'pk', 'id', 'reservation', 'site', 'location', 'group', 'rack', 'unit_list', 'status', 'user', 'created',
+            'tenant', 'tenant_group', 'description', 'comments', 'tags', 'actions', 'created', 'last_updated',
         )
         )
         default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'status', 'user', 'description')
         default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'status', 'user', 'description')

+ 44 - 3
netbox/dcim/tests/test_api.py

@@ -280,6 +280,38 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
+class RackGroupTest(APIViewTestCases.APIViewTestCase):
+    model = RackGroup
+    brief_fields = ['description', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
+    create_data = [
+        {
+            'name': 'Rack Group 4',
+            'slug': 'rack-group-4',
+        },
+        {
+            'name': 'Rack Group 5',
+            'slug': 'rack-group-5',
+        },
+        {
+            'name': 'Rack Group 6',
+            'slug': 'rack-group-6',
+        },
+    ]
+    bulk_update_data = {
+        'description': 'New description',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+
+        rack_groups = (
+            RackGroup(name='Rack Group 1', slug='rack-group-1'),
+            RackGroup(name='Rack Group 2', slug='rack-group-2'),
+            RackGroup(name='Rack Group 3', slug='rack-group-3'),
+        )
+        RackGroup.objects.bulk_create(rack_groups)
+
+
 class RackRoleTest(APIViewTestCases.APIViewTestCase):
 class RackRoleTest(APIViewTestCases.APIViewTestCase):
     model = RackRole
     model = RackRole
     brief_fields = ['description', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
@@ -397,6 +429,12 @@ class RackTest(APIViewTestCases.APIViewTestCase):
             Location.objects.create(site=sites[1], name='Location 2', slug='location-2'),
             Location.objects.create(site=sites[1], name='Location 2', slug='location-2'),
         )
         )
 
 
+        rack_groups = (
+            RackGroup(name='Rack Group 1', slug='rack-group-1'),
+            RackGroup(name='Rack Group 2', slug='rack-group-2'),
+        )
+        RackGroup.objects.bulk_create(rack_groups)
+
         rack_roles = (
         rack_roles = (
             RackRole(name='Rack Role 1', slug='rack-role-1', color='ff0000'),
             RackRole(name='Rack Role 1', slug='rack-role-1', color='ff0000'),
             RackRole(name='Rack Role 2', slug='rack-role-2', color='00ff00'),
             RackRole(name='Rack Role 2', slug='rack-role-2', color='00ff00'),
@@ -404,9 +442,9 @@ class RackTest(APIViewTestCases.APIViewTestCase):
         RackRole.objects.bulk_create(rack_roles)
         RackRole.objects.bulk_create(rack_roles)
 
 
         racks = (
         racks = (
-            Rack(site=sites[0], location=locations[0], role=rack_roles[0], name='Rack 1'),
-            Rack(site=sites[0], location=locations[0], role=rack_roles[0], name='Rack 2'),
-            Rack(site=sites[0], location=locations[0], role=rack_roles[0], name='Rack 3'),
+            Rack(site=sites[0], location=locations[0], group=rack_groups[0], role=rack_roles[0], name='Rack 1'),
+            Rack(site=sites[0], location=locations[0], group=rack_groups[0], role=rack_roles[0], name='Rack 2'),
+            Rack(site=sites[0], location=locations[0], group=rack_groups[0], role=rack_roles[0], name='Rack 3'),
         )
         )
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
@@ -415,18 +453,21 @@ class RackTest(APIViewTestCases.APIViewTestCase):
                 'name': 'Test Rack 4',
                 'name': 'Test Rack 4',
                 'site': sites[1].pk,
                 'site': sites[1].pk,
                 'location': locations[1].pk,
                 'location': locations[1].pk,
+                'group': rack_groups[1].pk,
                 'role': rack_roles[1].pk,
                 'role': rack_roles[1].pk,
             },
             },
             {
             {
                 'name': 'Test Rack 5',
                 'name': 'Test Rack 5',
                 'site': sites[1].pk,
                 'site': sites[1].pk,
                 'location': locations[1].pk,
                 'location': locations[1].pk,
+                'group': rack_groups[1].pk,
                 'role': rack_roles[1].pk,
                 'role': rack_roles[1].pk,
             },
             },
             {
             {
                 'name': 'Test Rack 6',
                 'name': 'Test Rack 6',
                 'site': sites[1].pk,
                 'site': sites[1].pk,
                 'location': locations[1].pk,
                 'location': locations[1].pk,
+                'group': rack_groups[1].pk,
                 'role': rack_roles[1].pk,
                 'role': rack_roles[1].pk,
             },
             },
         ]
         ]

+ 77 - 15
netbox/dcim/tests/test_filtersets.py

@@ -534,6 +534,37 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
 
 
+class RackGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = RackGroup.objects.all()
+    filterset = RackGroupFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+
+        rack_groups = (
+            RackGroup(name='Rack Group 1', slug='rack-group-1', description='foobar1'),
+            RackGroup(name='Rack Group 2', slug='rack-group-2', description='foobar2'),
+            RackGroup(name='Rack Group 3', slug='rack-group-3'),
+        )
+        RackGroup.objects.bulk_create(rack_groups)
+
+    def test_q(self):
+        params = {'q': 'foobar1'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_name(self):
+        params = {'name': ['Rack Group 1', 'Rack Group 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_slug(self):
+        params = {'slug': ['rack-group-1', 'rack-group-2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
 class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
 class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = RackRole.objects.all()
     queryset = RackRole.objects.all()
     filterset = RackRoleFilterSet
     filterset = RackRoleFilterSet
@@ -738,18 +769,18 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
         for region in regions:
         for region in regions:
             region.save()
             region.save()
 
 
-        groups = (
+        site_groups = (
             SiteGroup(name='Site Group 1', slug='site-group-1'),
             SiteGroup(name='Site Group 1', slug='site-group-1'),
             SiteGroup(name='Site Group 2', slug='site-group-2'),
             SiteGroup(name='Site Group 2', slug='site-group-2'),
             SiteGroup(name='Site Group 3', slug='site-group-3'),
             SiteGroup(name='Site Group 3', slug='site-group-3'),
         )
         )
-        for group in groups:
-            group.save()
+        for site_group in site_groups:
+            site_group.save()
 
 
         sites = (
         sites = (
-            Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
-            Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
-            Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
+            Site(name='Site 1', slug='site-1', region=regions[0], group=site_groups[0]),
+            Site(name='Site 2', slug='site-2', region=regions[1], group=site_groups[1]),
+            Site(name='Site 3', slug='site-3', region=regions[2], group=site_groups[2]),
         )
         )
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
@@ -810,6 +841,13 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         )
         RackType.objects.bulk_create(rack_types)
         RackType.objects.bulk_create(rack_types)
 
 
+        rack_groups = (
+            RackGroup(name='Rack Group 1', slug='rack-group-1'),
+            RackGroup(name='Rack Group 2', slug='rack-group-2'),
+            RackGroup(name='Rack Group 3', slug='rack-group-3'),
+        )
+        RackGroup.objects.bulk_create(rack_groups)
+
         rack_roles = (
         rack_roles = (
             RackRole(name='Rack Role 1', slug='rack-role-1'),
             RackRole(name='Rack Role 1', slug='rack-role-1'),
             RackRole(name='Rack Role 2', slug='rack-role-2'),
             RackRole(name='Rack Role 2', slug='rack-role-2'),
@@ -838,6 +876,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
                 facility_id='rack-1',
                 facility_id='rack-1',
                 site=sites[0],
                 site=sites[0],
                 location=locations[0],
                 location=locations[0],
+                group=rack_groups[0],
                 tenant=tenants[0],
                 tenant=tenants[0],
                 status=RackStatusChoices.STATUS_ACTIVE,
                 status=RackStatusChoices.STATUS_ACTIVE,
                 role=rack_roles[0],
                 role=rack_roles[0],
@@ -862,6 +901,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
                 facility_id='rack-2',
                 facility_id='rack-2',
                 site=sites[1],
                 site=sites[1],
                 location=locations[1],
                 location=locations[1],
+                group=rack_groups[1],
                 tenant=tenants[1],
                 tenant=tenants[1],
                 status=RackStatusChoices.STATUS_PLANNED,
                 status=RackStatusChoices.STATUS_PLANNED,
                 role=rack_roles[1],
                 role=rack_roles[1],
@@ -886,6 +926,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
                 facility_id='rack-3',
                 facility_id='rack-3',
                 site=sites[2],
                 site=sites[2],
                 location=locations[2],
                 location=locations[2],
+                group=rack_groups[2],
                 tenant=tenants[2],
                 tenant=tenants[2],
                 status=RackStatusChoices.STATUS_RESERVED,
                 status=RackStatusChoices.STATUS_RESERVED,
                 role=rack_roles[2],
                 role=rack_roles[2],
@@ -1017,6 +1058,13 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'location': [locations[0].slug, locations[1].slug]}
         params = {'location': [locations[0].slug, locations[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_rack_group(self):
+        rack_groups = RackGroup.objects.all()[:2]
+        params = {'group_id': [rack_groups[0].pk, rack_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'group': [rack_groups[0].slug, rack_groups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_status(self):
     def test_status(self):
         params = {'status': [RackStatusChoices.STATUS_ACTIVE, RackStatusChoices.STATUS_PLANNED]}
         params = {'status': [RackStatusChoices.STATUS_ACTIVE, RackStatusChoices.STATUS_PLANNED]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
@@ -1095,18 +1143,18 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
         for region in regions:
         for region in regions:
             region.save()
             region.save()
 
 
-        groups = (
+        site_groups = (
             SiteGroup(name='Site Group 1', slug='site-group-1'),
             SiteGroup(name='Site Group 1', slug='site-group-1'),
             SiteGroup(name='Site Group 2', slug='site-group-2'),
             SiteGroup(name='Site Group 2', slug='site-group-2'),
             SiteGroup(name='Site Group 3', slug='site-group-3'),
             SiteGroup(name='Site Group 3', slug='site-group-3'),
         )
         )
-        for group in groups:
-            group.save()
+        for site_group in site_groups:
+            site_group.save()
 
 
         sites = (
         sites = (
-            Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
-            Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
-            Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
+            Site(name='Site 1', slug='site-1', region=regions[0], group=site_groups[0]),
+            Site(name='Site 2', slug='site-2', region=regions[1], group=site_groups[1]),
+            Site(name='Site 3', slug='site-3', region=regions[2], group=site_groups[2]),
         )
         )
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
@@ -1118,10 +1166,17 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
         for location in locations:
         for location in locations:
             location.save()
             location.save()
 
 
+        rack_groups = (
+            RackGroup(name='Rack Group 1', slug='rack-group-1'),
+            RackGroup(name='Rack Group 2', slug='rack-group-2'),
+            RackGroup(name='Rack Group 3', slug='rack-group-3'),
+        )
+        RackGroup.objects.bulk_create(rack_groups)
+
         racks = (
         racks = (
-            Rack(name='Rack 1', site=sites[0], location=locations[0]),
-            Rack(name='Rack 2', site=sites[1], location=locations[1]),
-            Rack(name='Rack 3', site=sites[2], location=locations[2]),
+            Rack(name='Rack 1', site=sites[0], location=locations[0], group=rack_groups[0]),
+            Rack(name='Rack 2', site=sites[1], location=locations[1], group=rack_groups[1]),
+            Rack(name='Rack 3', site=sites[2], location=locations[2], group=rack_groups[2]),
         )
         )
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
@@ -1207,6 +1262,13 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'location': [locations[0].slug, locations[1].slug]}
         params = {'location': [locations[0].slug, locations[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_rack_group(self):
+        rack_groups = RackGroup.objects.all()[:2]
+        params = {'group_id': [rack_groups[0].pk, rack_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'group': [rack_groups[0].slug, rack_groups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_status(self):
     def test_status(self):
         params = {'status': [RackReservationStatusChoices.STATUS_ACTIVE, RackReservationStatusChoices.STATUS_PENDING]}
         params = {'status': [RackReservationStatusChoices.STATUS_ACTIVE, RackReservationStatusChoices.STATUS_PENDING]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 55 - 6
netbox/dcim/tests/test_views.py

@@ -267,6 +267,47 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
         }
         }
 
 
 
 
+class RackGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
+    model = RackGroup
+
+    @classmethod
+    def setUpTestData(cls):
+
+        rack_groups = (
+            RackGroup(name='Rack Group 1', slug='rack-group-1'),
+            RackGroup(name='Rack Group 2', slug='rack-group-2'),
+            RackGroup(name='Rack Group 3', slug='rack-group-3'),
+        )
+        RackGroup.objects.bulk_create(rack_groups)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'name': 'Rack Group X',
+            'slug': 'rack-group-x',
+            'description': 'New group',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            "name,slug,description",
+            "Rack Group 4,rack-group-4,Fourth group",
+            "Rack Group 5,rack-group-5,Fifth group",
+            "Rack Group 6,rack-group-6,",
+        )
+
+        cls.csv_update_data = (
+            "id,name,description",
+            f"{rack_groups[0].pk},Rack Group 7,New description7",
+            f"{rack_groups[1].pk},Rack Group 8,New description8",
+            f"{rack_groups[2].pk},Rack Group 9,New description9",
+        )
+
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
+
 class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
 class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = RackRole
     model = RackRole
 
 
@@ -472,6 +513,12 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         for location in locations:
         for location in locations:
             location.save()
             location.save()
 
 
+        rack_groups = (
+            RackGroup(name='Rack Group 1', slug='rack-group-1'),
+            RackGroup(name='Rack Group 2', slug='rack-group-2'),
+        )
+        RackGroup.objects.bulk_create(rack_groups)
+
         rackroles = (
         rackroles = (
             RackRole(name='Rack Role 1', slug='rack-role-1'),
             RackRole(name='Rack Role 1', slug='rack-role-1'),
             RackRole(name='Rack Role 2', slug='rack-role-2'),
             RackRole(name='Rack Role 2', slug='rack-role-2'),
@@ -479,8 +526,8 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         RackRole.objects.bulk_create(rackroles)
         RackRole.objects.bulk_create(rackroles)
 
 
         racks = (
         racks = (
-            Rack(name='Rack 1', site=sites[0]),
-            Rack(name='Rack 2', site=sites[0]),
+            Rack(name='Rack 1', site=sites[0], group=rack_groups[0], role=rackroles[0]),
+            Rack(name='Rack 2', site=sites[0], group=rack_groups[1]),
             Rack(name='Rack 3', site=sites[0]),
             Rack(name='Rack 3', site=sites[0]),
         )
         )
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
@@ -492,6 +539,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'facility_id': 'Facility X',
             'facility_id': 'Facility X',
             'site': sites[1].pk,
             'site': sites[1].pk,
             'location': locations[1].pk,
             'location': locations[1].pk,
+            'group': rack_groups[1].pk,
             'tenant': None,
             'tenant': None,
             'status': RackStatusChoices.STATUS_PLANNED,
             'status': RackStatusChoices.STATUS_PLANNED,
             'role': rackroles[1].pk,
             'role': rackroles[1].pk,
@@ -513,10 +561,10 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
-            "site,location,name,status,width,u_height,weight,max_weight,weight_unit",
-            "Site 1,,Rack 4,active,19,42,100,2000,kg",
-            "Site 1,Location 1,Rack 5,active,19,42,100,2000,kg",
-            "Site 2,Location 2,Rack 6,active,19,42,100,2000,kg",
+            "site,location,group,name,status,width,u_height,weight,max_weight,weight_unit",
+            "Site 1,,,Rack 4,active,19,42,100,2000,kg",
+            "Site 1,Location 1,Rack Group 1,Rack 5,active,19,42,100,2000,kg",
+            "Site 2,Location 2,Rack Group 2,Rack 6,active,19,42,100,2000,kg",
         )
         )
 
 
         cls.csv_update_data = (
         cls.csv_update_data = (
@@ -529,6 +577,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
             'site': sites[1].pk,
             'site': sites[1].pk,
             'location': locations[1].pk,
             'location': locations[1].pk,
+            'group': rack_groups[1].pk,
             'tenant': None,
             'tenant': None,
             'status': RackStatusChoices.STATUS_DEPRECATED,
             'status': RackStatusChoices.STATUS_DEPRECATED,
             'role': rackroles[1].pk,
             'role': rackroles[1].pk,

+ 1 - 0
netbox/dcim/ui/panels.py

@@ -43,6 +43,7 @@ class RackPanel(panels.ObjectAttributesPanel):
     region = attrs.NestedObjectAttr('site.region', linkify=True)
     region = attrs.NestedObjectAttr('site.region', linkify=True)
     site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group')
     site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group')
     location = attrs.NestedObjectAttr('location', linkify=True)
     location = attrs.NestedObjectAttr('location', linkify=True)
+    group = attrs.RelatedObjectAttr('group', linkify=True, label=_('Rack group'))
     name = attrs.TextAttr('name')
     name = attrs.TextAttr('name')
     facility_id = attrs.TextAttr('facility_id', label=_('Facility ID'))
     facility_id = attrs.TextAttr('facility_id', label=_('Facility ID'))
     tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
     tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')

+ 3 - 0
netbox/dcim/urls.py

@@ -19,6 +19,9 @@ urlpatterns = [
     path('locations/', include(get_model_urls('dcim', 'location', detail=False))),
     path('locations/', include(get_model_urls('dcim', 'location', detail=False))),
     path('locations/<int:pk>/', include(get_model_urls('dcim', 'location'))),
     path('locations/<int:pk>/', include(get_model_urls('dcim', 'location'))),
 
 
+    path('rack-groups/', include(get_model_urls('dcim', 'rackgroup', detail=False))),
+    path('rack-groups/<int:pk>/', include(get_model_urls('dcim', 'rackgroup'))),
+
     path('rack-roles/', include(get_model_urls('dcim', 'rackrole', detail=False))),
     path('rack-roles/', include(get_model_urls('dcim', 'rackrole', detail=False))),
     path('rack-roles/<int:pk>/', include(get_model_urls('dcim', 'rackrole'))),
     path('rack-roles/<int:pk>/', include(get_model_urls('dcim', 'rackrole'))),
 
 

+ 80 - 1
netbox/dcim/views.py

@@ -785,6 +785,85 @@ class LocationBulkDeleteView(generic.BulkDeleteView):
     table = tables.LocationTable
     table = tables.LocationTable
 
 
 
 
+#
+# Rack groups
+#
+
+
+@register_model_view(RackGroup, 'list', path='', detail=False)
+class RackGroupListView(generic.ObjectListView):
+    queryset = RackGroup.objects.annotate(
+        rack_count=count_related(Rack, 'group')
+    )
+    filterset = filtersets.RackGroupFilterSet
+    filterset_form = forms.RackGroupFilterForm
+    table = tables.RackGroupTable
+
+
+@register_model_view(RackGroup)
+class RackGroupView(GetRelatedModelsMixin, generic.ObjectView):
+    queryset = RackGroup.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            OrganizationalObjectPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+            CustomFieldsPanel(),
+            CommentsPanel(),
+        ],
+    )
+
+    def get_extra_context(self, request, instance):
+        return {
+            'related_models': self.get_related_models(request, instance),
+        }
+
+
+@register_model_view(RackGroup, 'add', detail=False)
+@register_model_view(RackGroup, 'edit')
+class RackGroupEditView(generic.ObjectEditView):
+    queryset = RackGroup.objects.all()
+    form = forms.RackGroupForm
+
+
+@register_model_view(RackGroup, 'delete')
+class RackGroupDeleteView(generic.ObjectDeleteView):
+    queryset = RackGroup.objects.all()
+
+
+@register_model_view(RackGroup, 'bulk_import', path='import', detail=False)
+class RackGroupBulkImportView(generic.BulkImportView):
+    queryset = RackGroup.objects.all()
+    model_form = forms.RackGroupImportForm
+
+
+@register_model_view(RackGroup, 'bulk_edit', path='edit', detail=False)
+class RackGroupBulkEditView(generic.BulkEditView):
+    queryset = RackGroup.objects.annotate(
+        rack_count=count_related(Rack, 'group')
+    )
+    filterset = filtersets.RackGroupFilterSet
+    table = tables.RackGroupTable
+    form = forms.RackGroupBulkEditForm
+
+
+@register_model_view(RackGroup, 'bulk_rename', path='rename', detail=False)
+class RackGroupBulkRenameView(generic.BulkRenameView):
+    queryset = RackGroup.objects.all()
+    filterset = filtersets.RackGroupFilterSet
+
+
+@register_model_view(RackGroup, 'bulk_delete', path='delete', detail=False)
+class RackGroupBulkDeleteView(generic.BulkDeleteView):
+    queryset = RackGroup.objects.annotate(
+        rack_count=count_related(Rack, 'group')
+    )
+    filterset = filtersets.RackGroupFilterSet
+    table = tables.RackGroupTable
+
+
 #
 #
 # Rack roles
 # Rack roles
 #
 #
@@ -1160,7 +1239,7 @@ class RackReservationView(generic.ObjectView):
     queryset = RackReservation.objects.all()
     queryset = RackReservation.objects.all()
     layout = layout.SimpleLayout(
     layout = layout.SimpleLayout(
         left_panels=[
         left_panels=[
-            panels.RackPanel(accessor='object.rack', only=['region', 'site', 'location', 'name']),
+            panels.RackPanel(accessor='object.rack', only=['region', 'site', 'location', 'group', 'name']),
             panels.RackReservationPanel(title=_('Reservation')),
             panels.RackReservationPanel(title=_('Reservation')),
             CustomFieldsPanel(),
             CustomFieldsPanel(),
             TagsPanel(),
             TagsPanel(),

+ 1 - 0
netbox/extras/tests/test_filtersets.py

@@ -1279,6 +1279,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
         'provideraccount',
         'provideraccount',
         'providernetwork',
         'providernetwork',
         'rack',
         'rack',
+        'rackgroup',
         'rackreservation',
         'rackreservation',
         'rackrole',
         'rackrole',
         'racktype',
         'racktype',

+ 1 - 1
netbox/ipam/constants.py

@@ -74,7 +74,7 @@ VLAN_VID_MAX = 4094
 
 
 # models values for ContentTypes which may be VLANGroup scope types
 # models values for ContentTypes which may be VLANGroup scope types
 VLANGROUP_SCOPE_TYPES = (
 VLANGROUP_SCOPE_TYPES = (
-    'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster',
+    'region', 'sitegroup', 'site', 'location', 'rackgroup', 'rack', 'clustergroup', 'cluster',
 )
 )
 
 
 
 

+ 3 - 0
netbox/ipam/filtersets.py

@@ -958,6 +958,9 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
     location = django_filters.NumberFilter(
     location = django_filters.NumberFilter(
         method='filter_scope'
         method='filter_scope'
     )
     )
+    rack_group = django_filters.NumberFilter(
+        method='filter_scope'
+    )
     rack = django_filters.NumberFilter(
     rack = django_filters.NumberFilter(
         method='filter_scope'
         method='filter_scope'
     )
     )

+ 7 - 2
netbox/ipam/forms/filtersets.py

@@ -1,7 +1,7 @@
 from django import forms
 from django import forms
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
-from dcim.models import Device, Location, Rack, Region, Site, SiteGroup
+from dcim.models import Device, Location, Rack, RackGroup, Region, Site, SiteGroup
 from ipam.choices import *
 from ipam.choices import *
 from ipam.constants import *
 from ipam.constants import *
 from ipam.models import *
 from ipam.models import *
@@ -458,7 +458,7 @@ class FHRPGroupFilterForm(PrimaryModelFilterSetForm):
 class VLANGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
 class VLANGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('q', 'filter_id', 'tag'),
-        FieldSet('region', 'site_group', 'site', 'location', 'rack', name=_('Location')),
+        FieldSet('region', 'site_group', 'site', 'location', 'rack_group', 'rack', name=_('Location')),
         FieldSet('cluster_group', 'cluster', name=_('Cluster')),
         FieldSet('cluster_group', 'cluster', name=_('Cluster')),
         FieldSet('contains_vid', name=_('VLANs')),
         FieldSet('contains_vid', name=_('VLANs')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
@@ -485,6 +485,11 @@ class VLANGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
         required=False,
         required=False,
         label=_('Location')
         label=_('Location')
     )
     )
+    rack_group = DynamicModelMultipleChoiceField(
+        queryset=RackGroup.objects.all(),
+        required=False,
+        label=_('Rack group')
+    )
     rack = DynamicModelMultipleChoiceField(
     rack = DynamicModelMultipleChoiceField(
         queryset=Rack.objects.all(),
         queryset=Rack.objects.all(),
         required=False,
         required=False,

+ 7 - 1
netbox/netbox/navigation/menu.py

@@ -50,7 +50,6 @@ RACKS_MENU = Menu(
             label=_('Racks'),
             label=_('Racks'),
             items=(
             items=(
                 get_model_item('dcim', 'rack', _('Racks')),
                 get_model_item('dcim', 'rack', _('Racks')),
-                get_model_item('dcim', 'rackrole', _('Rack Roles')),
                 get_model_item('dcim', 'rackreservation', _('Reservations')),
                 get_model_item('dcim', 'rackreservation', _('Reservations')),
                 MenuItem(
                 MenuItem(
                     link='dcim:rack_elevation_list',
                     link='dcim:rack_elevation_list',
@@ -59,6 +58,13 @@ RACKS_MENU = Menu(
                 ),
                 ),
             ),
             ),
         ),
         ),
+        MenuGroup(
+            label=_('Rack Organization'),
+            items=(
+                get_model_item('dcim', 'rackgroup', _('Rack Groups')),
+                get_model_item('dcim', 'rackrole', _('Rack Roles')),
+            ),
+        ),
         MenuGroup(
         MenuGroup(
             label=_('Rack Types'),
             label=_('Rack Types'),
             items=(
             items=(

+ 10 - 0
netbox/templates/dcim/rackgroup.html

@@ -0,0 +1,10 @@
+{% extends 'generic/object.html' %}
+{% load i18n %}
+
+{% block extra_controls %}
+  {% if perms.dcim.add_rack %}
+    <a href="{% url 'dcim:rack_add' %}?group={{ object.pk }}" class="btn btn-primary">
+      <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Rack" %}
+    </a>
+  {% endif %}
+{% endblock extra_controls %}