Repository: netbox-community/netbox
Pull request: Fixes: #16905 - Allow filtering on Device Status in InventoryItemTable
### Fixes: #16905 Allow filtering on Device Status in InventoryItemTable

Adds `device_status` as a filter field in Inventory Item list view, and associated filter in `InventoryItemFilterSet`.

<img width="1202" alt="Screenshot 2024-08-23 at 5 27 48 PM" src="https://github.com/user-attachments/assets/bfa002e8-ad29-4567-8998-bf5932efdf6d">


Problem statement:
Allow filtering of Inventory Items by Device Status
### NetBox version

3.7.8

### Feature type

New functionality

### Proposed functionality

Extend the filterable options for Inventory Items to include Device Status

### Use case

Currently the Filter tab on Inventory Items allows you to filter by Device Type, Device Role & Device. We use the Device Status attribute to determine if a server is actively provisioned in our environment or not so it would be useful to be able to filter Inventory Items by device status to assist in locating an unused server with a given inventory. 

i.e. 
Site: London
Device Status: Inventory
Role: CPU
Label Contains: Gold

### Database changes

_No response_

### External dependencies

_No response_

Hints from issue discussion:
I would like to own this issue.
Thanks @dannywade, I've assigned this to you!
I opened a PR but still have a bug I'm trying to figure out. Details are in the PR. I'm open to suggestions!
Opening this back up for anyone else who wants to take a stab at it.

Commit message: Add device_status to common superclasses for Device Components, and refactor ChoiceFieldColumn to support a "color" callable allowing get_FOO_color behavior to be overridden

Patch to review:
diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py
index 6d386f3cf47..0b0f3bd47c0 100644
--- a/netbox/dcim/filtersets.py
+++ b/netbox/dcim/filtersets.py
@@ -1411,6 +1411,10 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
         to_field_name='name',
         label=_('Virtual Chassis'),
     )
+    device_status = django_filters.MultipleChoiceFilter(
+        choices=DeviceStatusChoices,
+        field_name='device__status',
+    )
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -1479,7 +1483,7 @@ class ConsolePortFilterSet(
 
     class Meta:
         model = ConsolePort
-        fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end')
+        fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end', 'device_status')
 
 
 class ConsoleServerPortFilterSet(
@@ -1495,7 +1499,7 @@ class ConsoleServerPortFilterSet(
 
     class Meta:
         model = ConsoleServerPort
-        fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end')
+        fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end', 'device_status')
 
 
 class PowerPortFilterSet(
@@ -1513,6 +1517,7 @@ class Meta:
         model = PowerPort
         fields = (
             'id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable_end',
+            'device_status',
         )
 
 
@@ -1538,7 +1543,7 @@ class PowerOutletFilterSet(
     class Meta:
         model = PowerOutlet
         fields = (
-            'id', 'name', 'label', 'feed_leg', 'description', 'mark_connected', 'cable_end',
+            'id', 'name', 'label', 'feed_leg', 'description', 'mark_connected', 'cable_end', 'device_status',
         )
 
 
@@ -1683,7 +1688,7 @@ class Meta:
         fields = (
             'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role',
             'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected',
-            'cable_id', 'cable_end',
+            'cable_id', 'cable_end', 'device_status',
         )
 
     def filter_virtual_chassis_member(self, queryset, name, value):
@@ -1721,6 +1726,7 @@ class Meta:
         model = FrontPort
         fields = (
             'id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description', 'mark_connected', 'cable_end',
+            'device_status',
         )
 
 
@@ -1738,6 +1744,7 @@ class Meta:
         model = RearPort
         fields = (
             'id', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable_end',
+            'device_status',
         )
 
 
@@ -1750,7 +1757,7 @@ class ModuleBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
 
     class Meta:
         model = ModuleBay
-        fields = ('id', 'name', 'label', 'position', 'description')
+        fields = ('id', 'name', 'label', 'position', 'description', 'device_status',)
 
 
 class DeviceBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
@@ -1767,7 +1774,7 @@ class DeviceBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
 
     class Meta:
         model = DeviceBay
-        fields = ('id', 'name', 'label', 'description')
+        fields = ('id', 'name', 'label', 'description', 'device_status')
 
 
 class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
@@ -1803,7 +1810,7 @@ class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
 
     class Meta:
         model = InventoryItem
-        fields = ('id', 'name', 'label', 'part_id', 'asset_tag', 'description', 'discovered')
+        fields = ('id', 'name', 'label', 'part_id', 'asset_tag', 'description', 'discovered', 'device_status')
 
     def search(self, queryset, name, value):
         if not value.strip():
diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py
index 97f9eb422ea..dfe5a12fa4e 100644
--- a/netbox/dcim/forms/filtersets.py
+++ b/netbox/dcim/forms/filtersets.py
@@ -129,6 +129,11 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
         },
         label=_('Device')
     )
+    device_status = forms.MultipleChoiceField(
+        choices=DeviceStatusChoices,
+        required=False,
+        label=_('Device Status'),
+    )
 
 
 class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
@@ -1173,7 +1178,9 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
-        FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')),
+        FieldSet(
+            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
+        ),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
     )
     type = forms.MultipleChoiceField(
@@ -1195,7 +1202,10 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
-        FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')),
+        FieldSet(
+            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            name=_('Device')
+        ),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
     )
     type = forms.MultipleChoiceField(
@@ -1217,7 +1227,9 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('name', 'label', 'type', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
-        FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')),
+        FieldSet(
+            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
+        ),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
     )
     type = forms.MultipleChoiceField(
@@ -1234,7 +1246,10 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('name', 'label', 'type', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
-        FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')),
+        FieldSet(
+            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            name=_('Device')
+        ),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
     )
     type = forms.MultipleChoiceField(
@@ -1254,7 +1269,10 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
         FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
-        FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id', name=_('Device')),
+        FieldSet(
+            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'vdc_id',
+            name=_('Device')
+        ),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
     )
     selector_fields = ('filter_id', 'q', 'device_id')
@@ -1362,7 +1380,9 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
-        FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')),
+        FieldSet(
+            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
+        ),
         FieldSet('cabled', 'occupied', name=_('Cable')),
     )
     model = FrontPort
@@ -1384,7 +1404,10 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
-        FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')),
+        FieldSet(
+            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            name=_('Device')
+        ),
         FieldSet('cabled', 'occupied', name=_('Cable')),
     )
     type = forms.MultipleChoiceField(
@@ -1405,7 +1428,10 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('name', 'label', 'position', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
-        FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')),
+        FieldSet(
+            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            name=_('Device')
+        ),
     )
     tag = TagFilterField(model)
     position = forms.CharField(
@@ -1420,7 +1446,10 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('name', 'label', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
-        FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')),
+        FieldSet(
+            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            name=_('Device')
+        ),
     )
     tag = TagFilterField(model)
 
@@ -1434,7 +1463,10 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
             name=_('Attributes')
         ),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
-        FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')),
+        FieldSet(
+            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            name=_('Device')
+        ),
     )
     role_id = DynamicModelMultipleChoiceField(
         queryset=InventoryItemRole.objects.all(),
diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py
index d6435ed4bf6..4156a5a0a74 100644
--- a/netbox/dcim/tables/devices.py
+++ b/netbox/dcim/tables/devices.py
@@ -290,6 +290,11 @@ class DeviceComponentTable(NetBoxTable):
         linkify=True,
         order_by=('_name',)
     )
+    device_status = columns.ChoiceFieldColumn(
+        accessor=tables.A('device__status'),
+        verbose_name=_('Device Status'),
+        color=lambda x: x.device.get_status_color(),
+    )
 
     class Meta(NetBoxTable.Meta):
         order_by = ('device', 'name')
diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py
index 32eaf35156c..479efa8644b 100644
--- a/netbox/netbox/tables/columns.py
+++ b/netbox/netbox/tables/columns.py
@@ -330,19 +330,26 @@ def render(self, record, table, **kwargs):
 class ChoiceFieldColumn(tables.Column):
     """
     Render a model's static ChoiceField with its value from `get_FOO_display()` as a colored badge. Background color is
-    set by the instance's get_FOO_color() method, if defined.
+    set by the instance's get_FOO_color() method, if defined, or can be overridden by a "color" callable.
     """
     DEFAULT_BG_COLOR = 'secondary'
 
+    def __init__(self, *args, color=None, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.color = color
+
     def render(self, record, bound_column, value):
         if value in self.empty_values:
             return self.default
 
-        # Determine the background color to use (try calling object.get_FOO_color())
-        try:
-            bg_color = getattr(record, f'get_{bound_column.name}_color')() or self.DEFAULT_BG_COLOR
-        except AttributeError:
-            bg_color = self.DEFAULT_BG_COLOR
+        # Determine the background color to use (use "color" callable if given, else try calling object.get_FOO_color())
+        if self.color:
+            bg_color = self.color(record)
+        else:
+            try:
+                bg_color = getattr(record, f'get_{bound_column.name}_color')() or self.DEFAULT_BG_COLOR
+            except AttributeError:
+                bg_color = self.DEFAULT_BG_COLOR
 
         return mark_safe(f'<span class="badge text-bg-{bg_color}">{value}</span>')
 


Code review comments:
diff_hunk: @@ -1479,7 +1483,7 @@ class ConsolePortFilterSet(
 
     class Meta:
         model = ConsolePort
-        fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end')
+        fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end', 'device_status')
path: netbox/dcim/filtersets.py
text: @user1:
Filters defined on the filterset class don't need to be added here; `fields` just denotes the model fields for which filters should be generated automatically.