mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-24 09:28:38 -06:00
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>
This commit is contained in:
parent
ae447bd187
commit
7274e75b26
@ -343,9 +343,9 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
|
|||||||
model = DeviceType
|
model = DeviceType
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height',
|
'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',
|
'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
|
||||||
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
|
'front_image', 'rear_image', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||||
'console_port_template_count', 'console_server_port_template_count', 'power_port_template_count',
|
'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',
|
'power_outlet_template_count', 'interface_template_count', 'front_port_template_count',
|
||||||
'rear_port_template_count', 'device_bay_template_count', 'module_bay_template_count',
|
'rear_port_template_count', 'device_bay_template_count', 'module_bay_template_count',
|
||||||
'inventory_item_template_count',
|
'inventory_item_template_count',
|
||||||
|
@ -496,7 +496,8 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = DeviceType
|
model = DeviceType
|
||||||
fields = [
|
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):
|
def search(self, queryset, name, value):
|
||||||
|
@ -420,6 +420,11 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
widget=BulkEditNullBooleanSelect(),
|
widget=BulkEditNullBooleanSelect(),
|
||||||
label=_('Is full depth')
|
label=_('Is full depth')
|
||||||
)
|
)
|
||||||
|
exclude_from_utilization = forms.NullBooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=BulkEditNullBooleanSelect(),
|
||||||
|
label=_('Exclude from utilization')
|
||||||
|
)
|
||||||
airflow = forms.ChoiceField(
|
airflow = forms.ChoiceField(
|
||||||
label=_('Airflow'),
|
label=_('Airflow'),
|
||||||
choices=add_blank_choice(DeviceAirflowChoices),
|
choices=add_blank_choice(DeviceAirflowChoices),
|
||||||
@ -445,7 +450,10 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
|
|
||||||
model = DeviceType
|
model = DeviceType
|
||||||
fieldsets = (
|
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')),
|
(_('Weight'), ('weight', 'weight_unit')),
|
||||||
)
|
)
|
||||||
nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments')
|
nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments')
|
||||||
|
@ -335,8 +335,8 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = DeviceType
|
model = DeviceType
|
||||||
fields = [
|
fields = [
|
||||||
'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
|
'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization',
|
||||||
'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', 'tags',
|
'is_full_depth', 'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', 'tags',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -302,7 +302,8 @@ class DeviceTypeForm(NetBoxModelForm):
|
|||||||
fieldsets = (
|
fieldsets = (
|
||||||
(_('Device Type'), ('manufacturer', 'model', 'slug', 'default_platform', 'description', 'tags')),
|
(_('Device Type'), ('manufacturer', 'model', 'slug', 'default_platform', 'description', 'tags')),
|
||||||
(_('Chassis'), (
|
(_('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')),
|
(_('Images'), ('front_image', 'rear_image')),
|
||||||
)
|
)
|
||||||
@ -310,9 +311,9 @@ class DeviceTypeForm(NetBoxModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = DeviceType
|
model = DeviceType
|
||||||
fields = [
|
fields = [
|
||||||
'manufacturer', 'model', 'slug', 'default_platform', 'part_number', 'u_height', 'is_full_depth',
|
'manufacturer', 'model', 'slug', 'default_platform', 'part_number', 'u_height', 'exclude_from_utilization',
|
||||||
'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'description',
|
'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image',
|
||||||
'comments', 'tags',
|
'description', 'comments', 'tags',
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'front_image': ClearableFileInput(attrs={
|
'front_image': ClearableFileInput(attrs={
|
||||||
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
@ -106,6 +106,11 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
|||||||
default=1.0,
|
default=1.0,
|
||||||
verbose_name=_('height (U)')
|
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(
|
is_full_depth = models.BooleanField(
|
||||||
default=True,
|
default=True,
|
||||||
verbose_name=_('is full depth'),
|
verbose_name=_('is full depth'),
|
||||||
|
@ -357,7 +357,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
|||||||
|
|
||||||
return [u for u in elevation.values()]
|
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).
|
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
|
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 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 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 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
|
# Gather all devices which consume U space within the rack
|
||||||
devices = self.devices.prefetch_related('device_type').filter(position__gte=1)
|
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:
|
if exclude is not None:
|
||||||
devices = devices.exclude(pk__in=exclude)
|
devices = devices.exclude(pk__in=exclude)
|
||||||
|
|
||||||
@ -453,7 +457,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
|||||||
"""
|
"""
|
||||||
# Determine unoccupied units
|
# Determine unoccupied units
|
||||||
total_units = len(list(self.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
|
# Remove reserved units
|
||||||
for ru in self.get_reserved_units():
|
for ru in self.get_reserved_units():
|
||||||
|
@ -98,6 +98,7 @@ class DeviceTypeTable(NetBoxTable):
|
|||||||
verbose_name=_('U Height'),
|
verbose_name=_('U Height'),
|
||||||
template_code='{{ value|floatformat }}'
|
template_code='{{ value|floatformat }}'
|
||||||
)
|
)
|
||||||
|
exclude_from_utilization = columns.BooleanColumn()
|
||||||
weight = columns.TemplateColumn(
|
weight = columns.TemplateColumn(
|
||||||
verbose_name=_('Weight'),
|
verbose_name=_('Weight'),
|
||||||
template_code=WEIGHT,
|
template_code=WEIGHT,
|
||||||
@ -142,9 +143,9 @@ class DeviceTypeTable(NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = models.DeviceType
|
model = models.DeviceType
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height', 'is_full_depth',
|
'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height',
|
||||||
'subdevice_role', 'airflow', 'weight', 'description', 'comments', 'instance_count', 'tags', 'created',
|
'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
|
||||||
'last_updated',
|
'description', 'comments', 'instance_count', 'tags', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',
|
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',
|
||||||
|
@ -238,6 +238,40 @@ class RackTestCase(TestCase):
|
|||||||
# Check that Device1 is now assigned to Site B
|
# Check that Device1 is now assigned to Site B
|
||||||
self.assertEqual(Device.objects.get(pk=device1.pk).site, 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):
|
class DeviceTestCase(TestCase):
|
||||||
|
|
||||||
|
@ -40,6 +40,10 @@
|
|||||||
<td>{% trans "Height (U" %})</td>
|
<td>{% trans "Height (U" %})</td>
|
||||||
<td>{{ object.u_height|floatformat }}</td>
|
<td>{{ object.u_height|floatformat }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{% trans "Exclude From Utilization" %})</td>
|
||||||
|
<td>{% checkmark object.exclude_from_utilization %}</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{% trans "Full Depth" %}</td>
|
<td>{% trans "Full Depth" %}</td>
|
||||||
<td>{% checkmark object.is_full_depth %}</td>
|
<td>{% checkmark object.is_full_depth %}</td>
|
||||||
|
Loading…
Reference in New Issue
Block a user