From fbc5e74318bd58b3bcb0cdec78627cb76a4df5ef Mon Sep 17 00:00:00 2001 From: Faidon Liambotis Date: Tue, 18 Jul 2017 01:50:26 +0300 Subject: [PATCH 001/125] Allow import/export of regions (#1347) --- netbox/dcim/forms.py | 22 ++++++++++++++++++++++ netbox/dcim/models.py | 11 +++++++++++ netbox/dcim/urls.py | 1 + netbox/dcim/views.py | 7 +++++++ netbox/extras/constants.py | 3 ++- netbox/templates/_base.html | 1 + netbox/templates/dcim/region_list.html | 5 +++++ 7 files changed, 49 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 440c12623..8399b0de6 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -70,6 +70,28 @@ class RegionForm(BootstrapMixin, forms.ModelForm): fields = ['parent', 'name', 'slug'] +class RegionCSVForm(forms.ModelForm): + parent = forms.ModelChoiceField( + queryset=Region.objects.all(), + required=False, + to_field_name='name', + help_text='Name of parent region', + error_messages={ + 'invalid_choice': 'Region not found.', + } + ) + + class Meta: + model = Region + fields = [ + 'name', 'slug', 'parent', + ] + help_texts = { + 'name': 'Region name', + 'slug': 'URL-friendly slug', + } + + # # Sites # diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 8dd11e663..af3387368 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -43,6 +43,10 @@ class Region(MPTTModel): name = models.CharField(max_length=50, unique=True) slug = models.SlugField(unique=True) + csv_headers = [ + 'name', 'slug', 'parent', + ] + class MPTTMeta: order_insertion_by = ['name'] @@ -52,6 +56,13 @@ class Region(MPTTModel): def get_absolute_url(self): return "{}?region={}".format(reverse('dcim:site_list'), self.slug) + def to_csv(self): + return csv_format([ + self.name, + self.slug, + self.parent.name if self.parent else None, + ]) + # # Sites diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 172f634fb..b03b7d030 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -15,6 +15,7 @@ urlpatterns = [ # Regions url(r'^regions/$', views.RegionListView.as_view(), name='region_list'), url(r'^regions/add/$', views.RegionCreateView.as_view(), name='region_add'), + url(r'^regions/import/$', views.RegionBulkImportView.as_view(), name='region_import'), url(r'^regions/delete/$', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), url(r'^regions/(?P\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index ea07138d5..ca144afe8 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -202,6 +202,13 @@ class RegionEditView(RegionCreateView): permission_required = 'dcim.change_region' +class RegionBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_region' + model_form = forms.RegionCSVForm + table = tables.RegionTable + default_return_url = 'dcim:region_list' + + class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_region' cls = Region diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 86da90895..0ebd49af0 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -37,7 +37,8 @@ GRAPH_TYPE_CHOICES = ( # Models which support export templates EXPORTTEMPLATE_MODELS = [ - 'site', 'rack', 'device', 'consoleport', 'powerport', 'interfaceconnection', # DCIM + 'site', 'region', 'rack', 'device', # DCIM + 'consoleport', 'powerport', 'interfaceconnection', # DCIM 'aggregate', 'prefix', 'ipaddress', 'vlan', # IPAM 'provider', 'circuit', # Circuits 'tenant', # Tenants diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index 10b4970a8..65cf39b30 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -40,6 +40,7 @@
  • Regions
  • {% if perms.dcim.add_region %}
  • Add a Region
  • +
  • Import Regions
  • {% endif %}
  • Tenants
  • diff --git a/netbox/templates/dcim/region_list.html b/netbox/templates/dcim/region_list.html index b54201a34..f5dde06d8 100644 --- a/netbox/templates/dcim/region_list.html +++ b/netbox/templates/dcim/region_list.html @@ -10,7 +10,12 @@ Add a region + + + Import regions + {% endif %} + {% include 'inc/export_button.html' with obj_type='regions' %}

    {{ block.title }}

    From beb91559e5e2215c671c3ff00e247a86540bcee9 Mon Sep 17 00:00:00 2001 From: Faidon Liambotis Date: Tue, 18 Jul 2017 02:04:54 +0300 Subject: [PATCH 002/125] Allow import/export of rack groups (#1347) --- netbox/dcim/forms.py | 21 +++++++++++++++++++++ netbox/dcim/models.py | 11 +++++++++++ netbox/dcim/urls.py | 1 + netbox/dcim/views.py | 7 +++++++ netbox/extras/constants.py | 2 +- netbox/templates/_base.html | 1 + netbox/templates/dcim/rackgroup_list.html | 5 +++++ 7 files changed, 47 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 8399b0de6..a216bb75f 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -190,6 +190,27 @@ class RackGroupForm(BootstrapMixin, forms.ModelForm): fields = ['site', 'name', 'slug'] +class RackGroupCSVForm(forms.ModelForm): + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + help_text='Name of parent site', + error_messages={ + 'invalid_choice': 'Site not found.', + } + ) + + class Meta: + model = RackGroup + fields = [ + 'site', 'name', 'slug', + ] + help_texts = { + 'name': 'Name of rack group', + 'slug': 'URL-friendly slug', + } + + class RackGroupFilterForm(BootstrapMixin, forms.Form): site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('rack_groups')), to_field_name='slug') diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index af3387368..1cc948643 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -159,6 +159,10 @@ class RackGroup(models.Model): slug = models.SlugField() site = models.ForeignKey('Site', related_name='rack_groups', on_delete=models.CASCADE) + csv_headers = [ + 'site', 'name', 'slug', + ] + class Meta: ordering = ['site', 'name'] unique_together = [ @@ -172,6 +176,13 @@ class RackGroup(models.Model): def get_absolute_url(self): return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk) + def to_csv(self): + return csv_format([ + self.site, + self.name, + self.slug, + ]) + @python_2_unicode_compatible class RackRole(models.Model): diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index b03b7d030..12f8ebf86 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -32,6 +32,7 @@ urlpatterns = [ # Rack groups url(r'^rack-groups/$', views.RackGroupListView.as_view(), name='rackgroup_list'), url(r'^rack-groups/add/$', views.RackGroupCreateView.as_view(), name='rackgroup_add'), + url(r'^rack-groups/import/$', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'), url(r'^rack-groups/delete/$', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'), url(r'^rack-groups/(?P\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index ca144afe8..c155774db 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -314,6 +314,13 @@ class RackGroupEditView(RackGroupCreateView): permission_required = 'dcim.change_rackgroup' +class RackGroupBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_rackgroup' + model_form = forms.RackGroupCSVForm + table = tables.RackGroupTable + default_return_url = 'dcim:rackgroup_list' + + class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rackgroup' cls = RackGroup diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 0ebd49af0..2c3918f75 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -37,7 +37,7 @@ GRAPH_TYPE_CHOICES = ( # Models which support export templates EXPORTTEMPLATE_MODELS = [ - 'site', 'region', 'rack', 'device', # DCIM + 'site', 'region', 'rack', 'rackgroup', 'device', # DCIM 'consoleport', 'powerport', 'interfaceconnection', # DCIM 'aggregate', 'prefix', 'ipaddress', 'vlan', # IPAM 'provider', 'circuit', # Circuits diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index 65cf39b30..ebc8bc680 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -68,6 +68,7 @@
  • Rack Groups
  • {% if perms.dcim.add_rackgroup %}
  • Add a Rack Group
  • +
  • Import Rack Groups
  • {% endif %}
  • Rack Roles
  • diff --git a/netbox/templates/dcim/rackgroup_list.html b/netbox/templates/dcim/rackgroup_list.html index dee6472fb..086a7df27 100644 --- a/netbox/templates/dcim/rackgroup_list.html +++ b/netbox/templates/dcim/rackgroup_list.html @@ -10,7 +10,12 @@ Add a rack group + + + Import rack groups + {% endif %} + {% include 'inc/export_button.html' with obj_type='rackgroups' %}

    Rack Groups

    From f30fa925eaa2f562a65f88319aa8681116436867 Mon Sep 17 00:00:00 2001 From: Faidon Liambotis Date: Tue, 18 Jul 2017 02:37:28 +0300 Subject: [PATCH 003/125] Allow import/export of manufacturers (#1347) --- netbox/dcim/forms.py | 12 ++++++++++++ netbox/dcim/models.py | 10 ++++++++++ netbox/dcim/urls.py | 1 + netbox/dcim/views.py | 7 +++++++ netbox/extras/constants.py | 2 +- netbox/templates/_base.html | 1 + netbox/templates/dcim/manufacturer_list.html | 5 +++++ 7 files changed, 37 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index a216bb75f..6d37621c3 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -423,6 +423,18 @@ class ManufacturerForm(BootstrapMixin, forms.ModelForm): fields = ['name', 'slug'] +class ManufacturerCSVForm(forms.ModelForm): + class Meta: + model = Manufacturer + fields = [ + 'name', 'slug' + ] + help_texts = { + 'name': 'Manufacturer name', + 'slug': 'URL-friendly slug', + } + + # # Device types # diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 1cc948643..eb4aae53d 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -455,6 +455,10 @@ class Manufacturer(models.Model): name = models.CharField(max_length=50, unique=True) slug = models.SlugField(unique=True) + csv_headers = [ + 'name', 'slug', + ] + class Meta: ordering = ['name'] @@ -464,6 +468,12 @@ class Manufacturer(models.Model): def get_absolute_url(self): return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug) + def to_csv(self): + return csv_format([ + self.name, + self.slug, + ]) + @python_2_unicode_compatible class DeviceType(models.Model, CustomFieldModel): diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 12f8ebf86..ffd9b9005 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -64,6 +64,7 @@ urlpatterns = [ # Manufacturers url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'), url(r'^manufacturers/add/$', views.ManufacturerCreateView.as_view(), name='manufacturer_add'), + url(r'^manufacturers/import/$', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'), url(r'^manufacturers/delete/$', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), url(r'^manufacturers/(?P[\w-]+)/edit/$', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index c155774db..6766ab246 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -561,6 +561,13 @@ class ManufacturerEditView(ManufacturerCreateView): permission_required = 'dcim.change_manufacturer' +class ManufacturerBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_manufacturer' + model_form = forms.ManufacturerCSVForm + table = tables.ManufacturerTable + default_return_url = 'dcim:manufacturer_list' + + class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_manufacturer' cls = Manufacturer diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 2c3918f75..815c896c5 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -37,7 +37,7 @@ GRAPH_TYPE_CHOICES = ( # Models which support export templates EXPORTTEMPLATE_MODELS = [ - 'site', 'region', 'rack', 'rackgroup', 'device', # DCIM + 'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'device', # DCIM 'consoleport', 'powerport', 'interfaceconnection', # DCIM 'aggregate', 'prefix', 'ipaddress', 'vlan', # IPAM 'provider', 'circuit', # Circuits diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index ebc8bc680..d41b2c66f 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -105,6 +105,7 @@
  • Manufacturers
  • {% if perms.dcim.add_manufacturer %}
  • Add a Manufacturer
  • +
  • Import Manufacturers
  • {% endif %} {% if perms.dcim.add_manufacturer or perms.dcim.add_platform %}
  • diff --git a/netbox/templates/dcim/manufacturer_list.html b/netbox/templates/dcim/manufacturer_list.html index d535e11e8..e0d47dba1 100644 --- a/netbox/templates/dcim/manufacturer_list.html +++ b/netbox/templates/dcim/manufacturer_list.html @@ -10,7 +10,12 @@ Add a manufacturer + + + Import manufacturers + {% endif %} + {% include 'inc/export_button.html' with obj_type='manufacturers' %}

    Manufacturers

    From 4544893b4cbf3301ee66b3dbc79f5cbf6217ba77 Mon Sep 17 00:00:00 2001 From: Faidon Liambotis Date: Tue, 18 Jul 2017 04:50:24 +0300 Subject: [PATCH 004/125] Allow import/export of device types (#1347) --- netbox/dcim/forms.py | 33 +++++++++++++++++++++- netbox/dcim/models.py | 20 +++++++++++++ netbox/dcim/urls.py | 1 + netbox/dcim/views.py | 7 +++++ netbox/extras/constants.py | 2 +- netbox/templates/_base.html | 1 + netbox/templates/dcim/devicetype_list.html | 5 ++++ 7 files changed, 67 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 6d37621c3..bf6516b55 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -24,7 +24,7 @@ from .models import ( IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_FACE_CHOICES, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, RACK_WIDTH_19IN, RACK_WIDTH_23IN, - Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, + Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, SUBDEVICE_ROLE_CHOICES, ) @@ -451,6 +451,37 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldForm): } +class DeviceTypeCSVForm(forms.ModelForm): + manufacturer = forms.ModelChoiceField( + queryset=Manufacturer.objects.all(), + required=True, + to_field_name='name', + help_text='Manufacturer name', + error_messages={ + 'invalid_choice': 'Manufacturer not found.', + } + ) + subdevice_role = CSVChoiceField( + choices=SUBDEVICE_ROLE_CHOICES, + required=False, + help_text='Parent/child status' + ) + interface_ordering = CSVChoiceField( + choices=IFACE_ORDERING_CHOICES, + required=False, + help_text='Interface ordering' + ) + + class Meta: + model = DeviceType + fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', + 'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments'] + help_texts = { + 'model': 'Model name', + 'slug': 'URL-friendly slug', + } + + class DeviceTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput) manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index eb4aae53d..e24a05e18 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -513,6 +513,11 @@ class DeviceType(models.Model, CustomFieldModel): comments = models.TextField(blank=True) custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + csv_headers = [ + 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', + 'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', + ] + class Meta: ordering = ['manufacturer', 'model'] unique_together = [ @@ -532,6 +537,21 @@ class DeviceType(models.Model, CustomFieldModel): def get_absolute_url(self): return reverse('dcim:devicetype', args=[self.pk]) + def to_csv(self): + return csv_format([ + self.manufacturer.name, + self.model, + self.slug, + self.part_number, + self.u_height, + self.is_full_depth, + self.is_console_server, + self.is_pdu, + self.is_network_device, + self.get_subdevice_role_display() if self.subdevice_role else None, + self.get_interface_ordering_display(), + ]) + def clean(self): # If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index ffd9b9005..63b933d31 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -71,6 +71,7 @@ urlpatterns = [ # Device types url(r'^device-types/$', views.DeviceTypeListView.as_view(), name='devicetype_list'), url(r'^device-types/add/$', views.DeviceTypeCreateView.as_view(), name='devicetype_add'), + url(r'^device-types/import/$', views.DeviceTypeBulkImportView.as_view(), name='devicetype_import'), url(r'^device-types/edit/$', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'), url(r'^device-types/delete/$', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'), url(r'^device-types/(?P\d+)/$', views.DeviceTypeView.as_view(), name='devicetype'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 6766ab246..0e3114b8c 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -658,6 +658,13 @@ class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView): default_return_url = 'dcim:devicetype_list' +class DeviceTypeBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_devicetype' + model_form = forms.DeviceTypeCSVForm + table = tables.DeviceTypeTable + default_return_url = 'dcim:devicetype_list' + + class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_devicetype' cls = DeviceType diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 815c896c5..51bd97159 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -37,7 +37,7 @@ GRAPH_TYPE_CHOICES = ( # Models which support export templates EXPORTTEMPLATE_MODELS = [ - 'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'device', # DCIM + 'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device', # DCIM 'consoleport', 'powerport', 'interfaceconnection', # DCIM 'aggregate', 'prefix', 'ipaddress', 'vlan', # IPAM 'provider', 'circuit', # Circuits diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index d41b2c66f..d718f60c1 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -93,6 +93,7 @@
  • Device Types
  • {% if perms.dcim.add_devicetype %}
  • Add a Device Type
  • +
  • Import Device Types
  • {% endif %}
  • Device Roles
  • diff --git a/netbox/templates/dcim/devicetype_list.html b/netbox/templates/dcim/devicetype_list.html index 5ab97a481..c06633ef6 100644 --- a/netbox/templates/dcim/devicetype_list.html +++ b/netbox/templates/dcim/devicetype_list.html @@ -10,7 +10,12 @@ Add a device type + + + Import device types + {% endif %} + {% include 'inc/export_button.html' with obj_type='devicetypes' %}

    Device Types

    From d06813f52823459e57453b9ce593bf6c60f0e57b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 4 Aug 2017 17:02:52 -0400 Subject: [PATCH 005/125] Initial work on virtualization support (#142) --- netbox/dcim/views.py | 90 +------ netbox/netbox/settings.py | 1 + netbox/netbox/urls.py | 1 + netbox/templates/_base.html | 28 +++ netbox/templates/virtualization/cluster.html | 92 +++++++ .../virtualization/cluster_list.html | 26 ++ .../virtualization/clustergroup_list.html | 19 ++ .../virtualization/clustertype_list.html | 19 ++ .../virtualization/virtualmachine.html | 77 ++++++ .../virtualization/virtualmachine_edit.html | 39 +++ .../virtualization/virtualmachine_list.html | 26 ++ netbox/utilities/views.py | 87 +++++++ netbox/virtualization/__init__.py | 0 netbox/virtualization/apps.py | 7 + netbox/virtualization/forms.py | 101 ++++++++ .../virtualization/migrations/0001_initial.py | 107 ++++++++ netbox/virtualization/migrations/__init__.py | 0 netbox/virtualization/models.py | 218 +++++++++++++++++ netbox/virtualization/tables.py | 84 +++++++ netbox/virtualization/urls.py | 41 ++++ netbox/virtualization/views.py | 229 ++++++++++++++++++ 21 files changed, 1210 insertions(+), 82 deletions(-) create mode 100644 netbox/templates/virtualization/cluster.html create mode 100644 netbox/templates/virtualization/cluster_list.html create mode 100644 netbox/templates/virtualization/clustergroup_list.html create mode 100644 netbox/templates/virtualization/clustertype_list.html create mode 100644 netbox/templates/virtualization/virtualmachine.html create mode 100644 netbox/templates/virtualization/virtualmachine_edit.html create mode 100644 netbox/templates/virtualization/virtualmachine_list.html create mode 100644 netbox/virtualization/__init__.py create mode 100644 netbox/virtualization/apps.py create mode 100644 netbox/virtualization/forms.py create mode 100644 netbox/virtualization/migrations/0001_initial.py create mode 100644 netbox/virtualization/migrations/__init__.py create mode 100644 netbox/virtualization/models.py create mode 100644 netbox/virtualization/tables.py create mode 100644 netbox/virtualization/urls.py create mode 100644 netbox/virtualization/views.py diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 78881c200..552a6a534 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -23,7 +23,8 @@ from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_S from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.views import ( - BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, + BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, ComponentEditView, + ObjectDeleteView, ObjectEditView, ObjectListView, ) from . import filters, forms, tables from .models import ( @@ -60,87 +61,6 @@ def expand_pattern(string): yield "{0}{1}".format(lead, i) -class ComponentCreateView(View): - parent_model = None - parent_field = None - model = None - form = None - model_form = None - - def get(self, request, pk): - - parent = get_object_or_404(self.parent_model, pk=pk) - form = self.form(parent, initial=request.GET) - - return render(request, 'dcim/device_component_add.html', { - 'parent': parent, - 'component_type': self.model._meta.verbose_name, - 'form': form, - 'return_url': parent.get_absolute_url(), - }) - - def post(self, request, pk): - - parent = get_object_or_404(self.parent_model, pk=pk) - - form = self.form(parent, request.POST) - if form.is_valid(): - - new_components = [] - data = deepcopy(form.cleaned_data) - - for name in form.cleaned_data['name_pattern']: - component_data = { - self.parent_field: parent.pk, - 'name': name, - } - # Replace objects with their primary key to keep component_form.clean() happy - for k, v in data.items(): - if hasattr(v, 'pk'): - component_data[k] = v.pk - else: - component_data[k] = v - component_form = self.model_form(component_data) - if component_form.is_valid(): - new_components.append(component_form.save(commit=False)) - else: - for field, errors in component_form.errors.as_data().items(): - # Assign errors on the child form's name field to name_pattern on the parent form - if field == 'name': - field = 'name_pattern' - for e in errors: - form.add_error(field, '{}: {}'.format(name, ', '.join(e))) - - if not form.errors: - self.model.objects.bulk_create(new_components) - messages.success(request, "Added {} {} to {}.".format( - len(new_components), self.model._meta.verbose_name_plural, parent - )) - if '_addanother' in request.POST: - return redirect(request.path) - else: - return redirect(parent.get_absolute_url()) - - return render(request, 'dcim/device_component_add.html', { - 'parent': parent, - 'component_type': self.model._meta.verbose_name, - 'form': form, - 'return_url': parent.get_absolute_url(), - }) - - -class ComponentEditView(ObjectEditView): - - def get_return_url(self, request, obj): - return obj.device.get_absolute_url() - - -class ComponentDeleteView(ObjectDeleteView): - - def get_return_url(self, request, obj): - return obj.device.get_absolute_url() - - class BulkDisconnectView(View): """ An extendable view for disconnection console/power/interface components in bulk. @@ -662,6 +582,7 @@ class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView model = ConsolePortTemplate form = forms.ConsolePortTemplateCreateForm model_form = forms.ConsolePortTemplateForm + template_name = 'dcim/device_component_add.html' class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -680,6 +601,7 @@ class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCrea model = ConsoleServerPortTemplate form = forms.ConsoleServerPortTemplateCreateForm model_form = forms.ConsoleServerPortTemplateForm + template_name = 'dcim/device_component_add.html' class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -696,6 +618,7 @@ class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): model = PowerPortTemplate form = forms.PowerPortTemplateCreateForm model_form = forms.PowerPortTemplateForm + template_name = 'dcim/device_component_add.html' class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -712,6 +635,7 @@ class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView model = PowerOutletTemplate form = forms.PowerOutletTemplateCreateForm model_form = forms.PowerOutletTemplateForm + template_name = 'dcim/device_component_add.html' class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -728,6 +652,7 @@ class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): model = InterfaceTemplate form = forms.InterfaceTemplateCreateForm model_form = forms.InterfaceTemplateForm + template_name = 'dcim/device_component_add.html' class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): @@ -752,6 +677,7 @@ class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): model = DeviceBayTemplate form = forms.DeviceBayTemplateCreateForm model_form = forms.DeviceBayTemplateForm + template_name = 'dcim/device_component_add.html' class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 00acc3e67..0fcb02a3f 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -145,6 +145,7 @@ INSTALLED_APPS = ( 'tenancy', 'users', 'utilities', + 'virtualization', ) # Middleware diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index ddddf27a2..260d389cf 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -32,6 +32,7 @@ _patterns = [ url(r'^secrets/', include('secrets.urls')), url(r'^tenancy/', include('tenancy.urls')), url(r'^user/', include('users.urls')), + url(r'^virtualization/', include('virtualization.urls')), # API url(r'^api/$', APIRootView.as_view(), name='api-root'), diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index c56a95abe..4450e4213 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -202,6 +202,34 @@ {% endif %} +
    @@ -27,13 +27,13 @@
    {% if perms.virtualization.change_virtualmachine %} - + Edit this VM {% endif %} {% if perms.virtualization.delete_virtualmachine %} - + Delete this VM {% endif %} @@ -53,7 +53,68 @@ Cluster - {{ vm.cluster }} + + {% if vm.cluster.group %} + {{ vm.cluster.group }} + + {% endif %} + {{ vm.cluster }} + + + + Cluster Type + {{ vm.cluster.type }} + + + Tenant + + {% if vm.tenant %} + {% if vm.tenant.group %} + {{ vm.tenant.group.name }} + + {% endif %} + {{ vm.tenant }} + {% else %} + None + {% endif %} + + + +
    +
    +
    + Resources +
    + + + + + + + + + + + +
    Virtual CPUs + {% if vm.vcpus %} + {{ vm.vcpus }} + {% else %} + N/A + {% endif %} +
    Memory + {% if vm.memory %} + {{ vm.memory }} MB + {% else %} + N/A + {% endif %} +
    Disk Space + {% if vm.disk %} + {{ vm.disk }} GB + {% else %} + N/A + {% endif %} +
    diff --git a/netbox/templates/virtualization/virtualmachine_edit.html b/netbox/templates/virtualization/virtualmachine_edit.html index 41b085579..14acd13db 100644 --- a/netbox/templates/virtualization/virtualmachine_edit.html +++ b/netbox/templates/virtualization/virtualmachine_edit.html @@ -6,13 +6,17 @@
    Virtual Machine
    {% render_field form.name %} + {% render_field form.cluster_group %} + {% render_field form.cluster %} {% render_field form.platform %}
    -
    Cluster
    +
    Resources
    - {% render_field form.cluster %} + {% render_field form.vcpus %} + {% render_field form.memory %} + {% render_field form.disk %}
    diff --git a/netbox/templates/virtualization/virtualmachine_list.html b/netbox/templates/virtualization/virtualmachine_list.html index ae9959e3d..5cd601be5 100644 --- a/netbox/templates/virtualization/virtualmachine_list.html +++ b/netbox/templates/virtualization/virtualmachine_list.html @@ -12,7 +12,7 @@ Import virtual machines {% endif %} - {% include 'inc/export_button.html' with obj_type='virtualmachines' %} + {% include 'inc/export_button.html' with obj_type='virtual machines' %}

    {% block title %}Virtual Machines{% endblock %}

    @@ -24,3 +24,32 @@
    {% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 79fd73ca8..4c1d065d6 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -4,6 +4,7 @@ from rest_framework.viewsets import ModelViewSet from extras.api.views import CustomFieldModelViewSet from utilities.api import WritableSerializerMixin +from virtualization import filters from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from . import serializers @@ -26,6 +27,7 @@ class ClusterViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = Cluster.objects.select_related('type', 'group') serializer_class = serializers.ClusterSerializer write_serializer_class = serializers.WritableClusterSerializer + filter_class = filters.ClusterFilter # @@ -36,6 +38,7 @@ class VirtualMachineViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = VirtualMachine.objects.all() serializer_class = serializers.VirtualMachineSerializer write_serializer_class = serializers.WritableVirtualMachineSerializer + filter_class = filters.VirtualMachineFilter class VMInterfaceViewSet(WritableSerializerMixin, ModelViewSet): diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py new file mode 100644 index 000000000..4efcd06e2 --- /dev/null +++ b/netbox/virtualization/filters.py @@ -0,0 +1,99 @@ +from __future__ import unicode_literals + +import django_filters +from django.db.models import Q + +from dcim.models import Platform +from extras.filters import CustomFieldFilterSet +from tenancy.models import Tenant +from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter +from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine + + +class ClusterFilter(CustomFieldFilterSet): + id__in = NumericInFilter(name='id', lookup_expr='in') + q = django_filters.CharFilter( + method='search', + label='Search', + ) + group_id = NullableModelMultipleChoiceFilter( + queryset=ClusterGroup.objects.all(), + label='Parent group (ID)', + ) + group = NullableModelMultipleChoiceFilter( + queryset=ClusterGroup.objects.all(), + to_field_name='slug', + label='Parent group (slug)', + ) + type_id = django_filters.ModelMultipleChoiceFilter( + queryset=ClusterType.objects.all(), + label='Cluster type (ID)', + ) + type = django_filters.ModelMultipleChoiceFilter( + name='type__slug', + queryset=ClusterType.objects.all(), + to_field_name='slug', + label='Cluster type (slug)', + ) + + class Meta: + model = Cluster + fields = ['name'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(comments__icontains=value) + ) + + +class VirtualMachineFilter(CustomFieldFilterSet): + id__in = NumericInFilter(name='id', lookup_expr='in') + q = django_filters.CharFilter( + method='search', + label='Search', + ) + cluster_group_id = NullableModelMultipleChoiceFilter( + name='cluster__group', + queryset=ClusterGroup.objects.all(), + label='Cluster group (ID)', + ) + cluster_group = NullableModelMultipleChoiceFilter( + name='cluster__group__slug', + queryset=ClusterGroup.objects.all(), + to_field_name='slug', + label='Cluster group (slug)', + ) + tenant_id = NullableModelMultipleChoiceFilter( + queryset=Tenant.objects.all(), + label='Tenant (ID)', + ) + tenant = NullableModelMultipleChoiceFilter( + queryset=Tenant.objects.all(), + to_field_name='slug', + label='Tenant (slug)', + ) + platform_id = NullableModelMultipleChoiceFilter( + queryset=Platform.objects.all(), + label='Platform (ID)', + ) + platform = NullableModelMultipleChoiceFilter( + name='platform', + queryset=Platform.objects.all(), + to_field_name='slug', + label='Platform (slug)', + ) + + class Meta: + model = VirtualMachine + fields = ['name', 'cluster'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(comments__icontains=value) + ) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 8176ce676..7dbf7dcb3 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -1,11 +1,12 @@ from __future__ import unicode_literals from django import forms +from django.db.models import Count -from extras.forms import CustomFieldBulkEditForm, CustomFieldForm +from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm from tenancy.forms import TenancyForm from tenancy.models import Tenant -from utilities.forms import BootstrapMixin, SlugField +from utilities.forms import APISelect, BootstrapMixin, ChainedModelChoiceField, FilterChoiceField, SlugField from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -67,15 +68,58 @@ class ClusterCSVForm(forms.ModelForm): fields = ['name', 'type', 'group'] +class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Cluster + q = forms.CharField(required=False, label='Search') + group = FilterChoiceField( + queryset=ClusterGroup.objects.annotate(filter_count=Count('clusters')), + to_field_name='slug', + null_option=(0, 'None'), + required=False, + ) + type = FilterChoiceField( + queryset=ClusterType.objects.annotate(filter_count=Count('clusters')), + to_field_name='slug', + required=False, + ) + + # # Virtual Machines # class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm): + cluster_group = forms.ModelChoiceField( + queryset=ClusterGroup.objects.all(), + required=False, + widget=forms.Select( + attrs={'filter-for': 'cluster', 'nullable': 'true'} + ) + ) + cluster = ChainedModelChoiceField( + queryset=Cluster.objects.all(), + chains=( + ('group', 'cluster_group'), + ), + widget=APISelect( + api_url='/api/virtualization/clusters/?group_id={{cluster_group}}' + ) + ) class Meta: model = VirtualMachine - fields = ['name', 'cluster', 'tenant', 'platform', 'comments'] + fields = ['name', 'cluster_group', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments'] + + def __init__(self, *args, **kwargs): + + # Initialize helper selector + instance = kwargs.get('instance') + if instance.pk and instance.cluster is not None: + initial = kwargs.get('initial', {}).copy() + initial['cluster_group'] = instance.cluster.group + kwargs['initial'] = initial + + super(VirtualMachineForm, self).__init__(*args, **kwargs) class VirtualMachineCSVForm(forms.ModelForm): @@ -89,7 +133,7 @@ class VirtualMachineCSVForm(forms.ModelForm): ) class Meta: - fields = ['cluster', 'name', 'tenant'] + fields = ['cluster', 'name', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments'] class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): @@ -99,3 +143,17 @@ class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class Meta: nullable_fields = ['tenant'] + + +class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = VirtualMachine + q = forms.CharField(required=False, label='Search') + cluster_group = FilterChoiceField( + queryset=ClusterGroup.objects.all(), + to_field_name='slug', + null_option=(0, 'None'), + ) + cluster_id = FilterChoiceField( + queryset=Cluster.objects.annotate(filter_count=Count('virtual_machines')), + label='Cluster' + ) diff --git a/netbox/virtualization/migrations/0001_initial.py b/netbox/virtualization/migrations/0001_initial.py index ddf7c897d..1f812d209 100644 --- a/netbox/virtualization/migrations/0001_initial.py +++ b/netbox/virtualization/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-08-16 19:22 +# Generated by Django 1.11.4 on 2017-08-16 19:27 from __future__ import unicode_literals import dcim.fields @@ -13,9 +13,9 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('dcim', '0041_napalm_integration'), ('ipam', '0018_remove_service_uniqueness_constraint'), ('tenancy', '0003_unicode_literals'), + ('dcim', '0041_napalm_integration'), ] operations = [ @@ -63,9 +63,9 @@ class Migration(migrations.Migration): ('created', models.DateField(auto_now_add=True)), ('last_updated', models.DateTimeField(auto_now=True)), ('name', models.CharField(max_length=64, unique=True)), - ('vcpus', models.PositiveSmallIntegerField(blank=True, verbose_name='vCPUs')), - ('memory', models.PositiveIntegerField(blank=True, verbose_name='Memory (MB)')), - ('disk', models.PositiveIntegerField(blank=True, verbose_name='Disk (GB)')), + ('vcpus', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='vCPUs')), + ('memory', models.PositiveIntegerField(blank=True, null=True, verbose_name='Memory (MB)')), + ('disk', models.PositiveIntegerField(blank=True, null=True, verbose_name='Disk (GB)')), ('comments', models.TextField(blank=True)), ('cluster', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='virtualization.Cluster')), ('platform', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='virtual_machines', to='dcim.Platform')), diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 0cd6ac791..042bdf904 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -161,14 +161,17 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): ) vcpus = models.PositiveSmallIntegerField( blank=True, + null=True, verbose_name='vCPUs' ) memory = models.PositiveIntegerField( blank=True, + null=True, verbose_name='Memory (MB)' ) disk = models.PositiveIntegerField( blank=True, + null=True, verbose_name='Disk (GB)' ) comments = models.TextField( diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index a5bbad9a2..3794a59b5 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -77,8 +77,9 @@ class ClusterTable(BaseTable): class VirtualMachineTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn() + cluster = tables.LinkColumn('virtualization:cluster', args=[Accessor('cluster.pk')]) tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) class Meta(BaseTable.Meta): model = VirtualMachine - fields = ('pk', 'name', 'tenant') + fields = ('pk', 'name', 'cluster', 'tenant', 'vcpus', 'memory', 'disk') diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 1122cc28e..4be2d2563 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -11,6 +11,7 @@ from utilities.views import ( ObjectDeleteView, ObjectEditView, ObjectListView, ) from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface +from . import filters from . import forms from . import tables @@ -84,6 +85,8 @@ class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class ClusterListView(ObjectListView): queryset = Cluster.objects.annotate(vm_count=Count('virtual_machines')) table = tables.ClusterTable + filter = filters.ClusterFilter + filter_form = forms.ClusterFilterForm template_name = 'virtualization/cluster_list.html' @@ -138,8 +141,8 @@ class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class VirtualMachineListView(ObjectListView): queryset = VirtualMachine.objects.select_related('tenant') - # filter = filters.VirtualMachineFilter - # filter_form = forms.VirtualMachineFilterForm + filter = filters.VirtualMachineFilter + filter_form = forms.VirtualMachineFilterForm table = tables.VirtualMachineTable template_name = 'virtualization/virtualmachine_list.html' @@ -184,7 +187,7 @@ class VirtualMachineBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'virtualization.change_virtualmachine' cls = VirtualMachine queryset = VirtualMachine.objects.select_related('tenant') - # filter = filters.VirtualMachineFilter + filter = filters.VirtualMachineFilter table = tables.VirtualMachineTable form = forms.VirtualMachineBulkEditForm default_return_url = 'virtualization:virtualmachine_list' From a02055e9b578eb60e161acc1064323604c598cf4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 16 Aug 2017 17:21:34 -0400 Subject: [PATCH 009/125] Reworked relationship between devices and clusters --- netbox/dcim/api/serializers.py | 13 ++++++++++- netbox/dcim/migrations/0042_device_cluster.py | 22 +++++++++++++++++++ netbox/dcim/models.py | 7 ++++++ netbox/templates/virtualization/cluster.html | 7 ++++++ .../virtualization/migrations/0001_initial.py | 5 ++--- netbox/virtualization/models.py | 3 --- netbox/virtualization/views.py | 3 +++ 7 files changed, 53 insertions(+), 7 deletions(-) create mode 100644 netbox/dcim/migrations/0042_device_cluster.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index ebfb781e0..9f9b0f6a6 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -16,6 +16,7 @@ from dcim.models import ( from extras.api.customfields import CustomFieldModelSerializer from tenancy.api.serializers import NestedTenantSerializer from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer +from virtualization.models import Cluster # @@ -446,6 +447,15 @@ class DeviceIPAddressSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'family', 'address'] +# Cannot import virtualization.api.NestedClusterSerializer due to circular dependency +class NestedClusterSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') + + class Meta: + model = Cluster + fields = ['id', 'url', 'name'] + + class DeviceSerializer(CustomFieldModelSerializer): device_type = NestedDeviceTypeSerializer() device_role = NestedDeviceRoleSerializer() @@ -459,13 +469,14 @@ class DeviceSerializer(CustomFieldModelSerializer): primary_ip4 = DeviceIPAddressSerializer() primary_ip6 = DeviceIPAddressSerializer() parent_device = serializers.SerializerMethodField() + cluster = NestedClusterSerializer() class Meta: model = Device fields = [ 'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', - 'comments', 'custom_fields', + 'cluster', 'comments', 'custom_fields', ] def get_parent_device(self, obj): diff --git a/netbox/dcim/migrations/0042_device_cluster.py b/netbox/dcim/migrations/0042_device_cluster.py new file mode 100644 index 000000000..a3c2b7859 --- /dev/null +++ b/netbox/dcim/migrations/0042_device_cluster.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-16 21:06 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0001_initial'), + ('dcim', '0041_napalm_integration'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='cluster', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='devices', to='virtualization.Cluster'), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 9f72fc83e..d6e68df9b 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -808,6 +808,13 @@ class Device(CreatedUpdatedModel, CustomFieldModel): 'ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL, blank=True, null=True, verbose_name='Primary IPv6' ) + cluster = models.ForeignKey( + to='virtualization.Cluster', + on_delete=models.SET_NULL, + related_name='devices', + blank=True, + null=True + ) comments = models.TextField(blank=True) custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') images = GenericRelation(ImageAttachment) diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index c5d514713..23834e0c4 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -4,6 +4,13 @@ {% block content %}
    +
    diff --git a/netbox/virtualization/migrations/0001_initial.py b/netbox/virtualization/migrations/0001_initial.py index 1f812d209..57b8dc4e0 100644 --- a/netbox/virtualization/migrations/0001_initial.py +++ b/netbox/virtualization/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-08-16 19:27 +# Generated by Django 1.11.4 on 2017-08-16 21:06 from __future__ import unicode_literals import dcim.fields @@ -13,9 +13,9 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('ipam', '0018_remove_service_uniqueness_constraint'), ('tenancy', '0003_unicode_literals'), ('dcim', '0041_napalm_integration'), + ('ipam', '0018_remove_service_uniqueness_constraint'), ] operations = [ @@ -27,7 +27,6 @@ class Migration(migrations.Migration): ('last_updated', models.DateTimeField(auto_now=True)), ('name', models.CharField(max_length=100, unique=True)), ('comments', models.TextField(blank=True)), - ('devices', models.ManyToManyField(to='dcim.Device')), ], options={ 'ordering': ['name'], diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 042bdf904..7cdd46158 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -89,9 +89,6 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel): blank=True, null=True ) - devices = models.ManyToManyField( - to='dcim.Device' - ) comments = models.TextField( blank=True ) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 4be2d2563..8d6388445 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -6,6 +6,7 @@ from django.shortcuts import get_object_or_404, render from django.urls import reverse from django.views.generic import View +from dcim.models import Device from utilities.views import ( BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView, @@ -95,9 +96,11 @@ class ClusterView(View): def get(self, request, pk): cluster = get_object_or_404(Cluster, pk=pk) + devices = Device.objects.filter(cluster=cluster) return render(request, 'virtualization/cluster.html', { 'cluster': cluster, + 'devices': devices, }) From 6e580a731ed8f0871e7499ac612b7383fef05577 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 18 Aug 2017 12:29:39 -0400 Subject: [PATCH 010/125] Added template names to device component create views --- netbox/dcim/views.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 9279b1bda..b1bb8daf5 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -961,6 +961,7 @@ class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView): model = ConsolePort form = forms.ConsolePortCreateForm model_form = forms.ConsolePortForm + template_name = 'dcim/device_component_add.html' @permission_required('dcim.change_consoleport') @@ -1075,6 +1076,7 @@ class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView): model = ConsoleServerPort form = forms.ConsoleServerPortCreateForm model_form = forms.ConsoleServerPortForm + template_name = 'dcim/device_component_add.html' @permission_required('dcim.change_consoleserverport') @@ -1194,6 +1196,7 @@ class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView): model = PowerPort form = forms.PowerPortCreateForm model_form = forms.PowerPortForm + template_name = 'dcim/device_component_add.html' @permission_required('dcim.change_powerport') @@ -1308,6 +1311,7 @@ class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView): model = PowerOutlet form = forms.PowerOutletCreateForm model_form = forms.PowerOutletForm + template_name = 'dcim/device_component_add.html' @permission_required('dcim.change_poweroutlet') @@ -1429,6 +1433,7 @@ class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): model = Interface form = forms.InterfaceCreateForm model_form = forms.InterfaceForm + template_name = 'dcim/device_component_add.html' class InterfaceEditView(PermissionRequiredMixin, ComponentEditView): @@ -1480,6 +1485,7 @@ class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView): model = DeviceBay form = forms.DeviceBayCreateForm model_form = forms.DeviceBayForm + template_name = 'dcim/device_component_add.html' class DeviceBayEditView(PermissionRequiredMixin, ComponentEditView): From a7c56eab86b912eca8854785d8b6d3827487b3b1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 18 Aug 2017 13:10:19 -0400 Subject: [PATCH 011/125] Moved DeviceComponentForm to utilities.forms --- netbox/dcim/forms.py | 43 ++++++++++++++++----------------------- netbox/utilities/forms.py | 9 ++++++++ 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 440c12623..747092b3d 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -13,9 +13,9 @@ from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, - ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ConfirmationForm, CSVChoiceField, ExpandableNameField, - FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField, - FilterTreeNodeMultipleChoiceField, + ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm, ConfirmationForm, CSVChoiceField, + ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, + SlugField, FilterTreeNodeMultipleChoiceField, ) from .formfields import MACAddressFormField from .models import ( @@ -49,15 +49,6 @@ def get_device_by_name_or_pk(name): return device -class DeviceComponentForm(BootstrapMixin, forms.Form): - """ - Allow inclusion of the parent device as context for limiting field choices. - """ - def __init__(self, device, *args, **kwargs): - self.device = device - super(DeviceComponentForm, self).__init__(*args, **kwargs) - - # # Regions # @@ -452,7 +443,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): } -class ConsolePortTemplateCreateForm(DeviceComponentForm): +class ConsolePortTemplateCreateForm(ComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -466,7 +457,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): } -class ConsoleServerPortTemplateCreateForm(DeviceComponentForm): +class ConsoleServerPortTemplateCreateForm(ComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -480,7 +471,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): } -class PowerPortTemplateCreateForm(DeviceComponentForm): +class PowerPortTemplateCreateForm(ComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -494,7 +485,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): } -class PowerOutletTemplateCreateForm(DeviceComponentForm): +class PowerOutletTemplateCreateForm(ComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -508,7 +499,7 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): } -class InterfaceTemplateCreateForm(DeviceComponentForm): +class InterfaceTemplateCreateForm(ComponentForm): name_pattern = ExpandableNameField(label='Name') form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) mgmt_only = forms.BooleanField(required=False, label='OOB Management') @@ -533,7 +524,7 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): } -class DeviceBayTemplateCreateForm(DeviceComponentForm): +class DeviceBayTemplateCreateForm(ComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -933,7 +924,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm): } -class ConsolePortCreateForm(DeviceComponentForm): +class ConsolePortCreateForm(ComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -1102,7 +1093,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): } -class ConsoleServerPortCreateForm(DeviceComponentForm): +class ConsoleServerPortCreateForm(ComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -1194,7 +1185,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm): } -class PowerPortCreateForm(DeviceComponentForm): +class PowerPortCreateForm(ComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -1363,7 +1354,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm): } -class PowerOutletCreateForm(DeviceComponentForm): +class PowerOutletCreateForm(ComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -1468,7 +1459,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): ) -class InterfaceCreateForm(DeviceComponentForm): +class InterfaceCreateForm(ComponentForm): name_pattern = ExpandableNameField(label='Name') form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) enabled = forms.BooleanField(required=False) @@ -1487,9 +1478,9 @@ class InterfaceCreateForm(DeviceComponentForm): super(InterfaceCreateForm, self).__init__(*args, **kwargs) # Limit LAG choices to interfaces belonging to this device - if self.device is not None: + if self.parent is not None: self.fields['lag'].queryset = Interface.objects.order_naturally().filter( - device=self.device, form_factor=IFACE_FF_LAG + device=self.parent, form_factor=IFACE_FF_LAG ) else: self.fields['lag'].queryset = Interface.objects.none() @@ -1715,7 +1706,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm): } -class DeviceBayCreateForm(DeviceComponentForm): +class DeviceBayCreateForm(ComponentForm): name_pattern = ExpandableNameField(label='Name') diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 7004d4260..a6fd3d87a 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -513,6 +513,15 @@ class ConfirmationForm(BootstrapMixin, ReturnURLForm): confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True) +class ComponentForm(BootstrapMixin, forms.Form): + """ + Allow inclusion of the parent Device/VirtualMachine as context for limiting field choices. + """ + def __init__(self, parent, *args, **kwargs): + self.parent = parent + super(ComponentForm, self).__init__(*args, **kwargs) + + class BulkEditForm(forms.Form): def __init__(self, model, *args, **kwargs): From e81e33af388a67a561acc9717514e5f2803cd5ab Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 18 Aug 2017 14:37:19 -0400 Subject: [PATCH 012/125] Added views for VM interfaces --- .../virtualization/inc/vminterface.html | 65 ++++++++++++++++ .../virtualization/virtualmachine.html | 63 +++++++++++++++- .../virtualmachine_component_add.html | 44 +++++++++++ netbox/virtualization/forms.py | 49 +++++++++++- netbox/virtualization/models.py | 1 + netbox/virtualization/tables.py | 11 +++ netbox/virtualization/urls.py | 8 ++ netbox/virtualization/views.py | 75 +++++++++++-------- 8 files changed, 279 insertions(+), 37 deletions(-) create mode 100644 netbox/templates/virtualization/inc/vminterface.html create mode 100644 netbox/templates/virtualization/virtualmachine_component_add.html diff --git a/netbox/templates/virtualization/inc/vminterface.html b/netbox/templates/virtualization/inc/vminterface.html new file mode 100644 index 000000000..ac6d98672 --- /dev/null +++ b/netbox/templates/virtualization/inc/vminterface.html @@ -0,0 +1,65 @@ + + {% if selectable and perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %} + + + + {% endif %} + + {{ iface.name }} + {% if iface.description %} + + {% endif %} + + {{ iface.mtu|default:"" }} + {{ iface.mac_address|default:"" }} + + {% if perms.virtualization.change_vminterface %} + + + + {% endif %} + {% if perms.virtualization.delete_vminterface %} + + + + {% endif %} + + +{% for ip in iface.ip_addresses.all %} + + {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %} + + {% endif %} + + {{ ip }} + {% if ip.description %} + + {% endif %} + {% if device.primary_ip4 == ip or device.primary_ip6 == ip %} + Primary + {% endif %} + + + {% if ip.vrf %} + {{ ip.vrf }} + {% else %} + Global + {% endif %} + + + {{ ip.get_status_display }} + + + {% if perms.ipam.change_ipaddress %} + + + + {% endif %} + {% if perms.ipam.delete_ipaddress %} + + + + {% endif %} + + +{% endfor %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 2bfa225c5..ecd857f21 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -41,7 +41,7 @@

    {% block title %}{{ vm }}{% endblock %}

    {% include 'inc/created_updated.html' with obj=vm %}
    -
    +
    Virtual Machine @@ -132,7 +132,66 @@
    -
    +
    + {% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %} + + {% csrf_token %} + + {% endif %} +
    +
    + Interfaces +
    + + {% if perms.virtualization.change_vminterface and interfaces|length > 1 %} + + {% endif %} + {% if perms.virtualization.add_vminterface and interfaces|length > 10 %} + + Add interfaces + + {% endif %} +
    +
    + + {% for iface in interfaces %} + {% include 'virtualization/inc/vminterface.html' with selectable=True %} + {% empty %} + + + + {% endfor %} +
    No interfaces defined
    + {% if perms.virtualization.add_vminterface or perms.virtualization.delete_vminterface %} + + {% endif %} +
    + {% if perms.virtualization.delete_vminterface %} + + {% endif %}
    {% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine_component_add.html b/netbox/templates/virtualization/virtualmachine_component_add.html new file mode 100644 index 000000000..7cac56705 --- /dev/null +++ b/netbox/templates/virtualization/virtualmachine_component_add.html @@ -0,0 +1,44 @@ +{% extends '_base.html' %} +{% load helpers %} +{% load form_helpers %} + +{% block title %}Create {{ component_type }} ({{ parent }}){% endblock %} + +{% block content %} +
    + {% csrf_token %} +
    +
    + {% if form.non_field_errors %} +
    +
    Errors
    +
    + {{ form.non_field_errors }} +
    +
    + {% endif %} +
    +
    + {{ component_type|bettertitle }} +
    +
    +
    + +
    +

    {{ parent }}

    +
    +
    + {% render_form form %} +
    +
    +
    +
    + + + Cancel +
    +
    +
    +
    +
    +{% endblock %} diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 7dbf7dcb3..cb2288416 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -3,11 +3,15 @@ from __future__ import unicode_literals from django import forms from django.db.models import Count +from dcim.formfields import MACAddressFormField from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm from tenancy.forms import TenancyForm from tenancy.models import Tenant -from utilities.forms import APISelect, BootstrapMixin, ChainedModelChoiceField, FilterChoiceField, SlugField -from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from utilities.forms import ( + APISelect, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ChainedModelChoiceField, ComponentForm, + ExpandableNameField, FilterChoiceField, SlugField, +) +from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface # @@ -157,3 +161,44 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm): queryset=Cluster.objects.annotate(filter_count=Count('virtual_machines')), label='Cluster' ) + + +# +# VM interfaces +# + +class VMInterfaceForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = VMInterface + fields = ['virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description'] + widgets = { + 'virtual_machine': forms.HiddenInput(), + } + + +class VMInterfaceCreateForm(ComponentForm): + name_pattern = ExpandableNameField(label='Name') + enabled = forms.BooleanField(required=False) + mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') + mac_address = MACAddressFormField(required=False, label='MAC Address') + description = forms.CharField(max_length=100, required=False) + + def __init__(self, *args, **kwargs): + + # Set interfaces enabled by default + kwargs['initial'] = kwargs.get('initial', {}).copy() + kwargs['initial'].update({'enabled': True}) + + super(VMInterfaceCreateForm, self).__init__(*args, **kwargs) + + +class VMInterfaceBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField(queryset=VMInterface.objects.all(), widget=forms.MultipleHiddenInput) + virtual_machine = forms.ModelChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.HiddenInput) + enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect) + mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') + description = forms.CharField(max_length=100, required=False) + + class Meta: + nullable_fields = ['mtu', 'description'] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 7cdd46158..ce5f266d8 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -225,6 +225,7 @@ class VMInterface(models.Model): class Meta: ordering = ['virtual_machine', 'name'] unique_together = ['virtual_machine', 'name'] + verbose_name = 'VM interface' def __str__(self): return self.name diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 3794a59b5..97261f3ae 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -83,3 +83,14 @@ class VirtualMachineTable(BaseTable): class Meta(BaseTable.Meta): model = VirtualMachine fields = ('pk', 'name', 'cluster', 'tenant', 'vcpus', 'memory', 'disk') + + +# +# VM components +# + +class VMInterfaceTable(BaseTable): + + class Meta(BaseTable.Meta): + model = VMInterface + fields = ('name', 'enabled', 'description') diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index 81038e3d4..8542919a4 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -38,4 +38,12 @@ urlpatterns = [ url(r'^virtual-machines/(?P\d+)/edit/$', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'), url(r'^virtual-machines/(?P\d+)/delete/$', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'), + # VM interfaces + # url(r'^virtual-machines/interfaces/add/$', views.VMBulkAddVMInterfaceView.as_view(), name='vm_bulk_add_vminterface'), + url(r'^virtual-machines/(?P\d+)/interfaces/add/$', views.VMInterfaceCreateView.as_view(), name='vminterface_add'), + url(r'^virtual-machines/(?P\d+)/interfaces/edit/$', views.VMInterfaceBulkEditView.as_view(), name='vminterface_bulk_edit'), + url(r'^virtual-machines/(?P\d+)/interfaces/delete/$', views.VMInterfaceBulkDeleteView.as_view(), name='vminterface_bulk_delete'), + url(r'^vm-interfaces/(?P\d+)/edit/$', views.VMInterfaceEditView.as_view(), name='vminterface_edit'), + url(r'^vm-interfaces/(?P\d+)/delete/$', views.VMInterfaceDeleteView.as_view(), name='vminterface_delete'), + ] diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 8d6388445..a46a55cae 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -155,9 +155,11 @@ class VirtualMachineView(View): def get(self, request, pk): vm = get_object_or_404(VirtualMachine.objects.select_related('tenant__group'), pk=pk) + interfaces = VMInterface.objects.filter(virtual_machine=vm) return render(request, 'virtualization/virtualmachine.html', { 'vm': vm, + 'interfaces': interfaces, }) @@ -200,36 +202,43 @@ class VirtualMachineBulkEditView(PermissionRequiredMixin, BulkEditView): # VM interfaces # -# class VMInterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): -# permission_required = 'virtualization.add_vminterface' -# parent_model = VirtualMachine -# parent_field = 'vm' -# model = VMInterface -# form = forms.VMInterfaceCreateForm -# model_form = forms.VMInterfaceForm -# -# -# class VMInterfaceEditView(PermissionRequiredMixin, ComponentEditView): -# permission_required = 'virtualization.change_vminterface' -# model = VMInterface -# form_class = forms.VMInterfaceForm -# -# -# class VMInterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView): -# permission_required = 'virtualization.delete_vminterface' -# model = VMInterface -# -# -# class VMInterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): -# permission_required = 'virtualization.change_vminterface' -# cls = VMInterface -# parent_cls = VirtualMachine -# table = tables.VMInterfaceTable -# form = forms.VMInterfaceBulkEditForm -# -# -# class VMInterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): -# permission_required = 'virtualization.delete_vminterface' -# cls = VMInterface -# parent_cls = VirtualMachine -# table = tables.VMInterfaceTable +class VMInterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): + permission_required = 'virtualization.add_vminterface' + parent_model = VirtualMachine + parent_field = 'virtual_machine' + model = VMInterface + form = forms.VMInterfaceCreateForm + model_form = forms.VMInterfaceForm + template_name = 'virtualization/virtualmachine_component_add.html' + + +class VMInterfaceEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'virtualization.change_vminterface' + model = VMInterface + form_class = forms.VMInterfaceForm + + def get_return_url(self, request, obj): + return obj.virtual_machine.get_absolute_url() + + +class VMInterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'virtualization.delete_vminterface' + model = VMInterface + + def get_return_url(self, request, obj): + return obj.virtual_machine.get_absolute_url() + + +class VMInterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'virtualization.change_vminterface' + cls = VMInterface + parent_cls = VirtualMachine + table = tables.VMInterfaceTable + form = forms.VMInterfaceBulkEditForm + + +class VMInterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'virtualization.delete_vminterface' + cls = VMInterface + parent_cls = VirtualMachine + table = tables.VMInterfaceTable From 97536c4e9bad771c8fff1593094c8843d2e07a98 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 18 Aug 2017 14:43:11 -0400 Subject: [PATCH 013/125] Genericized component edit/delete views --- netbox/dcim/views.py | 14 ++++++++++++++ netbox/utilities/views.py | 6 ++++-- netbox/virtualization/views.py | 12 ++++-------- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index b1bb8daf5..671e514dc 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1043,12 +1043,14 @@ def consoleport_disconnect(request, pk): class ConsolePortEditView(PermissionRequiredMixin, ComponentEditView): permission_required = 'dcim.change_consoleport' model = ConsolePort + parent_field = 'device' form_class = forms.ConsolePortForm class ConsolePortDeleteView(PermissionRequiredMixin, ComponentDeleteView): permission_required = 'dcim.delete_consoleport' model = ConsolePort + parent_field = 'device' class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -1161,12 +1163,14 @@ def consoleserverport_disconnect(request, pk): class ConsoleServerPortEditView(PermissionRequiredMixin, ComponentEditView): permission_required = 'dcim.change_consoleserverport' model = ConsoleServerPort + parent_field = 'device' form_class = forms.ConsoleServerPortForm class ConsoleServerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView): permission_required = 'dcim.delete_consoleserverport' model = ConsoleServerPort + parent_field = 'device' class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): @@ -1278,12 +1282,14 @@ def powerport_disconnect(request, pk): class PowerPortEditView(PermissionRequiredMixin, ComponentEditView): permission_required = 'dcim.change_powerport' model = PowerPort + parent_field = 'device' form_class = forms.PowerPortForm class PowerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView): permission_required = 'dcim.delete_powerport' model = PowerPort + parent_field = 'device' class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -1396,12 +1402,14 @@ def poweroutlet_disconnect(request, pk): class PowerOutletEditView(PermissionRequiredMixin, ComponentEditView): permission_required = 'dcim.change_poweroutlet' model = PowerOutlet + parent_field = 'device' form_class = forms.PowerOutletForm class PowerOutletDeleteView(PermissionRequiredMixin, ComponentDeleteView): permission_required = 'dcim.delete_poweroutlet' model = PowerOutlet + parent_field = 'device' class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): @@ -1439,12 +1447,14 @@ class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): class InterfaceEditView(PermissionRequiredMixin, ComponentEditView): permission_required = 'dcim.change_interface' model = Interface + parent_field = 'device' form_class = forms.InterfaceForm class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView): permission_required = 'dcim.delete_interface' model = Interface + parent_field = 'device' class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): @@ -1491,12 +1501,14 @@ class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView): class DeviceBayEditView(PermissionRequiredMixin, ComponentEditView): permission_required = 'dcim.change_devicebay' model = DeviceBay + parent_field = 'device' form_class = forms.DeviceBayForm class DeviceBayDeleteView(PermissionRequiredMixin, ComponentDeleteView): permission_required = 'dcim.delete_devicebay' model = DeviceBay + parent_field = 'device' @permission_required('dcim.change_devicebay') @@ -1803,6 +1815,7 @@ class InterfaceConnectionsListView(ObjectListView): class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView): permission_required = 'dcim.change_inventoryitem' model = InventoryItem + parent_field = 'device' form_class = forms.InventoryItemForm def alter_obj(self, obj, request, url_args, url_kwargs): @@ -1814,3 +1827,4 @@ class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView): class InventoryItemDeleteView(PermissionRequiredMixin, ComponentDeleteView): permission_required = 'dcim.delete_inventoryitem' model = InventoryItem + parent_field = 'device' diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index d52763a77..3125c374e 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -775,12 +775,14 @@ class ComponentCreateView(View): class ComponentEditView(ObjectEditView): + parent_field = None def get_return_url(self, request, obj): - return obj.device.get_absolute_url() + return getattr(obj, self.parent_field).get_absolute_url() class ComponentDeleteView(ObjectDeleteView): + parent_field = None def get_return_url(self, request, obj): - return obj.device.get_absolute_url() \ No newline at end of file + return getattr(obj, self.parent_field).get_absolute_url() diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index a46a55cae..eca176a77 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -212,21 +212,17 @@ class VMInterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'virtualization/virtualmachine_component_add.html' -class VMInterfaceEditView(PermissionRequiredMixin, ObjectEditView): +class VMInterfaceEditView(PermissionRequiredMixin, ComponentEditView): permission_required = 'virtualization.change_vminterface' model = VMInterface + parent_field = 'virtual_machine' form_class = forms.VMInterfaceForm - def get_return_url(self, request, obj): - return obj.virtual_machine.get_absolute_url() - -class VMInterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): +class VMInterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView): permission_required = 'virtualization.delete_vminterface' model = VMInterface - - def get_return_url(self, request, obj): - return obj.virtual_machine.get_absolute_url() + parent_field = 'virtual_machine' class VMInterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): From 5930a6420329f76fb8597a972fb78db0b5652bc9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 18 Aug 2017 16:57:20 -0400 Subject: [PATCH 014/125] Converted IPAddress.interface to a GenericForeignKey --- netbox/dcim/migrations/0042_device_cluster.py | 2 +- netbox/dcim/models.py | 5 +++ netbox/ipam/api/views.py | 6 ++- netbox/ipam/fixtures/ipam.json | 36 +++++++-------- .../0019_ipaddress_interface_to_gfk.py | 44 +++++++++++++++++++ netbox/ipam/models.py | 15 +++++-- netbox/ipam/views.py | 18 +++++--- .../virtualization/migrations/0001_initial.py | 7 +-- netbox/virtualization/models.py | 5 +++ 9 files changed, 106 insertions(+), 32 deletions(-) create mode 100644 netbox/ipam/migrations/0019_ipaddress_interface_to_gfk.py diff --git a/netbox/dcim/migrations/0042_device_cluster.py b/netbox/dcim/migrations/0042_device_cluster.py index a3c2b7859..f9a0a8637 100644 --- a/netbox/dcim/migrations/0042_device_cluster.py +++ b/netbox/dcim/migrations/0042_device_cluster.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-08-16 21:06 +# Generated by Django 1.11.4 on 2017-08-18 19:46 from __future__ import unicode_literals from django.db import migrations, models diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index d6e68df9b..5bb2996e0 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1175,6 +1175,11 @@ class Interface(models.Model): help_text="This interface is used only for out-of-band management" ) description = models.CharField(max_length=100, blank=True) + ip_addresses = GenericRelation( + to='ipam.IPAddress', + content_type_field='interface_type', + object_id_field='interface_id' + ) objects = InterfaceQuerySet.as_manager() diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 9cf93cb4b..50c9c8a95 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -134,7 +134,11 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet): # class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet): - queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside') + queryset = IPAddress.objects.select_related( + 'vrf__tenant', 'tenant', 'nat_inside' + ).prefetch_related( + 'interface__device' + ) serializer_class = serializers.IPAddressSerializer write_serializer_class = serializers.WritableIPAddressSerializer filter_class = filters.IPAddressFilter diff --git a/netbox/ipam/fixtures/ipam.json b/netbox/ipam/fixtures/ipam.json index 1a981a941..10e22b2d7 100644 --- a/netbox/ipam/fixtures/ipam.json +++ b/netbox/ipam/fixtures/ipam.json @@ -70,7 +70,7 @@ "family": 4, "address": "10.0.255.1/32", "vrf": null, - "interface": 3, + "interface_id": 3, "nat_inside": null, "description": "" } @@ -84,7 +84,7 @@ "family": 4, "address": "169.254.254.1/31", "vrf": null, - "interface": 4, + "interface_id": 4, "nat_inside": null, "description": "" } @@ -98,7 +98,7 @@ "family": 4, "address": "10.0.255.2/32", "vrf": null, - "interface": 185, + "interface_id": 185, "nat_inside": null, "description": "" } @@ -112,7 +112,7 @@ "family": 4, "address": "169.254.1.1/31", "vrf": null, - "interface": 213, + "interface_id": 213, "nat_inside": null, "description": "" } @@ -126,7 +126,7 @@ "family": 4, "address": "10.0.254.1/24", "vrf": null, - "interface": 12, + "interface_id": 12, "nat_inside": null, "description": "" } @@ -140,7 +140,7 @@ "family": 4, "address": "10.15.21.1/31", "vrf": null, - "interface": 218, + "interface_id": 218, "nat_inside": null, "description": "" } @@ -154,7 +154,7 @@ "family": 4, "address": "10.15.21.2/31", "vrf": null, - "interface": 9, + "interface_id": 9, "nat_inside": null, "description": "" } @@ -168,7 +168,7 @@ "family": 4, "address": "10.15.22.1/31", "vrf": null, - "interface": 8, + "interface_id": 8, "nat_inside": null, "description": "" } @@ -182,7 +182,7 @@ "family": 4, "address": "10.15.20.1/31", "vrf": null, - "interface": 7, + "interface_id": 7, "nat_inside": null, "description": "" } @@ -196,7 +196,7 @@ "family": 4, "address": "10.16.20.1/31", "vrf": null, - "interface": 216, + "interface_id": 216, "nat_inside": null, "description": "" } @@ -210,7 +210,7 @@ "family": 4, "address": "10.15.22.2/31", "vrf": null, - "interface": 206, + "interface_id": 206, "nat_inside": null, "description": "" } @@ -224,7 +224,7 @@ "family": 4, "address": "10.16.22.1/31", "vrf": null, - "interface": 217, + "interface_id": 217, "nat_inside": null, "description": "" } @@ -238,7 +238,7 @@ "family": 4, "address": "10.16.22.2/31", "vrf": null, - "interface": 205, + "interface_id": 205, "nat_inside": null, "description": "" } @@ -252,7 +252,7 @@ "family": 4, "address": "10.16.20.2/31", "vrf": null, - "interface": 211, + "interface_id": 211, "nat_inside": null, "description": "" } @@ -266,7 +266,7 @@ "family": 4, "address": "10.15.22.2/31", "vrf": null, - "interface": 212, + "interface_id": 212, "nat_inside": null, "description": "" } @@ -280,7 +280,7 @@ "family": 4, "address": "10.0.254.2/32", "vrf": null, - "interface": 188, + "interface_id": 188, "nat_inside": null, "description": "" } @@ -294,7 +294,7 @@ "family": 4, "address": "169.254.1.1/31", "vrf": null, - "interface": 200, + "interface_id": 200, "nat_inside": null, "description": "" } @@ -308,7 +308,7 @@ "family": 4, "address": "169.254.1.2/31", "vrf": null, - "interface": 194, + "interface_id": 194, "nat_inside": null, "description": "" } diff --git a/netbox/ipam/migrations/0019_ipaddress_interface_to_gfk.py b/netbox/ipam/migrations/0019_ipaddress_interface_to_gfk.py new file mode 100644 index 000000000..ab2f1b7f8 --- /dev/null +++ b/netbox/ipam/migrations/0019_ipaddress_interface_to_gfk.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-18 19:31 +from __future__ import unicode_literals + +from django.contrib.contenttypes.models import ContentType +from django.db import migrations, models +import django.db.models.deletion + + +def set_interface_type(apps, schema_editor): + """ + Set the interface_type field to 'Interface' for all IP addresses assigned to an Interface. + """ + Interface = apps.get_model('dcim', 'Interface') + interface_type = ContentType.objects.get_for_model(Interface) + IPAddress = apps.get_model('ipam', 'IPAddress') + IPAddress.objects.filter(interface__isnull=False).update(interface_type=interface_type) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('ipam', '0018_remove_service_uniqueness_constraint'), + ] + + operations = [ + migrations.AddField( + model_name='ipaddress', + name='interface_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='contenttypes.ContentType'), + ), + migrations.RunPython(set_interface_type), + migrations.AlterField( + model_name='IPAddress', + name='interface', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.RenameField( + model_name='IPAddress', + old_name='interface', + new_name='interface_id', + ) + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index bbd5e1827..f053c3892 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -2,10 +2,12 @@ from __future__ import unicode_literals import netaddr from django.conf import settings -from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from django.db.models import Q from django.db.models.expressions import RawSQL from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible @@ -407,8 +409,15 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): role = models.PositiveSmallIntegerField( 'Role', choices=IPADDRESS_ROLE_CHOICES, blank=True, null=True, help_text='The functional role of this IP' ) - interface = models.ForeignKey(Interface, related_name='ip_addresses', on_delete=models.CASCADE, blank=True, - null=True) + interface_type = models.ForeignKey( + to=ContentType, + on_delete=models.PROTECT, + limit_choices_to=Q(app_label='dcim', model='interface') | Q(app_label='virtualization', model='vminterface'), + blank=True, + null=True + ) + interface_id = models.PositiveIntegerField(blank=True, null=True) + interface = GenericForeignKey('interface_type', 'interface_id') nat_inside = models.OneToOneField('self', related_name='nat_outside', on_delete=models.SET_NULL, blank=True, null=True, verbose_name='NAT (Inside)', help_text="The IP for which this address is the \"outside\" IP") diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 05f16aa35..39834e306 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -594,7 +594,11 @@ class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class IPAddressListView(ObjectListView): - queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside') + queryset = IPAddress.objects.select_related( + 'vrf__tenant', 'tenant', 'nat_inside' + ).prefetch_related( + 'interface__device' + ) filter = filters.IPAddressFilter filter_form = forms.IPAddressFilterForm table = tables.IPAddressDetailTable @@ -605,7 +609,7 @@ class IPAddressView(View): def get(self, request, pk): - ipaddress = get_object_or_404(IPAddress.objects.select_related('interface__device'), pk=pk) + ipaddress = get_object_or_404(IPAddress.objects.select_related('vrf__tenant', 'tenant'), pk=pk) # Parent prefixes table parent_prefixes = Prefix.objects.filter( @@ -622,12 +626,14 @@ class IPAddressView(View): ).exclude( pk=ipaddress.pk ).select_related( - 'interface__device', 'nat_inside' + 'nat_inside' + ).prefetch_related( + 'interface__device' ) duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False) # Related IP table - related_ips = IPAddress.objects.select_related( + related_ips = IPAddress.objects.prefetch_related( 'interface__device' ).exclude( address=str(ipaddress.address) @@ -681,7 +687,7 @@ class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView): class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_ipaddress' cls = IPAddress - queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device') + queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant').prefetch_related('interface__device') filter = filters.IPAddressFilter table = tables.IPAddressTable form = forms.IPAddressBulkEditForm @@ -691,7 +697,7 @@ class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView): class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_ipaddress' cls = IPAddress - queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device') + queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant').prefetch_related('interface__device') filter = filters.IPAddressFilter table = tables.IPAddressTable default_return_url = 'ipam:ipaddress_list' diff --git a/netbox/virtualization/migrations/0001_initial.py b/netbox/virtualization/migrations/0001_initial.py index 57b8dc4e0..cc0135b40 100644 --- a/netbox/virtualization/migrations/0001_initial.py +++ b/netbox/virtualization/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-08-16 21:06 +# Generated by Django 1.11.4 on 2017-08-18 19:46 from __future__ import unicode_literals import dcim.fields @@ -13,9 +13,9 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('tenancy', '0003_unicode_literals'), ('dcim', '0041_napalm_integration'), - ('ipam', '0018_remove_service_uniqueness_constraint'), + ('ipam', '0019_ipaddress_interface_to_gfk'), + ('tenancy', '0003_unicode_literals'), ] operations = [ @@ -89,6 +89,7 @@ class Migration(migrations.Migration): ('virtual_machine', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='virtualization.VirtualMachine')), ], options={ + 'verbose_name': 'VM interface', 'ordering': ['virtual_machine', 'name'], }, ), diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index ce5f266d8..d23c6f2df 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -221,6 +221,11 @@ class VMInterface(models.Model): max_length=100, blank=True ) + ip_addresses = GenericRelation( + to='ipam.IPAddress', + content_type_field='interface_type', + object_id_field='interface_id' + ) class Meta: ordering = ['virtual_machine', 'name'] From 4587aba1d4af3222ccf1ed8d1dd069b6e48ac988 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 21 Aug 2017 16:53:36 -0400 Subject: [PATCH 015/125] Added views to add/remove hosts to/from clusters --- netbox/dcim/filters.py | 5 + .../templates/utilities/obj_bulk_remove.html | 38 ++++++++ netbox/templates/virtualization/cluster.html | 26 +++++- .../virtualization/cluster_add_devices.html | 44 +++++++++ netbox/utilities/forms.py | 13 +++ netbox/virtualization/forms.py | 67 +++++++++++++- netbox/virtualization/urls.py | 2 + netbox/virtualization/views.py | 92 +++++++++++++++++-- 8 files changed, 275 insertions(+), 12 deletions(-) create mode 100644 netbox/templates/utilities/obj_bulk_remove.html create mode 100644 netbox/templates/virtualization/cluster_add_devices.html diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 54a7af4e2..2ff9dfe47 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -10,6 +10,7 @@ from django.db.models import Q from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant from utilities.filters import NullableCharFieldFilter, NullableModelMultipleChoiceFilter, NumericInFilter +from virtualization.models import Cluster from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, STATUS_CHOICES, IFACE_FF_LAG, Interface, InterfaceConnection, @@ -407,6 +408,10 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): queryset=Rack.objects.all(), label='Rack (ID)', ) + cluster_id = NullableModelMultipleChoiceFilter( + queryset=Cluster.objects.all(), + label='VM cluster (ID)', + ) model = django_filters.ModelMultipleChoiceFilter( name='device_type__slug', queryset=DeviceType.objects.all(), diff --git a/netbox/templates/utilities/obj_bulk_remove.html b/netbox/templates/utilities/obj_bulk_remove.html new file mode 100644 index 000000000..a850cf914 --- /dev/null +++ b/netbox/templates/utilities/obj_bulk_remove.html @@ -0,0 +1,38 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block title %}Remove {{ table.rows|length }} {{ obj_type_plural|bettertitle }}?{% endblock %} + +{% block content %} +
    +
    +
    +
    Confirm Bulk Removal
    +
    + Warning: The following operation will remove {{ table.rows|length }} {{ obj_type_plural }} from {{ parent_obj }}. Please carefully review the {{ obj_type_plural }} to be removed and confirm below. +
    +
    +
    +
    +
    +
    +
    + {% include 'inc/table.html' %} +
    +
    +
    +
    +
    +
    + {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} +
    + + Cancel +
    +
    +
    +
    +{% endblock %} diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index 23834e0c4..6b4a46ea7 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -2,7 +2,7 @@ {% load helpers %} {% block content %} -
    +
    diff --git a/netbox/templates/virtualization/cluster_add_devices.html b/netbox/templates/virtualization/cluster_add_devices.html new file mode 100644 index 000000000..6c874d38f --- /dev/null +++ b/netbox/templates/virtualization/cluster_add_devices.html @@ -0,0 +1,44 @@ +{% extends '_base.html' %} +{% load static from staticfiles %} +{% load form_helpers %} + +{% block content %} +
    + {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} +
    +
    +

    {% block title %}Add Devices to Cluster {{ cluster }}{% endblock %}

    + {% if form.non_field_errors %} +
    +
    Errors
    +
    + {{ form.non_field_errors }} +
    +
    + {% endif %} +
    +
    Devices
    +
    + {% render_field form.region %} + {% render_field form.site %} + {% render_field form.rack %} + {% render_field form.devices %} +
    +
    +
    +
    +
    +
    + + Cancel +
    +
    +
    +{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index a6fd3d87a..93dbf5234 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -187,6 +187,10 @@ class APISelect(SelectWithDisabled): self.attrs['disabled-indicator'] = disabled_indicator +class APISelectMultiple(APISelect): + allow_multiple_selected = True + + class Livesearch(forms.TextInput): """ A text widget that carries a few extra bits of data for use in AJAX-powered autocomplete search @@ -385,6 +389,15 @@ class ChainedModelChoiceField(forms.ModelChoiceField): super(ChainedModelChoiceField, self).__init__(*args, **kwargs) +class ChainedModelMultipleChoiceField(forms.ModelMultipleChoiceField): + """ + See ChainedModelChoiceField + """ + def __init__(self, chains=None, *args, **kwargs): + self.chains = chains + super(ChainedModelMultipleChoiceField, self).__init__(*args, **kwargs) + + class SlugField(forms.SlugField): def __init__(self, slug_source='name', *args, **kwargs): diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index cb2288416..f0933e366 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -1,15 +1,19 @@ from __future__ import unicode_literals +from mptt.forms import TreeNodeChoiceField + from django import forms from django.db.models import Count from dcim.formfields import MACAddressFormField +from dcim.models import Device, Rack, Region, Site from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( - APISelect, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ChainedModelChoiceField, ComponentForm, - ExpandableNameField, FilterChoiceField, SlugField, + APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ChainedFieldsMixin, + ChainedModelChoiceField, ChainedModelMultipleChoiceField, ComponentForm, ConfirmationForm, ExpandableNameField, + FilterChoiceField, SlugField, ) from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -88,6 +92,65 @@ class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm): ) +class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): + region = TreeNodeChoiceField( + queryset=Region.objects.all(), + required=False, + widget=forms.Select( + attrs={'filter-for': 'site'} + ) + ) + site = ChainedModelChoiceField( + queryset=Site.objects.all(), + chains=( + ('region', 'region'), + ), + required=False, + widget=APISelect( + api_url='/api/dcim/sites/?region_id={{region}}', + attrs={'filter-for': 'rack'} + ) + ) + rack = ChainedModelChoiceField( + queryset=Rack.objects.all(), + chains=( + ('site', 'site'), + ), + required=False, + widget=APISelect( + api_url='/api/dcim/racks/?site_id={{site}}', + attrs={'filter-for': 'devices', 'nullable': 'true'} + ) + ) + devices = ChainedModelMultipleChoiceField( + queryset=Device.objects.filter(cluster__isnull=True), + chains=( + ('site', 'site'), + ('rack', 'rack'), + ), + label='Device', + required=False, + widget=APISelectMultiple( + api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}', + display_field='display_name', + disabled_indicator='cluster' + ) + ) + + class Meta: + fields = ['region', 'site', 'rack', 'devices'] + + def __init__(self, *args, **kwargs): + + super(ClusterAddDevicesForm, self).__init__(*args, **kwargs) + + self.fields['devices'].choices = [] + + +class ClusterRemoveDevicesForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) + + # # Virtual Machines # diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index 8542919a4..3e6cf265b 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -28,6 +28,8 @@ urlpatterns = [ url(r'^clusters/(?P\d+)/$', views.ClusterView.as_view(), name='cluster'), url(r'^clusters/(?P\d+)/edit/$', views.ClusterEditView.as_view(), name='cluster_edit'), url(r'^clusters/(?P\d+)/delete/$', views.ClusterDeleteView.as_view(), name='cluster_delete'), + url(r'^clusters/(?P\d+)/devices/add/$', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'), + url(r'^clusters/(?P\d+)/devices/remove/$', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_devices'), # Virtual machines url(r'^virtual-machines/$', views.VirtualMachineListView.as_view(), name='virtualmachine_list'), diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index eca176a77..ff06280b3 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -1,12 +1,14 @@ from __future__ import unicode_literals +from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin from django.db.models import Count -from django.shortcuts import get_object_or_404, render +from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.views.generic import View from dcim.models import Device +from dcim.tables import DeviceTable from utilities.views import ( BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView, @@ -96,11 +98,16 @@ class ClusterView(View): def get(self, request, pk): cluster = get_object_or_404(Cluster, pk=pk) - devices = Device.objects.filter(cluster=cluster) + devices = Device.objects.filter(cluster=cluster).select_related( + 'site', 'rack', 'tenant', 'device_type__manufacturer' + ) + device_table = DeviceTable(list(devices), orderable=False) + if request.user.has_perm('virtualization:change_cluster'): + device_table.columns.show('pk') return render(request, 'virtualization/cluster.html', { 'cluster': cluster, - 'devices': devices, + 'device_table': device_table, }) @@ -109,9 +116,6 @@ class ClusterCreateView(PermissionRequiredMixin, ObjectEditView): model = Cluster form_class = forms.ClusterForm - def get_return_url(self, request, obj): - return reverse('virtualization:cluster_list') - class ClusterEditView(ClusterCreateView): permission_required = 'virtualization.change_cluster' @@ -138,6 +142,82 @@ class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): default_return_url = 'virtualization:cluster_list' +class ClusterAddDevicesView(PermissionRequiredMixin, View): + permission_required = 'virtualization.change_cluster' + form = forms.ClusterAddDevicesForm + template_name = 'virtualization/cluster_add_devices.html' + + def get(self, request, pk): + + cluster = get_object_or_404(Cluster, pk=pk) + form = self.form() + + return render(request, self.template_name, { + 'cluster': cluster, + 'form': form, + 'return_url': reverse('virtualization:cluster', kwargs={'pk': pk}), + }) + + def post(self, request, pk): + + cluster = get_object_or_404(Cluster, pk=pk) + form = self.form(request.POST) + + if form.is_valid(): + + # Assign the selected Devices to the Cluster + devices = form.cleaned_data['devices'] + Device.objects.filter(pk__in=devices).update(cluster=cluster) + + messages.success(request, "Added {} devices to cluster {}".format( + len(devices), cluster + )) + return redirect(cluster.get_absolute_url()) + + return render(request, self.template_name, { + 'cluser': cluster, + 'form': form, + 'return_url': cluster.get_absolute_url(), + }) + + +class ClusterRemoveDevicesView(PermissionRequiredMixin, View): + permission_required = 'virtualization.change_cluster' + form = forms.ClusterRemoveDevicesForm + template_name = 'utilities/obj_bulk_remove.html' + + def post(self, request, pk): + + cluster = get_object_or_404(Cluster, pk=pk) + + if '_confirm' in request.POST: + form = self.form(request.POST) + if form.is_valid(): + + # Remove the selected Devices from the Cluster + devices = form.cleaned_data['pk'] + Device.objects.filter(pk__in=devices).update(cluster=None) + + messages.success(request, "Removed {} devices from cluster {}".format( + len(devices), cluster + )) + return redirect(cluster.get_absolute_url()) + + else: + form = self.form(initial={'pk': request.POST.getlist('pk')}) + + selected_objects = Device.objects.filter(pk__in=form.initial['pk']) + device_table = DeviceTable(list(selected_objects), orderable=False) + + return render(request, self.template_name, { + 'form': form, + 'parent_obj': cluster, + 'table': device_table, + 'obj_type_plural': 'devices', + 'return_url': cluster.get_absolute_url(), + }) + + # # Virtual machines # From b117130aaca6fe9935058cbbad897f61cdf6c79b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 21 Aug 2017 17:02:35 -0400 Subject: [PATCH 016/125] Added icons --- netbox/templates/virtualization/virtualmachine.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index ecd857f21..40f4445ea 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -87,7 +87,7 @@
    - + - + - + - {% if selectable and perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %} + {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %} @@ -13,13 +13,13 @@ {% endif %} - + + + + + + + +
    Virtual CPUs Virtual CPUs {% if vm.vcpus %} {{ vm.vcpus }} @@ -97,7 +97,7 @@
    Memory Memory {% if vm.memory %} {{ vm.memory }} MB @@ -107,7 +107,7 @@
    Disk Space Disk Space {% if vm.disk %} {{ vm.disk }} GB From daa947e7ebab7ca062c59bcaab3ecef4ec43bbf9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 21 Aug 2017 17:17:01 -0400 Subject: [PATCH 017/125] Added virtualization models to home/search --- netbox/netbox/views.py | 22 ++++++++++++++++++++- netbox/templates/home.html | 39 +++++++++++++++++++++++++++----------- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index d5224b462..3733a5715 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -25,6 +25,9 @@ from secrets.tables import SecretTable from tenancy.filters import TenantFilter from tenancy.models import Tenant from tenancy.tables import TenantTable +from virtualization.filters import ClusterFilter, VirtualMachineFilter +from virtualization.models import Cluster, VirtualMachine +from virtualization.tables import ClusterTable, VirtualMachineTable from .forms import SearchForm @@ -90,7 +93,7 @@ SEARCH_TYPES = OrderedDict(( 'url': 'ipam:prefix_list', }), ('ipaddress', { - 'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device'), + 'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant'), 'filter': IPAddressFilter, 'table': IPAddressTable, 'url': 'ipam:ipaddress_list', @@ -115,6 +118,19 @@ SEARCH_TYPES = OrderedDict(( 'table': TenantTable, 'url': 'tenancy:tenant_list', }), + # Virtualization + ('cluster', { + 'queryset': Cluster.objects.all(), + 'filter': ClusterFilter, + 'table': ClusterTable, + 'url': 'virtualization:cluster_list', + }), + ('virtualmachine', { + 'queryset': VirtualMachine.objects.select_related('cluster', 'tenant', 'platform'), + 'filter': VirtualMachineFilter, + 'table': VirtualMachineTable, + 'url': 'virtualization:virtualmachine_list', + }), )) @@ -150,6 +166,10 @@ class HomeView(View): # Secrets 'secret_count': Secret.objects.count(), + # Virtualization + 'cluster_count': Cluster.objects.count(), + 'virtualmachine_count': VirtualMachine.objects.count(), + } return render(request, self.template_name, { diff --git a/netbox/templates/home.html b/netbox/templates/home.html index 0c3c7d13b..c73de0000 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -55,20 +55,23 @@ - {% if perms.secrets %} -
    -
    - Secrets +
    +
    + Virtualization +
    +
    +
    + {{ stats.cluster_count }} +

    Clusters

    +

    Clusters of physical hosts in which VMs reside

    -
    -
    - {{ stats.secret_count }} -

    Secrets

    -

    Sensitive data (such as passwords) which has been stored securely

    -
    +
    + {{ stats.virtualmachine_count }} +

    Virtual Machines

    +

    Virtual compute instances running inside clusters

    - {% endif %} +
    @@ -120,6 +123,20 @@
    + {% if perms.secrets %} +
    +
    + Secrets +
    +
    +
    + {{ stats.secret_count }} +

    Secrets

    +

    Sensitive data (such as passwords) which has been stored securely

    +
    +
    +
    + {% endif %}
    From 6ebd8e78c8a72c1fbfba056a410bab976c21fd26 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 21 Aug 2017 17:21:02 -0400 Subject: [PATCH 018/125] Added VMs to tenant view --- netbox/templates/tenancy/tenant.html | 4 ++++ netbox/tenancy/views.py | 2 ++ 2 files changed, 6 insertions(+) diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index 2c893adae..c19195246 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -124,6 +124,10 @@

    {{ stats.circuit_count }}

    Circuits

    +
    +

    {{ stats.virtualmachine_count }}

    +

    Virtual machines

    +
    diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index e176075cb..86e419e54 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -12,6 +12,7 @@ from ipam.models import IPAddress, Prefix, VLAN, VRF from utilities.views import ( BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, ) +from virtualization.models import VirtualMachine from .models import Tenant, TenantGroup from . import filters, forms, tables @@ -79,6 +80,7 @@ class TenantView(View): ).count(), 'vlan_count': VLAN.objects.filter(tenant=tenant).count(), 'circuit_count': Circuit.objects.filter(tenant=tenant).count(), + 'virtualmachine_count': VirtualMachine.objects.filter(tenant=tenant).count(), } return render(request, 'tenancy/tenant.html', { From e9f75adddb17662b28e0ce97d96a936171c19e56 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 29 Aug 2017 14:24:58 -0400 Subject: [PATCH 019/125] Ditched VMInterface in favor of reusing dcim.Interface --- netbox/dcim/constants.py | 10 ++-- netbox/dcim/migrations/0042_device_cluster.py | 22 --------- netbox/dcim/migrations/0042_virtualization.py | 32 +++++++++++++ netbox/dcim/models.py | 40 ++++++++++++---- .../0019_ipaddress_interface_to_gfk.py | 44 ----------------- netbox/ipam/models.py | 11 +---- .../inc/{vminterface.html => interface.html} | 10 ++-- .../virtualization/virtualmachine.html | 26 +++++----- netbox/virtualization/api/serializers.py | 27 ++++++----- netbox/virtualization/api/urls.py | 2 +- netbox/virtualization/api/views.py | 11 +++-- netbox/virtualization/forms.py | 20 ++++---- ...0001_initial.py => 0001_virtualization.py} | 27 ++--------- netbox/virtualization/models.py | 46 ------------------ netbox/virtualization/tables.py | 7 +-- netbox/virtualization/urls.py | 12 ++--- netbox/virtualization/views.py | 48 +++++++++---------- 17 files changed, 158 insertions(+), 237 deletions(-) delete mode 100644 netbox/dcim/migrations/0042_device_cluster.py create mode 100644 netbox/dcim/migrations/0042_virtualization.py delete mode 100644 netbox/ipam/migrations/0019_ipaddress_interface_to_gfk.py rename netbox/templates/virtualization/inc/{vminterface.html => interface.html} (83%) rename netbox/virtualization/migrations/{0001_initial.py => 0001_virtualization.py} (77%) diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index f2c047910..8758bb55e 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -92,13 +92,15 @@ IFACE_FF_JUNIPER_VCP = 5200 # Other IFACE_FF_OTHER = 32767 +VIFACE_FF_CHOICES = [ + [IFACE_FF_VIRTUAL, 'Virtual'], + [IFACE_FF_LAG, 'Link Aggregation Group (LAG)'], +] + IFACE_FF_CHOICES = [ [ 'Virtual interfaces', - [ - [IFACE_FF_VIRTUAL, 'Virtual'], - [IFACE_FF_LAG, 'Link Aggregation Group (LAG)'], - ] + VIFACE_FF_CHOICES, ], [ 'Ethernet (fixed)', diff --git a/netbox/dcim/migrations/0042_device_cluster.py b/netbox/dcim/migrations/0042_device_cluster.py deleted file mode 100644 index f9a0a8637..000000000 --- a/netbox/dcim/migrations/0042_device_cluster.py +++ /dev/null @@ -1,22 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-08-18 19:46 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('virtualization', '0001_initial'), - ('dcim', '0041_napalm_integration'), - ] - - operations = [ - migrations.AddField( - model_name='device', - name='cluster', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='devices', to='virtualization.Cluster'), - ), - ] diff --git a/netbox/dcim/migrations/0042_virtualization.py b/netbox/dcim/migrations/0042_virtualization.py new file mode 100644 index 000000000..70c299ccb --- /dev/null +++ b/netbox/dcim/migrations/0042_virtualization.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-29 17:49 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0001_virtualization'), + ('dcim', '0041_napalm_integration'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='cluster', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='devices', to='virtualization.Cluster'), + ), + migrations.AddField( + model_name='interface', + name='virtual_machine', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='virtualization.VirtualMachine'), + ), + migrations.AlterField( + model_name='interface', + name='device', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='dcim.Device'), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 5bb2996e0..adf0d80a3 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1152,13 +1152,26 @@ class PowerOutlet(models.Model): @python_2_unicode_compatible class Interface(models.Model): """ - A physical data interface within a Device. An Interface can connect to exactly one other Interface via the creation - of an InterfaceConnection. + A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other + Interface via the creation of an InterfaceConnection. """ - device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE) + device = models.ForeignKey( + to='Device', + on_delete=models.CASCADE, + related_name='interfaces', + null=True, + blank=True + ) + virtual_machine = models.ForeignKey( + to='virtualization.VirtualMachine', + on_delete=models.CASCADE, + related_name='interfaces', + null=True, + blank=True + ) lag = models.ForeignKey( - 'self', - models.SET_NULL, + to='self', + on_delete=models.SET_NULL, related_name='member_interfaces', null=True, blank=True, @@ -1175,11 +1188,6 @@ class Interface(models.Model): help_text="This interface is used only for out-of-band management" ) description = models.CharField(max_length=100, blank=True) - ip_addresses = GenericRelation( - to='ipam.IPAddress', - content_type_field='interface_type', - object_id_field='interface_id' - ) objects = InterfaceQuerySet.as_manager() @@ -1192,6 +1200,18 @@ class Interface(models.Model): def clean(self): + # An Interface must belong to a Device *or* to a VirtualMachine + if self.device and self.virtual_machine: + raise ValidationError("An interface cannot belong to both a device and a virtual machine.") + if not self.device and not self.virtual_machine: + raise ValidationError("An interface must belong to either a device or a virtual machine.") + + # VM interfaces must be virtual + if self.virtual_machine and self.form_factor not in VIRTUAL_IFACE_TYPES: + raise ValidationError({ + 'form_factor': "Virtual machines cannot have physical interfaces." + }) + # Virtual interfaces cannot be connected if self.form_factor in NONCONNECTABLE_IFACE_TYPES and self.is_connected: raise ValidationError({ diff --git a/netbox/ipam/migrations/0019_ipaddress_interface_to_gfk.py b/netbox/ipam/migrations/0019_ipaddress_interface_to_gfk.py deleted file mode 100644 index ab2f1b7f8..000000000 --- a/netbox/ipam/migrations/0019_ipaddress_interface_to_gfk.py +++ /dev/null @@ -1,44 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-08-18 19:31 -from __future__ import unicode_literals - -from django.contrib.contenttypes.models import ContentType -from django.db import migrations, models -import django.db.models.deletion - - -def set_interface_type(apps, schema_editor): - """ - Set the interface_type field to 'Interface' for all IP addresses assigned to an Interface. - """ - Interface = apps.get_model('dcim', 'Interface') - interface_type = ContentType.objects.get_for_model(Interface) - IPAddress = apps.get_model('ipam', 'IPAddress') - IPAddress.objects.filter(interface__isnull=False).update(interface_type=interface_type) - - -class Migration(migrations.Migration): - - dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), - ('ipam', '0018_remove_service_uniqueness_constraint'), - ] - - operations = [ - migrations.AddField( - model_name='ipaddress', - name='interface_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='contenttypes.ContentType'), - ), - migrations.RunPython(set_interface_type), - migrations.AlterField( - model_name='IPAddress', - name='interface', - field=models.PositiveIntegerField(blank=True, null=True), - ), - migrations.RenameField( - model_name='IPAddress', - old_name='interface', - new_name='interface_id', - ) - ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index f053c3892..ddc1e9e48 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -409,15 +409,8 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): role = models.PositiveSmallIntegerField( 'Role', choices=IPADDRESS_ROLE_CHOICES, blank=True, null=True, help_text='The functional role of this IP' ) - interface_type = models.ForeignKey( - to=ContentType, - on_delete=models.PROTECT, - limit_choices_to=Q(app_label='dcim', model='interface') | Q(app_label='virtualization', model='vminterface'), - blank=True, - null=True - ) - interface_id = models.PositiveIntegerField(blank=True, null=True) - interface = GenericForeignKey('interface_type', 'interface_id') + interface = models.ForeignKey(Interface, related_name='ip_addresses', on_delete=models.CASCADE, blank=True, + null=True) nat_inside = models.OneToOneField('self', related_name='nat_outside', on_delete=models.SET_NULL, blank=True, null=True, verbose_name='NAT (Inside)', help_text="The IP for which this address is the \"outside\" IP") diff --git a/netbox/templates/virtualization/inc/vminterface.html b/netbox/templates/virtualization/inc/interface.html similarity index 83% rename from netbox/templates/virtualization/inc/vminterface.html rename to netbox/templates/virtualization/inc/interface.html index ac6d98672..5a08848da 100644 --- a/netbox/templates/virtualization/inc/vminterface.html +++ b/netbox/templates/virtualization/inc/interface.html @@ -1,5 +1,5 @@
    {{ iface.mtu|default:"" }} {{ iface.mac_address|default:"" }} - {% if perms.virtualization.change_vminterface %} - + {% if perms.dcim.change_interface %} + {% endif %} - {% if perms.virtualization.delete_vminterface %} - + {% if perms.dcim.delete_interface %} + {% endif %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 40f4445ea..cea8a0a83 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -133,7 +133,7 @@
    - {% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %} + {% if perms.dcim.change_interface or perms.dcim.delete_interface %}
    {% csrf_token %} @@ -145,13 +145,13 @@ - {% if perms.virtualization.change_vminterface and interfaces|length > 1 %} + {% if perms.dcim.change_interface and interfaces|length > 1 %} {% endif %} - {% if perms.virtualization.add_vminterface and interfaces|length > 10 %} - + {% if perms.dcim.add_interface and interfaces|length > 10 %} + Add interfaces {% endif %} @@ -159,28 +159,28 @@
    {% for iface in interfaces %} - {% include 'virtualization/inc/vminterface.html' with selectable=True %} + {% include 'virtualization/inc/interface.html' with selectable=True %} {% empty %} {% endfor %}
    No interfaces defined
    - {% if perms.virtualization.add_vminterface or perms.virtualization.delete_vminterface %} + {% if perms.dcim.add_interface or perms.dcim.delete_interface %} {% endif %} - {% if perms.virtualization.delete_vminterface %} + {% if perms.dcim.delete_interface %} {% endif %} diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index d5062100d..42d3f237e 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -3,10 +3,12 @@ from __future__ import unicode_literals from rest_framework import serializers from dcim.api.serializers import NestedPlatformSerializer +from dcim.constants import VIFACE_FF_CHOICES +from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer from tenancy.api.serializers import NestedTenantSerializer -from utilities.api import ValidatedModelSerializer -from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface +from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer +from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine # @@ -102,7 +104,7 @@ class NestedVirtualMachineSerializer(serializers.ModelSerializer): class WritableVirtualMachineSerializer(CustomFieldModelSerializer): class Meta: - model = Cluster + model = VirtualMachine fields = [ 'id', 'name', 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'comments', 'custom_fields', ] @@ -112,28 +114,29 @@ class WritableVirtualMachineSerializer(CustomFieldModelSerializer): # VM interfaces # -class VMInterfaceSerializer(serializers.ModelSerializer): +class InterfaceSerializer(serializers.ModelSerializer): virtual_machine = NestedVirtualMachineSerializer() + form_factor = ChoiceFieldSerializer(choices=VIFACE_FF_CHOICES) class Meta: - model = VMInterface + model = Interface fields = [ - 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'description', + 'id', 'name', 'virtual_machine', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description', ] -class NestedVMInterfaceSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:vminterface-detail') +class NestedInterfaceSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:interface-detail') class Meta: - model = VMInterface + model = Interface fields = ['id', 'url', 'name'] -class WritableVMInterfaceSerializer(ValidatedModelSerializer): +class WritableInterfaceSerializer(ValidatedModelSerializer): class Meta: - model = VMInterface + model = Interface fields = [ - 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'description', + 'id', 'name', 'virtual_machine', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description', ] diff --git a/netbox/virtualization/api/urls.py b/netbox/virtualization/api/urls.py index a53fdbde2..34ecf4431 100644 --- a/netbox/virtualization/api/urls.py +++ b/netbox/virtualization/api/urls.py @@ -23,7 +23,7 @@ router.register(r'clusters', views.ClusterViewSet) # VirtualMachines router.register(r'virtual-machines', views.VirtualMachineViewSet) -router.register(r'vm-interfaces', views.VMInterfaceViewSet) +router.register(r'interfaces', views.InterfaceViewSet) app_name = 'virtualization-api' urlpatterns = router.urls diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 4c1d065d6..315e1404e 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -2,10 +2,11 @@ from __future__ import unicode_literals from rest_framework.viewsets import ModelViewSet +from dcim.models import Interface from extras.api.views import CustomFieldModelViewSet from utilities.api import WritableSerializerMixin from virtualization import filters -from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface +from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine from . import serializers @@ -41,7 +42,7 @@ class VirtualMachineViewSet(WritableSerializerMixin, CustomFieldModelViewSet): filter_class = filters.VirtualMachineFilter -class VMInterfaceViewSet(WritableSerializerMixin, ModelViewSet): - queryset = VMInterface.objects.select_related('virtual_machine') - serializer_class = serializers.VMInterfaceSerializer - write_serializer_class = serializers.WritableVMInterfaceSerializer +class InterfaceViewSet(WritableSerializerMixin, ModelViewSet): + queryset = Interface.objects.filter(virtual_machine__isnull=False).select_related('virtual_machine') + serializer_class = serializers.InterfaceSerializer + write_serializer_class = serializers.WritableInterfaceSerializer diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index f0933e366..2dec7b912 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -5,8 +5,9 @@ from mptt.forms import TreeNodeChoiceField from django import forms from django.db.models import Count +from dcim.constants import VIFACE_FF_CHOICES from dcim.formfields import MACAddressFormField -from dcim.models import Device, Rack, Region, Site +from dcim.models import Device, Interface, Rack, Region, Site from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm from tenancy.forms import TenancyForm from tenancy.models import Tenant @@ -15,7 +16,7 @@ from utilities.forms import ( ChainedModelChoiceField, ChainedModelMultipleChoiceField, ComponentForm, ConfirmationForm, ExpandableNameField, FilterChoiceField, SlugField, ) -from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface +from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine # @@ -230,18 +231,19 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm): # VM interfaces # -class VMInterfaceForm(BootstrapMixin, forms.ModelForm): +class InterfaceForm(BootstrapMixin, forms.ModelForm): class Meta: - model = VMInterface - fields = ['virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description'] + model = Interface + fields = ['virtual_machine', 'name', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description'] widgets = { 'virtual_machine': forms.HiddenInput(), } -class VMInterfaceCreateForm(ComponentForm): +class InterfaceCreateForm(ComponentForm): name_pattern = ExpandableNameField(label='Name') + form_factor = forms.ChoiceField(choices=VIFACE_FF_CHOICES) enabled = forms.BooleanField(required=False) mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') mac_address = MACAddressFormField(required=False, label='MAC Address') @@ -253,11 +255,11 @@ class VMInterfaceCreateForm(ComponentForm): kwargs['initial'] = kwargs.get('initial', {}).copy() kwargs['initial'].update({'enabled': True}) - super(VMInterfaceCreateForm, self).__init__(*args, **kwargs) + super(InterfaceCreateForm, self).__init__(*args, **kwargs) -class VMInterfaceBulkEditForm(BootstrapMixin, BulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=VMInterface.objects.all(), widget=forms.MultipleHiddenInput) +class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) virtual_machine = forms.ModelChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.HiddenInput) enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect) mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') diff --git a/netbox/virtualization/migrations/0001_initial.py b/netbox/virtualization/migrations/0001_virtualization.py similarity index 77% rename from netbox/virtualization/migrations/0001_initial.py rename to netbox/virtualization/migrations/0001_virtualization.py index cc0135b40..3b8441d21 100644 --- a/netbox/virtualization/migrations/0001_initial.py +++ b/netbox/virtualization/migrations/0001_virtualization.py @@ -1,8 +1,7 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-08-18 19:46 +# Generated by Django 1.11.4 on 2017-08-29 17:49 from __future__ import unicode_literals -import dcim.fields from django.db import migrations, models import django.db.models.deletion import extras.models @@ -13,9 +12,9 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('dcim', '0041_napalm_integration'), - ('ipam', '0019_ipaddress_interface_to_gfk'), ('tenancy', '0003_unicode_literals'), + ('ipam', '0018_remove_service_uniqueness_constraint'), + ('dcim', '0041_napalm_integration'), ] operations = [ @@ -77,22 +76,6 @@ class Migration(migrations.Migration): }, bases=(models.Model, extras.models.CustomFieldModel), ), - migrations.CreateModel( - name='VMInterface', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=30)), - ('enabled', models.BooleanField(default=True)), - ('mac_address', dcim.fields.MACAddressField(blank=True, null=True, verbose_name='MAC Address')), - ('mtu', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='MTU')), - ('description', models.CharField(blank=True, max_length=100)), - ('virtual_machine', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='virtualization.VirtualMachine')), - ], - options={ - 'verbose_name': 'VM interface', - 'ordering': ['virtual_machine', 'name'], - }, - ), migrations.AddField( model_name='cluster', name='group', @@ -103,8 +86,4 @@ class Migration(migrations.Migration): name='type', field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='clusters', to='virtualization.ClusterType'), ), - migrations.AlterUniqueTogether( - name='vminterface', - unique_together=set([('virtual_machine', 'name')]), - ), ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index d23c6f2df..8b644af2c 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -188,49 +188,3 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): def get_absolute_url(self): return reverse('virtualization:virtualmachine', args=[self.pk]) - - -@python_2_unicode_compatible -class VMInterface(models.Model): - """ - A virtual interface which belongs to a VirtualMachine. Like the dcim.Interface model, IPAddresses can be assigned to - VMInterfaces. - """ - virtual_machine = models.ForeignKey( - to=VirtualMachine, - on_delete=models.CASCADE, - related_name='interfaces' - ) - name = models.CharField( - max_length=30 - ) - enabled = models.BooleanField( - default=True - ) - mac_address = MACAddressField( - blank=True, - null=True, - verbose_name='MAC Address' - ) - mtu = models.PositiveSmallIntegerField( - blank=True, - null=True, - verbose_name='MTU' - ) - description = models.CharField( - max_length=100, - blank=True - ) - ip_addresses = GenericRelation( - to='ipam.IPAddress', - content_type_field='interface_type', - object_id_field='interface_id' - ) - - class Meta: - ordering = ['virtual_machine', 'name'] - unique_together = ['virtual_machine', 'name'] - verbose_name = 'VM interface' - - def __str__(self): - return self.name diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 97261f3ae..04db8c04c 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -3,8 +3,9 @@ from __future__ import unicode_literals import django_tables2 as tables from django_tables2.utils import Accessor +from dcim.models import Interface from utilities.tables import BaseTable, ToggleColumn -from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface +from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine CLUSTERTYPE_ACTIONS = """ @@ -89,8 +90,8 @@ class VirtualMachineTable(BaseTable): # VM components # -class VMInterfaceTable(BaseTable): +class InterfaceTable(BaseTable): class Meta(BaseTable.Meta): - model = VMInterface + model = Interface fields = ('name', 'enabled', 'description') diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index 3e6cf265b..222754dcb 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -41,11 +41,11 @@ urlpatterns = [ url(r'^virtual-machines/(?P\d+)/delete/$', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'), # VM interfaces - # url(r'^virtual-machines/interfaces/add/$', views.VMBulkAddVMInterfaceView.as_view(), name='vm_bulk_add_vminterface'), - url(r'^virtual-machines/(?P\d+)/interfaces/add/$', views.VMInterfaceCreateView.as_view(), name='vminterface_add'), - url(r'^virtual-machines/(?P\d+)/interfaces/edit/$', views.VMInterfaceBulkEditView.as_view(), name='vminterface_bulk_edit'), - url(r'^virtual-machines/(?P\d+)/interfaces/delete/$', views.VMInterfaceBulkDeleteView.as_view(), name='vminterface_bulk_delete'), - url(r'^vm-interfaces/(?P\d+)/edit/$', views.VMInterfaceEditView.as_view(), name='vminterface_edit'), - url(r'^vm-interfaces/(?P\d+)/delete/$', views.VMInterfaceDeleteView.as_view(), name='vminterface_delete'), + # url(r'^virtual-machines/interfaces/add/$', views.VMBulkAddInterfaceView.as_view(), name='vm_bulk_add_interface'), + url(r'^virtual-machines/(?P\d+)/interfaces/add/$', views.InterfaceCreateView.as_view(), name='interface_add'), + url(r'^virtual-machines/(?P\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), + url(r'^virtual-machines/(?P\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), + url(r'^vm-interfaces/(?P\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'), + url(r'^vm-interfaces/(?P\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'), ] diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index ff06280b3..599956e66 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -7,13 +7,13 @@ from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.views.generic import View -from dcim.models import Device +from dcim.models import Device, Interface from dcim.tables import DeviceTable from utilities.views import ( BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView, ) -from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface +from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine from . import filters from . import forms from . import tables @@ -235,7 +235,7 @@ class VirtualMachineView(View): def get(self, request, pk): vm = get_object_or_404(VirtualMachine.objects.select_related('tenant__group'), pk=pk) - interfaces = VMInterface.objects.filter(virtual_machine=vm) + interfaces = Interface.objects.filter(virtual_machine=vm) return render(request, 'virtualization/virtualmachine.html', { 'vm': vm, @@ -282,39 +282,39 @@ class VirtualMachineBulkEditView(PermissionRequiredMixin, BulkEditView): # VM interfaces # -class VMInterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'virtualization.add_vminterface' +class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): + permission_required = 'dcim.add_interface' parent_model = VirtualMachine parent_field = 'virtual_machine' - model = VMInterface - form = forms.VMInterfaceCreateForm - model_form = forms.VMInterfaceForm + model = Interface + form = forms.InterfaceCreateForm + model_form = forms.InterfaceForm template_name = 'virtualization/virtualmachine_component_add.html' -class VMInterfaceEditView(PermissionRequiredMixin, ComponentEditView): - permission_required = 'virtualization.change_vminterface' - model = VMInterface +class InterfaceEditView(PermissionRequiredMixin, ComponentEditView): + permission_required = 'dcim.change_interface' + model = Interface parent_field = 'virtual_machine' - form_class = forms.VMInterfaceForm + form_class = forms.InterfaceForm -class VMInterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView): - permission_required = 'virtualization.delete_vminterface' - model = VMInterface +class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView): + permission_required = 'dcim.delete_interface' + model = Interface parent_field = 'virtual_machine' -class VMInterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'virtualization.change_vminterface' - cls = VMInterface +class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_interface' + cls = Interface parent_cls = VirtualMachine - table = tables.VMInterfaceTable - form = forms.VMInterfaceBulkEditForm + table = tables.InterfaceTable + form = forms.InterfaceBulkEditForm -class VMInterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'virtualization.delete_vminterface' - cls = VMInterface +class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_interface' + cls = Interface parent_cls = VirtualMachine - table = tables.VMInterfaceTable + table = tables.InterfaceTable From e945aafd7bd1da852a97498e23fd8ba5fbc59372 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 29 Aug 2017 15:10:52 -0400 Subject: [PATCH 020/125] Added fitlers to return the verbose model name of an object --- netbox/utilities/templatetags/helpers.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 7cb2753e7..e2e64ee1e 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -46,6 +46,22 @@ def gfm(value): return mark_safe(html) +@register.filter() +def model_name(obj): + """ + Return the name of the model of the given object + """ + return obj._meta.verbose_name + + +@register.filter() +def model_name_plural(obj): + """ + Return the plural name of the model of the given object + """ + return obj._meta.verbose_name_plural + + @register.filter() def contains(value, arg): """ From fa95191792e6d609a42e781e5c3856ecab04f564 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 29 Aug 2017 15:26:35 -0400 Subject: [PATCH 021/125] Allowed assigning an IP address to either a device or a VM --- netbox/dcim/models.py | 4 + netbox/ipam/forms.py | 89 ++++++------------- netbox/ipam/tables.py | 10 +-- netbox/ipam/views.py | 15 +++- netbox/templates/dcim/inc/interface.html | 2 +- netbox/templates/ipam/ipaddress.html | 4 +- netbox/templates/ipam/ipaddress_edit.html | 30 ++++--- .../virtualization/inc/interface.html | 7 +- .../virtualization/virtualmachine.html | 30 +++++++ 9 files changed, 106 insertions(+), 85 deletions(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index adf0d80a3..5e89ea6c2 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1241,6 +1241,10 @@ class Interface(models.Model): ) }) + @property + def parent(self): + return self.device or self.virtual_machine + @property def is_virtual(self): return self.form_factor in VIRTUAL_IFACE_TYPES diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index e19376e8e..8eabdd9a9 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -377,50 +377,9 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): # class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm): - interface_site = forms.ModelChoiceField( - queryset=Site.objects.all(), - required=False, - label='Site', - widget=forms.Select( - attrs={'filter-for': 'interface_rack'} - ) - ) - interface_rack = ChainedModelChoiceField( - queryset=Rack.objects.all(), - chains=( - ('site', 'interface_site'), - ), - required=False, - label='Rack', - widget=APISelect( - api_url='/api/dcim/racks/?site_id={{interface_site}}', - display_field='display_name', - attrs={'filter-for': 'interface_device', 'nullable': 'true'} - ) - ) - interface_device = ChainedModelChoiceField( - queryset=Device.objects.all(), - chains=( - ('site', 'interface_site'), - ('rack', 'interface_rack'), - ), - required=False, - label='Device', - widget=APISelect( - api_url='/api/dcim/devices/?site_id={{interface_site}}&rack_id={{interface_rack}}', - display_field='display_name', - attrs={'filter-for': 'interface'} - ) - ) - interface = ChainedModelChoiceField( + interface = forms.ModelChoiceField( queryset=Interface.objects.all(), - chains=( - ('device', 'interface_device'), - ), - required=False, - widget=APISelect( - api_url='/api/dcim/interfaces/?device_id={{interface_device}}' - ) + required=False ) nat_site = forms.ModelChoiceField( queryset=Site.objects.all(), @@ -479,13 +438,13 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) obj_label='address' ) ) - primary_for_device = forms.BooleanField(required=False, label='Make this the primary IP for the device') + primary_for_parent = forms.BooleanField(required=False, label='Make this the primary IP for the device/VM') class Meta: model = IPAddress fields = [ - 'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_device', 'nat_site', 'nat_rack', - 'nat_inside', 'tenant_group', 'tenant', + 'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_parent', 'nat_site', + 'nat_rack', 'nat_inside', 'tenant_group', 'tenant', ] def __init__(self, *args, **kwargs): @@ -493,10 +452,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) # Initialize helper selectors instance = kwargs.get('instance') initial = kwargs.get('initial', {}).copy() - if instance and instance.interface is not None: - initial['interface_site'] = instance.interface.device.site - initial['interface_rack'] = instance.interface.device.rack - initial['interface_device'] = instance.interface.device if instance and instance.nat_inside and instance.nat_inside.device is not None: initial['nat_site'] = instance.nat_inside.device.site initial['nat_rack'] = instance.nat_inside.device.rack @@ -507,22 +462,30 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) self.fields['vrf'].empty_label = 'Global' - # Initialize primary_for_device if IP address is already assigned - if self.instance.interface is not None: - device = self.instance.interface.device + # Limit interface selections to those belonging to the parent device/VM + if self.instance and self.instance.interface: + self.fields['interface'].queryset = Interface.objects.filter( + device=self.instance.interface.device, virtual_machine=self.instance.interface.virtual_machine + ) + else: + self.fields['interface'].choices = [] + + # Initialize primary_for_parent if IP address is already assigned + if self.instance.pk and self.instance.interface is not None: + parent = self.instance.interface.parent if ( - self.instance.address.version == 4 and device.primary_ip4 == self.instance or - self.instance.address.version == 6 and device.primary_ip6 == self.instance + self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or + self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk ): - self.initial['primary_for_device'] = True + self.initial['primary_for_parent'] = True def clean(self): super(IPAddressForm, self).clean() # Primary IP assignment is only available if an interface has been assigned. - if self.cleaned_data.get('primary_for_device') and not self.cleaned_data.get('interface'): + if self.cleaned_data.get('primary_for_parent') and not self.cleaned_data.get('interface'): self.add_error( - 'primary_for_device', "Only IP addresses assigned to an interface can be designated as primary IPs." + 'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs." ) def save(self, *args, **kwargs): @@ -530,13 +493,13 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) ipaddress = super(IPAddressForm, self).save(*args, **kwargs) # Assign this IPAddress as the primary for the associated Device. - if self.cleaned_data['primary_for_device']: - device = self.cleaned_data['interface'].device + if self.cleaned_data['primary_for_parent']: + parent = self.cleaned_data['interface'].parent if ipaddress.address.version == 4: - device.primary_ip4 = ipaddress + parent.primary_ip4 = ipaddress else: - device.primary_ip6 = ipaddress - device.save() + parent.primary_ip6 = ipaddress + parent.save() # Clear assignment as primary for device if set. else: diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 96127aec5..4482dca05 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -77,9 +77,9 @@ IPADDRESS_LINK = """ {% endif %} """ -IPADDRESS_DEVICE = """ +IPADDRESS_PARENT = """ {% if record.interface %} - {{ record.interface.device }} + {{ record.interface.parent }} {% else %} — {% endif %} @@ -265,12 +265,12 @@ class IPAddressTable(BaseTable): status = tables.TemplateColumn(STATUS_LABEL) vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF') tenant = tables.TemplateColumn(TENANT_LINK) - device = tables.TemplateColumn(IPADDRESS_DEVICE, orderable=False) + parent = tables.TemplateColumn(IPADDRESS_PARENT, orderable=False) interface = tables.Column(orderable=False) class Meta(BaseTable.Meta): model = IPAddress - fields = ('pk', 'address', 'vrf', 'status', 'role', 'tenant', 'device', 'interface', 'description') + fields = ('pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description') row_attrs = { 'class': lambda record: 'success' if not isinstance(record, IPAddress) else '', } @@ -283,7 +283,7 @@ class IPAddressDetailTable(IPAddressTable): class Meta(IPAddressTable.Meta): fields = ( - 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'device', 'interface', 'description', + 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'parent', 'interface', 'description', ) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 39834e306..d5cb06c0e 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -10,7 +10,7 @@ from django.shortcuts import get_object_or_404, render from django.urls import reverse from django.views.generic import View -from dcim.models import Device +from dcim.models import Device, Interface from utilities.paginator import EnhancedPaginator from utilities.views import ( BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, @@ -597,7 +597,7 @@ class IPAddressListView(ObjectListView): queryset = IPAddress.objects.select_related( 'vrf__tenant', 'tenant', 'nat_inside' ).prefetch_related( - 'interface__device' + 'interface__device', 'interface__virtual_machine' ) filter = filters.IPAddressFilter filter_form = forms.IPAddressFilterForm @@ -657,6 +657,17 @@ class IPAddressCreateView(PermissionRequiredMixin, ObjectEditView): template_name = 'ipam/ipaddress_edit.html' default_return_url = 'ipam:ipaddress_list' + def alter_obj(self, obj, request, url_args, url_kwargs): + + interface_id = request.GET.get('interface') + if interface_id: + try: + obj.interface = Interface.objects.get(pk=interface_id) + except (ValueError, Interface.DoesNotExist): + pass + + return obj + class IPAddressEditView(IPAddressCreateView): permission_required = 'ipam.change_ipaddress' diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index 75d0f027d..e3febfefb 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -64,7 +64,7 @@ {% endif %} {% endif %} {% if perms.ipam.add_ipaddress %} - + {% endif %} diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 789e89273..1d0786c6d 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -100,7 +100,7 @@
    Assignment {% if ipaddress.interface %} - {{ ipaddress.interface.device }} ({{ ipaddress.interface }}) + {{ ipaddress.interface.parent }} ({{ ipaddress.interface }}) {% else %} None {% endif %} @@ -112,7 +112,7 @@ {% if ipaddress.nat_inside %} {{ ipaddress.nat_inside }} {% if ipaddress.nat_inside.interface %} - ({{ ipaddress.nat_inside.interface.device }}) + ({{ ipaddress.nat_inside.interface.parent }}) {% endif %} {% else %} None diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index 5a625e03c..d0dad69ee 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -1,6 +1,7 @@ {% extends 'utilities/obj_edit.html' %} {% load static from staticfiles %} {% load form_helpers %} +{% load helpers %} {% block tabs %} {% if not obj.pk %} @@ -26,18 +27,25 @@ {% render_field form.tenant %} -
    -
    - Interface Assignment + {% if obj.interface %} +
    +
    + Interface Assignment +
    +
    +
    + + +
    + {% render_field form.interface %} + {% render_field form.primary_for_parent %} +
    -
    - {% render_field form.interface_site %} - {% render_field form.interface_rack %} - {% render_field form.interface_device %} - {% render_field form.interface %} - {% render_field form.primary_for_device %} -
    -
    + {% endif %}
    NAT IP (Inside)
    diff --git a/netbox/templates/virtualization/inc/interface.html b/netbox/templates/virtualization/inc/interface.html index 5a08848da..01307c9ca 100644 --- a/netbox/templates/virtualization/inc/interface.html +++ b/netbox/templates/virtualization/inc/interface.html @@ -13,6 +13,11 @@
    {{ iface.mtu|default:"" }} {{ iface.mac_address|default:"" }} + {% if perms.ipam.add_ipaddress %} + + + + {% endif %} {% if perms.dcim.change_interface %} @@ -30,7 +35,7 @@ {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %} + {{ ip }} {% if ip.description %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index cea8a0a83..5e0810fb4 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -79,6 +79,36 @@ {% endif %}
    Primary IPv4 + {% if vm.primary_ip4 %} + {{ vm.primary_ip4.address.ip }} + {% if vm.primary_ip4.nat_inside %} + (NAT for {{ vm.primary_ip4.nat_inside.address.ip }}) + {% elif vm.primary_ip4.nat_outside %} + (NAT: {{ vm.primary_ip4.nat_outside.address.ip }}) + {% endif %} + {% else %} + N/A + {% endif %} +
    Primary IPv6 + {% if vm.primary_ip6 %} + {{ vm.primary_ip6.address.ip }} + {% if vm.primary_ip6.nat_inside %} + (NAT for {{ vm.primary_ip6.nat_inside.address.ip }}) + {% elif vm.primary_ip6.nat_outside %} + (NAT: {{ vm.primary_ip6.nat_outside.address.ip }}) + {% endif %} + {% else %} + N/A + {% endif %} +
    From 71069ced7ffc66177347e4a49db9677d128f7647 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 29 Aug 2017 16:12:52 -0400 Subject: [PATCH 022/125] Added platform info to VM view --- netbox/templates/virtualization/virtualmachine.html | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 5e0810fb4..a88213090 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -65,6 +65,16 @@ Cluster Type {{ vm.cluster.type }} + + Platform + + {% if vm.platform %} + {{ vm.platform }} + {% else %} + None + {% endif %} + + Tenant From 2abb073b3a9e47290f182050a2610db909772afe Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 31 Aug 2017 10:17:59 -0400 Subject: [PATCH 023/125] Regenerated migrations --- .../{0042_virtualization.py => 0044_virtualization.py} | 4 ++-- netbox/virtualization/migrations/0001_virtualization.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) rename netbox/dcim/migrations/{0042_virtualization.py => 0044_virtualization.py} (91%) diff --git a/netbox/dcim/migrations/0042_virtualization.py b/netbox/dcim/migrations/0044_virtualization.py similarity index 91% rename from netbox/dcim/migrations/0042_virtualization.py rename to netbox/dcim/migrations/0044_virtualization.py index 70c299ccb..b1e250bc2 100644 --- a/netbox/dcim/migrations/0042_virtualization.py +++ b/netbox/dcim/migrations/0044_virtualization.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-08-29 17:49 +# Generated by Django 1.11.4 on 2017-08-31 14:15 from __future__ import unicode_literals from django.db import migrations, models @@ -10,7 +10,7 @@ class Migration(migrations.Migration): dependencies = [ ('virtualization', '0001_virtualization'), - ('dcim', '0041_napalm_integration'), + ('dcim', '0043_device_component_name_lengths'), ] operations = [ diff --git a/netbox/virtualization/migrations/0001_virtualization.py b/netbox/virtualization/migrations/0001_virtualization.py index 3b8441d21..cb553cf95 100644 --- a/netbox/virtualization/migrations/0001_virtualization.py +++ b/netbox/virtualization/migrations/0001_virtualization.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-08-29 17:49 +# Generated by Django 1.11.4 on 2017-08-31 14:15 from __future__ import unicode_literals from django.db import migrations, models @@ -12,9 +12,9 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('tenancy', '0003_unicode_literals'), ('ipam', '0018_remove_service_uniqueness_constraint'), - ('dcim', '0041_napalm_integration'), + ('dcim', '0043_device_component_name_lengths'), + ('tenancy', '0003_unicode_literals'), ] operations = [ From 85cb333a5d5e63bd0def5aa1d8706715306f4d77 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 31 Aug 2017 11:36:29 -0400 Subject: [PATCH 024/125] Added virtualization to API root --- netbox/netbox/views.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 3733a5715..faf3f2ee7 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -236,14 +236,15 @@ class APIRootView(APIView): def get(self, request, format=None): - return Response({ - 'circuits': reverse('circuits-api:api-root', request=request, format=format), - 'dcim': reverse('dcim-api:api-root', request=request, format=format), - 'extras': reverse('extras-api:api-root', request=request, format=format), - 'ipam': reverse('ipam-api:api-root', request=request, format=format), - 'secrets': reverse('secrets-api:api-root', request=request, format=format), - 'tenancy': reverse('tenancy-api:api-root', request=request, format=format), - }) + return Response(OrderedDict(( + ('circuits', reverse('circuits-api:api-root', request=request, format=format)), + ('dcim', reverse('dcim-api:api-root', request=request, format=format)), + ('extras', reverse('extras-api:api-root', request=request, format=format)), + ('ipam', reverse('ipam-api:api-root', request=request, format=format)), + ('secrets', reverse('secrets-api:api-root', request=request, format=format)), + ('tenancy', reverse('tenancy-api:api-root', request=request, format=format)), + ('virtualization', reverse('virtualization-api:api-root', request=request, format=format)), + ))) def handle_500(request): From 3bb0d523d345bf216dc7155b6134362219b786ab Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 31 Aug 2017 12:50:35 -0400 Subject: [PATCH 025/125] Enabled services on virtual machines --- netbox/dcim/urls.py | 2 +- netbox/ipam/api/serializers.py | 6 +- netbox/ipam/forms.py | 13 +++- netbox/ipam/migrations/0019_virtualization.py | 31 ++++++++++ netbox/ipam/models.py | 61 +++++++++++++++---- netbox/ipam/views.py | 5 +- netbox/templates/dcim/device.html | 4 +- .../templates/{dcim => ipam}/inc/service.html | 6 +- netbox/templates/ipam/service_edit.html | 19 ++++-- .../virtualization/inc/interface.html | 6 +- .../virtualization/virtualmachine.html | 23 +++++++ netbox/virtualization/urls.py | 2 + netbox/virtualization/views.py | 3 + 13 files changed, 151 insertions(+), 30 deletions(-) create mode 100644 netbox/ipam/migrations/0019_virtualization.py rename netbox/templates/{dcim => ipam}/inc/service.html (74%) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 172f634fb..3c0b30bcb 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -126,7 +126,7 @@ urlpatterns = [ url(r'^devices/(?P\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), url(r'^devices/(?P\d+)/config/$', views.DeviceConfigView.as_view(), name='device_config'), url(r'^devices/(?P\d+)/add-secret/$', secret_add, name='device_addsecret'), - url(r'^devices/(?P\d+)/services/assign/$', ServiceCreateView.as_view(), name='service_assign'), + url(r'^devices/(?P\d+)/services/assign/$', ServiceCreateView.as_view(), name='device_service_assign'), url(r'^devices/(?P\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}), # Console ports diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 3ef152ebe..a5b64fca6 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -12,6 +12,7 @@ from ipam.models import ( ) from tenancy.api.serializers import NestedTenantSerializer from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer +from virtualization.api.serializers import NestedVirtualMachineSerializer # @@ -295,12 +296,13 @@ class AvailableIPSerializer(serializers.Serializer): class ServiceSerializer(serializers.ModelSerializer): device = NestedDeviceSerializer() + virtual_machine = NestedVirtualMachineSerializer() protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES) ipaddresses = NestedIPAddressSerializer(many=True) class Meta: model = Service - fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description'] + fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description'] # TODO: Figure out how to use model validation with ManyToManyFields. Calling clean() yields a ValueError. @@ -308,4 +310,4 @@ class WritableServiceSerializer(serializers.ModelSerializer): class Meta: model = Service - fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description'] + fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description'] diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 8eabdd9a9..a91c783d1 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -861,5 +861,14 @@ class ServiceForm(BootstrapMixin, forms.ModelForm): super(ServiceForm, self).__init__(*args, **kwargs) - # Limit IP address choices to those assigned to interfaces of the parent device - self.fields['ipaddresses'].queryset = IPAddress.objects.filter(interface__device=self.instance.device) + # Limit IP address choices to those assigned to interfaces of the parent device/VM + if self.instance.device: + self.fields['ipaddresses'].queryset = IPAddress.objects.filter( + interface__device=self.instance.device + ) + elif self.instance.virtual_machine: + self.fields['ipaddresses'].queryset = IPAddress.objects.filter( + interface__virtual_machine=self.instance.virtual_machine + ) + else: + self.fields['ipaddresses'].choices = [] diff --git a/netbox/ipam/migrations/0019_virtualization.py b/netbox/ipam/migrations/0019_virtualization.py new file mode 100644 index 000000000..955ff8b4a --- /dev/null +++ b/netbox/ipam/migrations/0019_virtualization.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-31 15:44 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0001_virtualization'), + ('ipam', '0018_remove_service_uniqueness_constraint'), + ] + + operations = [ + migrations.AlterModelOptions( + name='service', + options={'ordering': ['protocol', 'port']}, + ), + migrations.AddField( + model_name='service', + name='virtual_machine', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='services', to='virtualization.VirtualMachine'), + ), + migrations.AlterField( + model_name='service', + name='device', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='services', to='dcim.Device', verbose_name='device'), + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index ddc1e9e48..f34d55db7 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -588,20 +588,59 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): @python_2_unicode_compatible class Service(CreatedUpdatedModel): """ - A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device. A Service may optionally be tied - to one or more specific IPAddresses belonging to the Device. + A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may + optionally be tied to one or more specific IPAddresses belonging to its parent. """ - device = models.ForeignKey('dcim.Device', related_name='services', on_delete=models.CASCADE, verbose_name='device') - name = models.CharField(max_length=30) - protocol = models.PositiveSmallIntegerField(choices=IP_PROTOCOL_CHOICES) - port = models.PositiveIntegerField(validators=[MinValueValidator(1), MaxValueValidator(65535)], - verbose_name='Port number') - ipaddresses = models.ManyToManyField('ipam.IPAddress', related_name='services', blank=True, - verbose_name='IP addresses') - description = models.CharField(max_length=100, blank=True) + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='services', + verbose_name='device', + null=True, + blank=True + ) + virtual_machine = models.ForeignKey( + to='virtualization.VirtualMachine', + on_delete=models.CASCADE, + related_name='services', + null=True, + blank=True + ) + name = models.CharField( + max_length=30 + ) + protocol = models.PositiveSmallIntegerField( + choices=IP_PROTOCOL_CHOICES + ) + port = models.PositiveIntegerField( + validators=[MinValueValidator(1), MaxValueValidator(65535)], + verbose_name='Port number' + ) + ipaddresses = models.ManyToManyField( + to='ipam.IPAddress', + related_name='services', + blank=True, + verbose_name='IP addresses' + ) + description = models.CharField( + max_length=100, + blank=True + ) class Meta: - ordering = ['device', 'protocol', 'port'] + ordering = ['protocol', 'port'] def __str__(self): return '{} ({}/{})'.format(self.name, self.port, self.get_protocol_display()) + + @property + def parent(self): + return self.device or self.virtual_machine + + def clean(self): + + # A Service must belong to a Device *or* to a VirtualMachine + if self.device and self.virtual_machine: + raise ValidationError("A service cannot be associated with both a device and a virtual machine.") + if not self.device and not self.virtual_machine: + raise ValidationError("A service must be associated with either a device or a virtual machine.") diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index fcca34b0f..edaa31869 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -15,6 +15,7 @@ from utilities.paginator import EnhancedPaginator from utilities.views import ( BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, ) +from virtualization.models import VirtualMachine from . import filters, forms, tables from .constants import IPADDRESS_ROLE_ANYCAST from .models import ( @@ -838,10 +839,12 @@ class ServiceCreateView(PermissionRequiredMixin, ObjectEditView): def alter_obj(self, obj, request, url_args, url_kwargs): if 'device' in url_kwargs: obj.device = get_object_or_404(Device, pk=url_kwargs['device']) + elif 'virtualmachine' in url_kwargs: + obj.virtual_machine = get_object_or_404(VirtualMachine, pk=url_kwargs['virtualmachine']) return obj def get_return_url(self, request, obj): - return obj.device.get_absolute_url() + return obj.parent.get_absolute_url() class ServiceEditView(ServiceCreateView): diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 3bf92fbca..18e14b5e0 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -196,7 +196,7 @@ {% if services %} {% for service in services %} - {% include 'dcim/inc/service.html' %} + {% include 'ipam/inc/service.html' %} {% endfor %}
    {% else %} @@ -206,7 +206,7 @@ {% endif %} {% if perms.ipam.add_service %} diff --git a/netbox/templates/dcim/inc/service.html b/netbox/templates/ipam/inc/service.html similarity index 74% rename from netbox/templates/dcim/inc/service.html rename to netbox/templates/ipam/inc/service.html index 1e42a1811..a88aff4ce 100644 --- a/netbox/templates/dcim/inc/service.html +++ b/netbox/templates/ipam/inc/service.html @@ -14,12 +14,12 @@ {% if perms.ipam.change_service %} - + {% endif %} {% if perms.ipam.delete_service %} - - + + {% endif %} diff --git a/netbox/templates/ipam/service_edit.html b/netbox/templates/ipam/service_edit.html index b5593de0d..e1db968dd 100644 --- a/netbox/templates/ipam/service_edit.html +++ b/netbox/templates/ipam/service_edit.html @@ -5,12 +5,21 @@
    Service
    -
    - -
    -

    {{ obj.device }}

    + {% if obj.device %} +
    + +
    +

    {{ obj.device }}

    +
    -
    + {% else %} +
    + +
    +

    {{ obj.virtual_machine }}

    +
    +
    + {% endif %} {% render_field form.name %}
    diff --git a/netbox/templates/virtualization/inc/interface.html b/netbox/templates/virtualization/inc/interface.html index 01307c9ca..ef37561bb 100644 --- a/netbox/templates/virtualization/inc/interface.html +++ b/netbox/templates/virtualization/inc/interface.html @@ -40,7 +40,7 @@ {% if ip.description %} {% endif %} - {% if device.primary_ip4 == ip or device.primary_ip6 == ip %} + {% if vm.primary_ip4 == ip or vm.primary_ip6 == ip %} Primary {% endif %} @@ -56,12 +56,12 @@ {% if perms.ipam.change_ipaddress %} - + {% endif %} {% if perms.ipam.delete_ipaddress %} - + {% endif %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index a88213090..7cef36ce3 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -158,6 +158,29 @@
    +
    +
    + Services +
    + {% if services %} + + {% for service in services %} + {% include 'ipam/inc/service.html' %} + {% endfor %} +
    + {% else %} +
    + None +
    + {% endif %} + {% if perms.ipam.add_service %} + + {% endif %} +
    {% include 'inc/custom_fields_panel.html' with custom_fields=vm.get_custom_fields %}
    diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index 222754dcb..c8d5163b6 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals from django.conf.urls import url +from ipam.views import ServiceCreateView from . import views @@ -39,6 +40,7 @@ urlpatterns = [ url(r'^virtual-machines/(?P\d+)/$', views.VirtualMachineView.as_view(), name='virtualmachine'), url(r'^virtual-machines/(?P\d+)/edit/$', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'), url(r'^virtual-machines/(?P\d+)/delete/$', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'), + url(r'^virtual-machines/(?P\d+)/services/assign/$', ServiceCreateView.as_view(), name='virtualmachine_service_assign'), # VM interfaces # url(r'^virtual-machines/interfaces/add/$', views.VMBulkAddInterfaceView.as_view(), name='vm_bulk_add_interface'), diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 599956e66..29a35f44b 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -9,6 +9,7 @@ from django.views.generic import View from dcim.models import Device, Interface from dcim.tables import DeviceTable +from ipam.models import Service from utilities.views import ( BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView, @@ -236,10 +237,12 @@ class VirtualMachineView(View): vm = get_object_or_404(VirtualMachine.objects.select_related('tenant__group'), pk=pk) interfaces = Interface.objects.filter(virtual_machine=vm) + services = Service.objects.filter(virtual_machine=vm) return render(request, 'virtualization/virtualmachine.html', { 'vm': vm, 'interfaces': interfaces, + 'services': services, }) From ba0a26184070ad0ae8945aeed2a392a857208efa Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 31 Aug 2017 13:04:56 -0400 Subject: [PATCH 026/125] Removed extraneous import --- netbox/virtualization/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 8b644af2c..d1f949407 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -5,7 +5,6 @@ from django.db import models from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible -from dcim.fields import MACAddressField from extras.models import CustomFieldModel, CustomFieldValue from utilities.models import CreatedUpdatedModel From a6b43baafe07fd583127cdf73d097c5e1b9ba0a3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 31 Aug 2017 13:22:12 -0400 Subject: [PATCH 027/125] Added documentation for virtualization data model --- docs/data-model/ipam.md | 2 +- docs/data-model/virtualization.md | 29 +++++++++++++++++++++++++++++ mkdocs.yml | 1 + 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 docs/data-model/virtualization.md diff --git a/docs/data-model/ipam.md b/docs/data-model/ipam.md index 8b6d53184..6848804fd 100644 --- a/docs/data-model/ipam.md +++ b/docs/data-model/ipam.md @@ -96,4 +96,4 @@ VLAN groups can be employed for administrative organization within NetBox. Each # Services -A service represents a TCP or UDP service available on a device. Each service must be defined with a name, protocol, and port number; for example, "SSH (TCP/22)." A service may optionally be bound to one or more specific IP addresses belonging to a device. (If no IP addresses are bound, the service is assumed to be reachable via any assigned IP address.) +A service represents a TCP or UDP service available on a device or virtual machine. Each service must be defined with a name, protocol, and port number; for example, "SSH (TCP/22)." A service may optionally be bound to one or more specific IP addresses belonging to its parent. (If no IP addresses are bound, the service is assumed to be reachable via any assigned IP address.) diff --git a/docs/data-model/virtualization.md b/docs/data-model/virtualization.md new file mode 100644 index 000000000..d49f7b323 --- /dev/null +++ b/docs/data-model/virtualization.md @@ -0,0 +1,29 @@ +NetBox supports the definition of virtual machines arranged in clusters. A cluster can optionally have physical host devices associated with it. + +# Clusters + +A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type, and may optionally be assigned an organizational group. + +Physical devices (from NetBox's DCIM component) may be associated with clusters as hosts. This allows users to track on which host(s) a particular VM may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device. + +### Cluster Types + +A cluster type represents a technology or mechanism by which a cluster is formed. For example, you might create a cluster type named "VMware vSphere" for a locally hosted cluster or "DigitalOcean NYC3" for one hosted by a cloud provider. + +### Cluster Groups + +Cluster groups may be created for the purpose of organizing clusters. + +--- + +# Virtual Machines + +A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be associated with exactly one cluster. + +Like devices, each VM can have interfaces created on it. These behave similarly to device interfaces, and can be assigned IP addresses, however given their virtual nature they cannot be connected to other interfaces. VMs can also be assigned layer four services. Unlike physical devices, VMs cannot be assigned console or power ports, or device bays. + +The following resources can be defined for each VM: + +* vCPU count +* Memory (MB) +* Disk space (GB) diff --git a/mkdocs.yml b/mkdocs.yml index f204749d5..b1a4b188a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -18,6 +18,7 @@ pages: - 'IPAM': 'data-model/ipam.md' - 'Secrets': 'data-model/secrets.md' - 'Tenancy': 'data-model/tenancy.md' + - 'Virtualization': 'data-model/virtualization.md' - 'Extras': 'data-model/extras.md' - 'API': - 'Overview': 'api/overview.md' From cbc239ceaab1550b327c7798ca2e0b340bcc3b19 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 31 Aug 2017 14:03:12 -0400 Subject: [PATCH 028/125] Added virtualization API tests --- netbox/virtualization/tests/__init__.py | 0 netbox/virtualization/tests/test_api.py | 297 ++++++++++++++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 netbox/virtualization/tests/__init__.py create mode 100644 netbox/virtualization/tests/test_api.py diff --git a/netbox/virtualization/tests/__init__.py b/netbox/virtualization/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py new file mode 100644 index 000000000..9d3e01091 --- /dev/null +++ b/netbox/virtualization/tests/test_api.py @@ -0,0 +1,297 @@ +from __future__ import unicode_literals + +from rest_framework import status +from rest_framework.test import APITestCase + +from django.contrib.auth.models import User +from django.urls import reverse + +from users.models import Token +from utilities.tests import HttpStatusMixin +from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine + + +class ClusterTypeTest(HttpStatusMixin, APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.clustertype1 = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1') + self.clustertype2 = ClusterType.objects.create(name='Test Cluster Type 2', slug='test-cluster-type-2') + self.clustertype3 = ClusterType.objects.create(name='Test Cluster Type 3', slug='test-cluster-type-3') + + def test_get_clustertype(self): + + url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.clustertype1.name) + + def test_list_clustertypes(self): + + url = reverse('virtualization-api:clustertype-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_clustertype(self): + + data = { + 'name': 'Test Cluster Type 4', + 'slug': 'test-cluster-type-4', + } + + url = reverse('virtualization-api:clustertype-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(ClusterType.objects.count(), 4) + clustertype4 = ClusterType.objects.get(pk=response.data['id']) + self.assertEqual(clustertype4.name, data['name']) + self.assertEqual(clustertype4.slug, data['slug']) + + def test_update_clustertype(self): + + data = { + 'name': 'Test Cluster Type X', + 'slug': 'test-cluster-type-x', + } + + url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(ClusterType.objects.count(), 3) + clustertype1 = ClusterType.objects.get(pk=response.data['id']) + self.assertEqual(clustertype1.name, data['name']) + self.assertEqual(clustertype1.slug, data['slug']) + + def test_delete_clustertype(self): + + url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(ClusterType.objects.count(), 2) + + +class ClusterGroupTest(HttpStatusMixin, APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.clustergroup1 = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1') + self.clustergroup2 = ClusterGroup.objects.create(name='Test Cluster Group 2', slug='test-cluster-group-2') + self.clustergroup3 = ClusterGroup.objects.create(name='Test Cluster Group 3', slug='test-cluster-group-3') + + def test_get_clustergroup(self): + + url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.clustergroup1.name) + + def test_list_clustergroups(self): + + url = reverse('virtualization-api:clustergroup-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_clustergroup(self): + + data = { + 'name': 'Test Cluster Group 4', + 'slug': 'test-cluster-group-4', + } + + url = reverse('virtualization-api:clustergroup-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(ClusterGroup.objects.count(), 4) + clustergroup4 = ClusterGroup.objects.get(pk=response.data['id']) + self.assertEqual(clustergroup4.name, data['name']) + self.assertEqual(clustergroup4.slug, data['slug']) + + def test_update_clustergroup(self): + + data = { + 'name': 'Test Cluster Group X', + 'slug': 'test-cluster-group-x', + } + + url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(ClusterGroup.objects.count(), 3) + clustergroup1 = ClusterGroup.objects.get(pk=response.data['id']) + self.assertEqual(clustergroup1.name, data['name']) + self.assertEqual(clustergroup1.slug, data['slug']) + + def test_delete_clustergroup(self): + + url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(ClusterGroup.objects.count(), 2) + + +class ClusterTest(HttpStatusMixin, APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1') + cluster_group = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1') + + self.cluster1 = Cluster.objects.create(name='Test Cluster 1', type=cluster_type, group=cluster_group) + self.cluster2 = Cluster.objects.create(name='Test Cluster 2', type=cluster_type, group=cluster_group) + self.cluster3 = Cluster.objects.create(name='Test Cluster 3', type=cluster_type, group=cluster_group) + + def test_get_cluster(self): + + url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.cluster1.name) + + def test_list_clusters(self): + + url = reverse('virtualization-api:cluster-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_cluster(self): + + data = { + 'name': 'Test Cluster 4', + 'type': ClusterType.objects.first().pk, + 'group': ClusterGroup.objects.first().pk, + } + + url = reverse('virtualization-api:cluster-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Cluster.objects.count(), 4) + cluster4 = Cluster.objects.get(pk=response.data['id']) + self.assertEqual(cluster4.name, data['name']) + self.assertEqual(cluster4.type.pk, data['type']) + self.assertEqual(cluster4.group.pk, data['group']) + + def test_update_cluster(self): + + cluster_type2 = ClusterType.objects.create(name='Test Cluster Type 2', slug='test-cluster-type-2') + cluster_group2 = ClusterGroup.objects.create(name='Test Cluster Group 2', slug='test-cluster-group-2') + data = { + 'name': 'Test Cluster X', + 'type': cluster_type2.pk, + 'group': cluster_group2.pk, + } + + url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(Cluster.objects.count(), 3) + cluster1 = Cluster.objects.get(pk=response.data['id']) + self.assertEqual(cluster1.name, data['name']) + self.assertEqual(cluster1.type.pk, data['type']) + self.assertEqual(cluster1.group.pk, data['group']) + + def test_delete_cluster(self): + + url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(Cluster.objects.count(), 2) + + +class VirtualMachineTest(HttpStatusMixin, APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1') + cluster_group = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1') + cluster = Cluster.objects.create(name='Test Cluster 1', type=cluster_type, group=cluster_group) + + self.virtualmachine1 = VirtualMachine.objects.create(name='Test Virtual Machine 1', cluster=cluster) + self.virtualmachine2 = VirtualMachine.objects.create(name='Test Virtual Machine 2', cluster=cluster) + self.virtualmachine3 = VirtualMachine.objects.create(name='Test Virtual Machine 3', cluster=cluster) + + def test_get_virtualmachine(self): + + url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.virtualmachine1.name) + + def test_list_virtualmachines(self): + + url = reverse('virtualization-api:virtualmachine-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_virtualmachine(self): + + data = { + 'name': 'Test Virtual Machine 4', + 'cluster': Cluster.objects.first().pk, + } + + url = reverse('virtualization-api:virtualmachine-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(VirtualMachine.objects.count(), 4) + virtualmachine4 = VirtualMachine.objects.get(pk=response.data['id']) + self.assertEqual(virtualmachine4.name, data['name']) + self.assertEqual(virtualmachine4.cluster.pk, data['cluster']) + + def test_update_virtualmachine(self): + + cluster2 = Cluster.objects.create( + name='Test Cluster 2', + type=ClusterType.objects.first(), + group=ClusterGroup.objects.first() + ) + data = { + 'name': 'Test Virtual Machine X', + 'cluster': cluster2.pk, + } + + url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(VirtualMachine.objects.count(), 3) + virtualmachine1 = VirtualMachine.objects.get(pk=response.data['id']) + self.assertEqual(virtualmachine1.name, data['name']) + self.assertEqual(virtualmachine1.cluster.pk, data['cluster']) + + def test_delete_virtualmachine(self): + + url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(VirtualMachine.objects.count(), 2) From 3ded8196c4e35867babf7e566e31214dd7aa7fca Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 1 Sep 2017 10:09:52 -0400 Subject: [PATCH 029/125] Fixed IP toggle button --- .../virtualization/virtualmachine.html | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 7cef36ce3..a597ee060 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -258,3 +258,20 @@
    {% endblock %} + +{% block javascript %} + +{% endblock %} From 1341ab5703787ee249b50db69d442b50b775b964 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 1 Sep 2017 10:10:10 -0400 Subject: [PATCH 030/125] Fixed selection of sites not assigned to a region --- netbox/virtualization/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 2dec7b912..292f40df7 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -98,7 +98,7 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): queryset=Region.objects.all(), required=False, widget=forms.Select( - attrs={'filter-for': 'site'} + attrs={'filter-for': 'site', 'nullable': 'true'} ) ) site = ChainedModelChoiceField( From f5c265b7b5f9eea2f02d75f357d8a49d60057e76 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 1 Sep 2017 12:25:57 -0400 Subject: [PATCH 031/125] Modified form action to preserve query parameters --- netbox/templates/utilities/confirmation_form.html | 2 +- netbox/templates/utilities/obj_bulk_delete.html | 2 +- netbox/templates/utilities/obj_bulk_edit.html | 2 +- netbox/templates/utilities/obj_edit.html | 2 +- netbox/templates/utilities/obj_import.html | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/netbox/templates/utilities/confirmation_form.html b/netbox/templates/utilities/confirmation_form.html index 9f3f4b8e6..af15f7ab9 100644 --- a/netbox/templates/utilities/confirmation_form.html +++ b/netbox/templates/utilities/confirmation_form.html @@ -4,7 +4,7 @@ {% block content %}
    -
    + {% csrf_token %} {% for field in form.hidden_fields %} {{ field }} diff --git a/netbox/templates/utilities/obj_bulk_delete.html b/netbox/templates/utilities/obj_bulk_delete.html index 5ec26a9c8..373d43d28 100644 --- a/netbox/templates/utilities/obj_bulk_delete.html +++ b/netbox/templates/utilities/obj_bulk_delete.html @@ -23,7 +23,7 @@
    - + {% csrf_token %} {% for field in form.hidden_fields %} {{ field }} diff --git a/netbox/templates/utilities/obj_bulk_edit.html b/netbox/templates/utilities/obj_bulk_edit.html index e2c501aba..abdbec261 100644 --- a/netbox/templates/utilities/obj_bulk_edit.html +++ b/netbox/templates/utilities/obj_bulk_edit.html @@ -4,7 +4,7 @@ {% block content %}

    {% block title %}Editing {{ table.rows|length }} {{ obj_type_plural|bettertitle }}{% endblock %}

    - + {% csrf_token %} {% if request.POST.return_url %} diff --git a/netbox/templates/utilities/obj_edit.html b/netbox/templates/utilities/obj_edit.html index 07a39634d..2b24208fd 100644 --- a/netbox/templates/utilities/obj_edit.html +++ b/netbox/templates/utilities/obj_edit.html @@ -2,7 +2,7 @@ {% load form_helpers %} {% block content %} - + {% csrf_token %} {% for field in form.hidden_fields %} {{ field }} diff --git a/netbox/templates/utilities/obj_import.html b/netbox/templates/utilities/obj_import.html index 90cb81a61..ced3ee733 100644 --- a/netbox/templates/utilities/obj_import.html +++ b/netbox/templates/utilities/obj_import.html @@ -15,7 +15,7 @@
    {% endif %} - + {% csrf_token %} {% render_form form %}
    From 237a889f54ba1efae1a0113564fc9f9850686018 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 1 Sep 2017 12:28:34 -0400 Subject: [PATCH 032/125] Preserve query parameters on redirect after clicking "add another" --- netbox/utilities/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 1ec21b628..1fa1e7327 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -214,7 +214,7 @@ class ObjectEditView(GetReturnURLMixin, View): UserAction.objects.log_edit(request.user, obj, msg) if '_addanother' in request.POST: - return redirect(request.path) + return redirect(request.get_full_path()) return_url = form.cleaned_data.get('return_url') if return_url is not None and is_safe_url(url=return_url, host=request.get_host()): From f66b0b7feac1e85be93aad1bd47ed9a72b794d51 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 11 Sep 2017 15:42:18 -0400 Subject: [PATCH 033/125] Finished implementing CSV imports --- netbox/virtualization/forms.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 292f40df7..346c82bbf 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -7,7 +7,7 @@ from django.db.models import Count from dcim.constants import VIFACE_FF_CHOICES from dcim.formfields import MACAddressFormField -from dcim.models import Device, Interface, Rack, Region, Site +from dcim.models import Device, Interface, Platform, Rack, Region, Site from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm from tenancy.forms import TenancyForm from tenancy.models import Tenant @@ -74,6 +74,7 @@ class ClusterCSVForm(forms.ModelForm): ) class Meta: + model = Cluster fields = ['name', 'type', 'group'] @@ -199,9 +200,28 @@ class VirtualMachineCSVForm(forms.ModelForm): 'invalid_choice': 'Invalid cluster name.', } ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned tenant', + error_messages={ + 'invalid_choice': 'Tenant not found.' + } + ) + platform = forms.ModelChoiceField( + queryset=Platform.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned platform', + error_messages={ + 'invalid_choice': 'Invalid platform.', + } + ) class Meta: - fields = ['cluster', 'name', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments'] + model = VirtualMachine + fields = ['name', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments'] class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): From 7a3adca7715554f0711c0dc7aaee7dd147af627d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 11 Sep 2017 15:51:03 -0400 Subject: [PATCH 034/125] Added device count to clusters table --- netbox/virtualization/tables.py | 3 ++- netbox/virtualization/views.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 04db8c04c..59f3f5393 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -64,11 +64,12 @@ class ClusterGroupTable(BaseTable): class ClusterTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn() + device_count = tables.Column(verbose_name='Devices') vm_count = tables.Column(verbose_name='VMs') class Meta(BaseTable.Meta): model = Cluster - fields = ('pk', 'name', 'type', 'group', 'vm_count') + fields = ('pk', 'name', 'type', 'group', 'device_count', 'vm_count') # diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 29a35f44b..f7b626589 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -87,7 +87,10 @@ class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class ClusterListView(ObjectListView): - queryset = Cluster.objects.annotate(vm_count=Count('virtual_machines')) + queryset = Cluster.objects.annotate( + device_count=Count('devices', distinct=True), + vm_count=Count('virtual_machines', distinct=True) + ) table = tables.ClusterTable filter = filters.ClusterFilter filter_form = forms.ClusterFilterForm From ef2dd673ec93b1b76a9f1e0e5f337dfccdd48bd9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 11 Sep 2017 16:14:05 -0400 Subject: [PATCH 035/125] Finished bulk edit/delete views --- .../virtualization/cluster_list.html | 2 +- .../virtualization/virtualmachine_list.html | 2 +- netbox/virtualization/forms.py | 22 +++++++++++++--- netbox/virtualization/urls.py | 6 +++-- netbox/virtualization/views.py | 26 ++++++++++++++++--- 5 files changed, 47 insertions(+), 11 deletions(-) diff --git a/netbox/templates/virtualization/cluster_list.html b/netbox/templates/virtualization/cluster_list.html index edb67e0ab..dad7a9e49 100644 --- a/netbox/templates/virtualization/cluster_list.html +++ b/netbox/templates/virtualization/cluster_list.html @@ -17,7 +17,7 @@

    {% block title %}Clusters{% endblock %}

    - {% include 'utilities/obj_table.html' %} + {% include 'utilities/obj_table.html' with bulk_edit_url='virtualization:cluster_bulk_edit' bulk_delete_url='virtualization:cluster_bulk_delete' %}
    {% include 'inc/search_panel.html' %} diff --git a/netbox/templates/virtualization/virtualmachine_list.html b/netbox/templates/virtualization/virtualmachine_list.html index 5cd601be5..38e842e74 100644 --- a/netbox/templates/virtualization/virtualmachine_list.html +++ b/netbox/templates/virtualization/virtualmachine_list.html @@ -17,7 +17,7 @@

    {% block title %}Virtual Machines{% endblock %}

    - {% include 'utilities/obj_table.html' %} + {% include 'utilities/obj_table.html' with bulk_edit_url='virtualization:virtualmachine_bulk_edit' bulk_delete_url='virtualization:virtualmachine_bulk_delete' %}
    {% include 'inc/search_panel.html' %} diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 346c82bbf..b1ce920e4 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -13,8 +13,8 @@ from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ChainedFieldsMixin, - ChainedModelChoiceField, ChainedModelMultipleChoiceField, ComponentForm, ConfirmationForm, ExpandableNameField, - FilterChoiceField, SlugField, + ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm, ConfirmationForm, + ExpandableNameField, FilterChoiceField, SlugField, SmallTextarea, ) from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -78,6 +78,15 @@ class ClusterCSVForm(forms.ModelForm): fields = ['name', 'type', 'group'] +class ClusterBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField(queryset=Cluster.objects.all(), widget=forms.MultipleHiddenInput) + type = forms.ModelChoiceField(queryset=ClusterType.objects.all(), required=False) + group = forms.ModelChoiceField(queryset=ClusterGroup.objects.all(), required=False) + + class Meta: + nullable_fields = ['group'] + + class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Cluster q = forms.CharField(required=False, label='Search') @@ -226,11 +235,16 @@ class VirtualMachineCSVForm(forms.ModelForm): class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput) - cluster = forms.ModelChoiceField(queryset=Cluster.objects.all(), required=False, label='Cluster') + cluster = forms.ModelChoiceField(queryset=Cluster.objects.all(), required=False) tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) + platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False) + vcpus = forms.IntegerField(required=False, label='vCPUs') + memory = forms.IntegerField(required=False, label='Memory (MB)') + disk = forms.IntegerField(required=False, label='Disk (GB)') + comments = CommentField(widget=SmallTextarea) class Meta: - nullable_fields = ['tenant'] + nullable_fields = ['tenant', 'platform', 'vcpus', 'memory', 'disk'] class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm): diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index c8d5163b6..bc8c44767 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -25,7 +25,8 @@ urlpatterns = [ url(r'^clusters/$', views.ClusterListView.as_view(), name='cluster_list'), url(r'^clusters/add/$', views.ClusterCreateView.as_view(), name='cluster_add'), url(r'^clusters/import/$', views.ClusterBulkImportView.as_view(), name='cluster_import'), - # url(r'^clusters/edit/$', views.ClusterBulkEditView.as_view(), name='cluster_bulk_edit'), + url(r'^clusters/edit/$', views.ClusterBulkEditView.as_view(), name='cluster_bulk_edit'), + url(r'^clusters/delete/$', views.ClusterBulkDeleteView.as_view(), name='cluster_bulk_delete'), url(r'^clusters/(?P\d+)/$', views.ClusterView.as_view(), name='cluster'), url(r'^clusters/(?P\d+)/edit/$', views.ClusterEditView.as_view(), name='cluster_edit'), url(r'^clusters/(?P\d+)/delete/$', views.ClusterDeleteView.as_view(), name='cluster_delete'), @@ -36,7 +37,8 @@ urlpatterns = [ url(r'^virtual-machines/$', views.VirtualMachineListView.as_view(), name='virtualmachine_list'), url(r'^virtual-machines/add/$', views.VirtualMachineCreateView.as_view(), name='virtualmachine_add'), url(r'^virtual-machines/import/$', views.VirtualMachineBulkImportView.as_view(), name='virtualmachine_import'), - # url(r'^virtual-machines/edit/$', views.VirtualMachineBulkEditView.as_view(), name='virtualmachine_bulk_edit'), + url(r'^virtual-machines/edit/$', views.VirtualMachineBulkEditView.as_view(), name='virtualmachine_bulk_edit'), + url(r'^virtual-machines/delete/$', views.VirtualMachineBulkDeleteView.as_view(), name='virtualmachine_bulk_delete'), url(r'^virtual-machines/(?P\d+)/$', views.VirtualMachineView.as_view(), name='virtualmachine'), url(r'^virtual-machines/(?P\d+)/edit/$', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'), url(r'^virtual-machines/(?P\d+)/delete/$', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'), diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index f7b626589..c8b3ea8f2 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -138,10 +138,22 @@ class ClusterBulkImportView(PermissionRequiredMixin, BulkImportView): default_return_url = 'virtualization:cluster_list' +class ClusterBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'virtualization.change_cluster' + cls = Cluster + filter = filters.ClusterFilter + table = tables.ClusterTable + form = forms.ClusterBulkEditForm + default_return_url = 'virtualization:cluster_list' + + class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'virtualization.delete_cluster' cls = Cluster - queryset = Cluster.objects.annotate(vm_count=Count('virtual_machines')) + queryset = Cluster.objects.annotate( + device_count=Count('devices', distinct=True), + vm_count=Count('virtual_machines', distinct=True) + ) table = tables.ClusterTable default_return_url = 'virtualization:cluster_list' @@ -227,7 +239,7 @@ class ClusterRemoveDevicesView(PermissionRequiredMixin, View): # class VirtualMachineListView(ObjectListView): - queryset = VirtualMachine.objects.select_related('tenant') + queryset = VirtualMachine.objects.select_related('cluster', 'tenant') filter = filters.VirtualMachineFilter filter_form = forms.VirtualMachineFilterForm table = tables.VirtualMachineTable @@ -277,13 +289,21 @@ class VirtualMachineBulkImportView(PermissionRequiredMixin, BulkImportView): class VirtualMachineBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'virtualization.change_virtualmachine' cls = VirtualMachine - queryset = VirtualMachine.objects.select_related('tenant') + queryset = VirtualMachine.objects.select_related('cluster', 'tenant') filter = filters.VirtualMachineFilter table = tables.VirtualMachineTable form = forms.VirtualMachineBulkEditForm default_return_url = 'virtualization:virtualmachine_list' +class VirtualMachineBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'virtualization.delete_virtualmachine' + cls = VirtualMachine + queryset = VirtualMachine.objects.select_related('cluster', 'tenant') + table = tables.VirtualMachineTable + default_return_url = 'virtualization:virtualmachine_list' + + # # VM interfaces # From 700194b80da3ea51f7e013da90cab53db9252a3f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 Sep 2017 12:49:01 -0400 Subject: [PATCH 036/125] Implemented bulk interface creation for virtual machines --- netbox/dcim/forms.py | 11 +- netbox/dcim/views.py | 106 ++++++------------ .../obj_bulk_add_component.html} | 16 +-- .../inc/virtualmachine_table.html | 14 +++ .../virtualization/virtualmachine_list.html | 2 +- netbox/utilities/views.py | 82 ++++++++++++++ netbox/virtualization/forms.py | 16 +++ netbox/virtualization/urls.py | 2 +- netbox/virtualization/views.py | 18 ++- 9 files changed, 169 insertions(+), 98 deletions(-) rename netbox/templates/{dcim/device_bulk_add_component.html => utilities/obj_bulk_add_component.html} (69%) create mode 100644 netbox/templates/virtualization/inc/virtualmachine_table.html diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 747092b3d..18896bac6 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -903,11 +903,12 @@ class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField(label='Name') -class DeviceBulkAddInterfaceForm(forms.ModelForm, DeviceBulkAddComponentForm): - - class Meta: - model = Interface - fields = ['pk', 'name_pattern', 'form_factor', 'mgmt_only', 'description'] +class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm): + form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) + enabled = forms.BooleanField(required=False, initial=True) + mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') + mgmt_only = forms.BooleanField(required=False, label='OOB Management') + description = forms.CharField(max_length=100, required=False) # diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 671e514dc..3e456cfd4 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -23,8 +23,8 @@ from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_S from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.views import ( - BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, ComponentEditView, - ObjectDeleteView, ObjectEditView, ObjectListView, + BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, + ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView, ) from . import filters, forms, tables from .models import ( @@ -1569,109 +1569,67 @@ class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # -# Bulk device component creation +# Bulk Device component creation # -class DeviceBulkAddComponentView(View): - """ - Add one or more components (e.g. interfaces) to a selected set of Devices. - """ - form = forms.DeviceBulkAddComponentForm - model = None - model_form = None - - def get(self): - return redirect('dcim:device_list') - - def post(self, request): - - # Are we editing *all* objects in the queryset or just a selected subset? - if request.POST.get('_all'): - pk_list = [obj.pk for obj in filters.DeviceFilter(request.GET, Device.objects.all())] - else: - pk_list = [int(pk) for pk in request.POST.getlist('pk')] - - if '_create' in request.POST: - form = self.form(request.POST) - if form.is_valid(): - - new_components = [] - data = deepcopy(form.cleaned_data) - for device in data['pk']: - - names = data['name_pattern'] - for name in names: - component_data = { - 'device': device.pk, - 'name': name, - } - component_data.update(data) - component_form = self.model_form(component_data) - if component_form.is_valid(): - new_components.append(component_form.save(commit=False)) - else: - for field, errors in component_form.errors.as_data().items(): - for e in errors: - form.add_error(field, '{} {}: {}'.format(device, name, ', '.join(e))) - - if not form.errors: - self.model.objects.bulk_create(new_components) - messages.success(request, "Added {} {} to {} devices.".format( - len(new_components), self.model._meta.verbose_name_plural, len(form.cleaned_data['pk']) - )) - return redirect('dcim:device_list') - - else: - form = self.form(initial={'pk': pk_list}) - - selected_devices = Device.objects.filter(pk__in=pk_list) - if not selected_devices: - messages.warning(request, "No devices were selected.") - return redirect('dcim:device_list') - - return render(request, 'dcim/device_bulk_add_component.html', { - 'form': form, - 'component_name': self.model._meta.verbose_name_plural, - 'selected_devices': selected_devices, - 'return_url': reverse('dcim:device_list'), - }) - - -class DeviceBulkAddConsolePortView(PermissionRequiredMixin, DeviceBulkAddComponentView): +class DeviceBulkAddConsolePortView(PermissionRequiredMixin, BulkComponentCreateView): permission_required = 'dcim.add_consoleport' + parent_model = Device + parent_field = 'device' + form = forms.DeviceBulkAddComponentForm model = ConsolePort model_form = forms.ConsolePortForm + table = tables.DeviceTable -class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, DeviceBulkAddComponentView): +class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, BulkComponentCreateView): permission_required = 'dcim.add_consoleserverport' + parent_model = Device + parent_field = 'device' + form = forms.DeviceBulkAddComponentForm model = ConsoleServerPort model_form = forms.ConsoleServerPortForm + table = tables.DeviceTable -class DeviceBulkAddPowerPortView(PermissionRequiredMixin, DeviceBulkAddComponentView): +class DeviceBulkAddPowerPortView(PermissionRequiredMixin, BulkComponentCreateView): permission_required = 'dcim.add_powerport' + parent_model = Device + parent_field = 'device' + form = forms.DeviceBulkAddComponentForm model = PowerPort model_form = forms.PowerPortForm + table = tables.DeviceTable -class DeviceBulkAddPowerOutletView(PermissionRequiredMixin, DeviceBulkAddComponentView): +class DeviceBulkAddPowerOutletView(PermissionRequiredMixin, BulkComponentCreateView): permission_required = 'dcim.add_poweroutlet' + parent_model = Device + parent_field = 'device' + form = forms.DeviceBulkAddComponentForm model = PowerOutlet model_form = forms.PowerOutletForm + table = tables.DeviceTable -class DeviceBulkAddInterfaceView(PermissionRequiredMixin, DeviceBulkAddComponentView): +class DeviceBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateView): permission_required = 'dcim.add_interface' + parent_model = Device + parent_field = 'device' form = forms.DeviceBulkAddInterfaceForm model = Interface model_form = forms.InterfaceForm + table = tables.DeviceTable -class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, DeviceBulkAddComponentView): +class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateView): permission_required = 'dcim.add_devicebay' + parent_model = Device + parent_field = 'device' + form = forms.DeviceBulkAddComponentForm model = DeviceBay model_form = forms.DeviceBayForm + table = tables.DeviceTable # diff --git a/netbox/templates/dcim/device_bulk_add_component.html b/netbox/templates/utilities/obj_bulk_add_component.html similarity index 69% rename from netbox/templates/dcim/device_bulk_add_component.html rename to netbox/templates/utilities/obj_bulk_add_component.html index 697942998..93c7060a4 100644 --- a/netbox/templates/dcim/device_bulk_add_component.html +++ b/netbox/templates/utilities/obj_bulk_add_component.html @@ -14,21 +14,7 @@
    -
    Selected Devices
    - - - - - - - {% for device in selected_devices %} - - - - - - {% endfor %} -
    DeviceTypeRole
    {{ device }}{{ device.device_type.full_name }}{{ device.device_role }}
    + {% include 'inc/table.html' %}
    diff --git a/netbox/templates/virtualization/inc/virtualmachine_table.html b/netbox/templates/virtualization/inc/virtualmachine_table.html new file mode 100644 index 000000000..ce249593e --- /dev/null +++ b/netbox/templates/virtualization/inc/virtualmachine_table.html @@ -0,0 +1,14 @@ +{% extends 'utilities/obj_table.html' %} + +{% block extra_actions %} + {% if perms.virtualization.change_virtualmachine %} +
    + + +
    + {% endif %} +{% endblock %} \ No newline at end of file diff --git a/netbox/templates/virtualization/virtualmachine_list.html b/netbox/templates/virtualization/virtualmachine_list.html index 38e842e74..a771dfbd9 100644 --- a/netbox/templates/virtualization/virtualmachine_list.html +++ b/netbox/templates/virtualization/virtualmachine_list.html @@ -17,7 +17,7 @@

    {% block title %}Virtual Machines{% endblock %}

    - {% include 'utilities/obj_table.html' with bulk_edit_url='virtualization:virtualmachine_bulk_edit' bulk_delete_url='virtualization:virtualmachine_bulk_delete' %} + {% include 'virtualization/inc/virtualmachine_table.html' with bulk_edit_url='virtualization:virtualmachine_bulk_edit' bulk_delete_url='virtualization:virtualmachine_bulk_delete' %}
    {% include 'inc/search_panel.html' %} diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 4d9c0c675..1da5b85e2 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -705,6 +705,9 @@ class BulkDeleteView(View): # class ComponentCreateView(View): + """ + Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine. + """ parent_model = None parent_field = None model = None @@ -786,3 +789,82 @@ class ComponentDeleteView(ObjectDeleteView): def get_return_url(self, request, obj): return getattr(obj, self.parent_field).get_absolute_url() + + +class BulkComponentCreateView(View): + """ + Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines. + """ + parent_model = None + parent_field = None + form = None + model = None + model_form = None + filter = None + table = None + template_name = 'utilities/obj_bulk_add_component.html' + default_return_url = 'home' + + def post(self, request): + + # Are we editing *all* objects in the queryset or just a selected subset? + if request.POST.get('_all') and self.filter is not None: + pk_list = [obj.pk for obj in self.filter(request.GET, self.model.objects.only('pk')).qs] + else: + pk_list = [int(pk) for pk in request.POST.getlist('pk')] + + # Determine URL to redirect users upon modification of objects + posted_return_url = request.POST.get('return_url') + if posted_return_url and is_safe_url(url=posted_return_url, host=request.get_host()): + return_url = posted_return_url + else: + return_url = reverse(self.default_return_url) + + selected_objects = self.parent_model.objects.filter(pk__in=pk_list) + if not selected_objects: + messages.warning(request, "No {} were selected.".format(self.parent_model._meta.verbose_name_plural)) + return redirect(return_url) + table = self.table(selected_objects) + + if '_create' in request.POST: + form = self.form(request.POST) + if form.is_valid(): + + new_components = [] + data = deepcopy(form.cleaned_data) + for obj in data['pk']: + + names = data['name_pattern'] + for name in names: + component_data = { + self.parent_field: obj.pk, + 'name': name, + } + component_data.update(data) + component_form = self.model_form(component_data) + if component_form.is_valid(): + new_components.append(component_form.save(commit=False)) + else: + for field, errors in component_form.errors.as_data().items(): + for e in errors: + form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e))) + + if not form.errors: + self.model.objects.bulk_create(new_components) + messages.success(request, "Added {} {} to {} {}.".format( + len(new_components), + self.model._meta.verbose_name_plural, + len(form.cleaned_data['pk']), + self.parent_model._meta.verbose_name_plural + )) + return redirect(return_url) + + else: + form = self.form(initial={'pk': pk_list}) + + return render(request, self.template_name, { + 'form': form, + 'component_name': self.model._meta.verbose_name_plural, + 'table': table, + 'return_url': reverse('dcim:device_list'), + }) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index b1ce920e4..661d6094d 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -301,3 +301,19 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): class Meta: nullable_fields = ['mtu', 'description'] + + +# +# Bulk VirtualMachine component creation +# + +class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form): + pk = forms.ModelMultipleChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput) + name_pattern = ExpandableNameField(label='Name') + + +class VirtualMachineBulkAddInterfaceForm(VirtualMachineBulkAddComponentForm): + form_factor = forms.ChoiceField(choices=VIFACE_FF_CHOICES) + enabled = forms.BooleanField(required=False, initial=True) + mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') + description = forms.CharField(max_length=100, required=False) diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index bc8c44767..9d976f300 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -45,7 +45,7 @@ urlpatterns = [ url(r'^virtual-machines/(?P\d+)/services/assign/$', ServiceCreateView.as_view(), name='virtualmachine_service_assign'), # VM interfaces - # url(r'^virtual-machines/interfaces/add/$', views.VMBulkAddInterfaceView.as_view(), name='vm_bulk_add_interface'), + url(r'^virtual-machines/interfaces/add/$', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_interface'), url(r'^virtual-machines/(?P\d+)/interfaces/add/$', views.InterfaceCreateView.as_view(), name='interface_add'), url(r'^virtual-machines/(?P\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), url(r'^virtual-machines/(?P\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index c8b3ea8f2..79629bf98 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -11,8 +11,8 @@ from dcim.models import Device, Interface from dcim.tables import DeviceTable from ipam.models import Service from utilities.views import ( - BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, ComponentEditView, - ObjectDeleteView, ObjectEditView, ObjectListView, + BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, + ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView, ) from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine from . import filters @@ -344,3 +344,17 @@ class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): cls = Interface parent_cls = VirtualMachine table = tables.InterfaceTable + + +# +# Bulk Device component creation +# + +class VirtualMachineBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateView): + permission_required = 'dcim.add_interface' + parent_model = VirtualMachine + parent_field = 'virtual_machine' + form = forms.VirtualMachineBulkAddInterfaceForm + model = Interface + model_form = forms.InterfaceForm + table = tables.VirtualMachineTable From 8fb37233f4492251fbd70988d8e9e81bdc1b022b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 Sep 2017 13:53:15 -0400 Subject: [PATCH 037/125] Started documenting utility views --- docs/development/utility-views.md | 53 +++++++++++++++++++++++++++++++ mkdocs.yml | 2 ++ 2 files changed, 55 insertions(+) create mode 100644 docs/development/utility-views.md diff --git a/docs/development/utility-views.md b/docs/development/utility-views.md new file mode 100644 index 000000000..a6e50f71e --- /dev/null +++ b/docs/development/utility-views.md @@ -0,0 +1,53 @@ +# Utility Views + +Utility views are reusable views that handle common CRUD tasks, such as listing and updating objects. Some views operate on individual objects, whereas others (referred to as "bulk" views) operate on multiple objects at once. + +## Individual Views + +### ObjectListView + +Generates a paginated table of objects from a given queryset, which may optionally be filtered. + +### ObjectEditView + +Updates an object identified by a primary key (PK) or slug. If no existing object is specified, a new object will be created. + +### ObjectDeleteView + +Deletes an object. The user is redirected to a confirmation page before the deletion is executed. + +## Bulk Views + +### BulkCreateView + +Creates multiple objects at once based on a given pattern. Currently used only for IP addresses. + +### BulkImportView + +Accepts CSV-formatted data and creates a new object for each line. Creation is all-or-none. + +### BulkEditView + +Applies changes to multiple objects at once in a two-step operation. First, the list of PKs for selected objects is POSTed and an edit form is presented to the user. On submission of that form, the specified changes are made to all selected objects. + +### BulkDeleteView + +Deletes multiple objects. The user selects the objects to be deleted and confirms the deletion. + +## Component Views + +### ComponentCreateView + +Create one or more component objects beloning to a parent object (e.g. interfaces attached to a device). + +### ComponentEditView + +A subclass of `ObjectEditView`: Updates an individual component object. + +### ComponentDeleteView + +A subclass of `ObjectDeleteView`: Deletes an individual component object. + +### BulkComponentCreateView + +Create a set of components objects for each of a selected set of parent objects. This view can be used e.g. to create multiple interfaces on multiple devices at once. diff --git a/mkdocs.yml b/mkdocs.yml index b1a4b188a..c26a9ed17 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -27,6 +27,8 @@ pages: - 'Examples': 'api/examples.md' - 'Shell': - 'Introduction': 'shell/intro.md' + - 'Development': + - 'Utility Views': 'development/utility-views.md' markdown_extensions: - admonition: From d651deb31c79e94b239fe2a3f980511ac46e1843 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 Sep 2017 13:54:44 -0400 Subject: [PATCH 038/125] Renamed `form_class` attribute to `model_form` for consistency --- netbox/circuits/views.py | 8 ++--- netbox/dcim/views.py | 36 +++++++++---------- netbox/extras/views.py | 2 +- netbox/ipam/forms.py | 2 +- netbox/ipam/views.py | 20 +++++------ netbox/secrets/views.py | 2 +- netbox/templates/ipam/ipaddress_bulk_add.html | 2 +- netbox/tenancy/views.py | 4 +-- netbox/utilities/views.py | 26 +++++++------- netbox/virtualization/views.py | 10 +++--- 10 files changed, 56 insertions(+), 56 deletions(-) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 345e3379d..467450346 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -52,7 +52,7 @@ class ProviderView(View): class ProviderCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'circuits.add_provider' model = Provider - form_class = forms.ProviderForm + model_form = forms.ProviderForm template_name = 'circuits/provider_edit.html' default_return_url = 'circuits:provider_list' @@ -104,7 +104,7 @@ class CircuitTypeListView(ObjectListView): class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'circuits.add_circuittype' model = CircuitType - form_class = forms.CircuitTypeForm + model_form = forms.CircuitTypeForm def get_return_url(self, request, obj): return reverse('circuits:circuittype_list') @@ -160,7 +160,7 @@ class CircuitView(View): class CircuitCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'circuits.add_circuit' model = Circuit - form_class = forms.CircuitForm + model_form = forms.CircuitForm template_name = 'circuits/circuit_edit.html' default_return_url = 'circuits:circuit_list' @@ -253,7 +253,7 @@ def circuit_terminations_swap(request, pk): class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'circuits.add_circuittermination' model = CircuitTermination - form_class = forms.CircuitTerminationForm + model_form = forms.CircuitTerminationForm template_name = 'circuits/circuittermination_edit.html' def alter_obj(self, obj, request, url_args, url_kwargs): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 3e456cfd4..ab0bfbbc9 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -112,7 +112,7 @@ class RegionListView(ObjectListView): class RegionCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_region' model = Region - form_class = forms.RegionForm + model_form = forms.RegionForm def get_return_url(self, request, obj): return reverse('dcim:region_list') @@ -170,7 +170,7 @@ class SiteView(View): class SiteCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_site' model = Site - form_class = forms.SiteForm + model_form = forms.SiteForm template_name = 'dcim/site_edit.html' default_return_url = 'dcim:site_list' @@ -217,7 +217,7 @@ class RackGroupListView(ObjectListView): class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_rackgroup' model = RackGroup - form_class = forms.RackGroupForm + model_form = forms.RackGroupForm def get_return_url(self, request, obj): return reverse('dcim:rackgroup_list') @@ -249,7 +249,7 @@ class RackRoleListView(ObjectListView): class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_rackrole' model = RackRole - form_class = forms.RackRoleForm + model_form = forms.RackRoleForm def get_return_url(self, request, obj): return reverse('dcim:rackrole_list') @@ -352,7 +352,7 @@ class RackView(View): class RackCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_rack' model = Rack - form_class = forms.RackForm + model_form = forms.RackForm template_name = 'dcim/rack_edit.html' default_return_url = 'dcim:rack_list' @@ -408,7 +408,7 @@ class RackReservationListView(ObjectListView): class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_rackreservation' model = RackReservation - form_class = forms.RackReservationForm + model_form = forms.RackReservationForm def alter_obj(self, obj, request, args, kwargs): if not obj.pk: @@ -452,7 +452,7 @@ class ManufacturerListView(ObjectListView): class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_manufacturer' model = Manufacturer - form_class = forms.ManufacturerForm + model_form = forms.ManufacturerForm def get_return_url(self, request, obj): return reverse('dcim:manufacturer_list') @@ -537,7 +537,7 @@ class DeviceTypeView(View): class DeviceTypeCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_devicetype' model = DeviceType - form_class = forms.DeviceTypeForm + model_form = forms.DeviceTypeForm template_name = 'dcim/devicetype_edit.html' default_return_url = 'dcim:devicetype_list' @@ -700,7 +700,7 @@ class DeviceRoleListView(ObjectListView): class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_devicerole' model = DeviceRole - form_class = forms.DeviceRoleForm + model_form = forms.DeviceRoleForm def get_return_url(self, request, obj): return reverse('dcim:devicerole_list') @@ -731,7 +731,7 @@ class PlatformListView(ObjectListView): class PlatformCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_platform' model = Platform - form_class = forms.PlatformForm + model_form = forms.PlatformForm def get_return_url(self, request, obj): return reverse('dcim:platform_list') @@ -889,7 +889,7 @@ class DeviceConfigView(PermissionRequiredMixin, View): class DeviceCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_device' model = Device - form_class = forms.DeviceForm + model_form = forms.DeviceForm template_name = 'dcim/device_edit.html' default_return_url = 'dcim:device_list' @@ -1044,7 +1044,7 @@ class ConsolePortEditView(PermissionRequiredMixin, ComponentEditView): permission_required = 'dcim.change_consoleport' model = ConsolePort parent_field = 'device' - form_class = forms.ConsolePortForm + model_form = forms.ConsolePortForm class ConsolePortDeleteView(PermissionRequiredMixin, ComponentDeleteView): @@ -1164,7 +1164,7 @@ class ConsoleServerPortEditView(PermissionRequiredMixin, ComponentEditView): permission_required = 'dcim.change_consoleserverport' model = ConsoleServerPort parent_field = 'device' - form_class = forms.ConsoleServerPortForm + model_form = forms.ConsoleServerPortForm class ConsoleServerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView): @@ -1283,7 +1283,7 @@ class PowerPortEditView(PermissionRequiredMixin, ComponentEditView): permission_required = 'dcim.change_powerport' model = PowerPort parent_field = 'device' - form_class = forms.PowerPortForm + model_form = forms.PowerPortForm class PowerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView): @@ -1403,7 +1403,7 @@ class PowerOutletEditView(PermissionRequiredMixin, ComponentEditView): permission_required = 'dcim.change_poweroutlet' model = PowerOutlet parent_field = 'device' - form_class = forms.PowerOutletForm + model_form = forms.PowerOutletForm class PowerOutletDeleteView(PermissionRequiredMixin, ComponentDeleteView): @@ -1448,7 +1448,7 @@ class InterfaceEditView(PermissionRequiredMixin, ComponentEditView): permission_required = 'dcim.change_interface' model = Interface parent_field = 'device' - form_class = forms.InterfaceForm + model_form = forms.InterfaceForm class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView): @@ -1502,7 +1502,7 @@ class DeviceBayEditView(PermissionRequiredMixin, ComponentEditView): permission_required = 'dcim.change_devicebay' model = DeviceBay parent_field = 'device' - form_class = forms.DeviceBayForm + model_form = forms.DeviceBayForm class DeviceBayDeleteView(PermissionRequiredMixin, ComponentDeleteView): @@ -1774,7 +1774,7 @@ class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView): permission_required = 'dcim.change_inventoryitem' model = InventoryItem parent_field = 'device' - form_class = forms.InventoryItemForm + model_form = forms.InventoryItemForm def alter_obj(self, obj, request, url_args, url_kwargs): if 'device' in url_kwargs: diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 06c852d68..881525237 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -11,7 +11,7 @@ from .models import ImageAttachment class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'extras.change_imageattachment' model = ImageAttachment - form_class = ImageAttachmentForm + model_form = ImageAttachmentForm def alter_obj(self, imageattachment, request, args, kwargs): if not imageattachment.pk: diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index a91c783d1..1fa6562d1 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -517,7 +517,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) return ipaddress -class IPAddressPatternForm(BootstrapMixin, forms.Form): +class IPAddressBulkCreateForm(BootstrapMixin, forms.Form): pattern = ExpandableIPAddressField(label='Address pattern') diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index edaa31869..fbc2e390a 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -119,7 +119,7 @@ class VRFView(View): class VRFCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_vrf' model = VRF - form_class = forms.VRFForm + model_form = forms.VRFForm template_name = 'ipam/vrf_edit.html' default_return_url = 'ipam:vrf_list' @@ -251,7 +251,7 @@ class RIRListView(ObjectListView): class RIRCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_rir' model = RIR - form_class = forms.RIRForm + model_form = forms.RIRForm def get_return_url(self, request, obj): return reverse('ipam:rir_list') @@ -342,7 +342,7 @@ class AggregateView(View): class AggregateCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_aggregate' model = Aggregate - form_class = forms.AggregateForm + model_form = forms.AggregateForm template_name = 'ipam/aggregate_edit.html' default_return_url = 'ipam:aggregate_list' @@ -396,7 +396,7 @@ class RoleListView(ObjectListView): class RoleCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_role' model = Role - form_class = forms.RoleForm + model_form = forms.RoleForm def get_return_url(self, request, obj): return reverse('ipam:role_list') @@ -549,7 +549,7 @@ class PrefixIPAddressesView(View): class PrefixCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_prefix' model = Prefix - form_class = forms.PrefixForm + model_form = forms.PrefixForm template_name = 'ipam/prefix_edit.html' default_return_url = 'ipam:prefix_list' @@ -658,7 +658,7 @@ class IPAddressView(View): class IPAddressCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_ipaddress' model = IPAddress - form_class = forms.IPAddressForm + model_form = forms.IPAddressForm template_name = 'ipam/ipaddress_edit.html' default_return_url = 'ipam:ipaddress_list' @@ -686,7 +686,7 @@ class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView): class IPAddressBulkCreateView(PermissionRequiredMixin, BulkCreateView): permission_required = 'ipam.add_ipaddress' - pattern_form = forms.IPAddressPatternForm + form = forms.IPAddressBulkCreateForm model_form = forms.IPAddressBulkAddForm pattern_target = 'address' template_name = 'ipam/ipaddress_bulk_add.html' @@ -734,7 +734,7 @@ class VLANGroupListView(ObjectListView): class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_vlangroup' model = VLANGroup - form_class = forms.VLANGroupForm + model_form = forms.VLANGroupForm def get_return_url(self, request, obj): return reverse('ipam:vlangroup_list') @@ -785,7 +785,7 @@ class VLANView(View): class VLANCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_vlan' model = VLAN - form_class = forms.VLANForm + model_form = forms.VLANForm template_name = 'ipam/vlan_edit.html' default_return_url = 'ipam:vlan_list' @@ -833,7 +833,7 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class ServiceCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_service' model = Service - form_class = forms.ServiceForm + model_form = forms.ServiceForm template_name = 'ipam/service_edit.html' def alter_obj(self, obj, request, url_args, url_kwargs): diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index 71cf42c13..d1febc245 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -42,7 +42,7 @@ class SecretRoleListView(ObjectListView): class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'secrets.add_secretrole' model = SecretRole - form_class = forms.SecretRoleForm + model_form = forms.SecretRoleForm def get_return_url(self, request, obj): return reverse('secrets:secretrole_list') diff --git a/netbox/templates/ipam/ipaddress_bulk_add.html b/netbox/templates/ipam/ipaddress_bulk_add.html index 78406a3f2..17d48388c 100644 --- a/netbox/templates/ipam/ipaddress_bulk_add.html +++ b/netbox/templates/ipam/ipaddress_bulk_add.html @@ -12,7 +12,7 @@
    IP Addresses
    - {% render_field pattern_form.pattern %} + {% render_field form.pattern %} {% render_field model_form.status %} {% render_field model_form.role %} {% render_field model_form.vrf %} diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 86e419e54..9bcfee82d 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -30,7 +30,7 @@ class TenantGroupListView(ObjectListView): class TenantGroupCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'tenancy.add_tenantgroup' model = TenantGroup - form_class = forms.TenantGroupForm + model_form = forms.TenantGroupForm def get_return_url(self, request, obj): return reverse('tenancy:tenantgroup_list') @@ -92,7 +92,7 @@ class TenantView(View): class TenantCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'tenancy.add_tenant' model = Tenant - form_class = forms.TenantForm + model_form = forms.TenantForm template_name = 'tenancy/tenant_edit.html' default_return_url = 'tenancy:tenant_list' diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 1da5b85e2..1bd9cb7c1 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -156,12 +156,12 @@ class ObjectEditView(GetReturnURLMixin, View): Create or edit a single object. model: The model of the object being edited - form_class: The form used to create or edit the object + model_form: The form used to create or edit the object template_name: The name of the template default_return_url: The name of the URL used to display a list of this object type """ model = None - form_class = None + model_form = None template_name = 'utilities/obj_edit.html' def get_object(self, kwargs): @@ -183,7 +183,7 @@ class ObjectEditView(GetReturnURLMixin, View): obj = self.alter_obj(obj, request, args, kwargs) # Parse initial data manually to avoid setting field values as lists initial_data = {k: request.GET[k] for k in request.GET} - form = self.form_class(instance=obj, initial=initial_data) + form = self.model_form(instance=obj, initial=initial_data) return render(request, self.template_name, { 'obj': obj, @@ -196,7 +196,7 @@ class ObjectEditView(GetReturnURLMixin, View): obj = self.get_object(kwargs) obj = self.alter_obj(obj, request, args, kwargs) - form = self.form_class(request.POST, request.FILES, instance=obj) + form = self.model_form(request.POST, request.FILES, instance=obj) if form.is_valid(): obj_created = not form.instance.pk @@ -295,12 +295,12 @@ class BulkCreateView(View): """ Create new objects in bulk. - pattern_form: Form class which provides the `pattern` field + form: Form class which provides the `pattern` field model_form: The ModelForm used to create individual objects template_name: The name of the template default_return_url: Name of the URL to which the user is redirected after creating the objects """ - pattern_form = None + form = None model_form = None pattern_target = '' template_name = None @@ -308,12 +308,12 @@ class BulkCreateView(View): def get(self, request): - pattern_form = self.pattern_form() + form = self.form() model_form = self.model_form() return render(request, self.template_name, { 'obj_type': self.model_form._meta.model._meta.verbose_name, - 'pattern_form': pattern_form, + 'form': form, 'model_form': model_form, 'return_url': reverse(self.default_return_url), }) @@ -321,12 +321,12 @@ class BulkCreateView(View): def post(self, request): model = self.model_form._meta.model - pattern_form = self.pattern_form(request.POST) + form = self.form(request.POST) model_form = self.model_form(request.POST) - if pattern_form.is_valid(): + if form.is_valid(): - pattern = pattern_form.cleaned_data['pattern'] + pattern = form.cleaned_data['pattern'] new_objs = [] try: @@ -348,7 +348,7 @@ class BulkCreateView(View): # Copy any errors on the pattern target field to the pattern form. errors = model_form.errors.as_data() if errors.get(self.pattern_target): - pattern_form.add_error('pattern', errors[self.pattern_target]) + form.add_error('pattern', errors[self.pattern_target]) # Raise an IntegrityError to break the for loop and abort the transaction. raise IntegrityError() @@ -365,7 +365,7 @@ class BulkCreateView(View): pass return render(request, self.template_name, { - 'pattern_form': pattern_form, + 'form': form, 'model_form': model_form, 'obj_type': model._meta.verbose_name, 'return_url': reverse(self.default_return_url), diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 79629bf98..460715a41 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -33,7 +33,7 @@ class ClusterTypeListView(ObjectListView): class ClusterTypeCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'virtualization.add_clustertype' model = ClusterType - form_class = forms.ClusterTypeForm + model_form = forms.ClusterTypeForm def get_return_url(self, request, obj): return reverse('virtualization:clustertype_list') @@ -64,7 +64,7 @@ class ClusterGroupListView(ObjectListView): class ClusterGroupCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'virtualization.add_clustergroup' model = ClusterGroup - form_class = forms.ClusterGroupForm + model_form = forms.ClusterGroupForm def get_return_url(self, request, obj): return reverse('virtualization:clustergroup_list') @@ -118,7 +118,7 @@ class ClusterView(View): class ClusterCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'virtualization.add_cluster' model = Cluster - form_class = forms.ClusterForm + model_form = forms.ClusterForm class ClusterEditView(ClusterCreateView): @@ -264,7 +264,7 @@ class VirtualMachineView(View): class VirtualMachineCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'virtualization.add_virtualmachine' model = VirtualMachine - form_class = forms.VirtualMachineForm + model_form = forms.VirtualMachineForm template_name = 'virtualization/virtualmachine_edit.html' default_return_url = 'virtualization:virtualmachine_list' @@ -322,7 +322,7 @@ class InterfaceEditView(PermissionRequiredMixin, ComponentEditView): permission_required = 'dcim.change_interface' model = Interface parent_field = 'virtual_machine' - form_class = forms.InterfaceForm + model_form = forms.InterfaceForm class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView): From 136d16b7fdec497782eb56698de31dd6c840e80a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 Sep 2017 14:14:08 -0400 Subject: [PATCH 039/125] Don't add a placeholder option to multiselect widgets --- netbox/project-static/js/forms.js | 4 +++- netbox/virtualization/forms.py | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 5959335db..945a94d45 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -77,7 +77,9 @@ $(document).ready(function() { // Wipe out any existing options within the child field and create a default option child_field.empty(); - child_field.append($("").attr("value", "").text("---------")); + if (!child_field.attr('multiple')) { + child_field.append($("").attr("value", "").text("---------")); + } if ($(this).val() || $(this).attr('nullable') == 'true') { var api_url = child_field.attr('api-url') + '&limit=1000'; diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 661d6094d..8a1feaaea 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -140,7 +140,6 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): ('rack', 'rack'), ), label='Device', - required=False, widget=APISelectMultiple( api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}', display_field='display_name', From 6872ab0e1406111e3042fa8881c483bece029759 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 14 Sep 2017 14:35:34 -0400 Subject: [PATCH 040/125] Added a status field for virtual machines --- .../virtualization/virtualmachine.html | 6 +++++ .../virtualization/virtualmachine_edit.html | 1 + netbox/virtualization/api/serializers.py | 8 ++++-- netbox/virtualization/constants.py | 19 +++++++++++++ netbox/virtualization/filters.py | 4 +++ netbox/virtualization/forms.py | 27 +++++++++++++++---- .../0002_virtualmachine_add_status.py | 20 ++++++++++++++ netbox/virtualization/models.py | 9 +++++++ netbox/virtualization/tables.py | 7 ++++- 9 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 netbox/virtualization/constants.py create mode 100644 netbox/virtualization/migrations/0002_virtualmachine_add_status.py diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index a597ee060..cd66cb105 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -51,6 +51,12 @@ Name {{ vm.name }} + + Status + + {{ vm.get_status_display }} + + Cluster diff --git a/netbox/templates/virtualization/virtualmachine_edit.html b/netbox/templates/virtualization/virtualmachine_edit.html index 14acd13db..085f3cadf 100644 --- a/netbox/templates/virtualization/virtualmachine_edit.html +++ b/netbox/templates/virtualization/virtualmachine_edit.html @@ -6,6 +6,7 @@
    Virtual Machine
    {% render_field form.name %} + {% render_field form.status %} {% render_field form.cluster_group %} {% render_field form.cluster %} {% render_field form.platform %} diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 42d3f237e..6849a5396 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -8,6 +8,7 @@ from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer from tenancy.api.serializers import NestedTenantSerializer from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer +from virtualization.constants import STATUS_CHOICES from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -82,6 +83,7 @@ class WritableClusterSerializer(CustomFieldModelSerializer): # class VirtualMachineSerializer(CustomFieldModelSerializer): + status = ChoiceFieldSerializer(choices=STATUS_CHOICES) cluster = NestedClusterSerializer() tenant = NestedTenantSerializer() platform = NestedPlatformSerializer() @@ -89,7 +91,8 @@ class VirtualMachineSerializer(CustomFieldModelSerializer): class Meta: model = VirtualMachine fields = [ - 'id', 'name', 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'comments', 'custom_fields', + 'id', 'name', 'status', 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'comments', + 'custom_fields', ] @@ -106,7 +109,8 @@ class WritableVirtualMachineSerializer(CustomFieldModelSerializer): class Meta: model = VirtualMachine fields = [ - 'id', 'name', 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'comments', 'custom_fields', + 'id', 'name', 'status', 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'comments', + 'custom_fields', ] diff --git a/netbox/virtualization/constants.py b/netbox/virtualization/constants.py new file mode 100644 index 000000000..b05a541eb --- /dev/null +++ b/netbox/virtualization/constants.py @@ -0,0 +1,19 @@ +from __future__ import unicode_literals + + +# VirtualMachine statuses (replicated from Device statuses) +STATUS_OFFLINE = 0 +STATUS_ACTIVE = 1 +STATUS_STAGED = 3 +STATUS_CHOICES = [ + [STATUS_ACTIVE, 'Active'], + [STATUS_OFFLINE, 'Offline'], + [STATUS_STAGED, 'Staged'], +] + +# Bootstrap CSS classes for VirtualMachine statuses +VM_STATUS_CLASSES = { + 0: 'warning', + 1: 'success', + 3: 'primary', +} diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index 4efcd06e2..0cb06fa9c 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -7,6 +7,7 @@ from dcim.models import Platform from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter +from .constants import STATUS_CHOICES from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -55,6 +56,9 @@ class VirtualMachineFilter(CustomFieldFilterSet): method='search', label='Search', ) + status = django_filters.MultipleChoiceFilter( + choices=STATUS_CHOICES + ) cluster_group_id = NullableModelMultipleChoiceFilter( name='cluster__group', queryset=ClusterGroup.objects.all(), diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 8a1feaaea..7fc033d4c 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -12,10 +12,11 @@ from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFi from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( - APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ChainedFieldsMixin, - ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm, ConfirmationForm, - ExpandableNameField, FilterChoiceField, SlugField, SmallTextarea, + add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, + ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm, + ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, SlugField, SmallTextarea, ) +from .constants import STATUS_CHOICES from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -185,7 +186,9 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm): class Meta: model = VirtualMachine - fields = ['name', 'cluster_group', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments'] + fields = [ + 'name', 'status', 'cluster_group', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + ] def __init__(self, *args, **kwargs): @@ -200,6 +203,11 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm): class VirtualMachineCSVForm(forms.ModelForm): + status = CSVChoiceField( + choices=STATUS_CHOICES, + required=False, + help_text='Operational status of device' + ) cluster = forms.ModelChoiceField( queryset=Cluster.objects.all(), to_field_name='name', @@ -229,11 +237,12 @@ class VirtualMachineCSVForm(forms.ModelForm): class Meta: model = VirtualMachine - fields = ['name', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments'] + fields = ['name', 'status', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments'] class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput) + status = forms.ChoiceField(choices=add_blank_choice(STATUS_CHOICES), required=False, initial='') cluster = forms.ModelChoiceField(queryset=Cluster.objects.all(), required=False) tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False) @@ -246,6 +255,13 @@ class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): nullable_fields = ['tenant', 'platform', 'vcpus', 'memory', 'disk'] +def vm_status_choices(): + status_counts = {} + for status in VirtualMachine.objects.values('status').annotate(count=Count('status')).order_by('status'): + status_counts[status['status']] = status['count'] + return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in STATUS_CHOICES] + + class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VirtualMachine q = forms.CharField(required=False, label='Search') @@ -258,6 +274,7 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm): queryset=Cluster.objects.annotate(filter_count=Count('virtual_machines')), label='Cluster' ) + status = forms.MultipleChoiceField(choices=vm_status_choices, required=False) # diff --git a/netbox/virtualization/migrations/0002_virtualmachine_add_status.py b/netbox/virtualization/migrations/0002_virtualmachine_add_status.py new file mode 100644 index 000000000..5b03b6e33 --- /dev/null +++ b/netbox/virtualization/migrations/0002_virtualmachine_add_status.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-14 17:49 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0001_virtualization'), + ] + + operations = [ + migrations.AddField( + model_name='virtualmachine', + name='status', + field=models.PositiveSmallIntegerField(choices=[[1, 'Active'], [0, 'Offline'], [3, 'Staged']], default=1, verbose_name='Status'), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index d1f949407..1501dabad 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -7,6 +7,7 @@ from django.utils.encoding import python_2_unicode_compatible from extras.models import CustomFieldModel, CustomFieldValue from utilities.models import CreatedUpdatedModel +from .constants import STATUS_ACTIVE, STATUS_CHOICES, VM_STATUS_CLASSES # @@ -139,6 +140,11 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): max_length=64, unique=True ) + status = models.PositiveSmallIntegerField( + choices=STATUS_CHOICES, + default=STATUS_ACTIVE, + verbose_name='Status' + ) primary_ip4 = models.OneToOneField( to='ipam.IPAddress', on_delete=models.SET_NULL, @@ -187,3 +193,6 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): def get_absolute_url(self): return reverse('virtualization:virtualmachine', args=[self.pk]) + + def get_status_class(self): + return VM_STATUS_CLASSES[self.status] diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 59f3f5393..28bf1c12a 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -20,6 +20,10 @@ CLUSTERGROUP_ACTIONS = """ {% endif %} """ +VIRTUALMACHINE_STATUS = """ +{{ record.get_status_display }} +""" + # # Cluster types @@ -79,12 +83,13 @@ class ClusterTable(BaseTable): class VirtualMachineTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn() + status = tables.TemplateColumn(template_code=VIRTUALMACHINE_STATUS) cluster = tables.LinkColumn('virtualization:cluster', args=[Accessor('cluster.pk')]) tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) class Meta(BaseTable.Meta): model = VirtualMachine - fields = ('pk', 'name', 'cluster', 'tenant', 'vcpus', 'memory', 'disk') + fields = ('pk', 'name', 'status', 'cluster', 'tenant', 'vcpus', 'memory', 'disk') # From 90d9eb3520b1937f6041787f567a3026d6b1ecea Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 15 Sep 2017 11:08:23 -0400 Subject: [PATCH 041/125] Closes #1490: Rearrange order of items in the virtualization nav menu --- netbox/templates/_base.html | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index ad8afb4b1..3a2cd85d4 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -205,20 +205,18 @@
    diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index 0cb06fa9c..41c5b474b 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -70,6 +70,10 @@ class VirtualMachineFilter(CustomFieldFilterSet): to_field_name='slug', label='Cluster group (slug)', ) + cluster_id = NullableModelMultipleChoiceFilter( + queryset=Cluster.objects.all(), + label='Cluster (ID)', + ) tenant_id = NullableModelMultipleChoiceFilter( queryset=Tenant.objects.all(), label='Tenant (ID)', From 039c2a6d579b3ce8a3bad53e71524e5d19eaf678 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 15 Sep 2017 11:47:29 -0400 Subject: [PATCH 043/125] Closes #1496: Added primary IP column to virtual machine list --- netbox/virtualization/tables.py | 16 ++++++++++++++++ netbox/virtualization/views.py | 4 ++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 28bf1c12a..7956f898e 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -24,6 +24,12 @@ VIRTUALMACHINE_STATUS = """ {{ record.get_status_display }} """ +VIRTUALMACHINE_PRIMARY_IP = """ +{{ record.primary_ip6.address.ip|default:"" }} +{% if record.primary_ip6 and record.primary_ip4 %}
    {% endif %} +{{ record.primary_ip4.address.ip|default:"" }} +""" + # # Cluster types @@ -92,6 +98,16 @@ class VirtualMachineTable(BaseTable): fields = ('pk', 'name', 'status', 'cluster', 'tenant', 'vcpus', 'memory', 'disk') +class VirtualMachineDetailTable(VirtualMachineTable): + primary_ip = tables.TemplateColumn( + orderable=False, verbose_name='IP Address', template_code=VIRTUALMACHINE_PRIMARY_IP + ) + + class Meta(BaseTable.Meta): + model = VirtualMachine + fields = ('pk', 'name', 'status', 'cluster', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip') + + # # VM components # diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 460715a41..0b490db9a 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -239,10 +239,10 @@ class ClusterRemoveDevicesView(PermissionRequiredMixin, View): # class VirtualMachineListView(ObjectListView): - queryset = VirtualMachine.objects.select_related('cluster', 'tenant') + queryset = VirtualMachine.objects.select_related('cluster', 'tenant', 'primary_ip4', 'primary_ip6') filter = filters.VirtualMachineFilter filter_form = forms.VirtualMachineFilterForm - table = tables.VirtualMachineTable + table = tables.VirtualMachineDetailTable template_name = 'virtualization/virtualmachine_list.html' From 5fc92ab0e6f0e63cb51a7879abe35e0f9a2dff5b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 15 Sep 2017 14:07:09 -0400 Subject: [PATCH 044/125] Removed old RPC-based LLDP neighbors code --- netbox/extras/rpc.py | 37 ------------------------------------- 1 file changed, 37 deletions(-) diff --git a/netbox/extras/rpc.py b/netbox/extras/rpc.py index 613bdb743..d2d4835ea 100644 --- a/netbox/extras/rpc.py +++ b/netbox/extras/rpc.py @@ -20,19 +20,6 @@ class RPCClient(object): except AttributeError: raise Exception("Specified device ({}) does not have a primary IP defined.".format(device)) - def get_lldp_neighbors(self): - """ - Returns a list of dictionaries, each representing an LLDP neighbor adjacency. - - { - 'local-interface': , - 'name': , - 'remote-interface': , - 'chassis-id': , - } - """ - raise NotImplementedError("Feature not implemented for this platform.") - def get_inventory(self): """ Returns a dictionary representing the device chassis and installed inventory items. @@ -123,30 +110,6 @@ class JunosNC(RPCClient): # Close the connection to the device self.manager.close_session() - def get_lldp_neighbors(self): - - rpc_reply = self.manager.dispatch('get-lldp-neighbors-information') - lldp_neighbors_raw = xmltodict.parse(rpc_reply.xml)['rpc-reply']['lldp-neighbors-information']['lldp-neighbor-information'] - - result = [] - for neighbor_raw in lldp_neighbors_raw: - neighbor = dict() - neighbor['local-interface'] = neighbor_raw.get('lldp-local-port-id') - name = neighbor_raw.get('lldp-remote-system-name') - if name: - neighbor['name'] = name.split('.')[0] # Split hostname from domain if one is present - else: - neighbor['name'] = '' - try: - neighbor['remote-interface'] = neighbor_raw['lldp-remote-port-description'] - except KeyError: - # Older versions of Junos report on interface ID instead of description - neighbor['remote-interface'] = neighbor_raw.get('lldp-remote-port-id') - neighbor['chassis-id'] = neighbor_raw.get('lldp-remote-chassis-id') - result.append(neighbor) - - return result - def get_inventory(self): def glean_items(node, depth=0): From e5e169f476563579616e3706b706db62a7451ca5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Sep 2017 12:54:50 -0400 Subject: [PATCH 045/125] Fixes #1504: Added missing vcpus/memory/disk fields to virtual machine API serializer --- netbox/virtualization/api/serializers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 6849a5396..fd0ace6a0 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -91,8 +91,8 @@ class VirtualMachineSerializer(CustomFieldModelSerializer): class Meta: model = VirtualMachine fields = [ - 'id', 'name', 'status', 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'comments', - 'custom_fields', + 'id', 'name', 'status', 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', + 'disk', 'comments', 'custom_fields', ] @@ -109,8 +109,8 @@ class WritableVirtualMachineSerializer(CustomFieldModelSerializer): class Meta: model = VirtualMachine fields = [ - 'id', 'name', 'status', 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'comments', - 'custom_fields', + 'id', 'name', 'status', 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', + 'disk', 'comments', 'custom_fields', ] From 9927ce14d3a439eb5889ad45b5d1f4f0c5a082be Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Sep 2017 13:12:58 -0400 Subject: [PATCH 046/125] Fixes #1502: Fixed CSV export for clusters and virtual machines --- netbox/virtualization/forms.py | 7 ++++--- netbox/virtualization/models.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 7fc033d4c..73d6e7445 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -76,16 +76,17 @@ class ClusterCSVForm(forms.ModelForm): class Meta: model = Cluster - fields = ['name', 'type', 'group'] + fields = ['name', 'type', 'group', 'comments'] class ClusterBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Cluster.objects.all(), widget=forms.MultipleHiddenInput) type = forms.ModelChoiceField(queryset=ClusterType.objects.all(), required=False) group = forms.ModelChoiceField(queryset=ClusterGroup.objects.all(), required=False) + comments = CommentField(widget=SmallTextarea) class Meta: - nullable_fields = ['group'] + nullable_fields = ['group', 'comments'] class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm): @@ -252,7 +253,7 @@ class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): comments = CommentField(widget=SmallTextarea) class Meta: - nullable_fields = ['tenant', 'platform', 'vcpus', 'memory', 'disk'] + nullable_fields = ['tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments'] def vm_status_choices(): diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 1501dabad..8dc241335 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -7,6 +7,7 @@ from django.utils.encoding import python_2_unicode_compatible from extras.models import CustomFieldModel, CustomFieldValue from utilities.models import CreatedUpdatedModel +from utilities.utils import csv_format from .constants import STATUS_ACTIVE, STATUS_CHOICES, VM_STATUS_CLASSES @@ -98,6 +99,10 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel): object_id_field='obj_id' ) + csv_headers = [ + 'name', 'type', 'group', 'comments', + ] + class Meta: ordering = ['name'] @@ -107,6 +112,14 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel): def get_absolute_url(self): return reverse('virtualization:cluster', args=[self.pk]) + def to_csv(self): + return csv_format([ + self.name, + self.type.name, + self.group.name if self.group else None, + self.comments, + ]) + # # Virtual machines @@ -185,6 +198,10 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): object_id_field='obj_id' ) + csv_headers = [ + 'name', 'status', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + ] + class Meta: ordering = ['name'] @@ -194,5 +211,18 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): def get_absolute_url(self): return reverse('virtualization:virtualmachine', args=[self.pk]) + def to_csv(self): + return csv_format([ + self.name, + self.get_status_display(), + self.cluster.name, + self.tenant.name if self.tenant else None, + self.platform.name if self.platform else None, + self.vcpus, + self.memory, + self.disk, + self.comments, + ]) + def get_status_class(self): return VM_STATUS_CLASSES[self.status] From 512a3bf09a8ed587a45cb26ff9c0087a72fa7fe0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Sep 2017 13:30:20 -0400 Subject: [PATCH 047/125] Fixed #1500: Allow assigning IP addresses to virtual machines during CSV bulk import --- netbox/ipam/forms.py | 60 +++++++++++++++++++++++++++++++++---------- netbox/ipam/models.py | 4 ++- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index f989ff3d2..1e4c3ed2d 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -13,6 +13,7 @@ from utilities.forms import ( ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm, SlugField, add_blank_choice, ) +from virtualization.models import VirtualMachine from .models import ( Aggregate, IPAddress, IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN, VLANGroup, VLAN_STATUS_CHOICES, VRF, @@ -566,6 +567,15 @@ class IPAddressCSVForm(forms.ModelForm): 'invalid_choice': 'Device not found.', } ) + virtual_machine = forms.ModelChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned virtual machine', + error_messages={ + 'invalid_choice': 'Virtual machine not found.', + } + ) interface_name = forms.CharField( help_text='Name of assigned interface', required=False @@ -577,30 +587,47 @@ class IPAddressCSVForm(forms.ModelForm): class Meta: model = IPAddress - fields = ['address', 'vrf', 'tenant', 'status', 'role', 'device', 'interface_name', 'is_primary', 'description'] + fields = [ + 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary', + 'description', + ] def clean(self): super(IPAddressCSVForm, self).clean() device = self.cleaned_data.get('device') + virtual_machine = self.cleaned_data.get('virtual_machine') interface_name = self.cleaned_data.get('interface_name') is_primary = self.cleaned_data.get('is_primary') # Validate interface - if device and interface_name: + if interface_name and device: try: self.instance.interface = Interface.objects.get(device=device, name=interface_name) except Interface.DoesNotExist: - raise forms.ValidationError("Invalid interface {} for device {}".format(interface_name, device)) - elif device and not interface_name: - raise forms.ValidationError("Device set ({}) but interface missing".format(device)) - elif interface_name and not device: - raise forms.ValidationError("Interface set ({}) but device missing or invalid".format(interface_name)) + raise forms.ValidationError("Invalid interface {} for device {}".format( + interface_name, device + )) + elif interface_name and virtual_machine: + try: + self.instance.interface = Interface.objects.get(virtual_machine=virtual_machine, name=interface_name) + except Interface.DoesNotExist: + raise forms.ValidationError("Invalid interface {} for virtual machine {}".format( + interface_name, virtual_machine + )) + elif interface_name: + raise forms.ValidationError("Interface given ({}) but parent device/virtual machine not specified".format( + interface_name + )) + elif device: + raise forms.ValidationError("Device specified ({}) but interface missing".format(device)) + elif virtual_machine: + raise forms.ValidationError("Virtual machine specified ({}) but interface missing".format(virtual_machine)) # Validate is_primary - if is_primary and not device: - raise forms.ValidationError("No device specified; cannot set as primary IP") + if is_primary and not device and not virtual_machine: + raise forms.ValidationError("No device or virtual machine specified; cannot set as primary IP") def save(self, *args, **kwargs): @@ -610,17 +637,22 @@ class IPAddressCSVForm(forms.ModelForm): device=self.cleaned_data['device'], name=self.cleaned_data['interface_name'] ) + elif self.cleaned_data['virtual_machine'] and self.cleaned_data['interface_name']: + self.instance.interface = Interface.objects.get( + virtual_machine=self.cleaned_data['virtual_machine'], + name=self.cleaned_data['interface_name'] + ) ipaddress = super(IPAddressCSVForm, self).save(*args, **kwargs) - # Set as primary for device + # Set as primary for device/VM if self.cleaned_data['is_primary']: - device = self.cleaned_data['device'] + parent = self.cleaned_data['device'] or self.cleaned_data['virtual_machine'] if self.instance.address.version == 4: - device.primary_ip4 = ipaddress + parent.primary_ip4 = ipaddress elif self.instance.address.version == 6: - device.primary_ip6 = ipaddress - device.save() + parent.primary_ip6 = ipaddress + parent.save() return ipaddress diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index f34d55db7..395af257c 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -420,7 +420,8 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): objects = IPAddressManager() csv_headers = [ - 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'interface_name', 'is_primary', 'description', + 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary', + 'description', ] class Meta: @@ -475,6 +476,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): self.get_status_display(), self.get_role_display(), self.device.identifier if self.device else None, + self.virtual_machine.name if self.device else None, self.interface.name if self.interface else None, is_primary, self.description, From 35b4e190d91487a0a08f7236df421adf5470a253 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 19 Sep 2017 10:40:38 -0400 Subject: [PATCH 048/125] Fixes #1508: Enabled creating custom fields for clusters and virtual machines via the admin UI --- netbox/extras/constants.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 86da90895..90edad18d 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -3,10 +3,11 @@ from __future__ import unicode_literals # Models which support custom fields CUSTOMFIELD_MODELS = ( + 'provider', 'circuit', # Circuits 'site', 'rack', 'devicetype', 'device', # DCIM 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', # IPAM - 'provider', 'circuit', # Circuits - 'tenant', # Tenants + 'tenant', # Tenancy + 'cluster', 'virtualmachine', # Virtualization ) # Custom field types From e93129f1ae010ef42e665e1cb0252bc132c3095f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 19 Sep 2017 11:31:29 -0400 Subject: [PATCH 049/125] Enabled export templates for clusters and virtual machines --- netbox/extras/admin.py | 47 +++++++++++++++++++++++++++++++++++--- netbox/extras/constants.py | 5 ++-- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 9d396dd3d..752a6ccaa 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -7,6 +7,18 @@ from django.utils.safestring import mark_safe from .models import CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction +def order_content_types(field): + """ + Order the list of available ContentTypes by application + """ + queryset = field.queryset.order_by('app_label', 'model') + field.choices = [(ct.pk, '{} > {}'.format(ct.app_label, ct.name)) for ct in queryset] + + +# +# Custom fields +# + class CustomFieldForm(forms.ModelForm): class Meta: @@ -16,9 +28,7 @@ class CustomFieldForm(forms.ModelForm): def __init__(self, *args, **kwargs): super(CustomFieldForm, self).__init__(*args, **kwargs) - # Organize the available ContentTypes - queryset = self.fields['obj_type'].queryset.order_by('app_label', 'model') - self.fields['obj_type'].choices = [(ct.pk, '{} > {}'.format(ct.app_label, ct.name)) for ct in queryset] + order_content_types(self.fields['obj_type']) class CustomFieldChoiceAdmin(admin.TabularInline): @@ -36,16 +46,43 @@ class CustomFieldAdmin(admin.ModelAdmin): return ', '.join([ct.name for ct in obj.obj_type.all()]) +# +# Graphs +# + @admin.register(Graph) class GraphAdmin(admin.ModelAdmin): list_display = ['name', 'type', 'weight', 'source'] +# +# Export templates +# + +class ExportTemplateForm(forms.ModelForm): + + class Meta: + model = ExportTemplate + exclude = [] + + def __init__(self, *args, **kwargs): + super(ExportTemplateForm, self).__init__(*args, **kwargs) + + # Format ContentType choices + order_content_types(self.fields['content_type']) + self.fields['content_type'].choices.insert(0, ('', '---------')) + + @admin.register(ExportTemplate) class ExportTemplateAdmin(admin.ModelAdmin): list_display = ['name', 'content_type', 'description', 'mime_type', 'file_extension'] + form = ExportTemplateForm +# +# Topology maps +# + @admin.register(TopologyMap) class TopologyMapAdmin(admin.ModelAdmin): list_display = ['name', 'slug', 'site'] @@ -54,6 +91,10 @@ class TopologyMapAdmin(admin.ModelAdmin): } +# +# User actions +# + @admin.register(UserAction) class UserActionAdmin(admin.ModelAdmin): actions = None diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 90edad18d..99eb779c6 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -38,10 +38,11 @@ GRAPH_TYPE_CHOICES = ( # Models which support export templates EXPORTTEMPLATE_MODELS = [ + 'provider', 'circuit', # Circuits 'site', 'rack', 'device', 'consoleport', 'powerport', 'interfaceconnection', # DCIM 'aggregate', 'prefix', 'ipaddress', 'vlan', # IPAM - 'provider', 'circuit', # Circuits - 'tenant', # Tenants + 'tenant', # Tenancy + 'cluster', 'virtualmachine', # Virtualization ] # User action types From 8f1607e01022485124f7f06494955562bd29af2c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 19 Sep 2017 17:47:42 -0400 Subject: [PATCH 050/125] Initial work on reports --- .gitignore | 2 + netbox/extras/constants.py | 14 +++ .../extras/management/commands/runreport.py | 47 ++++++++ netbox/extras/reports.py | 100 ++++++++++++++++++ netbox/reports/__init__.py | 0 5 files changed, 163 insertions(+) create mode 100644 netbox/extras/management/commands/runreport.py create mode 100644 netbox/extras/reports.py create mode 100644 netbox/reports/__init__.py diff --git a/.gitignore b/.gitignore index 2f957c678..b33d46a40 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ *.pyc /netbox/netbox/configuration.py /netbox/netbox/ldap_config.py +/netbox/reports/* +!/netbox/reports/__init__.py /netbox/static .idea /*.sh diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 99eb779c6..fc5786a64 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -62,3 +62,17 @@ ACTION_CHOICES = ( (ACTION_DELETE, 'deleted'), (ACTION_BULK_DELETE, 'bulk deleted'), ) + +# Report logging levels +LOG_DEFAULT = 0 +LOG_SUCCESS = 10 +LOG_INFO = 20 +LOG_WARNING = 30 +LOG_FAILURE = 40 +LOG_LEVEL_CODES = { + LOG_DEFAULT: 'default', + LOG_SUCCESS: 'success', + LOG_INFO: 'info', + LOG_WARNING: 'warning', + LOG_FAILURE: 'failure', +} diff --git a/netbox/extras/management/commands/runreport.py b/netbox/extras/management/commands/runreport.py new file mode 100644 index 000000000..d228771d2 --- /dev/null +++ b/netbox/extras/management/commands/runreport.py @@ -0,0 +1,47 @@ +from __future__ import unicode_literals +import importlib +import inspect + +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone + +from extras.reports import Report + + +class Command(BaseCommand): + help = "Run a report to validate data in NetBox" + + def add_arguments(self, parser): + parser.add_argument('reports', nargs='+', help="Report(s) to run") + # parser.add_argument('--verbose', action='store_true', default=False, help="Print all logs") + + def handle(self, *args, **options): + + # Gather all reports to be run + reports = [] + for module_name in options['reports']: + try: + report_module = importlib.import_module('reports.report_{}'.format(module_name)) + except ImportError: + self.stdout.write( + "Report '{}' not found. Ensure that the report has been saved as 'report_{}.py' in the reports " + "directory.".format(module_name, module_name) + ) + return + for name, cls in inspect.getmembers(report_module, inspect.isclass): + if cls in Report.__subclasses__(): + reports.append((name, cls)) + + # Run reports + for name, report in reports: + self.stdout.write("[{:%H:%M:%S}] Running report {}...".format(timezone.now(), name)) + report = report() + report.run() + status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS') + self.stdout.write("[{:%H:%M:%S}] {}: {}".format(timezone.now(), name, status)) + for test_name, attrs in report.results.items(): + self.stdout.write(" {}: {} success, {} info, {} warning, {} failed".format( + test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failed'] + )) + + self.stdout.write("[{:%H:%M:%S}] Finished".format(timezone.now())) diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py new file mode 100644 index 000000000..22169c6b8 --- /dev/null +++ b/netbox/extras/reports.py @@ -0,0 +1,100 @@ +from collections import OrderedDict + +from django.utils import timezone + +from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_LEVEL_CODES, LOG_SUCCESS, LOG_WARNING + + +class Report(object): + """ + NetBox users can extend this object to write custom reports to be used for validating data within NetBox. Each + report must have one or more test methods named `test_*`. + + The `results` attribute of a completed report will take the following form: + + { + 'test_bar': { + 'failures': 42, + 'log': [ + (, , , ), + ... + ] + }, + 'test_foo': { + 'failures': 0, + 'log': [ + (, , , ), + ... + ] + } + } + """ + results = OrderedDict() + active_test = None + failed = False + + def __init__(self): + + # Compile test methods and initialize results skeleton + test_methods = [] + for method in dir(self): + if method.startswith('test_') and callable(getattr(self, method)): + test_methods.append(method) + self.results[method] = OrderedDict([ + ('success', 0), + ('info', 0), + ('warning', 0), + ('failed', 0), + ('log', []), + ]) + if not test_methods: + raise Exception("A report must contain at least one test method.") + self.test_methods = test_methods + + def _log(self, obj, message, level=LOG_DEFAULT): + """ + Log a message from a test method. Do not call this method directly; use one of the log_* wrappers below. + """ + if level not in LOG_LEVEL_CODES: + raise Exception("Unknown logging level: {}".format(level)) + logline = [timezone.now(), level, obj, message] + self.results[self.active_test]['log'].append(logline) + + def log_success(self, obj, message=None): + """ + Record a successful test against an object. Logging a message is optional. + """ + if message: + self._log(obj, message, level=LOG_SUCCESS) + self.results[self.active_test]['success'] += 1 + + def log_info(self, obj, message): + """ + Log an informational message. + """ + self._log(obj, message, level=LOG_INFO) + self.results[self.active_test]['info'] += 1 + + def log_warning(self, obj, message): + """ + Log a warning. + """ + self._log(obj, message, level=LOG_WARNING) + self.results[self.active_test]['warning'] += 1 + + def log_failure(self, obj, message): + """ + Log a failure. Calling this method will automatically mark the report as failed. + """ + self._log(obj, message, level=LOG_FAILURE) + self.results[self.active_test]['failed'] += 1 + self.failed = True + + def run(self): + """ + Run the report. Each test method will be executed in order. + """ + for method_name in self.test_methods: + self.active_test = method_name + test_method = getattr(self, method_name) + test_method() diff --git a/netbox/reports/__init__.py b/netbox/reports/__init__.py new file mode 100644 index 000000000..e69de29bb From 2174ccf016b50a3e313836919a3bb23a639e2b83 Mon Sep 17 00:00:00 2001 From: johnhu Date: Wed, 20 Sep 2017 11:48:49 +0000 Subject: [PATCH 051/125] Fixes #1514: No field to edit Cluster comments --- netbox/virtualization/forms.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 73d6e7445..0d247c303 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -49,10 +49,11 @@ class ClusterGroupForm(BootstrapMixin, forms.ModelForm): # class ClusterForm(BootstrapMixin, CustomFieldForm): + comments = CommentField(widget=SmallTextarea) class Meta: model = Cluster - fields = ['name', 'type', 'group'] + fields = ['name', 'type', 'group', 'comments'] class ClusterCSVForm(forms.ModelForm): From d87023e1ce6f537ad6874e73f025adc21e1d8746 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 20 Sep 2017 11:47:37 -0400 Subject: [PATCH 052/125] Limited virtual machine interfaces to type "virtual" (removed LAG type) --- netbox/virtualization/forms.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 73d6e7445..2bbde1e8d 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -5,7 +5,7 @@ from mptt.forms import TreeNodeChoiceField from django import forms from django.db.models import Count -from dcim.constants import VIFACE_FF_CHOICES +from dcim.constants import IFACE_FF_VIRTUAL, VIFACE_FF_CHOICES from dcim.formfields import MACAddressFormField from dcim.models import Device, Interface, Platform, Rack, Region, Site from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm @@ -289,12 +289,13 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): fields = ['virtual_machine', 'name', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description'] widgets = { 'virtual_machine': forms.HiddenInput(), + 'form_factor': forms.HiddenInput(), } class InterfaceCreateForm(ComponentForm): name_pattern = ExpandableNameField(label='Name') - form_factor = forms.ChoiceField(choices=VIFACE_FF_CHOICES) + form_factor = forms.ChoiceField(choices=VIFACE_FF_CHOICES, initial=IFACE_FF_VIRTUAL, widget=forms.HiddenInput()) enabled = forms.BooleanField(required=False) mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') mac_address = MACAddressFormField(required=False, label='MAC Address') @@ -330,7 +331,7 @@ class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form): class VirtualMachineBulkAddInterfaceForm(VirtualMachineBulkAddComponentForm): - form_factor = forms.ChoiceField(choices=VIFACE_FF_CHOICES) + form_factor = forms.ChoiceField(choices=VIFACE_FF_CHOICES, initial=IFACE_FF_VIRTUAL, widget=forms.HiddenInput()) enabled = forms.BooleanField(required=False, initial=True) mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') description = forms.CharField(max_length=100, required=False) From 4cfad2ef3bae69002012b2d353315ec5ed5dabcf Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 20 Sep 2017 14:03:56 -0400 Subject: [PATCH 053/125] Added virtualization models to nbshell and fixed up status constants --- netbox/extras/management/commands/nbshell.py | 2 +- netbox/virtualization/constants.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/netbox/extras/management/commands/nbshell.py b/netbox/extras/management/commands/nbshell.py index a50b1384d..d016c7f57 100644 --- a/netbox/extras/management/commands/nbshell.py +++ b/netbox/extras/management/commands/nbshell.py @@ -11,7 +11,7 @@ from django.core.management.base import BaseCommand from django.db.models import Model -APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users'] +APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users', 'virtualization'] BANNER_TEXT = """### NetBox interactive shell ({node}) ### Python {python} | Django {django} | NetBox {netbox} diff --git a/netbox/virtualization/constants.py b/netbox/virtualization/constants.py index b05a541eb..f1d9e6c39 100644 --- a/netbox/virtualization/constants.py +++ b/netbox/virtualization/constants.py @@ -1,10 +1,9 @@ from __future__ import unicode_literals +from dcim.constants import STATUS_ACTIVE, STATUS_OFFLINE, STATUS_STAGED + # VirtualMachine statuses (replicated from Device statuses) -STATUS_OFFLINE = 0 -STATUS_ACTIVE = 1 -STATUS_STAGED = 3 STATUS_CHOICES = [ [STATUS_ACTIVE, 'Active'], [STATUS_OFFLINE, 'Offline'], From 16d1f9aca8a35553461e25faa5d67b10a17266e9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 Sep 2017 13:49:04 -0400 Subject: [PATCH 054/125] Tweaked report run logic --- .../extras/management/commands/runreport.py | 33 +++++++++++++------ netbox/extras/reports.py | 11 ++++--- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/netbox/extras/management/commands/runreport.py b/netbox/extras/management/commands/runreport.py index d228771d2..43f027842 100644 --- a/netbox/extras/management/commands/runreport.py +++ b/netbox/extras/management/commands/runreport.py @@ -20,26 +20,39 @@ class Command(BaseCommand): # Gather all reports to be run reports = [] for module_name in options['reports']: + + # Split the report name off if one has been provided. + report_name = None + if '.' in module_name: + module_name, report_name = module_name.split('.', 1) + + # Import the report module try: report_module = importlib.import_module('reports.report_{}'.format(module_name)) except ImportError: self.stdout.write( - "Report '{}' not found. Ensure that the report has been saved as 'report_{}.py' in the reports " - "directory.".format(module_name, module_name) + "Report module '{}' not found. Ensure that the report has been saved as 'report_{}.py' in the " + "reports directory.".format(module_name, module_name) ) return - for name, cls in inspect.getmembers(report_module, inspect.isclass): - if cls in Report.__subclasses__(): - reports.append((name, cls)) + + # If the name of a particular report has been given, run that. Otherwise, run all reports in the module. + if report_name is not None: + report_cls = getattr(report_module, report_name) + reports = [(report_name, report_cls)] + else: + for name, report_cls in inspect.getmembers(report_module, inspect.isclass): + if report_cls in Report.__subclasses__(): + reports.append((name, report_cls)) # Run reports - for name, report in reports: - self.stdout.write("[{:%H:%M:%S}] Running report {}...".format(timezone.now(), name)) - report = report() - report.run() + for name, report_cls in reports: + self.stdout.write("[{:%H:%M:%S}] Running {}...".format(timezone.now(), name)) + report = report_cls() + results = report.run() status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS') self.stdout.write("[{:%H:%M:%S}] {}: {}".format(timezone.now(), name, status)) - for test_name, attrs in report.results.items(): + for test_name, attrs in results.items(): self.stdout.write(" {}: {} success, {} info, {} warning, {} failed".format( test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failed'] )) diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 22169c6b8..e65ef46a2 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -29,12 +29,13 @@ class Report(object): } } """ - results = OrderedDict() - active_test = None - failed = False def __init__(self): + self.results = OrderedDict() + self.active_test = None + self.failed = False + # Compile test methods and initialize results skeleton test_methods = [] for method in dir(self): @@ -92,9 +93,11 @@ class Report(object): def run(self): """ - Run the report. Each test method will be executed in order. + Run the report and return its results. Each test method will be executed in order. """ for method_name in self.test_methods: self.active_test = method_name test_method = getattr(self, method_name) test_method() + + return self.results From b5ab498e75cbdc29b2fec92c5bcd4e3786bccc44 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 Sep 2017 16:32:05 -0400 Subject: [PATCH 055/125] Initial work on reports API --- netbox/extras/api/urls.py | 3 + netbox/extras/api/views.py | 27 ++++++- .../extras/management/commands/runreport.py | 72 ++++++++----------- netbox/extras/migrations/0008_reports.py | 33 +++++++++ netbox/extras/models.py | 21 ++++++ netbox/extras/reports.py | 34 +++++++++ netbox/extras/urls.py | 3 + netbox/extras/views.py | 32 ++++++++- netbox/templates/extras/report_list.html | 18 +++++ 9 files changed, 199 insertions(+), 44 deletions(-) create mode 100644 netbox/extras/migrations/0008_reports.py create mode 100644 netbox/templates/extras/report_list.html diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index c5268318c..da76e67bd 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -28,6 +28,9 @@ router.register(r'topology-maps', views.TopologyMapViewSet) # Image attachments router.register(r'image-attachments', views.ImageAttachmentViewSet) +# Reports +router.register(r'reports', views.ReportViewSet, base_name='report') + # Recent activity router.register(r'recent-activity', views.RecentActivityViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 37112f2c6..bf54a3220 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,7 +1,9 @@ from __future__ import unicode_literals +from collections import OrderedDict from rest_framework.decorators import detail_route -from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet, ViewSet from django.contrib.contenttypes.models import ContentType from django.http import HttpResponse @@ -9,6 +11,7 @@ from django.shortcuts import get_object_or_404 from extras import filters from extras.models import ExportTemplate, Graph, ImageAttachment, TopologyMap, UserAction +from extras.reports import get_reports from utilities.api import WritableSerializerMixin from . import serializers @@ -88,6 +91,28 @@ class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet): write_serializer_class = serializers.WritableImageAttachmentSerializer +class ReportViewSet(ViewSet): + _ignore_model_permissions = True + exclude_from_schema = True + + def list(self, request): + + ret_list = [] + for module_name, reports in get_reports(): + for report_name, report_cls in reports: + report = OrderedDict(( + ('module', module_name), + ('name', report_name), + ('description', report_cls.description), + ('test_methods', report_cls().test_methods), + )) + ret_list.append(report) + + return Response(ret_list) + + + + class RecentActivityViewSet(ReadOnlyModelViewSet): """ List all UserActions to provide a log of recent activity. diff --git a/netbox/extras/management/commands/runreport.py b/netbox/extras/management/commands/runreport.py index 43f027842..6c63eed40 100644 --- a/netbox/extras/management/commands/runreport.py +++ b/netbox/extras/management/commands/runreport.py @@ -1,11 +1,10 @@ from __future__ import unicode_literals -import importlib -import inspect -from django.core.management.base import BaseCommand, CommandError +from django.core.management.base import BaseCommand from django.utils import timezone -from extras.reports import Report +from extras.models import ReportResult +from extras.reports import get_reports class Command(BaseCommand): @@ -18,43 +17,34 @@ class Command(BaseCommand): def handle(self, *args, **options): # Gather all reports to be run - reports = [] - for module_name in options['reports']: - - # Split the report name off if one has been provided. - report_name = None - if '.' in module_name: - module_name, report_name = module_name.split('.', 1) - - # Import the report module - try: - report_module = importlib.import_module('reports.report_{}'.format(module_name)) - except ImportError: - self.stdout.write( - "Report module '{}' not found. Ensure that the report has been saved as 'report_{}.py' in the " - "reports directory.".format(module_name, module_name) - ) - return - - # If the name of a particular report has been given, run that. Otherwise, run all reports in the module. - if report_name is not None: - report_cls = getattr(report_module, report_name) - reports = [(report_name, report_cls)] - else: - for name, report_cls in inspect.getmembers(report_module, inspect.isclass): - if report_cls in Report.__subclasses__(): - reports.append((name, report_cls)) + reports = get_reports() # Run reports - for name, report_cls in reports: - self.stdout.write("[{:%H:%M:%S}] Running {}...".format(timezone.now(), name)) - report = report_cls() - results = report.run() - status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS') - self.stdout.write("[{:%H:%M:%S}] {}: {}".format(timezone.now(), name, status)) - for test_name, attrs in results.items(): - self.stdout.write(" {}: {} success, {} info, {} warning, {} failed".format( - test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failed'] - )) + for module_name, report in reports: + for report_name, report_cls in report: + report_name_full = '{}.{}'.format(module_name, report_name) + if module_name in options['reports'] or report_name_full in options['reports']: - self.stdout.write("[{:%H:%M:%S}] Finished".format(timezone.now())) + # Run the report + self.stdout.write( + "[{:%H:%M:%S}] Running {}.{}...".format(timezone.now(), module_name, report_name) + ) + report = report_cls() + results = report.run() + + # Report on success/failure + status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS') + for test_name, attrs in results.items(): + self.stdout.write( + "\t{}: {} success, {} info, {} warning, {} failed".format( + test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failed'] + ) + ) + self.stdout.write( + "[{:%H:%M:%S}] {}.{}: {}".format(timezone.now(), module_name, report_name, status) + ) + + # Wrap things up + self.stdout.write( + "[{:%H:%M:%S}] Finished".format(timezone.now()) + ) diff --git a/netbox/extras/migrations/0008_reports.py b/netbox/extras/migrations/0008_reports.py new file mode 100644 index 000000000..77f4c6501 --- /dev/null +++ b/netbox/extras/migrations/0008_reports.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-21 20:31 +from __future__ import unicode_literals + +from django.conf import settings +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('extras', '0007_unicode_literals'), + ] + + operations = [ + migrations.CreateModel( + name='ReportResult', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_created=True)), + ('report', models.CharField(max_length=255, unique=True)), + ('data', django.contrib.postgres.fields.jsonb.JSONField()), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['report'], + 'permissions': (('run_report', 'Run a report and save the results'),), + }, + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 4afc3afcf..413f9994d 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -6,6 +6,7 @@ import graphviz from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.fields import JSONField from django.core.validators import ValidationError from django.db import models from django.db.models import Q @@ -388,6 +389,26 @@ class ImageAttachment(models.Model): return None +# +# Report results +# + +class ReportResult(models.Model): + """ + This model stores the results from running a user-defined report. + """ + report = models.CharField(max_length=255, unique=True) + created = models.DateTimeField(auto_created=True) + user = models.ForeignKey(User, on_delete=models.SET_NULL, related_name='+', blank=True, null=True) + data = JSONField() + + class Meta: + ordering = ['report'] + permissions = ( + ('run_report', 'Run a report and save the results'), + ) + + # # User actions # diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index e65ef46a2..1f91930a6 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -1,8 +1,41 @@ from collections import OrderedDict +import importlib +import inspect +import pkgutil from django.utils import timezone from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_LEVEL_CODES, LOG_SUCCESS, LOG_WARNING +import reports as user_reports + + +def is_report(obj): + """ + Returns True if the given object is a Report. + """ + if obj in Report.__subclasses__(): + return True + return False + + +def get_reports(): + """ + Compile a list of all reports available across all modules in the reports path. + """ + module_list = [] + + # Iterate through all modules within the reports path + for importer, module_name, is_pkg in pkgutil.walk_packages(user_reports.__path__): + module = importlib.import_module('reports.{}'.format(module_name)) + report_list = [] + + # Iterate through all Report classes within the module + for report_name, report_cls in inspect.getmembers(module, is_report): + report_list.append((report_name, report_cls)) + + module_list.append((module_name, report_list)) + + return module_list class Report(object): @@ -29,6 +62,7 @@ class Report(object): } } """ + description = None def __init__(self): diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index f980158e8..3828d696e 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -12,4 +12,7 @@ urlpatterns = [ url(r'^image-attachments/(?P\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), url(r'^image-attachments/(?P\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), + # Reports + url(r'^reports/$', views.ReportListView.as_view(), name='report_list'), + ] diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 881525237..4692482ee 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,13 +1,21 @@ from __future__ import unicode_literals from django.contrib.auth.mixins import PermissionRequiredMixin -from django.shortcuts import get_object_or_404 +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.views.generic import View +from . import reports from utilities.views import ObjectDeleteView, ObjectEditView from .forms import ImageAttachmentForm -from .models import ImageAttachment +from .models import ImageAttachment, ReportResult +from .reports import get_reports +# +# Image attachments +# + class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'extras.change_imageattachment' model = ImageAttachment @@ -30,3 +38,23 @@ class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView): def get_return_url(self, request, imageattachment): return imageattachment.parent.get_absolute_url() + + +# +# Reports +# + +class ReportListView(View): + """ + Retrieve all of the available reports from disk and the recorded ReportResult (if any) for each. + """ + + def get(self, request): + + reports = get_reports() + results = {r.name: r for r in ReportResult.objects.all()} + + return render(request, 'extras/report_list.html', { + 'reports': reports, + 'results': results, + }) diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html new file mode 100644 index 000000000..04602869f --- /dev/null +++ b/netbox/templates/extras/report_list.html @@ -0,0 +1,18 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block content %} +

    Reports

    +
    +
    + {% for module, report_list in reports %} +

    {{ module|bettertitle }}

    +
      + {% for name, cls in report_list %} +
    • {{ name }}
    • + {% endfor %} +
    + {% endfor %} +
    +
    +{% endblock %} From 79fdf641c007a289110af3a2092f5be0e2121f8c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 Sep 2017 12:11:10 -0400 Subject: [PATCH 056/125] Implemented rough UI for accessing report results --- .../extras/management/commands/runreport.py | 4 ++ netbox/extras/migrations/0008_reports.py | 5 +- netbox/extras/models.py | 3 +- netbox/extras/reports.py | 19 +++++-- netbox/extras/views.py | 19 +++++-- netbox/templates/extras/report_list.html | 53 ++++++++++++++++--- 6 files changed, 85 insertions(+), 18 deletions(-) diff --git a/netbox/extras/management/commands/runreport.py b/netbox/extras/management/commands/runreport.py index 6c63eed40..acffdc54d 100644 --- a/netbox/extras/management/commands/runreport.py +++ b/netbox/extras/management/commands/runreport.py @@ -32,6 +32,10 @@ class Command(BaseCommand): report = report_cls() results = report.run() + # Record the results + ReportResult.objects.filter(report=report_name_full).delete() + ReportResult(report=report_name_full, failed=report.failed, data=results).save() + # Report on success/failure status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS') for test_name, attrs in results.items(): diff --git a/netbox/extras/migrations/0008_reports.py b/netbox/extras/migrations/0008_reports.py index 77f4c6501..0cfe48ba5 100644 --- a/netbox/extras/migrations/0008_reports.py +++ b/netbox/extras/migrations/0008_reports.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-09-21 20:31 +# Generated by Django 1.11.4 on 2017-09-22 15:21 from __future__ import unicode_literals from django.conf import settings @@ -20,8 +20,9 @@ class Migration(migrations.Migration): name='ReportResult', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', models.DateTimeField(auto_created=True)), ('report', models.CharField(max_length=255, unique=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('failed', models.BooleanField()), ('data', django.contrib.postgres.fields.jsonb.JSONField()), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), ], diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 413f9994d..d1eee12cb 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -398,8 +398,9 @@ class ReportResult(models.Model): This model stores the results from running a user-defined report. """ report = models.CharField(max_length=255, unique=True) - created = models.DateTimeField(auto_created=True) + created = models.DateTimeField(auto_now_add=True) user = models.ForeignKey(User, on_delete=models.SET_NULL, related_name='+', blank=True, null=True) + failed = models.BooleanField() data = JSONField() class Meta: diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 1f91930a6..99f42d9b8 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -20,7 +20,18 @@ def is_report(obj): def get_reports(): """ - Compile a list of all reports available across all modules in the reports path. + Compile a list of all reports available across all modules in the reports path. Returns a list of tuples: + + [ + (module_name, ( + (report_name, report_class), + (report_name, report_class) + ), + (module_name, ( + (report_name, report_class), + (report_name, report_class) + ) + ] """ module_list = [] @@ -30,8 +41,8 @@ def get_reports(): report_list = [] # Iterate through all Report classes within the module - for report_name, report_cls in inspect.getmembers(module, is_report): - report_list.append((report_name, report_cls)) + for report_name, report_class in inspect.getmembers(module, is_report): + report_list.append((report_name, report_class)) module_list.append((module_name, report_list)) @@ -92,7 +103,7 @@ class Report(object): """ if level not in LOG_LEVEL_CODES: raise Exception("Unknown logging level: {}".format(level)) - logline = [timezone.now(), level, obj, message] + logline = [timezone.now().isoformat(), level, str(obj), message] self.results[self.active_test]['log'].append(logline) def log_success(self, obj, message=None): diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 4692482ee..d39d8c17c 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,7 +1,8 @@ from __future__ import unicode_literals +from collections import OrderedDict from django.contrib.auth.mixins import PermissionRequiredMixin -from django.shortcuts import get_object_or_404, redirect, render +from django.shortcuts import get_object_or_404, render from django.urls import reverse from django.views.generic import View @@ -52,9 +53,19 @@ class ReportListView(View): def get(self, request): reports = get_reports() - results = {r.name: r for r in ReportResult.objects.all()} + results = {r.report: r for r in ReportResult.objects.all()} + + foo = [] + for module, report_list in reports: + module_reports = [] + for report_name, report_class in report_list: + module_reports.append({ + 'name': report_name, + 'description': report_class.description, + 'results': results.get('{}.{}'.format(module, report_name), None) + }) + foo.append((module, module_reports)) return render(request, 'extras/report_list.html', { - 'reports': reports, - 'results': results, + 'reports': foo, }) diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index 04602869f..1b987c520 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -5,13 +5,52 @@

    Reports

    - {% for module, report_list in reports %} -

    {{ module|bettertitle }}

    -
      - {% for name, cls in report_list %} -
    • {{ name }}
    • - {% endfor %} -
    + {% for module, module_reports in reports %} +

    {{ module|bettertitle }}

    + + + + + + + + + + + {% for report in module_reports %} + + + + {% if report.results %} + + + {% else %} + + + {% endif %} + + {% for method, stats in report.results.data.items %} + + + + {% endfor %} + {% endfor %} + +
    NameDescriptionLast RunStatus
    {{ report.name }}{{ report.description|default:"" }}{{ report.results.created }} + {% if report.results.failed %} + + {% else %} + + {% endif %} + Never
    +
    + + + + +
    + {{ method }} +
    {% endfor %}
    From 2ca161f3d833ab9d76329c6923b9cbe70471f293 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 Sep 2017 12:53:09 -0400 Subject: [PATCH 057/125] Closes #1509: Extended cluster model to allow site assignment --- netbox/dcim/models.py | 6 +++ netbox/templates/virtualization/cluster.html | 10 +++++ netbox/virtualization/api/serializers.py | 7 +-- netbox/virtualization/filters.py | 12 ++++- netbox/virtualization/forms.py | 45 ++++++++++++++++--- .../migrations/0003_cluster_add_site.py | 22 +++++++++ netbox/virtualization/models.py | 23 +++++++++- netbox/virtualization/tables.py | 2 +- netbox/virtualization/views.py | 4 +- 9 files changed, 117 insertions(+), 14 deletions(-) create mode 100644 netbox/virtualization/migrations/0003_cluster_add_site.py diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index b6c726863..73ec6c622 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -921,6 +921,12 @@ class Device(CreatedUpdatedModel, CustomFieldModel): 'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip6), }) + # A Device can only be assigned to a Cluster in the same Site (or no Site) + if self.cluster and self.cluster.site is not None and self.cluster.site != self.site: + raise ValidationError({ + 'cluster': "The assigned cluster belongs to a different site ({})".format(self.cluster.site) + }) + def save(self, *args, **kwargs): is_new = not bool(self.pk) diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index 6d7d43524..08251e2fa 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -66,6 +66,16 @@ {% endif %} + + Site + + {% if cluster.site %} + {{ cluster.site }} + {% else %} + None + {% endif %} + + Virtual Machines {{ cluster.virtual_machines.count }} diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index fd0ace6a0..b85495d83 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals from rest_framework import serializers -from dcim.api.serializers import NestedPlatformSerializer +from dcim.api.serializers import NestedPlatformSerializer, NestedSiteSerializer from dcim.constants import VIFACE_FF_CHOICES from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer @@ -57,10 +57,11 @@ class NestedClusterGroupSerializer(serializers.ModelSerializer): class ClusterSerializer(CustomFieldModelSerializer): type = NestedClusterTypeSerializer() group = NestedClusterGroupSerializer() + site = NestedSiteSerializer() class Meta: model = Cluster - fields = ['id', 'name', 'type', 'group', 'comments', 'custom_fields'] + fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields'] class NestedClusterSerializer(serializers.ModelSerializer): @@ -75,7 +76,7 @@ class WritableClusterSerializer(CustomFieldModelSerializer): class Meta: model = Cluster - fields = ['id', 'name', 'type', 'group', 'comments', 'custom_fields'] + fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields'] # diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index 41c5b474b..ea7686a23 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import django_filters from django.db.models import Q -from dcim.models import Platform +from dcim.models import Platform, Site from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter @@ -36,6 +36,16 @@ class ClusterFilter(CustomFieldFilterSet): to_field_name='slug', label='Cluster type (slug)', ) + site_id = django_filters.ModelMultipleChoiceFilter( + queryset=Site.objects.all(), + label='Site (ID)', + ) + site = django_filters.ModelMultipleChoiceFilter( + name='site__slug', + queryset=Site.objects.all(), + to_field_name='slug', + label='Site (slug)', + ) class Meta: model = Cluster diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index ca39a43ce..bcae2f842 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals from mptt.forms import TreeNodeChoiceField from django import forms +from django.core.exceptions import ValidationError from django.db.models import Count from dcim.constants import IFACE_FF_VIRTUAL, VIFACE_FF_CHOICES @@ -53,7 +54,7 @@ class ClusterForm(BootstrapMixin, CustomFieldForm): class Meta: model = Cluster - fields = ['name', 'type', 'group', 'comments'] + fields = ['name', 'type', 'group', 'site', 'comments'] class ClusterCSVForm(forms.ModelForm): @@ -74,34 +75,50 @@ class ClusterCSVForm(forms.ModelForm): 'invalid_choice': 'Invalid cluster group name.', } ) + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + required=False, + help_text='Name of assigned site', + error_messages={ + 'invalid_choice': 'Invalid site name.', + } + ) class Meta: model = Cluster - fields = ['name', 'type', 'group', 'comments'] + fields = ['name', 'type', 'group', 'site', 'comments'] class ClusterBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Cluster.objects.all(), widget=forms.MultipleHiddenInput) type = forms.ModelChoiceField(queryset=ClusterType.objects.all(), required=False) group = forms.ModelChoiceField(queryset=ClusterGroup.objects.all(), required=False) + site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) comments = CommentField(widget=SmallTextarea) class Meta: - nullable_fields = ['group', 'comments'] + nullable_fields = ['group', 'site', 'comments'] class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Cluster q = forms.CharField(required=False, label='Search') + type = FilterChoiceField( + queryset=ClusterType.objects.annotate(filter_count=Count('clusters')), + to_field_name='slug', + required=False, + ) group = FilterChoiceField( queryset=ClusterGroup.objects.annotate(filter_count=Count('clusters')), to_field_name='slug', null_option=(0, 'None'), required=False, ) - type = FilterChoiceField( - queryset=ClusterType.objects.annotate(filter_count=Count('clusters')), + site = FilterChoiceField( + queryset=Site.objects.annotate(filter_count=Count('clusters')), to_field_name='slug', + null_option=(0, 'None'), required=False, ) @@ -153,12 +170,28 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): class Meta: fields = ['region', 'site', 'rack', 'devices'] - def __init__(self, *args, **kwargs): + def __init__(self, cluster, *args, **kwargs): + + self.cluster = cluster super(ClusterAddDevicesForm, self).__init__(*args, **kwargs) self.fields['devices'].choices = [] + def clean(self): + + super(ClusterAddDevicesForm, self).clean() + + # If the Cluster is assigned to a Site, all Devices must be assigned to that Site. + if self.cluster.site is not None: + for device in self.cleaned_data.get('devices'): + if device.site != self.cluster.site: + raise ValidationError({ + 'devices': "{} belongs to a different site ({}) than the cluster ({})".format( + device, device.site, self.cluster.site + ) + }) + class ClusterRemoveDevicesForm(ConfirmationForm): pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) diff --git a/netbox/virtualization/migrations/0003_cluster_add_site.py b/netbox/virtualization/migrations/0003_cluster_add_site.py new file mode 100644 index 000000000..5ac3c578b --- /dev/null +++ b/netbox/virtualization/migrations/0003_cluster_add_site.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-22 16:30 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0044_virtualization'), + ('virtualization', '0002_virtualmachine_add_status'), + ] + + operations = [ + migrations.AddField( + model_name='cluster', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='clusters', to='dcim.Site'), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 8dc241335..edf0385d4 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -1,10 +1,12 @@ from __future__ import unicode_literals from django.contrib.contenttypes.fields import GenericRelation +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible +from dcim.models import Device from extras.models import CustomFieldModel, CustomFieldValue from utilities.models import CreatedUpdatedModel from utilities.utils import csv_format @@ -90,6 +92,13 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel): blank=True, null=True ) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='clusters', + blank=True, + null=True + ) comments = models.TextField( blank=True ) @@ -100,7 +109,7 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel): ) csv_headers = [ - 'name', 'type', 'group', 'comments', + 'name', 'type', 'group', 'site', 'comments', ] class Meta: @@ -112,6 +121,18 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel): def get_absolute_url(self): return reverse('virtualization:cluster', args=[self.pk]) + def clean(self): + + # If the Cluster is assigned to a Site, verify that all host Devices belong to that Site. + if self.pk and self.site: + nonsite_devices = Device.objects.filter(cluster=self).exclude(site=self.site).count() + if nonsite_devices: + raise ValidationError({ + 'site': "{} devices are assigned as hosts for this cluster but are not in site {}".format( + nonsite_devices, self.site + ) + }) + def to_csv(self): return csv_format([ self.name, diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 7956f898e..21314b51c 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -79,7 +79,7 @@ class ClusterTable(BaseTable): class Meta(BaseTable.Meta): model = Cluster - fields = ('pk', 'name', 'type', 'group', 'device_count', 'vm_count') + fields = ('pk', 'name', 'type', 'group', 'site', 'device_count', 'vm_count') # diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 0b490db9a..6dcb685d6 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -166,7 +166,7 @@ class ClusterAddDevicesView(PermissionRequiredMixin, View): def get(self, request, pk): cluster = get_object_or_404(Cluster, pk=pk) - form = self.form() + form = self.form(cluster) return render(request, self.template_name, { 'cluster': cluster, @@ -177,7 +177,7 @@ class ClusterAddDevicesView(PermissionRequiredMixin, View): def post(self, request, pk): cluster = get_object_or_404(Cluster, pk=pk) - form = self.form(request.POST) + form = self.form(cluster, request.POST) if form.is_valid(): From 88c57d002d6d499e5f801c4d068c3d6695565b23 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 25 Sep 2017 16:22:50 -0400 Subject: [PATCH 058/125] Added navigation panel --- netbox/templates/extras/inc/report_label.html | 7 ++++ netbox/templates/extras/report_list.html | 35 ++++++++++++------- 2 files changed, 29 insertions(+), 13 deletions(-) create mode 100644 netbox/templates/extras/inc/report_label.html diff --git a/netbox/templates/extras/inc/report_label.html b/netbox/templates/extras/inc/report_label.html new file mode 100644 index 000000000..8e2a2a5b5 --- /dev/null +++ b/netbox/templates/extras/inc/report_label.html @@ -0,0 +1,7 @@ +{% if report.results.failed %} + +{% elif report.results %} + +{% else %} + +{% endif %} diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index 1b987c520..492da8b75 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -2,12 +2,12 @@ {% load helpers %} {% block content %} -

    Reports

    +

    {% block title %}Reports{% endblock %}

    {% for module, module_reports in reports %} -

    {{ module|bettertitle }}

    - +

    {{ module|bettertitle }}

    +
    @@ -19,25 +19,18 @@ {% for report in module_reports %} - + {% if report.results %} - {% else %} - {% endif %} + {% for method, stats in report.results.data.items %} - + {% endfor %} {% endfor %} @@ -53,5 +47,20 @@
    Name
    {{ report.name }}{{ report.name }} {{ report.description|default:"" }}{{ report.results.created }} - {% if report.results.failed %} - - {% else %} - - {% endif %} - Never{% include 'extras/inc/report_label.html' %}
    +
    @@ -46,6 +39,7 @@
    {{ method }}
    {% endfor %}
    +
    + +
    {% endblock %} From d35a2b0faab897998bde0510f597f433c181700c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 25 Sep 2017 17:27:58 -0400 Subject: [PATCH 059/125] Extended reports API --- netbox/extras/api/serializers.py | 32 +++++++- netbox/extras/api/views.py | 78 +++++++++++++++---- .../extras/management/commands/runreport.py | 6 +- netbox/extras/reports.py | 8 ++ 4 files changed, 107 insertions(+), 17 deletions(-) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 0eeab49ec..34e4312ac 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -7,7 +7,7 @@ from rest_framework import serializers from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer from dcim.models import Device, Rack, Site from extras.models import ( - ACTION_CHOICES, ExportTemplate, Graph, GRAPH_TYPE_CHOICES, ImageAttachment, TopologyMap, UserAction, + ACTION_CHOICES, ExportTemplate, Graph, GRAPH_TYPE_CHOICES, ImageAttachment, ReportResult, TopologyMap, UserAction, ) from users.api.serializers import NestedUserSerializer from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer, ValidatedModelSerializer @@ -127,6 +127,36 @@ class WritableImageAttachmentSerializer(ValidatedModelSerializer): return data +# +# Reports +# + +class ReportResultSerializer(serializers.ModelSerializer): + + class Meta: + model = ReportResult + fields = ['created', 'user', 'failed', 'data'] + + +class NestedReportResultSerializer(serializers.ModelSerializer): + + class Meta: + model = ReportResult + fields = ['created', 'user', 'failed'] + + +class ReportSerializer(serializers.Serializer): + module = serializers.CharField(max_length=255) + name = serializers.CharField(max_length=255) + description = serializers.CharField(max_length=255, required=False) + test_methods = serializers.ListField(child=serializers.CharField(max_length=255)) + result = NestedReportResultSerializer() + + +class ReportDetailSerializer(ReportSerializer): + result = ReportResultSerializer() + + # # User actions # diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index bf54a3220..27a806155 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,17 +1,16 @@ from __future__ import unicode_literals -from collections import OrderedDict from rest_framework.decorators import detail_route from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet, ViewSet from django.contrib.contenttypes.models import ContentType -from django.http import HttpResponse +from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 from extras import filters -from extras.models import ExportTemplate, Graph, ImageAttachment, TopologyMap, UserAction -from extras.reports import get_reports +from extras.models import ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction +from extras.reports import get_report, get_reports from utilities.api import WritableSerializerMixin from . import serializers @@ -94,23 +93,76 @@ class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet): class ReportViewSet(ViewSet): _ignore_model_permissions = True exclude_from_schema = True + lookup_value_regex = '[^/]+' # Allow dots def list(self, request): - ret_list = [] + # Compile all reports + report_list = [] for module_name, reports in get_reports(): for report_name, report_cls in reports: - report = OrderedDict(( - ('module', module_name), - ('name', report_name), - ('description', report_cls.description), - ('test_methods', report_cls().test_methods), - )) - ret_list.append(report) + data = { + 'module': module_name, + 'name': report_name, + 'description': report_cls.description, + 'test_methods': report_cls().test_methods, + 'result': None, + } + try: + result = ReportResult.objects.defer('data').get(report='{}.{}'.format(module_name, report_name)) + data['result'] = result + except ReportResult.DoesNotExist: + pass + report_list.append(data) - return Response(ret_list) + serializer = serializers.ReportSerializer(report_list, many=True, context={'request': request}) + return Response(serializer.data) + def retrieve(self, request, pk): + + # Retrieve report by . + if '.' not in pk: + raise Http404 + module_name, report_name = pk.split('.', 1) + report_cls = get_report(module_name, report_name) + data = { + 'module': module_name, + 'name': report_name, + 'description': report_cls.description, + 'test_methods': report_cls().test_methods, + 'result': None, + } + + # Attach report result + try: + result = ReportResult.objects.get(report='{}.{}'.format(module_name, report_name)) + data['result'] = result + except ReportResult.DoesNotExist: + pass + + serializer = serializers.ReportDetailSerializer(data) + + return Response(serializer.data) + + @detail_route() + def run(self, request, pk): + + # Retrieve report by . + if '.' not in pk: + raise Http404 + module_name, report_name = pk.split('.', 1) + report_cls = get_report(module_name, report_name) + + # Run the report + report = report_cls() + result = report.run() + + # Save the ReportResult + ReportResult.objects.filter(report=pk).delete() + ReportResult(report=pk, failed=report.failed, data=result).save() + + return Response('Report completed.') class RecentActivityViewSet(ReadOnlyModelViewSet): diff --git a/netbox/extras/management/commands/runreport.py b/netbox/extras/management/commands/runreport.py index acffdc54d..70893b934 100644 --- a/netbox/extras/management/commands/runreport.py +++ b/netbox/extras/management/commands/runreport.py @@ -30,15 +30,15 @@ class Command(BaseCommand): "[{:%H:%M:%S}] Running {}.{}...".format(timezone.now(), module_name, report_name) ) report = report_cls() - results = report.run() + result = report.run() # Record the results ReportResult.objects.filter(report=report_name_full).delete() - ReportResult(report=report_name_full, failed=report.failed, data=results).save() + ReportResult(report=report_name_full, failed=report.failed, data=result).save() # Report on success/failure status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS') - for test_name, attrs in results.items(): + for test_name, attrs in result.items(): self.stdout.write( "\t{}: {} success, {} info, {} warning, {} failed".format( test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failed'] diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 99f42d9b8..1ded211ed 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -18,6 +18,14 @@ def is_report(obj): return False +def get_report(module_name, report_name): + """ + Return a specific report from within a module. + """ + module = importlib.import_module('reports.{}'.format(module_name)) + return getattr(module, report_name) + + def get_reports(): """ Compile a list of all reports available across all modules in the reports path. Returns a list of tuples: From a3a77a0bb6030e9780c9e24f5e5413374b291e83 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 26 Sep 2017 15:45:14 -0400 Subject: [PATCH 060/125] Fixes #1540: Added missing 'cluster' field to WritableDeviceSerializer --- netbox/dcim/api/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 9f9b0f6a6..c08c0f65b 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -496,7 +496,7 @@ class WritableDeviceSerializer(CustomFieldModelSerializer): model = Device fields = [ 'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack', - 'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'comments', 'custom_fields', + 'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'cluster', 'comments', 'custom_fields', ] validators = [] From 3395b510868a66c7e31e0a7bc589040b225b323a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 26 Sep 2017 16:36:43 -0400 Subject: [PATCH 061/125] Cleaned up the API quite a bit --- netbox/extras/api/views.py | 93 ++++++++++++++++++-------------------- netbox/extras/reports.py | 40 ++++++++-------- netbox/extras/views.py | 8 ++-- 3 files changed, 70 insertions(+), 71 deletions(-) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 27a806155..65e1e5182 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -95,74 +95,69 @@ class ReportViewSet(ViewSet): exclude_from_schema = True lookup_value_regex = '[^/]+' # Allow dots + def _retrieve_report(self, pk): + + # Read the PK as "." + if '.' not in pk: + raise Http404 + module_name, report_name = pk.split('.', 1) + + # Raise a 404 on an invalid Report module/name + report = get_report(module_name, report_name) + if report is None: + raise Http404 + + return report + def list(self, request): - - # Compile all reports + """ + Compile all reports and their related results (if any). Result data is deferred in the list view. + """ report_list = [] - for module_name, reports in get_reports(): - for report_name, report_cls in reports: - data = { - 'module': module_name, - 'name': report_name, - 'description': report_cls.description, - 'test_methods': report_cls().test_methods, - 'result': None, - } - try: - result = ReportResult.objects.defer('data').get(report='{}.{}'.format(module_name, report_name)) - data['result'] = result - except ReportResult.DoesNotExist: - pass - report_list.append(data) - serializer = serializers.ReportSerializer(report_list, many=True, context={'request': request}) + # Iterate through all available Reports. + for module_name, reports in get_reports(): + for report in reports: + + # Attach the relevant ReportResult (if any) to each Report. + report.result = ReportResult.objects.filter(report=report.full_name).defer('data').first() + report_list.append(report) + + serializer = serializers.ReportSerializer(report_list, many=True) return Response(serializer.data) def retrieve(self, request, pk): + """ + Retrieve a single Report identified as ".". + """ - # Retrieve report by . - if '.' not in pk: - raise Http404 - module_name, report_name = pk.split('.', 1) - report_cls = get_report(module_name, report_name) - data = { - 'module': module_name, - 'name': report_name, - 'description': report_cls.description, - 'test_methods': report_cls().test_methods, - 'result': None, - } + # Retrieve the Report and ReportResult, if any. + report = self._retrieve_report(pk) + report.result = ReportResult.objects.filter(report=report.full_name).first() - # Attach report result - try: - result = ReportResult.objects.get(report='{}.{}'.format(module_name, report_name)) - data['result'] = result - except ReportResult.DoesNotExist: - pass - - serializer = serializers.ReportDetailSerializer(data) + serializer = serializers.ReportDetailSerializer(report) return Response(serializer.data) @detail_route() def run(self, request, pk): + """ + Run a Report and create a new ReportResult, overwriting any previous result for the Report. + """ - # Retrieve report by . - if '.' not in pk: - raise Http404 - module_name, report_name = pk.split('.', 1) - report_cls = get_report(module_name, report_name) - - # Run the report - report = report_cls() + # Retrieve and run the Report. + report = self._retrieve_report(pk) result = report.run() - # Save the ReportResult + # Delete the old ReportResult (if any) and save the new one. ReportResult.objects.filter(report=pk).delete() - ReportResult(report=pk, failed=report.failed, data=result).save() + report.result = ReportResult(report=pk, failed=report.failed, data=result) + report.result.save() - return Response('Report completed.') + serializer = serializers.ReportDetailSerializer(report) + + return Response(serializer.data) class RecentActivityViewSet(ReadOnlyModelViewSet): diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 1ded211ed..e5fa24553 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -6,7 +6,7 @@ import pkgutil from django.utils import timezone from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_LEVEL_CODES, LOG_SUCCESS, LOG_WARNING -import reports as user_reports +import reports as custom_reports def is_report(obj): @@ -23,7 +23,8 @@ def get_report(module_name, report_name): Return a specific report from within a module. """ module = importlib.import_module('reports.{}'.format(module_name)) - return getattr(module, report_name) + report = getattr(module, report_name, None) + return report() def get_reports(): @@ -31,27 +32,18 @@ def get_reports(): Compile a list of all reports available across all modules in the reports path. Returns a list of tuples: [ - (module_name, ( - (report_name, report_class), - (report_name, report_class) - ), - (module_name, ( - (report_name, report_class), - (report_name, report_class) - ) + (module_name, (report_class, report_class, report_class, ...)), + (module_name, (report_class, report_class, report_class, ...)), + ... ] """ module_list = [] - # Iterate through all modules within the reports path - for importer, module_name, is_pkg in pkgutil.walk_packages(user_reports.__path__): + # Iterate through all modules within the reports path. These are the user-defined files in which reports are + # defined. + for importer, module_name, is_pkg in pkgutil.walk_packages(custom_reports.__path__): module = importlib.import_module('reports.{}'.format(module_name)) - report_list = [] - - # Iterate through all Report classes within the module - for report_name, report_class in inspect.getmembers(module, is_report): - report_list.append((report_name, report_class)) - + report_list = [cls() for _, cls in inspect.getmembers(module, is_report)] module_list.append((module_name, report_list)) return module_list @@ -105,6 +97,18 @@ class Report(object): raise Exception("A report must contain at least one test method.") self.test_methods = test_methods + @property + def module(self): + return self.__module__.rsplit('.', 1)[1] + + @property + def name(self): + return self.__class__.__name__ + + @property + def full_name(self): + return '.'.join([self.module, self.name]) + def _log(self, obj, message, level=LOG_DEFAULT): """ Log a message from a test method. Do not call this method directly; use one of the log_* wrappers below. diff --git a/netbox/extras/views.py b/netbox/extras/views.py index d39d8c17c..d9549bd19 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -58,11 +58,11 @@ class ReportListView(View): foo = [] for module, report_list in reports: module_reports = [] - for report_name, report_class in report_list: + for report in report_list: module_reports.append({ - 'name': report_name, - 'description': report_class.description, - 'results': results.get('{}.{}'.format(module, report_name), None) + 'name': report.name, + 'description': report.description, + 'results': results.get(report.full_name, None) }) foo.append((module, module_reports)) From 9a1781e6e799018b1f2f288e9d82124d4b0c3708 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 26 Sep 2017 16:55:25 -0400 Subject: [PATCH 062/125] Added url field for nested report results --- netbox/extras/api/serializers.py | 7 ++++++- netbox/extras/api/views.py | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 34e4312ac..8998b509b 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -139,10 +139,15 @@ class ReportResultSerializer(serializers.ModelSerializer): class NestedReportResultSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='extras-api:report-detail', + lookup_field='report', + lookup_url_kwarg='pk' + ) class Meta: model = ReportResult - fields = ['created', 'user', 'failed'] + fields = ['url', 'created', 'user', 'failed'] class ReportSerializer(serializers.Serializer): diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 65e1e5182..6aaa8ba4b 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -123,7 +123,9 @@ class ReportViewSet(ViewSet): report.result = ReportResult.objects.filter(report=report.full_name).defer('data').first() report_list.append(report) - serializer = serializers.ReportSerializer(report_list, many=True) + serializer = serializers.ReportSerializer(report_list, many=True, context={ + 'request': request, + }) return Response(serializer.data) From 696d91daa3d46fd395594bd36c6f00069850e5d5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 26 Sep 2017 17:17:28 -0400 Subject: [PATCH 063/125] Prettied up the reports list --- netbox/project-static/css/base.css | 12 ++++++++++ netbox/templates/extras/report_list.html | 30 ++++++++++++++---------- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 0f6b24077..ce6a8e2d8 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -338,6 +338,18 @@ table.component-list tr.ipaddress:hover td { background-color: #e6f7f7; } +/* Reports */ +table.reports td.method { + font-family: monospace; + padding-left: 30px; +} +table.reports td.stats label { + display: inline-block; + line-height: 14px; + margin-bottom: 0; + min-width: 40px; +} + /* AJAX loader */ .loading { position: fixed; diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index 492da8b75..947e22fb5 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -7,39 +7,43 @@
    {% for module, module_reports in reports %}

    {{ module|bettertitle }}

    - +
    + - {% for report in module_reports %} - + + {% if report.results %} {% else %} {% endif %} - {% for method, stats in report.results.data.items %} - + - {% endfor %} {% endfor %} From f4c87b37396eae003921c2c6dc1a3db5fa7ed1e4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 26 Sep 2017 17:31:16 -0400 Subject: [PATCH 064/125] Removed custom permission --- netbox/extras/api/views.py | 5 +++++ netbox/extras/migrations/0008_reports.py | 3 +-- netbox/extras/models.py | 3 --- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 6aaa8ba4b..216ea42a0 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals from rest_framework.decorators import detail_route +from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet, ViewSet @@ -148,6 +149,10 @@ class ReportViewSet(ViewSet): Run a Report and create a new ReportResult, overwriting any previous result for the Report. """ + # Check that the user has permission to run reports. + if not request.user.has_perm('extras.add_reportresult'): + raise PermissionDenied("This user does not have permission to run reports.") + # Retrieve and run the Report. report = self._retrieve_report(pk) result = report.run() diff --git a/netbox/extras/migrations/0008_reports.py b/netbox/extras/migrations/0008_reports.py index 0cfe48ba5..c9fc16cc3 100644 --- a/netbox/extras/migrations/0008_reports.py +++ b/netbox/extras/migrations/0008_reports.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-09-22 15:21 +# Generated by Django 1.11.4 on 2017-09-26 21:25 from __future__ import unicode_literals from django.conf import settings @@ -28,7 +28,6 @@ class Migration(migrations.Migration): ], options={ 'ordering': ['report'], - 'permissions': (('run_report', 'Run a report and save the results'),), }, ), ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index d1eee12cb..5181e88e9 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -405,9 +405,6 @@ class ReportResult(models.Model): class Meta: ordering = ['report'] - permissions = ( - ('run_report', 'Run a report and save the results'), - ) # From 2fbb39bf6fa6dfd4eaa9f7bcae8f1e8772014cbc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 27 Sep 2017 17:39:22 -0400 Subject: [PATCH 065/125] Started adding a view for individual reports --- netbox/extras/reports.py | 2 + netbox/extras/urls.py | 1 + netbox/extras/views.py | 40 +++++++++++++------ netbox/templates/extras/inc/report_label.html | 4 +- netbox/templates/extras/report.html | 31 ++++++++++++++ netbox/templates/extras/report_list.html | 9 ++--- 6 files changed, 68 insertions(+), 19 deletions(-) create mode 100644 netbox/templates/extras/report.html diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index e5fa24553..7af639e49 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -24,6 +24,8 @@ def get_report(module_name, report_name): """ module = importlib.import_module('reports.{}'.format(module_name)) report = getattr(module, report_name, None) + if report is None: + return None return report() diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 3828d696e..360e7d8e5 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -14,5 +14,6 @@ urlpatterns = [ # Reports url(r'^reports/$', views.ReportListView.as_view(), name='report_list'), + url(r'^reports/(?P[^/]+\.[^/]+)/$', views.ReportView.as_view(), name='report'), ] diff --git a/netbox/extras/views.py b/netbox/extras/views.py index d9549bd19..42bc10e6d 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,16 +1,14 @@ from __future__ import unicode_literals -from collections import OrderedDict from django.contrib.auth.mixins import PermissionRequiredMixin +from django.http import Http404 from django.shortcuts import get_object_or_404, render -from django.urls import reverse from django.views.generic import View -from . import reports from utilities.views import ObjectDeleteView, ObjectEditView from .forms import ImageAttachmentForm from .models import ImageAttachment, ReportResult -from .reports import get_reports +from .reports import get_report, get_reports # @@ -55,17 +53,35 @@ class ReportListView(View): reports = get_reports() results = {r.report: r for r in ReportResult.objects.all()} - foo = [] + ret = [] for module, report_list in reports: module_reports = [] for report in report_list: - module_reports.append({ - 'name': report.name, - 'description': report.description, - 'results': results.get(report.full_name, None) - }) - foo.append((module, module_reports)) + report.result = results.get(report.full_name, None) + module_reports.append(report) + ret.append((module, module_reports)) return render(request, 'extras/report_list.html', { - 'reports': foo, + 'reports': ret, + }) + + +class ReportView(View): + """ + Display a single Report and its associated ReportResult (if any). + """ + + def get(self, request, name): + + # Retrieve the Report by "." + module_name, report_name = name.split('.') + report = get_report(module_name, report_name) + if report is None: + raise Http404 + + # Attach the ReportResult (if any) + report.result = ReportResult.objects.filter(report=report.full_name).first() + + return render(request, 'extras/report.html', { + 'report': report, }) diff --git a/netbox/templates/extras/inc/report_label.html b/netbox/templates/extras/inc/report_label.html index 8e2a2a5b5..67fc0556b 100644 --- a/netbox/templates/extras/inc/report_label.html +++ b/netbox/templates/extras/inc/report_label.html @@ -1,6 +1,6 @@ -{% if report.results.failed %} +{% if report.result.failed %} -{% elif report.results %} +{% elif report.result %} {% else %} diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html new file mode 100644 index 000000000..19cf24257 --- /dev/null +++ b/netbox/templates/extras/report.html @@ -0,0 +1,31 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block title %}{{ report.name }}{% endblock %} + +{% block content %} +
    +
    + +
    +
    +
    +
    +

    {{ report.name }}{% include 'extras/inc/report_label.html' %}

    +
    +
    + {% if report.description %} +

    {{ report.description }}

    + {% endif %} + {% if report.result %} +

    Last run: {{ report.result.created }}

    + {% else %} +

    Last run: Never

    + {% endif %} +
    +
    +{% endblock %} diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index 947e22fb5..251394f33 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -20,20 +20,19 @@ {% for report in module_reports %}
    - {% if report.results %} - + {% if report.result %} + {% else %} {% endif %} - {% for method, stats in report.results.data.items %} + {% for method, stats in report.result.data.items %} + {% if device.cluster %} + + + + + {% endif %}
    NameStatus Description Last RunStatus
    {{ report.name }} + + {{ report.name }} + + {% include 'extras/inc/report_label.html' %} + {{ report.description|default:"" }}{{ report.results.created }}Never{% include 'extras/inc/report_label.html' %}
    -
    - - - - -
    - {{ method }} +
    + {{ method }} + + + + +
    - - {{ report.name }} + {{ report.name }} {% include 'extras/inc/report_label.html' %} {{ report.description|default:"" }}{{ report.results.created }}{{ report.result.created }}Never
    {{ method }} From 0ea58ab26891ca872b98d5b4f719f7adbedd2011 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 09:29:12 -0400 Subject: [PATCH 066/125] Fixes #1543: Added missing virtual machine field to IP address and service filters --- netbox/ipam/filters.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 045ca1df4..05ab873a3 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -10,6 +10,7 @@ from dcim.models import Site, Device, Interface from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter +from virtualization.models import VirtualMachine from .models import ( Aggregate, IPAddress, IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN, VLAN_STATUS_CHOICES, VLANGroup, VRF, @@ -237,6 +238,17 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='name', label='Device (name)', ) + virtual_machine_id = django_filters.ModelMultipleChoiceFilter( + name='interface__virtual_machine', + queryset=VirtualMachine.objects.all(), + label='Virtual machine (ID)', + ) + virtual_machine = django_filters.ModelMultipleChoiceFilter( + name='interface__virtual_machine__name', + queryset=VirtualMachine.objects.all(), + to_field_name='name', + label='Virtual machine (name)', + ) interface_id = django_filters.ModelMultipleChoiceFilter( queryset=Interface.objects.all(), label='Interface (ID)', @@ -372,6 +384,16 @@ class ServiceFilter(django_filters.FilterSet): to_field_name='name', label='Device (name)', ) + virtual_machine_id = django_filters.ModelMultipleChoiceFilter( + queryset=VirtualMachine.objects.all(), + label='Virtual machine (ID)', + ) + virtual_machine = django_filters.ModelMultipleChoiceFilter( + name='virtual_machine__name', + queryset=VirtualMachine.objects.all(), + to_field_name='name', + label='Virtual machine (name)', + ) class Meta: model = Service From e2b2815545e3f69ce3a63758dd7b9f1b875f65c7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 10:21:41 -0400 Subject: [PATCH 067/125] Fixes #1542: Added cluster field to device view --- netbox/templates/dcim/device.html | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 18e14b5e0..9625a002c 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -155,6 +155,18 @@ {% endif %}
    Cluster + {% if device.cluster.group %} + {{ device.cluster.group }} + + {% endif %} + {{ device.cluster }} +
    {% with device.get_custom_fields as custom_fields %} From 571b817f0483d567e024dd152d84e024929fcfda Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 12:50:32 -0400 Subject: [PATCH 068/125] Moved ReportResult creation into Report.run() --- netbox/extras/api/views.py | 9 ++------- netbox/extras/reports.py | 27 ++++++++++++++++----------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 216ea42a0..81eb5e739 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -153,14 +153,9 @@ class ReportViewSet(ViewSet): if not request.user.has_perm('extras.add_reportresult'): raise PermissionDenied("This user does not have permission to run reports.") - # Retrieve and run the Report. + # Retrieve and run the Report. This will create a new ReportResult. report = self._retrieve_report(pk) - result = report.run() - - # Delete the old ReportResult (if any) and save the new one. - ReportResult.objects.filter(report=pk).delete() - report.result = ReportResult(report=pk, failed=report.failed, data=result) - report.result.save() + report.run() serializer = serializers.ReportDetailSerializer(report) diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 7af639e49..7bf8951ea 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -6,6 +6,7 @@ import pkgutil from django.utils import timezone from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_LEVEL_CODES, LOG_SUCCESS, LOG_WARNING +from .models import ReportResult import reports as custom_reports @@ -34,8 +35,8 @@ def get_reports(): Compile a list of all reports available across all modules in the reports path. Returns a list of tuples: [ - (module_name, (report_class, report_class, report_class, ...)), - (module_name, (report_class, report_class, report_class, ...)), + (module_name, (report, report, report, ...)), + (module_name, (report, report, report, ...)), ... ] """ @@ -56,7 +57,7 @@ class Report(object): NetBox users can extend this object to write custom reports to be used for validating data within NetBox. Each report must have one or more test methods named `test_*`. - The `results` attribute of a completed report will take the following form: + The `_results` attribute of a completed report will take the following form: { 'test_bar': { @@ -79,7 +80,7 @@ class Report(object): def __init__(self): - self.results = OrderedDict() + self._results = OrderedDict() self.active_test = None self.failed = False @@ -88,7 +89,7 @@ class Report(object): for method in dir(self): if method.startswith('test_') and callable(getattr(self, method)): test_methods.append(method) - self.results[method] = OrderedDict([ + self._results[method] = OrderedDict([ ('success', 0), ('info', 0), ('warning', 0), @@ -118,7 +119,7 @@ class Report(object): if level not in LOG_LEVEL_CODES: raise Exception("Unknown logging level: {}".format(level)) logline = [timezone.now().isoformat(), level, str(obj), message] - self.results[self.active_test]['log'].append(logline) + self._results[self.active_test]['log'].append(logline) def log_success(self, obj, message=None): """ @@ -126,28 +127,28 @@ class Report(object): """ if message: self._log(obj, message, level=LOG_SUCCESS) - self.results[self.active_test]['success'] += 1 + self._results[self.active_test]['success'] += 1 def log_info(self, obj, message): """ Log an informational message. """ self._log(obj, message, level=LOG_INFO) - self.results[self.active_test]['info'] += 1 + self._results[self.active_test]['info'] += 1 def log_warning(self, obj, message): """ Log a warning. """ self._log(obj, message, level=LOG_WARNING) - self.results[self.active_test]['warning'] += 1 + self._results[self.active_test]['warning'] += 1 def log_failure(self, obj, message): """ Log a failure. Calling this method will automatically mark the report as failed. """ self._log(obj, message, level=LOG_FAILURE) - self.results[self.active_test]['failed'] += 1 + self._results[self.active_test]['failed'] += 1 self.failed = True def run(self): @@ -159,4 +160,8 @@ class Report(object): test_method = getattr(self, method_name) test_method() - return self.results + # Delete any previous ReportResult and create a new one to record the result. + ReportResult.objects.filter(report=self.full_name).delete() + result = ReportResult(report=self.full_name, failed=self.failed, data=self._results) + result.save() + self.result = result From b65e9fe0f53ef1690c10c9be76fe2a7b727dd24b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 12:50:52 -0400 Subject: [PATCH 069/125] Fixed runreport management command --- .../extras/management/commands/runreport.py | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/netbox/extras/management/commands/runreport.py b/netbox/extras/management/commands/runreport.py index 70893b934..a6ae18758 100644 --- a/netbox/extras/management/commands/runreport.py +++ b/netbox/extras/management/commands/runreport.py @@ -16,36 +16,30 @@ class Command(BaseCommand): def handle(self, *args, **options): - # Gather all reports to be run + # Gather all available reports reports = get_reports() # Run reports - for module_name, report in reports: - for report_name, report_cls in report: - report_name_full = '{}.{}'.format(module_name, report_name) - if module_name in options['reports'] or report_name_full in options['reports']: + for module_name, report_list in reports: + for report in report_list: + if module_name in options['reports'] or report.full_namel in options['reports']: - # Run the report + # Run the report and create a new ReportResult self.stdout.write( - "[{:%H:%M:%S}] Running {}.{}...".format(timezone.now(), module_name, report_name) + "[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name) ) - report = report_cls() - result = report.run() - - # Record the results - ReportResult.objects.filter(report=report_name_full).delete() - ReportResult(report=report_name_full, failed=report.failed, data=result).save() + report.run() # Report on success/failure status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS') - for test_name, attrs in result.items(): + for test_name, attrs in report.result.data.items(): self.stdout.write( "\t{}: {} success, {} info, {} warning, {} failed".format( test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failed'] ) ) self.stdout.write( - "[{:%H:%M:%S}] {}.{}: {}".format(timezone.now(), module_name, report_name, status) + "[{:%H:%M:%S}] {}: {}".format(timezone.now(), report.full_name, status) ) # Wrap things up From 2b33e78fd39f72ed3de4ca368c15a4e34215717c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 12:51:10 -0400 Subject: [PATCH 070/125] Added a run view for reports --- netbox/extras/urls.py | 1 + netbox/extras/views.py | 35 +++++++++++++++++++++++++++-- netbox/templates/extras/report.html | 13 ++++++++--- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 360e7d8e5..1ac7fce6c 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -15,5 +15,6 @@ urlpatterns = [ # Reports url(r'^reports/$', views.ReportListView.as_view(), name='report_list'), url(r'^reports/(?P[^/]+\.[^/]+)/$', views.ReportView.as_view(), name='report'), + url(r'^reports/(?P[^/]+\.[^/]+)/run/$', views.ReportRunView.as_view(), name='report_run'), ] diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 42bc10e6d..e92b28187 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,13 +1,16 @@ from __future__ import unicode_literals from django.contrib.auth.mixins import PermissionRequiredMixin +from django.contrib import messages from django.http import Http404 -from django.shortcuts import get_object_or_404, render +from django.shortcuts import get_object_or_404, redirect, render +from django.utils.safestring import mark_safe from django.views.generic import View +from utilities.forms import ConfirmationForm from utilities.views import ObjectDeleteView, ObjectEditView from .forms import ImageAttachmentForm -from .models import ImageAttachment, ReportResult +from .models import ImageAttachment, ReportResult, UserAction from .reports import get_report, get_reports @@ -84,4 +87,32 @@ class ReportView(View): return render(request, 'extras/report.html', { 'report': report, + 'run_form': ConfirmationForm(), }) + + +class ReportRunView(PermissionRequiredMixin, View): + """ + Run a Report and record a new ReportResult. + """ + permission_required = 'extras.add_reportresult' + + def post(self, request, name): + + # Retrieve the Report by "." + module_name, report_name = name.split('.') + report = get_report(module_name, report_name) + if report is None: + raise Http404 + + form = ConfirmationForm(request.POST) + if form.is_valid(): + + # Run the Report. A new ReportResult is created. + report.run() + result = 'failed' if report.failed else 'passed' + msg = "Ran report {} ({})".format(report.full_name, result) + messages.success(request, mark_safe(msg)) + UserAction.objects.log_create(request.user, report.result, msg) + + return redirect('extras:report', name=report.full_name) diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html index 19cf24257..cb3e716f5 100644 --- a/netbox/templates/extras/report.html +++ b/netbox/templates/extras/report.html @@ -5,16 +5,23 @@ {% block content %}
    -
    + -
    -
    + {% if perms.extras.add_reportresult %} +
    + + {% csrf_token %} + {{ run_form }} + + +
    + {% endif %}

    {{ report.name }}{% include 'extras/inc/report_label.html' %}

    From 6c6b67330fe778989f9663cf6013ab23368200d6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 13:35:18 -0400 Subject: [PATCH 071/125] Expanded report view --- netbox/extras/reports.py | 19 +++++++--- netbox/templates/extras/report.html | 56 ++++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 7bf8951ea..421c8456f 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -93,7 +93,7 @@ class Report(object): ('success', 0), ('info', 0), ('warning', 0), - ('failed', 0), + ('failure', 0), ('log', []), ]) if not test_methods: @@ -118,8 +118,19 @@ class Report(object): """ if level not in LOG_LEVEL_CODES: raise Exception("Unknown logging level: {}".format(level)) - logline = [timezone.now().isoformat(), level, str(obj), message] - self._results[self.active_test]['log'].append(logline) + self._results[self.active_test]['log'].append( + timezone.now().isoformat(), + LOG_LEVEL_CODES.get(level), + str(obj) if obj else None, + obj.get_absolute_url() if getattr(obj, 'get_absolute_url', None) else None, + message, + ) + + def log(self, message): + """ + Log a message which is not associated with a particular object. + """ + self._log(None, message, level=LOG_DEFAULT) def log_success(self, obj, message=None): """ @@ -148,7 +159,7 @@ class Report(object): Log a failure. Calling this method will automatically mark the report as failed. """ self._log(obj, message, level=LOG_FAILURE) - self._results[self.active_test]['failed'] += 1 + self._results[self.active_test]['failure'] += 1 self.failed = True def run(self): diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html index cb3e716f5..2c087e437 100644 --- a/netbox/templates/extras/report.html +++ b/netbox/templates/extras/report.html @@ -24,7 +24,7 @@ {% endif %}

    {{ report.name }}{% include 'extras/inc/report_label.html' %}

    -
    +
    {% if report.description %}

    {{ report.description }}

    {% endif %} @@ -34,5 +34,59 @@

    Last run: Never

    {% endif %}
    +
    + {% if report.result %} + + + + + + + + + + {% for method, data in report.result.data.items %} + + + + {% for time, level, obj, url, message in data.log %} + + + + + + + {% endfor %} + {% endfor %} +
    TimeLevelObjectMessage
    {{ method }}
    {{ time }} + + + {% if obj and url %} + {{ obj }} + {% elif obj %} + {{ obj }} + {% endif %} + {{ message }}
    + {% else %} +
    No results are available for this report. Please run the report first.
    + {% endif %} +
    +
    + {% if report.result %} +
    +
    + Methods +
    +
      + {% for method, data in report.result.data.items %} +
    • + {{ method }} + {{ data.log|length }} +
    • + {% endfor %} +
    +
    + {% endif %} +
    {% endblock %} From f9a677c1a3600e7e08fac820d0b1dfedcb04cb7a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 13:36:50 -0400 Subject: [PATCH 072/125] Bugfixes --- netbox/extras/reports.py | 4 ++-- netbox/templates/extras/report_list.html | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 421c8456f..921aab380 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -118,13 +118,13 @@ class Report(object): """ if level not in LOG_LEVEL_CODES: raise Exception("Unknown logging level: {}".format(level)) - self._results[self.active_test]['log'].append( + self._results[self.active_test]['log'].append(( timezone.now().isoformat(), LOG_LEVEL_CODES.get(level), str(obj) if obj else None, obj.get_absolute_url() if getattr(obj, 'get_absolute_url', None) else None, message, - ) + )) def log(self, message): """ diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index 251394f33..fde83ddc3 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -41,7 +41,7 @@ - + {% endfor %} From 1ad099d9fd202f886a2f48033683d84bcae74aa9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 14:54:11 -0400 Subject: [PATCH 073/125] Added nav menu link to reports list --- netbox/templates/_base.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index 3a2cd85d4..c81b12d72 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -28,7 +28,7 @@
    - +
    {% endblock %} From 21485ca6e2d3a56f816259608438ecb83166e0a2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 16:04:37 -0400 Subject: [PATCH 076/125] Restrict the running of reports via API to POST requests --- docs/miscellaneous/reports.md | 0 netbox/extras/api/views.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 docs/miscellaneous/reports.md diff --git a/docs/miscellaneous/reports.md b/docs/miscellaneous/reports.md new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 81eb5e739..bd1d33fa3 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -143,7 +143,7 @@ class ReportViewSet(ViewSet): return Response(serializer.data) - @detail_route() + @detail_route(methods=['post']) def run(self, request, pk): """ Run a Report and create a new ReportResult, overwriting any previous result for the Report. From e630a1ace1a4ca24fa14e7cc1bd73ea29467e9c4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 16:25:42 -0400 Subject: [PATCH 077/125] Added docs for reports --- docs/miscellaneous/reports.md | 119 ++++++++++++++++++++++++++++++++++ mkdocs.yml | 2 + 2 files changed, 121 insertions(+) diff --git a/docs/miscellaneous/reports.md b/docs/miscellaneous/reports.md index e69de29bb..79e4fb085 100644 --- a/docs/miscellaneous/reports.md +++ b/docs/miscellaneous/reports.md @@ -0,0 +1,119 @@ +# NetBox Reports + +A NetBox report is a mechanism for validating the integrity of data within NetBox. Running a report allows the user to verify that the objects defined within NetBox meet certain arbitrary conditions. For example, you can write reports to check that: + +* All top-of-rack switches have a console connection +* Every router has a loopback interface with an IP address assigned +* Each interface description conforms to a standard format +* Every site has a minimum set of VLANs defined +* All IP addresses have a parent prefix + +...and so on. Reports are completely customizable, so there's practically no limit to what you can test for. + +## Writing Reports + +Reports must be saved as files in the `netbox/reports/` path within the NetBox installation path. Each file created within this path is considered a separate module. Each module holds one or more reports, each of which performs a certain function. The logic of each report is broken into discrete test methods, each of which applies a small portion of the logic comprising the overall test. + +!!! warning + The reports path includes a file named `__init__.py`, which registers the path as a Python module. Do not delete this file. + +For example, we can create a module named `devices.py` to hold all of our reports which pertain to devices in NetBox. Within that module, we might define several reports. Each report is defined as a Python class inheriting from `extras.reports.Report`. + +``` +from extras.reports import Report + +class DeviceConnectionsReport(Report): + description = "Validate the minimum physical connections for each device" + +class DeviceIPsReport(Report): + description = "Check that every device has a primary IP address assigned" +``` + +Within each report class, we'll create a number of test methods to execute our report's logic. In DeviceConnectionsReport, for instance, we want to ensure that every live device has a console connection, an out-of-band management connection, and two power connections. + +``` +from dcim.constants import CONNECTION_STATUS_PLANNED, STATUS_ACTIVE +from dcim.models import ConsolePort, Device, PowerPort +from extras.reports import Report + + +class DeviceConnectionsReport(Report): + description = "Validate the minimum physical connections for each device" + + def test_console_connection(self): + + # Check that every console port for every active device has a connection defined. + for console_port in ConsolePort.objects.select_related('device').filter(device__status=STATUS_ACTIVE): + if console_port.cs_port is None: + self.log_failure( + console_port.device, + "No console connection defined for {}".format(console_port.name) + ) + elif console_port.connection_status == CONNECTION_STATUS_PLANNED: + self.log_warning( + console_port.device, + "Console connection for {} marked as planned".format(console_port.name) + ) + else: + self.log_success(console_port.device) + + def test_power_connections(self): + + # Check that every active device has at least two connected power supplies. + for device in Device.objects.filter(status=STATUS_ACTIVE): + connected_ports = 0 + for power_port in PowerPort.objects.filter(device=device): + if power_port.power_outlet is not None: + connected_ports += 1 + if power_port.connection_status == CONNECTION_STATUS_PLANNED: + self.log_warning( + device, + "Power connection for {} marked as planned".format(power_port.name) + ) + if connected_ports < 2: + self.log_failure( + device, + "{} connected power supplies found (2 needed)".format(connected_ports) + ) + else: + self.log_success(device) +``` + +As you can see, reports are completely customizable. Validation logic can be as simple or as complex as needed. + +!!! warning + Reports should never alter data: If you find yourself using the `create()`, `save()`, `update()`, or `delete()` methods on objects within reports, stop and re-evaluate what you're trying to accomplish. Note that there are no safeguards against the accidental alteration or destruction of data. + +The following methods are available to log results within a report: + +* log(message) +* log_success(object, message=None) +* log_info(object, message) +* log_warning(object, message) +* log_failure(object, message) + +The recording of one or more failure messages will automatically flag a report as failed. It is advised to log a success for each object that is evaluated so that the results will reflect how many objects are being reported on. (The inclusion of a log message is optional for successes.) Messages recorded with `log()` will appear in a report's results but are not associated with a particular object or status. + +Once you have created a report, it will appear in the reports list. Initially, reports will have no results associated with them. To generate results, run the report. + +## Running Reports + +### Via the Web UI + +Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Note that a user must have permission to create ReportResults in order to run reports. (Permissions can be assigned through the admin UI.) + +Once a report has been run, its associated results will be included in the report view. + +### Via the API + +To run a report via the API, simply issue a POST request. Reports are identified by their module and class name. + +``` + POST /api/extras/reports/./ +``` + +Our example report above would be called as: + +``` + POST /api/extras/reports/devices.DeviceConnectionsReport/ +``` diff --git a/mkdocs.yml b/mkdocs.yml index c26a9ed17..e3ac9f50b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -27,6 +27,8 @@ pages: - 'Examples': 'api/examples.md' - 'Shell': - 'Introduction': 'shell/intro.md' + - 'Miscellaneous': + - 'Reports': 'miscellaneous/reports.md' - 'Development': - 'Utility Views': 'development/utility-views.md' From 67f0dfa4498b852a1f9f42f1d0352800696dd093 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 16:32:59 -0400 Subject: [PATCH 078/125] We need PostgreSQL 9.4 or higher for jsonb fields --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index 1576da4cf..d7acf1db2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,6 +13,8 @@ python: install: - pip install -r requirements.txt - pip install pep8 +addons: + - postgresql: "9.4" script: - ./scripts/cibuild.sh after_success: From 669aee2d73ed9e84df98f78de84fc565240e6f3f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 16:43:35 -0400 Subject: [PATCH 079/125] Bumped psycopg2 hoping to fix jsonb errors in CI --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8cd72419f..82041879e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ ncclient==0.5.3 netaddr==0.7.18 paramiko>=2.0.0 Pillow>=4.0.0 -psycopg2>=2.6.1 +psycopg2>=2.7.3 py-gfm>=0.1.3 pycrypto>=2.6.1 sqlparse>=0.2 From c65af6a74fd17438d0a2f73397957389165de55d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 17:03:47 -0400 Subject: [PATCH 080/125] Trying to get Travis to run PostgreSQL 9.4 --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index d7acf1db2..2c22e30b9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,7 @@ sudo: required services: - docker + - postgresql env: - DOCKER_TAG=$TRAVIS_TAG From 667eadb4307365869582cd2d4ddbb9fdd8047887 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 17:36:05 -0400 Subject: [PATCH 081/125] Removed Docker bits from CI build --- .travis.yml | 10 ---------- scripts/docker-build.sh | 21 --------------------- 2 files changed, 31 deletions(-) delete mode 100755 scripts/docker-build.sh diff --git a/.travis.yml b/.travis.yml index 2c22e30b9..b4b0f3685 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,12 +1,6 @@ sudo: required - services: - - docker - postgresql - -env: - - DOCKER_TAG=$TRAVIS_TAG - language: python python: - "2.7" @@ -18,7 +12,3 @@ addons: - postgresql: "9.4" script: - ./scripts/cibuild.sh -after_success: - - if [ ! -z "$TRAVIS_TAG" ] && [ "$TRAVIS_PULL_REQUEST" == "false" ]; then - ./scripts/docker-build.sh; - fi diff --git a/scripts/docker-build.sh b/scripts/docker-build.sh deleted file mode 100755 index f8691461b..000000000 --- a/scripts/docker-build.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash - -docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD" -if [ $? -ne 0 ]; then - echo "docker login failed." - exit 1 -fi - -docker build -t "$DOCKER_REPOSITORY/$DOCKER_IMAGE_NAME:$DOCKER_TAG" . -if [ $? -ne 0 ]; then - echo "docker build failed." - exit 1 -fi - -docker push "$DOCKER_REPOSITORY/$DOCKER_IMAGE_NAME:$DOCKER_TAG" -if [ $? -ne 0 ]; then - echo "docker push failed." - exit 1 -fi - -exit 0 From 90ec1773601a8adf707e998ce8de51e6120d771a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 17:47:11 -0400 Subject: [PATCH 082/125] Troubleshooting Travis CI --- .travis.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index b4b0f3685..00ec9bd61 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,8 @@ sudo: required services: - postgresql +addons: + - postgresql: "9.4" language: python python: - "2.7" @@ -8,7 +10,8 @@ python: install: - pip install -r requirements.txt - pip install pep8 -addons: - - postgresql: "9.4" +before_script: + - psql --version + - psql -U postgres -c 'SELECT version();' script: - ./scripts/cibuild.sh From 75fed52bb6d1bec66f9a90549cba6492afba8b39 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 Sep 2017 17:55:05 -0400 Subject: [PATCH 083/125] Fixed YAML --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 00ec9bd61..94bfa0dab 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,7 @@ sudo: required services: - postgresql addons: - - postgresql: "9.4" + postgresql: "9.4" language: python python: - "2.7" From f49d7ce1da8e1a4d312612e8027066119ee8a10c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 29 Sep 2017 10:18:18 -0400 Subject: [PATCH 084/125] Removed deprecated NETBOX_USERNAME and NETBOX_PASSWORD configuration parameters --- docs/configuration/optional-settings.md | 8 -------- netbox/netbox/settings.py | 15 --------------- netbox/templates/home.html | 8 -------- 3 files changed, 31 deletions(-) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 9cc4e75fd..fd85c0775 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -191,14 +191,6 @@ The amount of time (in seconds) to wait for NAPALM to connect to a device. --- -## NETBOX_USERNAME (Deprecated) - -## NETBOX_PASSWORD (Deprecated) - -These settings have been deprecated and will be removed in NetBox v2.2. Please use `NAPALM_USERNAME` and `NAPALM_PASSWORD` instead. - ---- - ## PAGINATE_COUNT Default: 50 diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index b6cfa75db..59adbb823 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -52,8 +52,6 @@ NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '') NAPALM_PASSWORD = getattr(configuration, 'NAPALM_PASSWORD', '') NAPALM_TIMEOUT = getattr(configuration, 'NAPALM_TIMEOUT', 30) NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {}) -NETBOX_USERNAME = getattr(configuration, 'NETBOX_USERNAME', '') # Deprecated -NETBOX_PASSWORD = getattr(configuration, 'NETBOX_PASSWORD', '') # Deprecated PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50) PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d') @@ -64,19 +62,6 @@ TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC') CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS -# Check for deprecated configuration parameters -config_logger = logging.getLogger('configuration') -config_logger.addHandler(logging.StreamHandler()) -config_logger.setLevel(logging.WARNING) -if NETBOX_USERNAME: - config_logger.warning('NETBOX_USERNAME is deprecated and will be removed in v2.2. Please use NAPALM_USERNAME instead.') - if not NAPALM_USERNAME: - NAPALM_USERNAME = NETBOX_USERNAME -if NETBOX_PASSWORD: - config_logger.warning('NETBOX_PASSWORD is deprecated and will be removed in v2.2. Please use NAPALM_PASSWORD instead.') - if not NAPALM_PASSWORD: - NAPALM_PASSWORD = NETBOX_PASSWORD - # Attempt to import LDAP configuration if it has been defined LDAP_IGNORE_CERT_ERRORS = False try: diff --git a/netbox/templates/home.html b/netbox/templates/home.html index c73de0000..beb157f1a 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -1,14 +1,6 @@ {% extends '_base.html' %} {% block content %} -{% if settings.NETBOX_USERNAME or settings.NETBOX_PASSWORD %} - -{% endif %} {% include 'search_form.html' %}
    From 6243fbfd0d713a9a87ed1f627ec39d2f20c66b40 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 29 Sep 2017 11:13:41 -0400 Subject: [PATCH 085/125] Closes #1493: Added functional roles for virtual machines --- .../virtualization/virtualmachine.html | 37 ++++++++++++++----- .../virtualization/virtualmachine_edit.html | 8 +++- netbox/virtualization/api/serializers.py | 11 +++--- netbox/virtualization/filters.py | 15 +++++++- netbox/virtualization/forms.py | 36 +++++++++++++++--- .../0004_virtualmachine_add_role.py | 22 +++++++++++ netbox/virtualization/models.py | 7 ++++ netbox/virtualization/tables.py | 4 +- 8 files changed, 115 insertions(+), 25 deletions(-) create mode 100644 netbox/virtualization/migrations/0004_virtualmachine_add_role.py diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index cd66cb105..cb83142ea 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -58,24 +58,20 @@ - Cluster + Role - {% if vm.cluster.group %} - {{ vm.cluster.group }} - + {% if vm.role %} + {{ vm.role }} + {% else %} + None {% endif %} - {{ vm.cluster }} - - Cluster Type - {{ vm.cluster.type }} - Platform {% if vm.platform %} - {{ vm.platform }} + {{ vm.platform }} {% else %} None {% endif %} @@ -127,6 +123,27 @@
    +
    +
    + Cluster +
    + + + + + + + + + +
    Cluster + {% if vm.cluster.group %} + {{ vm.cluster.group }} + + {% endif %} + {{ vm.cluster }} +
    Cluster Type{{ vm.cluster.type }}
    +
    Resources diff --git a/netbox/templates/virtualization/virtualmachine_edit.html b/netbox/templates/virtualization/virtualmachine_edit.html index 085f3cadf..7c240857f 100644 --- a/netbox/templates/virtualization/virtualmachine_edit.html +++ b/netbox/templates/virtualization/virtualmachine_edit.html @@ -7,9 +7,15 @@
    {% render_field form.name %} {% render_field form.status %} + {% render_field form.role %} + {% render_field form.platform %} +
    +
    +
    +
    Cluster
    +
    {% render_field form.cluster_group %} {% render_field form.cluster %} - {% render_field form.platform %}
    diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index b85495d83..68f5118bf 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals from rest_framework import serializers -from dcim.api.serializers import NestedPlatformSerializer, NestedSiteSerializer +from dcim.api.serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer from dcim.constants import VIFACE_FF_CHOICES from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer @@ -86,14 +86,15 @@ class WritableClusterSerializer(CustomFieldModelSerializer): class VirtualMachineSerializer(CustomFieldModelSerializer): status = ChoiceFieldSerializer(choices=STATUS_CHOICES) cluster = NestedClusterSerializer() + role = NestedDeviceRoleSerializer() tenant = NestedTenantSerializer() platform = NestedPlatformSerializer() class Meta: model = VirtualMachine fields = [ - 'id', 'name', 'status', 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', - 'disk', 'comments', 'custom_fields', + 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', + 'memory', 'disk', 'comments', 'custom_fields', ] @@ -110,8 +111,8 @@ class WritableVirtualMachineSerializer(CustomFieldModelSerializer): class Meta: model = VirtualMachine fields = [ - 'id', 'name', 'status', 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', - 'disk', 'comments', 'custom_fields', + 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', + 'memory', 'disk', 'comments', 'custom_fields', ] diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index ea7686a23..31a002009 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import django_filters from django.db.models import Q -from dcim.models import Platform, Site +from dcim.models import DeviceRole, Platform, Site from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter @@ -80,10 +80,21 @@ class VirtualMachineFilter(CustomFieldFilterSet): to_field_name='slug', label='Cluster group (slug)', ) - cluster_id = NullableModelMultipleChoiceFilter( + cluster_id = django_filters.ModelMultipleChoiceFilter( queryset=Cluster.objects.all(), label='Cluster (ID)', ) + role_id = NullableModelMultipleChoiceFilter( + name='role_id', + queryset=DeviceRole.objects.all(), + label='Role (ID)', + ) + role = NullableModelMultipleChoiceFilter( + name='role__slug', + queryset=DeviceRole.objects.all(), + to_field_name='slug', + label='Role (slug)', + ) tenant_id = NullableModelMultipleChoiceFilter( queryset=Tenant.objects.all(), label='Tenant (ID)', diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index bcae2f842..ef27ea994 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -8,7 +8,7 @@ from django.db.models import Count from dcim.constants import IFACE_FF_VIRTUAL, VIFACE_FF_CHOICES from dcim.formfields import MACAddressFormField -from dcim.models import Device, Interface, Platform, Rack, Region, Site +from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm from tenancy.forms import TenancyForm from tenancy.models import Tenant @@ -222,7 +222,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm): class Meta: model = VirtualMachine fields = [ - 'name', 'status', 'cluster_group', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', + 'comments', ] def __init__(self, *args, **kwargs): @@ -251,6 +252,15 @@ class VirtualMachineCSVForm(forms.ModelForm): 'invalid_choice': 'Invalid cluster name.', } ) + role = forms.ModelChoiceField( + queryset=DeviceRole.objects.all(), + required=False, + to_field_name='name', + help_text='Name of functional role', + error_messages={ + 'invalid_choice': 'Invalid role name.' + } + ) tenant = forms.ModelChoiceField( queryset=Tenant.objects.all(), required=False, @@ -272,13 +282,14 @@ class VirtualMachineCSVForm(forms.ModelForm): class Meta: model = VirtualMachine - fields = ['name', 'status', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments'] + fields = ['name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments'] class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput) status = forms.ChoiceField(choices=add_blank_choice(STATUS_CHOICES), required=False, initial='') cluster = forms.ModelChoiceField(queryset=Cluster.objects.all(), required=False) + role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False) tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False) vcpus = forms.IntegerField(required=False, label='vCPUs') @@ -287,7 +298,7 @@ class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): comments = CommentField(widget=SmallTextarea) class Meta: - nullable_fields = ['tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments'] + nullable_fields = ['role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments'] def vm_status_choices(): @@ -303,13 +314,28 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm): cluster_group = FilterChoiceField( queryset=ClusterGroup.objects.all(), to_field_name='slug', - null_option=(0, 'None'), + null_option=(0, 'None') ) cluster_id = FilterChoiceField( queryset=Cluster.objects.annotate(filter_count=Count('virtual_machines')), label='Cluster' ) + role = FilterChoiceField( + queryset=DeviceRole.objects.annotate(filter_count=Count('virtual_machines')), + to_field_name='slug', + null_option=(0, 'None') + ) status = forms.MultipleChoiceField(choices=vm_status_choices, required=False) + tenant = FilterChoiceField( + queryset=Tenant.objects.annotate(filter_count=Count('virtual_machines')), + to_field_name='slug', + null_option=(0, 'None') + ) + platform = FilterChoiceField( + queryset=Platform.objects.annotate(filter_count=Count('virtual_machines')), + to_field_name='slug', + null_option=(0, 'None') + ) # diff --git a/netbox/virtualization/migrations/0004_virtualmachine_add_role.py b/netbox/virtualization/migrations/0004_virtualmachine_add_role.py new file mode 100644 index 000000000..10dec60fa --- /dev/null +++ b/netbox/virtualization/migrations/0004_virtualmachine_add_role.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-29 14:32 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0044_virtualization'), + ('virtualization', '0003_cluster_add_site'), + ] + + operations = [ + migrations.AddField( + model_name='virtualmachine', + name='role', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.DeviceRole'), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index edf0385d4..47b179670 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -179,6 +179,13 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): default=STATUS_ACTIVE, verbose_name='Status' ) + role = models.ForeignKey( + to='dcim.DeviceRole', + on_delete=models.PROTECT, + related_name='virtual_machines', + blank=True, + null=True + ) primary_ip4 = models.OneToOneField( to='ipam.IPAddress', on_delete=models.SET_NULL, diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 21314b51c..a40af6091 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -95,7 +95,7 @@ class VirtualMachineTable(BaseTable): class Meta(BaseTable.Meta): model = VirtualMachine - fields = ('pk', 'name', 'status', 'cluster', 'tenant', 'vcpus', 'memory', 'disk') + fields = ('pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk') class VirtualMachineDetailTable(VirtualMachineTable): @@ -105,7 +105,7 @@ class VirtualMachineDetailTable(VirtualMachineTable): class Meta(BaseTable.Meta): model = VirtualMachine - fields = ('pk', 'name', 'status', 'cluster', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip') + fields = ('pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip') # From 0deae84ecb25e89f0dce68474c5854d0ecdc5365 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 29 Sep 2017 11:50:26 -0400 Subject: [PATCH 086/125] Renamed column --- netbox/dcim/tables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 427f0bb42..a695958d9 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -362,7 +362,7 @@ class DeviceRoleTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn(verbose_name='Name') device_count = tables.Column(verbose_name='Devices') - color = tables.TemplateColumn(COLOR_LABEL, verbose_name='Color') + color = tables.TemplateColumn(COLOR_LABEL, verbose_name='Label') slug = tables.Column(verbose_name='Slug') actions = tables.TemplateColumn(template_code=DEVICEROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='') From a6599874db1e125e9d86fdb3b44565bb9cdee265 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 29 Sep 2017 12:11:20 -0400 Subject: [PATCH 087/125] #1493: Extended DeviceRole to include a toggle indicating applicability to virtual machines --- netbox/dcim/api/serializers.py | 2 +- netbox/dcim/forms.py | 2 +- .../migrations/0045_devicerole_vm_role.py | 20 +++++++++++++++++++ netbox/dcim/models.py | 8 +++++++- netbox/dcim/tables.py | 2 +- netbox/virtualization/forms.py | 6 +++--- netbox/virtualization/models.py | 1 + 7 files changed, 34 insertions(+), 7 deletions(-) create mode 100644 netbox/dcim/migrations/0045_devicerole_vm_role.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index c08c0f65b..46dfd7682 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -404,7 +404,7 @@ class DeviceRoleSerializer(ValidatedModelSerializer): class Meta: model = DeviceRole - fields = ['id', 'name', 'slug', 'color'] + fields = ['id', 'name', 'slug', 'color', 'vm_role'] class NestedDeviceRoleSerializer(serializers.ModelSerializer): diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index e10b0df56..e4e7a6675 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -531,7 +531,7 @@ class DeviceRoleForm(BootstrapMixin, forms.ModelForm): class Meta: model = DeviceRole - fields = ['name', 'slug', 'color'] + fields = ['name', 'slug', 'color', 'vm_role'] # diff --git a/netbox/dcim/migrations/0045_devicerole_vm_role.py b/netbox/dcim/migrations/0045_devicerole_vm_role.py new file mode 100644 index 000000000..775effaf2 --- /dev/null +++ b/netbox/dcim/migrations/0045_devicerole_vm_role.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-29 16:09 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0044_virtualization'), + ] + + operations = [ + migrations.AddField( + model_name='devicerole', + name='vm_role', + field=models.BooleanField(default=True, help_text='Virtual machines may be assigned to this role', verbose_name='VM Role'), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 1fb818f5d..66095a5e8 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -743,11 +743,17 @@ class DeviceBayTemplate(models.Model): class DeviceRole(models.Model): """ Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a - color to be used when displaying rack elevations. + color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to + virtual machines as well. """ name = models.CharField(max_length=50, unique=True) slug = models.SlugField(unique=True) color = ColorField() + vm_role = models.BooleanField( + default=True, + verbose_name="VM Role", + help_text="Virtual machines may be assigned to this role" + ) class Meta: ordering = ['name'] diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index a695958d9..1a24035b0 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -369,7 +369,7 @@ class DeviceRoleTable(BaseTable): class Meta(BaseTable.Meta): model = DeviceRole - fields = ('pk', 'name', 'device_count', 'color', 'slug', 'actions') + fields = ('pk', 'name', 'device_count', 'color', 'vm_role', 'slug', 'actions') # diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index ef27ea994..fb938ab1f 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -253,7 +253,7 @@ class VirtualMachineCSVForm(forms.ModelForm): } ) role = forms.ModelChoiceField( - queryset=DeviceRole.objects.all(), + queryset=DeviceRole.objects.filter(vm_role=True), required=False, to_field_name='name', help_text='Name of functional role', @@ -289,7 +289,7 @@ class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput) status = forms.ChoiceField(choices=add_blank_choice(STATUS_CHOICES), required=False, initial='') cluster = forms.ModelChoiceField(queryset=Cluster.objects.all(), required=False) - role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False) + role = forms.ModelChoiceField(queryset=DeviceRole.objects.filter(vm_role=True), required=False) tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False) vcpus = forms.IntegerField(required=False, label='vCPUs') @@ -321,7 +321,7 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm): label='Cluster' ) role = FilterChoiceField( - queryset=DeviceRole.objects.annotate(filter_count=Count('virtual_machines')), + queryset=DeviceRole.objects.filter(vm_role=True).annotate(filter_count=Count('virtual_machines')), to_field_name='slug', null_option=(0, 'None') ) diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 47b179670..8fb13c7cc 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -181,6 +181,7 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): ) role = models.ForeignKey( to='dcim.DeviceRole', + limit_choices_to={'vm_role': True}, on_delete=models.PROTECT, related_name='virtual_machines', blank=True, From 86a298792a95af9ed782f2c4f8e2e801be4ef48f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 29 Sep 2017 12:15:14 -0400 Subject: [PATCH 088/125] Corrected default return URL for BulkComponentCreateView --- netbox/dcim/views.py | 6 ++++++ netbox/utilities/views.py | 2 +- netbox/virtualization/views.py | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index ab0bfbbc9..c53f81ecb 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1580,6 +1580,7 @@ class DeviceBulkAddConsolePortView(PermissionRequiredMixin, BulkComponentCreateV model = ConsolePort model_form = forms.ConsolePortForm table = tables.DeviceTable + default_return_url = 'dcim:device_list' class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, BulkComponentCreateView): @@ -1590,6 +1591,7 @@ class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, BulkComponentC model = ConsoleServerPort model_form = forms.ConsoleServerPortForm table = tables.DeviceTable + default_return_url = 'dcim:device_list' class DeviceBulkAddPowerPortView(PermissionRequiredMixin, BulkComponentCreateView): @@ -1600,6 +1602,7 @@ class DeviceBulkAddPowerPortView(PermissionRequiredMixin, BulkComponentCreateVie model = PowerPort model_form = forms.PowerPortForm table = tables.DeviceTable + default_return_url = 'dcim:device_list' class DeviceBulkAddPowerOutletView(PermissionRequiredMixin, BulkComponentCreateView): @@ -1610,6 +1613,7 @@ class DeviceBulkAddPowerOutletView(PermissionRequiredMixin, BulkComponentCreateV model = PowerOutlet model_form = forms.PowerOutletForm table = tables.DeviceTable + default_return_url = 'dcim:device_list' class DeviceBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateView): @@ -1620,6 +1624,7 @@ class DeviceBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateVie model = Interface model_form = forms.InterfaceForm table = tables.DeviceTable + default_return_url = 'dcim:device_list' class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateView): @@ -1630,6 +1635,7 @@ class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateVie model = DeviceBay model_form = forms.DeviceBayForm table = tables.DeviceTable + default_return_url = 'dcim:device_list' # diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 29f0d9b93..89acf5bcb 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -869,5 +869,5 @@ class BulkComponentCreateView(View): 'form': form, 'component_name': self.model._meta.verbose_name_plural, 'table': table, - 'return_url': reverse('dcim:device_list'), + 'return_url': reverse(self.default_return_url), }) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 6dcb685d6..08d41d170 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -358,3 +358,4 @@ class VirtualMachineBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentC model = Interface model_form = forms.InterfaceForm table = tables.VirtualMachineTable + default_return_url = 'virtualization:virtualmachine_list' From 77182d87118b02a4306d3d0812b3ab3eb8d82958 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 29 Sep 2017 14:27:42 -0400 Subject: [PATCH 089/125] Redesigned the navigation menus --- netbox/project-static/css/base.css | 13 + netbox/templates/_base.html | 294 +-------------------- netbox/templates/inc/nav_menu.html | 410 +++++++++++++++++++++++++++++ 3 files changed, 424 insertions(+), 293 deletions(-) create mode 100644 netbox/templates/inc/nav_menu.html diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index e8167d916..b65f188d8 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -85,6 +85,19 @@ footer p { li.subnav > a { padding-left: 30px; } +ul.dropdown-menu { + width: 250px; +} +ul.dropdown-menu div.buttons { + padding-right: 10px; + padding-top: 2px; +} +ul.dropdown-menu div.buttons a { + width: 26px; +} +ul.dropdown-menu > li > a { + clear: left; +} /* Forms */ label { diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index 9b54ef61c..1a187a681 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -12,299 +12,7 @@ - + {% include 'inc/nav_menu.html' %}
    {% if settings.BANNER_TOP %} {% endif %} - Interface Connections + Interface Connections From b41b4fc584f900dc0b84a92c3e6311d18c51d026 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 9 Oct 2017 13:44:22 -0400 Subject: [PATCH 092/125] Closes #1506: Extended rack facility ID field from 30 to 50 characters --- .../0046_rack_lengthen_facility_id.py | 21 +++++++++++++++++++ netbox/dcim/models.py | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 netbox/dcim/migrations/0046_rack_lengthen_facility_id.py diff --git a/netbox/dcim/migrations/0046_rack_lengthen_facility_id.py b/netbox/dcim/migrations/0046_rack_lengthen_facility_id.py new file mode 100644 index 000000000..d04006524 --- /dev/null +++ b/netbox/dcim/migrations/0046_rack_lengthen_facility_id.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-10-09 17:43 +from __future__ import unicode_literals + +from django.db import migrations +import utilities.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0045_devicerole_vm_role'), + ] + + operations = [ + migrations.AlterField( + model_name='rack', + name='facility_id', + field=utilities.fields.NullableCharField(blank=True, max_length=50, null=True, verbose_name='Facility ID'), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 6eaf4b39c..45d46d92f 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -217,7 +217,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): Each Rack is assigned to a Site and (optionally) a RackGroup. """ name = models.CharField(max_length=50) - facility_id = NullableCharField(max_length=30, blank=True, null=True, verbose_name='Facility ID') + facility_id = NullableCharField(max_length=50, blank=True, null=True, verbose_name='Facility ID') site = models.ForeignKey('Site', related_name='racks', on_delete=models.PROTECT) group = models.ForeignKey('RackGroup', related_name='racks', blank=True, null=True, on_delete=models.SET_NULL) tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='racks', on_delete=models.PROTECT) From b734599f4001daf2643d15322411299d7f53a79b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 9 Oct 2017 13:59:53 -0400 Subject: [PATCH 093/125] Closes #1557: Added filtering for virtual machine interfaces --- netbox/virtualization/api/views.py | 1 + netbox/virtualization/filters.py | 37 +++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 315e1404e..bae8d5f90 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -46,3 +46,4 @@ class InterfaceViewSet(WritableSerializerMixin, ModelViewSet): queryset = Interface.objects.filter(virtual_machine__isnull=False).select_related('virtual_machine') serializer_class = serializers.InterfaceSerializer write_serializer_class = serializers.WritableInterfaceSerializer + filter_class = filters.InterfaceFilter diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index 31a002009..870ac85c6 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -1,9 +1,12 @@ from __future__ import unicode_literals import django_filters +from netaddr import EUI +from netaddr.core import AddrFormatError + from django.db.models import Q -from dcim.models import DeviceRole, Platform, Site +from dcim.models import DeviceRole, Interface, Platform, Site from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter @@ -126,3 +129,35 @@ class VirtualMachineFilter(CustomFieldFilterSet): Q(name__icontains=value) | Q(comments__icontains=value) ) + + +class InterfaceFilter(django_filters.FilterSet): + virtual_machine_id = django_filters.ModelMultipleChoiceFilter( + name='virtual_machine', + queryset=VirtualMachine.objects.all(), + label='Virtual machine (ID)', + ) + virtual_machine = django_filters.ModelMultipleChoiceFilter( + name='virtual_machine__name', + queryset=VirtualMachine.objects.all(), + to_field_name='name', + label='Virtual machine', + ) + mac_address = django_filters.CharFilter( + method='_mac_address', + label='MAC address', + ) + + class Meta: + model = Interface + fields = ['name', 'enabled', 'mtu'] + + def _mac_address(self, queryset, name, value): + value = value.strip() + if not value: + return queryset + try: + mac = EUI(value.strip()) + return queryset.filter(mac_address=mac) + except AddrFormatError: + return queryset.none() From 100d979d3e0c82f0c7c2321c51aeaca048e527c0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 9 Oct 2017 14:27:31 -0400 Subject: [PATCH 094/125] Fixes #1554: Don't require form_factor when creating an interface assigned to a virtual machine --- netbox/dcim/constants.py | 10 ++++------ netbox/dcim/models.py | 4 ++-- netbox/virtualization/api/serializers.py | 6 +++--- netbox/virtualization/forms.py | 7 ++++++- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index b3d434c85..dca952a88 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -93,15 +93,13 @@ IFACE_FF_JUNIPER_VCP = 5200 # Other IFACE_FF_OTHER = 32767 -VIFACE_FF_CHOICES = [ - [IFACE_FF_VIRTUAL, 'Virtual'], - [IFACE_FF_LAG, 'Link Aggregation Group (LAG)'], -] - IFACE_FF_CHOICES = [ [ 'Virtual interfaces', - VIFACE_FF_CHOICES, + [ + [IFACE_FF_VIRTUAL, 'Virtual'], + [IFACE_FF_LAG, 'Link Aggregation Group (LAG)'], + ], ], [ 'Ethernet (fixed)', diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 45d46d92f..860475ad5 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1303,9 +1303,9 @@ class Interface(models.Model): raise ValidationError("An interface must belong to either a device or a virtual machine.") # VM interfaces must be virtual - if self.virtual_machine and self.form_factor not in VIRTUAL_IFACE_TYPES: + if self.virtual_machine and self.form_factor is not IFACE_FF_VIRTUAL: raise ValidationError({ - 'form_factor': "Virtual machines cannot have physical interfaces." + 'form_factor': "Virtual machines can only have virtual interfaces." }) # Virtual interfaces cannot be connected diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 68f5118bf..b53734734 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals from rest_framework import serializers from dcim.api.serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer -from dcim.constants import VIFACE_FF_CHOICES +from dcim.constants import IFACE_FF_VIRTUAL from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer from tenancy.api.serializers import NestedTenantSerializer @@ -122,12 +122,11 @@ class WritableVirtualMachineSerializer(CustomFieldModelSerializer): class InterfaceSerializer(serializers.ModelSerializer): virtual_machine = NestedVirtualMachineSerializer() - form_factor = ChoiceFieldSerializer(choices=VIFACE_FF_CHOICES) class Meta: model = Interface fields = [ - 'id', 'name', 'virtual_machine', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description', + 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'description', ] @@ -140,6 +139,7 @@ class NestedInterfaceSerializer(serializers.ModelSerializer): class WritableInterfaceSerializer(ValidatedModelSerializer): + form_factor = serializers.IntegerField(default=IFACE_FF_VIRTUAL) class Meta: model = Interface diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index fb938ab1f..91c65c8f1 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -6,7 +6,7 @@ from django import forms from django.core.exceptions import ValidationError from django.db.models import Count -from dcim.constants import IFACE_FF_VIRTUAL, VIFACE_FF_CHOICES +from dcim.constants import IFACE_FF_VIRTUAL from dcim.formfields import MACAddressFormField from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm @@ -21,6 +21,11 @@ from .constants import STATUS_CHOICES from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine +VIFACE_FF_CHOICES = ( + (IFACE_FF_VIRTUAL, 'Virtual'), +) + + # # Cluster types # From 960e1d49c23eade78066bebd37da838220cb2103 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 9 Oct 2017 14:45:10 -0400 Subject: [PATCH 095/125] Closes #1551: Added CPAK, CFP2, and CFP4 100GE interface form factors --- netbox/dcim/constants.py | 6 +++++ .../0047_more_100ge_form_factors.py | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 netbox/dcim/migrations/0047_more_100ge_form_factors.py diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index dca952a88..b355e6c85 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -66,6 +66,9 @@ IFACE_FF_10GE_X2 = 1320 IFACE_FF_25GE_SFP28 = 1350 IFACE_FF_40GE_QSFP_PLUS = 1400 IFACE_FF_100GE_CFP = 1500 +IFACE_FF_100GE_CFP2 = 1510 +IFACE_FF_100GE_CFP4 = 1520 +IFACE_FF_100GE_CPAK = 1550 IFACE_FF_100GE_QSFP28 = 1600 # Wireless IFACE_FF_80211A = 2600 @@ -122,6 +125,9 @@ IFACE_FF_CHOICES = [ [IFACE_FF_25GE_SFP28, 'SFP28 (25GE)'], [IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'], [IFACE_FF_100GE_CFP, 'CFP (100GE)'], + [IFACE_FF_100GE_CFP2, 'CFP2 (100GE)'], + [IFACE_FF_100GE_CFP4, 'CFP4 (100GE)'], + [IFACE_FF_100GE_CPAK, 'Cisco CPAK (100GE)'], [IFACE_FF_100GE_QSFP28, 'QSFP28 (100GE)'], ] ], diff --git a/netbox/dcim/migrations/0047_more_100ge_form_factors.py b/netbox/dcim/migrations/0047_more_100ge_form_factors.py new file mode 100644 index 000000000..dafa81a54 --- /dev/null +++ b/netbox/dcim/migrations/0047_more_100ge_form_factors.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-10-09 18:43 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0046_rack_lengthen_facility_id'), + ] + + operations = [ + migrations.AlterField( + model_name='interface', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200), + ), + ] From 35c2c8e8de9994f637cca5352a5173a3b72fbc9d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 9 Oct 2017 15:01:57 -0400 Subject: [PATCH 096/125] #1444: Added a serial number field to the rack model --- netbox/dcim/api/serializers.py | 8 +++--- netbox/dcim/filters.py | 3 ++- netbox/dcim/forms.py | 12 +++++---- netbox/dcim/migrations/0048_rack_serial.py | 20 +++++++++++++++ netbox/dcim/models.py | 4 ++- netbox/templates/dcim/rack.html | 29 +++++++++++++++++----- netbox/templates/dcim/rack_edit.html | 1 + 7 files changed, 60 insertions(+), 17 deletions(-) create mode 100644 netbox/dcim/migrations/0048_rack_serial.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 46dfd7682..fa0f6dc14 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -142,8 +142,8 @@ class RackSerializer(CustomFieldModelSerializer): class Meta: model = Rack fields = [ - 'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', - 'desc_units', 'comments', 'custom_fields', + 'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width', + 'u_height', 'desc_units', 'comments', 'custom_fields', ] @@ -160,8 +160,8 @@ class WritableRackSerializer(CustomFieldModelSerializer): class Meta: model = Rack fields = [ - 'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units', - 'comments', 'custom_fields', + 'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width', 'u_height', + 'desc_units', 'comments', 'custom_fields', ] # Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This # prevents facility_id from being interpreted as a required field. diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 50a4e0f98..6e0c6fa3d 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -159,7 +159,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = Rack - fields = ['type', 'width', 'u_height', 'desc_units'] + fields = ['serial', 'type', 'width', 'u_height', 'desc_units'] def search(self, queryset, name, value): if not value.strip(): @@ -167,6 +167,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): return queryset.filter( Q(name__icontains=value) | Q(facility_id__icontains=value) | + Q(serial__icontains=value.strip()) | Q(comments__icontains=value) ) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 3c1c2e383..1fb156d2f 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -232,8 +232,8 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm): class Meta: model = Rack fields = [ - 'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'role', 'type', 'width', 'u_height', - 'desc_units', 'comments', + 'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'role', 'serial', 'type', 'width', + 'u_height', 'desc_units', 'comments', ] help_texts = { 'site': "The site at which the rack exists", @@ -293,7 +293,8 @@ class RackCSVForm(forms.ModelForm): class Meta: model = Rack fields = [ - 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units', + 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'serial', 'type', 'width', 'u_height', + 'desc_units', ] help_texts = { 'name': 'Rack name', @@ -321,6 +322,7 @@ class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group') tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) role = forms.ModelChoiceField(queryset=RackRole.objects.all(), required=False) + serial = forms.CharField(max_length=50, required=False, label='Serial Number') type = forms.ChoiceField(choices=add_blank_choice(RACK_TYPE_CHOICES), required=False, label='Type') width = forms.ChoiceField(choices=add_blank_choice(RACK_WIDTH_CHOICES), required=False, label='Width') u_height = forms.IntegerField(required=False, label='Height (U)') @@ -328,7 +330,7 @@ class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): comments = CommentField(widget=SmallTextarea) class Meta: - nullable_fields = ['group', 'tenant', 'role', 'comments'] + nullable_fields = ['group', 'tenant', 'role', 'serial', 'comments'] class RackFilterForm(BootstrapMixin, CustomFieldFilterForm): @@ -938,7 +940,7 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): serial = forms.CharField(max_length=50, required=False, label='Serial Number') class Meta: - nullable_fields = ['tenant', 'platform'] + nullable_fields = ['tenant', 'platform', 'serial'] def device_status_choices(): diff --git a/netbox/dcim/migrations/0048_rack_serial.py b/netbox/dcim/migrations/0048_rack_serial.py new file mode 100644 index 000000000..8e060c865 --- /dev/null +++ b/netbox/dcim/migrations/0048_rack_serial.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-10-09 18:50 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0047_more_100ge_form_factors'), + ] + + operations = [ + migrations.AddField( + model_name='rack', + name='serial', + field=models.CharField(blank=True, max_length=50, verbose_name='Serial number'), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 860475ad5..9120f94e3 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -222,6 +222,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): group = models.ForeignKey('RackGroup', related_name='racks', blank=True, null=True, on_delete=models.SET_NULL) tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='racks', on_delete=models.PROTECT) role = models.ForeignKey('RackRole', related_name='racks', blank=True, null=True, on_delete=models.PROTECT) + serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number') type = models.PositiveSmallIntegerField(choices=RACK_TYPE_CHOICES, blank=True, null=True, verbose_name='Type') width = models.PositiveSmallIntegerField(choices=RACK_WIDTH_CHOICES, default=RACK_WIDTH_19IN, verbose_name='Width', help_text='Rail-to-rail width') @@ -236,7 +237,8 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): objects = RackManager() csv_headers = [ - 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units', + 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height', + 'desc_units', ] class Meta: diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 2adb78e92..4463ee21b 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -112,6 +112,29 @@ {% endif %} + + Serial Number + + {% if rack.serial %} + {{ rack.serial }} + {% else %} + N/A + {% endif %} + + + + Devices + + {{ rack.devices.count }} + + + +
    +
    +
    + Dimensions +
    + - - - -
    Type @@ -130,12 +153,6 @@ Height {{ rack.u_height }}U ({% if rack.desc_units %}descending{% else %}ascending{% endif %})
    Devices - {{ rack.devices.count }} -
    {% with rack.get_custom_fields as custom_fields %} diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index d7a6f0dfb..4ab129a1d 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -10,6 +10,7 @@ {% render_field form.facility_id %} {% render_field form.group %} {% render_field form.role %} + {% render_field form.serial %}
    From 42e557bd3f9f0ebb518a71fed459c89fb3081441 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 9 Oct 2017 15:09:40 -0400 Subject: [PATCH 097/125] Added bulk import view for tenant groups --- netbox/templates/inc/nav_menu.html | 2 +- netbox/tenancy/forms.py | 11 +++++++++++ netbox/tenancy/urls.py | 1 + netbox/tenancy/views.py | 7 +++++++ 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index c61e99394..c260eeec3 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -53,7 +53,7 @@ {% if perms.tenancy.add_tenantgroup %}
    - +
    {% endif %} Tenant Groups diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 969050841..5eb3bda61 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -22,6 +22,17 @@ class TenantGroupForm(BootstrapMixin, forms.ModelForm): fields = ['name', 'slug'] +class TenantGroupCSVForm(forms.ModelForm): + slug = SlugField() + + class Meta: + model = TenantGroup + fields = ['name', 'slug'] + help_texts = { + 'name': 'Group name', + } + + # # Tenants # diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index 76d800344..a0337a938 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -11,6 +11,7 @@ urlpatterns = [ # Tenant groups url(r'^tenant-groups/$', views.TenantGroupListView.as_view(), name='tenantgroup_list'), url(r'^tenant-groups/add/$', views.TenantGroupCreateView.as_view(), name='tenantgroup_add'), + url(r'^tenant-groups/import/$', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'), url(r'^tenant-groups/delete/$', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'), url(r'^tenant-groups/(?P[\w-]+)/edit/$', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'), diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 9bcfee82d..32709dfb7 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -40,6 +40,13 @@ class TenantGroupEditView(TenantGroupCreateView): permission_required = 'tenancy.change_tenantgroup' +class TenantGroupBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'tenancy.add_tenantgroup' + model_form = forms.TenantGroupCSVForm + table = tables.TenantGroupTable + default_return_url = 'tenancy:tenantgroup_list' + + class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'tenancy.delete_tenantgroup' cls = TenantGroup From 9351f686b1082067715fc96af268c3b470c9feaa Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 9 Oct 2017 15:28:46 -0400 Subject: [PATCH 098/125] Added bulk import views for rack roles, device roles, and platforms --- netbox/dcim/forms.py | 36 ++++++++++++++++++++++++++++++ netbox/dcim/urls.py | 3 +++ netbox/dcim/views.py | 22 +++++++++++++++++- netbox/templates/inc/nav_menu.html | 6 ++--- 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 1fb156d2f..da21f2859 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -212,6 +212,18 @@ class RackRoleForm(BootstrapMixin, forms.ModelForm): fields = ['name', 'slug', 'color'] +class RackRoleCSVForm(forms.ModelForm): + slug = SlugField() + + class Meta: + model = RackRole + fields = ['name', 'slug', 'color'] + help_texts = { + 'name': 'Name of rack role', + 'color': 'RGB color in hexadecimal (e.g. 00ff00)' + } + + # # Racks # @@ -622,6 +634,18 @@ class DeviceRoleForm(BootstrapMixin, forms.ModelForm): fields = ['name', 'slug', 'color', 'vm_role'] +class DeviceRoleCSVForm(forms.ModelForm): + slug = SlugField() + + class Meta: + model = DeviceRole + fields = ['name', 'slug', 'color', 'vm_role'] + help_texts = { + 'name': 'Name of device role', + 'color': 'RGB color in hexadecimal (e.g. 00ff00)' + } + + # # Platforms # @@ -634,6 +658,18 @@ class PlatformForm(BootstrapMixin, forms.ModelForm): fields = ['name', 'slug', 'napalm_driver', 'rpc_client'] +class PlatformCSVForm(forms.ModelForm): + slug = SlugField() + + class Meta: + model = Platform + fields = ['name', 'slug', 'napalm_driver'] + help_texts = { + 'name': 'Platform name', + # 'color': 'RGB color in hexadecimal (e.g. 00ff00)' + } + + # # Devices # diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index a993daa28..633cf9a8b 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -39,6 +39,7 @@ urlpatterns = [ # Rack roles url(r'^rack-roles/$', views.RackRoleListView.as_view(), name='rackrole_list'), url(r'^rack-roles/add/$', views.RackRoleCreateView.as_view(), name='rackrole_add'), + url(r'^rack-roles/import/$', views.RackRoleBulkImportView.as_view(), name='rackrole_import'), url(r'^rack-roles/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), url(r'^rack-roles/(?P\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'), @@ -106,12 +107,14 @@ urlpatterns = [ # Device roles url(r'^device-roles/$', views.DeviceRoleListView.as_view(), name='devicerole_list'), url(r'^device-roles/add/$', views.DeviceRoleCreateView.as_view(), name='devicerole_add'), + url(r'^device-roles/import/$', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'), url(r'^device-roles/delete/$', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), url(r'^device-roles/(?P[\w-]+)/edit/$', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), # Platforms url(r'^platforms/$', views.PlatformListView.as_view(), name='platform_list'), url(r'^platforms/add/$', views.PlatformCreateView.as_view(), name='platform_add'), + url(r'^platforms/import/$', views.PlatformBulkImportView.as_view(), name='platform_import'), url(r'^platforms/delete/$', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), url(r'^platforms/(?P[\w-]+)/edit/$', views.PlatformEditView.as_view(), name='platform_edit'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index ad87466b2..387af4b61 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,5 +1,4 @@ from __future__ import unicode_literals -from copy import deepcopy import re from natsort import natsorted from operator import attrgetter @@ -273,6 +272,13 @@ class RackRoleEditView(RackRoleCreateView): permission_required = 'dcim.change_rackrole' +class RackRoleBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_rackrole' + model_form = forms.RackRoleCSVForm + table = tables.RackRoleTable + default_return_url = 'dcim:rackrole_list' + + class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rackrole' cls = RackRole @@ -738,6 +744,13 @@ class DeviceRoleEditView(DeviceRoleCreateView): permission_required = 'dcim.change_devicerole' +class DeviceRoleBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_devicerole' + model_form = forms.DeviceRoleCSVForm + table = tables.DeviceRoleTable + default_return_url = 'dcim:devicerole_list' + + class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicerole' cls = DeviceRole @@ -769,6 +782,13 @@ class PlatformEditView(PlatformCreateView): permission_required = 'dcim.change_platform' +class PlatformBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_platform' + model_form = forms.PlatformCSVForm + table = tables.PlatformTable + default_return_url = 'dcim:platform_list' + + class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_platform' cls = Platform diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index c260eeec3..64b3c9678 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -91,7 +91,7 @@ {% if perms.dcim.add_rackrole %}
    - +
    {% endif %} Rack Roles @@ -121,7 +121,7 @@ {% if perms.dcim.add_devicerole %}
    - +
    {% endif %} Device Roles @@ -130,7 +130,7 @@ {% if perms.dcim.add_platform %}
    - +
    {% endif %} Platforms From 987587b5f32a6d142e44cf4d6426ccc4d70961a4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 9 Oct 2017 15:30:48 -0400 Subject: [PATCH 099/125] Added NAPALM driver to platform table; removed legacy RPC client --- netbox/dcim/tables.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 1a24035b0..2a0127389 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -381,13 +381,12 @@ class PlatformTable(BaseTable): name = tables.LinkColumn(verbose_name='Name') device_count = tables.Column(verbose_name='Devices') slug = tables.Column(verbose_name='Slug') - rpc_client = tables.Column(accessor='get_rpc_client_display', orderable=False, verbose_name='RPC Client') actions = tables.TemplateColumn(template_code=PLATFORM_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='') class Meta(BaseTable.Meta): model = Platform - fields = ('pk', 'name', 'device_count', 'slug', 'rpc_client', 'actions') + fields = ('pk', 'name', 'device_count', 'slug', 'napalm_driver', 'actions') # From 977cad3830e90117d7e16bebcb7074d957400376 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 9 Oct 2017 15:42:06 -0400 Subject: [PATCH 100/125] Added bulk import views for RIRs, roles, and VLAN groups --- netbox/dcim/forms.py | 1 - netbox/ipam/forms.py | 42 ++++++++++++++++++++++++++++++ netbox/ipam/urls.py | 3 +++ netbox/ipam/views.py | 21 +++++++++++++++ netbox/templates/inc/nav_menu.html | 6 ++--- 5 files changed, 69 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index da21f2859..820477e24 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -666,7 +666,6 @@ class PlatformCSVForm(forms.ModelForm): fields = ['name', 'slug', 'napalm_driver'] help_texts = { 'name': 'Platform name', - # 'color': 'RGB color in hexadecimal (e.g. 00ff00)' } diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 1e4c3ed2d..5ed405c8d 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -97,6 +97,17 @@ class RIRForm(BootstrapMixin, forms.ModelForm): fields = ['name', 'slug', 'is_private'] +class RIRCSVForm(forms.ModelForm): + slug = SlugField() + + class Meta: + model = RIR + fields = ['name', 'slug', 'is_private'] + help_texts = { + 'name': 'RIR name', + } + + class RIRFilterForm(BootstrapMixin, forms.Form): is_private = forms.NullBooleanField(required=False, label='Private', widget=forms.Select(choices=[ ('', '---------'), @@ -169,6 +180,17 @@ class RoleForm(BootstrapMixin, forms.ModelForm): fields = ['name', 'slug'] +class RoleCSVForm(forms.ModelForm): + slug = SlugField() + + class Meta: + model = Role + fields = ['name', 'slug'] + help_texts = { + 'name': 'Role name', + } + + # # Prefixes # @@ -718,6 +740,26 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm): fields = ['site', 'name', 'slug'] +class VLANGroupCSVForm(forms.ModelForm): + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False, + to_field_name='name', + help_text='Name of parent site', + error_messages={ + 'invalid_choice': 'Site not found.', + } + ) + slug = SlugField() + + class Meta: + model = VLANGroup + fields = ['site', 'name', 'slug'] + help_texts = { + 'name': 'Name of VLAN group', + } + + class VLANGroupFilterForm(BootstrapMixin, forms.Form): site = FilterChoiceField( queryset=Site.objects.annotate(filter_count=Count('vlan_groups')), diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 15634c0ee..c5723ef94 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -21,6 +21,7 @@ urlpatterns = [ # RIRs url(r'^rirs/$', views.RIRListView.as_view(), name='rir_list'), url(r'^rirs/add/$', views.RIRCreateView.as_view(), name='rir_add'), + url(r'^rirs/import/$', views.RIRBulkImportView.as_view(), name='rir_import'), url(r'^rirs/delete/$', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'), url(r'^rirs/(?P[\w-]+)/edit/$', views.RIREditView.as_view(), name='rir_edit'), @@ -37,6 +38,7 @@ urlpatterns = [ # Roles url(r'^roles/$', views.RoleListView.as_view(), name='role_list'), url(r'^roles/add/$', views.RoleCreateView.as_view(), name='role_add'), + url(r'^roles/import/$', views.RoleBulkImportView.as_view(), name='role_import'), url(r'^roles/delete/$', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'), url(r'^roles/(?P[\w-]+)/edit/$', views.RoleEditView.as_view(), name='role_edit'), @@ -65,6 +67,7 @@ urlpatterns = [ # VLAN groups url(r'^vlan-groups/$', views.VLANGroupListView.as_view(), name='vlangroup_list'), url(r'^vlan-groups/add/$', views.VLANGroupCreateView.as_view(), name='vlangroup_add'), + url(r'^vlan-groups/import/$', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'), url(r'^vlan-groups/delete/$', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'), url(r'^vlan-groups/(?P\d+)/edit/$', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 1723b08d7..7cf24614f 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -261,6 +261,13 @@ class RIREditView(RIRCreateView): permission_required = 'ipam.change_rir' +class RIRBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'ipam.add_rir' + model_form = forms.RIRCSVForm + table = tables.RIRTable + default_return_url = 'ipam:rir_list' + + class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_rir' cls = RIR @@ -407,6 +414,13 @@ class RoleEditView(RoleCreateView): permission_required = 'ipam.change_role' +class RoleBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'ipam.add_role' + model_form = forms.RoleCSVForm + table = tables.RoleTable + default_return_url = 'ipam:role_list' + + class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_role' cls = Role @@ -745,6 +759,13 @@ class VLANGroupEditView(VLANGroupCreateView): permission_required = 'ipam.change_vlangroup' +class VLANGroupBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'ipam.add_vlangroup' + model_form = forms.VLANGroupCSVForm + table = tables.VLANGroupTable + default_return_url = 'ipam:vlangroup_list' + + class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_vlangroup' cls = VLANGroup diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index 64b3c9678..cd1e96aa5 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -211,7 +211,7 @@ {% if perms.ipam.add_role %}
    - +
    {% endif %} Prefix/VLAN Roles @@ -231,7 +231,7 @@ {% if perms.ipam.add_rir %}
    - +
    {% endif %} RIRs @@ -262,7 +262,7 @@ {% if perms.ipam.add_vlangroup %}
    - +
    {% endif %} VLAN Groups From a38cd449c55d8069ee955f0e56b5e5c8fb8e2666 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 9 Oct 2017 15:49:48 -0400 Subject: [PATCH 101/125] Added bulk import views for cluster types and groups --- netbox/templates/inc/nav_menu.html | 4 ++-- netbox/virtualization/forms.py | 22 ++++++++++++++++++++++ netbox/virtualization/tables.py | 2 ++ netbox/virtualization/urls.py | 2 ++ netbox/virtualization/views.py | 14 ++++++++++++++ 5 files changed, 42 insertions(+), 2 deletions(-) diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index cd1e96aa5..65e6be224 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -297,7 +297,7 @@ {% if perms.virtualization.add_clustertype %}
    - +
    {% endif %} Cluster Types @@ -306,7 +306,7 @@ {% if perms.virtualization.add_clustergroup %}
    - +
    {% endif %} Cluster Groups diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 91c65c8f1..461c1cf11 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -38,6 +38,17 @@ class ClusterTypeForm(BootstrapMixin, forms.ModelForm): fields = ['name', 'slug'] +class ClusterTypeCSVForm(forms.ModelForm): + slug = SlugField() + + class Meta: + model = ClusterType + fields = ['name', 'slug'] + help_texts = { + 'name': 'Name of cluster type', + } + + # # Cluster groups # @@ -50,6 +61,17 @@ class ClusterGroupForm(BootstrapMixin, forms.ModelForm): fields = ['name', 'slug'] +class ClusterGroupCSVForm(forms.ModelForm): + slug = SlugField() + + class Meta: + model = ClusterGroup + fields = ['name', 'slug'] + help_texts = { + 'name': 'Name of cluster group', + } + + # # Clusters # diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index a40af6091..f7b7ffbe9 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -37,6 +37,7 @@ VIRTUALMACHINE_PRIMARY_IP = """ class ClusterTypeTable(BaseTable): pk = ToggleColumn() + name = tables.LinkColumn() cluster_count = tables.Column(verbose_name='Clusters') actions = tables.TemplateColumn( template_code=CLUSTERTYPE_ACTIONS, @@ -55,6 +56,7 @@ class ClusterTypeTable(BaseTable): class ClusterGroupTable(BaseTable): pk = ToggleColumn() + name = tables.LinkColumn() cluster_count = tables.Column(verbose_name='Clusters') actions = tables.TemplateColumn( template_code=CLUSTERGROUP_ACTIONS, diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index 9d976f300..ca794cdd8 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -12,12 +12,14 @@ urlpatterns = [ # Cluster types url(r'^cluster-types/$', views.ClusterTypeListView.as_view(), name='clustertype_list'), url(r'^cluster-types/add/$', views.ClusterTypeCreateView.as_view(), name='clustertype_add'), + url(r'^cluster-types/import/$', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'), url(r'^cluster-types/delete/$', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'), url(r'^cluster-types/(?P[\w-]+)/edit/$', views.ClusterTypeEditView.as_view(), name='clustertype_edit'), # Cluster groups url(r'^cluster-groups/$', views.ClusterGroupListView.as_view(), name='clustergroup_list'), url(r'^cluster-groups/add/$', views.ClusterGroupCreateView.as_view(), name='clustergroup_add'), + url(r'^cluster-groups/import/$', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'), url(r'^cluster-groups/delete/$', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'), url(r'^cluster-groups/(?P[\w-]+)/edit/$', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'), diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 08d41d170..ad49b7dec 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -43,6 +43,13 @@ class ClusterTypeEditView(ClusterTypeCreateView): permission_required = 'virtualization.change_clustertype' +class ClusterTypeBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'virtualization.add_clustertype' + model_form = forms.ClusterTypeCSVForm + table = tables.ClusterTypeTable + default_return_url = 'virtualization:clustertype_list' + + class ClusterTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'virtualization.delete_clustertype' cls = ClusterType @@ -74,6 +81,13 @@ class ClusterGroupEditView(ClusterGroupCreateView): permission_required = 'virtualization.change_clustergroup' +class ClusterGroupBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'virtualization.add_clustergroup' + model_form = forms.ClusterGroupCSVForm + table = tables.ClusterGroupTable + default_return_url = 'virtualization:clustergroup_list' + + class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'virtualization.delete_clustergroup' cls = ClusterGroup From 2c9b41db7514feab46236f3f8cd30bb5f3c441fd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 9 Oct 2017 15:52:22 -0400 Subject: [PATCH 102/125] Added bulk import view for circuit types --- netbox/circuits/forms.py | 11 +++++++++++ netbox/circuits/urls.py | 1 + netbox/circuits/views.py | 7 +++++++ netbox/templates/inc/nav_menu.html | 2 +- 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 2a2cd6843..1e60cad57 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -85,6 +85,17 @@ class CircuitTypeForm(BootstrapMixin, forms.ModelForm): fields = ['name', 'slug'] +class CircuitTypeCSVForm(forms.ModelForm): + slug = SlugField() + + class Meta: + model = CircuitType + fields = ['name', 'slug'] + help_texts = { + 'name': 'Name of circuit type', + } + + # # Circuits # diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 7dd72ad9d..636ce5205 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -21,6 +21,7 @@ urlpatterns = [ # Circuit types url(r'^circuit-types/$', views.CircuitTypeListView.as_view(), name='circuittype_list'), url(r'^circuit-types/add/$', views.CircuitTypeCreateView.as_view(), name='circuittype_add'), + url(r'^circuit-types/import/$', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'), url(r'^circuit-types/delete/$', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), url(r'^circuit-types/(?P[\w-]+)/edit/$', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 467450346..4a49e713a 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -114,6 +114,13 @@ class CircuitTypeEditView(CircuitTypeCreateView): permission_required = 'circuits.change_circuittype' +class CircuitTypeBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'circuits.add_circuittype' + model_form = forms.CircuitTypeCSVForm + table = tables.CircuitTypeTable + default_return_url = 'circuits:circuittype_list' + + class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'circuits.delete_circuittype' cls = CircuitType diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index 65e6be224..9b4fa2624 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -330,7 +330,7 @@ {% if perms.circuits.add_circuittype %}
    - +
    {% endif %} Circuit Types From f4f0896470da72223ead489861cbbe95e513f334 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 9 Oct 2017 15:56:17 -0400 Subject: [PATCH 103/125] Added bulk import view for secret roles --- netbox/secrets/forms.py | 11 +++++++++++ netbox/secrets/urls.py | 1 + netbox/secrets/views.py | 7 +++++++ netbox/templates/inc/nav_menu.html | 3 +-- 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index e71bb92fa..4e8d6667c 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -43,6 +43,17 @@ class SecretRoleForm(BootstrapMixin, forms.ModelForm): fields = ['name', 'slug', 'users', 'groups'] +class SecretRoleCSVForm(forms.ModelForm): + slug = SlugField() + + class Meta: + model = SecretRole + fields = ['name', 'slug'] + help_texts = { + 'name': 'Name of secret role', + } + + # # Secrets # diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py index 961e0f0ed..ecba5741f 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -11,6 +11,7 @@ urlpatterns = [ # Secret roles url(r'^secret-roles/$', views.SecretRoleListView.as_view(), name='secretrole_list'), url(r'^secret-roles/add/$', views.SecretRoleCreateView.as_view(), name='secretrole_add'), + url(r'^secret-roles/import/$', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'), url(r'^secret-roles/delete/$', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'), url(r'^secret-roles/(?P[\w-]+)/edit/$', views.SecretRoleEditView.as_view(), name='secretrole_edit'), diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index d1febc245..6197faf10 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -52,6 +52,13 @@ class SecretRoleEditView(SecretRoleCreateView): permission_required = 'secrets.change_secretrole' +class SecretRoleBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'secrets.add_secretrole' + model_form = forms.SecretRoleCSVForm + table = tables.SecretRoleTable + default_return_url = 'secrets:secretrole_list' + + class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'secrets.delete_secretrole' cls = SecretRole diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index 9b4fa2624..c1dc9dc67 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -356,7 +356,6 @@
  • {% if perms.secrets.add_secret %}
    -
    {% endif %} @@ -366,7 +365,7 @@ {% if perms.secrets.add_secretrole %}
    - +
    {% endif %} Secret Roles From d17b36519ab624777cd965b98c46c4f88a755920 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 9 Oct 2017 16:03:45 -0400 Subject: [PATCH 104/125] Closes #1479: Added CARP IP address role --- netbox/ipam/constants.py | 2 ++ .../0020_ipaddress_add_role_carp.py | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 netbox/ipam/migrations/0020_ipaddress_add_role_carp.py diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index 3beb18823..8413d38c3 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -39,6 +39,7 @@ IPADDRESS_ROLE_VIP = 40 IPADDRESS_ROLE_VRRP = 41 IPADDRESS_ROLE_HSRP = 42 IPADDRESS_ROLE_GLBP = 43 +IPADDRESS_ROLE_CARP = 44 IPADDRESS_ROLE_CHOICES = ( (IPADDRESS_ROLE_LOOPBACK, 'Loopback'), (IPADDRESS_ROLE_SECONDARY, 'Secondary'), @@ -47,6 +48,7 @@ IPADDRESS_ROLE_CHOICES = ( (IPADDRESS_ROLE_VRRP, 'VRRP'), (IPADDRESS_ROLE_HSRP, 'HSRP'), (IPADDRESS_ROLE_GLBP, 'GLBP'), + (IPADDRESS_ROLE_CARP, 'CARP'), ) # VLAN statuses diff --git a/netbox/ipam/migrations/0020_ipaddress_add_role_carp.py b/netbox/ipam/migrations/0020_ipaddress_add_role_carp.py new file mode 100644 index 000000000..9d16be049 --- /dev/null +++ b/netbox/ipam/migrations/0020_ipaddress_add_role_carp.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-10-09 20:02 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0019_virtualization'), + ] + + operations = [ + migrations.AlterField( + model_name='ipaddress', + name='role', + field=models.PositiveSmallIntegerField(blank=True, choices=[(10, 'Loopback'), (20, 'Secondary'), (30, 'Anycast'), (40, 'VIP'), (41, 'VRRP'), (42, 'HSRP'), (43, 'GLBP'), (44, 'CARP')], help_text='The functional role of this IP', null=True, verbose_name='Role'), + ), + ] From ea1d298eb3a22909da95478c0e0770ebc3e80de7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 9 Oct 2017 16:25:52 -0400 Subject: [PATCH 105/125] Closes #494: Include asset tag in device info pop-up on rack elevation --- netbox/templates/dcim/inc/rack_elevation.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/dcim/inc/rack_elevation.html b/netbox/templates/dcim/inc/rack_elevation.html index 049dcbc61..e6b721db4 100644 --- a/netbox/templates/dcim/inc/rack_elevation.html +++ b/netbox/templates/dcim/inc/rack_elevation.html @@ -26,7 +26,7 @@
  • {% ifequal u.device.face face_id %} + data-content="{{ u.device.device_role }}
    {{ u.device.device_type.full_name }} ({{ u.device.device_type.u_height }}U){% if u.device.asset_tag %}
    {{ u.device.asset_tag }}{% endif %}"> {{ u.device.name|default:u.device.device_role }} {% if u.device.devicebay_count %} ({{ u.device.get_children.count }}/{{ u.device.devicebay_count }}) From c3008799957bdb97e3ab8f5717e69b8fcabf00f6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 9 Oct 2017 17:40:15 -0400 Subject: [PATCH 106/125] Fixes #1527: Replace deprecated pycrypto library with pycryptodome --- netbox/secrets/models.py | 21 +++++++-------------- old_requirements.txt | 1 + requirements.txt | 2 +- upgrade.sh | 5 +++++ 4 files changed, 14 insertions(+), 15 deletions(-) create mode 100644 old_requirements.txt diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index e8eb2ff45..b3dac91cb 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -1,8 +1,9 @@ from __future__ import unicode_literals import os -from Crypto.Cipher import AES, PKCS1_OAEP, XOR +from Crypto.Cipher import AES, PKCS1_OAEP from Crypto.PublicKey import RSA +from Crypto.Util import strxor from django.conf import settings from django.contrib.auth.hashers import make_password, check_password @@ -45,14 +46,6 @@ def decrypt_master_key(master_key_cipher, private_key): return cipher.decrypt(master_key_cipher) -def xor_keys(key_a, key_b): - """ - Return the binary XOR of two given keys. - """ - xor = XOR.new(key_a) - return xor.encrypt(key_b) - - class UserKeyQuerySet(models.QuerySet): def active(self): @@ -98,7 +91,7 @@ class UserKey(CreatedUpdatedModel): # Validate the public key format try: - pubkey = RSA.importKey(self.public_key) + pubkey = RSA.import_key(self.public_key) except ValueError: raise ValidationError({ 'public_key': "Invalid RSA key format." @@ -108,7 +101,7 @@ class UserKey(CreatedUpdatedModel): "uploading a valid RSA public key in PEM format (no SSH/PGP).") # Validate the public key length - pubkey_length = pubkey.size() + 1 # key.size() returns 1 less than the key modulus + pubkey_length = pubkey.size_in_bits() if pubkey_length < settings.SECRETS_MIN_PUBKEY_SIZE: raise ValidationError({ 'public_key': "Insufficient key length. Keys must be at least {} bits long.".format( @@ -214,7 +207,7 @@ class SessionKey(models.Model): self.hash = make_password(self.key) # Encrypt master key using the session key - self.cipher = xor_keys(self.key, master_key) + self.cipher = strxor.strxor(self.key, master_key) super(SessionKey, self).save(*args, **kwargs) @@ -225,14 +218,14 @@ class SessionKey(models.Model): raise InvalidKey("Invalid session key") # Decrypt master key using provided session key - master_key = xor_keys(session_key, bytes(self.cipher)) + master_key = strxor.strxor(session_key, bytes(self.cipher)) return master_key def get_session_key(self, master_key): # Recover session key using the master key - session_key = xor_keys(master_key, bytes(self.cipher)) + session_key = strxor.strxor(master_key, bytes(self.cipher)) # Validate the recovered session key if not check_password(session_key, self.hash): diff --git a/old_requirements.txt b/old_requirements.txt new file mode 100644 index 000000000..904545b4f --- /dev/null +++ b/old_requirements.txt @@ -0,0 +1 @@ +pycrypto diff --git a/requirements.txt b/requirements.txt index 82041879e..20c1dff27 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,6 +17,6 @@ paramiko>=2.0.0 Pillow>=4.0.0 psycopg2>=2.7.3 py-gfm>=0.1.3 -pycrypto>=2.6.1 +pycryptodome>=3.4.7 sqlparse>=0.2 xmltodict>=0.10.2 diff --git a/upgrade.sh b/upgrade.sh index 24189a424..a1930eb3d 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -39,6 +39,11 @@ COMMAND="${PREFIX}find . -name \"*.pyc\" -delete" echo "Cleaning up stale Python bytecode ($COMMAND)..." eval $COMMAND +# Uninstall any Python packages which are no longer needed +COMMAND="${PREFIX}${PIP} uninstall -r old_requirements.txt -y" +echo "Removing old Python packages ($COMMAND)..." +eval $COMMAND + # Install any new Python packages COMMAND="${PREFIX}${PIP} install -r requirements.txt --upgrade" echo "Updating required Python packages ($COMMAND)..." From 8403e91fc94f2f00790847cf03e8a52d6f342221 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 10 Oct 2017 13:22:40 -0400 Subject: [PATCH 107/125] Fixes #1567: Prompt user for session key when importing secrets --- netbox/project-static/js/secrets.js | 2 +- netbox/secrets/views.py | 2 + netbox/templates/secrets/secret_import.html | 66 +-------------------- netbox/utilities/views.py | 6 +- 4 files changed, 9 insertions(+), 67 deletions(-) diff --git a/netbox/project-static/js/secrets.js b/netbox/project-static/js/secrets.js index d1ec3d883..a592330fd 100644 --- a/netbox/project-static/js/secrets.js +++ b/netbox/project-static/js/secrets.js @@ -18,7 +18,7 @@ $(document).ready(function() { $('form').submit(function(event) { $(this).find('.requires-session-key').each(function() { if (this.value && document.cookie.indexOf('session_key') == -1) { - console.log('Field ' + this.value + ' requires a session key'); + console.log('Field ' + this.name + ' requires a session key'); $('#privkey_modal').modal('show'); event.preventDefault(); return false; diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index 6197faf10..e7f6939a9 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -203,7 +203,9 @@ class SecretBulkImportView(BulkImportView): permission_required = 'ipam.add_vlan' model_form = forms.SecretCSVForm table = tables.SecretTable + template_name = 'secrets/secret_import.html' default_return_url = 'secrets:secret_list' + widget_attrs = {'class': 'requires-session-key'} master_key = None diff --git a/netbox/templates/secrets/secret_import.html b/netbox/templates/secrets/secret_import.html index 1f12494ec..a460f80e8 100644 --- a/netbox/templates/secrets/secret_import.html +++ b/netbox/templates/secrets/secret_import.html @@ -1,70 +1,8 @@ -{% extends '_base.html' %} +{% extends 'utilities/obj_import.html' %} {% load static from staticfiles %} -{% load form_helpers %} {% block content %} -

    {% block title %}Secret Import{% endblock %}

    -
    - -
    -

    CSV Format

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    FieldDescriptionExample
    DeviceName of the parent deviceedge-router1
    RoleFunctional roleLogin Credentials
    Name (optional)Username or other labelroot
    SecretSecret dataMyP@ssw0rd!
    -

    Example

    -
    edge-router1,Login Credentials,root,MyP@ssw0rd!
    -
    -
    - +{{ block.super }} {% include 'secrets/inc/private_key_modal.html' %} {% endblock %} diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 89acf5bcb..d7b82236c 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -10,7 +10,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import transaction, IntegrityError from django.db.models import ProtectedError -from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, TypedChoiceField +from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea, TypedChoiceField from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.template import TemplateSyntaxError @@ -380,11 +380,13 @@ class BulkImportView(View): table: The django-tables2 Table used to render the list of imported objects template_name: The name of the template default_return_url: The name of the URL to use for the cancel button + widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key) """ model_form = None table = None default_return_url = None template_name = 'utilities/obj_import.html' + widget_attrs = {} def _import_form(self, *args, **kwargs): @@ -392,7 +394,7 @@ class BulkImportView(View): required_fields = [name for name, field in self.model_form().fields.items() if field.required] class ImportForm(BootstrapMixin, Form): - csv = CSVDataField(fields=fields, required_fields=required_fields) + csv = CSVDataField(fields=fields, required_fields=required_fields, widget=Textarea(attrs=self.widget_attrs)) return ImportForm(*args, **kwargs) From afbbe1148fd343989cb140a419967ce4b3bdabbd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 10 Oct 2017 14:21:20 -0400 Subject: [PATCH 108/125] Moved core API classes out of utilities --- netbox/netbox/api.py | 139 ++++++++++++++++++++++++++++++++++++++ netbox/netbox/settings.py | 10 +-- netbox/utilities/api.py | 131 +---------------------------------- 3 files changed, 145 insertions(+), 135 deletions(-) create mode 100644 netbox/netbox/api.py diff --git a/netbox/netbox/api.py b/netbox/netbox/api.py new file mode 100644 index 000000000..702334ddd --- /dev/null +++ b/netbox/netbox/api.py @@ -0,0 +1,139 @@ +from __future__ import unicode_literals + +from rest_framework import authentication, exceptions +from rest_framework.pagination import LimitOffsetPagination +from rest_framework.permissions import DjangoModelPermissions, SAFE_METHODS +from rest_framework.renderers import BrowsableAPIRenderer +from rest_framework.views import get_view_name as drf_get_view_name + +from users.models import Token + + +# +# Renderers +# + +class FormlessBrowsableAPIRenderer(BrowsableAPIRenderer): + """ + Override the built-in BrowsableAPIRenderer to disable HTML forms. + """ + def show_form_for_method(self, *args, **kwargs): + return False + + +# +# Authentication +# + +class TokenAuthentication(authentication.TokenAuthentication): + """ + A custom authentication scheme which enforces Token expiration times. + """ + model = Token + + def authenticate_credentials(self, key): + model = self.get_model() + try: + token = model.objects.select_related('user').get(key=key) + except model.DoesNotExist: + raise exceptions.AuthenticationFailed("Invalid token") + + # Enforce the Token's expiration time, if one has been set. + if token.is_expired: + raise exceptions.AuthenticationFailed("Token expired") + + if not token.user.is_active: + raise exceptions.AuthenticationFailed("User inactive") + + return token.user, token + + +class TokenPermissions(DjangoModelPermissions): + """ + Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability + for unsafe requests (POST/PUT/PATCH/DELETE). + """ + def __init__(self): + # LOGIN_REQUIRED determines whether read-only access is provided to anonymous users. + from django.conf import settings + self.authenticated_users_only = settings.LOGIN_REQUIRED + super(TokenPermissions, self).__init__() + + def has_permission(self, request, view): + # If token authentication is in use, verify that the token allows write operations (for unsafe methods). + if request.method not in SAFE_METHODS and isinstance(request.auth, Token): + if not request.auth.write_enabled: + return False + return super(TokenPermissions, self).has_permission(request, view) + + +# +# Pagination +# + +class OptionalLimitOffsetPagination(LimitOffsetPagination): + """ + Override the stock paginator to allow setting limit=0 to disable pagination for a request. This returns all objects + matching a query, but retains the same format as a paginated request. The limit can only be disabled if + MAX_PAGE_SIZE has been set to 0 or None. + """ + + def paginate_queryset(self, queryset, request, view=None): + + try: + self.count = queryset.count() + except (AttributeError, TypeError): + self.count = len(queryset) + self.limit = self.get_limit(request) + self.offset = self.get_offset(request) + self.request = request + + if self.limit and self.count > self.limit and self.template is not None: + self.display_page_controls = True + + if self.count == 0 or self.offset > self.count: + return list() + + if self.limit: + return list(queryset[self.offset:self.offset + self.limit]) + else: + return list(queryset[self.offset:]) + + def get_limit(self, request): + + from django.conf import settings + + if self.limit_query_param: + try: + limit = int(request.query_params[self.limit_query_param]) + if limit < 0: + raise ValueError() + # Enforce maximum page size, if defined + if settings.MAX_PAGE_SIZE: + if limit == 0: + return settings.MAX_PAGE_SIZE + else: + return min(limit, settings.MAX_PAGE_SIZE) + return limit + except (KeyError, ValueError): + pass + + return self.default_limit + + +# +# Miscellaneous +# + +def get_view_name(view_cls, suffix=None): + """ + Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name`. + """ + if hasattr(view_cls, 'queryset'): + name = view_cls.queryset.model._meta.verbose_name + name = ' '.join([w[0].upper() + w[1:] for w in name.split()]) # Capitalize each word + if suffix: + name = "{} {}".format(name, suffix) + return name + + return drf_get_view_name(view_cls, suffix) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 59adbb823..f5f181ffd 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -211,23 +211,23 @@ REST_FRAMEWORK = { 'ALLOWED_VERSIONS': [REST_FRAMEWORK_VERSION], 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework.authentication.SessionAuthentication', - 'utilities.api.TokenAuthentication', + 'netbox.api.TokenAuthentication', ), 'DEFAULT_FILTER_BACKENDS': ( 'rest_framework.filters.DjangoFilterBackend', ), - 'DEFAULT_PAGINATION_CLASS': 'utilities.api.OptionalLimitOffsetPagination', + 'DEFAULT_PAGINATION_CLASS': 'netbox.api.OptionalLimitOffsetPagination', 'DEFAULT_PERMISSION_CLASSES': ( - 'utilities.api.TokenPermissions', + 'netbox.api.TokenPermissions', ), 'DEFAULT_RENDERER_CLASSES': ( 'rest_framework.renderers.JSONRenderer', - 'utilities.api.FormlessBrowsableAPIRenderer', + 'netbox.api.FormlessBrowsableAPIRenderer', ), 'DEFAULT_VERSION': REST_FRAMEWORK_VERSION, 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning', 'PAGE_SIZE': PAGINATE_COUNT, - 'VIEW_NAME_FUNCTION': 'utilities.api.get_view_name', + 'VIEW_NAME_FUNCTION': 'netbox.api.get_view_name', } # Django debug toolbar diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 3698bc47c..6806af73a 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -3,16 +3,10 @@ from __future__ import unicode_literals from django.conf import settings from django.contrib.contenttypes.models import ContentType -from rest_framework import authentication, exceptions from rest_framework.compat import is_authenticated from rest_framework.exceptions import APIException -from rest_framework.pagination import LimitOffsetPagination -from rest_framework.permissions import BasePermission, DjangoModelPermissions, SAFE_METHODS -from rest_framework.renderers import BrowsableAPIRenderer +from rest_framework.permissions import BasePermission from rest_framework.serializers import Field, ModelSerializer, ValidationError -from rest_framework.views import get_view_name as drf_get_view_name - -from users.models import Token WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete'] @@ -27,47 +21,6 @@ class ServiceUnavailable(APIException): # Authentication # -class TokenAuthentication(authentication.TokenAuthentication): - """ - A custom authentication scheme which enforces Token expiration times. - """ - model = Token - - def authenticate_credentials(self, key): - model = self.get_model() - try: - token = model.objects.select_related('user').get(key=key) - except model.DoesNotExist: - raise exceptions.AuthenticationFailed("Invalid token") - - # Enforce the Token's expiration time, if one has been set. - if token.is_expired: - raise exceptions.AuthenticationFailed("Token expired") - - if not token.user.is_active: - raise exceptions.AuthenticationFailed("User inactive") - - return token.user, token - - -class TokenPermissions(DjangoModelPermissions): - """ - Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability - for unsafe requests (POST/PUT/PATCH/DELETE). - """ - def __init__(self): - # LOGIN_REQUIRED determines whether read-only access is provided to anonymous users. - self.authenticated_users_only = settings.LOGIN_REQUIRED - super(TokenPermissions, self).__init__() - - def has_permission(self, request, view): - # If token authentication is in use, verify that the token allows write operations (for unsafe methods). - if request.method not in SAFE_METHODS and isinstance(request.auth, Token): - if not request.auth.write_enabled: - return False - return super(TokenPermissions, self).has_permission(request, view) - - class IsAuthenticatedOrLoginNotRequired(BasePermission): """ Returns True if the user is authenticated or LOGIN_REQUIRED is False. @@ -153,85 +106,3 @@ class WritableSerializerMixin(object): if self.action in WRITE_OPERATIONS and hasattr(self, 'write_serializer_class'): return self.write_serializer_class return self.serializer_class - - -# -# Pagination -# - -class OptionalLimitOffsetPagination(LimitOffsetPagination): - """ - Override the stock paginator to allow setting limit=0 to disable pagination for a request. This returns all objects - matching a query, but retains the same format as a paginated request. The limit can only be disabled if - MAX_PAGE_SIZE has been set to 0 or None. - """ - - def paginate_queryset(self, queryset, request, view=None): - - try: - self.count = queryset.count() - except (AttributeError, TypeError): - self.count = len(queryset) - self.limit = self.get_limit(request) - self.offset = self.get_offset(request) - self.request = request - - if self.limit and self.count > self.limit and self.template is not None: - self.display_page_controls = True - - if self.count == 0 or self.offset > self.count: - return list() - - if self.limit: - return list(queryset[self.offset:self.offset + self.limit]) - else: - return list(queryset[self.offset:]) - - def get_limit(self, request): - - if self.limit_query_param: - try: - limit = int(request.query_params[self.limit_query_param]) - if limit < 0: - raise ValueError() - # Enforce maximum page size, if defined - if settings.MAX_PAGE_SIZE: - if limit == 0: - return settings.MAX_PAGE_SIZE - else: - return min(limit, settings.MAX_PAGE_SIZE) - return limit - except (KeyError, ValueError): - pass - - return self.default_limit - - -# -# Renderers -# - -class FormlessBrowsableAPIRenderer(BrowsableAPIRenderer): - """ - Override the built-in BrowsableAPIRenderer to disable HTML forms. - """ - def show_form_for_method(self, *args, **kwargs): - return False - - -# -# Miscellaneous -# - -def get_view_name(view_cls, suffix=None): - """ - Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name`. - """ - if hasattr(view_cls, 'queryset'): - name = view_cls.queryset.model._meta.verbose_name - name = ' '.join([w[0].upper() + w[1:] for w in name.split()]) # Capitalize each word - if suffix: - name = "{} {}".format(name, suffix) - return name - - return drf_get_view_name(view_cls, suffix) From f824d1eb3b568852080344e7df5019fc787bfcdf Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 10 Oct 2017 16:41:35 -0400 Subject: [PATCH 109/125] Closes #1556: Added API endpoints listing static field choices for each app --- netbox/circuits/api/urls.py | 3 ++ netbox/circuits/api/views.py | 12 ++++++- netbox/dcim/api/urls.py | 3 ++ netbox/dcim/api/views.py | 18 +++++++++- netbox/extras/api/urls.py | 3 ++ netbox/extras/api/views.py | 43 ++++++++++++++++++++++-- netbox/ipam/api/urls.py | 3 ++ netbox/ipam/api/views.py | 16 ++++++++- netbox/secrets/api/urls.py | 3 ++ netbox/secrets/api/views.py | 10 +++++- netbox/tenancy/api/urls.py | 3 ++ netbox/tenancy/api/views.py | 10 +++++- netbox/utilities/api.py | 53 ++++++++++++++++++++++++++++++ netbox/virtualization/api/urls.py | 3 ++ netbox/virtualization/api/views.py | 12 ++++++- 15 files changed, 187 insertions(+), 8 deletions(-) diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index 25df44bfd..3fb4eda0a 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -16,6 +16,9 @@ class CircuitsRootView(routers.APIRootView): router = routers.DefaultRouter() router.APIRootView = CircuitsRootView +# Field choices +router.register(r'_choices', views.CircuitsFieldChoicesViewSet, base_name='field-choice') + # Providers router.register(r'providers', views.ProviderViewSet) diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 685fa8f9e..9defb5060 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -11,10 +11,20 @@ from circuits.models import Provider, CircuitTermination, CircuitType, Circuit from extras.models import Graph, GRAPH_TYPE_PROVIDER from extras.api.serializers import RenderedGraphSerializer from extras.api.views import CustomFieldModelViewSet -from utilities.api import WritableSerializerMixin +from utilities.api import FieldChoicesViewSet, WritableSerializerMixin from . import serializers +# +# Field choices +# + +class CircuitsFieldChoicesViewSet(FieldChoicesViewSet): + fields = ( + (CircuitTermination, ['term_side']), + ) + + # # Providers # diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 6f16310e5..a03432c61 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -16,6 +16,9 @@ class DCIMRootView(routers.APIRootView): router = routers.DefaultRouter() router.APIRootView = DCIMRootView +# Field choices +router.register(r'_choices', views.DCIMFieldChoicesViewSet, base_name='field-choice') + # Sites router.register(r'regions', views.RegionViewSet) router.register(r'sites', views.SiteViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 56d4221da..af41a2a83 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -20,11 +20,27 @@ from dcim import filters from extras.api.serializers import RenderedGraphSerializer from extras.api.views import CustomFieldModelViewSet from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE -from utilities.api import IsAuthenticatedOrLoginNotRequired, ServiceUnavailable, WritableSerializerMixin +from utilities.api import IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ServiceUnavailable, WritableSerializerMixin from .exceptions import MissingFilterException from . import serializers +# +# Field choices +# + +class DCIMFieldChoicesViewSet(FieldChoicesViewSet): + fields = ( + (Device, ['face', 'status']), + (ConsolePort, ['connection_status']), + (Interface, ['form_factor']), + (InterfaceConnection, ['connection_status']), + (InterfaceTemplate, ['form_factor']), + (PowerPort, ['connection_status']), + (Rack, ['type', 'width']), + ) + + # # Regions # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index da76e67bd..cc278644d 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -16,6 +16,9 @@ class ExtrasRootView(routers.APIRootView): router = routers.DefaultRouter() router.APIRootView = ExtrasRootView +# Field choices +router.register(r'_choices', views.ExtrasFieldChoicesViewSet, base_name='field-choice') + # Graphs router.register(r'graphs', views.GraphViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index bd1d33fa3..72569dae1 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -10,12 +10,27 @@ from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 from extras import filters -from extras.models import ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction +from extras.models import CustomField, ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction from extras.reports import get_report, get_reports -from utilities.api import WritableSerializerMixin +from utilities.api import FieldChoicesViewSet, WritableSerializerMixin from . import serializers +# +# Field choices +# + +class ExtrasFieldChoicesViewSet(FieldChoicesViewSet): + fields = ( + (CustomField, ['type']), + (Graph, ['type']), + ) + + +# +# Custom fields +# + class CustomFieldModelViewSet(ModelViewSet): """ Include the applicable set of CustomFields in the ModelViewSet context. @@ -46,6 +61,10 @@ class CustomFieldModelViewSet(ModelViewSet): return super(CustomFieldModelViewSet, self).get_queryset().prefetch_related('custom_field_values__field') +# +# Graphs +# + class GraphViewSet(WritableSerializerMixin, ModelViewSet): queryset = Graph.objects.all() serializer_class = serializers.GraphSerializer @@ -53,12 +72,20 @@ class GraphViewSet(WritableSerializerMixin, ModelViewSet): filter_class = filters.GraphFilter +# +# Export templates +# + class ExportTemplateViewSet(WritableSerializerMixin, ModelViewSet): queryset = ExportTemplate.objects.all() serializer_class = serializers.ExportTemplateSerializer filter_class = filters.ExportTemplateFilter +# +# Topology maps +# + class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet): queryset = TopologyMap.objects.select_related('site') serializer_class = serializers.TopologyMapSerializer @@ -85,12 +112,20 @@ class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet): return response +# +# Image attachments +# + class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet): queryset = ImageAttachment.objects.all() serializer_class = serializers.ImageAttachmentSerializer write_serializer_class = serializers.WritableImageAttachmentSerializer +# +# Reports +# + class ReportViewSet(ViewSet): _ignore_model_permissions = True exclude_from_schema = True @@ -162,6 +197,10 @@ class ReportViewSet(ViewSet): return Response(serializer.data) +# +# User activity +# + class RecentActivityViewSet(ReadOnlyModelViewSet): """ List all UserActions to provide a log of recent activity. diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index e6b1bb13d..ca046cd93 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -16,6 +16,9 @@ class IPAMRootView(routers.APIRootView): router = routers.DefaultRouter() router.APIRootView = IPAMRootView +# Field choices +router.register(r'_choices', views.IPAMFieldChoicesViewSet, base_name='field-choice') + # VRFs router.register(r'vrfs', views.VRFViewSet) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 1c56efa8f..dabb518ae 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -12,10 +12,24 @@ from django.shortcuts import get_object_or_404 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from ipam import filters from extras.api.views import CustomFieldModelViewSet -from utilities.api import WritableSerializerMixin +from utilities.api import FieldChoicesViewSet, WritableSerializerMixin from . import serializers +# +# Field choices +# + +class IPAMFieldChoicesViewSet(FieldChoicesViewSet): + fields = ( + (Aggregate, ['family']), + (Prefix, ['family', 'status']), + (IPAddress, ['family', 'status', 'role']), + (VLAN, ['status']), + (Service, ['protocol']), + ) + + # # VRFs # diff --git a/netbox/secrets/api/urls.py b/netbox/secrets/api/urls.py index 3b1e7d3d0..2a24c445a 100644 --- a/netbox/secrets/api/urls.py +++ b/netbox/secrets/api/urls.py @@ -16,6 +16,9 @@ class SecretsRootView(routers.APIRootView): router = routers.DefaultRouter() router.APIRootView = SecretsRootView +# Field choices +router.register(r'_choices', views.SecretsFieldChoicesViewSet, base_name='field-choice') + # Secrets router.register(r'secret-roles', views.SecretRoleViewSet) router.register(r'secrets', views.SecretViewSet) diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 52a77b87c..07897aa19 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -12,7 +12,7 @@ from django.http import HttpResponseBadRequest from secrets import filters from secrets.exceptions import InvalidKey from secrets.models import Secret, SecretRole, SessionKey, UserKey -from utilities.api import WritableSerializerMixin +from utilities.api import FieldChoicesViewSet, WritableSerializerMixin from . import serializers @@ -22,6 +22,14 @@ ERR_PRIVKEY_MISSING = "Private key was not provided." ERR_PRIVKEY_INVALID = "Invalid private key." +# +# Field choices +# + +class SecretsFieldChoicesViewSet(FieldChoicesViewSet): + fields = () + + # # Secret Roles # diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py index a3ce7774a..a36a1ec3d 100644 --- a/netbox/tenancy/api/urls.py +++ b/netbox/tenancy/api/urls.py @@ -16,6 +16,9 @@ class TenancyRootView(routers.APIRootView): router = routers.DefaultRouter() router.APIRootView = TenancyRootView +# Field choices +router.register(r'_choices', views.TenancyFieldChoicesViewSet, base_name='field-choice') + # Tenants router.register(r'tenant-groups', views.TenantGroupViewSet) router.register(r'tenants', views.TenantViewSet) diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 3c930bf73..c1f7d990d 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -5,10 +5,18 @@ from rest_framework.viewsets import ModelViewSet from extras.api.views import CustomFieldModelViewSet from tenancy import filters from tenancy.models import Tenant, TenantGroup -from utilities.api import WritableSerializerMixin +from utilities.api import FieldChoicesViewSet, WritableSerializerMixin from . import serializers +# +# Field choices +# + +class TenancyFieldChoicesViewSet(FieldChoicesViewSet): + fields = () + + # # Tenant Groups # diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 6806af73a..2da24a23e 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -1,12 +1,16 @@ from __future__ import unicode_literals +from collections import OrderedDict from django.conf import settings from django.contrib.contenttypes.models import ContentType +from django.http import Http404 from rest_framework.compat import is_authenticated from rest_framework.exceptions import APIException from rest_framework.permissions import BasePermission +from rest_framework.response import Response from rest_framework.serializers import Field, ModelSerializer, ValidationError +from rest_framework.viewsets import ViewSet WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete'] @@ -94,6 +98,55 @@ class ContentTypeFieldSerializer(Field): raise ValidationError("Invalid content type") +# +# Views +# + +class FieldChoicesViewSet(ViewSet): + """ + Expose the built-in numeric values which represent static choices for a model's field. + """ + permission_classes = [IsAuthenticatedOrLoginNotRequired] + fields = [] + + def __init__(self, *args, **kwargs): + super(FieldChoicesViewSet, self).__init__(*args, **kwargs) + + # Compile a dict of all fields in this view + self._fields = OrderedDict() + for cls, field_list in self.fields: + for field_name in field_list: + model_name = cls._meta.verbose_name.lower().replace(' ', '-') + key = ':'.join([model_name, field_name]) + choices = [] + for k, v in cls._meta.get_field(field_name).choices: + if type(v) in [list, tuple]: + for k2, v2 in v: + choices.append({ + 'value': v2, + 'label': k2, + }) + else: + choices.append({ + 'value': v, + 'label': k, + }) + self._fields[key] = choices + + def list(self, request): + return Response(self._fields) + + def retrieve(self, request, pk): + + if pk not in self._fields: + raise Http404 + + return Response(self._fields[pk]) + + def get_view_name(self): + return "Field Choices" + + # # Mixins # diff --git a/netbox/virtualization/api/urls.py b/netbox/virtualization/api/urls.py index 34ecf4431..45db6aa6a 100644 --- a/netbox/virtualization/api/urls.py +++ b/netbox/virtualization/api/urls.py @@ -16,6 +16,9 @@ class VirtualizationRootView(routers.APIRootView): router = routers.DefaultRouter() router.APIRootView = VirtualizationRootView +# Field choices +router.register(r'_choices', views.VirtualizationFieldChoicesViewSet, base_name='field-choice') + # Clusters router.register(r'cluster-types', views.ClusterTypeViewSet) router.register(r'cluster-groups', views.ClusterGroupViewSet) diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index bae8d5f90..2b7ce4b60 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -4,12 +4,22 @@ from rest_framework.viewsets import ModelViewSet from dcim.models import Interface from extras.api.views import CustomFieldModelViewSet -from utilities.api import WritableSerializerMixin +from utilities.api import FieldChoicesViewSet, WritableSerializerMixin from virtualization import filters from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine from . import serializers +# +# Field choices +# + +class VirtualizationFieldChoicesViewSet(FieldChoicesViewSet): + fields = ( + (VirtualMachine, ['status']), + ) + + # # Clusters # From 0c645b12d15d88d8b69b6fc5c83508df5a446d74 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 10 Oct 2017 17:23:41 -0400 Subject: [PATCH 110/125] Moved querysets to separate files; tweaked interface ordering logic (#1523) --- netbox/dcim/models.py | 68 +---------------------------------- netbox/dcim/querysets.py | 72 +++++++++++++++++++++++++++++++++++++ netbox/ipam/models.py | 40 ++------------------- netbox/ipam/querysets.py | 38 ++++++++++++++++++++ netbox/secrets/models.py | 11 +----- netbox/secrets/querysets.py | 13 +++++++ 6 files changed, 127 insertions(+), 115 deletions(-) create mode 100644 netbox/dcim/querysets.py create mode 100644 netbox/ipam/querysets.py create mode 100644 netbox/secrets/querysets.py diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 9120f94e3..336ae0c0b 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -13,7 +13,6 @@ from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Count, Q, ObjectDoesNotExist -from django.db.models.expressions import RawSQL from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible @@ -27,6 +26,7 @@ from utilities.models import CreatedUpdatedModel from utilities.utils import csv_format from .constants import * from .fields import ASNField, MACAddressField +from .querysets import InterfaceQuerySet # @@ -687,72 +687,6 @@ class PowerOutletTemplate(models.Model): return self.name -class InterfaceQuerySet(models.QuerySet): - - def order_naturally(self, method=IFACE_ORDERING_POSITION): - """ - Naturally order interfaces by their type and numeric position. The sort method must be one of the defined - IFACE_ORDERING_CHOICES (typically indicated by a parent Device's DeviceType). - - To order interfaces naturally, the `name` field is split into six distinct components: leading text (type), - slot, subslot, position, channel, and virtual circuit: - - {type}{slot}/{subslot}/{position}/{subposition}:{channel}.{vc} - - Components absent from the interface name are ignored. For example, an interface named GigabitEthernet1/2/3 - would be parsed as follows: - - name = 'GigabitEthernet' - slot = 1 - subslot = 2 - position = 3 - subposition = 0 - channel = None - vc = 0 - - The original `name` field is taken as a whole to serve as a fallback in the event interfaces do not match any of - the prescribed fields. - """ - sql_col = '{}.name'.format(self.model._meta.db_table) - ordering = { - IFACE_ORDERING_POSITION: ( - '_slot', '_subslot', '_position', '_subposition', '_channel', '_vc', '_type', '_id', 'name', - ), - IFACE_ORDERING_NAME: ( - '_type', '_slot', '_subslot', '_position', '_subposition', '_channel', '_vc', '_id', 'name', - ), - }[method] - - TYPE_RE = r"SUBSTRING({} FROM '^([^0-9]+)')" - ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)$') AS integer)" - SLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)\/') AS integer)" - SUBSLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/)([0-9]+)') AS integer)" - POSITION_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/){{2}}([0-9]+)') AS integer)" - SUBPOSITION_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/){{3}}([0-9]+)') AS integer)" - CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)" - VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)" - - fields = { - '_type': RawSQL(TYPE_RE.format(sql_col), []), - '_id': RawSQL(ID_RE.format(sql_col), []), - '_slot': RawSQL(SLOT_RE.format(sql_col), []), - '_subslot': RawSQL(SUBSLOT_RE.format(sql_col), []), - '_position': RawSQL(POSITION_RE.format(sql_col), []), - '_subposition': RawSQL(SUBPOSITION_RE.format(sql_col), []), - '_channel': RawSQL(CHANNEL_RE.format(sql_col), []), - '_vc': RawSQL(VC_RE.format(sql_col), []), - } - - return self.annotate(**fields).order_by(*ordering) - - def connectable(self): - """ - Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or - wireless). - """ - return self.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES) - - @python_2_unicode_compatible class InterfaceTemplate(models.Model): """ diff --git a/netbox/dcim/querysets.py b/netbox/dcim/querysets.py new file mode 100644 index 000000000..69dbf99dc --- /dev/null +++ b/netbox/dcim/querysets.py @@ -0,0 +1,72 @@ +from __future__ import unicode_literals + +from django.db.models import QuerySet +from django.db.models.expressions import RawSQL + +from .constants import IFACE_ORDERING_NAME, IFACE_ORDERING_POSITION, NONCONNECTABLE_IFACE_TYPES + + +class InterfaceQuerySet(QuerySet): + + def order_naturally(self, method=IFACE_ORDERING_POSITION): + """ + Naturally order interfaces by their type and numeric position. The sort method must be one of the defined + IFACE_ORDERING_CHOICES (typically indicated by a parent Device's DeviceType). + + To order interfaces naturally, the `name` field is split into six distinct components: leading text (type), + slot, subslot, position, channel, and virtual circuit: + + {type}{slot}/{subslot}/{position}/{subposition}:{channel}.{vc} + + Components absent from the interface name are ignored. For example, an interface named GigabitEthernet1/2/3 + would be parsed as follows: + + name = 'GigabitEthernet' + slot = 1 + subslot = 2 + position = 3 + subposition = 0 + channel = None + vc = 0 + + The original `name` field is taken as a whole to serve as a fallback in the event interfaces do not match any of + the prescribed fields. + """ + sql_col = '{}.name'.format(self.model._meta.db_table) + ordering = { + IFACE_ORDERING_POSITION: ( + '_slot', '_subslot', '_position', '_subposition', '_channel', '_type', '_vc', '_id', 'name', + ), + IFACE_ORDERING_NAME: ( + '_type', '_slot', '_subslot', '_position', '_subposition', '_channel', '_vc', '_id', 'name', + ), + }[method] + + TYPE_RE = r"SUBSTRING({} FROM '^([^0-9]+)')" + ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)$') AS integer)" + SLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)\/') AS integer)" + SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/)([0-9]+)') AS integer), 0)" + POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/){{2}}([0-9]+)') AS integer), 0)" + SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/){{3}}([0-9]+)') AS integer), 0)" + CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)" + VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)" + + fields = { + '_type': RawSQL(TYPE_RE.format(sql_col), []), + '_id': RawSQL(ID_RE.format(sql_col), []), + '_slot': RawSQL(SLOT_RE.format(sql_col), []), + '_subslot': RawSQL(SUBSLOT_RE.format(sql_col), []), + '_position': RawSQL(POSITION_RE.format(sql_col), []), + '_subposition': RawSQL(SUBPOSITION_RE.format(sql_col), []), + '_channel': RawSQL(CHANNEL_RE.format(sql_col), []), + '_vc': RawSQL(VC_RE.format(sql_col), []), + } + + return self.annotate(**fields).order_by(*ordering) + + def connectable(self): + """ + Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or + wireless). + """ + return self.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES) diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index a764af175..791ba1b1a 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -2,12 +2,11 @@ from __future__ import unicode_literals import netaddr from django.conf import settings -from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from django.db.models import Q from django.db.models.expressions import RawSQL from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible @@ -16,10 +15,10 @@ from dcim.models import Interface from extras.models import CustomFieldModel, CustomFieldValue from tenancy.models import Tenant from utilities.models import CreatedUpdatedModel -from utilities.sql import NullsFirstQuerySet from utilities.utils import csv_format from .constants import * from .fields import IPNetworkField, IPAddressField +from .querysets import PrefixQuerySet @python_2_unicode_compatible @@ -190,41 +189,6 @@ class Role(models.Model): return self.vlans.count() -class PrefixQuerySet(NullsFirstQuerySet): - - def annotate_depth(self, limit=None): - """ - Iterate through a QuerySet of Prefixes and annotate the hierarchical level of each. While it would be preferable - to do this using .extra() on the QuerySet to count the unique parents of each prefix, that approach introduces - performance issues at scale. - - Because we're adding a non-field attribute to the model, annotation must be made *after* any QuerySet - modifications. - """ - queryset = self - stack = [] - for p in queryset: - try: - prev_p = stack[-1] - except IndexError: - prev_p = None - if prev_p is not None: - while (p.prefix not in prev_p.prefix) or p.prefix == prev_p.prefix: - stack.pop() - try: - prev_p = stack[-1] - except IndexError: - prev_p = None - break - if prev_p is not None: - prev_p.has_children = True - stack.append(p) - p.depth = len(stack) - 1 - if limit is None: - return queryset - return list(filter(lambda p: p.depth <= limit, queryset)) - - @python_2_unicode_compatible class Prefix(CreatedUpdatedModel, CustomFieldModel): """ diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py new file mode 100644 index 000000000..f606ab1b4 --- /dev/null +++ b/netbox/ipam/querysets.py @@ -0,0 +1,38 @@ +from __future__ import unicode_literals + +from utilities.sql import NullsFirstQuerySet + + +class PrefixQuerySet(NullsFirstQuerySet): + + def annotate_depth(self, limit=None): + """ + Iterate through a QuerySet of Prefixes and annotate the hierarchical level of each. While it would be preferable + to do this using .extra() on the QuerySet to count the unique parents of each prefix, that approach introduces + performance issues at scale. + + Because we're adding a non-field attribute to the model, annotation must be made *after* any QuerySet + modifications. + """ + queryset = self + stack = [] + for p in queryset: + try: + prev_p = stack[-1] + except IndexError: + prev_p = None + if prev_p is not None: + while (p.prefix not in prev_p.prefix) or p.prefix == prev_p.prefix: + stack.pop() + try: + prev_p = stack[-1] + except IndexError: + prev_p = None + break + if prev_p is not None: + prev_p.has_children = True + stack.append(p) + p.depth = len(stack) - 1 + if limit is None: + return queryset + return list(filter(lambda p: p.depth <= limit, queryset)) diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index b3dac91cb..ded93ed58 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -17,6 +17,7 @@ from dcim.models import Device from utilities.models import CreatedUpdatedModel from .exceptions import InvalidKey from .hashers import SecretValidationHasher +from .querysets import UserKeyQuerySet def generate_random_key(bits=256): @@ -46,16 +47,6 @@ def decrypt_master_key(master_key_cipher, private_key): return cipher.decrypt(master_key_cipher) -class UserKeyQuerySet(models.QuerySet): - - def active(self): - return self.filter(master_key_cipher__isnull=False) - - def delete(self): - # Disable bulk deletion to avoid accidentally wiping out all copies of the master key. - raise Exception("Bulk deletion has been disabled.") - - @python_2_unicode_compatible class UserKey(CreatedUpdatedModel): """ diff --git a/netbox/secrets/querysets.py b/netbox/secrets/querysets.py new file mode 100644 index 000000000..c5595e1d3 --- /dev/null +++ b/netbox/secrets/querysets.py @@ -0,0 +1,13 @@ +from __future__ import unicode_literals + +from django.db.models import QuerySet + + +class UserKeyQuerySet(QuerySet): + + def active(self): + return self.filter(master_key_cipher__isnull=False) + + def delete(self): + # Disable bulk deletion to avoid accidentally wiping out all copies of the master key. + raise Exception("Bulk deletion has been disabled.") From abfe71bb04c6741ffd5ba9a22a609329791483be Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 10 Oct 2017 17:47:53 -0400 Subject: [PATCH 111/125] #1556: Swapped label and value --- netbox/utilities/api.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 2da24a23e..f384021b7 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -123,13 +123,13 @@ class FieldChoicesViewSet(ViewSet): if type(v) in [list, tuple]: for k2, v2 in v: choices.append({ - 'value': v2, - 'label': k2, + 'value': k2, + 'label': v2, }) else: choices.append({ - 'value': v, - 'label': k, + 'value': k, + 'label': v, }) self._fields[key] = choices From f253f164a3a90f7cde8926c94793cb0197190744 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 11 Oct 2017 10:14:09 -0400 Subject: [PATCH 112/125] Closes #1510: Added ability to search by name when adding devices to a cluster --- .../virtualization/cluster_add_devices.html | 47 +++++++++++++++++-- netbox/virtualization/forms.py | 1 - 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/netbox/templates/virtualization/cluster_add_devices.html b/netbox/templates/virtualization/cluster_add_devices.html index 6c874d38f..2029ea5b8 100644 --- a/netbox/templates/virtualization/cluster_add_devices.html +++ b/netbox/templates/virtualization/cluster_add_devices.html @@ -20,11 +20,27 @@
  • {% endif %}
    -
    Devices
    +
    Device Selection
    - {% render_field form.region %} - {% render_field form.site %} - {% render_field form.rack %} + +
    + +
    + {% render_field form.region %} + {% render_field form.site %} + {% render_field form.rack %} +
    +
    {% render_field form.devices %}
    @@ -40,5 +56,26 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 461c1cf11..63561a08c 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -186,7 +186,6 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): ('site', 'site'), ('rack', 'rack'), ), - label='Device', widget=APISelectMultiple( api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}', display_field='display_name', From baa277c592e7d13c9cf763b0ccbc4f361d943d2b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 11 Oct 2017 12:22:35 -0400 Subject: [PATCH 113/125] Updated install docs to install pip using setuptools --- docs/installation/netbox.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/installation/netbox.md b/docs/installation/netbox.md index d1aa5d001..b92fa3ad1 100644 --- a/docs/installation/netbox.md +++ b/docs/installation/netbox.md @@ -5,13 +5,15 @@ Python 3: ```no-highlight -# apt-get install -y python3 python3-dev python3-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev +# apt-get install -y python3 python3-dev python3-setuptools libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev +# easy_install3 pip ``` Python 2: ```no-highlight -# apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev +# apt-get install -y python2.7 python-dev python-setuptools libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev +# easy_install pip ``` **CentOS** @@ -22,14 +24,14 @@ Python 3: # yum install -y epel-release # yum install -y gcc python34 python34-devel python34-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config # easy_install-3.4 pip -# ln -s -f python3.4 /usr/bin/python ``` Python 2: ```no-highlight # yum install -y epel-release -# yum install -y gcc python2 python-devel python-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config +# yum install -y gcc python2 python-devel python-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config +# easy_install pip ``` You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub. @@ -97,6 +99,9 @@ Python 2: # pip install -r requirements.txt ``` +!!! note + If you encounter errors while installing the required packages, check that you're running a recent version of pip (v9.0.1 or higher) with the command `pip -V` or `pip3 -V`. + ### NAPALM Automation As of v2.1.0, NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. Installation of NAPALM is optional. To enable it, install the `napalm` package using pip or pip3: From 043f2cb214aa720a71d54e895a946a61b513a8db Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 11 Oct 2017 13:12:49 -0400 Subject: [PATCH 114/125] Tweaked PostgreSQL version number in example --- docs/installation/postgresql.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/postgresql.md b/docs/installation/postgresql.md index 02827e26c..243021408 100644 --- a/docs/installation/postgresql.md +++ b/docs/installation/postgresql.md @@ -45,7 +45,7 @@ At a minimum, we need to create a database for NetBox and assign it a username a ```no-highlight # sudo -u postgres psql -psql (9.3.13) +psql (9.4.5) Type "help" for help. postgres=# CREATE DATABASE netbox; From 51194e20f2997a4d0bc17985ea02bd1203fe92b3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 11 Oct 2017 13:30:38 -0400 Subject: [PATCH 115/125] Improved template for when no reports are found --- netbox/templates/extras/report_list.html | 114 ++++++++++++----------- 1 file changed, 61 insertions(+), 53 deletions(-) diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index 0ff0a432c..023ffba7c 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -5,69 +5,77 @@

    {% block title %}Reports{% endblock %}

    + {% if reports %} {% for module, module_reports in reports %} -

    {{ module|bettertitle }}

    - - - - - - - - - - - {% for report in module_reports %} +

    {{ module|bettertitle }}

    +
    NameStatusDescriptionLast Run
    + - - - - {% if report.result %} - - {% else %} - - {% endif %} + + + + - {% for method, stats in report.result.data.items %} + + + {% for report in module_reports %} - - + + {% if report.result %} + + {% else %} + + {% endif %} + {% for method, stats in report.result.data.items %} + + + + + {% endfor %} {% endfor %} - {% endfor %} - -
    - {{ report.name }} - - {% include 'extras/inc/report_label.html' %} - {{ report.description|default:"" }}{{ report.result.created }}NeverNameStatusDescriptionLast Run
    - {{ method }} + + {{ report.name }} - - - - + + {% include 'extras/inc/report_label.html' %} {{ report.description|default:"" }}{{ report.result.created }}Never
    + {{ method }} + + + + + +
    - {% endfor %} + + + {% endfor %} + {% else %} +
    + No reports found. +
    + {% endif %}
    -
    - {% for module, module_reports in reports %} -
    - {{ module|bettertitle }} -
    - - {% endfor %} -
    + {% if reports %} +
    + {% for module, module_reports in reports %} +
    + {{ module|bettertitle }} +
    + + {% endfor %} +
    + {% endif %}
    {% endblock %} From fdae3a3f3184dfbc3cee53f47480d3e8e99de9cd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 11 Oct 2017 14:03:35 -0400 Subject: [PATCH 116/125] Introduced the REPORTS_ROOT config parameter; Python2 fixes --- docs/configuration/optional-settings.md | 10 +++++++++- netbox/extras/reports.py | 7 ++++--- netbox/netbox/configuration.example.py | 4 ++++ netbox/netbox/settings.py | 1 + netbox/templates/extras/report_list.html | 3 ++- 5 files changed, 20 insertions(+), 5 deletions(-) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index fd85c0775..d34137ea0 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -145,7 +145,7 @@ An API consumer can request an arbitrary number of objects by appending the "lim Default: $BASE_DIR/netbox/media/ -The file path to the location where media files (such as image attachments) are stored. By default, this is the `netbox/media` directory within the base NetBox installation path. +The file path to the location where media files (such as image attachments) are stored. By default, this is the `netbox/media/` directory within the base NetBox installation path. --- @@ -207,6 +207,14 @@ When determining the primary IP address for a device, IPv6 is preferred over IPv --- +## REPORTS_ROOT + +Default: $BASE_DIR/netbox/reports/ + +The file path to the location where custom reports will be kept. By default, this is the `netbox/reports/` directory within the base NetBox installation path. + +--- + ## TIME_ZONE Default: UTC diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 921aab380..409aaaf30 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -1,13 +1,14 @@ +from __future__ import unicode_literals from collections import OrderedDict import importlib import inspect import pkgutil +from django.conf import settings from django.utils import timezone from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_LEVEL_CODES, LOG_SUCCESS, LOG_WARNING from .models import ReportResult -import reports as custom_reports def is_report(obj): @@ -42,9 +43,9 @@ def get_reports(): """ module_list = [] - # Iterate through all modules within the reports path. These are the user-defined files in which reports are + # Iterate through all modules within the reports path. These are the user-created files in which reports are # defined. - for importer, module_name, is_pkg in pkgutil.walk_packages(custom_reports.__path__): + for importer, module_name, is_pkg in pkgutil.walk_packages([settings.REPORTS_ROOT]): module = importlib.import_module('reports.{}'.format(module_name)) report_list = [cls() for _, cls in inspect.getmembers(module, is_report)] module_list.append((module_name, report_list)) diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index ce7a62464..4c4cf4277 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -118,6 +118,10 @@ PAGINATE_COUNT = 50 # prefer IPv4 instead. PREFER_IPV4 = False +# The file path where custom reports will be stored. A trailing slash is not needed. Note that the default value of +# this setting is derived from the installed location. +# REPORTS_ROOT = '/opt/netbox/netbox/reports' + # Time zone (default: UTC) TIME_ZONE = 'UTC' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 653ec1a7b..52d9a3b59 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -54,6 +54,7 @@ NAPALM_TIMEOUT = getattr(configuration, 'NAPALM_TIMEOUT', 30) NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {}) PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50) PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) +REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d') SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i') SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s') diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index 023ffba7c..9cba1a71b 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -52,7 +52,8 @@ {% endfor %} {% else %}
    - No reports found. +

    No reports found.

    +

    Reports should be saved to {{ settings.REPORTS_ROOT }}. (This path can be changed by setting REPORTS_ROOT in NetBox's configuration.)

    {% endif %}
    From 7e6573d191a003a0115a093656a37ebb42978121 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 11 Oct 2017 14:37:25 -0400 Subject: [PATCH 117/125] Added missing permission class to reports API viewset --- netbox/extras/api/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 72569dae1..689780879 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -12,7 +12,7 @@ from django.shortcuts import get_object_or_404 from extras import filters from extras.models import CustomField, ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction from extras.reports import get_report, get_reports -from utilities.api import FieldChoicesViewSet, WritableSerializerMixin +from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, WritableSerializerMixin from . import serializers @@ -127,6 +127,7 @@ class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet): # class ReportViewSet(ViewSet): + permission_classes = [IsAuthenticatedOrLoginNotRequired] _ignore_model_permissions = True exclude_from_schema = True lookup_value_regex = '[^/]+' # Allow dots From 924cf9bfd4e3ad809d0b0ff1840c6df29ef8c720 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 11 Oct 2017 14:39:51 -0400 Subject: [PATCH 118/125] Removed obsolete docker build file --- netbox/netbox/configuration.docker.py | 79 --------------------------- 1 file changed, 79 deletions(-) delete mode 100644 netbox/netbox/configuration.docker.py diff --git a/netbox/netbox/configuration.docker.py b/netbox/netbox/configuration.docker.py deleted file mode 100644 index 56f9da366..000000000 --- a/netbox/netbox/configuration.docker.py +++ /dev/null @@ -1,79 +0,0 @@ -import os -######################### -# # -# Required settings # -# # -######################### - -# This is a list of valid fully-qualified domain names (FQDNs) for the NetBox server. NetBox will not permit write -# access to the server via any other hostnames. The first FQDN in the list will be treated as the preferred name. -# -# Example: ALLOWED_HOSTS = ['netbox.example.com', 'netbox.internal.local'] -ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '').split(' ') - -# PostgreSQL database configuration. -DATABASE = { - 'NAME': os.environ.get('DB_NAME', 'netbox'), # Database name - 'USER': os.environ.get('DB_USER', ''), # PostgreSQL username - 'PASSWORD': os.environ.get('DB_PASSWORD', ''), # PostgreSQL password - 'HOST': os.environ.get('DB_HOST', 'localhost'), # Database server - 'PORT': os.environ.get('DB_PORT', ''), # Database port (leave blank for default) -} - -# This key is used for secure generation of random numbers and strings. It must never be exposed outside of this file. -# For optimal security, SECRET_KEY should be at least 50 characters in length and contain a mix of letters, numbers, and -# symbols. NetBox will not run without this defined. For more information, see -# https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-SECRET_KEY -SECRET_KEY = os.environ.get('SECRET_KEY', '') - -######################### -# # -# Optional settings # -# # -######################### - -# Specify one or more name and email address tuples representing NetBox administrators. These people will be notified of -# application errors (assuming correct email settings are provided). -ADMINS = [ - # ['John Doe', 'jdoe@example.com'], -] - -# Email settings -EMAIL = { - 'SERVER': os.environ.get('EMAIL_SERVER', 'localhost'), - 'PORT': os.environ.get('EMAIL_PORT', 25), - 'USERNAME': os.environ.get('EMAIL_USERNAME', ''), - 'PASSWORD': os.environ.get('EMAIL_PASSWORD', ''), - 'TIMEOUT': os.environ.get('EMAIL_TIMEOUT', 10), # seconds - 'FROM_EMAIL': os.environ.get('EMAIL_FROM', ''), -} - -# Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users -# are permitted to access most data in NetBox (excluding secrets) but not make any changes. -LOGIN_REQUIRED = os.environ.get('LOGIN_REQUIRED', False) - -# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set: -# BASE_PATH = 'netbox/' -BASE_PATH = os.environ.get('BASE_PATH', '') - -# Setting this to True will display a "maintenance mode" banner at the top of every page. -MAINTENANCE_MODE = os.environ.get('MAINTENANCE_MODE', False) - -# Credentials that NetBox will use to access live devices. -NAPALM_USERNAME = os.environ.get('NAPALM_USERNAME', '') -NAPALM_PASSWORD = os.environ.get('NAPALM_PASSWORD', '') - -# Determine how many objects to display per page within a list. (Default: 50) -PAGINATE_COUNT = os.environ.get('PAGINATE_COUNT', 50) - -# Time zone (default: UTC) -TIME_ZONE = os.environ.get('TIME_ZONE', 'UTC') - -# Date/time formatting. See the following link for supported formats: -# https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date -DATE_FORMAT = os.environ.get('DATE_FORMAT', 'N j, Y') -SHORT_DATE_FORMAT = os.environ.get('SHORT_DATE_FORMAT', 'Y-m-d') -TIME_FORMAT = os.environ.get('TIME_FORMAT', 'g:i a') -SHORT_TIME_FORMAT = os.environ.get('SHORT_TIME_FORMAT', 'H:i:s') -DATETIME_FORMAT = os.environ.get('DATETIME_FORMAT', 'N j, Y g:i a') -SHORT_DATETIME_FORMAT = os.environ.get('SHORT_DATETIME_FORMAT', 'Y-m-d H:i') From b301b8f6f289d92af246e3e4d9783ac3d21abae9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 11 Oct 2017 14:47:43 -0400 Subject: [PATCH 119/125] Branded the browsable API template --- netbox/templates/rest_framework/api.html | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 netbox/templates/rest_framework/api.html diff --git a/netbox/templates/rest_framework/api.html b/netbox/templates/rest_framework/api.html new file mode 100644 index 000000000..76f43629b --- /dev/null +++ b/netbox/templates/rest_framework/api.html @@ -0,0 +1,5 @@ +{% extends 'rest_framework/base.html' %} + +{% block branding %} + NetBox +{% endblock %} From 990c9d6f7c74774874e2da09851ec0dcd22cfbc8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 11 Oct 2017 15:11:51 -0400 Subject: [PATCH 120/125] Updated package requirements --- netbox/netbox/settings.py | 8 -------- requirements.txt | 15 ++++++--------- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 52d9a3b59..3f4c85a4a 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -232,14 +232,6 @@ REST_FRAMEWORK = { } # Django debug toolbar -# Disable the templates panel by default due to a performance issue in Django 1.11; see -# https://github.com/jazzband/django-debug-toolbar/issues/910 -DEBUG_TOOLBAR_CONFIG = { - 'DISABLE_PANELS': [ - 'debug_toolbar.panels.redirects.RedirectsPanel', - 'debug_toolbar.panels.templates.TemplatesPanel', - ], -} INTERNAL_IPS = ( '127.0.0.1', '::1', diff --git a/requirements.txt b/requirements.txt index 20c1dff27..cdda3cf1d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,11 @@ -cffi>=1.8 -cryptography>=1.8 -Django>=1.11 -django-cors-headers>=2.0 -django-debug-toolbar>=1.7 -django-filter>=1.0.2 +Django>=1.11,<2.0 +django-cors-headers>=2.1 +django-debug-toolbar>=1.8 +django-filter>=1.0.4 django-mptt==0.8.7 django-rest-swagger>=2.1.0 -django-tables2>=1.7.0 -djangorestframework>=3.6.2 +django-tables2>=1.10.0 +djangorestframework>=3.6.4 graphviz>=0.6 Markdown>=2.6.7 natsort>=5.0.0 @@ -18,5 +16,4 @@ Pillow>=4.0.0 psycopg2>=2.7.3 py-gfm>=0.1.3 pycryptodome>=3.4.7 -sqlparse>=0.2 xmltodict>=0.10.2 From f27e1ba885fb8ba5ce33ee84d76dc562bf51db70 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 11 Oct 2017 16:16:44 -0400 Subject: [PATCH 121/125] Check that PostgreSQL is 9.4 or higher on initialization --- netbox/netbox/__init__.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/netbox/netbox/__init__.py b/netbox/netbox/__init__.py index e69de29bb..74a9061a3 100644 --- a/netbox/netbox/__init__.py +++ b/netbox/netbox/__init__.py @@ -0,0 +1,12 @@ +from distutils.version import StrictVersion + +from django.db import connection + + +# NetBox v2.2 and later requires PostgreSQL 9.4 or higher +with connection.cursor() as cursor: + cursor.execute("SELECT VERSION()") + row = cursor.fetchone() + pg_version = row[0].split()[1] + if StrictVersion(pg_version) < StrictVersion('9.4.0'): + raise Exception("PostgreSQL 9.4.0 or higher is required. ({} found)".format(pg_version)) From 392b45e6cbe3b2df584faa31fad7e2938b409e7d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 11 Oct 2017 16:20:50 -0400 Subject: [PATCH 122/125] Added warning for PostgreSQL 9.4 requirement --- docs/installation/postgresql.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/installation/postgresql.md b/docs/installation/postgresql.md index 243021408..592ba1f38 100644 --- a/docs/installation/postgresql.md +++ b/docs/installation/postgresql.md @@ -1,11 +1,12 @@ -NetBox requires a PostgreSQL 9.4 or higher database to store data. (Please note that MySQL is not supported, as NetBox leverages PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/9.6/static/datatype-net-types.html).) +NetBox requires a PostgreSQL 9.4 or higher database to store data. (Please note that MySQL is not supported, as NetBox leverages PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/current/static/datatype-net-types.html).) !!! note The installation instructions provided here have been tested to work on Ubuntu 16.04 and CentOS 6.9. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors. -# Installation +!!! warning + NetBox v2.2 or later requires PostgreSQL 9.4.0 or higher. -NetBox v2.2 or later requires PostgreSQL 9.4 or higher. +# Installation **Ubuntu** From 17a45109f46b957388b29cadfd829cd5c8e05b7a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 11 Oct 2017 16:28:05 -0400 Subject: [PATCH 123/125] Bypass PostgreSQL 9.4 check if database is inaccessible --- netbox/netbox/__init__.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/netbox/netbox/__init__.py b/netbox/netbox/__init__.py index 74a9061a3..361659e58 100644 --- a/netbox/netbox/__init__.py +++ b/netbox/netbox/__init__.py @@ -1,12 +1,18 @@ from distutils.version import StrictVersion from django.db import connection +from django.db.utils import OperationalError -# NetBox v2.2 and later requires PostgreSQL 9.4 or higher -with connection.cursor() as cursor: - cursor.execute("SELECT VERSION()") - row = cursor.fetchone() - pg_version = row[0].split()[1] - if StrictVersion(pg_version) < StrictVersion('9.4.0'): - raise Exception("PostgreSQL 9.4.0 or higher is required. ({} found)".format(pg_version)) +# NetBox v2.2 and later requires PostgreSQL 9.4 or higher. +try: + with connection.cursor() as cursor: + cursor.execute("SELECT VERSION()") + row = cursor.fetchone() + pg_version = row[0].split()[1] + if StrictVersion(pg_version) < StrictVersion('9.4.0'): + raise Exception("PostgreSQL 9.4.0 or higher is required. ({} found)".format(pg_version)) + +# Skip if the database is missing (e.g. for CI testing) or misconfigured. +except OperationalError: + pass From 6052405eb7ec8d7209fc3e3c2e43fe6924c95e59 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 11 Oct 2017 16:41:16 -0400 Subject: [PATCH 124/125] Added info about API _choices endpoints --- docs/api/overview.md | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/docs/api/overview.md b/docs/api/overview.md index 39a4109f9..db9c1de4d 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -88,10 +88,10 @@ The base serializer is used to represent the default view of a model. This inclu "vid": 101, "name": "Users-Floor1", "tenant": null, - "status": [ - 1, - "Active" - ], + "status": { + "value": 1, + "label": "Active" + }, "role": { "id": 9, "url": "http://localhost:8000/api/ipam/roles/9/", @@ -122,6 +122,37 @@ When a base serializer includes one or more nested serializers, the hierarchical } ``` +## Static Choice Fields + +Some model fields, such as the `status` field in the above example, utilize static integers corresponding to static choices. The available choices can be retrieved from the read-only `_choices` endpoint within each app. A specific `model:field` tuple may optionally be specified in the URL. + +Each choice includes a human-friendly label and its corresponding numeric value. For example, `GET /api/ipam/_choices/prefix:status/` will return: + +``` +[ + { + "value": 0, + "label": "Container" + }, + { + "value": 1, + "label": "Active" + }, + { + "value": 2, + "label": "Reserved" + }, + { + "value": 3, + "label": "Deprecated" + } +] +``` + +Thus, to set a prefix's status to "Reserved," it would be assigned the integer `2`. + +A request for `GET /api/ipam/_choices/` will return choices for _all_ fields belonging to models within the IPAM app. + # Pagination API responses which contain a list of objects (for example, a request to `/api/dcim/devices/`) will be paginated to avoid unnecessary overhead. The root JSON object will contain the following attributes: From 60e3ff0bf03d5635659ec2a7ca3803c74b8396ab Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 11 Oct 2017 16:42:44 -0400 Subject: [PATCH 125/125] Updated version number for v2.2 build --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 3f4c85a4a..7272da8ae 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -13,7 +13,7 @@ except ImportError: ) -VERSION = '2.1.6-dev' +VERSION = '2.2.0-dev' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))