Explorar o código

13230 Allow Devices to be excluded from Rack utilization (#14099)

* 13230 add exclusion flag to device type

* 13230 forms, detail views

* 13230 add tests

* 13230 extraneous model field

* 13230 extraneous form field

* Update netbox/dcim/forms/bulk_edit.py

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

* 13230 review feedback

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
Arthur Hanson %!s(int64=2) %!d(string=hai) anos
pai
achega
7274e75b26

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

@@ -343,9 +343,9 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
         model = DeviceType
         fields = [
             'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height',
-            'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image',
-            'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
-            'console_port_template_count', 'console_server_port_template_count', 'power_port_template_count',
+            'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
+            'front_image', 'rear_image', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+            'device_count', 'console_port_template_count', 'console_server_port_template_count', 'power_port_template_count',
             'power_outlet_template_count', 'interface_template_count', 'front_port_template_count',
             'rear_port_template_count', 'device_bay_template_count', 'module_bay_template_count',
             'inventory_item_template_count',

+ 2 - 1
netbox/dcim/filtersets.py

@@ -496,7 +496,8 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
     class Meta:
         model = DeviceType
         fields = [
-            'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
+            'id', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth', 'subdevice_role',
+            'airflow', 'weight', 'weight_unit',
         ]
 
     def search(self, queryset, name, value):

+ 9 - 1
netbox/dcim/forms/bulk_edit.py

@@ -420,6 +420,11 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
         widget=BulkEditNullBooleanSelect(),
         label=_('Is full depth')
     )
+    exclude_from_utilization = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect(),
+        label=_('Exclude from utilization')
+    )
     airflow = forms.ChoiceField(
         label=_('Airflow'),
         choices=add_blank_choice(DeviceAirflowChoices),
@@ -445,7 +450,10 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
 
     model = DeviceType
     fieldsets = (
-        (_('Device Type'), ('manufacturer', 'default_platform', 'part_number', 'u_height', 'is_full_depth', 'airflow', 'description')),
+        (_('Device Type'), (
+            'manufacturer', 'default_platform', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth',
+            'airflow', 'description',
+        )),
         (_('Weight'), ('weight', 'weight_unit')),
     )
     nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments')

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

@@ -335,8 +335,8 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
     class Meta:
         model = DeviceType
         fields = [
-            'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
-            'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', 'tags',
+            'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization',
+            'is_full_depth', 'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', 'tags',
         ]
 
 

+ 5 - 4
netbox/dcim/forms/model_forms.py

@@ -302,7 +302,8 @@ class DeviceTypeForm(NetBoxModelForm):
     fieldsets = (
         (_('Device Type'), ('manufacturer', 'model', 'slug', 'default_platform', 'description', 'tags')),
         (_('Chassis'), (
-            'u_height', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
+            'u_height', 'exclude_from_utilization', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow',
+            'weight', 'weight_unit',
         )),
         (_('Images'), ('front_image', 'rear_image')),
     )
@@ -310,9 +311,9 @@ class DeviceTypeForm(NetBoxModelForm):
     class Meta:
         model = DeviceType
         fields = [
-            'manufacturer', 'model', 'slug', 'default_platform', 'part_number', 'u_height', 'is_full_depth',
-            'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'description',
-            'comments', 'tags',
+            'manufacturer', 'model', 'slug', 'default_platform', 'part_number', 'u_height', 'exclude_from_utilization',
+            'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image',
+            'description', 'comments', 'tags',
         ]
         widgets = {
             'front_image': ClearableFileInput(attrs={

+ 17 - 0
netbox/dcim/migrations/0182_devicetype_exclude_from_utilization.py

@@ -0,0 +1,17 @@
+# Generated by Django 4.2.5 on 2023-10-20 22:30
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('dcim', '0181_rename_device_role_device_role'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='devicetype',
+            name='exclude_from_utilization',
+            field=models.BooleanField(default=False),
+        ),
+    ]

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

@@ -106,6 +106,11 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         default=1.0,
         verbose_name=_('height (U)')
     )
+    exclude_from_utilization = models.BooleanField(
+        default=False,
+        verbose_name=_('exclude from utilization'),
+        help_text=_('Exclude from rack utilization calculations.')
+    )
     is_full_depth = models.BooleanField(
         default=True,
         verbose_name=_('is full depth'),

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

@@ -357,7 +357,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
 
         return [u for u in elevation.values()]
 
-    def get_available_units(self, u_height=1, rack_face=None, exclude=None):
+    def get_available_units(self, u_height=1, rack_face=None, exclude=None, ignore_excluded_devices=False):
         """
         Return a list of units within the rack available to accommodate a device of a given U height (default 1).
         Optionally exclude one or more devices when calculating empty units (needed when moving a device from one
@@ -366,9 +366,13 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         :param u_height: Minimum number of contiguous free units required
         :param rack_face: The face of the rack (front or rear) required; 'None' if device is full depth
         :param exclude: List of devices IDs to exclude (useful when moving a device within a rack)
+        :param ignore_excluded_devices: Ignore devices that are marked to exclude from utilization calculations
         """
         # Gather all devices which consume U space within the rack
         devices = self.devices.prefetch_related('device_type').filter(position__gte=1)
+        if ignore_excluded_devices:
+            devices = devices.exclude(device_type__exclude_from_utilization=True)
+
         if exclude is not None:
             devices = devices.exclude(pk__in=exclude)
 
@@ -453,7 +457,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         """
         # Determine unoccupied units
         total_units = len(list(self.units))
-        available_units = self.get_available_units(u_height=0.5)
+        available_units = self.get_available_units(u_height=0.5, ignore_excluded_devices=True)
 
         # Remove reserved units
         for ru in self.get_reserved_units():

+ 4 - 3
netbox/dcim/tables/devicetypes.py

@@ -98,6 +98,7 @@ class DeviceTypeTable(NetBoxTable):
         verbose_name=_('U Height'),
         template_code='{{ value|floatformat }}'
     )
+    exclude_from_utilization = columns.BooleanColumn()
     weight = columns.TemplateColumn(
         verbose_name=_('Weight'),
         template_code=WEIGHT,
@@ -142,9 +143,9 @@ class DeviceTypeTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = models.DeviceType
         fields = (
-            'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height', 'is_full_depth',
-            'subdevice_role', 'airflow', 'weight', 'description', 'comments', 'instance_count', 'tags', 'created',
-            'last_updated',
+            'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height',
+            'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
+            'description', 'comments', 'instance_count', 'tags', 'created', 'last_updated',
         )
         default_columns = (
             'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',

+ 34 - 0
netbox/dcim/tests/test_models.py

@@ -238,6 +238,40 @@ class RackTestCase(TestCase):
         # Check that Device1 is now assigned to Site B
         self.assertEqual(Device.objects.get(pk=device1.pk).site, site_b)
 
+    def test_utilization(self):
+        site = Site.objects.first()
+        rack = Rack.objects.first()
+
+        Device(
+            name='Device 1',
+            role=DeviceRole.objects.first(),
+            device_type=DeviceType.objects.first(),
+            site=site,
+            rack=rack,
+            position=1
+        ).save()
+        rack.refresh_from_db()
+        self.assertEqual(rack.get_utilization(), 1 / 42 * 100)
+
+        # create device excluded from utilization calculations
+        dt = DeviceType.objects.create(
+            manufacturer=Manufacturer.objects.first(),
+            model='Device Type 4',
+            slug='device-type-4',
+            u_height=1,
+            exclude_from_utilization=True
+        )
+        Device(
+            name='Device 2',
+            role=DeviceRole.objects.first(),
+            device_type=dt,
+            site=site,
+            rack=rack,
+            position=5
+        ).save()
+        rack.refresh_from_db()
+        self.assertEqual(rack.get_utilization(), 1 / 42 * 100)
+
 
 class DeviceTestCase(TestCase):
 

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

@@ -40,6 +40,10 @@
                             <td>{% trans "Height (U" %})</td>
                             <td>{{ object.u_height|floatformat }}</td>
                         </tr>
+                        <tr>
+                            <td>{% trans "Exclude From Utilization" %})</td>
+                            <td>{% checkmark object.exclude_from_utilization %}</td>
+                        </tr>
                         <tr>
                             <td>{% trans "Full Depth" %}</td>
                             <td>{% checkmark object.is_full_depth %}</td>