diff --git a/docs/configuration/data-validation.md b/docs/configuration/data-validation.md index e4eb4baff..adadb555a 100644 --- a/docs/configuration/data-validation.md +++ b/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` diff --git a/docs/models/dcim/module.md b/docs/models/dcim/module.md index c90430faa..060c2b094 100644 --- a/docs/models/dcim/module.md +++ b/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. diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index e52b35110..6a52d48ae 100644 --- a/docs/release-notes/version-3.4.md +++ b/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 diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 4b8c95a73..020697b25 100644 --- a/netbox/dcim/api/serializers.py +++ b/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', ] diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 47a4d126b..8d191b1a1 100644 --- a/netbox/dcim/choices.py +++ b/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 # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index b190ae65a..1412cf571 100644 --- a/netbox/dcim/filtersets.py +++ b/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(): diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index d0eae8ac9..2987beebc 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/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') diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 6861395d1..f4f95ff39 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/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): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 53adb4d56..68c7cd07b 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/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 ) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index c0a5cf3c4..867d362a3 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/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', ] diff --git a/netbox/dcim/migrations/0167_module_status.py b/netbox/dcim/migrations/0167_module_status.py new file mode 100644 index 000000000..c048b4bd8 --- /dev/null +++ b/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), + ), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 241b62746..53c6d12a7 100644 --- a/netbox/dcim/models/devices.py +++ b/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() diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py index 9df26eb73..a66fa4aa6 100644 --- a/netbox/dcim/tables/modules.py +++ b/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', ) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 672551f42..301af8d18 100644 --- a/netbox/dcim/tests/test_api.py +++ b/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', }, diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index f3dff428c..3e4ab55fc 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/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) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 300228601..3bb5b890a 100644 --- a/netbox/dcim/tests/test_views.py +++ b/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=['*']) diff --git a/netbox/templates/dcim/module.html b/netbox/templates/dcim/module.html index 139ac2eb8..78d5a1a05 100644 --- a/netbox/templates/dcim/module.html +++ b/netbox/templates/dcim/module.html @@ -62,6 +62,10 @@