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 <jstretch@netboxlabs.com>
This commit is contained in:
Arthur Hanson 2024-07-16 19:58:22 +07:00 committed by GitHub
parent b0e7294bc1
commit 5a6ffde67e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1504 additions and 353 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -51,7 +51,7 @@ class LocationStatusChoices(ChoiceSet):
# Racks
#
class RackTypeChoices(ChoiceSet):
class RackFormFactorChoices(ChoiceSet):
TYPE_2POST = '2-post-frame'
TYPE_4POST = '4-post-frame'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -63,6 +63,14 @@ urlpatterns = [
path('racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
path('racks/<int:pk>/', 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/<int:pk>/', 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'),

View File

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

View File

@ -1184,6 +1184,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
'rack',
'rackreservation',
'rackrole',
'racktype',
'rearport',
'region',
'rir',

View File

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

View File

@ -0,0 +1,48 @@
{% load i18n %}
<div class="card">
<h5 class="card-header">{% trans "Dimensions" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Form factor" %}</th>
<td>{{ object.get_form_factor_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Width" %}</th>
<td>{{ object.get_width_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Height" %}</th>
<td>{{ object.u_height }}U</td>
</tr>
<tr>
<th scope="row">{% trans "Outer Width" %}</th>
<td>
{% if object.outer_width %}
{{ object.outer_width }} {{ object.get_outer_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Outer Depth" %}</th>
<td>
{% if object.outer_depth %}
{{ object.outer_depth }} {{ object.get_outer_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Mounting Depth" %}</th>
<td>
{% if object.mounting_depth %}
{{ object.mounting_depth }} {% trans "Millimeters" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
</div>

View File

@ -0,0 +1,14 @@
{% load i18n %}
<div class="card">
<h5 class="card-header">{% trans "Numbering" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Starting Unit" %}</th>
<td>{{ object.starting_unit }}</td>
</tr>
<tr>
<th scope="row">{% trans "Descending Units" %}</th>
<td>{% checkmark object.desc_units %}</td>
</tr>
</table>
</div>

View File

@ -9,157 +9,107 @@
{% block content %}
<div class="row">
<div class="col col-12 col-xl-5">
<div class="card">
<h5 class="card-header">{% trans "Rack" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Region" %}</th>
<td>
{% nested_tree object.site.region %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Site" %}</th>
<td>{{ object.site|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Location" %}</th>
<td>{% nested_tree object.location %}</td>
</tr>
<tr>
<th scope="row">{% trans "Facility ID" %}</th>
<td>{{ object.facility_id|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>
{% if object.tenant.group %}
{{ object.tenant.group|linkify }} /
{% endif %}
{{ object.tenant|linkify|placeholder }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Role" %}</th>
<td>{{ object.role|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Serial Number" %}</th>
<td class="font-monospace">{{ object.serial|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Asset Tag" %}</th>
<td class="font-monospace">{{ object.asset_tag|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Space Utilization" %}</th>
<td>{% utilization_graph object.get_utilization %}</td>
</tr>
<tr>
<th scope="row">{% trans "Power Utilization" %}</th>
<td>{% utilization_graph object.get_power_utilization %}</td>
</tr>
</table>
</div>
<div class="card">
<h5 class="card-header">{% trans "Dimensions" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Type" %}</th>
<td>
{% if object.type %}
{{ object.get_type_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Width" %}</th>
<td>{{ object.get_width_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Height" %}</th>
<td>{{ object.u_height }}U ({% if object.desc_units %}{% trans "descending" %}{% else %}{% trans "ascending" %}{% endif %})</td>
</tr>
<tr>
<th scope="row">{% trans "Starting Unit" %}</th>
<td>
{{ object.starting_unit }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Outer Width" %}</th>
<td>
{% if object.outer_width %}
{{ object.outer_width }} {{ object.get_outer_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Outer Depth" %}</th>
<td>
{% if object.outer_depth %}
{{ object.outer_depth }} {{ object.get_outer_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Mounting Depth" %}</th>
<td>
{% if object.mounting_depth %}
{{ object.mounting_depth }} {% trans "Millimeters" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Rack Weight" %}</th>
<td>
{% if object.weight %}
{{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Maximum Weight" %}</th>
<td>
{% if object.max_weight %}
{{ object.max_weight }} {{ object.get_weight_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Total Weight" %}</th>
<td>
{{ object.total_weight|floatformat }} {% trans "Kilograms" %}
({{ object.total_weight|kg_to_pounds|floatformat }} {% trans "Pounds" %})
</td>
</tr>
</table>
</div>
{% 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 %}
<div class="card">
<h5 class="card-header">{% trans "Rack" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Region" %}</th>
<td>{% nested_tree object.site.region %}</td>
</tr>
<tr>
<th scope="row">{% trans "Site" %}</th>
<td>{{ object.site|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Location" %}</th>
<td>{% nested_tree object.location %}</td>
</tr>
<tr>
<th scope="row">{% trans "Facility ID" %}</th>
<td>{{ object.facility_id|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>
{% if object.tenant.group %}
{{ object.tenant.group|linkify }} /
{% endif %}
{{ object.tenant|linkify|placeholder }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Rack Type" %}</th>
<td>{{ object.rack_type|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Role" %}</th>
<td>{{ object.role|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Serial Number" %}</th>
<td class="font-monospace">{{ object.serial|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Asset Tag" %}</th>
<td class="font-monospace">{{ object.asset_tag|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Space Utilization" %}</th>
<td>{% utilization_graph object.get_utilization %}</td>
</tr>
<tr>
<th scope="row">{% trans "Power Utilization" %}</th>
<td>{% utilization_graph object.get_power_utilization %}</td>
</tr>
</table>
</div>
{% include 'dcim/inc/panels/racktype_dimensions.html' %}
{% include 'dcim/inc/panels/racktype_numbering.html' %}
<div class="card">
<h5 class="card-header">{% trans "Weight" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Rack Weight" %}</th>
<td>
{% if object.weight %}
{{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Maximum Weight" %}</th>
<td>
{% if object.max_weight %}
{{ object.max_weight }} {{ object.get_weight_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Total Weight" %}</th>
<td>
{{ object.total_weight|floatformat }} {% trans "Kilograms" %}
({{ object.total_weight|kg_to_pounds|floatformat }} {% trans "Pounds" %})
</td>
</tr>
</table>
</div>
{% 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 %}
</div>
<div class="col col-12 col-xl-7">
<div class="text-end mb-4">
@ -170,26 +120,26 @@
</select>
</div>
<div class="row" style="margin-bottom: 20px">
<div class="col col-md-6 col-sm-6 col-xs-12 text-center">
<div style="margin-left: 30px">
<h4>{% trans "Front" %}</h4>
{% include 'dcim/inc/rack_elevation.html' with face='front' extra_params=svg_extra %}
</div>
<div class="col col-md-6 col-sm-6 col-xs-12 text-center">
<div style="margin-left: 30px">
<h4>{% trans "Front" %}</h4>
{% include 'dcim/inc/rack_elevation.html' with face='front' extra_params=svg_extra %}
</div>
<div class="col col-md-6 col-sm-6 col-xs-12 text-center">
<div style="margin-left: 30px">
<h4>{% trans "Rear" %}</h4>
{% include 'dcim/inc/rack_elevation.html' with face='rear' extra_params=svg_extra %}
</div>
</div>
<div class="col col-md-6 col-sm-6 col-xs-12 text-center">
<div style="margin-left: 30px">
<h4>{% trans "Rear" %}</h4>
{% include 'dcim/inc/rack_elevation.html' with face='rear' extra_params=svg_extra %}
</div>
</div>
</div>
{% include 'inc/panels/related_objects.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,71 @@
{% extends 'generic/object.html' %}
{% load buttons %}
{% load helpers %}
{% load static %}
{% load plugins %}
{% load i18n %}
{% load mptt %}
{% block content %}
<div class="row">
<div class="col col-6">
<div class="card">
<h5 class="card-header">{% trans "Rack Type" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Manufacturer" %}</th>
<td>{{ object.manufacturer|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
{% include 'dcim/inc/panels/racktype_dimensions.html' %}
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-6">
{% include 'dcim/inc/panels/racktype_numbering.html' %}
<div class="card">
<h5 class="card-header">{% trans "Weight" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Rack Weight" %}</th>
<td>
{% if object.weight %}
{{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Maximum Weight" %}</th>
<td>
{% if object.max_weight %}
{{ object.max_weight }} {{ object.get_weight_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/related_objects.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}