From 5a6ffde67e671509c66102ce788923fb63ffc045 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Tue, 16 Jul 2024 19:58:22 +0700 Subject: [PATCH] 12826 Add Rack Type (#16739) * 12826 add RackType * 12826 add forms, filters, tables * 12826 add to menu * 12826 remove role * 12826 add api/serializers * 12826 add tests and fixes * 12826 fix tests * 12826 fix tests * 12826 fix tests * 12826 fix tests * 12826 add device_type to device and instantiation * 12826 test device creation * 12826 add slug * 12826 fix tests * 12826 fix slug field * 12826 prevent modification of rack fields if rack_type set * 12826 update rack fields on rack_type edit * Misc cleanup * Update model docs * Add manufacturer field to RackType * Add test for mounting_depth * Rename 'type' to 'form_factor' * Create base classes for Rack & RackType models, serializers * Hide RackType-defined fields on RackForm when a rack type is set * Establish a base filter form for Rack & RackType * Clean up RackType attr inheritance * Clean up templates --------- Co-authored-by: Jeremy Stretch --- docs/features/facilities.md | 4 + docs/models/dcim/rack.md | 47 +-- docs/models/dcim/racktype.md | 57 ++++ mkdocs.yml | 1 + netbox/dcim/api/serializers_/racks.py | 101 ++++++- netbox/dcim/api/urls.py | 1 + netbox/dcim/api/views.py | 10 + netbox/dcim/choices.py | 2 +- netbox/dcim/filtersets.py | 50 +++- netbox/dcim/forms/bulk_edit.py | 104 ++++++- netbox/dcim/forms/bulk_import.py | 57 +++- netbox/dcim/forms/filtersets.py | 95 ++++-- netbox/dcim/forms/model_forms.py | 75 ++++- netbox/dcim/graphql/filters.py | 7 + netbox/dcim/graphql/schema.py | 5 + netbox/dcim/graphql/types.py | 12 + netbox/dcim/migrations/0188_racktype.py | 93 ++++++ netbox/dcim/models/racks.py | 265 ++++++++++++----- netbox/dcim/search.py | 11 + netbox/dcim/tables/racks.py | 64 +++- netbox/dcim/tests/test_api.py | 41 +++ netbox/dcim/tests/test_filtersets.py | 156 +++++++++- netbox/dcim/tests/test_models.py | 55 ++++ netbox/dcim/tests/test_views.py | 73 ++++- netbox/dcim/urls.py | 8 + netbox/dcim/views.py | 50 ++++ netbox/extras/tests/test_filtersets.py | 1 + netbox/netbox/navigation/menu.py | 1 + .../dcim/inc/panels/racktype_dimensions.html | 48 +++ .../dcim/inc/panels/racktype_numbering.html | 14 + netbox/templates/dcim/rack.html | 278 +++++++----------- netbox/templates/dcim/racktype.html | 71 +++++ 32 files changed, 1504 insertions(+), 353 deletions(-) create mode 100644 docs/models/dcim/racktype.md create mode 100644 netbox/dcim/migrations/0188_racktype.py create mode 100644 netbox/templates/dcim/inc/panels/racktype_dimensions.html create mode 100644 netbox/templates/dcim/inc/panels/racktype_numbering.html create mode 100644 netbox/templates/dcim/racktype.html diff --git a/docs/features/facilities.md b/docs/features/facilities.md index 84c7c5733..4c8dfe265 100644 --- a/docs/features/facilities.md +++ b/docs/features/facilities.md @@ -56,6 +56,10 @@ A site typically represents a building within a region and/or site group. Each s A location can be any logical subdivision within a building, such as a floor or room. Like regions and site groups, locations can be nested into a self-recursive hierarchy for maximum flexibility. And like sites, each location has an operational status assigned to it. +## Rack Types + +A rack type represents a unique specification of a rack which exists in the real world. Each rack type can be setup with weight, height, and unit ordering. New racks of this type can then be created in NetBox, and any associated specifications will be automatically replicated from the device type. + ## Racks Finally, NetBox models each equipment rack as a discrete object within a site and location. These are physical objects into which devices are installed. Each rack can be assigned an operational status, type, facility ID, and other attributes related to inventory tracking. Each rack also must define a height (in rack units) and width, and may optionally specify its physical dimensions. diff --git a/docs/models/dcim/rack.md b/docs/models/dcim/rack.md index 3989e2b04..d610f6368 100644 --- a/docs/models/dcim/rack.md +++ b/docs/models/dcim/rack.md @@ -20,6 +20,10 @@ The [location](./location.md) within a site where the rack has been installed (o The rack's name or identifier. Must be unique to the rack's location, if assigned. +### Rack Type + +The [physical type](./racktype.md) of this rack. The rack type defines physical attributes such as height and weight. + ### Status Operational status. @@ -43,44 +47,5 @@ The unique physical serial number assigned to this rack. A unique, locally-administered label used to identify hardware resources. -### Type - -A rack can be designated as one of the following types: - -* 2-post frame -* 4-post frame -* 4-post cabinet -* Wall-mounted frame -* Wall-mounted cabinet - -### Width - -The canonical distance between the two vertical rails on a face. (This is typically 19 inches, however other standard widths exist.) - -### Height - -The height of the rack, measured in units. - -### Starting Unit - -The number of the numerically lowest unit in the rack. This value defaults to one, but may be higher in certain situations. For example, you may want to model only a select range of units within a shared physical rack (e.g. U13 through U24). - -### Outer Dimensions - -The external width and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches. - -### Mounting Depth - -The maximum depth of a mounted device that the rack can accommodate, in millimeters. For four-post frames or cabinets, this is the horizontal distance between the front and rear vertical rails. (Note that this measurement does _not_ include space between the rails and the cabinet doors.) - -### Weight - -The numeric weight of the rack, including a unit designation (e.g. 10 kilograms or 20 pounds). - -### Maximum Weight - -The maximum total weight capacity for all installed devices, inclusive of the rack itself. - -### Descending Units - -If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use ascending numbering, with unit 1 assigned to the bottommost position.) +!!! note + Some additional fields pertaining to physical attributes such as height and weight can also be defined on each rack, but should generally be defined instead on the [rack type](./racktype.md). diff --git a/docs/models/dcim/racktype.md b/docs/models/dcim/racktype.md new file mode 100644 index 000000000..d44e17b17 --- /dev/null +++ b/docs/models/dcim/racktype.md @@ -0,0 +1,57 @@ +# Rack Types + +A rack type defines the physical characteristics of a particular model of [rack](./rack.md). + +## Fields + +### Manufacturer + +The [manufacturer](./manufacturer.md) which produces this type of rack. + +### Name + +The unique name of the rack type. + +### Form Factor + +A rack can be designated as one of the following form factors: + +* 2-post frame +* 4-post frame +* 4-post cabinet +* Wall-mounted frame +* Wall-mounted cabinet + +### Width + +The canonical distance between the two vertical rails on a face. (This is typically 19 inches, however other standard widths exist.) + +### Height + +The height of the rack, measured in units. + +### Starting Unit + +The number of the numerically lowest unit in the rack. This value defaults to one, but may be higher in certain situations. For example, you may want to model only a select range of units within a shared physical rack (e.g. U13 through U24). + +### Outer Dimensions + +The external width and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches. + +### Mounting Depth + +The maximum depth of a mounted device that the rack can accommodate, in millimeters. For four-post frames or cabinets, this is the horizontal distance between the front and rear vertical rails. (Note that this measurement does _not_ include space between the rails and the cabinet doors.) + +### Weight + +The numeric weight of the rack, including a unit designation (e.g. 10 kilograms or 20 pounds). + +### Maximum Weight + +The maximum total weight capacity for all installed devices, inclusive of the rack itself. + +### Descending Units + +If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use ascending numbering, with unit 1 assigned to the bottommost position.) + + diff --git a/mkdocs.yml b/mkdocs.yml index 4aab9d743..f5819d0ea 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -206,6 +206,7 @@ nav: - Rack: 'models/dcim/rack.md' - RackReservation: 'models/dcim/rackreservation.md' - RackRole: 'models/dcim/rackrole.md' + - RackType: 'models/dcim/racktype.md' - RearPort: 'models/dcim/rearport.md' - RearPortTemplate: 'models/dcim/rearporttemplate.md' - Region: 'models/dcim/region.md' diff --git a/netbox/dcim/api/serializers_/racks.py b/netbox/dcim/api/serializers_/racks.py index d8d738001..4fb96e08c 100644 --- a/netbox/dcim/api/serializers_/racks.py +++ b/netbox/dcim/api/serializers_/racks.py @@ -3,12 +3,13 @@ from rest_framework import serializers from dcim.choices import * from dcim.constants import * -from dcim.models import Rack, RackReservation, RackRole +from dcim.models import Rack, RackReservation, RackRole, RackType from netbox.api.fields import ChoiceField, RelatedObjectCountField from netbox.api.serializers import NetBoxModelSerializer from netbox.config import ConfigItem from tenancy.api.serializers_.tenants import TenantSerializer from users.api.serializers_.users import UserSerializer +from .manufacturers import ManufacturerSerializer from .sites import LocationSerializer, SiteSerializer __all__ = ( @@ -16,6 +17,7 @@ __all__ = ( 'RackReservationSerializer', 'RackRoleSerializer', 'RackSerializer', + 'RackTypeSerializer', ) @@ -33,18 +35,84 @@ class RackRoleSerializer(NetBoxModelSerializer): brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count') -class RackSerializer(NetBoxModelSerializer): - site = SiteSerializer(nested=True) - location = LocationSerializer(nested=True, required=False, allow_null=True, default=None) - tenant = TenantSerializer(nested=True, required=False, allow_null=True) - status = ChoiceField(choices=RackStatusChoices, required=False) - role = RackRoleSerializer(nested=True, required=False, allow_null=True) - type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False, allow_null=True) - facility_id = serializers.CharField(max_length=50, allow_blank=True, allow_null=True, label=_('Facility ID'), - default=None) - width = ChoiceField(choices=RackWidthChoices, required=False) - outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False, allow_null=True) - weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True) +class RackBaseSerializer(NetBoxModelSerializer): + form_factor = ChoiceField( + choices=RackFormFactorChoices, + allow_blank=True, + required=False, + allow_null=True + ) + width = ChoiceField( + choices=RackWidthChoices, + required=False + ) + outer_unit = ChoiceField( + choices=RackDimensionUnitChoices, + allow_blank=True, + required=False, + allow_null=True + ) + weight_unit = ChoiceField( + choices=WeightUnitChoices, + allow_blank=True, + required=False, + allow_null=True + ) + + +class RackTypeSerializer(RackBaseSerializer): + manufacturer = ManufacturerSerializer( + nested=True + ) + + class Meta: + model = RackType + fields = [ + 'id', 'url', 'display_url', 'display', 'manufacturer', 'name', 'slug', 'description', 'form_factor', + 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'weight', + 'max_weight', 'weight_unit', 'mounting_depth', 'description', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'manufacturer', 'name', 'slug', 'description') + + +class RackSerializer(RackBaseSerializer): + site = SiteSerializer( + nested=True + ) + location = LocationSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + tenant = TenantSerializer( + nested=True, + required=False, + allow_null=True + ) + status = ChoiceField( + choices=RackStatusChoices, + required=False + ) + role = RackRoleSerializer( + nested=True, + required=False, + allow_null=True + ) + facility_id = serializers.CharField( + max_length=50, + allow_blank=True, + allow_null=True, + label=_('Facility ID'), + default=None + ) + rack_type = RackTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) # Related object counts device_count = RelatedObjectCountField('devices') @@ -54,9 +122,10 @@ class RackSerializer(NetBoxModelSerializer): model = Rack fields = [ 'id', 'url', 'display_url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', - 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'starting_unit', 'weight', 'max_weight', - 'weight_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description', - 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count', + 'role', 'serial', 'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'weight', + 'max_weight', 'weight_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', + 'description', '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 36a0c99a5..d099b392a 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-types', views.RackTypeViewSet) router.register('rack-roles', views.RackRoleViewSet) router.register('racks', views.RackViewSet) router.register('rack-reservations', views.RackReservationViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index be7a9c306..87aa7535c 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -161,6 +161,16 @@ class RackRoleViewSet(NetBoxModelViewSet): filterset_class = filtersets.RackRoleFilterSet +# +# Rack Types +# + +class RackTypeViewSet(NetBoxModelViewSet): + queryset = RackType.objects.all() + serializer_class = serializers.RackTypeSerializer + filterset_class = filtersets.RackTypeFilterSet + + # # Racks # diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index fe8d8a158..464c396ff 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -51,7 +51,7 @@ class LocationStatusChoices(ChoiceSet): # Racks # -class RackTypeChoices(ChoiceSet): +class RackFormFactorChoices(ChoiceSet): TYPE_2POST = '2-post-frame' TYPE_4POST = '4-post-frame' diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index fdcad870b..7159efb40 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -69,6 +69,7 @@ __all__ = ( 'RackFilterSet', 'RackReservationFilterSet', 'RackRoleFilterSet', + 'RackTypeFilterSet', 'RearPortFilterSet', 'RearPortTemplateFilterSet', 'RegionFilterSet', @@ -289,6 +290,41 @@ class RackRoleFilterSet(OrganizationalModelFilterSet): fields = ('id', 'name', 'slug', 'color', 'description') +class RackTypeFilterSet(NetBoxModelFilterSet): + manufacturer_id = django_filters.ModelMultipleChoiceFilter( + queryset=Manufacturer.objects.all(), + label=_('Manufacturer (ID)'), + ) + manufacturer = django_filters.ModelMultipleChoiceFilter( + field_name='manufacturer__slug', + queryset=Manufacturer.objects.all(), + to_field_name='slug', + label=_('Manufacturer (slug)'), + ) + form_factor = django_filters.MultipleChoiceFilter( + choices=RackFormFactorChoices + ) + width = django_filters.MultipleChoiceFilter( + choices=RackWidthChoices + ) + + class Meta: + model = RackType + fields = ( + 'id', 'name', 'slug', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', + 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', + ) + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) | + Q(comments__icontains=value) + ) + + class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), @@ -339,12 +375,22 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe to_field_name='slug', label=_('Location (slug)'), ) + rack_type = django_filters.ModelMultipleChoiceFilter( + field_name='rack_type__slug', + queryset=RackType.objects.all(), + to_field_name='slug', + label=_('Rack type (slug)'), + ) + rack_type_id = django_filters.ModelMultipleChoiceFilter( + queryset=RackType.objects.all(), + label=_('Rack type (ID)'), + ) status = django_filters.MultipleChoiceFilter( choices=RackStatusChoices, null_value=None ) - type = django_filters.MultipleChoiceFilter( - choices=RackTypeChoices + form_factor = django_filters.MultipleChoiceFilter( + choices=RackFormFactorChoices ) width = django_filters.MultipleChoiceFilter( choices=RackWidthChoices diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 25b049e6d..4a017dd46 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -13,7 +13,7 @@ from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import BulkEditForm, add_blank_choice, form_from_model from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField -from utilities.forms.rendering import FieldSet +from utilities.forms.rendering import FieldSet, InlineFields from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions from wireless.models import WirelessLAN, WirelessLANGroup from wireless.choices import WirelessRoleChoices @@ -52,6 +52,7 @@ __all__ = ( 'RackBulkEditForm', 'RackReservationBulkEditForm', 'RackRoleBulkEditForm', + 'RackTypeBulkEditForm', 'RearPortBulkEditForm', 'RearPortTemplateBulkEditForm', 'RegionBulkEditForm', @@ -218,6 +219,97 @@ class RackRoleBulkEditForm(NetBoxModelBulkEditForm): nullable_fields = ('color', 'description') +class RackTypeBulkEditForm(NetBoxModelBulkEditForm): + manufacturer = DynamicModelChoiceField( + label=_('Manufacturer'), + queryset=Manufacturer.objects.all(), + required=False + ) + form_factor = forms.ChoiceField( + label=_('Form factor'), + choices=add_blank_choice(RackFormFactorChoices), + required=False + ) + width = forms.ChoiceField( + label=_('Width'), + choices=add_blank_choice(RackWidthChoices), + required=False + ) + u_height = forms.IntegerField( + required=False, + label=_('Height (U)') + ) + starting_unit = forms.IntegerField( + required=False, + min_value=1 + ) + desc_units = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect, + label=_('Descending units') + ) + outer_width = forms.IntegerField( + label=_('Outer width'), + required=False, + min_value=1 + ) + outer_depth = forms.IntegerField( + label=_('Outer depth'), + required=False, + min_value=1 + ) + outer_unit = forms.ChoiceField( + label=_('Outer unit'), + choices=add_blank_choice(RackDimensionUnitChoices), + required=False + ) + mounting_depth = forms.IntegerField( + label=_('Mounting depth'), + required=False, + min_value=1 + ) + weight = forms.DecimalField( + label=_('Weight'), + min_value=0, + required=False + ) + max_weight = forms.IntegerField( + label=_('Max weight'), + min_value=0, + required=False + ) + weight_unit = forms.ChoiceField( + label=_('Weight unit'), + choices=add_blank_choice(WeightUnitChoices), + required=False, + initial='' + ) + description = forms.CharField( + label=_('Description'), + max_length=200, + required=False + ) + comments = CommentField() + + model = RackType + fieldsets = ( + FieldSet('manufacturer', 'description', 'form_factor', name=_('Rack Type')), + FieldSet( + 'width', + 'u_height', + InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')), + InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')), + 'mounting_depth', + name=_('Dimensions') + ), + FieldSet('starting_unit', 'desc_units', name=_('Numbering')), + ) + nullable_fields = ( + 'outer_width', 'outer_depth', 'outer_unit', 'weight', + 'max_weight', 'weight_unit', 'description', 'comments', + ) + + class RackBulkEditForm(NetBoxModelBulkEditForm): region = DynamicModelChoiceField( label=_('Region'), @@ -278,9 +370,9 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): max_length=50, required=False ) - type = forms.ChoiceField( - label=_('Type'), - choices=add_blank_choice(RackTypeChoices), + form_factor = forms.ChoiceField( + label=_('Form factor'), + choices=add_blank_choice(RackFormFactorChoices), required=False ) width = forms.ChoiceField( @@ -345,8 +437,8 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): FieldSet('status', 'role', 'tenant', 'serial', 'asset_tag', 'description', name=_('Rack')), FieldSet('region', 'site_group', 'site', 'location', name=_('Location')), FieldSet( - 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', - name=_('Hardware') + 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', + 'mounting_depth', name=_('Hardware') ), FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')), ) diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index e49d14dd0..7f7efecf6 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -45,6 +45,7 @@ __all__ = ( 'RackImportForm', 'RackReservationImportForm', 'RackRoleImportForm', + 'RackTypeImportForm', 'RearPortImportForm', 'RegionImportForm', 'SiteImportForm', @@ -176,6 +177,54 @@ class RackRoleImportForm(NetBoxModelImportForm): fields = ('name', 'slug', 'color', 'description', 'tags') +class RackTypeImportForm(NetBoxModelImportForm): + manufacturer = forms.ModelChoiceField( + label=_('Manufacturer'), + queryset=Manufacturer.objects.all(), + to_field_name='name', + help_text=_('The manufacturer of this rack type') + ) + form_factor = CSVChoiceField( + label=_('Type'), + choices=RackFormFactorChoices, + required=False, + help_text=_('Form factor') + ) + starting_unit = forms.IntegerField( + required=False, + min_value=1, + help_text=_('The lowest-numbered position in the rack') + ) + width = forms.ChoiceField( + label=_('Width'), + choices=RackWidthChoices, + help_text=_('Rail-to-rail width (in inches)') + ) + outer_unit = CSVChoiceField( + label=_('Outer unit'), + choices=RackDimensionUnitChoices, + required=False, + help_text=_('Unit for outer dimensions') + ) + weight_unit = CSVChoiceField( + label=_('Weight unit'), + choices=WeightUnitChoices, + required=False, + help_text=_('Unit for rack weights') + ) + + class Meta: + model = RackType + fields = ( + 'manufacturer', 'name', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', + 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', + 'description', 'comments', 'tags', + ) + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + + class RackImportForm(NetBoxModelImportForm): site = CSVModelChoiceField( label=_('Site'), @@ -207,11 +256,11 @@ class RackImportForm(NetBoxModelImportForm): to_field_name='name', help_text=_('Name of assigned role') ) - type = CSVChoiceField( + form_factor = CSVChoiceField( label=_('Type'), - choices=RackTypeChoices, + choices=RackFormFactorChoices, required=False, - help_text=_('Rack type') + help_text=_('Form factor') ) width = forms.ChoiceField( label=_('Width'), @@ -234,7 +283,7 @@ class RackImportForm(NetBoxModelImportForm): class Meta: model = Rack fields = ( - 'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', + 'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'form_factor', 'serial', 'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags', ) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 22e66763b..5d16a7b39 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -48,6 +48,7 @@ __all__ = ( 'RackElevationFilterForm', 'RackReservationFilterForm', 'RackRoleFilterForm', + 'RackTypeFilterForm', 'RearPortFilterForm', 'RegionFilterForm', 'SiteFilterForm', @@ -240,16 +241,77 @@ class RackRoleFilterForm(NetBoxModelFilterSetForm): tag = TagFilterField(model) -class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): +class RackBaseFilterForm(NetBoxModelFilterSetForm): + form_factor = forms.MultipleChoiceField( + label=_('Form factor'), + choices=RackFormFactorChoices, + required=False + ) + width = forms.MultipleChoiceField( + label=_('Width'), + choices=RackWidthChoices, + required=False + ) + u_height = forms.IntegerField( + required=False, + min_value=1 + ) + starting_unit = forms.IntegerField( + required=False, + min_value=1 + ) + desc_units = forms.NullBooleanField( + required=False, + label=_('Descending units'), + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + weight = forms.DecimalField( + label=_('Weight'), + required=False, + min_value=1 + ) + max_weight = forms.IntegerField( + label=_('Max weight'), + required=False, + min_value=1 + ) + weight_unit = forms.ChoiceField( + label=_('Weight unit'), + choices=add_blank_choice(WeightUnitChoices), + required=False + ) + + +class RackTypeFilterForm(RackBaseFilterForm): + model = RackType + fieldsets = ( + FieldSet('q', 'filter_id', 'tag'), + FieldSet('form_factor', 'width', 'u_height', name=_('Rack Type')), + FieldSet('starting_unit', 'desc_units', name=_('Numbering')), + FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')), + ) + selector_fields = ('filter_id', 'q', 'manufacturer_id') + manufacturer_id = DynamicModelMultipleChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + label=_('Manufacturer') + ) + tag = TagFilterField(model) + + +class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterForm): model = Rack fieldsets = ( FieldSet('q', 'filter_id', 'tag'), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')), - FieldSet('status', 'role_id', name=_('Function')), - FieldSet('type', 'width', 'serial', 'asset_tag', name=_('Hardware')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), - FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), + FieldSet('status', 'role_id', 'serial', 'asset_tag', name=_('Rack')), + FieldSet('form_factor', 'width', 'u_height', name=_('Rack Type')), + FieldSet('starting_unit', 'desc_units', name=_('Numbering')), FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), ) selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id') region_id = DynamicModelMultipleChoiceField( @@ -284,16 +346,6 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte choices=RackStatusChoices, required=False ) - type = forms.MultipleChoiceField( - label=_('Type'), - choices=RackTypeChoices, - required=False - ) - width = forms.MultipleChoiceField( - label=_('Width'), - choices=RackWidthChoices, - required=False - ) role_id = DynamicModelMultipleChoiceField( queryset=RackRole.objects.all(), required=False, @@ -309,21 +361,6 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte required=False ) tag = TagFilterField(model) - weight = forms.DecimalField( - label=_('Weight'), - required=False, - min_value=1 - ) - max_weight = forms.IntegerField( - label=_('Max weight'), - required=False, - min_value=1 - ) - weight_unit = forms.ChoiceField( - label=_('Weight unit'), - choices=add_blank_choice(WeightUnitChoices), - required=False - ) class RackElevationFilterForm(RackFilterForm): diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index d493687f9..bff0d8c46 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -11,7 +11,7 @@ from extras.models import ConfigTemplate from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm -from utilities.forms import add_blank_choice +from utilities.forms import add_blank_choice, get_field_value from utilities.forms.fields import ( CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SlugField, ) @@ -57,6 +57,7 @@ __all__ = ( 'RackForm', 'RackReservationForm', 'RackRoleForm', + 'RackTypeForm', 'RearPortForm', 'RearPortTemplateForm', 'RegionForm', @@ -201,6 +202,34 @@ class RackRoleForm(NetBoxModelForm): ] +class RackTypeForm(NetBoxModelForm): + manufacturer = DynamicModelChoiceField( + label=_('Manufacturer'), + queryset=Manufacturer.objects.all() + ) + comments = CommentField() + slug = SlugField() + + fieldsets = ( + FieldSet('manufacturer', 'name', 'slug', 'description', 'form_factor', 'tags', name=_('Rack Type')), + FieldSet( + 'width', 'u_height', + InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')), + InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')), + 'mounting_depth', name=_('Dimensions') + ), + FieldSet('starting_unit', 'desc_units', name=_('Numbering')), + ) + + class Meta: + model = RackType + fields = [ + 'manufacturer', 'name', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', + 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', + 'description', 'comments', 'tags', + ] + + class RackForm(TenancyForm, NetBoxModelForm): site = DynamicModelChoiceField( label=_('Site'), @@ -220,28 +249,54 @@ class RackForm(TenancyForm, NetBoxModelForm): queryset=RackRole.objects.all(), required=False ) + rack_type = DynamicModelChoiceField( + label=_('Rack Type'), + queryset=RackType.objects.all(), + required=False, + help_text=_("Select a pre-defined rack type, or set physical characteristics below.") + ) comments = CommentField() fieldsets = ( - FieldSet('site', 'location', 'name', 'status', 'role', 'description', 'tags', name=_('Rack')), + FieldSet('site', 'location', 'name', 'status', 'role', 'rack_type', 'description', 'tags', name=_('Rack')), FieldSet('facility_id', 'serial', 'asset_tag', name=_('Inventory Control')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), - FieldSet( - 'type', 'width', 'starting_unit', 'u_height', - InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')), - InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')), - 'mounting_depth', 'desc_units', name=_('Dimensions') - ), ) class Meta: model = Rack fields = [ 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', - 'asset_tag', 'type', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', - 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags', + 'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', + 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', + 'comments', 'tags', ] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Mimic HTMXSelect() + self.fields['rack_type'].widget.attrs.update({ + 'hx-get': '.', + 'hx-include': '#form_fields', + 'hx-target': '#form_fields', + }) + + # Omit RackType-defined fields if rack_type is set + if get_field_value(self, 'rack_type'): + for field_name in Rack.RACKTYPE_FIELDS: + del self.fields[field_name] + else: + self.fieldsets = ( + *self.fieldsets, + FieldSet( + 'form_factor', 'width', 'starting_unit', 'u_height', + InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')), + InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')), + 'mounting_depth', 'desc_units', name=_('Dimensions') + ), + ) + class RackReservationForm(TenancyForm, NetBoxModelForm): rack = DynamicModelChoiceField( diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index 2ae5e7771..8c256aecb 100644 --- a/netbox/dcim/graphql/filters.py +++ b/netbox/dcim/graphql/filters.py @@ -38,6 +38,7 @@ __all__ = ( 'RackFilter', 'RackReservationFilter', 'RackRoleFilter', + 'RackTypeFilter', 'RearPortFilter', 'RearPortTemplateFilter', 'RegionFilter', @@ -234,6 +235,12 @@ class PowerPortTemplateFilter(BaseFilterMixin): pass +@strawberry_django.filter(models.RackType, lookups=True) +@autotype_decorator(filtersets.RackTypeFilterSet) +class RackTypeFilter(BaseFilterMixin): + pass + + @strawberry_django.filter(models.Rack, lookups=True) @autotype_decorator(filtersets.RackFilterSet) class RackFilter(BaseFilterMixin): diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index c3962a87a..713a63778 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -159,6 +159,11 @@ class DCIMQuery: return models.PowerPortTemplate.objects.get(id=id) power_port_template_list: List[PowerPortTemplateType] = strawberry_django.field() + @strawberry.field + def rack_type(self, id: int) -> RackTypeType: + return models.RackType.objects.get(id=id) + rack_type_list: List[RackTypeType] = strawberry_django.field() + @strawberry.field def rack(self, id: int) -> RackType: return models.Rack.objects.get(id=id) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 8b4613f14..d2bf4b416 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -50,6 +50,7 @@ __all__ = ( 'RackType', 'RackReservationType', 'RackRoleType', + 'RackTypeType', 'RearPortType', 'RearPortTemplateType', 'RegionType', @@ -606,6 +607,16 @@ class PowerPortTemplateType(ModularComponentTemplateType): poweroutlet_templates: List[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]] +@strawberry_django.type( + models.RackType, + fields='__all__', + filters=RackTypeFilter +) +class RackTypeType(NetBoxObjectType): + _name: str + manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] + + @strawberry_django.type( models.Rack, fields='__all__', @@ -618,6 +629,7 @@ class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObje tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None role: Annotated["RackRoleType", strawberry.lazy('dcim.graphql.types')] | None + rack_type: Annotated["RackTypeType", strawberry.lazy('dcim.graphql.types')] | None reservations: List[Annotated["RackReservationType", strawberry.lazy('dcim.graphql.types')]] devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] powerfeeds: List[Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')]] diff --git a/netbox/dcim/migrations/0188_racktype.py b/netbox/dcim/migrations/0188_racktype.py new file mode 100644 index 000000000..18e4152b6 --- /dev/null +++ b/netbox/dcim/migrations/0188_racktype.py @@ -0,0 +1,93 @@ +import django.core.validators +import django.db.models.deletion +import taggit.managers +from django.db import migrations, models + +import utilities.fields +import utilities.json +import utilities.ordering + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0117_customfield_uniqueness'), + ('dcim', '0187_alter_device_vc_position'), + ] + + operations = [ + migrations.CreateModel( + name='RackType', + 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 + )), + ('description', models.CharField(blank=True, max_length=200)), + ('comments', models.TextField(blank=True)), + ('weight', models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True)), + ('weight_unit', models.CharField(blank=True, max_length=50)), + ('_abs_weight', models.PositiveBigIntegerField(blank=True, null=True)), + ('manufacturer', models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name='rack_types', + to='dcim.manufacturer' + )), + ('name', models.CharField(max_length=100)), + ('_name', utilities.fields.NaturalOrderingField( + 'name', + blank=True, + max_length=100, + naturalize_function=utilities.ordering.naturalize + ), + ), + ('slug', models.SlugField(max_length=100, unique=True)), + ('form_factor', models.CharField(blank=True, max_length=50)), + ('width', models.PositiveSmallIntegerField(default=19)), + ('u_height', models.PositiveSmallIntegerField( + default=42, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(100), + ] + )), + ('starting_unit', models.PositiveSmallIntegerField( + default=1, + validators=[django.core.validators.MinValueValidator(1)] + )), + ('desc_units', models.BooleanField(default=False)), + ('outer_width', models.PositiveSmallIntegerField(blank=True, null=True)), + ('outer_depth', models.PositiveSmallIntegerField(blank=True, null=True)), + ('outer_unit', models.CharField(blank=True, max_length=50)), + ('max_weight', models.PositiveIntegerField(blank=True, null=True)), + ('_abs_max_weight', models.PositiveBigIntegerField(blank=True, null=True)), + ('mounting_depth', models.PositiveSmallIntegerField(blank=True, null=True)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'verbose_name': 'racktype', + 'verbose_name_plural': 'racktypes', + 'ordering': ('_name', 'pk'), + }, + ), + migrations.RenameField( + model_name='rack', + old_name='type', + new_name='form_factor', + ), + migrations.AddField( + model_name='rack', + name='rack_type', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='racks', + to='dcim.racktype', + ), + ), + ] diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 289c38133..e6487c705 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -29,9 +29,181 @@ __all__ = ( 'Rack', 'RackReservation', 'RackRole', + 'RackType', ) +# +# Rack Types +# + +class RackBase(WeightMixin, PrimaryModel): + """ + Base class for RackType & Rack. Holds + """ + form_factor = models.CharField( + choices=RackFormFactorChoices, + max_length=50, + blank=True, + verbose_name=_('form factor') + ) + width = models.PositiveSmallIntegerField( + choices=RackWidthChoices, + default=RackWidthChoices.WIDTH_19IN, + verbose_name=_('width'), + help_text=_('Rail-to-rail width') + ) + + # Numbering + u_height = models.PositiveSmallIntegerField( + default=RACK_U_HEIGHT_DEFAULT, + verbose_name=_('height (U)'), + validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX)], + help_text=_('Height in rack units') + ) + starting_unit = models.PositiveSmallIntegerField( + default=RACK_STARTING_UNIT_DEFAULT, + verbose_name=_('starting unit'), + validators=[MinValueValidator(1)], + help_text=_('Starting unit for rack') + ) + desc_units = models.BooleanField( + default=False, + verbose_name=_('descending units'), + help_text=_('Units are numbered top-to-bottom') + ) + + # Dimensions + outer_width = models.PositiveSmallIntegerField( + verbose_name=_('outer width'), + blank=True, + null=True, + help_text=_('Outer dimension of rack (width)') + ) + outer_depth = models.PositiveSmallIntegerField( + verbose_name=_('outer depth'), + blank=True, + null=True, + help_text=_('Outer dimension of rack (depth)') + ) + outer_unit = models.CharField( + verbose_name=_('outer unit'), + max_length=50, + choices=RackDimensionUnitChoices, + blank=True + ) + mounting_depth = models.PositiveSmallIntegerField( + verbose_name=_('mounting depth'), + blank=True, + null=True, + help_text=(_( + 'Maximum depth of a mounted device, in millimeters. For four-post racks, this is the distance between the ' + 'front and rear rails.' + )) + ) + + # Weight + # WeightMixin provides weight, weight_unit, and _abs_weight + max_weight = models.PositiveIntegerField( + verbose_name=_('max weight'), + blank=True, + null=True, + help_text=_('Maximum load capacity for the rack') + ) + # Stores the normalized max weight (in grams) for database ordering + _abs_max_weight = models.PositiveBigIntegerField( + blank=True, + null=True + ) + + class Meta: + abstract = True + + +class RackType(RackBase): + """ + Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. + Each Rack is assigned to a Site and (optionally) a Location. + """ + manufacturer = models.ForeignKey( + to='dcim.Manufacturer', + on_delete=models.PROTECT, + related_name='rack_types' + ) + name = models.CharField( + verbose_name=_('name'), + max_length=100 + ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) + slug = models.SlugField( + verbose_name=_('slug'), + max_length=100, + unique=True + ) + + clone_fields = ( + 'manufacturer', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', + 'mounting_depth', 'weight', 'max_weight', 'weight_unit', + ) + prerequisite_models = ( + 'dcim.Manufacturer', + ) + + class Meta: + ordering = ('_name', 'pk') # (site, location, name) may be non-unique + verbose_name = _('rack type') + verbose_name_plural = _('rack types') + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('dcim:racktype', args=[self.pk]) + + def clean(self): + super().clean() + + # Validate outer dimensions and unit + if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit: + raise ValidationError(_("Must specify a unit when setting an outer width/depth")) + + # Validate max_weight and weight_unit + if self.max_weight and not self.weight_unit: + raise ValidationError(_("Must specify a unit when setting a maximum weight")) + + def save(self, *args, **kwargs): + # Store the given max weight (if any) in grams for use in database ordering + if self.max_weight and self.weight_unit: + self._abs_max_weight = to_grams(self.max_weight, self.weight_unit) + else: + self._abs_max_weight = None + + # Clear unit if outer width & depth are not set + if self.outer_width is None and self.outer_depth is None: + self.outer_unit = '' + + super().save(*args, **kwargs) + + # Update all Racks associated with this RackType + for rack in self.racks.all(): + rack.snapshot() + rack.copy_racktype_attrs() + rack.save() + + @property + def units(self): + """ + Return a list of unit numbers, top to bottom. + """ + if self.desc_units: + return drange(decimal.Decimal(self.starting_unit), self.u_height + self.starting_unit, 0.5) + return drange(self.u_height + decimal.Decimal(0.5) + self.starting_unit - 1, 0.5 + self.starting_unit - 1, -0.5) + + # # Racks # @@ -54,11 +226,24 @@ class RackRole(OrganizationalModel): return reverse('dcim:rackrole', args=[self.pk]) -class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin): +class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): """ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. Each Rack is assigned to a Site and (optionally) a Location. """ + # Fields which cannot be set locally if a RackType is assigned + RACKTYPE_FIELDS = [ + 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', + 'mounting_depth', 'weight', 'weight_unit', 'max_weight' + ] + + rack_type = models.ForeignKey( + to='dcim.RackType', + on_delete=models.PROTECT, + related_name='racks', + blank=True, + null=True, + ) name = models.CharField( verbose_name=_('name'), max_length=100 @@ -121,73 +306,6 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin): verbose_name=_('asset tag'), help_text=_('A unique tag used to identify this rack') ) - type = models.CharField( - choices=RackTypeChoices, - max_length=50, - blank=True, - verbose_name=_('type') - ) - width = models.PositiveSmallIntegerField( - choices=RackWidthChoices, - default=RackWidthChoices.WIDTH_19IN, - verbose_name=_('width'), - help_text=_('Rail-to-rail width') - ) - u_height = models.PositiveSmallIntegerField( - default=RACK_U_HEIGHT_DEFAULT, - verbose_name=_('height (U)'), - validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX)], - help_text=_('Height in rack units') - ) - starting_unit = models.PositiveSmallIntegerField( - default=RACK_STARTING_UNIT_DEFAULT, - verbose_name=_('starting unit'), - validators=[MinValueValidator(1),], - help_text=_('Starting unit for rack') - ) - desc_units = models.BooleanField( - default=False, - verbose_name=_('descending units'), - help_text=_('Units are numbered top-to-bottom') - ) - outer_width = models.PositiveSmallIntegerField( - verbose_name=_('outer width'), - blank=True, - null=True, - help_text=_('Outer dimension of rack (width)') - ) - outer_depth = models.PositiveSmallIntegerField( - verbose_name=_('outer depth'), - blank=True, - null=True, - help_text=_('Outer dimension of rack (depth)') - ) - outer_unit = models.CharField( - verbose_name=_('outer unit'), - max_length=50, - choices=RackDimensionUnitChoices, - blank=True, - ) - max_weight = models.PositiveIntegerField( - verbose_name=_('max weight'), - blank=True, - null=True, - help_text=_('Maximum load capacity for the rack') - ) - # Stores the normalized max weight (in grams) for database ordering - _abs_max_weight = models.PositiveBigIntegerField( - blank=True, - null=True - ) - mounting_depth = models.PositiveSmallIntegerField( - verbose_name=_('mounting depth'), - blank=True, - null=True, - help_text=( - _('Maximum depth of a mounted device, in millimeters. For four-post racks, this is the ' - 'distance between the front and rear rails.') - ) - ) # Generic relations vlan_groups = GenericRelation( @@ -198,7 +316,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin): ) clone_fields = ( - 'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width', + 'site', 'location', 'tenant', 'status', 'role', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', ) prerequisite_models = ( @@ -271,6 +389,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin): }) def save(self, *args, **kwargs): + self.copy_racktype_attrs() # Store the given max weight (if any) in grams for use in database ordering if self.max_weight and self.weight_unit: @@ -284,6 +403,14 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin): super().save(*args, **kwargs) + def copy_racktype_attrs(self): + """ + Copy physical attributes from the assigned RackType (if any). + """ + if self.rack_type: + for field_name in self.RACKTYPE_FIELDS: + setattr(self, field_name, getattr(self.rack_type, field_name)) + @property def units(self): """ diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index b349bcac0..32add68d0 100644 --- a/netbox/dcim/search.py +++ b/netbox/dcim/search.py @@ -242,6 +242,17 @@ class PowerPortIndex(SearchIndex): display_attrs = ('device', 'label', 'type', 'description') +@register_search +class RackTypeIndex(SearchIndex): + model = models.RackType + fields = ( + ('name', 100), + ('description', 500), + ('comments', 5000), + ) + display_attrs = ('type', 'description') + + @register_search class RackIndex(SearchIndex): model = models.Rack diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 22ca3da90..d269681c5 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -2,7 +2,7 @@ from django.utils.translation import gettext_lazy as _ import django_tables2 as tables from django_tables2.utils import Accessor -from dcim.models import Rack, RackReservation, RackRole +from dcim.models import Rack, RackReservation, RackRole, RackType from netbox.tables import NetBoxTable, columns from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin from .template_code import WEIGHT @@ -11,6 +11,7 @@ __all__ = ( 'RackTable', 'RackReservationTable', 'RackRoleTable', + 'RackTypeTable', ) @@ -44,6 +45,61 @@ class RackRoleTable(NetBoxTable): default_columns = ('pk', 'name', 'rack_count', 'color', 'description') +# +# Rack Types +# + +class RackTypeTable(NetBoxTable): + name = tables.Column( + verbose_name=_('Name'), + order_by=('_name',), + linkify=True + ) + manufacturer = tables.Column( + verbose_name=_('Manufacturer'), + linkify=True + ) + u_height = tables.TemplateColumn( + template_code="{{ value }}U", + verbose_name=_('Height') + ) + outer_width = tables.TemplateColumn( + template_code="{{ record.outer_width }} {{ record.outer_unit }}", + verbose_name=_('Outer Width') + ) + outer_depth = tables.TemplateColumn( + template_code="{{ record.outer_depth }} {{ record.outer_unit }}", + verbose_name=_('Outer Depth') + ) + weight = columns.TemplateColumn( + verbose_name=_('Weight'), + template_code=WEIGHT, + order_by=('_abs_weight', 'weight_unit') + ) + max_weight = columns.TemplateColumn( + verbose_name=_('Max Weight'), + template_code=WEIGHT, + order_by=('_abs_max_weight', 'weight_unit') + ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) + tags = columns.TagColumn( + url_name='dcim:rack_list' + ) + + class Meta(NetBoxTable.Meta): + model = RackType + fields = ( + 'pk', 'id', 'name', 'manufacturer', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width', + 'outer_depth', 'mounting_depth', 'weight', 'max_weight', 'description', 'comments', 'tags', 'created', + 'last_updated', + ) + default_columns = ( + 'pk', 'name', 'manufacturer', 'type', 'u_height', 'description', + ) + + # # Racks # @@ -114,9 +170,9 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): model = Rack fields = ( 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial', - 'asset_tag', 'type', 'u_height', 'starting_unit', 'width', 'outer_width', 'outer_depth', 'mounting_depth', - 'weight', 'max_weight', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', - 'description', 'contacts', 'tags', 'created', 'last_updated', + 'asset_tag', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width', 'outer_depth', + 'mounting_depth', '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', 'u_height', 'device_count', diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 52b850b24..8d14c077f 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -274,6 +274,47 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase): RackRole.objects.bulk_create(rack_roles) +class RackTypeTest(APIViewTestCases.APIViewTestCase): + model = RackType + brief_fields = ['description', 'display', 'id', 'manufacturer', 'name', 'slug', 'url'] + bulk_update_data = { + 'description': 'new description', + } + + @classmethod + def setUpTestData(cls): + manufacturers = ( + Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), + Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), + ) + Manufacturer.objects.bulk_create(manufacturers) + + rack_types = ( + RackType(manufacturer=manufacturers[0], name='Rack Type 1', slug='rack-type-1'), + RackType(manufacturer=manufacturers[0], name='Rack Type 2', slug='rack-type-2'), + RackType(manufacturer=manufacturers[0], name='Rack Type 3', slug='rack-type-3'), + ) + RackType.objects.bulk_create(rack_types) + + cls.create_data = [ + { + 'manufacturer': manufacturers[1].pk, + 'name': 'Rack Type 4', + 'slug': 'rack-type-4', + }, + { + 'manufacturer': manufacturers[1].pk, + 'name': 'Rack Type 5', + 'slug': 'rack-type-5', + }, + { + 'manufacturer': manufacturers[1].pk, + 'name': 'Rack Type 6', + 'slug': 'rack-type-6', + }, + ] + + class RackTest(APIViewTestCases.APIViewTestCase): model = Rack brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'url'] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index a567d452f..12b14ee02 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -468,6 +468,152 @@ class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) +class RackTypeTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = RackType.objects.all() + filterset = RackTypeFilterSet + + @classmethod + def setUpTestData(cls): + manufacturers = ( + Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), + Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), + Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), + ) + Manufacturer.objects.bulk_create(manufacturers) + + racks = ( + RackType( + manufacturer=manufacturers[0], + name='RackType 1', + slug='rack-type-1', + form_factor=RackFormFactorChoices.TYPE_2POST, + width=RackWidthChoices.WIDTH_19IN, + u_height=42, + starting_unit=1, + desc_units=False, + outer_width=100, + outer_depth=100, + outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, + mounting_depth=100, + weight=10, + max_weight=1000, + weight_unit=WeightUnitChoices.UNIT_POUND, + description='foobar1' + ), + RackType( + manufacturer=manufacturers[1], + name='RackType 2', + slug='rack-type-2', + form_factor=RackFormFactorChoices.TYPE_4POST, + width=RackWidthChoices.WIDTH_21IN, + u_height=43, + starting_unit=2, + desc_units=False, + outer_width=200, + outer_depth=200, + outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, + mounting_depth=200, + weight=20, + max_weight=2000, + weight_unit=WeightUnitChoices.UNIT_POUND, + description='foobar2' + ), + RackType( + manufacturer=manufacturers[2], + name='RackType 3', + slug='rack-type-3', + form_factor=RackFormFactorChoices.TYPE_CABINET, + width=RackWidthChoices.WIDTH_23IN, + u_height=44, + starting_unit=3, + desc_units=True, + outer_width=300, + outer_depth=300, + outer_unit=RackDimensionUnitChoices.UNIT_INCH, + mounting_depth=300, + weight=30, + max_weight=3000, + weight_unit=WeightUnitChoices.UNIT_KILOGRAM, + description='foobar3' + ), + ) + RackType.objects.bulk_create(racks) + + def test_q(self): + params = {'q': 'foobar1'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_manufacturer(self): + manufacturers = Manufacturer.objects.all()[:2] + params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_name(self): + params = {'name': ['RackType 1', 'RackType 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_slug(self): + params = {'slug': ['rack-type-1', 'rack-type-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) + + def test_form_factor(self): + params = {'form_factor': [RackFormFactorChoices.TYPE_2POST, RackFormFactorChoices.TYPE_4POST]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_width(self): + params = {'width': [RackWidthChoices.WIDTH_19IN, RackWidthChoices.WIDTH_21IN]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_u_height(self): + params = {'u_height': [42, 43]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_starting_unit(self): + params = {'starting_unit': [1, 2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_desc_units(self): + params = {'desc_units': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'desc_units': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_outer_width(self): + params = {'outer_width': [100, 200]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_outer_depth(self): + params = {'outer_depth': [100, 200]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_outer_unit(self): + self.assertEqual(RackType.objects.filter(outer_unit__isnull=False).count(), 3) + params = {'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_mounting_depth(self): + params = {'mounting_depth': [100, 200]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_weight(self): + params = {'weight': [10, 20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_max_weight(self): + params = {'max_weight': [1000, 2000]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_weight_unit(self): + params = {'weight_unit': WeightUnitChoices.UNIT_POUND} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + class RackTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Rack.objects.all() filterset = RackFilterSet @@ -540,7 +686,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): role=rack_roles[0], serial='ABC', asset_tag='1001', - type=RackTypeChoices.TYPE_2POST, + form_factor=RackFormFactorChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, @@ -562,7 +708,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): role=rack_roles[1], serial='DEF', asset_tag='1002', - type=RackTypeChoices.TYPE_4POST, + form_factor=RackFormFactorChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, @@ -584,7 +730,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): role=rack_roles[2], serial='GHI', asset_tag='1003', - type=RackTypeChoices.TYPE_CABINET, + form_factor=RackFormFactorChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, @@ -619,8 +765,8 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_type(self): - params = {'type': [RackTypeChoices.TYPE_2POST, RackTypeChoices.TYPE_4POST]} + def test_form_factor(self): + params = {'form_factor': [RackFormFactorChoices.TYPE_2POST, RackFormFactorChoices.TYPE_4POST]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_width(self): diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 9056a66c0..229edc0de 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -74,6 +74,61 @@ class LocationTestCase(TestCase): self.assertEqual(PowerPanel.objects.get(pk=powerpanel1.pk).site, site_b) +class RackTypeTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + + RackType.objects.create( + manufacturer=manufacturer, + name='RackType 1', + slug='rack-type-1', + width=11, + u_height=22, + starting_unit=3, + desc_units=True, + outer_width=444, + outer_depth=5, + outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, + weight=66, + weight_unit=WeightUnitChoices.UNIT_POUND, + max_weight=7777, + mounting_depth=8, + ) + + def test_rack_creation(self): + rack_type = RackType.objects.first() + sites = ( + Site(name='Site 1', slug='site-1'), + ) + Site.objects.bulk_create(sites) + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + ) + for location in locations: + location.save() + + rack = Rack.objects.create( + name='Rack 1', + facility_id='A101', + site=sites[0], + location=locations[0], + rack_type=rack_type + ) + self.assertEqual(rack.width, rack_type.width) + self.assertEqual(rack.u_height, rack_type.u_height) + self.assertEqual(rack.starting_unit, rack_type.starting_unit) + self.assertEqual(rack.desc_units, rack_type.desc_units) + self.assertEqual(rack.outer_width, rack_type.outer_width) + self.assertEqual(rack.outer_depth, rack_type.outer_depth) + self.assertEqual(rack.outer_unit, rack_type.outer_unit) + self.assertEqual(rack.weight, rack_type.weight) + self.assertEqual(rack.weight_unit, rack_type.weight_unit) + self.assertEqual(rack.max_weight, rack_type.max_weight) + self.assertEqual(rack.mounting_depth, rack_type.mounting_depth) + + class RackTestCase(TestCase): @classmethod diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index ec85fc1d5..04281b9fd 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -336,6 +336,75 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase): } +class RackTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = RackType + + @classmethod + def setUpTestData(cls): + manufacturers = ( + Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), + Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), + ) + Manufacturer.objects.bulk_create(manufacturers) + + rack_types = ( + RackType(manufacturer=manufacturers[0], name='RackType 1', slug='rack-type-1',), + RackType(manufacturer=manufacturers[0], name='RackType 2', slug='rack-type-2',), + RackType(manufacturer=manufacturers[0], name='RackType 3', slug='rack-type-3',), + ) + RackType.objects.bulk_create(rack_types) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'manufacturer': manufacturers[1].pk, + 'name': 'RackType X', + 'slug': 'rack-type-x', + 'type': RackFormFactorChoices.TYPE_CABINET, + 'width': RackWidthChoices.WIDTH_19IN, + 'u_height': 48, + 'desc_units': False, + 'outer_width': 500, + 'outer_depth': 500, + 'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER, + 'starting_unit': 1, + 'weight': 100, + 'max_weight': 2000, + 'weight_unit': WeightUnitChoices.UNIT_POUND, + 'comments': 'Some comments', + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "manufacturer,name,slug,width,u_height,weight,max_weight,weight_unit", + "Manufacturer 1,RackType 4,rack-type-4,19,42,100,2000,kg", + "Manufacturer 1,RackType 5,rack-type-5,19,42,100,2000,kg", + "Manufacturer 1,RackType 6,rack-type-6,19,42,100,2000,kg", + ) + + cls.csv_update_data = ( + "id,name", + f"{rack_types[0].pk},RackType 7", + f"{rack_types[1].pk},RackType 8", + f"{rack_types[2].pk},RackType 9", + ) + + cls.bulk_edit_data = { + 'manufacturer': manufacturers[1].pk, + 'type': RackFormFactorChoices.TYPE_4POST, + 'width': RackWidthChoices.WIDTH_23IN, + 'u_height': 49, + 'desc_units': True, + 'outer_width': 30, + 'outer_depth': 30, + 'outer_unit': RackDimensionUnitChoices.UNIT_INCH, + 'weight': 200, + 'max_weight': 4000, + 'weight_unit': WeightUnitChoices.UNIT_POUND, + 'comments': 'New comments', + } + + class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Rack @@ -380,7 +449,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'role': rackroles[1].pk, 'serial': '123456', 'asset_tag': 'ABCDEF', - 'type': RackTypeChoices.TYPE_CABINET, + 'form_factor': RackFormFactorChoices.TYPE_CABINET, 'width': RackWidthChoices.WIDTH_19IN, 'u_height': 48, 'desc_units': False, @@ -416,7 +485,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'status': RackStatusChoices.STATUS_DEPRECATED, 'role': rackroles[1].pk, 'serial': '654321', - 'type': RackTypeChoices.TYPE_4POST, + 'form_factor': RackFormFactorChoices.TYPE_4POST, 'width': RackWidthChoices.WIDTH_23IN, 'u_height': 49, 'desc_units': True, diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index c71a0aff1..627136bf9 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -63,6 +63,14 @@ urlpatterns = [ path('racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'), path('racks//', include(get_model_urls('dcim', 'rack'))), + # Rack Types + path('rack-types/', views.RackTypeListView.as_view(), name='racktype_list'), + path('rack-types/add/', views.RackTypeEditView.as_view(), name='racktype_add'), + path('rack-types/import/', views.RackTypeBulkImportView.as_view(), name='racktype_import'), + path('rack-types/edit/', views.RackTypeBulkEditView.as_view(), name='racktype_bulk_edit'), + path('rack-types/delete/', views.RackTypeBulkDeleteView.as_view(), name='racktype_bulk_delete'), + path('rack-types//', include(get_model_urls('dcim', 'racktype'))), + # Manufacturers path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'), path('manufacturers/add/', views.ManufacturerEditView.as_view(), name='manufacturer_add'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index b3d8d298e..5ff20f35e 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -579,6 +579,56 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView): table = tables.RackRoleTable +# +# RackTypes +# + +class RackTypeListView(generic.ObjectListView): + queryset = RackType.objects.all() + filterset = filtersets.RackTypeFilterSet + filterset_form = forms.RackTypeFilterForm + table = tables.RackTypeTable + + +@register_model_view(RackType) +class RackTypeView(GetRelatedModelsMixin, generic.ObjectView): + queryset = RackType.objects.all() + + def get_extra_context(self, request, instance): + return { + 'related_models': self.get_related_models(request, instance), + } + + +@register_model_view(RackType, 'edit') +class RackTypeEditView(generic.ObjectEditView): + queryset = RackType.objects.all() + form = forms.RackTypeForm + + +@register_model_view(RackType, 'delete') +class RackTypeDeleteView(generic.ObjectDeleteView): + queryset = RackType.objects.all() + + +class RackTypeBulkImportView(generic.BulkImportView): + queryset = RackType.objects.all() + model_form = forms.RackTypeImportForm + + +class RackTypeBulkEditView(generic.BulkEditView): + queryset = RackType.objects.all() + filterset = filtersets.RackTypeFilterSet + table = tables.RackTypeTable + form = forms.RackTypeBulkEditForm + + +class RackTypeBulkDeleteView(generic.BulkDeleteView): + queryset = RackType.objects.all() + filterset = filtersets.RackTypeFilterSet + table = tables.RackTypeTable + + # # Racks # diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index bf34f96b8..85ebfc579 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -1184,6 +1184,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): 'rack', 'rackreservation', 'rackrole', + 'racktype', 'rearport', 'region', 'rir', diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index d9edab36b..b96465c35 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -24,6 +24,7 @@ ORGANIZATION_MENU = Menu( label=_('Racks'), items=( get_model_item('dcim', 'rack', _('Racks')), + get_model_item('dcim', 'racktype', _('Rack Types')), get_model_item('dcim', 'rackrole', _('Rack Roles')), get_model_item('dcim', 'rackreservation', _('Reservations')), MenuItem( diff --git a/netbox/templates/dcim/inc/panels/racktype_dimensions.html b/netbox/templates/dcim/inc/panels/racktype_dimensions.html new file mode 100644 index 000000000..e955413ed --- /dev/null +++ b/netbox/templates/dcim/inc/panels/racktype_dimensions.html @@ -0,0 +1,48 @@ +{% load i18n %} +
+
{% trans "Dimensions" %}
+ + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Form factor" %}{{ object.get_form_factor_display|placeholder }}
{% trans "Width" %}{{ object.get_width_display }}
{% trans "Height" %}{{ object.u_height }}U
{% trans "Outer Width" %} + {% if object.outer_width %} + {{ object.outer_width }} {{ object.get_outer_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Outer Depth" %} + {% if object.outer_depth %} + {{ object.outer_depth }} {{ object.get_outer_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Mounting Depth" %} + {% if object.mounting_depth %} + {{ object.mounting_depth }} {% trans "Millimeters" %} + {% else %} + {{ ''|placeholder }} + {% endif %} +
+
diff --git a/netbox/templates/dcim/inc/panels/racktype_numbering.html b/netbox/templates/dcim/inc/panels/racktype_numbering.html new file mode 100644 index 000000000..9a56fa43b --- /dev/null +++ b/netbox/templates/dcim/inc/panels/racktype_numbering.html @@ -0,0 +1,14 @@ +{% load i18n %} +
+
{% trans "Numbering" %}
+ + + + + + + + + +
{% trans "Starting Unit" %}{{ object.starting_unit }}
{% trans "Descending Units" %}{% checkmark object.desc_units %}
+
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index b28ee6e31..9bf2f1827 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -9,157 +9,107 @@ {% block content %}
-
-
{% trans "Rack" %}
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Region" %} - {% nested_tree object.site.region %} -
{% trans "Site" %}{{ object.site|linkify }}
{% trans "Location" %}{% nested_tree object.location %}
{% trans "Facility ID" %}{{ object.facility_id|placeholder }}
{% trans "Tenant" %} - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Role" %}{{ object.role|linkify|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Serial Number" %}{{ object.serial|placeholder }}
{% trans "Asset Tag" %}{{ object.asset_tag|placeholder }}
{% trans "Space Utilization" %}{% utilization_graph object.get_utilization %}
{% trans "Power Utilization" %}{% utilization_graph object.get_power_utilization %}
-
-
-
{% trans "Dimensions" %}
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Type" %} - {% if object.type %} - {{ object.get_type_display }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Width" %}{{ object.get_width_display }}
{% trans "Height" %}{{ object.u_height }}U ({% if object.desc_units %}{% trans "descending" %}{% else %}{% trans "ascending" %}{% endif %})
{% trans "Starting Unit" %} - {{ object.starting_unit }} -
{% trans "Outer Width" %} - {% if object.outer_width %} - {{ object.outer_width }} {{ object.get_outer_unit_display }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Outer Depth" %} - {% if object.outer_depth %} - {{ object.outer_depth }} {{ object.get_outer_unit_display }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Mounting Depth" %} - {% if object.mounting_depth %} - {{ object.mounting_depth }} {% trans "Millimeters" %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Rack Weight" %} - {% if object.weight %} - {{ object.weight|floatformat }} {{ object.get_weight_unit_display }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Maximum Weight" %} - {% if object.max_weight %} - {{ object.max_weight }} {{ object.get_weight_unit_display }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Total Weight" %} - {{ object.total_weight|floatformat }} {% trans "Kilograms" %} - ({{ object.total_weight|kg_to_pounds|floatformat }} {% trans "Pounds" %}) -
-
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/comments.html' %} - {% include 'inc/panels/image_attachments.html' %} - {% plugin_left_page object %} +
+
{% trans "Rack" %}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Region" %}{% nested_tree object.site.region %}
{% trans "Site" %}{{ object.site|linkify }}
{% trans "Location" %}{% nested_tree object.location %}
{% trans "Facility ID" %}{{ object.facility_id|placeholder }}
{% trans "Tenant" %} + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} +
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Rack Type" %}{{ object.rack_type|linkify|placeholder }}
{% trans "Role" %}{{ object.role|linkify|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Serial Number" %}{{ object.serial|placeholder }}
{% trans "Asset Tag" %}{{ object.asset_tag|placeholder }}
{% trans "Space Utilization" %}{% utilization_graph object.get_utilization %}
{% trans "Power Utilization" %}{% utilization_graph object.get_power_utilization %}
+
+ {% include 'dcim/inc/panels/racktype_dimensions.html' %} + {% include 'dcim/inc/panels/racktype_numbering.html' %} +
+
{% trans "Weight" %}
+ + + + + + + + + + + + + +
{% trans "Rack Weight" %} + {% if object.weight %} + {{ object.weight|floatformat }} {{ object.get_weight_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Maximum Weight" %} + {% if object.max_weight %} + {{ object.max_weight }} {{ object.get_weight_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Total Weight" %} + {{ object.total_weight|floatformat }} {% trans "Kilograms" %} + ({{ object.total_weight|kg_to_pounds|floatformat }} {% trans "Pounds" %}) +
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% include 'inc/panels/image_attachments.html' %} + {% plugin_left_page object %}
@@ -170,26 +120,26 @@
-
-
-

{% trans "Front" %}

- {% include 'dcim/inc/rack_elevation.html' with face='front' extra_params=svg_extra %} -
+
+
+

{% trans "Front" %}

+ {% include 'dcim/inc/rack_elevation.html' with face='front' extra_params=svg_extra %}
-
-
-

{% trans "Rear" %}

- {% include 'dcim/inc/rack_elevation.html' with face='rear' extra_params=svg_extra %} -
+
+
+
+

{% trans "Rear" %}

+ {% include 'dcim/inc/rack_elevation.html' with face='rear' extra_params=svg_extra %}
+
{% include 'inc/panels/related_objects.html' %} {% plugin_right_page object %}
-
- {% plugin_full_width_page object %} -
+
+ {% plugin_full_width_page object %} +
{% endblock %} diff --git a/netbox/templates/dcim/racktype.html b/netbox/templates/dcim/racktype.html new file mode 100644 index 000000000..0c82b13d1 --- /dev/null +++ b/netbox/templates/dcim/racktype.html @@ -0,0 +1,71 @@ +{% extends 'generic/object.html' %} +{% load buttons %} +{% load helpers %} +{% load static %} +{% load plugins %} +{% load i18n %} +{% load mptt %} + +{% block content %} +
+
+
+
{% trans "Rack Type" %}
+ + + + + + + + + + + + + +
{% trans "Manufacturer" %}{{ object.manufacturer|linkify }}
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
+
+ {% include 'dcim/inc/panels/racktype_dimensions.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_left_page object %} +
+
+ {% include 'dcim/inc/panels/racktype_numbering.html' %} +
+
{% trans "Weight" %}
+ + + + + + + + + +
{% trans "Rack Weight" %} + {% if object.weight %} + {{ object.weight|floatformat }} {{ object.get_weight_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Maximum Weight" %} + {% if object.max_weight %} + {{ object.max_weight }} {{ object.get_weight_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/related_objects.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %}