From 77247cccbebc839e95b3b92b5e89a3baccd9ca13 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 8 May 2017 13:55:19 -0400 Subject: [PATCH] Closes #154: Expand device status field options --- netbox/dcim/filters.py | 6 +- netbox/dcim/forms.py | 61 ++++++++++--------- .../0035_device_expand_status_choices.py | 27 ++++++++ netbox/dcim/models.py | 51 ++++++++++++---- netbox/dcim/tables.py | 12 ++-- .../management/commands/run_inventory.py | 6 +- netbox/templates/dcim/device.html | 6 +- netbox/templates/dcim/device_edit.html | 2 +- 8 files changed, 108 insertions(+), 63 deletions(-) create mode 100644 netbox/dcim/migrations/0035_device_expand_status_choices.py diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index d8c2277df..e57d6eb11 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -373,10 +373,6 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Platform (slug)', ) - status = django_filters.BooleanFilter( - name='status', - label='Status', - ) is_console_server = django_filters.BooleanFilter( name='device_type__is_console_server', label='Is a console server', @@ -396,7 +392,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = Device - fields = ['name', 'serial', 'asset_tag'] + fields = ['name', 'serial', 'asset_tag', 'status'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index f48e5aa80..3164729e5 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -532,27 +532,32 @@ class PlatformForm(BootstrapMixin, forms.ModelForm): class DeviceForm(BootstrapMixin, CustomFieldForm): site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'})) - rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, widget=APISelect( - api_url='/api/dcim/racks/?site_id={{site}}', - display_field='display_name', - attrs={'filter-for': 'position'} - )) - position = forms.TypedChoiceField(required=False, empty_value=None, - help_text="The lowest-numbered unit occupied by the device", - widget=APISelect(api_url='/api/dcim/racks/{{rack}}/units/?face={{face}}', - disabled_indicator='device')) - manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), - widget=forms.Select(attrs={'filter-for': 'device_type'})) - device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), label='Device type', widget=APISelect( - api_url='/api/dcim/device-types/?manufacturer_id={{manufacturer}}', - display_field='model' - )) + rack = forms.ModelChoiceField( + queryset=Rack.objects.all(), required=False, widget=APISelect( + api_url='/api/dcim/racks/?site_id={{site}}', + display_field='display_name', + attrs={'filter-for': 'position'} + ) + ) + position = forms.TypedChoiceField( + required=False, empty_value=None, help_text="The lowest-numbered unit occupied by the device", + widget=APISelect(api_url='/api/dcim/racks/{{rack}}/units/?face={{face}}', disabled_indicator='device') + ) + manufacturer = forms.ModelChoiceField( + queryset=Manufacturer.objects.all(), widget=forms.Select(attrs={'filter-for': 'device_type'}) + ) + device_type = forms.ModelChoiceField( + queryset=DeviceType.objects.all(), label='Device type', + widget=APISelect(api_url='/api/dcim/device-types/?manufacturer_id={{manufacturer}}', display_field='model') + ) comments = CommentField() class Meta: model = Device - fields = ['name', 'device_role', 'tenant', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', - 'face', 'status', 'platform', 'primary_ip4', 'primary_ip6', 'comments'] + fields = [ + 'name', 'device_role', 'tenant', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', + 'status', 'platform', 'primary_ip4', 'primary_ip6', 'comments', + ] help_texts = { 'device_role': "The function this device serves", 'serial': "Chassis serial number", @@ -764,6 +769,13 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): nullable_fields = ['tenant', 'platform'] +def device_status_choices(): + status_counts = {} + for status in Device.objects.values('status').annotate(count=Count('status')).order_by('status'): + status_counts[status['status']] = status['count'] + return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in STATUS_CHOICES] + + class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Device q = forms.CharField(required=False, label='Search') @@ -783,10 +795,7 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): queryset=Tenant.objects.annotate(filter_count=Count('devices')), to_field_name='slug', null_option=(0, 'None'), ) - manufacturer_id = FilterChoiceField( - queryset=Manufacturer.objects.all(), - label='Manufacturer', - ) + manufacturer_id = FilterChoiceField(queryset=Manufacturer.objects.all(), label='Manufacturer') device_type_id = FilterChoiceField( queryset=DeviceType.objects.select_related('manufacturer').order_by('model').annotate( filter_count=Count('instances'), @@ -798,14 +807,8 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', null_option=(0, 'None'), ) - status = forms.NullBooleanField( - required=False, - widget=forms.Select(choices=FORM_STATUS_CHOICES), - ) - mac_address = forms.CharField( - required=False, - label='MAC address', - ) + status = forms.ChoiceField(required=False, choices=device_status_choices) + mac_address = forms.CharField(required=False, label='MAC address') # diff --git a/netbox/dcim/migrations/0035_device_expand_status_choices.py b/netbox/dcim/migrations/0035_device_expand_status_choices.py new file mode 100644 index 000000000..16ea807c9 --- /dev/null +++ b/netbox/dcim/migrations/0035_device_expand_status_choices.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-05-08 15:57 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0034_rename_module_to_inventoryitem'), + ] + + # We convert the BooleanField to an IntegerField first as PostgreSQL does not provide a direct cast for boolean to + # smallint (attempting to convert directly yields the error "cannot cast type boolean to smallint"). + operations = [ + migrations.AlterField( + model_name='device', + name='status', + field=models.PositiveIntegerField(choices=[[1, b'Active'], [0, b'Offline'], [2, b'Planned'], [3, b'Staged'], [4, b'Failed'], [5, b'Inventory']], default=1, verbose_name=b'Status'), + ), + migrations.AlterField( + model_name='device', + name='status', + field=models.PositiveSmallIntegerField(choices=[[1, b'Active'], [0, b'Offline'], [2, b'Planned'], [3, b'Staged'], [4, b'Failed'], [5, b'Inventory']], default=1, verbose_name=b'Status'), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 8b263a06b..b7c4f9837 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -178,13 +178,30 @@ VIRTUAL_IFACE_TYPES = [ IFACE_FF_LAG, ] -STATUS_ACTIVE = True -STATUS_OFFLINE = False +STATUS_OFFLINE = 0 +STATUS_ACTIVE = 1 +STATUS_PLANNED = 2 +STATUS_STAGED = 3 +STATUS_FAILED = 4 +STATUS_INVENTORY = 5 STATUS_CHOICES = [ [STATUS_ACTIVE, 'Active'], [STATUS_OFFLINE, 'Offline'], + [STATUS_PLANNED, 'Planned'], + [STATUS_STAGED, 'Staged'], + [STATUS_FAILED, 'Failed'], + [STATUS_INVENTORY, 'Inventory'], ] +DEVICE_STATUS_CLASSES = { + 0: 'warning', + 1: 'success', + 2: 'info', + 3: 'primary', + 4: 'danger', + 5: 'default', +} + CONNECTION_STATUS_PLANNED = False CONNECTION_STATUS_CONNECTED = True CONNECTION_STATUS_CHOICES = [ @@ -933,19 +950,26 @@ class Device(CreatedUpdatedModel, CustomFieldModel): platform = models.ForeignKey('Platform', related_name='devices', blank=True, null=True, on_delete=models.SET_NULL) name = NullableCharField(max_length=64, blank=True, null=True, unique=True) serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number') - asset_tag = NullableCharField(max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag', - help_text='A unique tag used to identify this device') + asset_tag = NullableCharField( + max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag', + help_text='A unique tag used to identify this device' + ) site = models.ForeignKey('Site', related_name='devices', on_delete=models.PROTECT) rack = models.ForeignKey('Rack', related_name='devices', blank=True, null=True, on_delete=models.PROTECT) - position = models.PositiveSmallIntegerField(blank=True, null=True, validators=[MinValueValidator(1)], - verbose_name='Position (U)', - help_text='The lowest-numbered unit occupied by the device') + position = models.PositiveSmallIntegerField( + blank=True, null=True, validators=[MinValueValidator(1)], verbose_name='Position (U)', + help_text='The lowest-numbered unit occupied by the device' + ) face = models.PositiveSmallIntegerField(blank=True, null=True, choices=RACK_FACE_CHOICES, verbose_name='Rack face') - status = models.BooleanField(choices=STATUS_CHOICES, default=STATUS_ACTIVE, verbose_name='Status') - primary_ip4 = models.OneToOneField('ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL, - blank=True, null=True, verbose_name='Primary IPv4') - primary_ip6 = models.OneToOneField('ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL, - blank=True, null=True, verbose_name='Primary IPv6') + status = models.PositiveSmallIntegerField(choices=STATUS_CHOICES, default=STATUS_ACTIVE, verbose_name='Status') + primary_ip4 = models.OneToOneField( + 'ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL, blank=True, null=True, + verbose_name='Primary IPv4' + ) + primary_ip6 = models.OneToOneField( + 'ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL, blank=True, null=True, + verbose_name='Primary IPv6' + ) comments = models.TextField(blank=True) custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') images = GenericRelation(ImageAttachment) @@ -1108,6 +1132,9 @@ class Device(CreatedUpdatedModel, CustomFieldModel): """ return Device.objects.filter(parent_bay__device=self.pk) + def get_status_class(self): + return DEVICE_STATUS_CLASSES[self.status] + def get_rpc_client(self): """ Return the appropriate RPC (e.g. NETCONF, ssh, etc.) client for this device's platform, if one is defined. diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 2dbf83876..8e485f154 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -92,12 +92,8 @@ DEVICE_ROLE = """ """ -STATUS_ICON = """ -{% if record.status %} - -{% else %} - -{% endif %} +DEVICE_STATUS = """ +{{ record.get_status_display }} """ DEVICE_PRIMARY_IP = """ @@ -432,7 +428,7 @@ class PlatformTable(BaseTable): class DeviceTable(BaseTable): pk = ToggleColumn() name = tables.TemplateColumn(template_code=DEVICE_LINK) - status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='') + status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status') tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')]) @@ -452,7 +448,7 @@ class DeviceTable(BaseTable): class DeviceSearchTable(SearchTable): name = tables.TemplateColumn(template_code=DEVICE_LINK) - status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='') + status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status') tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')]) diff --git a/netbox/extras/management/commands/run_inventory.py b/netbox/extras/management/commands/run_inventory.py index a7d643173..c8008ed18 100644 --- a/netbox/extras/management/commands/run_inventory.py +++ b/netbox/extras/management/commands/run_inventory.py @@ -6,7 +6,7 @@ from django.conf import settings from django.core.management.base import BaseCommand, CommandError from django.db import transaction -from dcim.models import Device, InventoryItem, Site +from dcim.models import Device, InventoryItem, Site, STATUS_ACTIVE class Command(BaseCommand): @@ -39,7 +39,7 @@ class Command(BaseCommand): self.password = getpass("Password: ") # Attempt to inventory only active devices - device_list = Device.objects.filter(status=True) + device_list = Device.objects.filter(status=STATUS_ACTIVE) # --site: Include only devices belonging to specified site(s) if options['site']: @@ -72,7 +72,7 @@ class Command(BaseCommand): # Skip inactive devices if not device.status: - self.stdout.write("Skipped (inactive)") + self.stdout.write("Skipped (not active)") continue # Skip devices without primary_ip set diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 82a3a765e..02738da1f 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -123,11 +123,7 @@ Status - {% if device.status %} - {{ device.get_status_display }} - {% else %} - {{ device.get_status_display }} - {% endif %} + {{ device.get_status_display }} diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index fabb6dae2..522d39d1d 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -55,8 +55,8 @@
Management
- {% render_field form.platform %} {% render_field form.status %} + {% render_field form.platform %} {% if obj.pk %} {% render_field form.primary_ip4 %} {% render_field form.primary_ip6 %}