فهرست منبع

Closes #10675: Add max_weight field to track maximum load capacity for racks

jeremystretch 3 سال پیش
والد
کامیت
0b100b8fc8

+ 5 - 1
docs/models/dcim/rack.md

@@ -73,6 +73,10 @@ The maximum depth of a mounted device that the rack can accommodate, in millimet
 
 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 asceneding numbering, with unit 1 assigned to the bottommost position.)
+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.)

+ 2 - 1
docs/release-notes/version-3.4.md

@@ -6,6 +6,7 @@
 
 * [#815](https://github.com/netbox-community/netbox/issues/815) - Enable specifying terminations when bulk importing circuits
 * [#10371](https://github.com/netbox-community/netbox/issues/10371) - Add operational status field for modules
+* [#10675](https://github.com/netbox-community/netbox/issues/10675) - Add `max_weight` field to track maximum load capacity for racks
 * [#10945](https://github.com/netbox-community/netbox/issues/10945) - Enabled recurring execution of scheduled reports & scripts
 * [#11090](https://github.com/netbox-community/netbox/issues/11090) - Add regular expression support to global search engine
 * [#11022](https://github.com/netbox-community/netbox/issues/11022) - Introduce `QUEUE_MAPPINGS` configuration parameter to allow customization of background task prioritization
@@ -146,7 +147,7 @@ This release introduces a new programmatic API that enables plugins and custom s
     * Added `description` and `comments` fields
 * dcim.Rack
     * Added a `description` field
-    * Added optional `weight` and `weight_unit` fields
+    * Added optional `weight`, `max_weight`, and `weight_unit` fields
 * dcim.RackReservation
     * Added a `comments` field
 * dcim.VirtualChassis

+ 3 - 3
netbox/dcim/api/serializers.py

@@ -210,9 +210,9 @@ class RackSerializer(NetBoxModelSerializer):
         model = Rack
         fields = [
             'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial',
-            'asset_tag', 'type', 'width', 'u_height', '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',
+            'asset_tag', 'type', 'width', 'u_height', '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',
         ]
 
 

+ 1 - 1
netbox/dcim/filtersets.py

@@ -322,7 +322,7 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
         model = Rack
         fields = [
             'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
-            'outer_unit', 'mounting_depth', 'weight', 'weight_unit'
+            'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit'
         ]
 
     def search(self, queryset, name, value):

+ 6 - 2
netbox/dcim/forms/bulk_edit.py

@@ -294,6 +294,10 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
         min_value=0,
         required=False
     )
+    max_weight = forms.IntegerField(
+        min_value=0,
+        required=False
+    )
     weight_unit = forms.ChoiceField(
         choices=add_blank_choice(WeightUnitChoices),
         required=False,
@@ -316,11 +320,11 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
         ('Hardware', (
             'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth',
         )),
-        ('Weight', ('weight', 'weight_unit')),
+        ('Weight', ('weight', 'max_weight', 'weight_unit')),
     )
     nullable_fields = (
         'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'weight',
-        'weight_unit', 'description', 'comments',
+        'max_weight', 'weight_unit', 'description', 'comments',
     )
 
 

+ 7 - 2
netbox/dcim/forms/bulk_import.py

@@ -195,13 +195,18 @@ class RackImportForm(NetBoxModelImportForm):
         required=False,
         help_text=_('Unit for outer dimensions')
     )
+    weight_unit = CSVChoiceField(
+        choices=WeightUnitChoices,
+        required=False,
+        help_text=_('Unit for rack weights')
+    )
 
     class Meta:
         model = Rack
         fields = (
             'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag',
-            'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth',
-            'description', 'comments', 'tags',
+            'width', 'u_height', '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):

+ 7 - 2
netbox/dcim/forms/filtersets.py

@@ -229,7 +229,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
         ('Hardware', ('type', 'width', 'serial', 'asset_tag')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Contacts', ('contact', 'contact_role', 'contact_group')),
-        ('Weight', ('weight', 'weight_unit')),
+        ('Weight', ('weight', 'max_weight', 'weight_unit')),
     )
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
@@ -284,7 +284,12 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
     )
     tag = TagFilterField(model)
     weight = forms.DecimalField(
-        required=False
+        required=False,
+        min_value=1
+    )
+    max_weight = forms.IntegerField(
+        required=False,
+        min_value=1
     )
     weight_unit = forms.ChoiceField(
         choices=add_blank_choice(WeightUnitChoices),

+ 1 - 1
netbox/dcim/forms/model_forms.py

@@ -279,7 +279,7 @@ class RackForm(TenancyForm, NetBoxModelForm):
         fields = [
             'region', 'site_group', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status',
             'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
-            'outer_unit', 'mounting_depth', 'weight', 'weight_unit', 'description', 'comments', 'tags',
+            'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
         ]
         help_texts = {
             'site': _("The site at which the rack exists"),

+ 23 - 9
netbox/dcim/migrations/0163_rack_devicetype_moduletype_weights.py

@@ -1,5 +1,3 @@
-# Generated by Django 4.0.7 on 2022-09-23 01:01
-
 from django.db import migrations, models
 
 
@@ -10,11 +8,8 @@ class Migration(migrations.Migration):
     ]
 
     operations = [
-        migrations.AddField(
-            model_name='devicetype',
-            name='_abs_weight',
-            field=models.PositiveBigIntegerField(blank=True, null=True),
-        ),
+
+        # Device types
         migrations.AddField(
             model_name='devicetype',
             name='weight',
@@ -26,10 +21,12 @@ class Migration(migrations.Migration):
             field=models.CharField(blank=True, max_length=50),
         ),
         migrations.AddField(
-            model_name='moduletype',
+            model_name='devicetype',
             name='_abs_weight',
             field=models.PositiveBigIntegerField(blank=True, null=True),
         ),
+
+        # Module types
         migrations.AddField(
             model_name='moduletype',
             name='weight',
@@ -41,18 +38,35 @@ class Migration(migrations.Migration):
             field=models.CharField(blank=True, max_length=50),
         ),
         migrations.AddField(
-            model_name='rack',
+            model_name='moduletype',
             name='_abs_weight',
             field=models.PositiveBigIntegerField(blank=True, null=True),
         ),
+
+        # Racks
         migrations.AddField(
             model_name='rack',
             name='weight',
             field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True),
         ),
+        migrations.AddField(
+            model_name='rack',
+            name='max_weight',
+            field=models.PositiveIntegerField(blank=True, null=True),
+        ),
         migrations.AddField(
             model_name='rack',
             name='weight_unit',
             field=models.CharField(blank=True, max_length=50),
         ),
+        migrations.AddField(
+            model_name='rack',
+            name='_abs_weight',
+            field=models.PositiveBigIntegerField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='rack',
+            name='_abs_max_weight',
+            field=models.PositiveBigIntegerField(blank=True, null=True),
+        ),
     ]

+ 1 - 3
netbox/dcim/models/mixins.py

@@ -39,7 +39,5 @@ class WeightMixin(models.Model):
         super().clean()
 
         # Validate weight and weight_unit
-        if self.weight is not None and not self.weight_unit:
+        if self.weight and not self.weight_unit:
             raise ValidationError("Must specify a unit when setting a weight")
-        elif self.weight is None:
-            self.weight_unit = ''

+ 26 - 2
netbox/dcim/models/racks.py

@@ -17,7 +17,7 @@ from dcim.svg import RackElevationSVG
 from netbox.models import OrganizationalModel, PrimaryModel
 from utilities.choices import ColorChoices
 from utilities.fields import ColorField, NaturalOrderingField
-from utilities.utils import array_to_string, drange
+from utilities.utils import array_to_string, drange, to_grams
 from .device_components import PowerPort
 from .devices import Device, Module
 from .mixins import WeightMixin
@@ -149,6 +149,16 @@ class Rack(PrimaryModel, WeightMixin):
         choices=RackDimensionUnitChoices,
         blank=True,
     )
+    max_weight = models.PositiveIntegerField(
+        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(
         blank=True,
         null=True,
@@ -174,7 +184,7 @@ class Rack(PrimaryModel, WeightMixin):
 
     clone_fields = (
         'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
-        'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'weight_unit',
+        'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
     )
     prerequisite_models = (
         'dcim.Site',
@@ -215,6 +225,10 @@ class Rack(PrimaryModel, WeightMixin):
         elif self.outer_width is None and self.outer_depth is None:
             self.outer_unit = ''
 
+        # 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")
+
         if self.pk:
             # Validate that Rack is tall enough to house the installed Devices
             top_device = Device.objects.filter(
@@ -237,6 +251,16 @@ class Rack(PrimaryModel, WeightMixin):
                         'location': f"Location must be from the same site, {self.site}."
                     })
 
+    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
+
+        super().save(*args, **kwargs)
+
     @property
     def units(self):
         """

+ 2 - 2
netbox/dcim/tables/devicetypes.py

@@ -3,7 +3,7 @@ import django_tables2 as tables
 from dcim import models
 from netbox.tables import NetBoxTable, columns
 from tenancy.tables import ContactsColumnMixin
-from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS, DEVICE_WEIGHT
+from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS, WEIGHT
 
 __all__ = (
     'ConsolePortTemplateTable',
@@ -84,7 +84,7 @@ class DeviceTypeTable(NetBoxTable):
         template_code='{{ value|floatformat }}'
     )
     weight = columns.TemplateColumn(
-        template_code=DEVICE_WEIGHT,
+        template_code=WEIGHT,
         order_by=('_abs_weight', 'weight_unit')
     )
 

+ 2 - 2
netbox/dcim/tables/modules.py

@@ -2,7 +2,7 @@ import django_tables2 as tables
 
 from dcim.models import Module, ModuleType
 from netbox.tables import NetBoxTable, columns
-from .template_code import DEVICE_WEIGHT
+from .template_code import WEIGHT
 
 __all__ = (
     'ModuleTable',
@@ -28,7 +28,7 @@ class ModuleTypeTable(NetBoxTable):
         url_name='dcim:moduletype_list'
     )
     weight = columns.TemplateColumn(
-        template_code=DEVICE_WEIGHT,
+        template_code=WEIGHT,
         order_by=('_abs_weight', 'weight_unit')
     )
 

+ 8 - 4
netbox/dcim/tables/racks.py

@@ -4,7 +4,7 @@ from django_tables2.utils import Accessor
 from dcim.models import Rack, RackReservation, RackRole
 from netbox.tables import NetBoxTable, columns
 from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
-from .template_code import DEVICE_WEIGHT
+from .template_code import WEIGHT
 
 __all__ = (
     'RackTable',
@@ -81,17 +81,21 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
         verbose_name='Outer Depth'
     )
     weight = columns.TemplateColumn(
-        template_code=DEVICE_WEIGHT,
+        template_code=WEIGHT,
         order_by=('_abs_weight', 'weight_unit')
     )
+    max_weight = columns.TemplateColumn(
+        template_code=WEIGHT,
+        order_by=('_abs_max_weight', 'weight_unit')
+    )
 
     class Meta(NetBoxTable.Meta):
         model = Rack
         fields = (
             'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial',
             'asset_tag', 'type', 'u_height', 'width', 'outer_width', 'outer_depth', 'mounting_depth', 'weight',
-            'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'description', 'contacts', 'tags',
-            'created', 'last_updated',
+            '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',

+ 2 - 2
netbox/dcim/tables/template_code.py

@@ -15,9 +15,9 @@ CABLE_LENGTH = """
 {% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %}
 """
 
-DEVICE_WEIGHT = """
+WEIGHT = """
 {% load helpers %}
-{% if record.weight %}{{ record.weight|simplify_decimal }} {{ record.weight_unit }}{% endif %}
+{% if value %}{{ value|simplify_decimal }} {{ record.weight_unit }}{% endif %}
 """
 
 DEVICE_LINK = """

+ 7 - 3
netbox/dcim/tests/test_filtersets.py

@@ -409,9 +409,9 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
         Tenant.objects.bulk_create(tenants)
 
         racks = (
-            Rack(name='Rack 1', facility_id='rack-1', site=sites[0], location=locations[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=10, weight_unit=WeightUnitChoices.UNIT_POUND),
-            Rack(name='Rack 2', facility_id='rack-2', site=sites[1], location=locations[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=20, weight_unit=WeightUnitChoices.UNIT_POUND),
-            Rack(name='Rack 3', facility_id='rack-3', site=sites[2], location=locations[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH, weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM),
+            Rack(name='Rack 1', facility_id='rack-1', site=sites[0], location=locations[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=10, max_weight=1000, weight_unit=WeightUnitChoices.UNIT_POUND),
+            Rack(name='Rack 2', facility_id='rack-2', site=sites[1], location=locations[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=20, max_weight=2000, weight_unit=WeightUnitChoices.UNIT_POUND),
+            Rack(name='Rack 3', facility_id='rack-3', site=sites[2], location=locations[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH, weight=30, max_weight=3000, weight_unit=WeightUnitChoices.UNIT_KILOGRAM),
         )
         Rack.objects.bulk_create(racks)
 
@@ -521,6 +521,10 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
         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)

+ 10 - 4
netbox/dcim/tests/test_views.py

@@ -388,15 +388,18 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'outer_width': 500,
             'outer_depth': 500,
             'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER,
+            'weight': 100,
+            'max_weight': 2000,
+            'weight_unit': WeightUnitChoices.UNIT_POUND,
             'comments': 'Some comments',
             'tags': [t.pk for t in tags],
         }
 
         cls.csv_data = (
-            "site,location,name,status,width,u_height",
-            "Site 1,,Rack 4,active,19,42",
-            "Site 1,Location 1,Rack 5,active,19,42",
-            "Site 2,Location 2,Rack 6,active,19,42",
+            "site,location,name,status,width,u_height,weight,max_weight,weight_unit",
+            "Site 1,,Rack 4,active,19,42,100,2000,kg",
+            "Site 1,Location 1,Rack 5,active,19,42,100,2000,kg",
+            "Site 2,Location 2,Rack 6,active,19,42,100,2000,kg",
         )
 
         cls.csv_update_data = (
@@ -420,6 +423,9 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'outer_width': 30,
             'outer_depth': 30,
             'outer_unit': RackDimensionUnitChoices.UNIT_INCH,
+            'weight': 200,
+            'max_weight': 4000,
+            'weight_unit': WeightUnitChoices.UNIT_POUND,
             'comments': 'New comments',
         }
 

+ 10 - 0
netbox/templates/dcim/rack.html

@@ -169,6 +169,16 @@
                             {% endif %}
                         </td>
                     </tr>
+                    <tr>
+                        <th scope="row">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">Total Weight</th>
                         <td>

+ 5 - 1
netbox/templates/dcim/rack_edit.html

@@ -58,10 +58,14 @@
         </div>
         <div class="row mb-3">
             <label class="col col-md-3 col-form-label text-lg-end">Weight</label>
-            <div class="col col-md-6 mb-1">
+            <div class="col col-md-3 mb-1">
                 {{ form.weight }}
                 <div class="form-text">Weight</div>
             </div>
+            <div class="col col-md-3 mb-1">
+                {{ form.max_weight }}
+                <div class="form-text">Maximum Weight</div>
+            </div>
             <div class="col col-md-3 mb-1">
                 {{ form.weight_unit }}
                 <div class="form-text">Unit</div>