diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 01711709c..30d0eab82 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -256,12 +256,19 @@ class DeviceViewSet(CustomFieldModelViewSet): device.platform )) - # Check that NAPALM is installed and verify the configured driver + # Check that NAPALM is installed try: import napalm - from napalm_base.exceptions import ConnectAuthError, ModuleImportError except ImportError: raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.") + + # TODO: Remove support for NAPALM < 2.0 + try: + from napalm.base.exceptions import ConnectAuthError, ModuleImportError + except ImportError: + from napalm_base.exceptions import ConnectAuthError, ModuleImportError + + # Validate the configured driver try: driver = napalm.get_network_driver(device.platform.napalm_driver) except ModuleImportError: diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 3fd4fdb7f..e466723bd 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -425,7 +425,8 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Device model (slug)', ) status = django_filters.MultipleChoiceFilter( - choices=STATUS_CHOICES + choices=STATUS_CHOICES, + null_value=None ) is_full_depth = django_filters.BooleanFilter( name='device_type__is_full_depth', diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 67550e136..84d4dc39c 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1117,6 +1117,15 @@ class ConsoleServerPort(models.Model): def __str__(self): return self.name + def clean(self): + + # Check that the parent device's DeviceType is a console server + device_type = self.device.device_type + if not device_type.is_console_server: + raise ValidationError("The {} {} device type not support assignment of console server ports.".format( + device_type.manufacturer, device_type + )) + # # Power ports @@ -1182,6 +1191,15 @@ class PowerOutlet(models.Model): def __str__(self): return self.name + def clean(self): + + # Check that the parent device's DeviceType is a PDU + device_type = self.device.device_type + if not device_type.is_pdu: + raise ValidationError("The {} {} device type not support assignment of power outlets.".format( + device_type.manufacturer, device_type + )) + # # Interfaces @@ -1238,6 +1256,13 @@ class Interface(models.Model): def clean(self): + # Check that the parent device's DeviceType is a network device + device_type = self.device.device_type + if not device_type.is_network_device: + raise ValidationError("The {} {} device type not support assignment of network interfaces.".format( + device_type.manufacturer, device_type + )) + # 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.") diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 68367e3ac..f3529a28f 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1432,7 +1432,7 @@ class ConsoleServerPortTest(HttpStatusMixin, APITestCase): site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') devicetype = DeviceType.objects.create( - manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1', is_console_server=True ) devicerole = DeviceRole.objects.create( name='Test Device Role 1', slug='test-device-role-1', color='ff0000' @@ -1590,7 +1590,7 @@ class PowerOutletTest(HttpStatusMixin, APITestCase): site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') devicetype = DeviceType.objects.create( - manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1', is_pdu=True ) devicerole = DeviceRole.objects.create( name='Test Device Role 1', slug='test-device-role-1', color='ff0000' @@ -1667,7 +1667,7 @@ class InterfaceTest(HttpStatusMixin, APITestCase): site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') devicetype = DeviceType.objects.create( - manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1', is_network_device=True ) devicerole = DeviceRole.objects.create( name='Test Device Role 1', slug='test-device-role-1', color='ff0000' diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index f3a9815fe..0497138c4 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -7,7 +7,7 @@ from django.db import transaction from rest_framework import serializers from rest_framework.exceptions import ValidationError -from extras.constants import CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_SELECT +from extras.constants import CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT from extras.models import CustomField, CustomFieldChoice, CustomFieldValue from utilities.api import ValidatedModelSerializer @@ -38,6 +38,15 @@ class CustomFieldsSerializer(serializers.BaseSerializer): # Data validation if value not in [None, '']: + # Validate integer + if cf.type == CF_TYPE_INTEGER: + try: + int(value) + except ValueError: + raise ValidationError( + "Invalid value for integer field {}: {}".format(field_name, value) + ) + # Validate boolean if cf.type == CF_TYPE_BOOLEAN and value not in [True, False, 1, 0]: raise ValidationError( diff --git a/netbox/extras/migrations/0008_reports.py b/netbox/extras/migrations/0008_reports.py index 83565cce7..7f3eccc32 100644 --- a/netbox/extras/migrations/0008_reports.py +++ b/netbox/extras/migrations/0008_reports.py @@ -2,6 +2,7 @@ # Generated by Django 1.11.4 on 2017-09-26 21:25 from __future__ import unicode_literals from distutils.version import StrictVersion +import re from django.conf import settings import django.contrib.postgres.fields.jsonb @@ -18,7 +19,7 @@ def verify_postgresql_version(apps, schema_editor): with connection.cursor() as cursor: cursor.execute("SELECT VERSION()") row = cursor.fetchone() - pg_version = row[0].split()[1] + pg_version = re.match('^PostgreSQL (\d+\.\d+(\.\d+)?)', row[0]).group(1) if StrictVersion(pg_version) < StrictVersion('9.4.0'): raise Exception("PostgreSQL 9.4.0 or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(pg_version)) diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index c8f87a894..40933c43d 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -160,7 +160,8 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Role (slug)', ) status = django_filters.MultipleChoiceFilter( - choices=PREFIX_STATUS_CHOICES + choices=PREFIX_STATUS_CHOICES, + null_value=None ) class Meta: @@ -265,7 +266,8 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Interface (ID)', ) status = django_filters.MultipleChoiceFilter( - choices=IPADDRESS_STATUS_CHOICES + choices=IPADDRESS_STATUS_CHOICES, + null_value=None ) role = django_filters.MultipleChoiceFilter( choices=IPADDRESS_ROLE_CHOICES @@ -364,7 +366,8 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Role (slug)', ) status = django_filters.MultipleChoiceFilter( - choices=VLAN_STATUS_CHOICES + choices=VLAN_STATUS_CHOICES, + null_value=None ) class Meta: diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 44316aea2..d2bac96a6 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -688,6 +688,11 @@ class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): nullable_fields = ['vrf', 'role', 'tenant', 'description'] +class IPAddressAssignForm(BootstrapMixin, forms.Form): + vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF') + address = forms.CharField(label='IP Address') + + def ipaddress_status_choices(): status_counts = {} for status in IPAddress.objects.values('status').annotate(count=Count('status')).order_by('status'): diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index f7253c6bf..ebb86731c 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -76,6 +76,10 @@ IPADDRESS_LINK = """ {% endif %} """ +IPADDRESS_ASSIGN_LINK = """ +{{ record }} +""" + IPADDRESS_PARENT = """ {% if record.interface %} {{ record.interface.parent }} @@ -268,8 +272,8 @@ class PrefixDetailTable(PrefixTable): class IPAddressTable(BaseTable): pk = ToggleColumn() address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address') - status = tables.TemplateColumn(STATUS_LABEL) vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF') + status = tables.TemplateColumn(STATUS_LABEL) tenant = tables.TemplateColumn(TENANT_LINK) parent = tables.TemplateColumn(IPADDRESS_PARENT, orderable=False) interface = tables.Column(orderable=False) @@ -293,6 +297,18 @@ class IPAddressDetailTable(IPAddressTable): ) +class IPAddressAssignTable(BaseTable): + address = tables.TemplateColumn(IPADDRESS_ASSIGN_LINK, verbose_name='IP Address') + status = tables.TemplateColumn(STATUS_LABEL) + parent = tables.TemplateColumn(IPADDRESS_PARENT, orderable=False) + interface = tables.Column(orderable=False) + + class Meta(BaseTable.Meta): + model = IPAddress + fields = ('address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface') + orderable = False + + # # VLAN groups # diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index c5723ef94..a67e9e865 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -60,6 +60,7 @@ urlpatterns = [ url(r'^ip-addresses/import/$', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'), url(r'^ip-addresses/edit/$', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'), url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'), + url(r'^ip-addresses/assign/$', views.IPAddressAssignView.as_view(), name='ipaddress_assign'), url(r'^ip-addresses/(?P\d+)/$', views.IPAddressView.as_view(), name='ipaddress'), url(r'^ip-addresses/(?P\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'), url(r'^ip-addresses/(?P\d+)/delete/$', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index a298dd3d7..22335da8f 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -4,7 +4,7 @@ import netaddr from django.conf import settings from django.contrib.auth.mixins import PermissionRequiredMixin from django.db.models import Count, Q -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 django_tables2 import RequestConfig @@ -550,7 +550,7 @@ class PrefixIPAddressesView(View): 'prefix': prefix, 'ip_table': ip_table, 'permissions': permissions, - 'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf or '0', prefix.prefix), + 'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix), }) @@ -686,6 +686,51 @@ class IPAddressEditView(IPAddressCreateView): permission_required = 'ipam.change_ipaddress' +class IPAddressAssignView(PermissionRequiredMixin, View): + """ + Search for IPAddresses to be assigned to an Interface. + """ + permission_required = 'ipam.change_ipaddress' + + def dispatch(self, request, *args, **kwargs): + + # Redirect user if an interface has not been provided + if 'interface' not in request.GET: + return redirect('ipam:ipaddress_add') + + return super(IPAddressAssignView, self).dispatch(request, *args, **kwargs) + + def get(self, request): + + form = forms.IPAddressAssignForm() + + return render(request, 'ipam/ipaddress_assign.html', { + 'form': form, + 'return_url': request.GET.get('return_url', ''), + }) + + def post(self, request): + + form = forms.IPAddressAssignForm(request.POST) + table = None + + if form.is_valid(): + + queryset = IPAddress.objects.select_related( + 'vrf', 'tenant', 'interface__device', 'interface__virtual_machine' + ).filter( + vrf=form.cleaned_data['vrf'], + address__net_host=form.cleaned_data['address'], + ) + table = tables.IPAddressAssignTable(queryset) + + return render(request, 'ipam/ipaddress_assign.html', { + 'form': form, + 'table': table, + 'return_url': request.GET.get('return_url', ''), + }) + + class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'ipam.delete_ipaddress' model = IPAddress diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index d4fbc2359..557b8f067 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -13,7 +13,7 @@ except ImportError: ) -VERSION = '2.2.5-dev' +VERSION = '2.2.6-dev' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) diff --git a/netbox/templates/ipam/inc/ipadress_edit_header.html b/netbox/templates/ipam/inc/ipadress_edit_header.html index 7d590291a..b8ec3878a 100644 --- a/netbox/templates/ipam/inc/ipadress_edit_header.html +++ b/netbox/templates/ipam/inc/ipadress_edit_header.html @@ -1,4 +1,16 @@ +{% load helpers %} + diff --git a/netbox/templates/ipam/ipaddress_assign.html b/netbox/templates/ipam/ipaddress_assign.html new file mode 100644 index 000000000..a623a55f3 --- /dev/null +++ b/netbox/templates/ipam/ipaddress_assign.html @@ -0,0 +1,48 @@ +{% extends 'utilities/obj_edit.html' %} +{% load static from staticfiles %} +{% load form_helpers %} +{% load helpers %} + +{% block content %} +
+ {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} +
+
+

Assign an IP Address

+ {% include 'ipam/inc/ipadress_edit_header.html' with active_tab='assign' %} + {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
Select IP Address
+
+ {% render_field form.vrf %} + {% render_field form.address %} +
+
+
+
+
+
+ + Cancel +
+
+
+ {% if table %} +
+
+

Search Results

+ {% include 'utilities/obj_table.html' with table_template='panel_table.html' %} +
+
+ {% endif %} +{% endblock %} diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index ea6cd1173..2af936885 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -132,7 +132,7 @@ def querystring(request, **kwargs): querydict[k] = v elif k in querydict: querydict.pop(k) - querystring = querydict.urlencode() + querystring = querydict.urlencode(safe='/') if querystring: return '?' + querystring else: diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index b53734734..078df19b6 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -6,6 +6,7 @@ from dcim.api.serializers import NestedDeviceRoleSerializer, NestedPlatformSeria from dcim.constants import IFACE_FF_VIRTUAL from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer +from ipam.models import IPAddress from tenancy.api.serializers import NestedTenantSerializer from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer from virtualization.constants import STATUS_CHOICES @@ -83,18 +84,30 @@ class WritableClusterSerializer(CustomFieldModelSerializer): # Virtual machines # +# Cannot import ipam.api.NestedIPAddressSerializer due to circular dependency +class VirtualMachineIPAddressSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') + + class Meta: + model = IPAddress + fields = ['id', 'url', 'family', 'address'] + + class VirtualMachineSerializer(CustomFieldModelSerializer): status = ChoiceFieldSerializer(choices=STATUS_CHOICES) cluster = NestedClusterSerializer() role = NestedDeviceRoleSerializer() tenant = NestedTenantSerializer() platform = NestedPlatformSerializer() + primary_ip = VirtualMachineIPAddressSerializer() + primary_ip4 = VirtualMachineIPAddressSerializer() + primary_ip6 = VirtualMachineIPAddressSerializer() class Meta: model = VirtualMachine fields = [ - 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', - 'memory', 'disk', 'comments', 'custom_fields', + 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', + 'vcpus', 'memory', 'disk', 'comments', 'custom_fields', ] diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index 1000f2ab4..ac5b2436c 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -70,7 +70,8 @@ class VirtualMachineFilter(CustomFieldFilterSet): label='Search', ) status = django_filters.MultipleChoiceFilter( - choices=STATUS_CHOICES + choices=STATUS_CHOICES, + null_value=None ) cluster_group_id = django_filters.ModelMultipleChoiceFilter( name='cluster__group', diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 8fb13c7cc..edb35f4cb 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.db import models @@ -255,3 +256,14 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): def get_status_class(self): return VM_STATUS_CLASSES[self.status] + + @property + def primary_ip(self): + if settings.PREFER_IPV4 and self.primary_ip4: + return self.primary_ip4 + elif self.primary_ip6: + return self.primary_ip6 + elif self.primary_ip4: + return self.primary_ip4 + else: + return None