diff --git a/docs/models/dcim/poweroutlet.md b/docs/models/dcim/poweroutlet.md index a99f60b23..22a7ec63e 100644 --- a/docs/models/dcim/poweroutlet.md +++ b/docs/models/dcim/poweroutlet.md @@ -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." diff --git a/netbox/dcim/api/serializers_/device_components.py b/netbox/dcim/api/serializers_/device_components.py index b591030aa..8b9cd42df 100644 --- a/netbox/dcim/api/serializers_/device_components.py +++ b/netbox/dcim/api/serializers_/device_components.py @@ -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') diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index c5b6cbcad..8bd41b3d2 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -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 # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 60c3c4d38..e46730da8 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -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', ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index da5a45f15..3b9a183cd 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -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'), @@ -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') diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 37b8afd17..d794c6893 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -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', @@ -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): diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 5a3a27d25..91e23e8b1 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1308,7 +1308,7 @@ 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', ), ) @@ -1316,7 +1316,7 @@ class PowerOutletForm(ModularDeviceComponentForm): 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', ] diff --git a/netbox/dcim/migrations/0201_add_power_outlet_status.py b/netbox/dcim/migrations/0201_add_power_outlet_status.py new file mode 100644 index 000000000..21fd32186 --- /dev/null +++ b/netbox/dcim/migrations/0201_add_power_outlet_status.py @@ -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), + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 8a8e8f4cc..6a994d770 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -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, @@ -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) ) + def get_status_color(self): + return PowerOutletStatusChoices.colors.get(self.status) + # # Interfaces diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index b964421de..5dea2a09b 100644 --- a/netbox/dcim/search.py +++ b/netbox/dcim/search.py @@ -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 diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index d4f2f74b3..06f6469d3 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -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' @@ -530,9 +533,11 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable): 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): @@ -550,9 +555,11 @@ class DevicePowerOutletTable(PowerOutletTable): 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', ) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index ede1e2a09..7c9b8adc6 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -3684,6 +3684,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A, description='First', color='ff0000', + status=PowerOutletStatusChoices.STATUS_ENABLED, ), PowerOutlet( device=devices[1], @@ -3693,6 +3694,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF feed_leg=PowerOutletFeedLegChoices.FEED_LEG_B, description='Second', color='00ff00', + status=PowerOutletStatusChoices.STATUS_DISABLED, ), PowerOutlet( device=devices[2], @@ -3702,6 +3704,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF feed_leg=PowerOutletFeedLegChoices.FEED_LEG_C, description='Third', color='0000ff', + status=PowerOutletStatusChoices.STATUS_FAULTY, ), ) PowerOutlet.objects.bulk_create(power_outlets) @@ -3796,6 +3799,23 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF 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() diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index 89b7508f3..0067acaaf 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -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 @@ -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 diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index ff1eddd56..bdb07d6d1 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -465,7 +465,8 @@ class DeviceTestCase(TestCase): 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') diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index b84217882..4dea94c7d 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -2513,6 +2513,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): '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', @@ -2523,6 +2524,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): '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', @@ -2531,6 +2533,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): 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', diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html index 146f6d580..8e44df88e 100644 --- a/netbox/templates/dcim/poweroutlet.html +++ b/netbox/templates/dcim/poweroutlet.html @@ -36,6 +36,10 @@