Răsfoiți Sursa

9654 device weight (#10448)

* 9654 add weight fields to devices

* 9654 changes from code review

* 9654 change _abs_weight to grams

* Resolve migrations conflict

* 9654 code-review changes

* 9654 total weight on devices

* Misc cleanup

Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
Arthur Hanson 3 ani în urmă
părinte
comite
204c10c053

+ 4 - 0
docs/models/dcim/devicetype.md

@@ -41,6 +41,10 @@ Indicates whether this is a parent type (capable of housing child devices), a ch
 
 The default direction in which airflow circulates within the device chassis. This may be configured differently for instantiated devices (e.g. because of different fan modules).
 
+### Weight
+
+The numeric weight of the device, including a unit designation (e.g. 10 kilograms or 20 pounds).
+
 ### Front & Rear Images
 
 Users can upload illustrations of the device's front and rear panels. If present, these will be used to render the device in [rack](./rack.md) elevation diagrams.

+ 4 - 0
docs/models/dcim/moduletype.md

@@ -35,3 +35,7 @@ The model number assigned to this module type by its manufacturer. Must be uniqu
 ### Part Number
 
 An alternative part number to uniquely identify the module type.
+
+### Weight
+
+The numeric weight of the module, including a unit designation (e.g. 3 kilograms or 1 pound).

+ 4 - 0
docs/models/dcim/rack.md

@@ -65,6 +65,10 @@ The height of the rack, measured in units.
 
 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.
 
+### Weight
+
+The numeric weight of the rack, including a unit designation (e.g. 10 kilograms or 20 pounds).
+
 ### 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.)

+ 10 - 6
netbox/dcim/api/serializers.py

@@ -201,6 +201,7 @@ class RackSerializer(NetBoxModelSerializer):
                                         default=None)
     width = ChoiceField(choices=RackWidthChoices, required=False)
     outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False)
+    weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False)
     device_count = serializers.IntegerField(read_only=True)
     powerfeed_count = serializers.IntegerField(read_only=True)
 
@@ -208,8 +209,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', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
-            'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
+            'asset_tag', 'type', 'width', 'u_height', 'weight', 'weight_unit', 'desc_units', 'outer_width',
+            'outer_depth', 'outer_unit', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
+            'powerfeed_count',
         ]
 
 
@@ -315,27 +317,29 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
     )
     subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
     airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
+    weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False)
     device_count = serializers.IntegerField(read_only=True)
 
     class Meta:
         model = DeviceType
         fields = [
             'id', 'url', 'display', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
-            'subdevice_role', 'airflow', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields', 'created',
-            'last_updated', 'device_count',
+            'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'comments', 'tags',
+            'custom_fields', 'created', 'last_updated', 'device_count',
         ]
 
 
 class ModuleTypeSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail')
     manufacturer = NestedManufacturerSerializer()
+    weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False)
     # module_count = serializers.IntegerField(read_only=True)
 
     class Meta:
         model = ModuleType
         fields = [
-            'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'comments', 'tags', 'custom_fields',
-            'created', 'last_updated',
+            'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'comments', 'tags',
+            'custom_fields', 'created', 'last_updated',
         ]
 
 

+ 18 - 0
netbox/dcim/choices.py

@@ -1314,6 +1314,24 @@ class CableLengthUnitChoices(ChoiceSet):
     )
 
 
+class WeightUnitChoices(ChoiceSet):
+
+    # Metric
+    UNIT_KILOGRAM = 'kg'
+    UNIT_GRAM = 'g'
+
+    # Imperial
+    UNIT_POUND = 'lb'
+    UNIT_OUNCE = 'oz'
+
+    CHOICES = (
+        (UNIT_KILOGRAM, 'Kilograms'),
+        (UNIT_GRAM, 'Grams'),
+        (UNIT_POUND, 'Pounds'),
+        (UNIT_OUNCE, 'Ounces'),
+    )
+
+
 #
 # CableTerminations
 #

+ 3 - 3
netbox/dcim/filtersets.py

@@ -320,7 +320,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',
+            'outer_unit', 'weight', 'weight_unit'
         ]
 
     def search(self, queryset, name, value):
@@ -482,7 +482,7 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
     class Meta:
         model = DeviceType
         fields = [
-            'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
+            'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
         ]
 
     def search(self, queryset, name, value):
@@ -576,7 +576,7 @@ class ModuleTypeFilterSet(NetBoxModelFilterSet):
 
     class Meta:
         model = ModuleType
-        fields = ['id', 'model', 'part_number']
+        fields = ['id', 'model', 'part_number', 'weight', 'weight_unit']
 
     def search(self, queryset, name, value):
         if not value.strip():

+ 38 - 16
netbox/dcim/forms/bulk_edit.py

@@ -285,15 +285,26 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
         widget=SmallTextarea,
         label='Comments'
     )
+    weight = forms.DecimalField(
+        min_value=0,
+        required=False
+    )
+    weight_unit = forms.ChoiceField(
+        choices=add_blank_choice(WeightUnitChoices),
+        required=False,
+        initial='',
+        widget=StaticSelect()
+    )
 
     model = Rack
     fieldsets = (
         ('Rack', ('status', 'role', 'tenant', 'serial', 'asset_tag')),
         ('Location', ('region', 'site_group', 'site', 'location')),
         ('Hardware', ('type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit')),
+        ('Weight', ('weight', 'weight_unit')),
     )
     nullable_fields = (
-        'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
+        'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', 'weight', 'weight_unit'
     )
 
 
@@ -355,12 +366,23 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
         required=False,
         widget=StaticSelect()
     )
+    weight = forms.DecimalField(
+        min_value=0,
+        required=False
+    )
+    weight_unit = forms.ChoiceField(
+        choices=add_blank_choice(WeightUnitChoices),
+        required=False,
+        initial='',
+        widget=StaticSelect()
+    )
 
     model = DeviceType
     fieldsets = (
-        (None, ('manufacturer', 'part_number', 'u_height', 'is_full_depth', 'airflow')),
+        ('Device Type', ('manufacturer', 'part_number', 'u_height', 'is_full_depth', 'airflow')),
+        ('Weight', ('weight', 'weight_unit')),
     )
-    nullable_fields = ('part_number', 'airflow')
+    nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit')
 
 
 class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
@@ -371,12 +393,23 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
     part_number = forms.CharField(
         required=False
     )
+    weight = forms.DecimalField(
+        min_value=0,
+        required=False
+    )
+    weight_unit = forms.ChoiceField(
+        choices=add_blank_choice(WeightUnitChoices),
+        required=False,
+        initial='',
+        widget=StaticSelect()
+    )
 
     model = ModuleType
     fieldsets = (
-        (None, ('manufacturer', 'part_number')),
+        ('Module Type', ('manufacturer', 'part_number')),
+        ('Weight', ('weight', 'weight_unit')),
     )
-    nullable_fields = ('part_number',)
+    nullable_fields = ('part_number', 'weight', 'weight_unit')
 
 
 class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
@@ -553,17 +586,6 @@ class CableBulkEditForm(NetBoxModelBulkEditForm):
         'type', 'status', 'tenant', 'label', 'color', 'length',
     )
 
-    def clean(self):
-        super().clean()
-
-        # Validate length/unit
-        length = self.cleaned_data.get('length')
-        length_unit = self.cleaned_data.get('length_unit')
-        if length and not length_unit:
-            raise forms.ValidationError({
-                'length_unit': "Must specify a unit when setting length"
-            })
-
 
 class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm):
     domain = forms.CharField(

+ 24 - 0
netbox/dcim/forms/filtersets.py

@@ -228,6 +228,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')),
     )
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
@@ -281,6 +282,13 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
         required=False
     )
     tag = TagFilterField(model)
+    weight = forms.DecimalField(
+        required=False
+    )
+    weight_unit = forms.ChoiceField(
+        choices=add_blank_choice(WeightUnitChoices),
+        required=False
+    )
 
 
 class RackElevationFilterForm(RackFilterForm):
@@ -370,6 +378,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
             'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items',
         )),
+        ('Weight', ('weight', 'weight_unit')),
     )
     manufacturer_id = DynamicModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),
@@ -465,6 +474,13 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
         )
     )
     tag = TagFilterField(model)
+    weight = forms.DecimalField(
+        required=False
+    )
+    weight_unit = forms.ChoiceField(
+        choices=add_blank_choice(WeightUnitChoices),
+        required=False
+    )
 
 
 class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
@@ -476,6 +492,7 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
             'pass_through_ports',
         )),
+        ('Weight', ('weight', 'weight_unit')),
     )
     manufacturer_id = DynamicModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),
@@ -529,6 +546,13 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
         )
     )
     tag = TagFilterField(model)
+    weight = forms.DecimalField(
+        required=False
+    )
+    weight_unit = forms.ChoiceField(
+        choices=add_blank_choice(WeightUnitChoices),
+        required=False
+    )
 
 
 class DeviceRoleFilterForm(NetBoxModelFilterSetForm):

+ 12 - 5
netbox/dcim/forms/models.py

@@ -260,7 +260,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', 'comments', 'tags',
+            'outer_unit', 'weight', 'weight_unit', 'comments', 'tags',
         ]
         help_texts = {
             'site': "The site at which the rack exists",
@@ -273,6 +273,7 @@ class RackForm(TenancyForm, NetBoxModelForm):
             'type': StaticSelect(),
             'width': StaticSelect(),
             'outer_unit': StaticSelect(),
+            'weight_unit': StaticSelect(),
         }
 
 
@@ -363,6 +364,7 @@ class DeviceTypeForm(NetBoxModelForm):
         ('Chassis', (
             'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
         )),
+        ('Attributes', ('weight', 'weight_unit')),
         ('Images', ('front_image', 'rear_image')),
     )
 
@@ -370,7 +372,7 @@ class DeviceTypeForm(NetBoxModelForm):
         model = DeviceType
         fields = [
             'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
-            'front_image', 'rear_image', 'comments', 'tags',
+            'weight', 'weight_unit', 'front_image', 'rear_image', 'comments', 'tags',
         ]
         widgets = {
             'airflow': StaticSelect(),
@@ -380,7 +382,8 @@ class DeviceTypeForm(NetBoxModelForm):
             }),
             'rear_image': ClearableFileInput(attrs={
                 'accept': DEVICETYPE_IMAGE_FORMATS
-            })
+            }),
+            'weight_unit': StaticSelect(),
         }
 
 
@@ -392,16 +395,20 @@ class ModuleTypeForm(NetBoxModelForm):
 
     fieldsets = (
         ('Module Type', (
-            'manufacturer', 'model', 'part_number', 'tags',
+            'manufacturer', 'model', 'part_number', 'tags', 'weight', 'weight_unit'
         )),
     )
 
     class Meta:
         model = ModuleType
         fields = [
-            'manufacturer', 'model', 'part_number', 'comments', 'tags',
+            'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'comments', 'tags',
         ]
 
+        widgets = {
+            'weight_unit': StaticSelect(),
+        }
+
 
 class DeviceRoleForm(NetBoxModelForm):
     slug = SlugField()

+ 9 - 0
netbox/dcim/graphql/types.py

@@ -211,6 +211,9 @@ class DeviceTypeType(NetBoxObjectType):
     def resolve_airflow(self, info):
         return self.airflow or None
 
+    def resolve_weight_unit(self, info):
+        return self.weight_unit or None
+
 
 class FrontPortType(ComponentObjectType, CabledObjectMixin):
 
@@ -328,6 +331,9 @@ class ModuleTypeType(NetBoxObjectType):
         fields = '__all__'
         filterset_class = filtersets.ModuleTypeFilterSet
 
+    def resolve_weight_unit(self, info):
+        return self.weight_unit or None
+
 
 class PlatformType(OrganizationalObjectType):
 
@@ -416,6 +422,9 @@ class RackType(VLANGroupsMixin, ImageAttachmentsMixin, NetBoxObjectType):
     def resolve_outer_unit(self, info):
         return self.outer_unit or None
 
+    def resolve_weight_unit(self, info):
+        return self.weight_unit or None
+
 
 class RackReservationType(NetBoxObjectType):
 

+ 58 - 0
netbox/dcim/migrations/0163_rack_devicetype_moduletype_weights.py

@@ -0,0 +1,58 @@
+# Generated by Django 4.0.7 on 2022-09-23 01:01
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0162_unique_constraints'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='devicetype',
+            name='_abs_weight',
+            field=models.PositiveBigIntegerField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='devicetype',
+            name='weight',
+            field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True),
+        ),
+        migrations.AddField(
+            model_name='devicetype',
+            name='weight_unit',
+            field=models.CharField(blank=True, max_length=50),
+        ),
+        migrations.AddField(
+            model_name='moduletype',
+            name='_abs_weight',
+            field=models.PositiveBigIntegerField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='moduletype',
+            name='weight',
+            field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True),
+        ),
+        migrations.AddField(
+            model_name='moduletype',
+            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='weight',
+            field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True),
+        ),
+        migrations.AddField(
+            model_name='rack',
+            name='weight_unit',
+            field=models.CharField(blank=True, max_length=50),
+        ),
+    ]

+ 19 - 5
netbox/dcim/models/devices.py

@@ -1,7 +1,8 @@
 import decimal
-
 import yaml
 
+from functools import cached_property
+
 from django.apps import apps
 from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
@@ -21,6 +22,7 @@ from netbox.models import OrganizationalModel, NetBoxModel
 from utilities.choices import ColorChoices
 from utilities.fields import ColorField, NaturalOrderingField
 from .device_components import *
+from .mixins import WeightMixin
 
 
 __all__ = (
@@ -71,7 +73,7 @@ class Manufacturer(OrganizationalModel):
         return reverse('dcim:manufacturer', args=[self.pk])
 
 
-class DeviceType(NetBoxModel):
+class DeviceType(NetBoxModel, WeightMixin):
     """
     A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
     well as high-level functional role(s).
@@ -139,7 +141,7 @@ class DeviceType(NetBoxModel):
     )
 
     clone_fields = (
-        'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
+        'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
     )
 
     class Meta:
@@ -315,7 +317,7 @@ class DeviceType(NetBoxModel):
         return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD
 
 
-class ModuleType(NetBoxModel):
+class ModuleType(NetBoxModel, WeightMixin):
     """
     A ModuleType represents a hardware element that can be installed within a device and which houses additional
     components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a
@@ -344,7 +346,7 @@ class ModuleType(NetBoxModel):
         to='extras.ImageAttachment'
     )
 
-    clone_fields = ('manufacturer',)
+    clone_fields = ('manufacturer', 'weight', 'weight_unit',)
 
     class Meta:
         ordering = ('manufacturer', 'model')
@@ -946,6 +948,18 @@ class Device(NetBoxModel, ConfigContextModel):
     def get_status_color(self):
         return DeviceStatusChoices.colors.get(self.status)
 
+    @cached_property
+    def total_weight(self):
+        total_weight = sum(
+            module.module_type._abs_weight
+            for module in Module.objects.filter(device=self)
+            .exclude(module_type___abs_weight__isnull=True)
+            .prefetch_related('module_type')
+        )
+        if self.device_type._abs_weight:
+            total_weight += self.device_type._abs_weight
+        return round(total_weight / 1000, 2)
+
 
 class Module(NetBoxModel, ConfigContextModel):
     """

+ 45 - 0
netbox/dcim/models/mixins.py

@@ -0,0 +1,45 @@
+from django.core.exceptions import ValidationError
+from django.db import models
+from dcim.choices import *
+from utilities.utils import to_grams
+
+
+class WeightMixin(models.Model):
+    weight = models.DecimalField(
+        max_digits=8,
+        decimal_places=2,
+        blank=True,
+        null=True
+    )
+    weight_unit = models.CharField(
+        max_length=50,
+        choices=WeightUnitChoices,
+        blank=True,
+    )
+    # Stores the normalized weight (in grams) for database ordering
+    _abs_weight = models.PositiveBigIntegerField(
+        blank=True,
+        null=True
+    )
+
+    class Meta:
+        abstract = True
+
+    def save(self, *args, **kwargs):
+
+        # Store the given weight (if any) in grams for use in database ordering
+        if self.weight and self.weight_unit:
+            self._abs_weight = to_grams(self.weight, self.weight_unit)
+        else:
+            self._abs_weight = None
+
+        super().save(*args, **kwargs)
+
+    def clean(self):
+        super().clean()
+
+        # Validate weight and weight_unit
+        if self.weight is not None and not self.weight_unit:
+            raise ValidationError("Must specify a unit when setting a weight")
+        elif self.weight is None:
+            self.weight_unit = ''

+ 21 - 3
netbox/dcim/models/racks.py

@@ -1,4 +1,5 @@
 import decimal
+from functools import cached_property
 
 from django.apps import apps
 from django.contrib.auth.models import User
@@ -18,7 +19,8 @@ from utilities.choices import ColorChoices
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.utils import array_to_string, drange
 from .device_components import PowerPort
-from .devices import Device
+from .devices import Device, Module
+from .mixins import WeightMixin
 from .power import PowerFeed
 
 __all__ = (
@@ -62,7 +64,7 @@ class RackRole(OrganizationalModel):
         return reverse('dcim:rackrole', args=[self.pk])
 
 
-class Rack(NetBoxModel):
+class Rack(NetBoxModel, WeightMixin):
     """
     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.
@@ -185,7 +187,7 @@ class Rack(NetBoxModel):
 
     clone_fields = (
         'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
-        'outer_depth', 'outer_unit',
+        'outer_depth', 'outer_unit', 'weight', 'weight_unit',
     )
 
     class Meta:
@@ -454,6 +456,22 @@ class Rack(NetBoxModel):
 
         return int(allocated_draw / available_power_total * 100)
 
+    @cached_property
+    def total_weight(self):
+        total_weight = sum(
+            device.device_type._abs_weight
+            for device in self.devices.exclude(device_type___abs_weight__isnull=True).prefetch_related('device_type')
+        )
+        total_weight += sum(
+            module.module_type._abs_weight
+            for module in Module.objects.filter(device__rack=self)
+            .exclude(module_type___abs_weight__isnull=True)
+            .prefetch_related('module_type')
+        )
+        if self._abs_weight:
+            total_weight += self._abs_weight
+        return round(total_weight / 1000, 2)
+
 
 class RackReservation(NetBoxModel):
     """

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

@@ -5,7 +5,7 @@ from dcim.models import (
     InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
 )
 from netbox.tables import NetBoxTable, columns
-from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS
+from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS, DEVICE_WEIGHT
 
 __all__ = (
     'ConsolePortTemplateTable',
@@ -85,12 +85,16 @@ class DeviceTypeTable(NetBoxTable):
     tags = columns.TagColumn(
         url_name='dcim:devicetype_list'
     )
+    weight = columns.TemplateColumn(
+        template_code=DEVICE_WEIGHT,
+        order_by=('_abs_weight', 'weight_unit')
+    )
 
     class Meta(NetBoxTable.Meta):
         model = DeviceType
         fields = (
             'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
-            'airflow', 'comments', 'instance_count', 'tags', 'created', 'last_updated',
+            'airflow', 'weight', 'comments', 'instance_count', 'tags', 'created', 'last_updated',
         )
         default_columns = (
             'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',

+ 6 - 1
netbox/dcim/tables/modules.py

@@ -2,6 +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
 
 __all__ = (
     'ModuleTable',
@@ -26,11 +27,15 @@ class ModuleTypeTable(NetBoxTable):
     tags = columns.TagColumn(
         url_name='dcim:moduletype_list'
     )
+    weight = columns.TemplateColumn(
+        template_code=DEVICE_WEIGHT,
+        order_by=('_abs_weight', 'weight_unit')
+    )
 
     class Meta(NetBoxTable.Meta):
         model = ModuleType
         fields = (
-            'pk', 'id', 'model', 'manufacturer', 'part_number', 'comments', 'tags',
+            'pk', 'id', 'model', 'manufacturer', 'part_number', 'weight', 'comments', 'tags',
         )
         default_columns = (
             'pk', 'model', 'manufacturer', 'part_number',

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

@@ -4,6 +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 TenancyColumnsMixin
+from .template_code import DEVICE_WEIGHT
 
 __all__ = (
     'RackTable',
@@ -82,13 +83,17 @@ class RackTable(TenancyColumnsMixin, NetBoxTable):
         template_code="{{ record.outer_depth }} {{ record.outer_unit }}",
         verbose_name='Outer Depth'
     )
+    weight = columns.TemplateColumn(
+        template_code=DEVICE_WEIGHT,
+        order_by=('_abs_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', 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization',
-            'get_power_utilization', 'contacts', 'tags', 'created', 'last_updated',
+            'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial',
+            'asset_tag', 'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'weight', 'comments',
+            'device_count', 'get_utilization', 'get_power_utilization', 'contacts', 'tags', 'created', 'last_updated',
         )
         default_columns = (
             'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',

+ 5 - 0
netbox/dcim/tables/template_code.py

@@ -15,6 +15,11 @@ CABLE_LENGTH = """
 {% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %}
 """
 
+DEVICE_WEIGHT = """
+{% load helpers %}
+{% if record.weight %}{{ record.weight|simplify_decimal }} {{ record.weight_unit }}{% endif %}
+"""
+
 DEVICE_LINK = """
 <a href="{% url 'dcim:device' pk=record.pk %}">
     {{ record.name|default:'<span class="badge bg-info">Unnamed device</span>' }}

+ 33 - 9
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),
-            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),
-            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),
+            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.objects.bulk_create(racks)
 
@@ -517,6 +517,14 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
         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_weight_unit(self):
+        params = {'weight_unit': WeightUnitChoices.UNIT_POUND}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = RackReservation.objects.all()
@@ -688,9 +696,9 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
         Manufacturer.objects.bulk_create(manufacturers)
 
         device_types = (
-            DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True, front_image='front.png', rear_image='rear.png'),
-            DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR),
-            DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT),
+            DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True, front_image='front.png', rear_image='rear.png', weight=10, weight_unit=WeightUnitChoices.UNIT_POUND),
+            DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, weight=20, weight_unit=WeightUnitChoices.UNIT_POUND),
+            DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM),
         )
         DeviceType.objects.bulk_create(device_types)
 
@@ -839,6 +847,14 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'inventory_items': 'false'}
         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_weight_unit(self):
+        params = {'weight_unit': WeightUnitChoices.UNIT_POUND}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ModuleType.objects.all()
@@ -855,9 +871,9 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
         Manufacturer.objects.bulk_create(manufacturers)
 
         module_types = (
-            ModuleType(manufacturer=manufacturers[0], model='Model 1', part_number='Part Number 1'),
-            ModuleType(manufacturer=manufacturers[1], model='Model 2', part_number='Part Number 2'),
-            ModuleType(manufacturer=manufacturers[2], model='Model 3', part_number='Part Number 3'),
+            ModuleType(manufacturer=manufacturers[0], model='Model 1', part_number='Part Number 1', weight=10, weight_unit=WeightUnitChoices.UNIT_POUND),
+            ModuleType(manufacturer=manufacturers[1], model='Model 2', part_number='Part Number 2', weight=20, weight_unit=WeightUnitChoices.UNIT_POUND),
+            ModuleType(manufacturer=manufacturers[2], model='Model 3', part_number='Part Number 3', weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM),
         )
         ModuleType.objects.bulk_create(module_types)
 
@@ -943,6 +959,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'pass_through_ports': 'false'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
+    def test_weight(self):
+        params = {'weight': [10, 20]}
+        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 ConsolePortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ConsolePortTemplate.objects.all()

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

@@ -35,6 +35,16 @@
                             <td>Full Depth</td>
                             <td>{% checkmark object.is_full_depth %}</td>
                         </tr>
+                        <tr>
+                            <td>Weight</td>
+                            <td>
+                            {% if object.weight %}
+                              {{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
+                            {% else %}
+                              {{ ''|placeholder }}
+                            {% endif %}
+                            </td>
+                        </tr>
                         <tr>
                             <td>Parent/Child</td>
                             <td>

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

@@ -22,6 +22,16 @@
               <td>Part Number</td>
               <td>{{ object.part_number|placeholder }}</td>
             </tr>
+            <tr>
+                <td>Weight</td>
+                <td>
+                {% if object.weight %}
+                  {{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
+                {% else %}
+                  {{ ''|placeholder }}
+                {% endif %}
+                </td>
+            </tr>
             <tr>
               <td>Instances</td>
               <td><a href="{% url 'dcim:module_list' %}?module_type_id={{ object.pk }}">{{ instance_count }}</a></td>

+ 16 - 3
netbox/templates/dcim/rack.html

@@ -104,9 +104,7 @@
             </div>
         </div>
         <div class="card">
-            <h5 class="card-header">
-                Dimensions
-            </h5>
+            <h5 class="card-header">Dimensions</h5>
             <div class="card-body">
                 <table class="table table-hover attr-table">
                     <tr>
@@ -147,6 +145,20 @@
                             {% endif %}
                         </td>
                     </tr>
+                    <tr>
+                        <th scope="row">Rack Weight</th>
+                        <td>
+                            {% if object.weight %}
+                                {{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
+                            {% else %}
+                                {{ ''|placeholder }}
+                            {% endif %}
+                        </td>
+                    </tr>
+                    <tr>
+                        <th scope="row">Total Weight</th>
+                        <td>{{ object.total_weight|floatformat }} Kilograms</td>
+                    </tr>
                 </table>
             </div>
         </div>
@@ -186,6 +198,7 @@
                 </div>
             </div>
         {% endif %}
+
         {% include 'inc/panels/image_attachments.html' %}
         <div class="card">
             <h5 class="card-header">

+ 8 - 0
netbox/templates/dcim/rack_edit.html

@@ -57,6 +57,14 @@
         </div>
         {% render_field form.desc_units %}
     </div>
+    <div class="field-group my-5">
+        <div class="row mb-2">
+          <h5 class="offset-sm-3">Weight</h5>
+        </div>
+        {% render_field form.weight %}
+        {% render_field form.weight_unit %}
+    </div>
+
 
     {% if form.custom_fields %}
       <div class="field-group my-5">

+ 26 - 1
netbox/utilities/utils.py

@@ -12,7 +12,7 @@ from django.http import QueryDict
 from jinja2.sandbox import SandboxedEnvironment
 from mptt.models import MPTTModel
 
-from dcim.choices import CableLengthUnitChoices
+from dcim.choices import CableLengthUnitChoices, WeightUnitChoices
 from extras.plugins import PluginConfig
 from extras.utils import is_taggable
 from netbox.config import get_config
@@ -270,6 +270,31 @@ def to_meters(length, unit):
     raise ValueError(f"Unknown unit {unit}. Must be 'km', 'm', 'cm', 'mi', 'ft', or 'in'.")
 
 
+def to_grams(weight, unit):
+    """
+    Convert the given weight to kilograms.
+    """
+    try:
+        if weight < 0:
+            raise ValueError("Weight must be a positive number")
+    except TypeError:
+        raise TypeError(f"Invalid value '{weight}' for weight (must be a number)")
+
+    valid_units = WeightUnitChoices.values()
+    if unit not in valid_units:
+        raise ValueError(f"Unknown unit {unit}. Must be one of the following: {', '.join(valid_units)}")
+
+    if unit == WeightUnitChoices.UNIT_KILOGRAM:
+        return weight * 1000
+    if unit == WeightUnitChoices.UNIT_GRAM:
+        return weight
+    if unit == WeightUnitChoices.UNIT_POUND:
+        return weight * Decimal(453.592)
+    if unit == WeightUnitChoices.UNIT_OUNCE:
+        return weight * Decimal(28.3495)
+    raise ValueError(f"Unknown unit {unit}. Must be 'kg', 'g', 'lb', 'oz'.")
+
+
 def render_jinja2(template_code, context):
     """
     Render a Jinja2 template with the provided context. Return the rendered content.