Browse Source

Closes #10371: Add operational status field for modules

jeremystretch 3 years ago
parent
commit
97aa40f7a8

+ 2 - 0
docs/configuration/data-validation.md

@@ -58,9 +58,11 @@ The following model fields support configurable choices:
 * `circuits.Circuit.status`
 * `dcim.Device.status`
 * `dcim.Location.status`
+* `dcim.Module.status`
 * `dcim.PowerFeed.status`
 * `dcim.Rack.status`
 * `dcim.Site.status`
+* `dcim.VirtualDeviceContext.status`
 * `extras.JournalEntry.kind`
 * `ipam.IPAddress.status`
 * `ipam.IPRange.status`

+ 7 - 0
docs/models/dcim/module.md

@@ -18,6 +18,13 @@ The [module bay](./modulebay.md) into which the module is installed.
 
 The [module type](./moduletype.md) which represents the physical make & model of hardware. By default, module components will be instantiated automatically from the module type when creating a new module.
 
+### Status
+
+The module's operational status.
+
+!!! tip
+    Additional statuses may be defined by setting `Module.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
+
 ### Serial Number
 
 The unique physical serial number assigned to this module by its manufacturer.

+ 3 - 0
docs/release-notes/version-3.4.md

@@ -5,6 +5,7 @@
 ### Enhancements
 
 * [#815](https://github.com/netbox-community/netbox/issues/815) - Enable specifying terminations when bulk importing circuits
+* [#10371](https://github.com/netbox-community/netbox/issues/10371) - Add operational status field for modules
 * [#10945](https://github.com/netbox-community/netbox/issues/10945) - Enabled recurring execution of scheduled reports & scripts
 * [#11090](https://github.com/netbox-community/netbox/issues/11090) - Add regular expression support to global search engine
 * [#11022](https://github.com/netbox-community/netbox/issues/11022) - Introduce `QUEUE_MAPPINGS` configuration parameter to allow customization of background task prioritization
@@ -134,6 +135,8 @@ This release introduces a new programmatic API that enables plugins and custom s
     * Added a `description` field
 * dcim.Interface
     * Added the `vdcs` field
+* dcim.Module
+    * Added a `status` field
 * dcim.ModuleType
     * Added a `description` field
     * Added optional `weight` and `weight_unit` fields

+ 2 - 2
netbox/dcim/api/serializers.py

@@ -697,8 +697,8 @@ class ModuleSerializer(NetBoxModelSerializer):
     class Meta:
         model = Module
         fields = [
-            'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'description',
-            'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+            'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag',
+            'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
 
 

+ 24 - 0
netbox/dcim/choices.py

@@ -194,6 +194,30 @@ class DeviceAirflowChoices(ChoiceSet):
     )
 
 
+#
+# Modules
+#
+
+class ModuleStatusChoices(ChoiceSet):
+    key = 'Module.status'
+
+    STATUS_OFFLINE = 'offline'
+    STATUS_ACTIVE = 'active'
+    STATUS_PLANNED = 'planned'
+    STATUS_STAGED = 'staged'
+    STATUS_FAILED = 'failed'
+    STATUS_DECOMMISSIONING = 'decommissioning'
+
+    CHOICES = [
+        (STATUS_OFFLINE, 'Offline', 'gray'),
+        (STATUS_ACTIVE, 'Active', 'green'),
+        (STATUS_PLANNED, 'Planned', 'cyan'),
+        (STATUS_STAGED, 'Staged', 'blue'),
+        (STATUS_FAILED, 'Failed', 'red'),
+        (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'),
+    ]
+
+
 #
 # ConsolePorts
 #

+ 5 - 1
netbox/dcim/filtersets.py

@@ -1082,13 +1082,17 @@ class ModuleFilterSet(NetBoxModelFilterSet):
         queryset=Device.objects.all(),
         label=_('Device (ID)'),
     )
+    status = django_filters.MultipleChoiceFilter(
+        choices=ModuleStatusChoices,
+        null_value=None
+    )
     serial = MultiValueCharFilter(
         lookup_expr='iexact'
     )
 
     class Meta:
         model = Module
-        fields = ['id', 'asset_tag']
+        fields = ['id', 'status', 'asset_tag']
 
     def search(self, queryset, name, value):
         if not value.strip():

+ 7 - 1
netbox/dcim/forms/bulk_edit.py

@@ -574,6 +574,12 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
             'manufacturer_id': '$manufacturer'
         }
     )
+    status = forms.ChoiceField(
+        choices=add_blank_choice(ModuleStatusChoices),
+        required=False,
+        initial='',
+        widget=StaticSelect()
+    )
     serial = forms.CharField(
         max_length=50,
         required=False,
@@ -590,7 +596,7 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
 
     model = Module
     fieldsets = (
-        (None, ('manufacturer', 'module_type', 'serial', 'description')),
+        (None, ('manufacturer', 'module_type', 'status', 'serial', 'description')),
     )
     nullable_fields = ('serial', 'description', 'comments')
 

+ 5 - 1
netbox/dcim/forms/bulk_import.py

@@ -450,11 +450,15 @@ class ModuleImportForm(NetBoxModelImportForm):
         queryset=ModuleType.objects.all(),
         to_field_name='model'
     )
+    status = CSVChoiceField(
+        choices=ModuleStatusChoices,
+        help_text=_('Operational status')
+    )
 
     class Meta:
         model = Module
         fields = (
-            'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'description', 'comments', 'tags',
+            'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'status', 'description', 'comments', 'tags',
         )
 
     def __init__(self, data=None, *args, **kwargs):

+ 5 - 1
netbox/dcim/forms/filtersets.py

@@ -763,7 +763,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
     model = Module
     fieldsets = (
         (None, ('q', 'filter_id', 'tag')),
-        ('Hardware', ('manufacturer_id', 'module_type_id', 'serial', 'asset_tag')),
+        ('Hardware', ('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag')),
     )
     manufacturer_id = DynamicModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),
@@ -780,6 +780,10 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
         label=_('Type'),
         fetch_trigger='open'
     )
+    status = MultipleChoiceField(
+        choices=ModuleStatusChoices,
+        required=False
+    )
     serial = forms.CharField(
         required=False
     )

+ 2 - 2
netbox/dcim/forms/model_forms.py

@@ -703,7 +703,7 @@ class ModuleForm(NetBoxModelForm):
 
     fieldsets = (
         ('Module', (
-            'device', 'module_bay', 'manufacturer', 'module_type', 'description', 'tags',
+            'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'description', 'tags',
         )),
         ('Hardware', (
             'serial', 'asset_tag', 'replicate_components', 'adopt_components',
@@ -713,7 +713,7 @@ class ModuleForm(NetBoxModelForm):
     class Meta:
         model = Module
         fields = [
-            'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags',
+            'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag', 'tags',
             'replicate_components', 'adopt_components', 'description', 'comments',
         ]
 

+ 18 - 0
netbox/dcim/migrations/0167_module_status.py

@@ -0,0 +1,18 @@
+# Generated by Django 4.1.2 on 2022-12-09 15:09
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0166_virtualdevicecontext'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='module',
+            name='status',
+            field=models.CharField(default='active', max_length=50),
+        ),
+    ]

+ 9 - 1
netbox/dcim/models/devices.py

@@ -925,6 +925,11 @@ class Module(PrimaryModel, ConfigContextModel):
         on_delete=models.PROTECT,
         related_name='instances'
     )
+    status = models.CharField(
+        max_length=50,
+        choices=ModuleStatusChoices,
+        default=ModuleStatusChoices.STATUS_ACTIVE
+    )
     serial = models.CharField(
         max_length=50,
         blank=True,
@@ -939,7 +944,7 @@ class Module(PrimaryModel, ConfigContextModel):
         help_text=_('A unique tag used to identify this device')
     )
 
-    clone_fields = ('device', 'module_type')
+    clone_fields = ('device', 'module_type', 'status')
 
     class Meta:
         ordering = ('module_bay',)
@@ -950,6 +955,9 @@ class Module(PrimaryModel, ConfigContextModel):
     def get_absolute_url(self):
         return reverse('dcim:module', args=[self.pk])
 
+    def get_status_color(self):
+        return ModuleStatusChoices.colors.get(self.status)
+
     def clean(self):
         super().clean()
 

+ 4 - 3
netbox/dcim/tables/modules.py

@@ -56,6 +56,7 @@ class ModuleTable(NetBoxTable):
     module_type = tables.Column(
         linkify=True
     )
+    status = columns.ChoiceFieldColumn()
     comments = columns.MarkdownColumn()
     tags = columns.TagColumn(
         url_name='dcim:module_list'
@@ -64,9 +65,9 @@ class ModuleTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = Module
         fields = (
-            'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'description',
-            'comments', 'tags',
+            'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag',
+            'description', 'comments', 'tags',
         )
         default_columns = (
-            'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag',
+            'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag',
         )

+ 3 - 0
netbox/dcim/tests/test_api.py

@@ -1271,6 +1271,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
                 'device': device.pk,
                 'module_bay': module_bays[3].pk,
                 'module_type': module_types[0].pk,
+                'status': ModuleStatusChoices.STATUS_ACTIVE,
                 'serial': 'ABC123',
                 'asset_tag': 'Foo1',
             },
@@ -1278,6 +1279,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
                 'device': device.pk,
                 'module_bay': module_bays[4].pk,
                 'module_type': module_types[1].pk,
+                'status': ModuleStatusChoices.STATUS_ACTIVE,
                 'serial': 'DEF456',
                 'asset_tag': 'Foo2',
             },
@@ -1285,6 +1287,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
                 'device': device.pk,
                 'module_bay': module_bays[5].pk,
                 'module_type': module_types[2].pk,
+                'status': ModuleStatusChoices.STATUS_ACTIVE,
                 'serial': 'GHI789',
                 'asset_tag': 'Foo3',
             },

+ 13 - 9
netbox/dcim/tests/test_filtersets.py

@@ -1876,15 +1876,15 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
         ModuleBay.objects.bulk_create(module_bays)
 
         modules = (
-            Module(device=devices[0], module_bay=module_bays[0], module_type=module_types[0], serial='A', asset_tag='A'),
-            Module(device=devices[0], module_bay=module_bays[1], module_type=module_types[1], serial='B', asset_tag='B'),
-            Module(device=devices[0], module_bay=module_bays[2], module_type=module_types[2], serial='C', asset_tag='C'),
-            Module(device=devices[1], module_bay=module_bays[3], module_type=module_types[0], serial='D', asset_tag='D'),
-            Module(device=devices[1], module_bay=module_bays[4], module_type=module_types[1], serial='E', asset_tag='E'),
-            Module(device=devices[1], module_bay=module_bays[5], module_type=module_types[2], serial='F', asset_tag='F'),
-            Module(device=devices[2], module_bay=module_bays[6], module_type=module_types[0], serial='G', asset_tag='G'),
-            Module(device=devices[2], module_bay=module_bays[7], module_type=module_types[1], serial='H', asset_tag='H'),
-            Module(device=devices[2], module_bay=module_bays[8], module_type=module_types[2], serial='I', asset_tag='I'),
+            Module(device=devices[0], module_bay=module_bays[0], module_type=module_types[0], status=ModuleStatusChoices.STATUS_ACTIVE, serial='A', asset_tag='A'),
+            Module(device=devices[0], module_bay=module_bays[1], module_type=module_types[1], status=ModuleStatusChoices.STATUS_ACTIVE, serial='B', asset_tag='B'),
+            Module(device=devices[0], module_bay=module_bays[2], module_type=module_types[2], status=ModuleStatusChoices.STATUS_ACTIVE, serial='C', asset_tag='C'),
+            Module(device=devices[1], module_bay=module_bays[3], module_type=module_types[0], status=ModuleStatusChoices.STATUS_ACTIVE, serial='D', asset_tag='D'),
+            Module(device=devices[1], module_bay=module_bays[4], module_type=module_types[1], status=ModuleStatusChoices.STATUS_ACTIVE, serial='E', asset_tag='E'),
+            Module(device=devices[1], module_bay=module_bays[5], module_type=module_types[2], status=ModuleStatusChoices.STATUS_ACTIVE, serial='F', asset_tag='F'),
+            Module(device=devices[2], module_bay=module_bays[6], module_type=module_types[0], status=ModuleStatusChoices.STATUS_ACTIVE, serial='G', asset_tag='G'),
+            Module(device=devices[2], module_bay=module_bays[7], module_type=module_types[1], status=ModuleStatusChoices.STATUS_PLANNED, serial='H', asset_tag='H'),
+            Module(device=devices[2], module_bay=module_bays[8], module_type=module_types[2], status=ModuleStatusChoices.STATUS_FAILED, serial='I', asset_tag='I'),
         )
         Module.objects.bulk_create(modules)
 
@@ -1912,6 +1912,10 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'device_id': [device_types[0].pk, device_types[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
 
+    def test_status(self):
+        params = {'status': [ModuleStatusChoices.STATUS_PLANNED, ModuleStatusChoices.STATUS_FAILED]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_serial(self):
         params = {'serial': ['A', 'B']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 10 - 8
netbox/dcim/tests/test_views.py

@@ -1887,26 +1887,28 @@ class ModuleTestCase(
             'device': devices[0].pk,
             'module_bay': module_bays[3].pk,
             'module_type': module_types[0].pk,
+            'status': ModuleStatusChoices.STATUS_ACTIVE,
             'serial': 'A',
             'tags': [t.pk for t in tags],
         }
 
         cls.bulk_edit_data = {
             'module_type': module_types[3].pk,
+            'status': ModuleStatusChoices.STATUS_PLANNED,
         }
 
         cls.csv_data = (
-            "device,module_bay,module_type,serial,asset_tag",
-            "Device 2,Module Bay 1,Module Type 1,A,A",
-            "Device 2,Module Bay 2,Module Type 2,B,B",
-            "Device 2,Module Bay 3,Module Type 3,C,C",
+            "device,module_bay,module_type,status,serial,asset_tag",
+            "Device 2,Module Bay 1,Module Type 1,active,A,A",
+            "Device 2,Module Bay 2,Module Type 2,planned,B,B",
+            "Device 2,Module Bay 3,Module Type 3,failed,C,C",
         )
 
         cls.csv_update_data = (
-            "id,serial",
-            f"{modules[0].pk},Serial 2",
-            f"{modules[1].pk},Serial 3",
-            f"{modules[2].pk},Serial 1",
+            "id,status,serial",
+            f"{modules[0].pk},offline,Serial 2",
+            f"{modules[1].pk},offline,Serial 3",
+            f"{modules[2].pk},offline,Serial 1",
         )
 
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])

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

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