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
32 changed files with 1504 additions and 353 deletions

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