Просмотр исходного кода

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

Fixes #20961
Martin Hauser 7 часов назад
Родитель
Сommit
e2665ef211

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

@@ -1,6 +1,6 @@
 # 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.
 
@@ -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).
 
+### Rack Group
+
+The [group](./rackgroup.md) used to organize racks by physical placement (optional).
+
 ### Name
 
 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'
             - PowerPortTemplate: 'models/dcim/powerporttemplate.md'
             - Rack: 'models/dcim/rack.md'
+            - RackGroup: 'models/dcim/rackgroup.md'
             - RackReservation: 'models/dcim/rackreservation.md'
             - RackRole: 'models/dcim/rackrole.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.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.serializers import OrganizationalModelSerializer, PrimaryModelSerializer
 from netbox.choices import *
@@ -16,6 +16,7 @@ from .sites import LocationSerializer, SiteSerializer
 
 __all__ = (
     'RackElevationDetailFilterSerializer',
+    'RackGroupSerializer',
     'RackReservationSerializer',
     'RackRoleSerializer',
     '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):
 
     # Related object counts
@@ -87,6 +102,11 @@ class RackSerializer(RackBaseSerializer):
         allow_null=True,
         default=None
     )
+    group = RackGroupSerializer(
+        nested=True,
+        required=False,
+        allow_null=True
+    )
     tenant = TenantSerializer(
         nested=True,
         required=False,
@@ -127,11 +147,11 @@ class RackSerializer(RackBaseSerializer):
     class Meta:
         model = Rack
         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')
 

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

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

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

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

+ 34 - 0
netbox/dcim/filtersets.py

@@ -85,6 +85,7 @@ __all__ = (
     'PowerPortFilterSet',
     'PowerPortTemplateFilterSet',
     'RackFilterSet',
+    'RackGroupFilterSet',
     'RackReservationFilterSet',
     'RackRoleFilterSet',
     'RackTypeFilterSet',
@@ -315,6 +316,14 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupMode
         return queryset
 
 
+@register_filterset
+class RackGroupFilterSet(OrganizationalModelFilterSet):
+
+    class Meta:
+        model = RackGroup
+        fields = ('id', 'name', 'slug', 'description')
+
+
 @register_filterset
 class RackRoleFilterSet(OrganizationalModelFilterSet):
 
@@ -419,6 +428,18 @@ class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterS
         to_field_name='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(
         field_name='rack_type__manufacturer',
         queryset=Manufacturer.objects.all(),
@@ -553,6 +574,19 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
         to_field_name='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(
         choices=RackReservationStatusChoices,
         distinct=False,

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

@@ -61,6 +61,7 @@ __all__ = (
     'PowerPortBulkEditForm',
     'PowerPortTemplateBulkEditForm',
     'RackBulkEditForm',
+    'RackGroupBulkEditForm',
     'RackReservationBulkEditForm',
     'RackRoleBulkEditForm',
     'RackTypeBulkEditForm',
@@ -201,6 +202,14 @@ class LocationBulkEditForm(NestedGroupModelBulkEditForm):
     nullable_fields = ('parent', 'tenant', 'facility', 'description', 'comments')
 
 
+class RackGroupBulkEditForm(OrganizationalModelBulkEditForm):
+    model = RackGroup
+    fieldsets = (
+        FieldSet('description'),
+    )
+    nullable_fields = ('description', 'comments')
+
+
 class RackRoleBulkEditForm(OrganizationalModelBulkEditForm):
     color = ColorField(
         label=_('Color'),
@@ -336,6 +345,11 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
             'site_id': '$site'
         }
     )
+    group = DynamicModelChoiceField(
+        label=_('Group'),
+        queryset=RackGroup.objects.all(),
+        required=False
+    )
     tenant = DynamicModelChoiceField(
         label=_('Tenant'),
         queryset=Tenant.objects.all(),
@@ -435,14 +449,16 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
 
     model = Rack
     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('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('weight', 'max_weight', 'weight_unit', name=_('Weight')),
     )
     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',
     )
 

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

@@ -57,6 +57,7 @@ __all__ = (
     'PowerOutletImportForm',
     'PowerPanelImportForm',
     'PowerPortImportForm',
+    'RackGroupImportForm',
     'RackImportForm',
     'RackReservationImportForm',
     'RackRoleImportForm',
@@ -187,6 +188,13 @@ class LocationImportForm(NestedGroupModelImportForm):
             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 Meta:
@@ -261,6 +269,13 @@ class RackImportForm(PrimaryModelImportForm):
         to_field_name='name',
         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(
         label=_('Status'),
         choices=RackStatusChoices,
@@ -318,10 +333,10 @@ class RackImportForm(PrimaryModelImportForm):
     class Meta:
         model = Rack
         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):

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

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

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

@@ -74,6 +74,7 @@ __all__ = (
     'PowerPortForm',
     'PowerPortTemplateForm',
     'RackForm',
+    'RackGroupForm',
     'RackReservationForm',
     'RackRoleForm',
     '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):
     fieldsets = (
         FieldSet('name', 'slug', 'color', 'description', 'tags', name=_('Rack Role')),
@@ -263,6 +276,11 @@ class RackForm(TenancyForm, PrimaryModelForm):
             'site_id': '$site'
         }
     )
+    group = DynamicModelChoiceField(
+        label=_('Rack Group'),
+        queryset=RackGroup.objects.all(),
+        required=False
+    )
     role = DynamicModelChoiceField(
         label=_('Role'),
         queryset=RackRole.objects.all(),
@@ -278,7 +296,7 @@ class RackForm(TenancyForm, PrimaryModelForm):
 
     fieldsets = (
         FieldSet(
-            'site', 'location', 'name', 'status', 'role', 'rack_type', 'description', 'airflow', 'tags',
+            'site', 'location', 'group', 'name', 'status', 'role', 'rack_type', 'description', 'airflow', 'tags',
             name=_('Rack')
         ),
         FieldSet('facility_id', 'serial', 'asset_tag', name=_('Inventory Control')),
@@ -288,7 +306,7 @@ class RackForm(TenancyForm, PrimaryModelForm):
     class Meta:
         model = Rack
         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',
             'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight',
             'weight_unit', 'description', 'owner', 'comments', 'tags',

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

@@ -93,6 +93,7 @@ __all__ = (
     'PowerPortFilter',
     'PowerPortTemplateFilter',
     'RackFilter',
+    'RackGroupFilter',
     'RackReservationFilter',
     'RackRoleFilter',
     'RackTypeFilter',
@@ -959,6 +960,10 @@ class RackFilter(
     location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         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 = (
         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)
 class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilter):
     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_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_list: list[RackTypeType] = strawberry_django.field()
 

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

@@ -73,6 +73,7 @@ __all__ = (
     'PowerPanelType',
     'PowerPortTemplateType',
     'PowerPortType',
+    'RackGroupType',
     'RackReservationType',
     'RackRoleType',
     'RackType',
@@ -736,6 +737,17 @@ class PowerPortTemplateType(ModularComponentTemplateType):
     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(
     models.RackType,
     fields='__all__',
@@ -756,6 +768,7 @@ class RackTypeType(ImageAttachmentsMixin, PrimaryObjectType):
 class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, PrimaryObjectType):
     site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]
     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
     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__ = (
     'Rack',
+    'RackGroup',
     'RackReservation',
     'RackRole',
     '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):
@@ -123,6 +139,10 @@ class RackBase(WeightMixin, PrimaryModel):
         abstract = True
 
 
+#
+# Rack Types
+#
+
 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.
@@ -290,6 +310,14 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase):
         blank=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(
         to='tenancy.Tenant',
         on_delete=models.PROTECT,

+ 12 - 0
netbox/dcim/search.py

@@ -315,6 +315,18 @@ class RackReservationIndex(SearchIndex):
     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
 class RackRoleIndex(SearchIndex):
     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_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 tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
 
 from .template_code import OUTER_UNIT, WEIGHT
 
 __all__ = (
+    'RackGroupTable',
     'RackReservationTable',
     'RackRoleTable',
     '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):
     name = tables.Column(
         verbose_name=_('Name'),
@@ -111,6 +135,10 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
         verbose_name=_('Site'),
         linkify=True
     )
+    group = tables.Column(
+        verbose_name=_('Group'),
+        linkify=True,
+    )
     status = columns.ChoiceFieldColumn(
         verbose_name=_('Status'),
     )
@@ -172,15 +200,15 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
     class Meta(PrimaryModelTable.Meta):
         model = Rack
         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',
             'outer_height', 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'comments',
             'device_count', 'get_utilization', 'get_power_utilization', 'description', 'contacts',
             'tags', 'created', 'last_updated',
         )
         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'),
         linkify=True
     )
+    group = tables.Column(
+        verbose_name=_('Group'),
+        accessor=Accessor('rack__group'),
+        linkify=True
+    )
     rack = tables.Column(
         verbose_name=_('Rack'),
         linkify=True
@@ -218,7 +251,7 @@ class RackReservationTable(TenancyColumnsMixin, PrimaryModelTable):
     class Meta(PrimaryModelTable.Meta):
         model = RackReservation
         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')

+ 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):
     model = RackRole
     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'),
         )
 
+        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 = (
             RackRole(name='Rack Role 1', slug='rack-role-1', color='ff0000'),
             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)
 
         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)
 
@@ -415,18 +453,21 @@ class RackTest(APIViewTestCases.APIViewTestCase):
                 'name': 'Test Rack 4',
                 'site': sites[1].pk,
                 'location': locations[1].pk,
+                'group': rack_groups[1].pk,
                 'role': rack_roles[1].pk,
             },
             {
                 'name': 'Test Rack 5',
                 'site': sites[1].pk,
                 'location': locations[1].pk,
+                'group': rack_groups[1].pk,
                 'role': rack_roles[1].pk,
             },
             {
                 'name': 'Test Rack 6',
                 'site': sites[1].pk,
                 'location': locations[1].pk,
+                'group': rack_groups[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)
 
 
+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):
     queryset = RackRole.objects.all()
     filterset = RackRoleFilterSet
@@ -738,18 +769,18 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
         for region in regions:
             region.save()
 
-        groups = (
+        site_groups = (
             SiteGroup(name='Site Group 1', slug='site-group-1'),
             SiteGroup(name='Site Group 2', slug='site-group-2'),
             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 = (
-            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)
 
@@ -810,6 +841,13 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         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 = (
             RackRole(name='Rack Role 1', slug='rack-role-1'),
             RackRole(name='Rack Role 2', slug='rack-role-2'),
@@ -838,6 +876,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
                 facility_id='rack-1',
                 site=sites[0],
                 location=locations[0],
+                group=rack_groups[0],
                 tenant=tenants[0],
                 status=RackStatusChoices.STATUS_ACTIVE,
                 role=rack_roles[0],
@@ -862,6 +901,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
                 facility_id='rack-2',
                 site=sites[1],
                 location=locations[1],
+                group=rack_groups[1],
                 tenant=tenants[1],
                 status=RackStatusChoices.STATUS_PLANNED,
                 role=rack_roles[1],
@@ -886,6 +926,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
                 facility_id='rack-3',
                 site=sites[2],
                 location=locations[2],
+                group=rack_groups[2],
                 tenant=tenants[2],
                 status=RackStatusChoices.STATUS_RESERVED,
                 role=rack_roles[2],
@@ -1017,6 +1058,13 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'location': [locations[0].slug, locations[1].slug]}
         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):
         params = {'status': [RackStatusChoices.STATUS_ACTIVE, RackStatusChoices.STATUS_PLANNED]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
@@ -1095,18 +1143,18 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
         for region in regions:
             region.save()
 
-        groups = (
+        site_groups = (
             SiteGroup(name='Site Group 1', slug='site-group-1'),
             SiteGroup(name='Site Group 2', slug='site-group-2'),
             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 = (
-            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)
 
@@ -1118,10 +1166,17 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
         for location in locations:
             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 = (
-            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)
 
@@ -1207,6 +1262,13 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'location': [locations[0].slug, locations[1].slug]}
         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):
         params = {'status': [RackReservationStatusChoices.STATUS_ACTIVE, RackReservationStatusChoices.STATUS_PENDING]}
         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):
     model = RackRole
 
@@ -472,6 +513,12 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         for location in locations:
             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 = (
             RackRole(name='Rack Role 1', slug='rack-role-1'),
             RackRole(name='Rack Role 2', slug='rack-role-2'),
@@ -479,8 +526,8 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         RackRole.objects.bulk_create(rackroles)
 
         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.objects.bulk_create(racks)
@@ -492,6 +539,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'facility_id': 'Facility X',
             'site': sites[1].pk,
             'location': locations[1].pk,
+            'group': rack_groups[1].pk,
             'tenant': None,
             'status': RackStatusChoices.STATUS_PLANNED,
             'role': rackroles[1].pk,
@@ -513,10 +561,10 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
         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 = (
@@ -529,6 +577,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         cls.bulk_edit_data = {
             'site': sites[1].pk,
             'location': locations[1].pk,
+            'group': rack_groups[1].pk,
             'tenant': None,
             'status': RackStatusChoices.STATUS_DEPRECATED,
             '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)
     site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group')
     location = attrs.NestedObjectAttr('location', linkify=True)
+    group = attrs.RelatedObjectAttr('group', linkify=True, label=_('Rack group'))
     name = attrs.TextAttr('name')
     facility_id = attrs.TextAttr('facility_id', label=_('Facility ID'))
     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/<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/<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
 
 
+#
+# 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
 #
@@ -1160,7 +1239,7 @@ class RackReservationView(generic.ObjectView):
     queryset = RackReservation.objects.all()
     layout = layout.SimpleLayout(
         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')),
             CustomFieldsPanel(),
             TagsPanel(),

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

@@ -1279,6 +1279,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
         'provideraccount',
         'providernetwork',
         'rack',
+        'rackgroup',
         'rackreservation',
         'rackrole',
         '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
 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(
         method='filter_scope'
     )
+    rack_group = django_filters.NumberFilter(
+        method='filter_scope'
+    )
     rack = django_filters.NumberFilter(
         method='filter_scope'
     )

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

@@ -1,7 +1,7 @@
 from django import forms
 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.constants import *
 from ipam.models import *
@@ -458,7 +458,7 @@ class FHRPGroupFilterForm(PrimaryModelFilterSetForm):
 class VLANGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
     fieldsets = (
         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('contains_vid', name=_('VLANs')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
@@ -485,6 +485,11 @@ class VLANGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
         required=False,
         label=_('Location')
     )
+    rack_group = DynamicModelMultipleChoiceField(
+        queryset=RackGroup.objects.all(),
+        required=False,
+        label=_('Rack group')
+    )
     rack = DynamicModelMultipleChoiceField(
         queryset=Rack.objects.all(),
         required=False,

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

@@ -50,7 +50,6 @@ RACKS_MENU = Menu(
             label=_('Racks'),
             items=(
                 get_model_item('dcim', 'rack', _('Racks')),
-                get_model_item('dcim', 'rackrole', _('Rack Roles')),
                 get_model_item('dcim', 'rackreservation', _('Reservations')),
                 MenuItem(
                     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(
             label=_('Rack Types'),
             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 %}