diff --git a/docs/models/dcim/rack.md b/docs/models/dcim/rack.md index d610f6368..52df4aaf8 100644 --- a/docs/models/dcim/rack.md +++ b/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. diff --git a/docs/models/dcim/rackgroup.md b/docs/models/dcim/rackgroup.md new file mode 100644 index 000000000..13b3f39ab --- /dev/null +++ b/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.) diff --git a/mkdocs.yml b/mkdocs.yml index dc758f197..f5c7b9663 100644 --- a/mkdocs.yml +++ b/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' diff --git a/netbox/dcim/api/serializers_/racks.py b/netbox/dcim/api/serializers_/racks.py index b87140bed..8a7294f6b 100644 --- a/netbox/dcim/api/serializers_/racks.py +++ b/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') diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index e20e36c2f..328e5ac0f 100644 --- a/netbox/dcim/api/urls.py +++ b/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) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index a3bc7386a..96af81cf1 100644 --- a/netbox/dcim/api/views.py +++ b/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 # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index b92ec52b4..994d68674 100644 --- a/netbox/dcim/filtersets.py +++ b/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, diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index ad41e5a74..fff3fcddf 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/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', ) diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index c14b7d533..1749153db 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/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): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index b375f2bba..9fbdb6010 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/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', }, diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 741315323..aedc30943 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/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', diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index 9d1d097a4..191e9a8fd 100644 --- a/netbox/dcim/graphql/filters.py +++ b/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() diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index fcf1e828f..1ee426a91 100644 --- a/netbox/dcim/graphql/schema.py +++ b/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() diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index cf16bc39f..49aa18076 100644 --- a/netbox/dcim/graphql/types.py +++ b/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 diff --git a/netbox/dcim/migrations/0227_rack_group.py b/netbox/dcim/migrations/0227_rack_group.py new file mode 100644 index 000000000..8563b6c07 --- /dev/null +++ b/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', + ), + ), + ] diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 60707cb06..334235f32 100644 --- a/netbox/dcim/models/racks.py +++ b/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, diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index f956786c6..fa5cb9156 100644 --- a/netbox/dcim/search.py +++ b/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 diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 2a1642ae9..07d27b902 100644 --- a/netbox/dcim/tables/racks.py +++ b/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') diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index f574507f1..e0289a595 100644 --- a/netbox/dcim/tests/test_api.py +++ b/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, }, ] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 23ca1ba62..d4a53bca2 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/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) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 1aac72875..c90e6a91b 100644 --- a/netbox/dcim/tests/test_views.py +++ b/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, diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index fa7ad848a..a7b56217a 100644 --- a/netbox/dcim/ui/panels.py +++ b/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') diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index b24c81069..f3e1d6675 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -19,6 +19,9 @@ urlpatterns = [ path('locations/', include(get_model_urls('dcim', 'location', detail=False))), path('locations//', include(get_model_urls('dcim', 'location'))), + path('rack-groups/', include(get_model_urls('dcim', 'rackgroup', detail=False))), + path('rack-groups//', include(get_model_urls('dcim', 'rackgroup'))), + path('rack-roles/', include(get_model_urls('dcim', 'rackrole', detail=False))), path('rack-roles//', include(get_model_urls('dcim', 'rackrole'))), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index a2b0b6f31..ebcf60ec8 100644 --- a/netbox/dcim/views.py +++ b/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(), diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 4eb8533eb..0e6237f5f 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -1279,6 +1279,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): 'provideraccount', 'providernetwork', 'rack', + 'rackgroup', 'rackreservation', 'rackrole', 'racktype', diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index 44c5e7d39..223b785b3 100644 --- a/netbox/ipam/constants.py +++ b/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', ) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 4e8ec5d88..d4dd39433 100644 --- a/netbox/ipam/filtersets.py +++ b/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' ) diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index dbb9765a7..a141cb491 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/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, diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 1de2df0aa..0e0fa31d0 100644 --- a/netbox/netbox/navigation/menu.py +++ b/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=( diff --git a/netbox/templates/dcim/rackgroup.html b/netbox/templates/dcim/rackgroup.html new file mode 100644 index 000000000..7381de86f --- /dev/null +++ b/netbox/templates/dcim/rackgroup.html @@ -0,0 +1,10 @@ +{% extends 'generic/object.html' %} +{% load i18n %} + +{% block extra_controls %} + {% if perms.dcim.add_rack %} + + {% trans "Add Rack" %} + + {% endif %} +{% endblock extra_controls %}