Explorar o código

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

Closes #18352: Adds PowerOutlet.status field
bctiemann hai 11 meses
pai
achega
7c52698c08

+ 13 - 0
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."

+ 4 - 4
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')
 

+ 17 - 0
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
 #

+ 5 - 1
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',
         )
 
 

+ 5 - 2
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')

+ 6 - 1
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):

+ 2 - 2
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',
         ]
 

+ 16 - 0
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),
+        ),
+    ]

+ 9 - 0
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

+ 1 - 1
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

+ 10 - 3
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',
         )
 
 

+ 20 - 0
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()

+ 53 - 1
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

+ 2 - 1
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')
 

+ 3 - 0
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',

+ 4 - 0
netbox/templates/dcim/poweroutlet.html

@@ -36,6 +36,10 @@
                         <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>