Merge pull request #18748 from netbox-community/18352-add-poweroutlet-status

Closes #18352: Adds PowerOutlet.status field
This commit is contained in:
bctiemann 2025-03-03 20:07:39 -05:00 committed by GitHub
commit 7c52698c08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 170 additions and 16 deletions

View File

@ -29,6 +29,19 @@ An alternative physical label identifying the power outlet.
The type of power outlet. The type of power outlet.
### Status
The operational status of the power outlet. By default, the following statuses are available:
* Enabled
* Disabled
* Faulty
!!! tip "Custom power outlet statuses"
Additional power outlet statuses may be defined by setting `PowerOutlet.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
!!! info "This field was introduced in NetBox v4.3."
### Color ### Color
!!! info "This field was introduced in NetBox v4.2." !!! info "This field was introduced in NetBox v4.2."

View File

@ -156,10 +156,10 @@ class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
class Meta: class Meta:
model = PowerOutlet model = PowerOutlet
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'power_port', 'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'status', 'color',
'feed_leg', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable',
'created', 'last_updated', '_occupied', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')

View File

@ -1627,6 +1627,23 @@ class PowerFeedPhaseChoices(ChoiceSet):
) )
#
# PowerOutlets
#
class PowerOutletStatusChoices(ChoiceSet):
key = 'PowerOutlet.status'
STATUS_ENABLED = 'enabled'
STATUS_DISABLED = 'disabled'
STATUS_FAULTY = 'faulty'
CHOICES = [
(STATUS_ENABLED, _('Enabled'), 'green'),
(STATUS_DISABLED, _('Disabled'), 'red'),
(STATUS_FAULTY, _('Faulty'), 'gray'),
]
# #
# VDC # VDC
# #

View File

@ -1591,11 +1591,15 @@ class PowerOutletFilterSet(
queryset=PowerPort.objects.all(), queryset=PowerPort.objects.all(),
label=_('Power port (ID)'), label=_('Power port (ID)'),
) )
status = django_filters.MultipleChoiceFilter(
choices=PowerOutletStatusChoices,
null_value=None
)
class Meta: class Meta:
model = PowerOutlet model = PowerOutlet
fields = ( fields = (
'id', 'name', 'label', 'feed_leg', 'description', 'color', 'mark_connected', 'cable_end', 'id', 'name', 'status', 'label', 'feed_leg', 'description', 'color', 'mark_connected', 'cable_end',
) )

View File

@ -1379,7 +1379,10 @@ class PowerPortBulkEditForm(
class PowerOutletBulkEditForm( class PowerOutletBulkEditForm(
ComponentBulkEditForm, ComponentBulkEditForm,
form_from_model(PowerOutlet, ['label', 'type', 'color', 'feed_leg', 'power_port', 'mark_connected', 'description']) form_from_model(
PowerOutlet,
['label', 'type', 'status', 'color', 'feed_leg', 'power_port', 'mark_connected', 'description']
)
): ):
mark_connected = forms.NullBooleanField( mark_connected = forms.NullBooleanField(
label=_('Mark connected'), label=_('Mark connected'),
@ -1389,7 +1392,7 @@ class PowerOutletBulkEditForm(
model = PowerOutlet model = PowerOutlet
fieldsets = ( fieldsets = (
FieldSet('module', 'type', 'label', 'description', 'mark_connected', 'color'), FieldSet('module', 'type', 'label', 'status', 'description', 'mark_connected', 'color'),
FieldSet('feed_leg', 'power_port', name=_('Power')), FieldSet('feed_leg', 'power_port', name=_('Power')),
) )
nullable_fields = ('module', 'label', 'type', 'feed_leg', 'power_port', 'description') nullable_fields = ('module', 'label', 'type', 'feed_leg', 'power_port', 'description')

View File

@ -1305,7 +1305,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerOutlet model = PowerOutlet
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')), FieldSet('name', 'label', 'type', 'color', 'status', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet( FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
@ -1323,6 +1323,11 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
label=_('Color'), label=_('Color'),
required=False required=False
) )
status = forms.MultipleChoiceField(
label=_('Status'),
choices=PowerOutletStatusChoices,
required=False
)
class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):

View File

@ -1308,7 +1308,7 @@ class PowerOutletForm(ModularDeviceComponentForm):
fieldsets = ( fieldsets = (
FieldSet( FieldSet(
'device', 'module', 'name', 'label', 'type', 'color', 'power_port', 'feed_leg', 'mark_connected', 'device', 'module', 'name', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'mark_connected',
'description', 'tags', 'description', 'tags',
), ),
) )
@ -1316,7 +1316,7 @@ class PowerOutletForm(ModularDeviceComponentForm):
class Meta: class Meta:
model = PowerOutlet model = PowerOutlet
fields = [ fields = [
'device', 'module', 'name', 'label', 'type', 'color', 'power_port', 'feed_leg', 'mark_connected', 'device', 'module', 'name', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'mark_connected',
'description', 'tags', 'description', 'tags',
] ]

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0200_populate_mac_addresses'),
]
operations = [
migrations.AddField(
model_name='poweroutlet',
name='status',
field=models.CharField(default='enabled', max_length=50),
),
]

View File

@ -449,6 +449,12 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki
""" """
A physical power outlet (output) within a Device which provides power to a PowerPort. A physical power outlet (output) within a Device which provides power to a PowerPort.
""" """
status = models.CharField(
verbose_name=_('status'),
max_length=50,
choices=PowerOutletStatusChoices,
default=PowerOutletStatusChoices.STATUS_ENABLED
)
type = models.CharField( type = models.CharField(
verbose_name=_('type'), verbose_name=_('type'),
max_length=50, max_length=50,
@ -492,6 +498,9 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki
_("Parent power port ({power_port}) must belong to the same device").format(power_port=self.power_port) _("Parent power port ({power_port}) must belong to the same device").format(power_port=self.power_port)
) )
def get_status_color(self):
return PowerOutletStatusChoices.colors.get(self.status)
# #
# Interfaces # Interfaces

View File

@ -224,7 +224,7 @@ class PowerOutletIndex(SearchIndex):
('label', 200), ('label', 200),
('description', 500), ('description', 500),
) )
display_attrs = ('device', 'label', 'type', 'description') display_attrs = ('device', 'label', 'type', 'status', 'description')
@register_search @register_search

View File

@ -520,6 +520,9 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable):
verbose_name=_('Power Port'), verbose_name=_('Power Port'),
linkify=True linkify=True
) )
status = columns.ChoiceFieldColumn(
verbose_name=_('Status'),
)
color = columns.ColorColumn() color = columns.ColorColumn()
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:poweroutlet_list' url_name='dcim:poweroutlet_list'
@ -530,9 +533,11 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable):
fields = ( fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port', 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port',
'color', 'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'inventory_items', 'color', 'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'inventory_items',
'tags', 'created', 'last_updated', 'tags', 'created', 'last_updated', 'status',
)
default_columns = (
'pk', 'name', 'device', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'description',
) )
default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'power_port', 'feed_leg', 'description')
class DevicePowerOutletTable(PowerOutletTable): class DevicePowerOutletTable(PowerOutletTable):
@ -550,9 +555,11 @@ class DevicePowerOutletTable(PowerOutletTable):
fields = ( fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'color', 'power_port', 'feed_leg', 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'color', 'power_port', 'feed_leg',
'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions',
'status',
) )
default_columns = ( default_columns = (
'pk', 'name', 'label', 'type', 'color', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'pk', 'name', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'description', 'cable',
'connection',
) )

View File

@ -3684,6 +3684,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A, feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A,
description='First', description='First',
color='ff0000', color='ff0000',
status=PowerOutletStatusChoices.STATUS_ENABLED,
), ),
PowerOutlet( PowerOutlet(
device=devices[1], device=devices[1],
@ -3693,6 +3694,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_B, feed_leg=PowerOutletFeedLegChoices.FEED_LEG_B,
description='Second', description='Second',
color='00ff00', color='00ff00',
status=PowerOutletStatusChoices.STATUS_DISABLED,
), ),
PowerOutlet( PowerOutlet(
device=devices[2], device=devices[2],
@ -3702,6 +3704,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_C, feed_leg=PowerOutletFeedLegChoices.FEED_LEG_C,
description='Third', description='Third',
color='0000ff', color='0000ff',
status=PowerOutletStatusChoices.STATUS_FAULTY,
), ),
) )
PowerOutlet.objects.bulk_create(power_outlets) PowerOutlet.objects.bulk_create(power_outlets)
@ -3796,6 +3799,23 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
params = {'connected': False} params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_status(self):
params = {'status': [PowerOutletStatusChoices.STATUS_ENABLED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'status': [PowerOutletStatusChoices.STATUS_DISABLED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'status': [PowerOutletStatusChoices.STATUS_FAULTY]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'status': [
PowerOutletStatusChoices.STATUS_ENABLED,
PowerOutletStatusChoices.STATUS_DISABLED,
PowerOutletStatusChoices.STATUS_FAULTY,
]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = Interface.objects.all() queryset = Interface.objects.all()

View File

@ -1,6 +1,8 @@
from django.test import TestCase from django.test import TestCase
from dcim.choices import DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices, InterfaceModeChoices from dcim.choices import (
DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices, InterfaceModeChoices, PowerOutletStatusChoices
)
from dcim.forms import * from dcim.forms import *
from dcim.models import * from dcim.models import *
from ipam.models import VLAN from ipam.models import VLAN
@ -12,6 +14,56 @@ def get_id(model, slug):
return model.objects.get(slug=slug).id return model.objects.get(slug=slug).id
class PowerOutletFormTestCase(TestCase):
@classmethod
def setUpTestData(cls):
cls.site = site = Site.objects.create(name='Site 1', slug='site-1')
cls.manufacturer = manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
cls.role = role = DeviceRole.objects.create(
name='Device Role 1', slug='device-role-1', color='ff0000'
)
cls.device_type = device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', u_height=1
)
cls.rack = rack = Rack.objects.create(name='Rack 1', site=site)
cls.device = Device.objects.create(
name='Device 1', device_type=device_type, role=role, site=site, rack=rack, position=1
)
def test_status_is_required(self):
form = PowerOutletForm(data={
'device': self.device,
'module': None,
'name': 'New Enabled Outlet',
})
self.assertFalse(form.is_valid())
self.assertIn('status', form.errors)
def test_status_must_be_defined_choice(self):
form = PowerOutletForm(data={
'device': self.device,
'module': None,
'name': 'New Enabled Outlet',
'status': 'this isn\'t a defined choice',
})
self.assertFalse(form.is_valid())
self.assertIn('status', form.errors)
self.assertTrue(form.errors['status'][-1].startswith('Select a valid choice.'))
def test_status_recognizes_choices(self):
for index, choice in enumerate(PowerOutletStatusChoices.CHOICES):
form = PowerOutletForm(data={
'device': self.device,
'module': None,
'name': f'New Enabled Outlet {index + 1}',
'status': choice[0],
})
self.assertEqual({}, form.errors)
self.assertTrue(form.is_valid())
instance = form.save()
self.assertEqual(instance.status, choice[0])
class DeviceTestCase(TestCase): class DeviceTestCase(TestCase):
@classmethod @classmethod

View File

@ -465,7 +465,8 @@ class DeviceTestCase(TestCase):
device=device, device=device,
name='Power Outlet 1', name='Power Outlet 1',
power_port=powerport, power_port=powerport,
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A,
status=PowerOutletStatusChoices.STATUS_ENABLED,
) )
self.assertEqual(poweroutlet.cf['cf1'], 'foo') self.assertEqual(poweroutlet.cf['cf1'], 'foo')

View File

@ -2513,6 +2513,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
'device': device.pk, 'device': device.pk,
'name': 'Power Outlet X', 'name': 'Power Outlet X',
'type': PowerOutletTypeChoices.TYPE_IEC_C13, 'type': PowerOutletTypeChoices.TYPE_IEC_C13,
'status': PowerOutletStatusChoices.STATUS_ENABLED,
'power_port': powerports[1].pk, 'power_port': powerports[1].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
'description': 'A power outlet', 'description': 'A power outlet',
@ -2523,6 +2524,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
'device': device.pk, 'device': device.pk,
'name': 'Power Outlet [4-6]', 'name': 'Power Outlet [4-6]',
'type': PowerOutletTypeChoices.TYPE_IEC_C13, 'type': PowerOutletTypeChoices.TYPE_IEC_C13,
'status': PowerOutletStatusChoices.STATUS_ENABLED,
'power_port': powerports[1].pk, 'power_port': powerports[1].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
'description': 'A power outlet', 'description': 'A power outlet',
@ -2531,6 +2533,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.bulk_edit_data = { cls.bulk_edit_data = {
'type': PowerOutletTypeChoices.TYPE_IEC_C15, 'type': PowerOutletTypeChoices.TYPE_IEC_C15,
'status': PowerOutletStatusChoices.STATUS_ENABLED,
'power_port': powerports[1].pk, 'power_port': powerports[1].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
'description': 'New description', 'description': 'New description',

View File

@ -36,6 +36,10 @@
<th scope="row">{% trans "Type" %}</th> <th scope="row">{% trans "Type" %}</th>
<td>{{ object.get_type_display }}</td> <td>{{ object.get_type_display }}</td>
</tr> </tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr> <tr>
<th scope="row">{% trans "Description" %}</th> <th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td> <td>{{ object.description|placeholder }}</td>