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

Fixes #20961
This commit is contained in:
Martin Hauser 2026-03-10 16:19:12 +01:00 committed by GitHub
parent c384cec453
commit e2665ef211
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 628 additions and 54 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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