Skip to content

Commit

Permalink
Merge pull request #18748 from netbox-community/18352-add-poweroutlet…
Browse files Browse the repository at this point in the history
…-status

Closes #18352: Adds PowerOutlet.status field
  • Loading branch information
bctiemann authored Mar 4, 2025
2 parents 77b9820 + 913405a commit 7c52698
Show file tree
Hide file tree
Showing 16 changed files with 170 additions and 16 deletions.
13 changes: 13 additions & 0 deletions docs/models/dcim/poweroutlet.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@ An alternative physical label identifying the 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

!!! info "This field was introduced in NetBox v4.2."
Expand Down
8 changes: 4 additions & 4 deletions netbox/dcim/api/serializers_/device_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,10 @@ class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
class Meta:
model = PowerOutlet
fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'power_port',
'feed_leg', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied',
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'status', 'color',
'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable',
'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')

Expand Down
17 changes: 17 additions & 0 deletions netbox/dcim/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down
6 changes: 5 additions & 1 deletion netbox/dcim/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -1591,11 +1591,15 @@ class PowerOutletFilterSet(
queryset=PowerPort.objects.all(),
label=_('Power port (ID)'),
)
status = django_filters.MultipleChoiceFilter(
choices=PowerOutletStatusChoices,
null_value=None
)

class Meta:
model = PowerOutlet
fields = (
'id', 'name', 'label', 'feed_leg', 'description', 'color', 'mark_connected', 'cable_end',
'id', 'name', 'status', 'label', 'feed_leg', 'description', 'color', 'mark_connected', 'cable_end',
)


Expand Down
7 changes: 5 additions & 2 deletions netbox/dcim/forms/bulk_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -1379,7 +1379,10 @@ class PowerPortBulkEditForm(

class PowerOutletBulkEditForm(
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(
label=_('Mark connected'),
Expand All @@ -1389,7 +1392,7 @@ class PowerOutletBulkEditForm(

model = PowerOutlet
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')),
)
nullable_fields = ('module', 'label', 'type', 'feed_leg', 'power_port', 'description')
Expand Down
7 changes: 6 additions & 1 deletion netbox/dcim/forms/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -1305,7 +1305,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerOutlet
fieldsets = (
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(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
Expand All @@ -1323,6 +1323,11 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
label=_('Color'),
required=False
)
status = forms.MultipleChoiceField(
label=_('Status'),
choices=PowerOutletStatusChoices,
required=False
)


class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
Expand Down
4 changes: 2 additions & 2 deletions netbox/dcim/forms/model_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -1308,15 +1308,15 @@ class PowerOutletForm(ModularDeviceComponentForm):

fieldsets = (
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',
),
)

class Meta:
model = PowerOutlet
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',
]

Expand Down
16 changes: 16 additions & 0 deletions netbox/dcim/migrations/0201_add_power_outlet_status.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
9 changes: 9 additions & 0 deletions netbox/dcim/models/device_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,12 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki
"""
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(
verbose_name=_('type'),
max_length=50,
Expand Down Expand Up @@ -492,6 +498,9 @@ def clean(self):
_("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
Expand Down
2 changes: 1 addition & 1 deletion netbox/dcim/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ class PowerOutletIndex(SearchIndex):
('label', 200),
('description', 500),
)
display_attrs = ('device', 'label', 'type', 'description')
display_attrs = ('device', 'label', 'type', 'status', 'description')


@register_search
Expand Down
13 changes: 10 additions & 3 deletions netbox/dcim/tables/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,9 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable):
verbose_name=_('Power Port'),
linkify=True
)
status = columns.ChoiceFieldColumn(
verbose_name=_('Status'),
)
color = columns.ColorColumn()
tags = columns.TagColumn(
url_name='dcim:poweroutlet_list'
Expand All @@ -530,9 +533,11 @@ class Meta(DeviceComponentTable.Meta):
fields = (
'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',
'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):
Expand All @@ -550,9 +555,11 @@ class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'color', 'power_port', 'feed_leg',
'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions',
'status',
)
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',
)


Expand Down
20 changes: 20 additions & 0 deletions netbox/dcim/tests/test_filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -3684,6 +3684,7 @@ def setUpTestData(cls):
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A,
description='First',
color='ff0000',
status=PowerOutletStatusChoices.STATUS_ENABLED,
),
PowerOutlet(
device=devices[1],
Expand All @@ -3693,6 +3694,7 @@ def setUpTestData(cls):
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_B,
description='Second',
color='00ff00',
status=PowerOutletStatusChoices.STATUS_DISABLED,
),
PowerOutlet(
device=devices[2],
Expand All @@ -3702,6 +3704,7 @@ def setUpTestData(cls):
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_C,
description='Third',
color='0000ff',
status=PowerOutletStatusChoices.STATUS_FAULTY,
),
)
PowerOutlet.objects.bulk_create(power_outlets)
Expand Down Expand Up @@ -3796,6 +3799,23 @@ def test_connected(self):
params = {'connected': False}
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):
queryset = Interface.objects.all()
Expand Down
54 changes: 53 additions & 1 deletion netbox/dcim/tests/test_forms.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
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.models import *
from ipam.models import VLAN
Expand All @@ -12,6 +14,56 @@ def get_id(model, slug):
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):

@classmethod
Expand Down
3 changes: 2 additions & 1 deletion netbox/dcim/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,8 @@ def test_device_creation(self):
device=device,
name='Power Outlet 1',
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')

Expand Down
3 changes: 3 additions & 0 deletions netbox/dcim/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2513,6 +2513,7 @@ def setUpTestData(cls):
'device': device.pk,
'name': 'Power Outlet X',
'type': PowerOutletTypeChoices.TYPE_IEC_C13,
'status': PowerOutletStatusChoices.STATUS_ENABLED,
'power_port': powerports[1].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
'description': 'A power outlet',
Expand All @@ -2523,6 +2524,7 @@ def setUpTestData(cls):
'device': device.pk,
'name': 'Power Outlet [4-6]',
'type': PowerOutletTypeChoices.TYPE_IEC_C13,
'status': PowerOutletStatusChoices.STATUS_ENABLED,
'power_port': powerports[1].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
'description': 'A power outlet',
Expand All @@ -2531,6 +2533,7 @@ def setUpTestData(cls):

cls.bulk_edit_data = {
'type': PowerOutletTypeChoices.TYPE_IEC_C15,
'status': PowerOutletStatusChoices.STATUS_ENABLED,
'power_port': powerports[1].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
'description': 'New description',
Expand Down
4 changes: 4 additions & 0 deletions netbox/templates/dcim/poweroutlet.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ <h2 class="card-header">{% trans "Power Outlet" %}</h2>
<th scope="row">{% trans "Type" %}</th>
<td>{{ object.get_type_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
Expand Down

0 comments on commit 7c52698

Please sign in to comment.