Parcourir la source

Merge branch 'develop' into develop-2.3

Jeremy Stretch il y a 7 ans
Parent
commit
3df8c63d5c

+ 9 - 2
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:

+ 2 - 1
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',

+ 25 - 0
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.")

+ 3 - 3
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'

+ 10 - 1
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(

+ 2 - 1
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))
 

+ 6 - 3
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:

+ 5 - 0
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'):

+ 17 - 1
netbox/ipam/tables.py

@@ -76,6 +76,10 @@ IPADDRESS_LINK = """
 {% endif %}
 """
 
+IPADDRESS_ASSIGN_LINK = """
+<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ request.GET.interface }}&return_url={{ request.GET.return_url }}">{{ record }}</a>
+"""
+
 IPADDRESS_PARENT = """
 {% if record.interface %}
     <a href="{{ record.interface.parent.get_absolute_url }}">{{ record.interface.parent }}</a>
@@ -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
 #

+ 1 - 0
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<pk>\d+)/$', views.IPAddressView.as_view(), name='ipaddress'),
     url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
     url(r'^ip-addresses/(?P<pk>\d+)/delete/$', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),

+ 47 - 2
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

+ 1 - 1
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__)))
 

+ 14 - 2
netbox/templates/ipam/inc/ipadress_edit_header.html

@@ -1,4 +1,16 @@
+{% load helpers %}
+
 <ul class="nav nav-tabs" style="margin-bottom: 20px">
-    <li role="presentation"{% if active_tab == 'add' %} class="active"{% endif %}><a href="{% url 'ipam:ipaddress_add' %}">Individual</a></li>
-    <li role="presentation"{% if active_tab == 'bulk_add' %} class="active"{% endif %}><a href="{% url 'ipam:ipaddress_bulk_add' %}">Bulk</a></li>
+    <li role="presentation"{% if active_tab == 'add' %} class="active"{% endif %}>
+        <a href="{% url 'ipam:ipaddress_add' %}{% querystring request %}">New IP</a>
+    </li>
+    {% if 'interface' in request.GET %}
+        <li role="presentation"{% if active_tab == 'assign' %} class="active"{% endif %}>
+            <a href="{% url 'ipam:ipaddress_assign' %}{% querystring request %}">Assign IP</a>
+        </li>
+    {% else %}
+        <li role="presentation"{% if active_tab == 'bulk_add' %} class="active"{% endif %}>
+            <a href="{% url 'ipam:ipaddress_bulk_add' %}{% querystring request %}">Bulk Create</a>
+        </li>
+    {% endif %}
 </ul>

+ 48 - 0
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 %}
+    <form action="{% querystring request %}" method="post" class="form form-horizontal">
+        {% csrf_token %}
+        {% for field in form.hidden_fields %}
+            {{ field }}
+        {% endfor %}
+        <div class="row">
+            <div class="col-md-6 col-md-offset-3">
+                <h3>Assign an IP Address</h3>
+                {% include 'ipam/inc/ipadress_edit_header.html' with active_tab='assign' %}
+                {% if form.non_field_errors %}
+                    <div class="panel panel-danger">
+                        <div class="panel-heading"><strong>Errors</strong></div>
+                        <div class="panel-body">
+                            {{ form.non_field_errors }}
+                        </div>
+                    </div>
+                {% endif %}
+            <div class="panel panel-default">
+                <div class="panel-heading"><strong>Select IP Address</strong></div>
+                <div class="panel-body">
+                    {% render_field form.vrf %}
+                    {% render_field form.address %}
+                </div>
+            </div>
+            </div>
+        </div>
+        <div class="row">
+            <div class="col-md-6 col-md-offset-3 text-right">
+                <button type="submit" class="btn btn-primary">Search</button>
+                <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
+            </div>
+        </div>
+    </form>
+    {% if table %}
+        <div class="row">
+            <div class="col-md-10 col-md-offset-1" style="margin-top: 20px">
+                <h3>Search Results</h3>
+                {% include 'utilities/obj_table.html' with table_template='panel_table.html' %}
+            </div>
+        </div>
+    {% endif %}
+{% endblock %}

+ 1 - 1
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:

+ 15 - 2
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',
         ]
 
 

+ 2 - 1
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',

+ 12 - 0
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