1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Merge branch 'develop' into develop-2.3

This commit is contained in:
Jeremy Stretch
2017-11-14 14:38:32 -05:00
18 changed files with 220 additions and 21 deletions

@ -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:

@ -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',

@ -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.")

@ -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'

@ -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,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))

@ -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:

@ -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'):

@ -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
#

@ -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'),

@ -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

@ -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__)))

@ -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>

@ -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 %}

@ -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:

@ -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',
]

@ -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',

@ -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